Use msgpack for API

This commit is contained in:
Joe 2026-01-27 07:13:02 -06:00
parent 3d328c1d42
commit bda2af3dfb
12 changed files with 264 additions and 172 deletions

View file

@ -163,6 +163,7 @@ impl serde::ser::Error for CompactJson5Error {
/// # Errors
///
/// Returns an error if the value cannot be serialized to JSON5.
#[allow(dead_code)]
pub fn to_string_compact<T: Serialize>(value: &T) -> anyhow::Result<String> {
let mut output = String::new();
let mut serializer = CompactJson5Serializer {

View file

@ -19,7 +19,6 @@ use rbx_dom_weak::{
};
use crate::{
json,
serve_session::ServeSession,
snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
web::{
@ -28,12 +27,9 @@ use crate::{
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
},
util::{json_ok, json_response},
},
web_api::{
BufferEncode, InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest,
SerializeResponse,
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
},
web_api::{InstanceUpdate, RefPatchResponse, SerializeResponse},
};
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
@ -48,7 +44,7 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
if is_upgrade_request(&request) {
service.handle_api_socket(&mut request).await
} else {
json_response(
msgpack(
ErrorResponse::bad_request(
"/api/socket must be called as a websocket upgrade request",
),
@ -56,15 +52,19 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
)
}
}
(&Method::POST, "/api/serialize") => service.handle_api_serialize(request).await,
(&Method::POST, "/api/ref-patch") => service.handle_api_ref_patch(request).await,
(&Method::GET, path) if path.starts_with("/api/serialize/") => {
service.handle_api_serialize(request).await
}
(&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
service.handle_api_ref_patch(request).await
}
(&Method::POST, path) if path.starts_with("/api/open/") => {
service.handle_api_open(request).await
}
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
(_method, path) => json_response(
(_method, path) => msgpack(
ErrorResponse::not_found(format!("Route not found: {}", path)),
StatusCode::NOT_FOUND,
),
@ -85,7 +85,7 @@ impl ApiService {
let tree = self.serve_session.tree();
let root_instance_id = tree.get_root_id();
json_ok(&ServerInfoResponse {
msgpack_ok(&ServerInfoResponse {
server_version: SERVER_VERSION.to_owned(),
protocol_version: PROTOCOL_VERSION,
session_id: self.serve_session.session_id(),
@ -105,7 +105,7 @@ impl ApiService {
let input_cursor: u32 = match argument.parse() {
Ok(v) => v,
Err(err) => {
return json_response(
return msgpack(
ErrorResponse::bad_request(format!("Malformed message cursor: {}", err)),
StatusCode::BAD_REQUEST,
);
@ -116,7 +116,7 @@ impl ApiService {
let (response, websocket) = match upgrade(request, None) {
Ok(result) => result,
Err(err) => {
return json_response(
return msgpack(
ErrorResponse::internal_error(format!("WebSocket upgrade failed: {}", err)),
StatusCode::INTERNAL_SERVER_ERROR,
);
@ -143,10 +143,10 @@ impl ApiService {
let body = body::to_bytes(request.into_body()).await.unwrap();
let request: WriteRequest = match json::from_slice(&body) {
let request: WriteRequest = match deserialize_msgpack(&body) {
Ok(request) => request,
Err(err) => {
return json_response(
return msgpack(
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST,
);
@ -154,7 +154,7 @@ impl ApiService {
};
if request.session_id != session_id {
return json_response(
return msgpack(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
@ -180,7 +180,7 @@ impl ApiService {
})
.unwrap();
json_ok(WriteResponse { session_id })
msgpack_ok(WriteResponse { session_id })
}
async fn handle_api_read(&self, request: Request<Body>) -> Response<Body> {
@ -190,7 +190,7 @@ impl ApiService {
let requested_ids = match requested_ids {
Ok(ids) => ids,
Err(_) => {
return json_response(
return msgpack(
ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST,
);
@ -254,7 +254,7 @@ impl ApiService {
}
}
json_ok(ReadResponse {
msgpack_ok(ReadResponse {
session_id: self.serve_session.session_id(),
message_cursor,
instances,
@ -269,30 +269,23 @@ impl ApiService {
/// that correspond to the requested Instances. These values have their
/// `Value` property set to point to the requested Instance.
async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
let session_id = self.serve_session.session_id();
let body = body::to_bytes(request.into_body()).await.unwrap();
let argument = &request.uri().path()["/api/serialize/".len()..];
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
let request: SerializeRequest = match json::from_slice(&body) {
Ok(request) => request,
Err(err) => {
return json_response(
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
let requested_ids = match requested_ids {
Ok(ids) => ids,
Err(_) => {
return msgpack(
ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST,
);
}
};
if request.session_id != session_id {
return json_response(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
}
let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
let tree = self.serve_session.tree();
for id in &request.ids {
for id in &requested_ids {
if let Some(instance) = tree.get_instance(*id) {
let clone = response_dom.insert(
Ref::none(),
@ -316,7 +309,7 @@ impl ApiService {
response_dom.transfer_within(child_ref, object_value);
} else {
json_response(
msgpack(
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
StatusCode::BAD_REQUEST,
);
@ -327,9 +320,9 @@ impl ApiService {
let mut source = Vec::new();
rbx_binary::to_writer(&mut source, &response_dom, &[response_dom.root_ref()]).unwrap();
json_ok(SerializeResponse {
msgpack_ok(SerializeResponse {
session_id: self.serve_session.session_id(),
model_contents: BufferEncode::new(source),
model_contents: source,
})
}
@ -337,26 +330,22 @@ impl ApiService {
/// provided IDs. Used because the plugin does not store a RojoTree,
/// and referent properties need to be updated after the serialize
/// endpoint is used.
async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> {
let session_id = self.serve_session.session_id();
let body = body::to_bytes(request.into_body()).await.unwrap();
async fn handle_api_ref_patch(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/ref-patch/".len()..];
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
let request: RefPatchRequest = match json::from_slice(&body) {
Ok(request) => request,
Err(err) => {
return json_response(
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
let requested_ids = match requested_ids {
Ok(ids) => ids,
Err(_) => {
return msgpack(
ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST,
);
}
};
if request.session_id != session_id {
return json_response(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
}
// Convert to HashSet for efficient lookup
let ids: HashSet<Ref> = requested_ids.into_iter().collect();
let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
@ -366,7 +355,7 @@ impl ApiService {
let Variant::Ref(prop_value) = prop_value else {
continue;
};
if let Some(target_id) = request.ids.get(prop_value) {
if let Some(target_id) = ids.get(prop_value) {
let instance_id = instance.id();
let update =
instance_updates
@ -385,7 +374,7 @@ impl ApiService {
}
}
json_ok(RefPatchResponse {
msgpack_ok(RefPatchResponse {
session_id: self.serve_session.session_id(),
patch: SubscribeMessage {
added: HashMap::new(),
@ -401,7 +390,7 @@ impl ApiService {
let requested_id = match Ref::from_str(argument) {
Ok(id) => id,
Err(_) => {
return json_response(
return msgpack(
ErrorResponse::bad_request("Invalid instance ID"),
StatusCode::BAD_REQUEST,
);
@ -413,7 +402,7 @@ impl ApiService {
let instance = match tree.get_instance(requested_id) {
Some(instance) => instance,
None => {
return json_response(
return msgpack(
ErrorResponse::bad_request("Instance not found"),
StatusCode::NOT_FOUND,
);
@ -423,7 +412,7 @@ impl ApiService {
let script_path = match pick_script_path(instance) {
Some(path) => path,
None => {
return json_response(
return msgpack(
ErrorResponse::bad_request(
"No appropriate file could be found to open this script",
),
@ -436,7 +425,7 @@ impl ApiService {
Ok(()) => {}
Err(error) => match error {
OpenError::Io(io_error) => {
return json_response(
return msgpack(
ErrorResponse::internal_error(format!(
"Attempting to open {} failed because of the following io error: {}",
script_path.display(),
@ -450,7 +439,7 @@ impl ApiService {
status,
stderr,
} => {
return json_response(
return msgpack(
ErrorResponse::internal_error(format!(
r#"The command '{}' to open '{}' failed with the error code '{}'.
Error logs:
@ -466,7 +455,7 @@ impl ApiService {
},
};
json_ok(OpenResponse {
msgpack_ok(OpenResponse {
session_id: self.serve_session.session_id(),
})
}
@ -531,7 +520,7 @@ async fn handle_websocket_subscription(
match result {
Ok((new_cursor, messages)) => {
if !messages.is_empty() {
let json_message = {
let msgpack_message = {
let tree = tree_handle.lock().unwrap();
let api_messages: Vec<_> = messages
.into_iter()
@ -555,13 +544,12 @@ async fn handle_websocket_subscription(
}),
};
// Use compact JSON5 encoder for transport efficiency, preserves NaN/Infinity
json::to_string_compact(&response)?
serialize_msgpack(response)?
};
log::debug!("Sending batch of messages over WebSocket subscription");
if websocket.send(Message::Text(json_message)).await.is_err() {
if websocket.send(Message::Binary(msgpack_message)).await.is_err() {
// Client disconnected
log::debug!("WebSocket subscription closed by client");
break;
@ -677,7 +665,7 @@ fn filter_subscribe_message_for_scripts(
// ALL instances get ignoreUnknownInstances: true in scripts-only mode
// This ensures we only sync script content, never delete/modify other instances
// Non-scripts also have their properties cleared - they're just tree structure
for (_id, inst) in &mut msg.added {
for inst in msg.added.values_mut() {
inst.metadata = Some(InstanceMetadata {
ignore_unknown_instances: true,
});

View file

@ -160,9 +160,33 @@ pub struct ServerInfoResponse {
pub server_version: String,
pub protocol_version: u64,
pub project_name: String,
#[serde(
serialize_with = "serialize_place_ids",
deserialize_with = "deserialize_place_ids",
skip_serializing_if = "Option::is_none",
default
)]
pub expected_place_ids: Option<HashSet<u64>>,
#[serde(
serialize_with = "serialize_place_ids",
deserialize_with = "deserialize_place_ids",
skip_serializing_if = "Option::is_none",
default
)]
pub unexpected_place_ids: Option<HashSet<u64>>,
#[serde(
serialize_with = "serialize_optional_u64_as_f64",
deserialize_with = "deserialize_optional_f64_as_u64",
skip_serializing_if = "Option::is_none",
default
)]
pub game_id: Option<u64>,
#[serde(
serialize_with = "serialize_optional_u64_as_f64",
deserialize_with = "deserialize_optional_f64_as_u64",
skip_serializing_if = "Option::is_none",
default
)]
pub place_id: Option<u64>,
pub root_instance_id: Ref,
/// When true, the server only supports syncing script Source property changes.
@ -171,6 +195,50 @@ pub struct ServerInfoResponse {
pub sync_source_only: bool,
}
// Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua
fn serialize_place_ids<S>(ids: &Option<HashSet<u64>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match ids {
Some(set) => {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(set.len()))?;
for id in set {
seq.serialize_element(&(*id as f64))?;
}
seq.end()
}
None => serializer.serialize_none(),
}
}
fn deserialize_place_ids<'de, D>(deserializer: D) -> Result<Option<HashSet<u64>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<Vec<f64>> = Option::deserialize(deserializer)?;
Ok(opt.map(|v| v.into_iter().map(|f| f as u64).collect()))
}
fn serialize_optional_u64_as_f64<S>(value: &Option<u64>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match value {
Some(v) => serializer.serialize_f64(*v as f64),
None => serializer.serialize_none(),
}
}
fn deserialize_optional_f64_as_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<f64> = Option::deserialize(deserializer)?;
Ok(opt.map(|f| f as u64))
}
/// Response body from /api/read/{id}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -253,31 +321,8 @@ pub struct SerializeRequest {
#[serde(rename_all = "camelCase")]
pub struct SerializeResponse {
pub session_id: SessionId,
pub model_contents: BufferEncode,
}
/// Using this struct we can force Roblox to JSONDecode this as a buffer.
/// This is what Roblox's serde APIs use, so it saves a step in the plugin.
#[derive(Debug, Serialize, Deserialize)]
pub struct BufferEncode {
m: (),
t: Cow<'static, str>,
base64: String,
}
impl BufferEncode {
pub fn new(content: Vec<u8>) -> Self {
let base64 = data_encoding::BASE64.encode(&content);
Self {
m: (),
t: Cow::Borrowed("buffer"),
base64,
}
}
pub fn model(&self) -> &str {
&self.base64
}
#[serde(with = "serde_bytes")]
pub model_contents: Vec<u8>,
}
#[derive(Debug, Serialize, Deserialize)]

View file

@ -1,18 +1,52 @@
use hyper::{header::CONTENT_TYPE, Body, Response, StatusCode};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::json as json_module;
pub fn json_ok<T: Serialize>(value: T) -> Response<Body> {
json_response(value, StatusCode::OK)
pub fn msgpack_ok<T: Serialize>(value: T) -> Response<Body> {
msgpack(value, StatusCode::OK)
}
/// Alias for backwards compatibility
pub use json_response as json;
pub fn msgpack<T: Serialize>(value: T, code: StatusCode) -> Response<Body> {
let mut serialized = Vec::new();
let mut serializer = rmp_serde::Serializer::new(&mut serialized)
.with_human_readable()
.with_struct_map();
pub fn json_response<T: Serialize>(value: T, code: StatusCode) -> Response<Body> {
// Use compact JSON5 for transport efficiency, preserves NaN/Infinity
let serialized = match json_module::to_string_compact(&value) {
if let Err(err) = value.serialize(&mut serializer) {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(CONTENT_TYPE, "text/plain")
.body(Body::from(err.to_string()))
.unwrap();
};
Response::builder()
.status(code)
.header(CONTENT_TYPE, "application/msgpack")
.body(Body::from(serialized))
.unwrap()
}
pub fn serialize_msgpack<T: Serialize>(value: T) -> anyhow::Result<Vec<u8>> {
let mut serialized = Vec::new();
let mut serializer = rmp_serde::Serializer::new(&mut serialized)
.with_human_readable()
.with_struct_map();
value.serialize(&mut serializer)?;
Ok(serialized)
}
pub fn deserialize_msgpack<'a, T: Deserialize<'a>>(
input: &'a [u8],
) -> Result<T, rmp_serde::decode::Error> {
let mut deserializer = rmp_serde::Deserializer::new(input).with_human_readable();
T::deserialize(&mut deserializer)
}
pub fn json<T: Serialize>(value: T, code: StatusCode) -> Response<Body> {
let serialized = match serde_json::to_string(&value) {
Ok(v) => v,
Err(err) => {
return Response::builder()