Compare commits

...

8 Commits

55 changed files with 918 additions and 236 deletions

View File

@@ -45,6 +45,13 @@ jobs:
name: Rojo.rbxm name: Rojo.rbxm
path: Rojo.rbxm path: Rojo.rbxm
- name: Upload Plugin to Roblox
env:
RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }}
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }}
RBX_PLACE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }}
run: lune run upload-plugin Rojo.rbxm
build: build:
needs: ["create-release"] needs: ["create-release"]
strategy: strategy:

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@
# Macos file system junk # Macos file system junk
._* ._*
.DS_STORE .DS_STORE
# JetBrains IDEs
/.idea/

6
.gitmodules vendored
View File

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

8
.lune/.config.luau Normal file
View File

@@ -0,0 +1,8 @@
return {
luau = {
languagemode = "strict",
aliases = {
lune = "~/.lune/.typedefs/0.10.4/",
},
},
}

View File

@@ -0,0 +1,51 @@
local args: any = ...
assert(args, "no arguments passed to script")
local input: buffer = args.BinaryInput
local AssetService = game:GetService("AssetService")
local SerializationService = game:GetService("SerializationService")
local EncodingService = game:GetService("EncodingService")
local input_hash: buffer = EncodingService:ComputeBufferHash(input, Enum.HashAlgorithm.Sha256)
local hex_hash: { string } = table.create(buffer.len(input_hash))
for i = 0, buffer.len(input_hash) - 1 do
table.insert(hex_hash, string.format("%02x", buffer.readu8(input_hash, i)))
end
print(`Deserializing plugin file (size: {buffer.len(input)} bytes, hash: {table.concat(hex_hash, "")})`)
local plugin = SerializationService:DeserializeInstancesAsync(input)[1]
local UploadDetails = require(plugin.UploadDetails) :: any
local PLUGIN_ID = UploadDetails.assetId
local PLUGIN_NAME = UploadDetails.name
local PLUGIN_DESCRIPTION = UploadDetails.description
local PLUGIN_CREATOR_ID = UploadDetails.creatorId
local PLUGIN_CREATOR_TYPE = UploadDetails.creatorType
assert(typeof(PLUGIN_ID) == "number", "UploadDetails did not contain a number field 'assetId'")
assert(typeof(PLUGIN_NAME) == "string", "UploadDetails did not contain a string field 'name'")
assert(typeof(PLUGIN_DESCRIPTION) == "string", "UploadDetails did not contain a string field 'description'")
assert(typeof(PLUGIN_CREATOR_ID) == "number", "UploadDetails did not contain a number field 'creatorId'")
assert(typeof(PLUGIN_CREATOR_TYPE) == "string", "UploadDetails did not contain a string field 'creatorType'")
assert(
Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE) ~= nil,
"UploadDetails field 'creatorType' was not a valid member of Enum.AssetCreatorType"
)
print(`Uploading to {PLUGIN_ID}`)
print(`Plugin Name: {PLUGIN_NAME}`)
print(`Plugin Description: {PLUGIN_DESCRIPTION}`)
local result, version_or_err = AssetService:CreateAssetVersionAsync(plugin, Enum.AssetType.Plugin, PLUGIN_ID, {
["Name"] = PLUGIN_NAME,
["Description"] = PLUGIN_DESCRIPTION,
["CreatorId"] = PLUGIN_CREATOR_ID,
["CreatorType"] = Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE),
})
if result ~= Enum.CreateAssetResult.Success then
error(`Plugin failed to upload because: {result.Name} - {version_or_err}`)
end
print(`Plugin uploaded successfully. New version is {version_or_err}.`)

78
.lune/upload-plugin.luau Normal file
View File

@@ -0,0 +1,78 @@
local fs = require("@lune/fs")
local process = require("@lune/process")
local stdio = require("@lune/stdio")
local luau_execute = require("./opencloud-execute")
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
local PLACE_ID = process.env["RBX_PLACE_ID"]
local version_string = fs.readFile("plugin/Version.txt")
local versions = { string.match(version_string, "^v?(%d+)%.(%d+)%.(%d+)(.*)$") }
if versions[4] ~= "" then
print("This release is a pre-release. Skipping uploading plugin.")
process.exit(0)
end
local plugin_path = process.args[1]
assert(
typeof(plugin_path) == "string",
"no plugin path provided, expected usage is `lune run upload-plugin [PATH TO RBXM]`."
)
-- For local testing
if process.env["CI"] ~= "true" then
local rojo = process.exec("rojo", { "build", "plugin.project.json", "--output", plugin_path })
if not rojo.ok then
stdio.ewrite("plugin upload failed because: could not build plugin.rbxm\n\n")
stdio.ewrite(rojo.stderr)
stdio.ewrite("\n")
process.exit(1)
end
else
assert(fs.isFile(plugin_path), `Plugin file did not exist at {plugin_path}`)
end
local plugin_content = fs.readFile(plugin_path)
local engine_script = fs.readFile(".lune/scripts/plugin-upload.luau")
print("Creating task to upload plugin")
local task = luau_execute.create_task_latest(UNIVERSE_ID, PLACE_ID, engine_script, 300, false, plugin_content)
print("Waiting for task to finish")
local success = luau_execute.await_finish(task)
if not success then
local error = luau_execute.get_error(task)
assert(error, "could not fetch error from task")
stdio.ewrite("plugin upload failed because: task did not finish successfully\n\n")
stdio.ewrite(error.code)
stdio.ewrite("\n")
stdio.ewrite(error.message)
stdio.ewrite("\n")
process.exit(1)
end
print("Output from task:\n")
for _, log in luau_execute.get_structured_logs(task) do
if log.messageType == "ERROR" then
stdio.write(stdio.color("red"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
elseif log.messageType == "INFO" then
stdio.write(stdio.color("cyan"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
elseif log.messageType == "WARNING" then
stdio.write(stdio.color("yellow"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
else
stdio.write(stdio.color("reset"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
end
end

View File

@@ -30,9 +30,24 @@ Making a new release? Simply add the new header with the version and date undern
--> -->
## Unreleased ## Unreleased
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* `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])
* 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 [#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
[#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) ## [7.7.0-rc.1] (November 27th, 2025)

View File

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

20
Cargo.lock generated
View File

@@ -1319,6 +1319,7 @@ dependencies = [
"fs-err", "fs-err",
"notify", "notify",
"serde", "serde",
"tempfile",
] ]
[[package]] [[package]]
@@ -1519,6 +1520,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -2067,6 +2074,7 @@ dependencies = [
"num_cpus", "num_cpus",
"opener", "opener",
"paste", "paste",
"pathdiff",
"pretty_assertions", "pretty_assertions",
"profiling", "profiling",
"rayon", "rayon",
@@ -2077,10 +2085,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",
@@ -2221,6 +2231,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"

View File

@@ -100,10 +100,13 @@ clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15" profiling = "1.0.15"
yaml-rust2 = "0.10.3" yaml-rust2 = "0.10.3"
data-encoding = "2.8.0" data-encoding = "2.8.0"
pathdiff = "0.2.3"
blake3 = "1.5.0" blake3 = "1.5.0"
float-cmp = "0.9.0" float-cmp = "0.9.0"
indexmap = { version = "2.10.0", features = ["serde"] } indexmap = { version = "2.10.0", features = ["serde"] }
rmp-serde = "1.3.0"
serde_bytes = "0.11.19"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"
@@ -122,7 +125,7 @@ semver = "1.0.22"
rojo-insta-ext = { path = "crates/rojo-insta-ext" } rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.6" criterion = "0.3.6"
insta = { version = "1.36.1", features = ["redactions", "yaml"] } insta = { version = "1.36.1", features = ["redactions", "yaml", "json"] }
paste = "1.0.14" paste = "1.0.14"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
serde_yaml = "0.8.26" serde_yaml = "0.8.26"

View File

@@ -30,6 +30,11 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
continue; 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())?; let child_snapshot = snapshot_from_fs_path(&entry.path())?;
children.push((file_name, child_snapshot)); children.push((file_name, child_snapshot));
} }
@@ -70,6 +75,7 @@ fn main() -> Result<(), anyhow::Error> {
"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"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?, "Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
"UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?,
}), }),
}); });

View File

@@ -1,6 +1,7 @@
# memofs Changelog # memofs Changelog
## Unreleased Changes ## Unreleased Changes
* Added `Vfs::canonicalize`. [#1201]
## 0.3.1 (2025-11-27) ## 0.3.1 (2025-11-27)
* Added `Vfs::exists`. [#1169] * Added `Vfs::exists`. [#1169]

View File

@@ -19,3 +19,6 @@ crossbeam-channel = "0.5.12"
fs-err = "2.11.0" fs-err = "2.11.0"
notify = "4.0.17" notify = "4.0.17"
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
[dev-dependencies]
tempfile = "3.10.1"

View File

@@ -232,6 +232,33 @@ impl VfsBackend for InMemoryFs {
} }
} }
// TODO: We rely on Rojo to prepend cwd to any relative path before storing paths
// in MemoFS. The current implementation will error if no prepended absolute path
// is found. It really only normalizes paths within the provided path's context.
// Example: "/Users/username/project/../other/file.txt" ->
// "/Users/username/other/file.txt"
// Erroneous example: "/Users/../../other/file.txt" -> "/other/file.txt"
// This is not very robust. We should implement proper path normalization here or otherwise
// warn if we are missing context and can not fully canonicalize the path correctly.
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
_ => normalized.push(component),
}
}
let inner = self.inner.lock().unwrap();
match inner.entries.get(&normalized) {
Some(_) => Ok(normalized),
None => not_found(&normalized),
}
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
let inner = self.inner.lock().unwrap(); let inner = self.inner.lock().unwrap();

View File

@@ -77,6 +77,7 @@ pub trait VfsBackend: sealed::Sealed + Send + 'static {
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>; fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
fn remove_file(&mut self, path: &Path) -> io::Result<()>; fn remove_file(&mut self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf>;
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>; fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>;
fn watch(&mut self, path: &Path) -> io::Result<()>; fn watch(&mut self, path: &Path) -> io::Result<()>;
@@ -225,6 +226,11 @@ impl VfsInner {
self.backend.metadata(path) self.backend.metadata(path)
} }
fn canonicalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.backend.canonicalize(path)
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.backend.event_receiver() self.backend.event_receiver()
} }
@@ -413,6 +419,19 @@ impl Vfs {
self.inner.lock().unwrap().metadata(path) self.inner.lock().unwrap().metadata(path)
} }
/// Normalize a path via the underlying backend.
///
/// Roughly equivalent to [`std::fs::canonicalize`][std::fs::canonicalize]. Relative paths are
/// resolved against the backend's current working directory (if applicable) and errors are
/// surfaced directly from the backend.
///
/// [std::fs::canonicalize]: https://doc.rust-lang.org/stable/std/fs/fn.canonicalize.html
#[inline]
pub fn canonicalize<P: AsRef<Path>>(&self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.inner.lock().unwrap().canonicalize(path)
}
/// Retrieve a handle to the event receiver for this `Vfs`. /// Retrieve a handle to the event receiver for this `Vfs`.
#[inline] #[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -540,6 +559,13 @@ impl VfsLock<'_> {
self.inner.metadata(path) self.inner.metadata(path)
} }
/// Normalize a path via the underlying backend.
#[inline]
pub fn normalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.inner.canonicalize(path)
}
/// Retrieve a handle to the event receiver for this `Vfs`. /// Retrieve a handle to the event receiver for this `Vfs`.
#[inline] #[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -555,7 +581,9 @@ impl VfsLock<'_> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot}; use crate::{InMemoryFs, StdBackend, Vfs, VfsSnapshot};
use std::io;
use std::path::PathBuf;
/// https://github.com/rojo-rbx/rojo/issues/899 /// https://github.com/rojo-rbx/rojo/issues/899
#[test] #[test]
@@ -571,4 +599,62 @@ mod test {
"bar\nfoo\n\n" "bar\nfoo\n\n"
); );
} }
/// https://github.com/rojo-rbx/rojo/issues/1200
#[test]
fn canonicalize_in_memory_success() {
let mut imfs = InMemoryFs::new();
let contents = "Lorem ipsum dolor sit amet.".to_string();
imfs.load_snapshot("/test/file.txt", VfsSnapshot::file(contents.to_string()))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.canonicalize("/test/nested/../file.txt").unwrap(),
PathBuf::from("/test/file.txt")
);
assert_eq!(
vfs.read_to_string(vfs.canonicalize("/test/nested/../file.txt").unwrap())
.unwrap()
.to_string(),
contents.to_string()
);
}
#[test]
fn canonicalize_in_memory_missing_errors() {
let imfs = InMemoryFs::new();
let vfs = Vfs::new(imfs);
let err = vfs.canonicalize("test").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn canonicalize_std_backend_success() {
let contents = "Lorem ipsum dolor sit amet.".to_string();
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("file.txt");
fs_err::write(&file_path, contents.to_string()).unwrap();
let vfs = Vfs::new(StdBackend::new());
let canonicalized = vfs.canonicalize(&file_path).unwrap();
assert_eq!(canonicalized, file_path.canonicalize().unwrap());
assert_eq!(
vfs.read_to_string(&canonicalized).unwrap().to_string(),
contents.to_string()
);
}
#[test]
fn canonicalize_std_backend_missing_errors() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test");
let vfs = Vfs::new(StdBackend::new());
let err = vfs.canonicalize(&file_path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
} }

View File

@@ -1,5 +1,5 @@
use std::io; use std::io;
use std::path::Path; use std::path::{Path, PathBuf};
use crate::{Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{Metadata, ReadDir, VfsBackend, VfsEvent};
@@ -50,6 +50,10 @@ impl VfsBackend for NoopBackend {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::other("NoopBackend doesn't do anything"))
} }
fn canonicalize(&mut self, _path: &Path) -> io::Result<PathBuf> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
crossbeam_channel::never() crossbeam_channel::never()
} }

View File

@@ -106,6 +106,10 @@ impl VfsBackend for StdBackend {
}) })
} }
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
fs_err::canonicalize(path)
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.watcher_receiver.clone() self.watcher_receiver.clone()
} }

View File

@@ -22,6 +22,9 @@
}, },
"Version": { "Version": {
"$path": "plugin/Version.txt" "$path": "plugin/Version.txt"
},
"UploadDetails": {
"$path": "plugin/UploadDetails.json"
} }
} }
} }

View File

@@ -0,0 +1,7 @@
{
"assetId": 13916111004,
"name": "Rojo",
"description": "The plugin portion of Rojo, a tool to enable professional tooling for Roblox developers.",
"creatorId": 32644114,
"creatorType": "Group"
}

View File

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

View File

@@ -1,7 +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 msgpack = require(script.Parent.msgpack)
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)
@@ -68,4 +69,12 @@ function Http.jsonDecode(source)
return HttpService:JSONDecode(source) return HttpService:JSONDecode(source)
end end
function Http.msgpackEncode(object)
return msgpack.encode(object)
end
function Http.msgpackDecode(source)
return msgpack.decode(source)
end
return Http return Http

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,9 +206,12 @@ 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)
:andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(responseBody)
Log.info("Write response: {:?}", responseBody) Log.info("Write response: {:?}", responseBody)
return responseBody return responseBody
@@ -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,30 +293,38 @@ function ApiContext:open(id)
end end
function ApiContext:serialize(ids: { string }) function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/serialize"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) return Http.post(url, request_body)
if body.sessionId ~= self.__sessionId then :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiSerialize(body)) assert(validateApiSerialize(response_body))
return body return response_body
end) end)
end end
function ApiContext:refPatch(ids: { string }) function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/ref-patch"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) return Http.post(url, request_body)
if body.sessionId ~= self.__sessionId then :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiRefPatch(body)) assert(validateApiRefPatch(response_body))
return body return response_body
end) end)
end end

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,10 @@ local function trueEquals(a, b): boolean
end end
return true 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 -- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "number" and typeB == "number" then elseif typeA == "number" and typeB == "number" then
return fuzzyEq(a, b, 0.0001) return fuzzyEq(a, b, 0.0001)

View File

@@ -3,3 +3,4 @@ rojo = "rojo-rbx/rojo@7.5.1"
selene = "Kampfkarren/selene@0.29.0" selene = "Kampfkarren/selene@0.29.0"
stylua = "JohnnyMorganz/stylua@2.1.0" stylua = "JohnnyMorganz/stylua@2.1.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
lune = "lune-org/lune@0.10.4"

View File

@@ -1,12 +1,12 @@
use std::{
fs,
sync::{Arc, Mutex},
};
use crossbeam_channel::{select, Receiver, RecvError, Sender}; use crossbeam_channel::{select, Receiver, RecvError, Sender};
use jod_thread::JoinHandle; use jod_thread::JoinHandle;
use memofs::{IoResultExt, Vfs, VfsEvent}; use memofs::{IoResultExt, Vfs, VfsEvent};
use rbx_dom_weak::types::{Ref, Variant}; use rbx_dom_weak::types::{Ref, Variant};
use std::path::PathBuf;
use std::{
fs,
sync::{Arc, Mutex},
};
use crate::{ use crate::{
message_queue::MessageQueue, message_queue::MessageQueue,
@@ -114,18 +114,13 @@ struct JobThreadContext {
} }
impl JobThreadContext { impl JobThreadContext {
fn handle_vfs_event(&self, event: VfsEvent) { /// Computes and applies patches to the DOM for a given file path.
log::trace!("Vfs event: {:?}", event); ///
/// This function finds the nearest ancestor to the given path that has associated instances
// Update the VFS immediately with the event. /// in the tree.
self.vfs /// It then computes and applies changes for each affected instance ID and
.commit_event(&event) /// returns a vector of applied patch sets.
.expect("Error applying VFS change"); fn apply_patches(&self, path: PathBuf) -> Vec<AppliedPatchSet> {
// For a given VFS event, we might have many changes to different parts
// of the tree. Calculate and apply all of these changes.
let applied_patches = match event {
VfsEvent::Create(path) | VfsEvent::Remove(path) | VfsEvent::Write(path) => {
let mut tree = self.tree.lock().unwrap(); let mut tree = self.tree.lock().unwrap();
let mut applied_patches = Vec::new(); let mut applied_patches = Vec::new();
@@ -161,6 +156,29 @@ impl JobThreadContext {
applied_patches applied_patches
} }
fn handle_vfs_event(&self, event: VfsEvent) {
log::trace!("Vfs event: {:?}", event);
// Update the VFS immediately with the event.
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
// For a given VFS event, we might have many changes to different parts
// of the tree. Calculate and apply all of these changes.
let applied_patches = match event {
VfsEvent::Create(path) | VfsEvent::Write(path) => {
self.apply_patches(self.vfs.canonicalize(&path).unwrap())
}
VfsEvent::Remove(path) => {
// MemoFS does not track parent removals yet, so we can canonicalize
// the parent path safely and then append the removed path's file name.
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
let parent_normalized = self.vfs.canonicalize(parent).unwrap();
self.apply_patches(parent_normalized.join(file_name))
}
_ => { _ => {
log::warn!("Unhandled VFS event: {:?}", event); log::warn!("Unhandled VFS event: {:?}", event);
Vec::new() Vec::new()

View File

@@ -98,5 +98,5 @@ fn uninstall_plugin() -> anyhow::Result<()> {
#[test] #[test]
fn plugin_initialize() { fn plugin_initialize() {
assert!(initialize_plugin().is_ok()) let _ = initialize_plugin().unwrap();
} }

View File

@@ -0,0 +1,35 @@
---
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

@@ -0,0 +1,41 @@
---
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 memofs::Vfs;
use rayon::prelude::*; use rayon::prelude::*;
use rbx_dom_weak::{types::Ref, Ustr}; use rbx_dom_weak::{types::Ref, Ustr};
use serde::Serialize; use serde::{Deserialize, Serialize};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use crate::{ use crate::{
@@ -24,19 +24,20 @@ 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!"; const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
/// Representation of a node in the generated sourcemap tree. /// Representation of a node in the generated sourcemap tree.
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SourcemapNode<'a> { struct SourcemapNode<'a> {
name: &'a str, name: &'a str,
class_name: Ustr, class_name: Ustr,
#[serde( #[serde(
default,
skip_serializing_if = "Vec::is_empty", skip_serializing_if = "Vec::is_empty",
serialize_with = "crate::path_serializer::serialize_vec_absolute" serialize_with = "crate::path_serializer::serialize_vec_absolute"
)] )]
file_paths: Vec<Cow<'a, Path>>, file_paths: Vec<Cow<'a, Path>>,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
children: Vec<SourcemapNode<'a>>, children: Vec<SourcemapNode<'a>>,
} }
@@ -70,12 +71,13 @@ pub struct SourcemapCommand {
impl SourcemapCommand { impl SourcemapCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let project_path = resolve_path(&self.project); let project_path = fs_err::canonicalize(resolve_path(&self.project))?;
log::trace!("Constructing in-memory filesystem"); log::trace!("Constructing filesystem with StdBackend");
let vfs = Vfs::new_default(); let vfs = Vfs::new_default();
vfs.set_watch_enabled(self.watch); vfs.set_watch_enabled(self.watch);
log::trace!("Setting up session for sourcemap generation");
let session = ServeSession::new(vfs, project_path)?; let session = ServeSession::new(vfs, project_path)?;
let mut cursor = session.message_queue().cursor(); let mut cursor = session.message_queue().cursor();
@@ -87,14 +89,17 @@ impl SourcemapCommand {
// Pre-build a rayon threadpool with a low number of threads to avoid // Pre-build a rayon threadpool with a low number of threads to avoid
// dynamic creation overhead on systems with a high number of cpus. // dynamic creation overhead on systems with a high number of cpus.
log::trace!("Setting rayon global threadpool");
rayon::ThreadPoolBuilder::new() rayon::ThreadPoolBuilder::new()
.num_threads(num_cpus::get().min(6)) .num_threads(num_cpus::get().min(6))
.build_global() .build_global()
.unwrap(); .ok();
log::trace!("Writing initial sourcemap");
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?; write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
if self.watch { if self.watch {
log::trace!("Setting up runtime for watch mode");
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
loop { loop {
@@ -208,7 +213,7 @@ fn recurse_create_node<'a>(
} else { } else {
for val in file_paths { for val in file_paths {
output_file_paths.push(Cow::from( output_file_paths.push(Cow::from(
val.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR), pathdiff::diff_paths(val, project_dir).expect(PATH_STRIP_FAILED_ERR),
)); ));
} }
}; };
@@ -250,3 +255,80 @@ fn write_sourcemap(
Ok(()) 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, 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::{ use super::{
patch::{PatchAdd, PatchSet, PatchUpdate}, patch::{PatchAdd, PatchSet, PatchUpdate},
@@ -127,7 +127,7 @@ fn compute_property_patches(
match instance.properties().get(&name) { match instance.properties().get(&name) {
Some(instance_value) => { Some(instance_value) => {
if &snapshot_value != instance_value { if !variant_eq(&snapshot_value, instance_value) {
changed_properties.insert(name, Some(snapshot_value)); changed_properties.insert(name, Some(snapshot_value));
} }
} }

View File

@@ -42,7 +42,7 @@ pub fn snapshot_csv(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]), .relevant_paths(vec![vfs.canonicalize(path)?]),
); );
AdjacentMetadata::read_and_apply_all(vfs, path, name, &mut snapshot)?; AdjacentMetadata::read_and_apply_all(vfs, path, name, &mut snapshot)?;

View File

@@ -62,18 +62,19 @@ pub fn snapshot_dir_no_meta(
} }
} }
let normalized_path = vfs.canonicalize(path)?;
let relevant_paths = vec![ let relevant_paths = vec![
path.to_path_buf(), normalized_path.clone(),
// TODO: We shouldn't need to know about Lua existing in this // TODO: We shouldn't need to know about Lua existing in this
// middleware. Should we figure out a way for that function to add // middleware. Should we figure out a way for that function to add
// relevant paths to this middleware? // relevant paths to this middleware?
path.join("init.lua"), normalized_path.join("init.lua"),
path.join("init.luau"), normalized_path.join("init.luau"),
path.join("init.server.lua"), normalized_path.join("init.server.lua"),
path.join("init.server.luau"), normalized_path.join("init.server.luau"),
path.join("init.client.lua"), normalized_path.join("init.client.lua"),
path.join("init.client.luau"), normalized_path.join("init.client.luau"),
path.join("init.csv"), normalized_path.join("init.csv"),
]; ];
let snapshot = InstanceSnapshot::new() let snapshot = InstanceSnapshot::new()

View File

@@ -32,7 +32,7 @@ pub fn snapshot_json(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

@@ -59,7 +59,7 @@ pub fn snapshot_json_model(
snapshot.metadata = snapshot snapshot.metadata = snapshot
.metadata .metadata
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context) .context(context)
.specified_id(id) .specified_id(id)
.schema(schema); .schema(schema);

View File

@@ -88,7 +88,7 @@ pub fn snapshot_lua(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

@@ -28,7 +28,7 @@ pub fn snapshot_rbxm(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

@@ -31,7 +31,7 @@ pub fn snapshot_rbxmx(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

@@ -31,7 +31,7 @@ pub fn snapshot_toml(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

@@ -28,7 +28,7 @@ pub fn snapshot_txt(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

@@ -37,7 +37,7 @@ pub fn snapshot_yaml(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View File

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

View File

@@ -1,13 +1,7 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON. //! JSON.
use std::{ use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
collections::{HashMap, HashSet},
fs,
path::PathBuf,
str::FromStr,
sync::Arc,
};
use futures::{sink::SinkExt, stream::StreamExt}; use futures::{sink::SinkExt, stream::StreamExt};
use hyper::{body, Body, Method, Request, Response, StatusCode}; use hyper::{body, Body, Method, Request, Response, StatusCode};
@@ -19,7 +13,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,9 +21,11 @@ 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, json_ok}, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
},
web_api::{
InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest, SerializeResponse,
}, },
web_api::{BufferEncode, 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> {
@@ -45,7 +40,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( 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",
), ),
@@ -53,19 +48,15 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
) )
} }
} }
(&Method::GET, path) if path.starts_with("/api/serialize/") => { (&Method::POST, "/api/serialize") => service.handle_api_serialize(request).await,
service.handle_api_serialize(request).await (&Method::POST, "/api/ref-patch") => service.handle_api_ref_patch(request).await,
}
(&Method::GET, path) if path.starts_with("/api/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( (_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,
), ),
@@ -86,7 +77,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 +96,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( 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 +107,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( 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 +134,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( return msgpack(
ErrorResponse::bad_request(format!("Invalid body: {}", err)), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
@@ -154,7 +145,7 @@ impl ApiService {
}; };
if request.session_id != session_id { if request.session_id != session_id {
return json( return msgpack(
ErrorResponse::bad_request("Wrong session ID"), ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
@@ -180,7 +171,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 +181,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( return msgpack(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
@@ -214,7 +205,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,
@@ -229,22 +220,30 @@ 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 argument = &request.uri().path()["/api/serialize/".len()..]; let session_id = self.serve_session.session_id();
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect(); let body = body::to_bytes(request.into_body()).await.unwrap();
let requested_ids = match requested_ids { let request: SerializeRequest = match deserialize_msgpack(&body) {
Ok(ids) => ids, Ok(request) => request,
Err(_) => { Err(err) => {
return json( return msgpack(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
} }
}; };
if request.session_id != session_id {
return msgpack(
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 &requested_ids { for id in &request.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(),
@@ -268,7 +267,7 @@ impl ApiService {
response_dom.transfer_within(child_ref, object_value); response_dom.transfer_within(child_ref, object_value);
} else { } else {
json( 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,
); );
@@ -279,9 +278,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,
}) })
} }
@@ -290,20 +289,26 @@ impl ApiService {
/// 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 argument = &request.uri().path()["/api/ref-patch/".len()..]; let session_id = self.serve_session.session_id();
let requested_ids: Result<HashSet<Ref>, _> = let body = body::to_bytes(request.into_body()).await.unwrap();
argument.split(',').map(Ref::from_str).collect();
let requested_ids = match requested_ids { let request: RefPatchRequest = match deserialize_msgpack(&body) {
Ok(ids) => ids, Ok(request) => request,
Err(_) => { Err(err) => {
return json( return msgpack(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
} }
}; };
if request.session_id != session_id {
return msgpack(
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();
let tree = self.serve_session.tree(); let tree = self.serve_session.tree();
@@ -312,7 +317,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) = requested_ids.get(prop_value) { if let Some(target_id) = request.ids.get(prop_value) {
let instance_id = instance.id(); let instance_id = instance.id();
let update = let update =
instance_updates instance_updates
@@ -331,7 +336,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(),
@@ -347,7 +352,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( return msgpack(
ErrorResponse::bad_request("Invalid instance ID"), ErrorResponse::bad_request("Invalid instance ID"),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
@@ -359,7 +364,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( return msgpack(
ErrorResponse::bad_request("Instance not found"), ErrorResponse::bad_request("Instance not found"),
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
); );
@@ -369,7 +374,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( 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",
), ),
@@ -382,7 +387,7 @@ impl ApiService {
Ok(()) => {} Ok(()) => {}
Err(error) => match error { Err(error) => match error {
OpenError::Io(io_error) => { OpenError::Io(io_error) => {
return json( 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(),
@@ -396,7 +401,7 @@ impl ApiService {
status, status,
stderr, stderr,
} => { } => {
return json( 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:
@@ -412,7 +417,7 @@ impl ApiService {
}, },
}; };
json_ok(OpenResponse { msgpack_ok(OpenResponse {
session_id: self.serve_session.session_id(), session_id: self.serve_session.session_id(),
}) })
} }
@@ -476,7 +481,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 = messages let api_messages = messages
.into_iter() .into_iter()
@@ -492,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"); 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;

View File

@@ -238,35 +238,26 @@ pub struct OpenResponse {
pub session_id: SessionId, pub session_id: SessionId,
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SerializeRequest {
pub session_id: SessionId,
pub ids: Vec<Ref>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[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)] #[derive(Debug, Serialize, Deserialize)]
pub struct BufferEncode { #[serde(rename_all = "camelCase")]
m: (), pub struct RefPatchRequest {
t: Cow<'static, str>, pub session_id: SessionId,
base64: String, pub ids: HashSet<Ref>,
}
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,8 +1,48 @@
use hyper::{header::CONTENT_TYPE, Body, Response, StatusCode}; 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> { pub fn msgpack_ok<T: Serialize>(value: T) -> Response<Body> {
json(value, StatusCode::OK) 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> { pub fn json<T: Serialize>(value: T, code: StatusCode) -> Response<Body> {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
use std::{ use std::{
fmt::Write as _,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
@@ -11,10 +10,15 @@ use std::{
use hyper_tungstenite::tungstenite::{connect, Message}; use hyper_tungstenite::tungstenite::{connect, Message};
use rbx_dom_weak::types::Ref; use rbx_dom_weak::types::Ref;
use serde::{Deserialize, Serialize};
use tempfile::{tempdir, TempDir}; use tempfile::{tempdir, TempDir};
use librojo::web_api::{ use librojo::{
ReadResponse, SerializeResponse, ServerInfoResponse, SocketPacket, SocketPacketType, web_api::{
ReadResponse, SerializeRequest, SerializeResponse, ServerInfoResponse, SocketPacket,
SocketPacketType,
},
SessionId,
}; };
use rojo_insta_ext::RedactionMap; use rojo_insta_ext::RedactionMap;
@@ -158,22 +162,16 @@ impl TestServeSession {
pub fn get_api_rojo(&self) -> Result<ServerInfoResponse, reqwest::Error> { pub fn get_api_rojo(&self) -> Result<ServerInfoResponse, reqwest::Error> {
let url = format!("http://localhost:{}/api/rojo", self.port); 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()) Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
.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> { pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse<'_>, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id); 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()) Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
.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( pub fn get_api_socket_packet(
@@ -195,8 +193,8 @@ impl TestServeSession {
} }
match socket.read() { match socket.read() {
Ok(Message::Text(text)) => { Ok(Message::Binary(binary)) => {
let packet: SocketPacket = serde_json::from_str(&text)?; let packet: SocketPacket = deserialize_msgpack(&binary)?;
if packet.packet_type != packet_type { if packet.packet_type != packet_type {
continue; continue;
} }
@@ -209,7 +207,7 @@ impl TestServeSession {
return Err("WebSocket closed before receiving messages".into()); return Err("WebSocket closed before receiving messages".into());
} }
Ok(_) => { Ok(_) => {
// Ignore other message types (ping, pong, binary) // Ignore other message types (ping, pong, text)
continue; continue;
} }
Err(hyper_tungstenite::tungstenite::Error::Io(e)) Err(hyper_tungstenite::tungstenite::Error::Io(e))
@@ -226,19 +224,44 @@ impl TestServeSession {
} }
} }
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> { pub fn get_api_serialize(
let mut id_list = String::with_capacity(ids.len() * 33); &self,
for id in ids { ids: &[Ref],
write!(id_list, "{id},").unwrap(); session_id: SessionId,
} ) -> Result<SerializeResponse, reqwest::Error> {
id_list.pop(); let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/serialize", self.port);
let body = serialize_msgpack(&SerializeRequest {
session_id,
ids: ids.to_vec(),
})
.unwrap();
let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list); let body = client.post(url).body(body).send()?.bytes()?;
reqwest::blocking::get(url)?.json() 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 /// Probably-okay way to generate random enough port numbers for running the
/// Rojo live server. /// Rojo live server.
/// ///
@@ -256,11 +279,7 @@ fn get_port_number() -> usize {
/// Since the provided structure intentionally includes unredacted referents, /// Since the provided structure intentionally includes unredacted referents,
/// some post-processing is done to ensure they don't show up in the model. /// 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 { pub fn serialize_to_xml_model(response: &SerializeResponse, redactions: &RedactionMap) -> String {
let model_content = data_encoding::BASE64 let mut dom = rbx_binary::from_reader(response.model_contents.as_slice()).unwrap();
.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. // This makes me realize that maybe we need a `descendants_mut` iter.
let ref_list: Vec<Ref> = dom.descendants().map(|inst| inst.referent()).collect(); let ref_list: Vec<Ref> = dom.descendants().map(|inst| inst.referent()).collect();
for referent in ref_list { for referent in ref_list {

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]) .get_api_serialize(&[*meshpart, *objectvalue], info.session_id)
.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,7 +673,9 @@ 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.get_api_serialize(&[root_id]).unwrap(); let serialize_response = session
.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);