Skip to content

Session-sync relay WebSocket (/ws/sync/{session_id}) for cross-device followers #1030

Description

@topkoa

Summary

Add a small, generic session-sync relay WebSocket to core — /ws/sync/{session_id} — that fans JSON frames out to every other client in the same session ("room"). The server interprets nothing; it just relays.

This is the one missing primitive for cross-device followers: letting a browser on another LAN machine/tablet mirror live player state from a main instance. The immediate consumer is splitscreen's "pop out to LAN" feature (see linked plugin issue), but the primitive is deliberately consumer-agnostic — the same room could later carry Camera Director state, a remote control surface, etc.

Motivation

Splitscreen's pop-out follower windows are already URL-bootstrapped (/?ssFollower=1&filename=…&arrangement=…&mode=…) and every follower panel opens its own /ws/highway/{filename} connection using location.host — so chart data, plugins, and audio metadata already work from any device that can reach the server. The desktop app already ships an opt-in LAN access toggle (#441) that binds the backend to 0.0.0.0 and hands out LAN URLs; Docker/standalone deployments are LAN-reachable by nature.

The only machine-local link in the whole flow is the live session channel: playhead/playstate/song-change messages go over BroadcastChannel('slopsmith-ss'), which does not cross machines (or even browsers). A remote follower would load the song and then sit frozen at t=0. A dumb server-side relay closes exactly that gap.

Proposed design

New router lib/routers/ws_sync.py (same placement pattern as lib/routers/ws_highway.py):

/ws/sync/{session_id}
  • Room semantics: first join creates the room; a frame received from one socket is forwarded verbatim to every other socket in the room; room is deleted when the last socket leaves. No history, no replay, no persistence — a client that joins late simply waits for the next frame (the splitscreen protocol already re-sends current state promptly to fresh followers).
  • Text JSON frames only. The server does not parse beyond what's needed to enforce limits; message schemas are owned by the consumer.
  • session_id: client-generated and opaque to the server. Constrain to [A-Za-z0-9_-]{4,64} and reject otherwise. The server imposes no entropy policy — consumers pick their own id shape. In particular, the splitscreen consumer uses a short, human-typeable, persistent room key (~6 chars, saved in the plugin's settings and reused across sessions so viewers can bookmark the URL) rather than a per-session UUID; that's an acceptable trade on a trusted LAN given the caps below, and joining an idle/nonexistent room leaks nothing (it's just an empty room — frames only flow while a publisher is present). Statelessness is also what makes crash recovery work for consumers: a room is recreated by whoever joins, so a host that crashes and relaunches under the same id resumes publishing to reconnecting subscribers with no server-side coordination.
  • Limits (DoS hygiene, all constants): max frame size (e.g. 16 KB), max sockets per room (e.g. 16), max rooms (e.g. 32), and a per-socket inbound rate cap (e.g. 120 msg/s — splitscreen's broadcaster tops out at 60 Hz and should throttle to ~20 Hz for network sessions anyway). Over-limit → close with a policy code.
  • Disconnect handling: normal WebSocketDisconnect cleanup; a dead socket must never wedge the fan-out loop for the others (send failures drop that socket, not the room).

Optionally (nice-to-have, can be a follow-up): a trivial GET /api/sync/urls returning the server's reachable base URLs (bind address + non-loopback interface IPs) so a client loaded via 127.0.0.1 can build a shareable LAN URL. On desktop this already exists via the preload network.getLanAccess() IPC (getLanUrls() in feedback-desktop/src/main/python.ts), and in Docker location.host is already the LAN address — so this endpoint is only needed if we want the web app to do it without desktop help.

Why core and not a plugin routes.py

  • It's a transport primitive, not splitscreen behavior — second consumer candidates already exist (Camera Director in follower windows, future remote-control ideas).
  • It mirrors the existing core-owned WS surface (ws_highway.py) and keeps plugin repos free of backend code they'd each have to duplicate.

Security posture

  • LAN exposure remains opt-in (desktop LAN-access toggle, Left Handed #441; Docker operators opt in by publishing the port). Loopback-only deployments are unaffected.
  • Session ids may be short (typeable) at the consumer's choice; the relay carries only transient player-state frames — no library data, no file access, nothing persisted — and an idle room is indistinguishable from a nonexistent one, so code-guessing yields nothing unless a session is actively publishing. The rate/room caps below also bound any scanning attempt.
  • The caps above bound memory/CPU; no auth system is introduced (consistent with the rest of the server, which is unauthenticated by design for trusted-LAN use).

Non-goals

  • No message schema/semantics in the server (owned by consumers).
  • No auth/user system, no TLS termination, no internet/TURN traversal — trusted LAN only.
  • No state storage or late-join replay.

Acceptance criteria

  • Two WS clients on /ws/sync/abc123 can exchange JSON frames in both directions; a third joins and receives subsequent frames.
  • Sender never receives its own frame back.
  • Room is garbage-collected when the last client disconnects (no growth across repeated sessions).
  • Frame-size / room-size / room-count / rate limits enforced with clean closes; invalid session_id rejected.
  • A client disconnecting mid-broadcast doesn't disrupt delivery to remaining clients.
  • pytest coverage for the above (FastAPI TestClient websocket support suffices).
  • No change to existing endpoints; loopback-only deployments behave identically.

Consumer

got-feedback/feedBack-plugin-splitscreen — "pop out to LAN" follower mode (issue linked below) is the first consumer and the acceptance test bed for end-to-end behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions