From bda2af3dfb997016a33b1e0e6559624d3420cd1b Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 27 Jan 2026 07:13:02 -0600 Subject: [PATCH] Use msgpack for API --- Cargo.lock | 38 ++++++++----- Cargo.toml | 4 +- build.rs | 15 +++++ plugin/http/Response.lua | 15 +++-- plugin/http/init.lua | 23 ++++---- plugin/msgpack.lua | 2 + plugin/src/ApiContext.lua | 67 ++++++++++------------ src/json.rs | 1 + src/web/api.rs | 116 +++++++++++++++++--------------------- src/web/interface.rs | 95 +++++++++++++++++++++++-------- src/web/util.rs | 54 ++++++++++++++---- tests/tests/serve.rs | 6 +- 12 files changed, 264 insertions(+), 172 deletions(-) create mode 100644 plugin/msgpack.lua diff --git a/Cargo.lock b/Cargo.lock index 5e102c5b..df7b0236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,9 +200,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.53" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -2214,10 +2214,12 @@ dependencies = [ "rbx_xml", "reqwest", "ritz", + "rmp-serde", "roblox_install", "rojo-insta-ext", "semver", "serde", + "serde_bytes", "serde_json", "serde_yaml", "strum", @@ -2358,6 +2360,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -2501,9 +2513,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -2698,7 +2710,7 @@ dependencies = [ "libc", "mio 1.1.1", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2940,9 +2952,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -3487,18 +3499,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" dependencies = [ "proc-macro2 1.0.106", "quote 1.0.44", @@ -3561,9 +3573,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index fcf526d6..ebc5048c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ toml = "0.5.11" termcolor = "1.4.1" thiserror = "1.0.69" tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros"] } -uuid = { version = "1.19.0", features = ["v4", "serde"] } +uuid = { version = "1.20.0", features = ["v4", "serde"] } clap = { version = "3.2.25", features = ["derive"] } profiling = "1.0.17" yaml-rust2 = "0.10.4" @@ -96,6 +96,8 @@ data-encoding = "2.10.0" blake3 = "1.8.3" float-cmp = "0.9.0" indexmap = { version = "2.13.0", features = ["serde"] } +rmp-serde = "1.3.1" +serde_bytes = "0.11.19" [target.'cfg(windows)'.dependencies] winreg = "0.10.1" diff --git a/build.rs b/build.rs index 6bfd8e55..4deb93ea 100644 --- a/build.rs +++ b/build.rs @@ -30,6 +30,20 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result { continue; } + // Skip binary asset files that can't be read as text + if file_name.ends_with(".png") + || file_name.ends_with(".jpg") + || file_name.ends_with(".gif") + || file_name.ends_with(".webp") + { + continue; + } + + // Skip directories that aren't needed for plugin functionality + if file_name == "assets" || file_name == "benchmark" { + continue; + } + let child_snapshot = snapshot_from_fs_path(&entry.path())?; children.push((file_name, child_snapshot)); } @@ -66,6 +80,7 @@ fn main() -> Result<(), anyhow::Error> { "fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?, "http" => snapshot_from_fs_path(&plugin_dir.join("http"))?, "log" => snapshot_from_fs_path(&plugin_dir.join("log"))?, + "msgpack" => snapshot_from_fs_path(&plugin_dir.join("msgpack.lua"))?, "rbx_dom_lua" => snapshot_from_fs_path(&plugin_dir.join("rbx_dom_lua"))?, "src" => snapshot_from_fs_path(&plugin_dir.join("src"))?, "Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?, diff --git a/plugin/http/Response.lua b/plugin/http/Response.lua index 249a7bf6..b02ff644 100644 --- a/plugin/http/Response.lua +++ b/plugin/http/Response.lua @@ -1,5 +1,6 @@ local HttpService = game:GetService("HttpService") -local JSON5Decoder = require(script.Parent.Parent.json5.JSON5Decoder) + +local msgpack = require(script.Parent.Parent.msgpack) local stringTemplate = [[ Http.Response { @@ -29,13 +30,11 @@ function Response:isSuccess() end function Response:json() - -- Try fast native decode first, fallback to JSON5 for NaN/Infinity - local success, result = pcall(HttpService.JSONDecode, HttpService, self.body) - if success then - return result - end - print("[HTTP Response:json()] Native decode failed, using JSON5 for size:", #self.body) - return JSON5Decoder.Decode(self.body) + return HttpService:JSONDecode(self.body) +end + +function Response:msgpack() + return msgpack.decode(self.body) end return Response diff --git a/plugin/http/init.lua b/plugin/http/init.lua index 04287c7c..d74183ce 100644 --- a/plugin/http/init.lua +++ b/plugin/http/init.lua @@ -1,9 +1,8 @@ local HttpService = game:GetService("HttpService") -local Promise = require(script.Parent.Promise) local Log = require(script.Parent.Log) -local JSON5Decoder = require(script.Parent.json5.JSON5Decoder) -local JSONEncoder = require(script.Parent.json5.JSONEncoder) +local msgpack = require(script.Parent.msgpack) +local Promise = require(script.Parent.Promise) local HttpError = require(script.Error) local HttpResponse = require(script.Response) @@ -63,17 +62,19 @@ function Http.post(url, body) end function Http.jsonEncode(object) - return JSONEncoder.Compact5(object) + return HttpService:JSONEncode(object) end function Http.jsonDecode(source) - -- Try fast native decode first, fallback to JSON5 for NaN/Infinity - local success, result = pcall(HttpService.JSONDecode, HttpService, source) - if success then - return result - end - print("[Http.jsonDecode] Native decode failed, using JSON5 for size:", #source) - return JSON5Decoder.Decode(source) + return HttpService:JSONDecode(source) +end + +function Http.msgpackEncode(object) + return msgpack.encode(object) +end + +function Http.msgpackDecode(source) + return msgpack.decode(source) end return Http diff --git a/plugin/msgpack.lua b/plugin/msgpack.lua new file mode 100644 index 00000000..fa2dea01 --- /dev/null +++ b/plugin/msgpack.lua @@ -0,0 +1,2 @@ +-- Module alias for msgpack-luau +return require(script.Parent.Packages["msgpack-luau"].src.msgpack) diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua index 2d2459ec..fbaa0f3f 100644 --- a/plugin/src/ApiContext.lua +++ b/plugin/src/ApiContext.lua @@ -145,7 +145,7 @@ function ApiContext:connect() return Http.get(url) :andThen(rejectFailedRequests) - :andThen(Http.Response.json) + :andThen(Http.Response.msgpack) :andThen(rejectWrongProtocolVersion) :andThen(function(body) assert(validateApiInfo(body)) @@ -163,7 +163,7 @@ end function ApiContext:read(ids) local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ",")) - return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) + return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) if body.sessionId ~= self.__sessionId then return Promise.reject("Server changed ID") end @@ -191,9 +191,9 @@ function ApiContext:write(patch) table.insert(updated, fixedUpdate) end - -- Only add the 'added' field if the table is non-empty, or else Roblox's - -- JSON implementation will turn the table into an array instead of an - -- object, causing API validation to fail. + -- Only add the 'added' field if the table is non-empty, or else the msgpack + -- encode implementation will turn the table into an array instead of a map, + -- causing API validation to fail. local added if next(patch.added) ~= nil then added = patch.added @@ -206,13 +206,16 @@ function ApiContext:write(patch) added = added, } - body = Http.jsonEncode(body) + body = Http.msgpackEncode(body) - return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody) - Log.info("Write response: {:?}", responseBody) + return Http.post(url, body) + :andThen(rejectFailedRequests) + :andThen(Http.Response.msgpack) + :andThen(function(responseBody) + Log.info("Write response: {:?}", responseBody) - return responseBody - end) + return responseBody + end) end function ApiContext:connectWebSocket(packetHandlers) @@ -234,7 +237,7 @@ function ApiContext:connectWebSocket(packetHandlers) local closed, errored, received received = self.__wsClient.MessageReceived:Connect(function(msg) - local data = Http.jsonDecode(msg) + local data = Http.msgpackDecode(msg) if data.sessionId ~= self.__sessionId then Log.warn("Received message with wrong session ID; ignoring") return @@ -280,7 +283,7 @@ end function ApiContext:open(id) local url = ("%s/api/open/%s"):format(self.__baseUrl, id) - return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) + return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) if body.sessionId ~= self.__sessionId then return Promise.reject("Server changed ID") end @@ -290,39 +293,31 @@ function ApiContext:open(id) end function ApiContext:serialize(ids: { string }) - local url = ("%s/api/serialize"):format(self.__baseUrl) - local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids }) + local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ",")) - return Http.post(url, request_body) - :andThen(rejectFailedRequests) - :andThen(Http.Response.json) - :andThen(function(response_body) - if response_body.sessionId ~= self.__sessionId then - return Promise.reject("Server changed ID") - end + return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) + if body.sessionId ~= self.__sessionId then + return Promise.reject("Server changed ID") + end - assert(validateApiSerialize(response_body)) + assert(validateApiSerialize(body)) - return response_body - end) + return body + end) end function ApiContext:refPatch(ids: { string }) - local url = ("%s/api/ref-patch"):format(self.__baseUrl) - local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids }) + local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ",")) - return Http.post(url, request_body) - :andThen(rejectFailedRequests) - :andThen(Http.Response.json) - :andThen(function(response_body) - if response_body.sessionId ~= self.__sessionId then - return Promise.reject("Server changed ID") - end + return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) + if body.sessionId ~= self.__sessionId then + return Promise.reject("Server changed ID") + end - assert(validateApiRefPatch(response_body)) + assert(validateApiRefPatch(body)) - return response_body - end) + return body + end) end return ApiContext diff --git a/src/json.rs b/src/json.rs index ce3d2aae..441a2775 100644 --- a/src/json.rs +++ b/src/json.rs @@ -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(value: &T) -> anyhow::Result { let mut output = String::new(); let mut serializer = CompactJson5Serializer { diff --git a/src/web/api.rs b/src/web/api.rs index efdbf25f..0e5f46d6 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -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, mut request: Request) -> Response { @@ -48,7 +44,7 @@ pub async fn call(serve_session: Arc, mut request: Request) 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, mut request: Request) ) } } - (&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) -> Response { @@ -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) -> Response { - 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, _> = 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) -> Response { - 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) -> Response { + let argument = &request.uri().path()["/api/ref-patch/".len()..]; + let requested_ids: Result, _> = 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 = requested_ids.into_iter().collect(); let mut instance_updates: HashMap = 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, }); diff --git a/src/web/interface.rs b/src/web/interface.rs index d255e1ba..fded5678 100644 --- a/src/web/interface.rs +++ b/src/web/interface.rs @@ -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>, + #[serde( + serialize_with = "serialize_place_ids", + deserialize_with = "deserialize_place_ids", + skip_serializing_if = "Option::is_none", + default + )] pub unexpected_place_ids: Option>, + #[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, + #[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, 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(ids: &Option>, serializer: S) -> Result +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>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option> = Option::deserialize(deserializer)?; + Ok(opt.map(|v| v.into_iter().map(|f| f as u64).collect())) +} + +fn serialize_optional_u64_as_f64(value: &Option, serializer: S) -> Result +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, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option = 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) -> 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, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src/web/util.rs b/src/web/util.rs index c5eb93b4..1c1cbb50 100644 --- a/src/web/util.rs +++ b/src/web/util.rs @@ -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(value: T) -> Response { - json_response(value, StatusCode::OK) +pub fn msgpack_ok(value: T) -> Response { + msgpack(value, StatusCode::OK) } -/// Alias for backwards compatibility -pub use json_response as json; +pub fn msgpack(value: T, code: StatusCode) -> Response { + let mut serialized = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut serialized) + .with_human_readable() + .with_struct_map(); -pub fn json_response(value: T, code: StatusCode) -> Response { - // 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(value: T) -> anyhow::Result> { + 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 { + let mut deserializer = rmp_serde::Deserializer::new(input).with_human_readable(); + + T::deserialize(&mut deserializer) +} + +pub fn json(value: T, code: StatusCode) -> Response { + let serialized = match serde_json::to_string(&value) { Ok(v) => v, Err(err) => { return Response::builder() diff --git a/tests/tests/serve.rs b/tests/tests/serve.rs index 748f1687..ee0528e1 100644 --- a/tests/tests/serve.rs +++ b/tests/tests/serve.rs @@ -646,7 +646,7 @@ fn meshpart_with_id() { .unwrap(); let serialize_response = session - .get_api_serialize(&[*meshpart, *objectvalue], info.session_id) + .get_api_serialize(&[*meshpart, *objectvalue]) .unwrap(); // We don't assert a snapshot on the SerializeResponse because the model includes the @@ -673,9 +673,7 @@ fn forced_parent() { read_response.intern_and_redact(&mut redactions, root_id) ); - let serialize_response = session - .get_api_serialize(&[root_id], info.session_id) - .unwrap(); + let serialize_response = session.get_api_serialize(&[root_id]).unwrap(); assert_eq!(serialize_response.session_id, info.session_id);