@fieldnotes/sync-server
v0.8.0
Published
Authoritative WebSocket relay server for Field Notes real-time sync
Maintainers
Readme
@fieldnotes/sync-server
Authoritative WebSocket relay server for @fieldnotes/sync.
The relay holds the canonical per-room canvas state and fans out element
operations to every other connection in the room. Clients connect, request a
snapshot to catch up, then stream upsert / remove / clear ops that the hub
applies and forwards.
Pieces
SyncHub— transport-agnostic relay. Per-room canonical state lives behind an asyncHubBackend; each room processes messages on its own serial queue so concurrent edits to the same room never race, while different rooms run independently.MemoryHubBackend— in-memoryHubBackend(the default). Redis-backed and authenticated backends are planned. It reclaims a room's memory onclear, but retains state for uncleared / abandoned rooms — fine for dev / single-instance use. For a long-lived production process, use a Redis backend with a key TTL, orclearrooms you're done with.createSyncServer— a runnablewsreference server. Connect with?room=<id>in the query string; a missing room closes the socket with WS code4400.HubFanout— cross-instance live fan-out seam.SyncHubpublishes each live op to the fanout and forwards ops it receives from other instances to its local connections. The defaultInMemoryHubFanoutis in-process (a no-op for a single instance). For multiple relay instances to share live ops, pass a shared fanout viacreateSyncServer({ fanout })(e.g.RedisHubFanoutfrom@fieldnotes/sync-redis) together with a shared backend — a shared fanout alone leaves a new joiner's snapshot stale.
Usage
import { createSyncServer } from '@fieldnotes/sync-server';
const { close } = createSyncServer({ port: 8080 });
// ws://localhost:8080?room=my-roomHeartbeat
The server pings every client on an interval and terminates any that miss a pong,
so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
leaking room membership. Configure via heartbeatIntervalMs (default 30000; 0
disables):
createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });Browsers auto-pong at the protocol level, so no client change is needed.
Authentication
Pass an authenticate hook to gate connections:
import { createSyncServer } from '@fieldnotes/sync-server';
createSyncServer({
port: 8080,
authenticate: async ({ req, room }) => {
const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
},
});authenticate({ req, room }) → { userId, role? } | null runs on each connection and
may be sync or async. Returning null (or throwing) rejects the connection: the
socket is closed with WS code 4401 and the connection is never admitted to the room —
no membership, no snapshot. A resolved result admits the connection carrying its
userId and optional role.
With no hook, rooms stay open: every connection is admitted anonymously with
userId = connId. role is captured now and enforced in an upcoming release
(role-based authorization and per-viewer visibility filtering).
Messages that arrive during an async auth (notably the client's initial
request-snapshot) are queued and replayed once the connection is admitted, so the
first snapshot is never lost to the auth round-trip.
Passing a token
A browser WebSocket can't set request headers, so pass the token as a URL query
param (ws://relay?room=R&token=…) and read it from req.url in authenticate. URLs
land in access/proxy logs, so prefer short-lived / single-use tokens. Non-browser
clients can instead put the token in req.headers (e.g. Authorization), which
authenticate reads directly.
Authorization
Pass an authorize hook to gate writes. The hub calls it before applying each data
op (upsert / remove / clear) and drops denied ops — a denied op is not applied
to room state and not forwarded to any other connection:
authorize(ctx) => boolean | Promise<boolean>ctx is an AuthorizeContext:
{
userId?: string; // the connection's authenticated user (from authenticate)
role?: string; // the connection's role (from authenticate)
room: string;
op: SyncOp; // the incoming upsert / remove / clear
currentElement?: OwnedElement; // the STORED element, if this id already exists
}currentElement is the element currently in room state for an upsert/remove of an
existing id (typed OwnedElement = CanvasElement & { ownerId?: string }), and
undefined for a new/absent id.
Ownership is server-stamped and un-forgeable. A new element's ownerId is set to the
authenticated creator; on edit the stored owner is preserved; a client-supplied
ownerId is always discarded. A policy can therefore trust currentElement.ownerId
to enforce "own elements only".
With no hook, rooms are OPEN (allow-all — every op is accepted).
A copy-paste DM / player / display policy:
createSyncServer({
port: 8080,
authenticate: /* … supplies userId + role … */,
authorize: ({ role, op, currentElement, userId }) => {
if (role === 'dm') return true;
if (role === 'display') return false; // read-only monitor
if (role === 'player') { // own elements only, never destructive
if (op.kind === 'clear') return false;
if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
if (op.kind === 'remove') return currentElement?.ownerId === userId;
return false;
}
return false;
},
});Important:
- Ownership-based authz requires
authenticateto supply a STABLEuserId. The no-hook anonymous default isuserId = connId, which changes on every reconnect — a user would lose access to their own elements after reconnecting. - The authz path adds one
backend.get(a RedisHGET) per data op — negligible for low-write use. - Reads / visibility (a player not receiving hidden content) are a separate, upcoming
concern (D3).
authorizegates writes only. - When
authorizedenies an op, the hub sends the offending client a corrective op so its optimistic local edit self-corrects immediately — no client change, no waiting for reconnect: a rejected new element is removed, a rejected edit/remove is re-upserted from the canonical stored element, and a rejectedcleargets a fresh snapshot. The correction reuses existing op kinds and is sent only to the offending connection.
Read filtering
Pass a canRead hook to gate reads — what each viewer receives. Unlike client-side
hiding, the hub filters ops before they leave the server, so hidden elements never reach an
unauthorized client on either path: the live broadcast and the snapshot a client gets on join.
canRead({ userId, role, room, audience }) => booleanaudience is the opaque tag the client stamps on its own outgoing upserts via @fieldnotes/sync's
resolveAudience (e.g. 'dm' / 'shared', derived from the app's layers). With no hook,
everyone sees everything.
A two-tier DM / player policy — the relay's canRead paired with the client's resolveAudience:
// relay
createSyncServer({
port: 8080,
authenticate: /* … supplies userId + role … */,
canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
});
// client (@fieldnotes/sync)
new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access gets a synthetic remove, one who gains it gets an add.
Preconditions:
- Stable identity. Meaningful policy needs a stable
userId/rolefromauthenticate; the no-hook anonymous default is per-connection (userId = connId) and changes on reconnect. - Same
canReadon every instance. Unlikeauthorize(which runs on the write's origin instance only),canReadruns on every instance — each re-filters fanned-out ops for its own local members — so all relay instances must inject the samecanRead, alongside the existing shared-backend + shared-fanout requirement. - Labeling integrity.
audienceis client-asserted, so read secrecy is only as trustworthy as theauthorize(write) policy that stops a player from stampingdmon content they shouldn't control.
A Redis HubBackend and cross-instance fan-out ship in @fieldnotes/sync-redis.
