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

38
Cargo.lock generated
View file

@ -200,9 +200,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.53" version = "1.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@ -2214,10 +2214,12 @@ dependencies = [
"rbx_xml", "rbx_xml",
"reqwest", "reqwest",
"ritz", "ritz",
"rmp-serde",
"roblox_install", "roblox_install",
"rojo-insta-ext", "rojo-insta-ext",
"semver", "semver",
"serde", "serde",
"serde_bytes",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"strum", "strum",
@ -2358,6 +2360,16 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde_cbor" name = "serde_cbor"
version = "0.11.2" version = "0.11.2"
@ -2501,9 +2513,9 @@ dependencies = [
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@ -2698,7 +2710,7 @@ dependencies = [
"libc", "libc",
"mio 1.1.1", "mio 1.1.1",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.1", "socket2 0.6.2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -2940,9 +2952,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.19.0" version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
dependencies = [ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
"js-sys", "js-sys",
@ -3487,18 +3499,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.33" version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.33" version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
dependencies = [ dependencies = [
"proc-macro2 1.0.106", "proc-macro2 1.0.106",
"quote 1.0.44", "quote 1.0.44",
@ -3561,9 +3573,9 @@ dependencies = [
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.16" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
[[package]] [[package]]
name = "zstd" name = "zstd"

View file

@ -87,7 +87,7 @@ toml = "0.5.11"
termcolor = "1.4.1" termcolor = "1.4.1"
thiserror = "1.0.69" thiserror = "1.0.69"
tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros"] } 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"] } clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.17" profiling = "1.0.17"
yaml-rust2 = "0.10.4" yaml-rust2 = "0.10.4"
@ -96,6 +96,8 @@ data-encoding = "2.10.0"
blake3 = "1.8.3" blake3 = "1.8.3"
float-cmp = "0.9.0" float-cmp = "0.9.0"
indexmap = { version = "2.13.0", features = ["serde"] } indexmap = { version = "2.13.0", features = ["serde"] }
rmp-serde = "1.3.1"
serde_bytes = "0.11.19"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"

View file

@ -30,6 +30,20 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
continue; 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())?; let child_snapshot = snapshot_from_fs_path(&entry.path())?;
children.push((file_name, child_snapshot)); children.push((file_name, child_snapshot));
} }
@ -66,6 +80,7 @@ fn main() -> Result<(), anyhow::Error> {
"fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?, "fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?,
"http" => snapshot_from_fs_path(&plugin_dir.join("http"))?, "http" => snapshot_from_fs_path(&plugin_dir.join("http"))?,
"log" => snapshot_from_fs_path(&plugin_dir.join("log"))?, "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"))?, "rbx_dom_lua" => snapshot_from_fs_path(&plugin_dir.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?, "src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?, "Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,

View file

@ -1,5 +1,6 @@
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local JSON5Decoder = require(script.Parent.Parent.json5.JSON5Decoder)
local msgpack = require(script.Parent.Parent.msgpack)
local stringTemplate = [[ local stringTemplate = [[
Http.Response { Http.Response {
@ -29,13 +30,11 @@ function Response:isSuccess()
end end
function Response:json() function Response:json()
-- Try fast native decode first, fallback to JSON5 for NaN/Infinity return HttpService:JSONDecode(self.body)
local success, result = pcall(HttpService.JSONDecode, HttpService, self.body) end
if success then
return result function Response:msgpack()
end return msgpack.decode(self.body)
print("[HTTP Response:json()] Native decode failed, using JSON5 for size:", #self.body)
return JSON5Decoder.Decode(self.body)
end end
return Response return Response

View file

@ -1,9 +1,8 @@
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Promise)
local Log = require(script.Parent.Log) local Log = require(script.Parent.Log)
local JSON5Decoder = require(script.Parent.json5.JSON5Decoder) local msgpack = require(script.Parent.msgpack)
local JSONEncoder = require(script.Parent.json5.JSONEncoder) local Promise = require(script.Parent.Promise)
local HttpError = require(script.Error) local HttpError = require(script.Error)
local HttpResponse = require(script.Response) local HttpResponse = require(script.Response)
@ -63,17 +62,19 @@ function Http.post(url, body)
end end
function Http.jsonEncode(object) function Http.jsonEncode(object)
return JSONEncoder.Compact5(object) return HttpService:JSONEncode(object)
end end
function Http.jsonDecode(source) function Http.jsonDecode(source)
-- Try fast native decode first, fallback to JSON5 for NaN/Infinity return HttpService:JSONDecode(source)
local success, result = pcall(HttpService.JSONDecode, HttpService, source) end
if success then
return result function Http.msgpackEncode(object)
end return msgpack.encode(object)
print("[Http.jsonDecode] Native decode failed, using JSON5 for size:", #source) end
return JSON5Decoder.Decode(source)
function Http.msgpackDecode(source)
return msgpack.decode(source)
end end
return Http return Http

2
plugin/msgpack.lua Normal file
View file

@ -0,0 +1,2 @@
-- Module alias for msgpack-luau
return require(script.Parent.Packages["msgpack-luau"].src.msgpack)

View file

@ -145,7 +145,7 @@ function ApiContext:connect()
return Http.get(url) return Http.get(url)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(Http.Response.msgpack)
:andThen(rejectWrongProtocolVersion) :andThen(rejectWrongProtocolVersion)
:andThen(function(body) :andThen(function(body)
assert(validateApiInfo(body)) assert(validateApiInfo(body))
@ -163,7 +163,7 @@ end
function ApiContext:read(ids) function ApiContext:read(ids)
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(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 if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
@ -191,9 +191,9 @@ function ApiContext:write(patch)
table.insert(updated, fixedUpdate) table.insert(updated, fixedUpdate)
end end
-- Only add the 'added' field if the table is non-empty, or else Roblox's -- Only add the 'added' field if the table is non-empty, or else the msgpack
-- JSON implementation will turn the table into an array instead of an -- encode implementation will turn the table into an array instead of a map,
-- object, causing API validation to fail. -- causing API validation to fail.
local added local added
if next(patch.added) ~= nil then if next(patch.added) ~= nil then
added = patch.added added = patch.added
@ -206,13 +206,16 @@ function ApiContext:write(patch)
added = added, added = added,
} }
body = Http.jsonEncode(body) body = Http.msgpackEncode(body)
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody) return Http.post(url, body)
Log.info("Write response: {:?}", responseBody) :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(responseBody)
Log.info("Write response: {:?}", responseBody)
return responseBody return responseBody
end) end)
end end
function ApiContext:connectWebSocket(packetHandlers) function ApiContext:connectWebSocket(packetHandlers)
@ -234,7 +237,7 @@ function ApiContext:connectWebSocket(packetHandlers)
local closed, errored, received local closed, errored, received
received = self.__wsClient.MessageReceived:Connect(function(msg) received = self.__wsClient.MessageReceived:Connect(function(msg)
local data = Http.jsonDecode(msg) local data = Http.msgpackDecode(msg)
if data.sessionId ~= self.__sessionId then if data.sessionId ~= self.__sessionId then
Log.warn("Received message with wrong session ID; ignoring") Log.warn("Received message with wrong session ID; ignoring")
return return
@ -280,7 +283,7 @@ end
function ApiContext:open(id) function ApiContext:open(id)
local url = ("%s/api/open/%s"):format(self.__baseUrl, 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 if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
@ -290,39 +293,31 @@ function ApiContext:open(id)
end end
function ApiContext:serialize(ids: { string }) function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize"):format(self.__baseUrl) local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ","))
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body) return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body)
:andThen(rejectFailedRequests) if body.sessionId ~= self.__sessionId then
:andThen(Http.Response.json) return Promise.reject("Server changed ID")
:andThen(function(response_body) end
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSerialize(response_body)) assert(validateApiSerialize(body))
return response_body return body
end) end)
end end
function ApiContext:refPatch(ids: { string }) function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch"):format(self.__baseUrl) local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ","))
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body) return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body)
:andThen(rejectFailedRequests) if body.sessionId ~= self.__sessionId then
:andThen(Http.Response.json) return Promise.reject("Server changed ID")
:andThen(function(response_body) end
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRefPatch(response_body)) assert(validateApiRefPatch(body))
return response_body return body
end) end)
end end
return ApiContext return ApiContext

View file

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

View file

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

View file

@ -160,9 +160,33 @@ pub struct ServerInfoResponse {
pub server_version: String, pub server_version: String,
pub protocol_version: u64, pub protocol_version: u64,
pub project_name: String, 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>>, 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>>, 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>, 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 place_id: Option<u64>,
pub root_instance_id: Ref, pub root_instance_id: Ref,
/// When true, the server only supports syncing script Source property changes. /// When true, the server only supports syncing script Source property changes.
@ -171,6 +195,50 @@ pub struct ServerInfoResponse {
pub sync_source_only: bool, 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} /// Response body from /api/read/{id}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -253,31 +321,8 @@ pub struct SerializeRequest {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SerializeResponse { pub struct SerializeResponse {
pub session_id: SessionId, pub session_id: SessionId,
pub model_contents: BufferEncode, #[serde(with = "serde_bytes")]
} pub model_contents: Vec<u8>,
/// 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
}
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]

View file

@ -1,18 +1,52 @@
use hyper::{header::CONTENT_TYPE, Body, Response, StatusCode}; use hyper::{header::CONTENT_TYPE, Body, Response, StatusCode};
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::json as json_module; pub fn msgpack_ok<T: Serialize>(value: T) -> Response<Body> {
msgpack(value, StatusCode::OK)
pub fn json_ok<T: Serialize>(value: T) -> Response<Body> {
json_response(value, StatusCode::OK)
} }
/// Alias for backwards compatibility pub fn msgpack<T: Serialize>(value: T, code: StatusCode) -> Response<Body> {
pub use json_response as json; 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> { if let Err(err) = value.serialize(&mut serializer) {
// Use compact JSON5 for transport efficiency, preserves NaN/Infinity return Response::builder()
let serialized = match json_module::to_string_compact(&value) { .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, Ok(v) => v,
Err(err) => { Err(err) => {
return Response::builder() return Response::builder()

View file

@ -646,7 +646,7 @@ fn meshpart_with_id() {
.unwrap(); .unwrap();
let serialize_response = session let serialize_response = session
.get_api_serialize(&[*meshpart, *objectvalue], info.session_id) .get_api_serialize(&[*meshpart, *objectvalue])
.unwrap(); .unwrap();
// We don't assert a snapshot on the SerializeResponse because the model includes the // 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) read_response.intern_and_redact(&mut redactions, root_id)
); );
let serialize_response = session let serialize_response = session.get_api_serialize(&[root_id]).unwrap();
.get_api_serialize(&[root_id], info.session_id)
.unwrap();
assert_eq!(serialize_response.session_id, info.session_id); assert_eq!(serialize_response.session_id, info.session_id);