@fieldnotes/sync-server 0.16.0 → 0.17.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 +291 -283
- package/dist/index.cjs +145 -180
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -69
- package/dist/index.d.ts +43 -69
- package/dist/index.js +147 -186
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -1,283 +1,291 @@
|
|
|
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
|
-
`HubFanout.publish()` may return a promise. `SyncHub` awaits durable mutation publication before
|
|
33
|
-
notifying other local connections, and `handleMessage()` rejects if publication fails after the
|
|
34
|
-
backend commit. Custom server integrations must observe that rejection; `createSyncServer` reports
|
|
35
|
-
it through its relay error path. The room queue recovers so later messages can continue. Presence
|
|
36
|
-
publication remains best-effort because presence is ephemeral.
|
|
37
|
-
|
|
38
|
-
## Usage
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
42
|
-
|
|
43
|
-
const { close } = createSyncServer({ port: 8080 });
|
|
44
|
-
// ws://localhost:8080?room=my-room
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
## Server-originated presence
|
|
48
|
-
|
|
49
|
-
Trusted server integrations can broadcast ephemeral data without accessing the hub's connection
|
|
50
|
-
registries:
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
const { hub } = createSyncServer({ port: 8080, fanout });
|
|
54
|
-
|
|
55
|
-
const localDeliveries = hub.broadcastPresence('my-room', {
|
|
56
|
-
kind: 'poke',
|
|
57
|
-
feature: 'initiative',
|
|
58
|
-
});
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
Recipients receive the existing presence envelope with the server-owned identity
|
|
62
|
-
`{ from: 'hub', op: { kind: 'presence', data } }`. The event is delivered to every local room
|
|
63
|
-
member and published to the configured `HubFanout`, so other relay instances deliver it to their
|
|
64
|
-
local members too. The return value counts successful sends on the originating hub only; it is not a
|
|
65
|
-
cluster-wide acknowledgement.
|
|
66
|
-
|
|
67
|
-
Server presence is best-effort, is not persisted, does not enter a room's durable operation queue,
|
|
68
|
-
and is not filtered by `authorize` or `canRead`. Validate and authorize application requests before
|
|
69
|
-
calling this trusted-host API. Data must be JSON-serializable; invalid data throws before delivery.
|
|
70
|
-
The method constructs the presence operation itself and does not accept a raw sync operation or
|
|
71
|
-
caller-controlled sender identity.
|
|
72
|
-
|
|
73
|
-
## Heartbeat
|
|
74
|
-
|
|
75
|
-
The server pings every client on an interval and **terminates** any that miss a pong,
|
|
76
|
-
so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
|
|
77
|
-
leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
|
|
78
|
-
disables):
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Browsers auto-pong at the protocol level, so **no client change** is needed.
|
|
85
|
-
|
|
86
|
-
## Graceful shutdown
|
|
87
|
-
|
|
88
|
-
`close()` stops accepting connections, closes active clients with code `1001`, and waits up to
|
|
89
|
-
`shutdownGraceMs` (default `5000`) before terminating clients that have not completed the close
|
|
90
|
-
handshake. Repeated calls return the same promise, so shutdown hooks can safely converge on it.
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
const server = createSyncServer({ port: 8080, shutdownGraceMs: 5000 });
|
|
94
|
-
await server.close();
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
## Resource limits
|
|
98
|
-
|
|
99
|
-
The reference server bounds work and memory per connection by default. Oversized WebSocket messages
|
|
100
|
-
close with code `1009`; message-rate and pending-auth queue violations close with code `4408`.
|
|
101
|
-
Messages with excessive JSON nesting are dropped. Presence sends immediately, then coalesces rapid
|
|
102
|
-
updates so the latest state is forwarded at most once per throttle interval.
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
createSyncServer({
|
|
106
|
-
port: 8080,
|
|
107
|
-
maxMessageBytes: 1024 * 1024,
|
|
108
|
-
maxJsonDepth: 32,
|
|
109
|
-
maxPendingAuthMessages: 100,
|
|
110
|
-
maxPendingAuthBytes: 2 * 1024 * 1024,
|
|
111
|
-
messagesPerSecond: 120,
|
|
112
|
-
messageBurst: 240,
|
|
113
|
-
presenceThrottleMs: 50,
|
|
114
|
-
});
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
These values are the defaults. Tune them to the largest legitimate board operation and expected
|
|
118
|
-
client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
|
|
119
|
-
is allocated, including fragmented messages. The pending-auth limits bound messages held while an
|
|
120
|
-
asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use a token
|
|
121
|
-
bucket: `messageBurst` is the short spike allowance and `messagesPerSecond` is the refill rate.
|
|
122
|
-
|
|
123
|
-
## Authentication
|
|
124
|
-
|
|
125
|
-
Pass an `authenticate` hook to gate connections:
|
|
126
|
-
|
|
127
|
-
```ts
|
|
128
|
-
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
129
|
-
|
|
130
|
-
createSyncServer({
|
|
131
|
-
port: 8080,
|
|
132
|
-
authenticate: async ({ req, room }) => {
|
|
133
|
-
const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
|
|
134
|
-
const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
|
|
135
|
-
return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
|
|
136
|
-
},
|
|
137
|
-
});
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
`authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
|
|
141
|
-
may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
|
|
142
|
-
socket is closed with WS code `4401` and the connection is never admitted to the room —
|
|
143
|
-
no membership, no snapshot. A resolved result admits the connection carrying its
|
|
144
|
-
`userId` and optional `role`.
|
|
145
|
-
|
|
146
|
-
With **no hook**, rooms stay open: every connection is admitted anonymously with
|
|
147
|
-
`userId = connId`. `role` is captured now and enforced in an upcoming release
|
|
148
|
-
(role-based authorization and per-viewer visibility filtering).
|
|
149
|
-
|
|
150
|
-
Messages that arrive during an async auth (notably the client's initial
|
|
151
|
-
`request-snapshot`) are queued and replayed once the connection is admitted, so the
|
|
152
|
-
first snapshot is never lost to the auth round-trip.
|
|
153
|
-
|
|
154
|
-
### Relay identity
|
|
155
|
-
|
|
156
|
-
The relay assigns every admitted socket an opaque connection identity. That server-owned identity
|
|
157
|
-
is used as `from` on all relayed mutations, presence updates, cross-instance fan-out, and disconnect
|
|
158
|
-
leave events; the client-controlled envelope `from` cannot impersonate another connection. Presence
|
|
159
|
-
identity is intentionally connection-scoped and changes after reconnecting. Application authorization
|
|
160
|
-
continues to use the authenticated `userId` and `role`, not this transport identity.
|
|
161
|
-
|
|
162
|
-
### Passing a token
|
|
163
|
-
|
|
164
|
-
A browser `WebSocket` can't set request headers, so pass the token as a URL query
|
|
165
|
-
param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
|
|
166
|
-
land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
|
|
167
|
-
clients can instead put the token in `req.headers` (e.g. `Authorization`), which
|
|
168
|
-
`authenticate` reads directly.
|
|
169
|
-
|
|
170
|
-
## Authorization
|
|
171
|
-
|
|
172
|
-
Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
|
|
173
|
-
op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
|
|
174
|
-
to room state and not forwarded to any other connection:
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
authorize(ctx) => boolean | Promise<boolean>
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
`ctx` is an `AuthorizeContext`:
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
{
|
|
184
|
-
userId?: string; // the connection's authenticated user (from authenticate)
|
|
185
|
-
role?: string; // the connection's role (from authenticate)
|
|
186
|
-
room: string;
|
|
187
|
-
op: SyncOp; // the incoming upsert / remove / clear
|
|
188
|
-
currentElement?: OwnedElement; // the STORED element, if this id already exists
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
`currentElement` is the element currently in room state for an `upsert`/`remove` of an
|
|
193
|
-
**existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
|
|
194
|
-
`undefined` for a new/absent id.
|
|
195
|
-
|
|
196
|
-
**Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
|
|
197
|
-
authenticated creator; on edit the stored owner is **preserved**; a client-supplied
|
|
198
|
-
`ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
|
|
199
|
-
to enforce "own elements only".
|
|
200
|
-
|
|
201
|
-
With **no hook**, rooms are OPEN (allow-all — every op is accepted).
|
|
202
|
-
|
|
203
|
-
A copy-paste DM / player / display policy:
|
|
204
|
-
|
|
205
|
-
```ts
|
|
206
|
-
createSyncServer({
|
|
207
|
-
port: 8080,
|
|
208
|
-
authenticate: /* … supplies userId + role … */,
|
|
209
|
-
authorize: ({ role, op, currentElement, userId }) => {
|
|
210
|
-
if (role === 'dm') return true;
|
|
211
|
-
if (role === 'display') return false; // read-only monitor
|
|
212
|
-
if (role === 'player') { // own elements only, never destructive
|
|
213
|
-
if (op.kind === 'clear') return false;
|
|
214
|
-
if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
|
|
215
|
-
if (op.kind === 'remove') return currentElement?.ownerId === userId;
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
return false;
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
**Important:**
|
|
224
|
-
|
|
225
|
-
- Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
|
|
226
|
-
no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
|
|
227
|
-
user would lose access to their own elements after reconnecting.
|
|
228
|
-
- The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
|
|
229
|
-
low-write use.
|
|
230
|
-
- Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
|
|
231
|
-
concern (D3). `authorize` gates writes only.
|
|
232
|
-
- When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
|
|
233
|
-
optimistic local edit self-corrects immediately — no client change, no waiting for reconnect:
|
|
234
|
-
a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
|
|
235
|
-
stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
|
|
236
|
-
kinds and is sent only to the offending connection. When `canRead` is configured, corrections use
|
|
237
|
-
the same viewer filter: unreadable canonical upserts become byte-free removes and corrective
|
|
238
|
-
snapshots omit unreadable elements.
|
|
239
|
-
|
|
240
|
-
## Read filtering
|
|
241
|
-
|
|
242
|
-
Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
|
|
243
|
-
hiding, the hub filters ops before they leave the server, so hidden elements never reach an
|
|
244
|
-
unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
|
|
245
|
-
|
|
246
|
-
```ts
|
|
247
|
-
canRead({ userId, role, room, audience }) => boolean
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
`audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
|
|
251
|
-
`resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
|
|
252
|
-
everyone sees everything.
|
|
253
|
-
|
|
254
|
-
A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
|
|
255
|
-
|
|
256
|
-
```ts
|
|
257
|
-
// relay
|
|
258
|
-
createSyncServer({
|
|
259
|
-
port: 8080,
|
|
260
|
-
authenticate: /* … supplies userId + role … */,
|
|
261
|
-
canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
// client (@fieldnotes/sync)
|
|
265
|
-
new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
|
|
269
|
-
gets a synthetic **remove**, one who gains it gets an **add**.
|
|
270
|
-
|
|
271
|
-
**Preconditions:**
|
|
272
|
-
|
|
273
|
-
- **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
|
|
274
|
-
no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
|
|
275
|
-
- **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
|
|
276
|
-
instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
|
|
277
|
-
local members — so all relay instances must inject the **same** `canRead`, alongside the existing
|
|
278
|
-
shared-backend + shared-fanout requirement.
|
|
279
|
-
- **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
|
|
280
|
-
the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
|
|
281
|
-
control.
|
|
282
|
-
|
|
283
|
-
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
|
+
`HubFanout.publish()` may return a promise. `SyncHub` awaits durable mutation publication before
|
|
33
|
+
notifying other local connections, and `handleMessage()` rejects if publication fails after the
|
|
34
|
+
backend commit. Custom server integrations must observe that rejection; `createSyncServer` reports
|
|
35
|
+
it through its relay error path. The room queue recovers so later messages can continue. Presence
|
|
36
|
+
publication remains best-effort because presence is ephemeral.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
42
|
+
|
|
43
|
+
const { close } = createSyncServer({ port: 8080 });
|
|
44
|
+
// ws://localhost:8080?room=my-room
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Server-originated presence
|
|
48
|
+
|
|
49
|
+
Trusted server integrations can broadcast ephemeral data without accessing the hub's connection
|
|
50
|
+
registries:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const { hub } = createSyncServer({ port: 8080, fanout });
|
|
54
|
+
|
|
55
|
+
const localDeliveries = hub.broadcastPresence('my-room', {
|
|
56
|
+
kind: 'poke',
|
|
57
|
+
feature: 'initiative',
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Recipients receive the existing presence envelope with the server-owned identity
|
|
62
|
+
`{ from: 'hub', op: { kind: 'presence', data } }`. The event is delivered to every local room
|
|
63
|
+
member and published to the configured `HubFanout`, so other relay instances deliver it to their
|
|
64
|
+
local members too. The return value counts successful sends on the originating hub only; it is not a
|
|
65
|
+
cluster-wide acknowledgement.
|
|
66
|
+
|
|
67
|
+
Server presence is best-effort, is not persisted, does not enter a room's durable operation queue,
|
|
68
|
+
and is not filtered by `authorize` or `canRead`. Validate and authorize application requests before
|
|
69
|
+
calling this trusted-host API. Data must be JSON-serializable; invalid data throws before delivery.
|
|
70
|
+
The method constructs the presence operation itself and does not accept a raw sync operation or
|
|
71
|
+
caller-controlled sender identity.
|
|
72
|
+
|
|
73
|
+
## Heartbeat
|
|
74
|
+
|
|
75
|
+
The server pings every client on an interval and **terminates** any that miss a pong,
|
|
76
|
+
so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
|
|
77
|
+
leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
|
|
78
|
+
disables):
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Browsers auto-pong at the protocol level, so **no client change** is needed.
|
|
85
|
+
|
|
86
|
+
## Graceful shutdown
|
|
87
|
+
|
|
88
|
+
`close()` stops accepting connections, closes active clients with code `1001`, and waits up to
|
|
89
|
+
`shutdownGraceMs` (default `5000`) before terminating clients that have not completed the close
|
|
90
|
+
handshake. Repeated calls return the same promise, so shutdown hooks can safely converge on it.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const server = createSyncServer({ port: 8080, shutdownGraceMs: 5000 });
|
|
94
|
+
await server.close();
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Resource limits
|
|
98
|
+
|
|
99
|
+
The reference server bounds work and memory per connection by default. Oversized WebSocket messages
|
|
100
|
+
close with code `1009`; message-rate and pending-auth queue violations close with code `4408`.
|
|
101
|
+
Messages with excessive JSON nesting are dropped. Presence sends immediately, then coalesces rapid
|
|
102
|
+
updates so the latest state is forwarded at most once per throttle interval.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
createSyncServer({
|
|
106
|
+
port: 8080,
|
|
107
|
+
maxMessageBytes: 1024 * 1024,
|
|
108
|
+
maxJsonDepth: 32,
|
|
109
|
+
maxPendingAuthMessages: 100,
|
|
110
|
+
maxPendingAuthBytes: 2 * 1024 * 1024,
|
|
111
|
+
messagesPerSecond: 120,
|
|
112
|
+
messageBurst: 240,
|
|
113
|
+
presenceThrottleMs: 50,
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
These values are the defaults. Tune them to the largest legitimate board operation and expected
|
|
118
|
+
client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
|
|
119
|
+
is allocated, including fragmented messages. The pending-auth limits bound messages held while an
|
|
120
|
+
asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use a token
|
|
121
|
+
bucket: `messageBurst` is the short spike allowance and `messagesPerSecond` is the refill rate.
|
|
122
|
+
|
|
123
|
+
## Authentication
|
|
124
|
+
|
|
125
|
+
Pass an `authenticate` hook to gate connections:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
129
|
+
|
|
130
|
+
createSyncServer({
|
|
131
|
+
port: 8080,
|
|
132
|
+
authenticate: async ({ req, room }) => {
|
|
133
|
+
const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
|
|
134
|
+
const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
|
|
135
|
+
return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
|
|
141
|
+
may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
|
|
142
|
+
socket is closed with WS code `4401` and the connection is never admitted to the room —
|
|
143
|
+
no membership, no snapshot. A resolved result admits the connection carrying its
|
|
144
|
+
`userId` and optional `role`.
|
|
145
|
+
|
|
146
|
+
With **no hook**, rooms stay open: every connection is admitted anonymously with
|
|
147
|
+
`userId = connId`. `role` is captured now and enforced in an upcoming release
|
|
148
|
+
(role-based authorization and per-viewer visibility filtering).
|
|
149
|
+
|
|
150
|
+
Messages that arrive during an async auth (notably the client's initial
|
|
151
|
+
`request-snapshot`) are queued and replayed once the connection is admitted, so the
|
|
152
|
+
first snapshot is never lost to the auth round-trip.
|
|
153
|
+
|
|
154
|
+
### Relay identity
|
|
155
|
+
|
|
156
|
+
The relay assigns every admitted socket an opaque connection identity. That server-owned identity
|
|
157
|
+
is used as `from` on all relayed mutations, presence updates, cross-instance fan-out, and disconnect
|
|
158
|
+
leave events; the client-controlled envelope `from` cannot impersonate another connection. Presence
|
|
159
|
+
identity is intentionally connection-scoped and changes after reconnecting. Application authorization
|
|
160
|
+
continues to use the authenticated `userId` and `role`, not this transport identity.
|
|
161
|
+
|
|
162
|
+
### Passing a token
|
|
163
|
+
|
|
164
|
+
A browser `WebSocket` can't set request headers, so pass the token as a URL query
|
|
165
|
+
param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
|
|
166
|
+
land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
|
|
167
|
+
clients can instead put the token in `req.headers` (e.g. `Authorization`), which
|
|
168
|
+
`authenticate` reads directly.
|
|
169
|
+
|
|
170
|
+
## Authorization
|
|
171
|
+
|
|
172
|
+
Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
|
|
173
|
+
op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
|
|
174
|
+
to room state and not forwarded to any other connection:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
authorize(ctx) => boolean | Promise<boolean>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`ctx` is an `AuthorizeContext`:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
{
|
|
184
|
+
userId?: string; // the connection's authenticated user (from authenticate)
|
|
185
|
+
role?: string; // the connection's role (from authenticate)
|
|
186
|
+
room: string;
|
|
187
|
+
op: SyncOp; // the incoming upsert / remove / clear
|
|
188
|
+
currentElement?: OwnedElement; // the STORED element, if this id already exists
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`currentElement` is the element currently in room state for an `upsert`/`remove` of an
|
|
193
|
+
**existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
|
|
194
|
+
`undefined` for a new/absent id.
|
|
195
|
+
|
|
196
|
+
**Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
|
|
197
|
+
authenticated creator; on edit the stored owner is **preserved**; a client-supplied
|
|
198
|
+
`ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
|
|
199
|
+
to enforce "own elements only".
|
|
200
|
+
|
|
201
|
+
With **no hook**, rooms are OPEN (allow-all — every op is accepted).
|
|
202
|
+
|
|
203
|
+
A copy-paste DM / player / display policy:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
createSyncServer({
|
|
207
|
+
port: 8080,
|
|
208
|
+
authenticate: /* … supplies userId + role … */,
|
|
209
|
+
authorize: ({ role, op, currentElement, userId }) => {
|
|
210
|
+
if (role === 'dm') return true;
|
|
211
|
+
if (role === 'display') return false; // read-only monitor
|
|
212
|
+
if (role === 'player') { // own elements only, never destructive
|
|
213
|
+
if (op.kind === 'clear') return false;
|
|
214
|
+
if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
|
|
215
|
+
if (op.kind === 'remove') return currentElement?.ownerId === userId;
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Important:**
|
|
224
|
+
|
|
225
|
+
- Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
|
|
226
|
+
no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
|
|
227
|
+
user would lose access to their own elements after reconnecting.
|
|
228
|
+
- The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
|
|
229
|
+
low-write use.
|
|
230
|
+
- Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
|
|
231
|
+
concern (D3). `authorize` gates writes only.
|
|
232
|
+
- When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
|
|
233
|
+
optimistic local edit self-corrects immediately — no client change, no waiting for reconnect:
|
|
234
|
+
a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
|
|
235
|
+
stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
|
|
236
|
+
kinds and is sent only to the offending connection. When `canRead` is configured, corrections use
|
|
237
|
+
the same viewer filter: unreadable canonical upserts become byte-free removes and corrective
|
|
238
|
+
snapshots omit unreadable elements.
|
|
239
|
+
|
|
240
|
+
## Read filtering
|
|
241
|
+
|
|
242
|
+
Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
|
|
243
|
+
hiding, the hub filters ops before they leave the server, so hidden elements never reach an
|
|
244
|
+
unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
canRead({ userId, role, room, audience }) => boolean
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
|
|
251
|
+
`resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
|
|
252
|
+
everyone sees everything.
|
|
253
|
+
|
|
254
|
+
A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
// relay
|
|
258
|
+
createSyncServer({
|
|
259
|
+
port: 8080,
|
|
260
|
+
authenticate: /* … supplies userId + role … */,
|
|
261
|
+
canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// client (@fieldnotes/sync)
|
|
265
|
+
new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
|
|
269
|
+
gets a synthetic **remove**, one who gains it gets an **add**.
|
|
270
|
+
|
|
271
|
+
**Preconditions:**
|
|
272
|
+
|
|
273
|
+
- **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
|
|
274
|
+
no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
|
|
275
|
+
- **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
|
|
276
|
+
instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
|
|
277
|
+
local members — so all relay instances must inject the **same** `canRead`, alongside the existing
|
|
278
|
+
shared-backend + shared-fanout requirement.
|
|
279
|
+
- **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
|
|
280
|
+
the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
|
|
281
|
+
control.
|
|
282
|
+
|
|
283
|
+
A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
|
|
284
|
+
|
|
285
|
+
## Domain plugins
|
|
286
|
+
|
|
287
|
+
`SyncHubOptions.plugins` installs ordered `ServerSyncPlugin` middleware. Plugins can own legacy v3
|
|
288
|
+
kinds, register codec-validated extension kinds, provide versioned snapshots, return sender-only
|
|
289
|
+
corrections, and choose local or shared fanout per accepted operation. Fog authorization and state
|
|
290
|
+
application are supplied by `createFogServerPlugin()` from `@fieldnotes/vtt/server`; the generic
|
|
291
|
+
server has no runtime VTT dependency.
|