@fieldnotes/sync-server 0.18.0 → 0.19.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 +77 -14
- package/dist/index.cjs +219 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -2
- package/dist/index.d.ts +82 -2
- package/dist/index.js +217 -34
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -97,9 +97,11 @@ await server.close();
|
|
|
97
97
|
## Resource limits
|
|
98
98
|
|
|
99
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
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
close with code `1009`; message-rate, byte-rate and pending-auth queue violations close with code
|
|
101
|
+
`4408`; a socket over the per-address or per-room connection cap closes with `4429`. Messages with
|
|
102
|
+
excessive JSON nesting, and presence payloads over `maxPresenceBytes`, are dropped. Presence sends
|
|
103
|
+
immediately, then coalesces rapid updates so the latest state is forwarded at most once per throttle
|
|
104
|
+
interval.
|
|
103
105
|
|
|
104
106
|
```ts
|
|
105
107
|
createSyncServer({
|
|
@@ -110,15 +112,27 @@ createSyncServer({
|
|
|
110
112
|
maxPendingAuthBytes: 2 * 1024 * 1024,
|
|
111
113
|
messagesPerSecond: 120,
|
|
112
114
|
messageBurst: 240,
|
|
115
|
+
bytesPerSecond: 4 * 1024 * 1024,
|
|
116
|
+
byteBurst: 8 * 1024 * 1024,
|
|
113
117
|
presenceThrottleMs: 50,
|
|
118
|
+
maxPresenceBytes: 4 * 1024,
|
|
119
|
+
maxConnectionsPerIp: 64,
|
|
120
|
+
maxConnectionsPerRoom: 256,
|
|
121
|
+
clientAddress: (req) => req.socket.remoteAddress,
|
|
114
122
|
});
|
|
115
123
|
```
|
|
116
124
|
|
|
117
125
|
These values are the defaults. Tune them to the largest legitimate board operation and expected
|
|
118
126
|
client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
|
|
119
127
|
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
|
|
121
|
-
|
|
128
|
+
asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use token
|
|
129
|
+
buckets: `messageBurst` / `byteBurst` are the short spike allowances and `messagesPerSecond` /
|
|
130
|
+
`bytesPerSecond` the refill rates, so relay amplification is bounded in bytes, not just frames.
|
|
131
|
+
Connection caps are taken at the upgrade, before `authenticate` runs, and count pending-auth
|
|
132
|
+
sockets; behind a trusted proxy supply `clientAddress` to read the forwarded address, and return
|
|
133
|
+
`undefined` to exempt a connection from the per-address cap. Rate and burst values must be positive
|
|
134
|
+
finite numbers; connection caps must be positive safe integers, or `Infinity` to disable a cap.
|
|
135
|
+
Invalid values throw during `createSyncServer` construction.
|
|
122
136
|
|
|
123
137
|
## Authentication
|
|
124
138
|
|
|
@@ -161,11 +175,42 @@ continues to use the authenticated `userId` and `role`, not this transport ident
|
|
|
161
175
|
|
|
162
176
|
### Passing a token
|
|
163
177
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
`
|
|
178
|
+
`authenticate` receives `token`, resolved by the exported `readBearerToken(req)` in this order:
|
|
179
|
+
|
|
180
|
+
1. A `Sec-WebSocket-Protocol` entry `fieldnotes-bearer.<token>` — the browser-safe channel. A
|
|
181
|
+
browser `WebSocket` can't set request headers, but it can offer subprotocols; the relay reads
|
|
182
|
+
the token, selects the `fieldnotes-sync` subprotocol and never echoes the bearer entry. On the
|
|
183
|
+
client, `bearerSubprotocols(token)` from `@fieldnotes/sync` builds the offer:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import {
|
|
187
|
+
WebSocketTransport,
|
|
188
|
+
bearerSubprotocols,
|
|
189
|
+
createManagedSyncConnection,
|
|
190
|
+
} from '@fieldnotes/sync';
|
|
191
|
+
|
|
192
|
+
new WebSocketTransport('wss://relay?room=R', { protocols: bearerSubprotocols(token) });
|
|
193
|
+
// or, managed:
|
|
194
|
+
createManagedSyncConnection({
|
|
195
|
+
store,
|
|
196
|
+
clientId,
|
|
197
|
+
resolveUrl: async () => ({
|
|
198
|
+
url: 'wss://relay?room=R',
|
|
199
|
+
protocols: bearerSubprotocols(await mint()),
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The token must be a valid subprotocol token (JWT and base64url alphabets qualify; base64 `=`
|
|
205
|
+
padding does not).
|
|
206
|
+
|
|
207
|
+
2. An `Authorization: Bearer <token>` header, for non-browser clients.
|
|
208
|
+
3. The `token` URL query parameter (`ws://relay?room=R&token=…`). Still supported so existing
|
|
209
|
+
clients keep working, but URLs land in access/proxy logs: prefer the channels above, and use
|
|
210
|
+
**short-lived / single-use** tokens if you must stay on the URL.
|
|
211
|
+
|
|
212
|
+
`req` is still passed, so an `authenticate` hook that reads `req.url` or `req.headers` itself
|
|
213
|
+
keeps working unchanged.
|
|
169
214
|
|
|
170
215
|
## Authorization
|
|
171
216
|
|
|
@@ -198,8 +243,17 @@ authenticated creator; on edit the stored owner is **preserved**; a client-suppl
|
|
|
198
243
|
`ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
|
|
199
244
|
to enforce "own elements only".
|
|
200
245
|
|
|
246
|
+
**`ownerId` stays on the server.** It is stripped from every outbound frame (live ops,
|
|
247
|
+
snapshots, corrections, legacy translations) so a viewer's save file never records who
|
|
248
|
+
created what. Pass `canReadOwnerId({ userId, role, room }) => boolean` to reveal it to
|
|
249
|
+
privileged viewers, e.g. `canReadOwnerId: ({ role }) => role === 'dm'`.
|
|
250
|
+
|
|
201
251
|
With **no hook**, rooms are OPEN (allow-all — every op is accepted).
|
|
202
252
|
|
|
253
|
+
When `canRead` filtering is in use, `op.element.audience` is what the sender asserted unless a
|
|
254
|
+
`resolveAudience` hook is configured (see [Read filtering](#read-filtering)); an `authorize` policy
|
|
255
|
+
without that hook must validate the audience itself.
|
|
256
|
+
|
|
203
257
|
A copy-paste DM / player / display policy:
|
|
204
258
|
|
|
205
259
|
```ts
|
|
@@ -224,7 +278,8 @@ createSyncServer({
|
|
|
224
278
|
|
|
225
279
|
- Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
|
|
226
280
|
no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
|
|
227
|
-
user would lose access to their own elements after reconnecting.
|
|
281
|
+
user would lose access to their own elements after reconnecting. `createSyncServer`
|
|
282
|
+
therefore **throws** when `authorize` is configured without `authenticate`.
|
|
228
283
|
- The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
|
|
229
284
|
low-write use.
|
|
230
285
|
- Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
|
|
@@ -276,9 +331,17 @@ gets a synthetic **remove**, one who gains it gets an **add**.
|
|
|
276
331
|
instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
|
|
277
332
|
local members — so all relay instances must inject the **same** `canRead`, alongside the existing
|
|
278
333
|
shared-backend + shared-fanout requirement.
|
|
279
|
-
- **Labeling integrity.** `audience`
|
|
280
|
-
|
|
281
|
-
|
|
334
|
+
- **Labeling integrity.** `audience` arrives client-asserted. Without a `resolveAudience` hook a
|
|
335
|
+
player can tag an upsert `dm` to hide it from the table, or retag a hidden element `shared` to
|
|
336
|
+
reveal it, so **one of the two following hooks is required** for read secrecy:
|
|
337
|
+
- `resolveAudience({ userId, role, room, element, currentElement }) => string | undefined`
|
|
338
|
+
(recommended) makes the hub the authority: its return value replaces the client's tag
|
|
339
|
+
(`undefined` clears it) before `authorize`, storage and relay. A policy such as
|
|
340
|
+
`({ role, currentElement }) => currentElement?.audience ?? (role === 'dm' ? 'dm' : 'shared')`
|
|
341
|
+
keeps an element's audience stable and lets only a DM create hidden content.
|
|
342
|
+
- Otherwise `authorize` **must** enforce the audience contract itself: reject an `upsert` whose
|
|
343
|
+
`op.element.audience` the sender may not write to, and reject one whose audience differs from
|
|
344
|
+
`currentElement.audience` unless the sender may move it.
|
|
282
345
|
|
|
283
346
|
A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
|
|
284
347
|
|
package/dist/index.cjs
CHANGED
|
@@ -22,8 +22,11 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
InMemoryHubFanout: () => InMemoryHubFanout,
|
|
24
24
|
MemoryHubBackend: () => MemoryHubBackend,
|
|
25
|
+
ROOM_NAME_PATTERN: () => ROOM_NAME_PATTERN,
|
|
25
26
|
SyncHub: () => SyncHub,
|
|
26
27
|
createSyncServer: () => createSyncServer,
|
|
28
|
+
isValidRoomName: () => isValidRoomName,
|
|
29
|
+
readBearerToken: () => readBearerToken,
|
|
27
30
|
startHeartbeat: () => startHeartbeat
|
|
28
31
|
});
|
|
29
32
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -150,6 +153,11 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
|
|
|
150
153
|
var DEFAULT_MESSAGES_PER_SECOND = 120;
|
|
151
154
|
var DEFAULT_MESSAGE_BURST = 240;
|
|
152
155
|
var DEFAULT_PRESENCE_THROTTLE_MS = 50;
|
|
156
|
+
var DEFAULT_MAX_PRESENCE_BYTES = 4 * 1024;
|
|
157
|
+
var DEFAULT_BYTES_PER_SECOND = 4 * 1024 * 1024;
|
|
158
|
+
var DEFAULT_BYTE_BURST = 8 * 1024 * 1024;
|
|
159
|
+
var DEFAULT_MAX_CONNECTIONS_PER_IP = 64;
|
|
160
|
+
var DEFAULT_MAX_CONNECTIONS_PER_ROOM = 256;
|
|
153
161
|
var DEFAULT_MAX_PRESENCE_LANES = 16;
|
|
154
162
|
function hasJsonDepthAtMost(message, maxDepth) {
|
|
155
163
|
let depth = 0;
|
|
@@ -182,29 +190,30 @@ var MessageRateLimiter = class {
|
|
|
182
190
|
}
|
|
183
191
|
tokens;
|
|
184
192
|
updatedAt;
|
|
185
|
-
|
|
193
|
+
/** Charges `cost` tokens (frames or bytes); a cost above `burst` never fits. */
|
|
194
|
+
take(now = Date.now(), cost = 1) {
|
|
186
195
|
const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
|
|
187
196
|
this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
|
|
188
197
|
this.updatedAt = now;
|
|
189
|
-
if (this.tokens <
|
|
190
|
-
this.tokens -=
|
|
198
|
+
if (this.tokens < cost) return false;
|
|
199
|
+
this.tokens -= cost;
|
|
191
200
|
return true;
|
|
192
201
|
}
|
|
193
202
|
};
|
|
194
203
|
|
|
195
204
|
// src/sync-hub.ts
|
|
196
205
|
var HUB_FROM = "hub";
|
|
206
|
+
var utf8 = new TextEncoder();
|
|
207
|
+
function utf8ByteLength(text) {
|
|
208
|
+
return utf8.encode(text).byteLength;
|
|
209
|
+
}
|
|
197
210
|
function generateInstanceId() {
|
|
198
211
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
|
|
199
212
|
return crypto.randomUUID();
|
|
200
213
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
201
214
|
}
|
|
202
215
|
function isFanoutOp(op) {
|
|
203
|
-
|
|
204
|
-
const o = op;
|
|
205
|
-
if (o.kind === "upsert") return (0, import_sync2.isValidWireElement)(o.element);
|
|
206
|
-
if (o.kind === "remove") return typeof o.id === "string";
|
|
207
|
-
return o.kind === "clear";
|
|
216
|
+
return op.kind === "upsert" || op.kind === "remove" || op.kind === "clear";
|
|
208
217
|
}
|
|
209
218
|
var FALLBACK_PRESENCE_LANE = "";
|
|
210
219
|
var MAX_PRESENCE_LANE_LENGTH = 64;
|
|
@@ -217,9 +226,7 @@ function presenceLaneOf(data) {
|
|
|
217
226
|
return kind;
|
|
218
227
|
}
|
|
219
228
|
function isLayerOp(op) {
|
|
220
|
-
|
|
221
|
-
const k = op.kind;
|
|
222
|
-
return k === "layer-upsert" || k === "layer-remove";
|
|
229
|
+
return op.kind === "layer-upsert" || op.kind === "layer-remove";
|
|
223
230
|
}
|
|
224
231
|
function layerOpToRecord(op) {
|
|
225
232
|
return op.kind === "layer-upsert" ? { id: op.layer.id, version: op.version, editor: op.editor, definition: op.layer } : { id: op.id, version: op.version, editor: op.editor };
|
|
@@ -232,13 +239,22 @@ function layerRecordToOp(record) {
|
|
|
232
239
|
editor: record.editor
|
|
233
240
|
} : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
|
|
234
241
|
}
|
|
235
|
-
function
|
|
236
|
-
return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds]);
|
|
242
|
+
function encodingProfile(capabilities, revealOwner) {
|
|
243
|
+
return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds, revealOwner]);
|
|
244
|
+
}
|
|
245
|
+
function withoutOwnerId(element) {
|
|
246
|
+
if (element.ownerId === void 0) return element;
|
|
247
|
+
const rest = { ...element };
|
|
248
|
+
delete rest.ownerId;
|
|
249
|
+
return rest;
|
|
250
|
+
}
|
|
251
|
+
function stripOwnerId(op) {
|
|
252
|
+
if (op.kind === "upsert") return { ...op, element: withoutOwnerId(op.element) };
|
|
253
|
+
if (op.kind === "snapshot") return { ...op, elements: op.elements.map(withoutOwnerId) };
|
|
254
|
+
return op;
|
|
237
255
|
}
|
|
238
256
|
function isPresenceOp(op) {
|
|
239
|
-
|
|
240
|
-
const k = op.kind;
|
|
241
|
-
return k === "presence" || k === "presence-leave";
|
|
257
|
+
return op.kind === "presence" || op.kind === "presence-leave";
|
|
242
258
|
}
|
|
243
259
|
var SyncHub = class {
|
|
244
260
|
backend;
|
|
@@ -257,10 +273,13 @@ var SyncHub = class {
|
|
|
257
273
|
elementRegistry;
|
|
258
274
|
peerCapabilities = /* @__PURE__ */ new Map();
|
|
259
275
|
canRead;
|
|
276
|
+
canReadOwnerId;
|
|
277
|
+
resolveAudience;
|
|
260
278
|
memoryLayers = /* @__PURE__ */ new Map();
|
|
261
279
|
maxJsonDepth;
|
|
262
280
|
presenceThrottleMs;
|
|
263
281
|
maxPresenceLanes;
|
|
282
|
+
maxPresenceBytes;
|
|
264
283
|
/**
|
|
265
284
|
* Presence throttle state keyed by connection, then by lane. A lane is the
|
|
266
285
|
* payload's `kind` (a non-empty string of at most 64 chars) or the reserved
|
|
@@ -280,6 +299,8 @@ var SyncHub = class {
|
|
|
280
299
|
this.authorize = options.authorize;
|
|
281
300
|
this.authorizeLayer = options.authorizeLayer;
|
|
282
301
|
this.canRead = options.canRead;
|
|
302
|
+
this.canReadOwnerId = options.canReadOwnerId;
|
|
303
|
+
this.resolveAudience = options.resolveAudience;
|
|
283
304
|
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
284
305
|
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
285
306
|
const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
|
|
@@ -287,6 +308,11 @@ var SyncHub = class {
|
|
|
287
308
|
throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
|
|
288
309
|
}
|
|
289
310
|
this.maxPresenceLanes = Math.floor(maxPresenceLanes);
|
|
311
|
+
const maxPresenceBytes = options.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
|
312
|
+
if (!Number.isFinite(maxPresenceBytes) || maxPresenceBytes < 0) {
|
|
313
|
+
throw new RangeError("maxPresenceBytes must be a non-negative finite number");
|
|
314
|
+
}
|
|
315
|
+
this.maxPresenceBytes = maxPresenceBytes;
|
|
290
316
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
291
317
|
}
|
|
292
318
|
addConnection(conn) {
|
|
@@ -325,6 +351,7 @@ var SyncHub = class {
|
|
|
325
351
|
* forwards the same event to other instances on a best-effort basis.
|
|
326
352
|
*/
|
|
327
353
|
broadcastPresence(room, data) {
|
|
354
|
+
if (!this.isPresenceWithinLimit(data)) return 0;
|
|
328
355
|
const op = { kind: "presence", data };
|
|
329
356
|
const sent = this.relayToRoom(room, void 0, JSON.stringify({ from: HUB_FROM, op }));
|
|
330
357
|
this.safePublish(JSON.stringify({ o: this.instanceId, room, from: HUB_FROM, op }));
|
|
@@ -345,6 +372,7 @@ var SyncHub = class {
|
|
|
345
372
|
return Promise.resolve();
|
|
346
373
|
}
|
|
347
374
|
if (env.op.kind === "presence") {
|
|
375
|
+
if (!this.isPresenceWithinLimit(env.op.data)) return Promise.resolve();
|
|
348
376
|
this.schedulePresence(conn, env.op.data);
|
|
349
377
|
return Promise.resolve();
|
|
350
378
|
}
|
|
@@ -404,9 +432,22 @@ var SyncHub = class {
|
|
|
404
432
|
await this.deliverPluginResult(conn, result);
|
|
405
433
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
406
434
|
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
407
|
-
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
435
|
+
const needCurrent = (this.authorize || this.canRead || this.resolveAudience) && id !== void 0;
|
|
408
436
|
const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
409
437
|
const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
|
|
438
|
+
if (op.kind === "upsert" && this.resolveAudience) {
|
|
439
|
+
const audience = this.resolveAudience({
|
|
440
|
+
userId: conn.userId,
|
|
441
|
+
role: conn.role,
|
|
442
|
+
room: conn.room,
|
|
443
|
+
element: op.element,
|
|
444
|
+
currentElement: current
|
|
445
|
+
});
|
|
446
|
+
const element = { ...op.element };
|
|
447
|
+
if (audience === void 0) delete element.audience;
|
|
448
|
+
else element.audience = audience;
|
|
449
|
+
op = { kind: "upsert", element };
|
|
450
|
+
}
|
|
410
451
|
let outboundOp = op;
|
|
411
452
|
if (this.authorize) {
|
|
412
453
|
const allowed = await this.authorize({
|
|
@@ -567,6 +608,10 @@ var SyncHub = class {
|
|
|
567
608
|
}
|
|
568
609
|
map.set(record.id, record);
|
|
569
610
|
}
|
|
611
|
+
mayReadOwnerId(conn) {
|
|
612
|
+
if (!this.canReadOwnerId) return false;
|
|
613
|
+
return this.canReadOwnerId({ userId: conn.userId, role: conn.role, room: conn.room });
|
|
614
|
+
}
|
|
570
615
|
mayRead(conn, audience) {
|
|
571
616
|
if (!this.canRead) return true;
|
|
572
617
|
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
@@ -601,10 +646,11 @@ var SyncHub = class {
|
|
|
601
646
|
*/
|
|
602
647
|
sendToConnection(conn, from, op, encoded) {
|
|
603
648
|
const capabilities = this.peerCapabilities.get(conn.id) ?? (0, import_sync2.createLegacyCapabilities)();
|
|
604
|
-
const
|
|
649
|
+
const revealOwner = this.mayReadOwnerId(conn);
|
|
650
|
+
const profile = encoded ? encodingProfile(capabilities, revealOwner) : void 0;
|
|
605
651
|
let message = profile === void 0 ? void 0 : encoded?.get(profile);
|
|
606
652
|
if (message === void 0) {
|
|
607
|
-
message = this.encodeForPeer(from, op, capabilities);
|
|
653
|
+
message = this.encodeForPeer(from, op, capabilities, revealOwner);
|
|
608
654
|
if (profile !== void 0) encoded?.set(profile, message);
|
|
609
655
|
}
|
|
610
656
|
if (message === null) return false;
|
|
@@ -615,11 +661,15 @@ var SyncHub = class {
|
|
|
615
661
|
return false;
|
|
616
662
|
}
|
|
617
663
|
}
|
|
618
|
-
/**
|
|
619
|
-
|
|
664
|
+
/**
|
|
665
|
+
* Returns the wire frame for `op` translated for `capabilities`, or null
|
|
666
|
+
* when lossy. The server-stamped `ownerId` is a server-side authorization
|
|
667
|
+
* fact: it leaves the hub only for peers `canReadOwnerId` admits.
|
|
668
|
+
*/
|
|
669
|
+
encodeForPeer(from, op, capabilities, revealOwner) {
|
|
620
670
|
try {
|
|
621
671
|
const translated = (0, import_sync2.translateOpForPeer)(
|
|
622
|
-
op,
|
|
672
|
+
revealOwner ? op : stripOwnerId(op),
|
|
623
673
|
capabilities,
|
|
624
674
|
this.elementRegistry,
|
|
625
675
|
this.pluginRegistry.extensionDefinitions
|
|
@@ -799,6 +849,7 @@ var SyncHub = class {
|
|
|
799
849
|
if (correction) this.sendToConnection(conn, HUB_FROM, correction);
|
|
800
850
|
}
|
|
801
851
|
onFanout(payload) {
|
|
852
|
+
if (!hasJsonDepthAtMost(payload, this.maxJsonDepth)) return;
|
|
802
853
|
let env;
|
|
803
854
|
try {
|
|
804
855
|
env = JSON.parse(payload);
|
|
@@ -808,8 +859,11 @@ var SyncHub = class {
|
|
|
808
859
|
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
809
860
|
return;
|
|
810
861
|
if (env.o === this.instanceId) return;
|
|
811
|
-
const
|
|
862
|
+
const envelope = { from: env.from, op: env.op };
|
|
863
|
+
if (!(0, import_sync2.isValidEnvelope)(envelope)) return;
|
|
864
|
+
const op = envelope.op;
|
|
812
865
|
if (isPresenceOp(op)) {
|
|
866
|
+
if (op.kind === "presence" && !this.isPresenceWithinLimit(op.data)) return;
|
|
813
867
|
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
814
868
|
return;
|
|
815
869
|
}
|
|
@@ -819,8 +873,16 @@ var SyncHub = class {
|
|
|
819
873
|
this.relayOpToRoom(env.room, void 0, env.from, op);
|
|
820
874
|
return;
|
|
821
875
|
}
|
|
822
|
-
|
|
823
|
-
if (
|
|
876
|
+
let plugin;
|
|
877
|
+
if (op.kind === "extension") {
|
|
878
|
+
const entry = this.pluginRegistry.extension(op.extensionKind);
|
|
879
|
+
if (!entry || !entry.kind.codec.validate(op.payload)) return;
|
|
880
|
+
plugin = entry.plugin;
|
|
881
|
+
} else {
|
|
882
|
+
plugin = this.pluginRegistry.ownerOf(op.kind);
|
|
883
|
+
}
|
|
884
|
+
if (plugin) {
|
|
885
|
+
const owner = plugin;
|
|
824
886
|
const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
|
|
825
887
|
const operation = previous.then(async () => {
|
|
826
888
|
const context = {
|
|
@@ -829,7 +891,7 @@ var SyncHub = class {
|
|
|
829
891
|
backend: this.backend,
|
|
830
892
|
backendPlugin: (key) => this.backend.getService?.(key)
|
|
831
893
|
};
|
|
832
|
-
const accepted =
|
|
894
|
+
const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
|
|
833
895
|
if (accepted) {
|
|
834
896
|
this.relayOpToRoom(env.room, void 0, env.from, accepted);
|
|
835
897
|
}
|
|
@@ -851,6 +913,9 @@ var SyncHub = class {
|
|
|
851
913
|
const prevExisted = env.existed === true;
|
|
852
914
|
this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
|
|
853
915
|
}
|
|
916
|
+
isPresenceWithinLimit(data) {
|
|
917
|
+
return utf8ByteLength(JSON.stringify(data) ?? "") <= this.maxPresenceBytes;
|
|
918
|
+
}
|
|
854
919
|
async applyFanoutLayerOp(room, op) {
|
|
855
920
|
const record = layerOpToRecord(op);
|
|
856
921
|
const current = await this.getLayerRecord(room, record.id);
|
|
@@ -866,6 +931,23 @@ var SyncHub = class {
|
|
|
866
931
|
// src/create-sync-server.ts
|
|
867
932
|
var import_ws = require("ws");
|
|
868
933
|
|
|
934
|
+
// src/authenticate.ts
|
|
935
|
+
var import_sync3 = require("@fieldnotes/sync");
|
|
936
|
+
function readBearerToken(req) {
|
|
937
|
+
const fromProtocol = (0, import_sync3.readBearerSubprotocol)(headerValue(req.headers["sec-websocket-protocol"]));
|
|
938
|
+
if (fromProtocol) return fromProtocol;
|
|
939
|
+
const authorization = headerValue(req.headers["authorization"]);
|
|
940
|
+
if (authorization) {
|
|
941
|
+
const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
|
|
942
|
+
if (match?.[1]) return match[1];
|
|
943
|
+
}
|
|
944
|
+
const query = new URL(req.url ?? "", "http://localhost").searchParams.get("token");
|
|
945
|
+
return query || void 0;
|
|
946
|
+
}
|
|
947
|
+
function headerValue(value) {
|
|
948
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
949
|
+
}
|
|
950
|
+
|
|
869
951
|
// src/heartbeat.ts
|
|
870
952
|
function startHeartbeat(wss, intervalMs) {
|
|
871
953
|
if (intervalMs <= 0) {
|
|
@@ -897,6 +979,9 @@ function startHeartbeat(wss, intervalMs) {
|
|
|
897
979
|
return { track, stop: () => clearInterval(interval) };
|
|
898
980
|
}
|
|
899
981
|
|
|
982
|
+
// src/create-sync-server.ts
|
|
983
|
+
var import_sync4 = require("@fieldnotes/sync");
|
|
984
|
+
|
|
900
985
|
// src/shutdown.ts
|
|
901
986
|
var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
|
|
902
987
|
function drainWebSocketServer(wss, graceMs) {
|
|
@@ -931,16 +1016,78 @@ function drainWebSocketServer(wss, graceMs) {
|
|
|
931
1016
|
});
|
|
932
1017
|
}
|
|
933
1018
|
|
|
1019
|
+
// src/room-name.ts
|
|
1020
|
+
var ROOM_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1021
|
+
function isValidRoomName(room) {
|
|
1022
|
+
return typeof room === "string" && ROOM_NAME_PATTERN.test(room);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
934
1025
|
// src/create-sync-server.ts
|
|
1026
|
+
var ConcurrencyCounter = class {
|
|
1027
|
+
constructor(limit) {
|
|
1028
|
+
this.limit = limit;
|
|
1029
|
+
}
|
|
1030
|
+
counts = /* @__PURE__ */ new Map();
|
|
1031
|
+
/** Reserves a slot for `key`; returns a release function, or null when the cap is reached. */
|
|
1032
|
+
acquire(key) {
|
|
1033
|
+
const current = this.counts.get(key) ?? 0;
|
|
1034
|
+
if (current >= this.limit) return null;
|
|
1035
|
+
this.counts.set(key, current + 1);
|
|
1036
|
+
let released = false;
|
|
1037
|
+
return () => {
|
|
1038
|
+
if (released) return;
|
|
1039
|
+
released = true;
|
|
1040
|
+
const remaining = (this.counts.get(key) ?? 1) - 1;
|
|
1041
|
+
if (remaining <= 0) this.counts.delete(key);
|
|
1042
|
+
else this.counts.set(key, remaining);
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
935
1046
|
function rawDataByteLength(data) {
|
|
936
1047
|
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
937
1048
|
return data.byteLength;
|
|
938
1049
|
}
|
|
1050
|
+
function requirePositiveFinite(name, value) {
|
|
1051
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1052
|
+
throw new RangeError(`${name} must be a positive finite number`);
|
|
1053
|
+
}
|
|
1054
|
+
return value;
|
|
1055
|
+
}
|
|
1056
|
+
function requirePositiveIntegerOrInfinity(name, value) {
|
|
1057
|
+
if (value !== Infinity && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
1058
|
+
throw new RangeError(`${name} must be a positive safe integer or Infinity`);
|
|
1059
|
+
}
|
|
1060
|
+
return value;
|
|
1061
|
+
}
|
|
939
1062
|
function createSyncServer(options = {}) {
|
|
940
1063
|
const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
941
1064
|
if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
|
|
942
1065
|
throw new RangeError("shutdownGraceMs must be a non-negative finite number");
|
|
943
1066
|
}
|
|
1067
|
+
const messagesPerSecond = requirePositiveFinite(
|
|
1068
|
+
"messagesPerSecond",
|
|
1069
|
+
options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND
|
|
1070
|
+
);
|
|
1071
|
+
const messageBurst = requirePositiveFinite(
|
|
1072
|
+
"messageBurst",
|
|
1073
|
+
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
1074
|
+
);
|
|
1075
|
+
const bytesPerSecond = requirePositiveFinite(
|
|
1076
|
+
"bytesPerSecond",
|
|
1077
|
+
options.bytesPerSecond ?? DEFAULT_BYTES_PER_SECOND
|
|
1078
|
+
);
|
|
1079
|
+
const byteBurst = requirePositiveFinite("byteBurst", options.byteBurst ?? DEFAULT_BYTE_BURST);
|
|
1080
|
+
const maxConnectionsPerIp = requirePositiveIntegerOrInfinity(
|
|
1081
|
+
"maxConnectionsPerIp",
|
|
1082
|
+
options.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP
|
|
1083
|
+
);
|
|
1084
|
+
const maxConnectionsPerRoom = requirePositiveIntegerOrInfinity(
|
|
1085
|
+
"maxConnectionsPerRoom",
|
|
1086
|
+
options.maxConnectionsPerRoom ?? DEFAULT_MAX_CONNECTIONS_PER_ROOM
|
|
1087
|
+
);
|
|
1088
|
+
if (options.authorize && !options.authenticate) {
|
|
1089
|
+
throw new Error("createSyncServer: `authorize` requires an `authenticate` hook");
|
|
1090
|
+
}
|
|
944
1091
|
const hub = new SyncHub({
|
|
945
1092
|
backend: options.backend,
|
|
946
1093
|
fanout: options.fanout,
|
|
@@ -949,17 +1096,34 @@ function createSyncServer(options = {}) {
|
|
|
949
1096
|
authorizeLayer: options.authorizeLayer,
|
|
950
1097
|
plugins: options.plugins,
|
|
951
1098
|
canRead: options.canRead,
|
|
1099
|
+
canReadOwnerId: options.canReadOwnerId,
|
|
1100
|
+
resolveAudience: options.resolveAudience,
|
|
952
1101
|
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
953
1102
|
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
|
|
954
1103
|
maxPresenceLanes: options.maxPresenceLanes,
|
|
1104
|
+
maxPresenceBytes: options.maxPresenceBytes,
|
|
955
1105
|
elementRegistry: options.elementRegistry
|
|
956
1106
|
});
|
|
957
1107
|
const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
958
|
-
const
|
|
1108
|
+
const handleProtocols = (protocols) => {
|
|
1109
|
+
if (protocols.has(import_sync4.SYNC_WS_SUBPROTOCOL)) return import_sync4.SYNC_WS_SUBPROTOCOL;
|
|
1110
|
+
for (const protocol of protocols) {
|
|
1111
|
+
if (!protocol.startsWith(import_sync4.BEARER_SUBPROTOCOL_PREFIX)) return protocol;
|
|
1112
|
+
}
|
|
1113
|
+
return false;
|
|
1114
|
+
};
|
|
1115
|
+
const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes, handleProtocols }) : new import_ws.WebSocketServer({
|
|
1116
|
+
port: options.port ?? 0,
|
|
1117
|
+
maxPayload: maxMessageBytes,
|
|
1118
|
+
handleProtocols
|
|
1119
|
+
});
|
|
959
1120
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
960
1121
|
let shuttingDown = false;
|
|
961
1122
|
let closePromise;
|
|
962
1123
|
let counter = 0;
|
|
1124
|
+
const perIp = new ConcurrencyCounter(maxConnectionsPerIp);
|
|
1125
|
+
const perRoom = new ConcurrencyCounter(maxConnectionsPerRoom);
|
|
1126
|
+
const clientAddress = options.clientAddress ?? ((req) => req.socket.remoteAddress);
|
|
963
1127
|
wss.on("connection", (ws, req) => {
|
|
964
1128
|
if (shuttingDown) {
|
|
965
1129
|
ws.close(1001, "server shutting down");
|
|
@@ -973,6 +1137,22 @@ function createSyncServer(options = {}) {
|
|
|
973
1137
|
ws.close(4400, "room required");
|
|
974
1138
|
return;
|
|
975
1139
|
}
|
|
1140
|
+
if (!isValidRoomName(room)) {
|
|
1141
|
+
ws.close(4400, "invalid room");
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const address = clientAddress(req);
|
|
1145
|
+
const releaseIp = address ? perIp.acquire(address) : () => void 0;
|
|
1146
|
+
if (!releaseIp) {
|
|
1147
|
+
ws.close(4429, "too many connections");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const releaseRoom = perRoom.acquire(room);
|
|
1151
|
+
if (!releaseRoom) {
|
|
1152
|
+
releaseIp();
|
|
1153
|
+
ws.close(4429, "too many connections");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
976
1156
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
977
1157
|
let state = "pending";
|
|
978
1158
|
let closed = false;
|
|
@@ -981,10 +1161,8 @@ function createSyncServer(options = {}) {
|
|
|
981
1161
|
let queuedBytes = 0;
|
|
982
1162
|
const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
|
|
983
1163
|
const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
|
|
984
|
-
const limiter = new MessageRateLimiter(
|
|
985
|
-
|
|
986
|
-
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
987
|
-
);
|
|
1164
|
+
const limiter = new MessageRateLimiter(messagesPerSecond, messageBurst);
|
|
1165
|
+
const byteLimiter = new MessageRateLimiter(bytesPerSecond, byteBurst);
|
|
988
1166
|
const send = (m) => {
|
|
989
1167
|
try {
|
|
990
1168
|
ws.send(m);
|
|
@@ -999,7 +1177,8 @@ function createSyncServer(options = {}) {
|
|
|
999
1177
|
ws.close(1009, "message too large");
|
|
1000
1178
|
return;
|
|
1001
1179
|
}
|
|
1002
|
-
|
|
1180
|
+
const now = Date.now();
|
|
1181
|
+
if (!limiter.take(now) || !byteLimiter.take(now, messageBytes)) {
|
|
1003
1182
|
state = "rejected";
|
|
1004
1183
|
ws.close(4408, "rate limit exceeded");
|
|
1005
1184
|
return;
|
|
@@ -1019,9 +1198,13 @@ function createSyncServer(options = {}) {
|
|
|
1019
1198
|
});
|
|
1020
1199
|
ws.on("close", () => {
|
|
1021
1200
|
closed = true;
|
|
1201
|
+
releaseIp();
|
|
1202
|
+
releaseRoom();
|
|
1022
1203
|
if (admitted) hub.removeConnection(connId);
|
|
1023
1204
|
});
|
|
1024
|
-
Promise.resolve(
|
|
1205
|
+
Promise.resolve(
|
|
1206
|
+
options.authenticate ? options.authenticate({ req, room, token: readBearerToken(req) }) : { userId: connId }
|
|
1207
|
+
).then((result) => {
|
|
1025
1208
|
if (closed || state === "rejected" || shuttingDown) return;
|
|
1026
1209
|
if (!result) {
|
|
1027
1210
|
state = "rejected";
|
|
@@ -1062,8 +1245,11 @@ function createSyncServer(options = {}) {
|
|
|
1062
1245
|
0 && (module.exports = {
|
|
1063
1246
|
InMemoryHubFanout,
|
|
1064
1247
|
MemoryHubBackend,
|
|
1248
|
+
ROOM_NAME_PATTERN,
|
|
1065
1249
|
SyncHub,
|
|
1066
1250
|
createSyncServer,
|
|
1251
|
+
isValidRoomName,
|
|
1252
|
+
readBearerToken,
|
|
1067
1253
|
startHeartbeat
|
|
1068
1254
|
});
|
|
1069
1255
|
//# sourceMappingURL=index.cjs.map
|