@fieldnotes/sync-server 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,204 +1,238 @@
1
- # @fieldnotes/sync-server
2
-
3
- Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
-
5
- The relay holds the canonical per-room canvas state and fans out element
6
- operations to every other connection in the room. Clients connect, request a
7
- snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
- applies and forwards.
9
-
10
- ## Pieces
11
-
12
- - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
- an async `HubBackend`; each room processes messages on its own serial queue so
14
- concurrent edits to the same room never race, while different rooms run
15
- independently.
16
- - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
- authenticated backends are planned. It reclaims a room's memory on `clear`, but
18
- **retains** state for uncleared / abandoned rooms — fine for dev / single-instance
19
- use. For a long-lived production process, use a Redis backend with a key TTL, or
20
- `clear` rooms you're done with.
21
- - **`createSyncServer`** — a runnable `ws` reference server. Connect with
22
- `?room=<id>` in the query string; a missing room closes the socket with WS code
23
- `4400`.
24
- - **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
25
- op to the fanout and forwards ops it receives from other instances to its local
26
- connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
27
- instance). For multiple relay instances to share live ops, pass a shared fanout
28
- via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
29
- [`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
30
- shared fanout alone leaves a new joiner's snapshot stale.
31
-
32
- ## Usage
33
-
34
- ```ts
35
- import { createSyncServer } from '@fieldnotes/sync-server';
36
-
37
- const { close } = createSyncServer({ port: 8080 });
38
- // ws://localhost:8080?room=my-room
39
- ```
40
-
41
- ## Heartbeat
42
-
43
- The server pings every client on an interval and **terminates** any that miss a pong,
44
- so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
45
- leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
46
- disables):
47
-
48
- ```ts
49
- createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
50
- ```
51
-
52
- Browsers auto-pong at the protocol level, so **no client change** is needed.
53
-
54
- ## Authentication
55
-
56
- Pass an `authenticate` hook to gate connections:
57
-
58
- ```ts
59
- import { createSyncServer } from '@fieldnotes/sync-server';
60
-
61
- createSyncServer({
62
- port: 8080,
63
- authenticate: async ({ req, room }) => {
64
- const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
65
- const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
66
- return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
67
- },
68
- });
69
- ```
70
-
71
- `authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
72
- may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
73
- socket is closed with WS code `4401` and the connection is never admitted to the room —
74
- no membership, no snapshot. A resolved result admits the connection carrying its
75
- `userId` and optional `role`.
76
-
77
- With **no hook**, rooms stay open: every connection is admitted anonymously with
78
- `userId = connId`. `role` is captured now and enforced in an upcoming release
79
- (role-based authorization and per-viewer visibility filtering).
80
-
81
- Messages that arrive during an async auth (notably the client's initial
82
- `request-snapshot`) are queued and replayed once the connection is admitted, so the
83
- first snapshot is never lost to the auth round-trip.
84
-
85
- ### Passing a token
86
-
87
- A browser `WebSocket` can't set request headers, so pass the token as a URL query
88
- param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
89
- land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
90
- clients can instead put the token in `req.headers` (e.g. `Authorization`), which
91
- `authenticate` reads directly.
92
-
93
- ## Authorization
94
-
95
- Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
96
- op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
97
- to room state and not forwarded to any other connection:
98
-
99
- ```ts
100
- authorize(ctx) => boolean | Promise<boolean>
101
- ```
102
-
103
- `ctx` is an `AuthorizeContext`:
104
-
105
- ```ts
106
- {
107
- userId?: string; // the connection's authenticated user (from authenticate)
108
- role?: string; // the connection's role (from authenticate)
109
- room: string;
110
- op: SyncOp; // the incoming upsert / remove / clear
111
- currentElement?: OwnedElement; // the STORED element, if this id already exists
112
- }
113
- ```
114
-
115
- `currentElement` is the element currently in room state for an `upsert`/`remove` of an
116
- **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
117
- `undefined` for a new/absent id.
118
-
119
- **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
120
- authenticated creator; on edit the stored owner is **preserved**; a client-supplied
121
- `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
122
- to enforce "own elements only".
123
-
124
- With **no hook**, rooms are OPEN (allow-all every op is accepted).
125
-
126
- A copy-paste DM / player / display policy:
127
-
128
- ```ts
129
- createSyncServer({
130
- port: 8080,
131
- authenticate: /* supplies userId + role */,
132
- authorize: ({ role, op, currentElement, userId }) => {
133
- if (role === 'dm') return true;
134
- if (role === 'display') return false; // read-only monitor
135
- if (role === 'player') { // own elements only, never destructive
136
- if (op.kind === 'clear') return false;
137
- if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
138
- if (op.kind === 'remove') return currentElement?.ownerId === userId;
139
- return false;
140
- }
141
- return false;
142
- },
143
- });
144
- ```
145
-
146
- **Important:**
147
-
148
- - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
149
- no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
150
- user would lose access to their own elements after reconnecting.
151
- - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
152
- low-write use.
153
- - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
154
- concern (D3). `authorize` gates writes only.
155
- - When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
156
- optimistic local edit self-corrects immediately — no client change, no waiting for reconnect:
157
- a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
158
- stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
159
- kinds and is sent only to the offending connection.
160
-
161
- ## Read filtering
162
-
163
- Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
164
- hiding, the hub filters ops before they leave the server, so hidden elements never reach an
165
- unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
166
-
167
- ```ts
168
- canRead({ userId, role, room, audience }) => boolean
169
- ```
170
-
171
- `audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
172
- `resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
173
- everyone sees everything.
174
-
175
- A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
176
-
177
- ```ts
178
- // relay
179
- createSyncServer({
180
- port: 8080,
181
- authenticate: /* … supplies userId + role … */,
182
- canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
183
- });
184
-
185
- // client (@fieldnotes/sync)
186
- new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
187
- ```
188
-
189
- Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
190
- gets a synthetic **remove**, one who gains it gets an **add**.
191
-
192
- **Preconditions:**
193
-
194
- - **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
195
- no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
196
- - **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
197
- instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
198
- local members so all relay instances must inject the **same** `canRead`, alongside the existing
199
- shared-backend + shared-fanout requirement.
200
- - **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
201
- the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
202
- control.
203
-
204
- A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
1
+ # @fieldnotes/sync-server
2
+
3
+ Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
+
5
+ The relay holds the canonical per-room canvas state and fans out element
6
+ operations to every other connection in the room. Clients connect, request a
7
+ snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
+ applies and forwards.
9
+
10
+ ## Pieces
11
+
12
+ - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
+ an async `HubBackend`; each room processes messages on its own serial queue so
14
+ concurrent edits to the same room never race, while different rooms run
15
+ independently.
16
+ - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
+ authenticated backends are planned. It reclaims a room's memory on `clear`, but
18
+ **retains** state for uncleared / abandoned rooms — fine for dev / single-instance
19
+ use. For a long-lived production process, use a Redis backend with a key TTL, or
20
+ `clear` rooms you're done with.
21
+ - **`createSyncServer`** — a runnable `ws` reference server. Connect with
22
+ `?room=<id>` in the query string; a missing room closes the socket with WS code
23
+ `4400`.
24
+ - **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
25
+ op to the fanout and forwards ops it receives from other instances to its local
26
+ connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
27
+ instance). For multiple relay instances to share live ops, pass a shared fanout
28
+ via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
29
+ [`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
30
+ shared fanout alone leaves a new joiner's snapshot stale.
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { createSyncServer } from '@fieldnotes/sync-server';
36
+
37
+ const { close } = createSyncServer({ port: 8080 });
38
+ // ws://localhost:8080?room=my-room
39
+ ```
40
+
41
+ ## Heartbeat
42
+
43
+ The server pings every client on an interval and **terminates** any that miss a pong,
44
+ so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
45
+ leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
46
+ disables):
47
+
48
+ ```ts
49
+ createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
50
+ ```
51
+
52
+ Browsers auto-pong at the protocol level, so **no client change** is needed.
53
+
54
+ ## Resource limits
55
+
56
+ The reference server bounds work and memory per connection by default. Oversized WebSocket messages
57
+ close with code `1009`; message-rate and pending-auth queue violations close with code `4408`.
58
+ Messages with excessive JSON nesting are dropped. Presence sends immediately, then coalesces rapid
59
+ updates so the latest state is forwarded at most once per throttle interval.
60
+
61
+ ```ts
62
+ createSyncServer({
63
+ port: 8080,
64
+ maxMessageBytes: 1024 * 1024,
65
+ maxJsonDepth: 32,
66
+ maxPendingAuthMessages: 100,
67
+ maxPendingAuthBytes: 2 * 1024 * 1024,
68
+ messagesPerSecond: 120,
69
+ messageBurst: 240,
70
+ presenceThrottleMs: 50,
71
+ });
72
+ ```
73
+
74
+ These values are the defaults. Tune them to the largest legitimate board operation and expected
75
+ client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
76
+ is allocated, including fragmented messages. The pending-auth limits bound messages held while an
77
+ asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use a token
78
+ bucket: `messageBurst` is the short spike allowance and `messagesPerSecond` is the refill rate.
79
+
80
+ ## Authentication
81
+
82
+ Pass an `authenticate` hook to gate connections:
83
+
84
+ ```ts
85
+ import { createSyncServer } from '@fieldnotes/sync-server';
86
+
87
+ createSyncServer({
88
+ port: 8080,
89
+ authenticate: async ({ req, room }) => {
90
+ const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
91
+ const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
92
+ return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
93
+ },
94
+ });
95
+ ```
96
+
97
+ `authenticate({ req, room }) { userId, role? } | null` runs on each connection and
98
+ may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
99
+ socket is closed with WS code `4401` and the connection is never admitted to the room —
100
+ no membership, no snapshot. A resolved result admits the connection carrying its
101
+ `userId` and optional `role`.
102
+
103
+ With **no hook**, rooms stay open: every connection is admitted anonymously with
104
+ `userId = connId`. `role` is captured now and enforced in an upcoming release
105
+ (role-based authorization and per-viewer visibility filtering).
106
+
107
+ Messages that arrive during an async auth (notably the client's initial
108
+ `request-snapshot`) are queued and replayed once the connection is admitted, so the
109
+ first snapshot is never lost to the auth round-trip.
110
+
111
+ ### Relay identity
112
+
113
+ The relay assigns every admitted socket an opaque connection identity. That server-owned identity
114
+ is used as `from` on all relayed mutations, presence updates, cross-instance fan-out, and disconnect
115
+ leave events; the client-controlled envelope `from` cannot impersonate another connection. Presence
116
+ identity is intentionally connection-scoped and changes after reconnecting. Application authorization
117
+ continues to use the authenticated `userId` and `role`, not this transport identity.
118
+
119
+ ### Passing a token
120
+
121
+ A browser `WebSocket` can't set request headers, so pass the token as a URL query
122
+ param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
123
+ land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
124
+ clients can instead put the token in `req.headers` (e.g. `Authorization`), which
125
+ `authenticate` reads directly.
126
+
127
+ ## Authorization
128
+
129
+ Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
130
+ op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
131
+ to room state and not forwarded to any other connection:
132
+
133
+ ```ts
134
+ authorize(ctx) => boolean | Promise<boolean>
135
+ ```
136
+
137
+ `ctx` is an `AuthorizeContext`:
138
+
139
+ ```ts
140
+ {
141
+ userId?: string; // the connection's authenticated user (from authenticate)
142
+ role?: string; // the connection's role (from authenticate)
143
+ room: string;
144
+ op: SyncOp; // the incoming upsert / remove / clear
145
+ currentElement?: OwnedElement; // the STORED element, if this id already exists
146
+ }
147
+ ```
148
+
149
+ `currentElement` is the element currently in room state for an `upsert`/`remove` of an
150
+ **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
151
+ `undefined` for a new/absent id.
152
+
153
+ **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
154
+ authenticated creator; on edit the stored owner is **preserved**; a client-supplied
155
+ `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
156
+ to enforce "own elements only".
157
+
158
+ With **no hook**, rooms are OPEN (allow-all every op is accepted).
159
+
160
+ A copy-paste DM / player / display policy:
161
+
162
+ ```ts
163
+ createSyncServer({
164
+ port: 8080,
165
+ authenticate: /* supplies userId + role */,
166
+ authorize: ({ role, op, currentElement, userId }) => {
167
+ if (role === 'dm') return true;
168
+ if (role === 'display') return false; // read-only monitor
169
+ if (role === 'player') { // own elements only, never destructive
170
+ if (op.kind === 'clear') return false;
171
+ if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
172
+ if (op.kind === 'remove') return currentElement?.ownerId === userId;
173
+ return false;
174
+ }
175
+ return false;
176
+ },
177
+ });
178
+ ```
179
+
180
+ **Important:**
181
+
182
+ - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
183
+ no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
184
+ user would lose access to their own elements after reconnecting.
185
+ - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
186
+ low-write use.
187
+ - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
188
+ concern (D3). `authorize` gates writes only.
189
+ - When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
190
+ optimistic local edit self-corrects immediately no client change, no waiting for reconnect:
191
+ a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
192
+ stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
193
+ kinds and is sent only to the offending connection.
194
+
195
+ ## Read filtering
196
+
197
+ Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
198
+ hiding, the hub filters ops before they leave the server, so hidden elements never reach an
199
+ unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
200
+
201
+ ```ts
202
+ canRead({ userId, role, room, audience }) => boolean
203
+ ```
204
+
205
+ `audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
206
+ `resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
207
+ everyone sees everything.
208
+
209
+ A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
210
+
211
+ ```ts
212
+ // relay
213
+ createSyncServer({
214
+ port: 8080,
215
+ authenticate: /* … supplies userId + role … */,
216
+ canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
217
+ });
218
+
219
+ // client (@fieldnotes/sync)
220
+ new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
221
+ ```
222
+
223
+ Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
224
+ gets a synthetic **remove**, one who gains it gets an **add**.
225
+
226
+ **Preconditions:**
227
+
228
+ - **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
229
+ no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
230
+ - **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
231
+ instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
232
+ local members — so all relay instances must inject the **same** `canRead`, alongside the existing
233
+ shared-backend + shared-fanout requirement.
234
+ - **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
235
+ the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
236
+ control.
237
+
238
+ A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).