Implement live syncback feature and enhance API for service data transfer

- Added a new `performSyncback` function in the `App` module to handle the synchronization of service data from the game to the server.
- Introduced a `SyncbackConfirm` UI component to confirm syncback actions from the user.
- Updated the API to include a new `/api/syncback` endpoint for handling syncback requests, validating protocol and server versions.
- Enhanced the `SyncbackSignal` mechanism to facilitate communication between the server and the syncback process.
- Added tests to ensure the functionality of the live syncback feature and its integration with the CLI.

These changes improve the overall synchronization capabilities of the application, allowing for real-time updates and better project management.
This commit is contained in:
Joe 2026-02-18 04:36:57 -06:00
parent 6f8e5a5cf2
commit 307ac81aec
17 changed files with 1411 additions and 97 deletions

View file

@ -29,8 +29,9 @@ use crate::{
web::{
interface::{
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType,
SubscribeMessage, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse,
PROTOCOL_VERSION, SERVER_VERSION,
},
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
},
@ -122,6 +123,7 @@ fn variant_to_json(variant: &Variant) -> Option<serde_json::Value> {
pub async fn call(
serve_session: Arc<ServeSession>,
mut request: Request<Incoming>,
syncback_signal: Arc<super::SyncbackSignal>,
) -> Response<Full<Bytes>> {
let service = ApiService::new(serve_session);
@ -153,6 +155,9 @@ pub async fn call(
service.handle_api_open(request).await
}
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
(&Method::POST, "/api/syncback") => {
handle_api_syncback(request, &service, syncback_signal).await
}
(&Method::GET, "/api/validate-tree") => service.handle_api_validate_tree().await,
(_method, path) => msgpack(
@ -162,6 +167,96 @@ pub async fn call(
}
}
async fn handle_api_syncback(
request: Request<Incoming>,
service: &ApiService,
syncback_signal: Arc<super::SyncbackSignal>,
) -> Response<Full<Bytes>> {
let body = match request.into_body().collect().await {
Ok(collected) => collected.to_bytes(),
Err(err) => {
return msgpack(
ErrorResponse::bad_request(format!("Failed to read request body: {err}")),
StatusCode::BAD_REQUEST,
);
}
};
let syncback_request: SyncbackRequest = match deserialize_msgpack(&body) {
Ok(req) => req,
Err(err) => {
return msgpack(
ErrorResponse::bad_request(format!(
"Failed to deserialize syncback request: {err}"
)),
StatusCode::BAD_REQUEST,
);
}
};
if syncback_request.protocol_version != PROTOCOL_VERSION {
return msgpack(
ErrorResponse::bad_request(format!(
"Protocol version mismatch: expected {}, got {}",
PROTOCOL_VERSION, syncback_request.protocol_version
)),
StatusCode::BAD_REQUEST,
);
}
let server_semver = SERVER_VERSION;
let server_parts: Vec<&str> = server_semver.split('.').collect();
let client_parts: Vec<&str> = syncback_request.server_version.split('.').collect();
let server_major_minor = server_parts.get(..2);
let client_major_minor = client_parts.get(..2);
if server_major_minor != client_major_minor {
return msgpack(
ErrorResponse::bad_request(format!(
"Server version mismatch: server is {}, plugin expects {}",
SERVER_VERSION, syncback_request.server_version
)),
StatusCode::BAD_REQUEST,
);
}
if let Some(place_id) = syncback_request.place_id {
if let Some(expected) = service.serve_session.serve_place_ids() {
if !expected.contains(&place_id) {
return msgpack(
ErrorResponse::bad_request(format!(
"Place ID {} is not in the servePlaceIds whitelist",
place_id
)),
StatusCode::FORBIDDEN,
);
}
}
if let Some(blocked) = service.serve_session.blocked_place_ids() {
if blocked.contains(&place_id) {
return msgpack(
ErrorResponse::bad_request(format!(
"Place ID {} is in the blockedPlaceIds list",
place_id
)),
StatusCode::FORBIDDEN,
);
}
}
}
log::info!(
"Live syncback requested with {} service chunks",
syncback_request.services.len()
);
let payload = SyncbackPayload {
services: syncback_request.services,
};
syncback_signal.fire(payload);
msgpack_ok(&serde_json::json!({"status": "syncback_initiated"}))
}
pub struct ApiService {
serve_session: Arc<ServeSession>,
suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,

View file

@ -27,6 +27,36 @@ pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Current protocol version, which is required to match.
pub const PROTOCOL_VERSION: u64 = 6;
/// A single service's children serialized as rbxm binary data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceChunk {
pub class_name: String,
#[serde(with = "serde_bytes")]
pub data: Vec<u8>,
}
/// Incoming request from the plugin for live syncback.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncbackRequest {
pub protocol_version: u64,
pub server_version: String,
pub place_id: Option<u64>,
pub services: Vec<ServiceChunk>,
}
/// Payload passed from the API handler to the serve loop after validation.
#[derive(Debug)]
pub struct SyncbackPayload {
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")]

View file

@ -10,7 +10,7 @@ mod util;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use hyper::body::Incoming;
use hyper::server::conn::http1;
@ -19,52 +19,103 @@ use hyper::Request;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::runtime::Runtime;
use tokio::sync::Notify;
use crate::serve_session::ServeSession;
use self::interface::{ServerExitReason, SyncbackPayload};
/// Shared signal for the syncback endpoint to deposit its payload and notify
/// the accept loop to shut down.
pub struct SyncbackSignal {
payload: Mutex<Option<SyncbackPayload>>,
notify: Notify,
}
impl SyncbackSignal {
pub fn new() -> Self {
Self {
payload: Mutex::new(None),
notify: Notify::new(),
}
}
pub fn fire(&self, payload: SyncbackPayload) {
*self.payload.lock().unwrap() = Some(payload);
self.notify.notify_one();
}
pub fn take_payload(&self) -> Option<SyncbackPayload> {
self.payload.lock().unwrap().take()
}
}
pub struct LiveServer {
serve_session: Arc<ServeSession>,
syncback_signal: Arc<SyncbackSignal>,
}
impl LiveServer {
pub fn new(serve_session: Arc<ServeSession>) -> Self {
LiveServer { serve_session }
LiveServer {
serve_session,
syncback_signal: Arc::new(SyncbackSignal::new()),
}
}
pub fn start(self, address: SocketAddr) {
pub fn start(self, address: SocketAddr) -> ServerExitReason {
let serve_session = Arc::clone(&self.serve_session);
let syncback_signal = Arc::clone(&self.syncback_signal);
let rt = Runtime::new().unwrap();
rt.block_on(async move {
let exit_reason = rt.block_on(async move {
let listener = TcpListener::bind(address).await.unwrap();
loop {
let (stream, _) = listener.accept().await.unwrap();
let io = TokioIo::new(stream);
let serve_session = Arc::clone(&serve_session);
tokio::spawn(async move {
let service = service_fn(move |req: Request<Incoming>| {
tokio::select! {
result = listener.accept() => {
let (stream, _) = result.unwrap();
let io = TokioIo::new(stream);
let serve_session = Arc::clone(&serve_session);
let syncback_signal = Arc::clone(&syncback_signal);
async move {
if req.uri().path().starts_with("/api") {
Ok::<_, Infallible>(api::call(serve_session, req).await)
} else {
Ok::<_, Infallible>(ui::call(serve_session, req).await)
tokio::spawn(async move {
let service = service_fn(move |req: Request<Incoming>| {
let serve_session = Arc::clone(&serve_session);
let syncback_signal = Arc::clone(&syncback_signal);
async move {
if req.uri().path().starts_with("/api") {
Ok::<_, Infallible>(
api::call(serve_session, req, syncback_signal).await,
)
} else {
Ok::<_, Infallible>(ui::call(serve_session, req).await)
}
}
});
if let Err(err) = http1::Builder::new()
.serve_connection(io, service)
.with_upgrades()
.await
{
log::error!("Error serving connection: {err}");
}
}
});
if let Err(err) = http1::Builder::new()
.serve_connection(io, service)
.with_upgrades()
.await
{
log::error!("Error serving connection: {err}");
});
}
});
_ = syncback_signal.notify.notified() => {
break;
}
}
}
let payload = syncback_signal
.take_payload()
.expect("Syncback signal fired but no payload was deposited");
ServerExitReason::SyncbackRequested(payload)
});
exit_reason
}
}