Use msgpack for API (#1176)

This commit is contained in:
Micah
2026-02-12 18:37:24 -08:00
committed by GitHub
parent 4df2d3c5f8
commit 4deda0e155
15 changed files with 177 additions and 102 deletions

3
.gitmodules vendored
View File

@@ -16,3 +16,6 @@
[submodule "plugin/Packages/Highlighter"]
path = plugin/Packages/Highlighter
url = https://github.com/boatbomber/highlighter.git
[submodule "plugin/Packages/msgpack-luau"]
path = plugin/Packages/msgpack-luau
url = https://github.com/cipharius/msgpack-luau/

View File

@@ -31,6 +31,7 @@ Making a new release? Simply add the new header with the version and date undern
## Unreleased
* `inf` and `nan` values in properties are now synced ([#1176])
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
* Added actors and bindable/remote event/function variants to be synced back as JSON files. ([#1199])
@@ -38,6 +39,7 @@ Making a new release? Simply add the new header with the version and date undern
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
[#1199]: https://github.com/rojo-rbx/rojo/pull/1199

12
Cargo.lock generated
View File

@@ -2078,10 +2078,12 @@ dependencies = [
"rbx_xml",
"reqwest",
"ritz",
"rmp-serde",
"roblox_install",
"rojo-insta-ext",
"semver",
"serde",
"serde_bytes",
"serde_json",
"serde_yaml",
"strum",
@@ -2222,6 +2224,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"

View File

@@ -104,6 +104,8 @@ data-encoding = "2.8.0"
blake3 = "1.5.0"
float-cmp = "0.9.0"
indexmap = { version = "2.10.0", features = ["serde"] }
rmp-serde = "1.3.0"
serde_bytes = "0.11.19"
[target.'cfg(windows)'.dependencies]
winreg = "0.10.1"

View File

@@ -30,6 +30,11 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
continue;
}
// Ignore images in msgpack-luau because they aren't UTF-8 encoded.
if file_name.ends_with(".png") {
continue;
}
let child_snapshot = snapshot_from_fs_path(&entry.path())?;
children.push((file_name, child_snapshot));
}

View File

@@ -1,5 +1,7 @@
local HttpService = game:GetService("HttpService")
local msgpack = require(script.Parent.Parent.msgpack)
local stringTemplate = [[
Http.Response {
code: %d
@@ -31,4 +33,8 @@ function Response:json()
return HttpService:JSONDecode(self.body)
end
function Response:msgpack()
return msgpack.decode(self.body)
end
return Response

View File

@@ -1,7 +1,8 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Promise)
local Log = require(script.Parent.Log)
local msgpack = require(script.Parent.msgpack)
local Promise = require(script.Parent.Promise)
local HttpError = require(script.Error)
local HttpResponse = require(script.Response)
@@ -68,4 +69,12 @@ function Http.jsonDecode(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

View File

@@ -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
@@ -291,11 +294,11 @@ 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 request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(Http.Response.msgpack)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
@@ -309,11 +312,11 @@ 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 request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(Http.Response.msgpack)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")

View File

@@ -54,6 +54,10 @@ local function trueEquals(a, b): boolean
end
return true
-- For NaN, check if both values are not equal to themselves
elseif a ~= a and b ~= b then
return true
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "number" and typeB == "number" then
return fuzzyEq(a, b, 0.0001)

View File

@@ -8,7 +8,7 @@ use rbx_dom_weak::{
ustr, HashMapExt as _, UstrMap, UstrSet,
};
use crate::{RojoRef, REF_POINTER_ATTRIBUTE_PREFIX};
use crate::{variant_eq::variant_eq, RojoRef, REF_POINTER_ATTRIBUTE_PREFIX};
use super::{
patch::{PatchAdd, PatchSet, PatchUpdate},
@@ -127,7 +127,7 @@ fn compute_property_patches(
match instance.properties().get(&name) {
Some(instance_value) => {
if &snapshot_value != instance_value {
if !variant_eq(&snapshot_value, instance_value) {
changed_properties.insert(name, Some(snapshot_value));
}
}

View File

@@ -13,7 +13,6 @@ use rbx_dom_weak::{
};
use crate::{
json,
serve_session::ServeSession,
snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
web::{
@@ -22,11 +21,10 @@ use crate::{
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
},
util::{json, json_ok},
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
},
web_api::{
BufferEncode, InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest,
SerializeResponse,
InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest, SerializeResponse,
},
};
@@ -42,7 +40,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(
msgpack(
ErrorResponse::bad_request(
"/api/socket must be called as a websocket upgrade request",
),
@@ -58,7 +56,7 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
}
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
(_method, path) => json(
(_method, path) => msgpack(
ErrorResponse::not_found(format!("Route not found: {}", path)),
StatusCode::NOT_FOUND,
),
@@ -79,7 +77,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(),
@@ -98,7 +96,7 @@ impl ApiService {
let input_cursor: u32 = match argument.parse() {
Ok(v) => v,
Err(err) => {
return json(
return msgpack(
ErrorResponse::bad_request(format!("Malformed message cursor: {}", err)),
StatusCode::BAD_REQUEST,
);
@@ -109,7 +107,7 @@ impl ApiService {
let (response, websocket) = match upgrade(request, None) {
Ok(result) => result,
Err(err) => {
return json(
return msgpack(
ErrorResponse::internal_error(format!("WebSocket upgrade failed: {}", err)),
StatusCode::INTERNAL_SERVER_ERROR,
);
@@ -136,10 +134,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(
return msgpack(
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST,
);
@@ -147,7 +145,7 @@ impl ApiService {
};
if request.session_id != session_id {
return json(
return msgpack(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
@@ -173,7 +171,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> {
@@ -183,7 +181,7 @@ impl ApiService {
let requested_ids = match requested_ids {
Ok(ids) => ids,
Err(_) => {
return json(
return msgpack(
ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST,
);
@@ -207,7 +205,7 @@ impl ApiService {
}
}
json_ok(ReadResponse {
msgpack_ok(ReadResponse {
session_id: self.serve_session.session_id(),
message_cursor,
instances,
@@ -225,10 +223,10 @@ impl ApiService {
let session_id = self.serve_session.session_id();
let body = body::to_bytes(request.into_body()).await.unwrap();
let request: SerializeRequest = match json::from_slice(&body) {
let request: SerializeRequest = match deserialize_msgpack(&body) {
Ok(request) => request,
Err(err) => {
return json(
return msgpack(
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST,
);
@@ -236,7 +234,7 @@ impl ApiService {
};
if request.session_id != session_id {
return json(
return msgpack(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
@@ -269,7 +267,7 @@ impl ApiService {
response_dom.transfer_within(child_ref, object_value);
} else {
json(
msgpack(
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
StatusCode::BAD_REQUEST,
);
@@ -280,9 +278,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,
})
}
@@ -294,10 +292,10 @@ impl ApiService {
let session_id = self.serve_session.session_id();
let body = body::to_bytes(request.into_body()).await.unwrap();
let request: RefPatchRequest = match json::from_slice(&body) {
let request: RefPatchRequest = match deserialize_msgpack(&body) {
Ok(request) => request,
Err(err) => {
return json(
return msgpack(
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST,
);
@@ -305,7 +303,7 @@ impl ApiService {
};
if request.session_id != session_id {
return json(
return msgpack(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
@@ -338,7 +336,7 @@ impl ApiService {
}
}
json_ok(RefPatchResponse {
msgpack_ok(RefPatchResponse {
session_id: self.serve_session.session_id(),
patch: SubscribeMessage {
added: HashMap::new(),
@@ -354,7 +352,7 @@ impl ApiService {
let requested_id = match Ref::from_str(argument) {
Ok(id) => id,
Err(_) => {
return json(
return msgpack(
ErrorResponse::bad_request("Invalid instance ID"),
StatusCode::BAD_REQUEST,
);
@@ -366,7 +364,7 @@ impl ApiService {
let instance = match tree.get_instance(requested_id) {
Some(instance) => instance,
None => {
return json(
return msgpack(
ErrorResponse::bad_request("Instance not found"),
StatusCode::NOT_FOUND,
);
@@ -376,7 +374,7 @@ impl ApiService {
let script_path = match pick_script_path(instance) {
Some(path) => path,
None => {
return json(
return msgpack(
ErrorResponse::bad_request(
"No appropriate file could be found to open this script",
),
@@ -389,7 +387,7 @@ impl ApiService {
Ok(()) => {}
Err(error) => match error {
OpenError::Io(io_error) => {
return json(
return msgpack(
ErrorResponse::internal_error(format!(
"Attempting to open {} failed because of the following io error: {}",
script_path.display(),
@@ -403,7 +401,7 @@ impl ApiService {
status,
stderr,
} => {
return json(
return msgpack(
ErrorResponse::internal_error(format!(
r#"The command '{}' to open '{}' failed with the error code '{}'.
Error logs:
@@ -419,7 +417,7 @@ impl ApiService {
},
};
json_ok(OpenResponse {
msgpack_ok(OpenResponse {
session_id: self.serve_session.session_id(),
})
}
@@ -483,7 +481,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 = messages
.into_iter()
@@ -499,12 +497,12 @@ async fn handle_websocket_subscription(
}),
};
serde_json::to_string(&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;

View File

@@ -249,31 +249,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,8 +1,48 @@
use hyper::{header::CONTENT_TYPE, Body, Response, StatusCode};
use serde::Serialize;
use serde::{Deserialize, Serialize};
pub fn json_ok<T: Serialize>(value: T) -> Response<Body> {
json(value, StatusCode::OK)
pub fn msgpack_ok<T: Serialize>(value: T) -> Response<Body> {
msgpack(value, StatusCode::OK)
}
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();
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> {

View File

@@ -10,6 +10,7 @@ use std::{
use hyper_tungstenite::tungstenite::{connect, Message};
use rbx_dom_weak::types::Ref;
use serde::{Deserialize, Serialize};
use tempfile::{tempdir, TempDir};
use librojo::{
@@ -161,22 +162,16 @@ impl TestServeSession {
pub fn get_api_rojo(&self) -> Result<ServerInfoResponse, reqwest::Error> {
let url = format!("http://localhost:{}/api/rojo", self.port);
let body = reqwest::blocking::get(url)?.text()?;
let body = reqwest::blocking::get(url)?.bytes()?;
let value = jsonc_parser::parse_to_serde_value(&body, &Default::default())
.expect("Failed to parse JSON")
.expect("No JSON value");
Ok(serde_json::from_value(value).expect("Server returned malformed response"))
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse<'_>, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(url)?.text()?;
let body = reqwest::blocking::get(url)?.bytes()?;
let value = jsonc_parser::parse_to_serde_value(&body, &Default::default())
.expect("Failed to parse JSON")
.expect("No JSON value");
Ok(serde_json::from_value(value).expect("Server returned malformed response"))
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
pub fn get_api_socket_packet(
@@ -198,8 +193,8 @@ impl TestServeSession {
}
match socket.read() {
Ok(Message::Text(text)) => {
let packet: SocketPacket = serde_json::from_str(&text)?;
Ok(Message::Binary(binary)) => {
let packet: SocketPacket = deserialize_msgpack(&binary)?;
if packet.packet_type != packet_type {
continue;
}
@@ -212,7 +207,7 @@ impl TestServeSession {
return Err("WebSocket closed before receiving messages".into());
}
Ok(_) => {
// Ignore other message types (ping, pong, binary)
// Ignore other message types (ping, pong, text)
continue;
}
Err(hyper_tungstenite::tungstenite::Error::Io(e))
@@ -236,15 +231,37 @@ impl TestServeSession {
) -> Result<SerializeResponse, reqwest::Error> {
let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/serialize", self.port);
let body = serde_json::to_string(&SerializeRequest {
let body = serialize_msgpack(&SerializeRequest {
session_id,
ids: ids.to_vec(),
});
})
.unwrap();
client.post(url).body((body).unwrap()).send()?.json()
let body = client.post(url).body(body).send()?.bytes()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
}
fn serialize_msgpack<T: Serialize>(value: T) -> Result<Vec<u8>, rmp_serde::encode::Error> {
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)
}
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)
}
/// Probably-okay way to generate random enough port numbers for running the
/// Rojo live server.
///
@@ -262,11 +279,7 @@ fn get_port_number() -> usize {
/// Since the provided structure intentionally includes unredacted referents,
/// some post-processing is done to ensure they don't show up in the model.
pub fn serialize_to_xml_model(response: &SerializeResponse, redactions: &RedactionMap) -> String {
let model_content = data_encoding::BASE64
.decode(response.model_contents.model().as_bytes())
.unwrap();
let mut dom = rbx_binary::from_reader(model_content.as_slice()).unwrap();
let mut dom = rbx_binary::from_reader(response.model_contents.as_slice()).unwrap();
// This makes me realize that maybe we need a `descendants_mut` iter.
let ref_list: Vec<Ref> = dom.descendants().map(|inst| inst.referent()).collect();
for referent in ref_list {