Compare commits

..

15 Commits

Author SHA1 Message Date
ari
a6e9939d6c Merge branch 'master' into name-prop 2026-01-20 01:10:20 +01:00
5957368c04 Remove redundant code
Can't remember why I added this one
2026-01-20 01:08:59 +01:00
78916c8a63 Revert 2 semantic changes 2026-01-20 00:59:34 +01:00
791ccfcfd1 Remove addition of 'Actor' to json_model_classes 2026-01-20 00:55:03 +01:00
3500ebe02a Update CHANGELOG.md 2026-01-20 00:54:18 +01:00
0e1364945f Avoid clone in src/syncback/file_names.rs 2026-01-12 14:41:12 +01:00
ari
3a6aae65f7 Avoid clone in src/syncback/file_names.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:46 +01:00
ari
d13d229eef Avoid clone in src/snapshot_middleware/json_model.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:18 +01:00
ari
9a485d88ce Avoid clone in src/snapshot_middleware/lua.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:06 +01:00
020d72faef fix: improve middleware selection for actor and other container classes 2025-12-18 05:10:53 +01:00
60d150f4c6 feat: optimize name handling for leaf scripts with invalid names
Prefer slugified filenames + adjacent meta files for scripts without children instead of forcing directory creation
2025-12-18 04:43:47 +01:00
73dab330b5 test: remove oudated json_model_legacy_name test 2025-12-15 20:32:28 +01:00
790312a5b0 fix: lack of .model.json support 2025-12-15 20:26:25 +01:00
5c396322d9 fix: name prop not properly syncing 2025-12-15 19:08:18 +01:00
37e44e474a feat: support name property in meta and model jsons 2025-12-15 18:45:59 +01:00
27 changed files with 133 additions and 466 deletions

3
.gitmodules vendored
View File

@@ -16,6 +16,3 @@
[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

@@ -30,26 +30,15 @@ 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])
* Implemented support for the "name" property in meta/model JSON files. ([#1187])
* 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])
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
* 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])
* Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217])
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
[#1187]: https://github.com/rojo-rbx/rojo/pull/1187
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
[#1199]: https://github.com/rojo-rbx/rojo/pull/1199
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
## [7.7.0-rc.1] (November 27th, 2025)

View File

@@ -14,7 +14,6 @@ Code contributions are welcome for features and bugs that have been reported in
You'll want these tools to work on Rojo:
* Latest stable Rust compiler
* Rustfmt and Clippy are used for code formatting and linting.
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* [Rokit](https://github.com/rojo-rbx/rokit)
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)

19
Cargo.lock generated
View File

@@ -1520,12 +1520,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2074,7 +2068,6 @@ dependencies = [
"num_cpus",
"opener",
"paste",
"pathdiff",
"pretty_assertions",
"profiling",
"rayon",
@@ -2085,12 +2078,10 @@ dependencies = [
"rbx_xml",
"reqwest",
"ritz",
"rmp-serde",
"roblox_install",
"rojo-insta-ext",
"semver",
"serde",
"serde_bytes",
"serde_json",
"serde_yaml",
"strum",
@@ -2231,16 +2222,6 @@ 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

@@ -100,13 +100,10 @@ clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15"
yaml-rust2 = "0.10.3"
data-encoding = "2.8.0"
pathdiff = "0.2.3"
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"
@@ -125,7 +122,7 @@ semver = "1.0.22"
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.6"
insta = { version = "1.36.1", features = ["redactions", "yaml", "json"] }
insta = { version = "1.36.1", features = ["redactions", "yaml"] }
paste = "1.0.14"
pretty_assertions = "1.4.0"
serde_yaml = "0.8.26"

View File

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

View File

@@ -1,8 +1,7 @@
local HttpService = game:GetService("HttpService")
local Log = require(script.Parent.Log)
local msgpack = require(script.Parent.msgpack)
local Promise = require(script.Parent.Promise)
local Log = require(script.Parent.Log)
local HttpError = require(script.Error)
local HttpResponse = require(script.Response)
@@ -69,12 +68,4 @@ 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.msgpack)
:andThen(Http.Response.json)
: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.msgpack):andThen(function(body)
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):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 the msgpack
-- encode implementation will turn the table into an array instead of a map,
-- causing API validation to fail.
-- 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.
local added
if next(patch.added) ~= nil then
added = patch.added
@@ -206,16 +206,13 @@ function ApiContext:write(patch)
added = added,
}
body = Http.msgpackEncode(body)
body = Http.jsonEncode(body)
return Http.post(url, body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(responseBody)
Log.info("Write response: {:?}", responseBody)
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
Log.info("Write response: {:?}", responseBody)
return responseBody
end)
return responseBody
end)
end
function ApiContext:connectWebSocket(packetHandlers)
@@ -237,7 +234,7 @@ function ApiContext:connectWebSocket(packetHandlers)
local closed, errored, received
received = self.__wsClient.MessageReceived:Connect(function(msg)
local data = Http.msgpackDecode(msg)
local data = Http.jsonDecode(msg)
if data.sessionId ~= self.__sessionId then
Log.warn("Received message with wrong session ID; ignoring")
return
@@ -283,7 +280,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.msgpack):andThen(function(body)
return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
@@ -294,11 +291,11 @@ end
function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(Http.Response.json)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
@@ -312,11 +309,11 @@ end
function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(Http.Response.json)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")

View File

@@ -19,15 +19,9 @@ local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotifi
function FullscreenNotification:init()
self.transparency, self.setTransparency = Roact.createBinding(0)
self.lifetime = self.props.timeout
self.dismissed = false
end
function FullscreenNotification:dismiss()
if self.dismissed then
return
end
self.dismissed = true
if self.props.onClose then
self.props.onClose()
end
@@ -65,7 +59,7 @@ function FullscreenNotification:didMount()
end
function FullscreenNotification:willUnmount()
if self.timeout and coroutine.status(self.timeout) == "suspended" then
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end

View File

@@ -25,7 +25,6 @@ function Notification:init()
self.binding = bindingUtil.fromMotor(self.motor)
self.lifetime = self.props.timeout
self.dismissed = false
self.motor:onStep(function(value)
if value <= 0 and self.props.onClose then
@@ -35,11 +34,6 @@ function Notification:init()
end
function Notification:dismiss()
if self.dismissed then
return
end
self.dismissed = true
self.motor:setGoal(Flipper.Spring.new(0, {
frequency = 5,
dampingRatio = 1,
@@ -81,7 +75,7 @@ function Notification:didMount()
end
function Notification:willUnmount()
if self.timeout and coroutine.status(self.timeout) == "suspended" then
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end

View File

@@ -301,19 +301,6 @@ function App:setPriorSyncInfo(host: string, port: string, projectName: string)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:forgetPriorSyncInfo()
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
priorSyncInfos = {}
end
local id = tostring(game.PlaceId)
priorSyncInfos[id] = nil
Log.trace("Erased last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:getHostAndPort()
local host = self.host:getValue()
local port = self.port:getValue()
@@ -448,8 +435,7 @@ function App:checkSyncReminder()
self:findActiveServer()
:andThen(function(serverInfo, host, port)
self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`,
{ "Connect", "Dismiss" }
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`
)
end)
:catch(function()
@@ -460,8 +446,7 @@ function App:checkSyncReminder()
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`,
{ "Connect", "Forget", "Dismiss" }
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`
)
end
end)
@@ -501,16 +486,12 @@ function App:stopSyncReminderPolling()
end
end
function App:sendSyncReminder(message: string, shownActions: { string })
function App:sendSyncReminder(message: string)
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
local connectIndex = table.find(shownActions, "Connect")
local forgetIndex = table.find(shownActions, "Forget")
local dismissIndex = table.find(shownActions, "Dismiss")
self.dismissSyncReminder = self:addNotification({
text = message,
timeout = 120,
@@ -519,39 +500,24 @@ function App:sendSyncReminder(message: string, shownActions: { string })
self.dismissSyncReminder = nil
end,
actions = {
Connect = if connectIndex
then {
text = "Connect",
style = "Solid",
layoutOrder = connectIndex,
onClick = function()
self:startSession()
end,
}
else nil,
Forget = if forgetIndex
then {
text = "Forget",
style = "Bordered",
layoutOrder = forgetIndex,
onClick = function()
-- The user doesn't want to be reminded again about this sync
self:forgetPriorSyncInfo()
end,
}
else nil,
Dismiss = if dismissIndex
then {
text = "Dismiss",
style = "Bordered",
layoutOrder = dismissIndex,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
}
else nil,
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
},
},
})
end

View File

@@ -54,10 +54,6 @@ 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

@@ -1,35 +0,0 @@
---
source: src/cli/sourcemap.rs
expression: sourcemap_contents
---
{
"name": "default",
"className": "DataModel",
"filePaths": "[...1 path omitted...]",
"children": [
{
"name": "ReplicatedStorage",
"className": "ReplicatedStorage",
"children": [
{
"name": "Project",
"className": "ModuleScript",
"filePaths": "[...1 path omitted...]",
"children": [
{
"name": "Module",
"className": "Folder",
"children": [
{
"name": "module",
"className": "ModuleScript",
"filePaths": "[...1 path omitted...]"
}
]
}
]
}
]
}
]
}

View File

@@ -1,41 +0,0 @@
---
source: src/cli/sourcemap.rs
expression: sourcemap_contents
---
{
"name": "default",
"className": "DataModel",
"filePaths": [
"default.project.json"
],
"children": [
{
"name": "ReplicatedStorage",
"className": "ReplicatedStorage",
"children": [
{
"name": "Project",
"className": "ModuleScript",
"filePaths": [
"src/init.luau"
],
"children": [
{
"name": "Module",
"className": "Folder",
"children": [
{
"name": "module",
"className": "ModuleScript",
"filePaths": [
"../module/module.luau"
]
}
]
}
]
}
]
}
]
}

View File

@@ -10,7 +10,7 @@ use fs_err::File;
use memofs::Vfs;
use rayon::prelude::*;
use rbx_dom_weak::{types::Ref, Ustr};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use tokio::runtime::Runtime;
use crate::{
@@ -24,20 +24,19 @@ const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project
const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
/// Representation of a node in the generated sourcemap tree.
#[derive(Serialize, Deserialize)]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SourcemapNode<'a> {
name: &'a str,
class_name: Ustr,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
serialize_with = "crate::path_serializer::serialize_vec_absolute"
)]
file_paths: Vec<Cow<'a, Path>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<SourcemapNode<'a>>,
}
@@ -71,13 +70,12 @@ pub struct SourcemapCommand {
impl SourcemapCommand {
pub fn run(self) -> anyhow::Result<()> {
let project_path = fs_err::canonicalize(resolve_path(&self.project))?;
let project_path = resolve_path(&self.project);
log::trace!("Constructing filesystem with StdBackend");
log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new_default();
vfs.set_watch_enabled(self.watch);
log::trace!("Setting up session for sourcemap generation");
let session = ServeSession::new(vfs, project_path)?;
let mut cursor = session.message_queue().cursor();
@@ -89,17 +87,14 @@ impl SourcemapCommand {
// Pre-build a rayon threadpool with a low number of threads to avoid
// dynamic creation overhead on systems with a high number of cpus.
log::trace!("Setting rayon global threadpool");
rayon::ThreadPoolBuilder::new()
.num_threads(num_cpus::get().min(6))
.build_global()
.ok();
.unwrap();
log::trace!("Writing initial sourcemap");
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
if self.watch {
log::trace!("Setting up runtime for watch mode");
let rt = Runtime::new().unwrap();
loop {
@@ -213,7 +208,7 @@ fn recurse_create_node<'a>(
} else {
for val in file_paths {
output_file_paths.push(Cow::from(
pathdiff::diff_paths(val, project_dir).expect(PATH_STRIP_FAILED_ERR),
val.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR),
));
}
};
@@ -255,80 +250,3 @@ fn write_sourcemap(
Ok(())
}
#[cfg(test)]
mod test {
use crate::cli::sourcemap::SourcemapNode;
use crate::cli::SourcemapCommand;
use insta::internals::Content;
use std::path::Path;
#[test]
fn maps_relative_paths() {
let sourcemap_dir = tempfile::tempdir().unwrap();
let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
let project_path = fs_err::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test-projects")
.join("relative_paths")
.join("project"),
)
.unwrap();
let sourcemap_command = SourcemapCommand {
project: project_path,
output: Some(sourcemap_output.clone()),
include_non_scripts: false,
watch: false,
absolute: false,
};
assert!(sourcemap_command.run().is_ok());
let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
let sourcemap_contents =
serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
insta::assert_json_snapshot!(sourcemap_contents);
}
#[test]
fn maps_absolute_paths() {
let sourcemap_dir = tempfile::tempdir().unwrap();
let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
let project_path = fs_err::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test-projects")
.join("relative_paths")
.join("project"),
)
.unwrap();
let sourcemap_command = SourcemapCommand {
project: project_path,
output: Some(sourcemap_output.clone()),
include_non_scripts: false,
watch: false,
absolute: true,
};
assert!(sourcemap_command.run().is_ok());
let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
let sourcemap_contents =
serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
insta::assert_json_snapshot!(sourcemap_contents, {
".**.filePaths" => insta::dynamic_redaction(|mut value, _path| {
let mut paths_count = 0;
match value {
Content::Seq(ref mut vec) => {
for path in vec.iter().map(|i| i.as_str().unwrap()) {
assert_eq!(fs_err::canonicalize(path).is_ok(), true, "path was not valid");
assert_eq!(Path::new(path).is_absolute(), true, "path was not absolute");
paths_count += 1;
}
}
_ => panic!("Expected filePaths to be a sequence"),
}
format!("[...{} path{} omitted...]", paths_count, if paths_count != 1 { "s" } else { "" } )
})
});
}
}

View File

@@ -8,7 +8,7 @@ use rbx_dom_weak::{
ustr, HashMapExt as _, UstrMap, UstrSet,
};
use crate::{variant_eq::variant_eq, RojoRef, REF_POINTER_ATTRIBUTE_PREFIX};
use crate::{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 !variant_eq(&snapshot_value, instance_value) {
if &snapshot_value != instance_value {
changed_properties.insert(name, Some(snapshot_value));
}
}

View File

@@ -301,7 +301,6 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
static JSON_MODEL_CLASSES: OnceLock<HashSet<&str>> = OnceLock::new();
let json_model_classes = JSON_MODEL_CLASSES.get_or_init(|| {
[
"Actor",
"Sound",
"SoundGroup",
"Sky",
@@ -319,11 +318,6 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
"ChatInputBarConfiguration",
"BubbleChatConfiguration",
"ChannelTabsConfiguration",
"RemoteEvent",
"UnreliableRemoteEvent",
"RemoteFunction",
"BindableEvent",
"BindableFunction",
]
.into()
});

View File

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

View File

@@ -249,8 +249,31 @@ pub struct SerializeRequest {
#[serde(rename_all = "camelCase")]
pub struct SerializeResponse {
pub session_id: SessionId,
#[serde(with = "serde_bytes")]
pub model_contents: Vec<u8>,
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
}
}
#[derive(Debug, Serialize, Deserialize)]

View File

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

View File

@@ -1,14 +0,0 @@
{
"name": "default",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Project": {
"$path": "project/src",
"Module": {
"$path": "module"
}
}
}
}
}

View File

@@ -1 +0,0 @@
return nil

View File

@@ -1,14 +0,0 @@
{
"name": "default",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Project": {
"$path": "src/",
"Module": {
"$path": "../module"
}
}
}
}
}

View File

@@ -1 +0,0 @@
return nil

View File

@@ -10,7 +10,6 @@ use std::{
use hyper_tungstenite::tungstenite::{connect, Message};
use rbx_dom_weak::types::Ref;
use serde::{Deserialize, Serialize};
use tempfile::{tempdir, TempDir};
use librojo::{
@@ -162,16 +161,22 @@ 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)?.bytes()?;
let body = reqwest::blocking::get(url)?.text()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
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"))
}
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)?.bytes()?;
let body = reqwest::blocking::get(url)?.text()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
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"))
}
pub fn get_api_socket_packet(
@@ -193,8 +198,8 @@ impl TestServeSession {
}
match socket.read() {
Ok(Message::Binary(binary)) => {
let packet: SocketPacket = deserialize_msgpack(&binary)?;
Ok(Message::Text(text)) => {
let packet: SocketPacket = serde_json::from_str(&text)?;
if packet.packet_type != packet_type {
continue;
}
@@ -207,7 +212,7 @@ impl TestServeSession {
return Err("WebSocket closed before receiving messages".into());
}
Ok(_) => {
// Ignore other message types (ping, pong, text)
// Ignore other message types (ping, pong, binary)
continue;
}
Err(hyper_tungstenite::tungstenite::Error::Io(e))
@@ -231,37 +236,15 @@ impl TestServeSession {
) -> Result<SerializeResponse, reqwest::Error> {
let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/serialize", self.port);
let body = serialize_msgpack(&SerializeRequest {
let body = serde_json::to_string(&SerializeRequest {
session_id,
ids: ids.to_vec(),
})
.unwrap();
});
let body = client.post(url).body(body).send()?.bytes()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
client.post(url).body((body).unwrap()).send()?.json()
}
}
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.
///
@@ -279,7 +262,11 @@ 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 mut dom = rbx_binary::from_reader(response.model_contents.as_slice()).unwrap();
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();
// 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 {