rojo/src/web/interface.rs
Joe 5b38648ead Update dependencies and improve syncback handling
- Updated versions of several dependencies in `Cargo.lock`, including `async-compression` (0.4.40), `aws-lc-rs` (1.16.0), `bumpalo` (3.20.1), and `compression-codecs` (0.4.37), ensuring compatibility and access to the latest features and fixes.
- Refactored the `fire` method in `SyncbackSignal` to return a boolean indicating whether a syncback was successfully initiated, preventing concurrent syncbacks.
- Enhanced error handling in the API syncback process to return a conflict response if a syncback is already in progress.
- Improved the `serve.rs` file by replacing `forget` with `drop` for better memory management and added checks to ensure service chunk integrity during live syncback operations.
- Added new tests for edge cases and resilience in live syncback functionality, ensuring robustness against malformed data and rapid sequential requests.

These changes aim to enhance the reliability and performance of the syncback feature while keeping dependencies up to date.
2026-02-19 00:17:43 -06:00

477 lines
15 KiB
Rust

//! Defines all the structs needed to interact with the Rojo Serve API. This is
//! useful for tests to be able to use the same data structures as the
//! implementation.
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
};
use rbx_dom_weak::{
types::{Ref, Variant, VariantType},
Ustr, UstrMap,
};
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::{
session_id::SessionId,
snapshot::{
AppliedPatchSet, InstanceMetadata as RojoInstanceMetadata, InstanceWithMeta, RojoTree,
},
};
/// Server version to report over the API, not exposed outside this crate.
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Current protocol version, which is required to match.
pub const PROTOCOL_VERSION: u64 = 6;
/// Metadata for a single service in the live syncback request.
///
/// Children are NOT stored here -- they live in the top-level `data` blob
/// (a single rbxm containing all children from all services).
///
/// Each service's range in the blob is `child_count + ref_target_count`
/// consecutive entries. The first `child_count` are real children (parented
/// to the service). The next `ref_target_count` are ObjectValue carriers
/// whose `Value` property holds a Ref to the actual target.
///
/// `refs` maps property names to 1-based indices into the carrier subrange
/// (NOT the full service range). Index 1 = first carrier, index 2 = second,
/// etc. Index 0 or out-of-range values are ignored (treated as nil).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceChunk {
pub class_name: String,
#[serde(default)]
pub child_count: u32,
#[serde(default)]
pub ref_target_count: u32,
#[serde(default)]
pub properties: HashMap<String, Variant>,
#[serde(default)]
pub refs: HashMap<String, u32>,
}
/// Incoming request from the plugin for live syncback.
/// Numeric fields use `f64` because Luau has no integer type -- all numbers
/// are IEEE 754 doubles, and large values (like place IDs) get encoded as
/// msgpack float64 which rmp_serde cannot coerce into u64.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncbackRequest {
pub protocol_version: f64,
pub server_version: String,
pub place_id: Option<f64>,
#[serde(with = "serde_bytes", default)]
pub data: Vec<u8>,
pub services: Vec<ServiceChunk>,
}
/// Payload passed from the API handler to the serve loop after validation.
#[derive(Debug)]
pub struct SyncbackPayload {
pub data: Vec<u8>,
pub services: Vec<ServiceChunk>,
}
/// Why the live server exited its accept loop.
pub enum ServerExitReason {
SyncbackRequested(SyncbackPayload),
}
/// Message returned by Rojo API when a change has occurred.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscribeMessage<'a> {
pub removed: Vec<Ref>,
pub added: HashMap<Ref, Instance<'a>>,
pub updated: Vec<InstanceUpdate>,
}
impl<'a> SubscribeMessage<'a> {
pub(crate) fn from_patch_update(tree: &'a RojoTree, patch: AppliedPatchSet) -> Self {
let removed = patch.removed;
let mut added = HashMap::new();
for id in patch.added {
let instance = tree.get_instance(id).unwrap();
added.insert(id, Instance::from_rojo_instance(instance));
for instance in tree.descendants(id) {
added.insert(instance.id(), Instance::from_rojo_instance(instance));
}
}
let updated = patch
.updated
.into_iter()
.map(|update| {
let changed_metadata = update
.changed_metadata
.as_ref()
.map(InstanceMetadata::from_rojo_metadata);
let changed_properties = update
.changed_properties
.into_iter()
.filter(|(_key, value)| property_filter(value.as_ref()))
.collect();
InstanceUpdate {
id: update.id,
changed_name: update.changed_name,
changed_class_name: update.changed_class_name,
changed_properties,
changed_metadata,
}
})
.collect();
Self {
removed,
added,
updated,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstanceUpdate {
pub id: Ref,
pub changed_name: Option<String>,
pub changed_class_name: Option<Ustr>,
// TODO: Transform from UstrMap<String, Option<_>> to something else, since
// null will get lost when decoding from JSON in some languages.
#[serde(default)]
pub changed_properties: UstrMap<Option<Variant>>,
pub changed_metadata: Option<InstanceMetadata>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstanceMetadata {
pub ignore_unknown_instances: bool,
}
impl InstanceMetadata {
pub(crate) fn from_rojo_metadata(meta: &RojoInstanceMetadata) -> Self {
Self {
ignore_unknown_instances: meta.ignore_unknown_instances,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Instance<'a> {
pub id: Ref,
pub parent: Ref,
pub name: Cow<'a, str>,
pub class_name: Ustr,
pub properties: UstrMap<Cow<'a, Variant>>,
pub children: Cow<'a, [Ref]>,
pub metadata: Option<InstanceMetadata>,
}
impl Instance<'_> {
pub(crate) fn from_rojo_instance(source: InstanceWithMeta<'_>) -> Instance<'_> {
let properties = source
.properties()
.iter()
.filter(|(_key, value)| property_filter(Some(value)))
.map(|(key, value)| (*key, Cow::Borrowed(value)))
.collect();
Instance {
id: source.id(),
parent: source.parent(),
name: Cow::Borrowed(source.name()),
class_name: source.class_name(),
properties,
children: Cow::Borrowed(source.children()),
metadata: Some(InstanceMetadata::from_rojo_metadata(source.metadata())),
}
}
}
fn property_filter(value: Option<&Variant>) -> bool {
let ty = value.map(|value| value.ty());
// Lua can't do anything with SharedString values. They also can't be
// serialized directly by Serde!
ty != Some(VariantType::SharedString)
}
/// Git-related metadata sent to the plugin for smart sync direction defaults.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitMetadata {
pub changed_ids: Vec<Ref>,
/// SHA1 hashes of prior versions (git blob format) for changed script instances.
/// Contains 1-2 hashes per script: HEAD version and staged version (if different).
pub script_committed_hashes: HashMap<Ref, Vec<String>>,
}
/// Response body from /api/rojo
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfoResponse {
pub session_id: SessionId,
pub server_version: String,
pub protocol_version: u64,
pub server_fork: String,
pub project_name: String,
#[serde(
serialize_with = "serialize_place_ids",
deserialize_with = "deserialize_place_ids",
skip_serializing_if = "Option::is_none",
default
)]
pub expected_place_ids: Option<HashSet<u64>>,
#[serde(
serialize_with = "serialize_place_ids",
deserialize_with = "deserialize_place_ids",
skip_serializing_if = "Option::is_none",
default
)]
pub unexpected_place_ids: Option<HashSet<u64>>,
#[serde(
serialize_with = "serialize_optional_u64_as_f64",
deserialize_with = "deserialize_optional_f64_as_u64",
skip_serializing_if = "Option::is_none",
default
)]
pub game_id: Option<u64>,
#[serde(
serialize_with = "serialize_optional_u64_as_f64",
deserialize_with = "deserialize_optional_f64_as_u64",
skip_serializing_if = "Option::is_none",
default
)]
pub place_id: Option<u64>,
pub root_instance_id: Ref,
/// When true, the server only supports syncing script Source property changes.
/// Other property changes will be ignored.
#[serde(default)]
pub sync_source_only: bool,
/// When true, hidden/internal services should be ignored during sync.
/// The plugin should not mark these services as "to delete" during forward sync.
#[serde(default)]
pub ignore_hidden_services: bool,
/// List of service names that are considered "visible" when ignoreHiddenServices is true.
/// Services not in this list should be ignored during sync operations.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub visible_services: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_metadata: Option<GitMetadata>,
}
// Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua
fn serialize_place_ids<S>(ids: &Option<HashSet<u64>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match ids {
Some(set) => {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(set.len()))?;
for id in set {
seq.serialize_element(&(*id as f64))?;
}
seq.end()
}
None => serializer.serialize_none(),
}
}
fn deserialize_place_ids<'de, D>(deserializer: D) -> Result<Option<HashSet<u64>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<Vec<f64>> = Option::deserialize(deserializer)?;
Ok(opt.map(|v| v.into_iter().map(|f| f as u64).collect()))
}
fn serialize_optional_u64_as_f64<S>(value: &Option<u64>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match value {
Some(v) => serializer.serialize_f64(*v as f64),
None => serializer.serialize_none(),
}
}
fn deserialize_optional_f64_as_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<f64> = Option::deserialize(deserializer)?;
Ok(opt.map(|f| f as u64))
}
/// Response body from /api/read/{id}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadResponse<'a> {
pub session_id: SessionId,
pub message_cursor: u32,
pub instances: HashMap<Ref, Instance<'a>>,
}
/// Instance data for creating new instances via the write API
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddedInstance {
/// The parent instance's Ref (must exist in Rojo's tree)
/// For top-level added instances, this is required.
/// For nested children, this can be omitted (parent is implied by nesting).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<Ref>,
/// The instance name
pub name: String,
/// The class name (e.g., "ModuleScript", "Script", "LocalScript")
pub class_name: String,
/// Properties to set on the instance
#[serde(default)]
pub properties: HashMap<String, Variant>,
/// Children instances (recursively encoded for hierarchical syncback)
#[serde(default)]
pub children: Vec<AddedInstance>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteRequest {
pub session_id: SessionId,
pub removed: Vec<Ref>,
#[serde(default)]
pub added: HashMap<Ref, AddedInstance>,
pub updated: Vec<InstanceUpdate>,
/// Instance IDs whose backing files should be staged via git add.
#[serde(default)]
pub stage_ids: Vec<Ref>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteResponse {
pub session_id: SessionId,
}
/// Packet type enum for different websocket message types
#[derive(Debug, Serialize, Deserialize, Display, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum SocketPacketType {
Messages,
// TODO: Can we cleanly use the socket for all communication?
// Serialize,
// RefPatch,
}
/// Body content for messages packet type
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesPacket<'a> {
pub message_cursor: u32,
pub messages: Vec<SubscribeMessage<'a>>,
}
/// Body content for different packet types
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SocketPacketBody<'a> {
Messages(MessagesPacket<'a>),
// TODO: Can we cleanly use the socket for all communication?
// Serialize(SerializePacket),
// RefPatch(RefPatchPacket<'a>),
}
/// Message content from /api/socket
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SocketPacket<'a> {
pub session_id: SessionId,
pub packet_type: SocketPacketType,
pub body: SocketPacketBody<'a>,
}
/// Response body from /api/open/{id}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenResponse {
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)]
#[serde(rename_all = "camelCase")]
pub struct SerializeResponse {
pub session_id: SessionId,
#[serde(with = "serde_bytes")]
pub model_contents: Vec<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefPatchRequest {
pub session_id: SessionId,
pub ids: HashSet<Ref>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefPatchResponse<'a> {
pub session_id: SessionId,
pub patch: SubscribeMessage<'a>,
}
/// General response type returned from all Rojo routes
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorResponse {
kind: ErrorResponseKind,
details: String,
}
impl ErrorResponse {
pub fn not_found<S: Into<String>>(details: S) -> Self {
Self {
kind: ErrorResponseKind::NotFound,
details: details.into(),
}
}
pub fn bad_request<S: Into<String>>(details: S) -> Self {
Self {
kind: ErrorResponseKind::BadRequest,
details: details.into(),
}
}
pub fn internal_error<S: Into<String>>(details: S) -> Self {
Self {
kind: ErrorResponseKind::InternalError,
details: details.into(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ErrorResponseKind {
NotFound,
BadRequest,
InternalError,
}