@fieldnotes/sync-server 0.17.1 → 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 +79 -16
- package/dist/index.cjs +353 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +126 -21
- package/dist/index.d.ts +126 -21
- package/dist/index.js +358 -65
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -22,14 +22,18 @@ 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);
|
|
30
33
|
|
|
31
34
|
// src/sync-hub.ts
|
|
32
35
|
var import_sync2 = require("@fieldnotes/sync");
|
|
36
|
+
var import_core = require("@fieldnotes/core");
|
|
33
37
|
|
|
34
38
|
// src/memory-hub-backend.ts
|
|
35
39
|
var import_sync = require("@fieldnotes/sync");
|
|
@@ -98,8 +102,10 @@ var ServerPluginRegistry = class {
|
|
|
98
102
|
byName = /* @__PURE__ */ new Map();
|
|
99
103
|
legacyOwners = /* @__PURE__ */ new Map();
|
|
100
104
|
extensions = /* @__PURE__ */ new Map();
|
|
105
|
+
definitions;
|
|
101
106
|
constructor(plugins) {
|
|
102
107
|
for (const plugin of plugins) this.register(plugin);
|
|
108
|
+
this.definitions = new Map([...this.extensions].map(([name, entry]) => [name, entry.kind]));
|
|
103
109
|
}
|
|
104
110
|
register(plugin) {
|
|
105
111
|
if (this.byName.has(plugin.name))
|
|
@@ -131,6 +137,12 @@ var ServerPluginRegistry = class {
|
|
|
131
137
|
extension(extensionKind) {
|
|
132
138
|
return this.extensions.get(extensionKind);
|
|
133
139
|
}
|
|
140
|
+
get extensionKinds() {
|
|
141
|
+
return [...this.extensions.keys()];
|
|
142
|
+
}
|
|
143
|
+
get extensionDefinitions() {
|
|
144
|
+
return this.definitions;
|
|
145
|
+
}
|
|
134
146
|
};
|
|
135
147
|
|
|
136
148
|
// src/resource-limits.ts
|
|
@@ -141,6 +153,11 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
|
|
|
141
153
|
var DEFAULT_MESSAGES_PER_SECOND = 120;
|
|
142
154
|
var DEFAULT_MESSAGE_BURST = 240;
|
|
143
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;
|
|
144
161
|
var DEFAULT_MAX_PRESENCE_LANES = 16;
|
|
145
162
|
function hasJsonDepthAtMost(message, maxDepth) {
|
|
146
163
|
let depth = 0;
|
|
@@ -173,29 +190,30 @@ var MessageRateLimiter = class {
|
|
|
173
190
|
}
|
|
174
191
|
tokens;
|
|
175
192
|
updatedAt;
|
|
176
|
-
|
|
193
|
+
/** Charges `cost` tokens (frames or bytes); a cost above `burst` never fits. */
|
|
194
|
+
take(now = Date.now(), cost = 1) {
|
|
177
195
|
const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
|
|
178
196
|
this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
|
|
179
197
|
this.updatedAt = now;
|
|
180
|
-
if (this.tokens <
|
|
181
|
-
this.tokens -=
|
|
198
|
+
if (this.tokens < cost) return false;
|
|
199
|
+
this.tokens -= cost;
|
|
182
200
|
return true;
|
|
183
201
|
}
|
|
184
202
|
};
|
|
185
203
|
|
|
186
204
|
// src/sync-hub.ts
|
|
187
205
|
var HUB_FROM = "hub";
|
|
206
|
+
var utf8 = new TextEncoder();
|
|
207
|
+
function utf8ByteLength(text) {
|
|
208
|
+
return utf8.encode(text).byteLength;
|
|
209
|
+
}
|
|
188
210
|
function generateInstanceId() {
|
|
189
211
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
|
|
190
212
|
return crypto.randomUUID();
|
|
191
213
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
192
214
|
}
|
|
193
215
|
function isFanoutOp(op) {
|
|
194
|
-
|
|
195
|
-
const o = op;
|
|
196
|
-
if (o.kind === "upsert") return (0, import_sync2.isValidElement)(o.element);
|
|
197
|
-
if (o.kind === "remove") return typeof o.id === "string";
|
|
198
|
-
return o.kind === "clear";
|
|
216
|
+
return op.kind === "upsert" || op.kind === "remove" || op.kind === "clear";
|
|
199
217
|
}
|
|
200
218
|
var FALLBACK_PRESENCE_LANE = "";
|
|
201
219
|
var MAX_PRESENCE_LANE_LENGTH = 64;
|
|
@@ -208,9 +226,7 @@ function presenceLaneOf(data) {
|
|
|
208
226
|
return kind;
|
|
209
227
|
}
|
|
210
228
|
function isLayerOp(op) {
|
|
211
|
-
|
|
212
|
-
const k = op.kind;
|
|
213
|
-
return k === "layer-upsert" || k === "layer-remove";
|
|
229
|
+
return op.kind === "layer-upsert" || op.kind === "layer-remove";
|
|
214
230
|
}
|
|
215
231
|
function layerOpToRecord(op) {
|
|
216
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 };
|
|
@@ -223,10 +239,22 @@ function layerRecordToOp(record) {
|
|
|
223
239
|
editor: record.editor
|
|
224
240
|
} : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
|
|
225
241
|
}
|
|
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;
|
|
255
|
+
}
|
|
226
256
|
function isPresenceOp(op) {
|
|
227
|
-
|
|
228
|
-
const k = op.kind;
|
|
229
|
-
return k === "presence" || k === "presence-leave";
|
|
257
|
+
return op.kind === "presence" || op.kind === "presence-leave";
|
|
230
258
|
}
|
|
231
259
|
var SyncHub = class {
|
|
232
260
|
backend;
|
|
@@ -242,11 +270,16 @@ var SyncHub = class {
|
|
|
242
270
|
authorize;
|
|
243
271
|
authorizeLayer;
|
|
244
272
|
pluginRegistry;
|
|
273
|
+
elementRegistry;
|
|
274
|
+
peerCapabilities = /* @__PURE__ */ new Map();
|
|
245
275
|
canRead;
|
|
276
|
+
canReadOwnerId;
|
|
277
|
+
resolveAudience;
|
|
246
278
|
memoryLayers = /* @__PURE__ */ new Map();
|
|
247
279
|
maxJsonDepth;
|
|
248
280
|
presenceThrottleMs;
|
|
249
281
|
maxPresenceLanes;
|
|
282
|
+
maxPresenceBytes;
|
|
250
283
|
/**
|
|
251
284
|
* Presence throttle state keyed by connection, then by lane. A lane is the
|
|
252
285
|
* payload's `kind` (a non-empty string of at most 64 chars) or the reserved
|
|
@@ -260,11 +293,14 @@ var SyncHub = class {
|
|
|
260
293
|
constructor(options = {}) {
|
|
261
294
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
262
295
|
this.pluginRegistry = new ServerPluginRegistry(options.plugins ?? []);
|
|
296
|
+
this.elementRegistry = options.elementRegistry ?? (0, import_core.getDefaultElementRegistry)();
|
|
263
297
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
264
298
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
265
299
|
this.authorize = options.authorize;
|
|
266
300
|
this.authorizeLayer = options.authorizeLayer;
|
|
267
301
|
this.canRead = options.canRead;
|
|
302
|
+
this.canReadOwnerId = options.canReadOwnerId;
|
|
303
|
+
this.resolveAudience = options.resolveAudience;
|
|
268
304
|
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
269
305
|
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
270
306
|
const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
|
|
@@ -272,6 +308,11 @@ var SyncHub = class {
|
|
|
272
308
|
throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
|
|
273
309
|
}
|
|
274
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;
|
|
275
316
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
276
317
|
}
|
|
277
318
|
addConnection(conn) {
|
|
@@ -287,6 +328,7 @@ var SyncHub = class {
|
|
|
287
328
|
const conn = this.conns.get(connId);
|
|
288
329
|
if (!conn) return;
|
|
289
330
|
this.conns.delete(connId);
|
|
331
|
+
this.peerCapabilities.delete(connId);
|
|
290
332
|
const room = conn.room;
|
|
291
333
|
const hadPresence = this.presenceConnections.delete(connId);
|
|
292
334
|
this.clearPresenceLanes(connId);
|
|
@@ -309,6 +351,7 @@ var SyncHub = class {
|
|
|
309
351
|
* forwards the same event to other instances on a best-effort basis.
|
|
310
352
|
*/
|
|
311
353
|
broadcastPresence(room, data) {
|
|
354
|
+
if (!this.isPresenceWithinLimit(data)) return 0;
|
|
312
355
|
const op = { kind: "presence", data };
|
|
313
356
|
const sent = this.relayToRoom(room, void 0, JSON.stringify({ from: HUB_FROM, op }));
|
|
314
357
|
this.safePublish(JSON.stringify({ o: this.instanceId, room, from: HUB_FROM, op }));
|
|
@@ -320,7 +363,16 @@ var SyncHub = class {
|
|
|
320
363
|
if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();
|
|
321
364
|
const env = (0, import_sync2.parseEnvelope)(message);
|
|
322
365
|
if (!env) return Promise.resolve();
|
|
366
|
+
if (env.op.kind === "capabilities") {
|
|
367
|
+
this.peerCapabilities.set(conn.id, env.op.capabilities);
|
|
368
|
+
this.sendToConnection(conn, HUB_FROM, {
|
|
369
|
+
kind: "capabilities",
|
|
370
|
+
capabilities: (0, import_sync2.createCurrentCapabilities)(this.pluginRegistry.extensionKinds)
|
|
371
|
+
});
|
|
372
|
+
return Promise.resolve();
|
|
373
|
+
}
|
|
323
374
|
if (env.op.kind === "presence") {
|
|
375
|
+
if (!this.isPresenceWithinLimit(env.op.data)) return Promise.resolve();
|
|
324
376
|
this.schedulePresence(conn, env.op.data);
|
|
325
377
|
return Promise.resolve();
|
|
326
378
|
}
|
|
@@ -335,9 +387,14 @@ var SyncHub = class {
|
|
|
335
387
|
return operation;
|
|
336
388
|
}
|
|
337
389
|
async process(conn, env) {
|
|
338
|
-
|
|
390
|
+
let op = env.op;
|
|
391
|
+
if (op.kind === "upsert") {
|
|
392
|
+
const element = this.normalizeElement(op.element);
|
|
393
|
+
if (!element) return;
|
|
394
|
+
op = { ...op, element };
|
|
395
|
+
}
|
|
339
396
|
if (op.kind === "request-snapshot") {
|
|
340
|
-
const all = await this.backend.snapshot(conn.room);
|
|
397
|
+
const all = this.normalizeElements(await this.backend.snapshot(conn.room));
|
|
341
398
|
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
342
399
|
const layers = await this.getLayerRecords(conn.room);
|
|
343
400
|
const snapshotOp = {
|
|
@@ -358,7 +415,7 @@ var SyncHub = class {
|
|
|
358
415
|
else extensions[plugin.name] = snapshot;
|
|
359
416
|
}
|
|
360
417
|
if (Object.keys(extensions).length > 0) snapshotOp["extensions"] = extensions;
|
|
361
|
-
|
|
418
|
+
this.sendToConnection(conn, HUB_FROM, snapshotOp);
|
|
362
419
|
} else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
|
|
363
420
|
await this.processLayerOp(conn, op);
|
|
364
421
|
} else if (op.kind === "extension") {
|
|
@@ -375,8 +432,22 @@ var SyncHub = class {
|
|
|
375
432
|
await this.deliverPluginResult(conn, result);
|
|
376
433
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
377
434
|
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
378
|
-
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
379
|
-
const
|
|
435
|
+
const needCurrent = (this.authorize || this.canRead || this.resolveAudience) && id !== void 0;
|
|
436
|
+
const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
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
|
+
}
|
|
380
451
|
let outboundOp = op;
|
|
381
452
|
if (this.authorize) {
|
|
382
453
|
const allowed = await this.authorize({
|
|
@@ -400,7 +471,7 @@ var SyncHub = class {
|
|
|
400
471
|
const prevAudience = current?.audience;
|
|
401
472
|
const result = await this.runCorePlugins(conn, outboundOp);
|
|
402
473
|
for (const correction of result.corrections) {
|
|
403
|
-
|
|
474
|
+
this.sendToConnection(conn, HUB_FROM, correction);
|
|
404
475
|
}
|
|
405
476
|
const accepted = result.accepted;
|
|
406
477
|
if (accepted && (accepted.kind === "upsert" || accepted.kind === "remove" || accepted.kind === "clear")) {
|
|
@@ -456,7 +527,7 @@ var SyncHub = class {
|
|
|
456
527
|
}
|
|
457
528
|
async deliverPluginResult(conn, result) {
|
|
458
529
|
for (const correction of result.corrections) {
|
|
459
|
-
|
|
530
|
+
this.sendToConnection(conn, HUB_FROM, correction);
|
|
460
531
|
}
|
|
461
532
|
if (result.accepted) await this.publishPluginOp(conn, result.accepted, result.locality);
|
|
462
533
|
for (const broadcast of result.broadcast ?? []) {
|
|
@@ -469,7 +540,7 @@ var SyncHub = class {
|
|
|
469
540
|
JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
|
|
470
541
|
);
|
|
471
542
|
}
|
|
472
|
-
this.
|
|
543
|
+
this.relayOpToRoom(conn.room, conn.id, conn.id, op);
|
|
473
544
|
}
|
|
474
545
|
/**
|
|
475
546
|
* Applies a layer-definition edit on the room's serial queue. Convergence is
|
|
@@ -491,19 +562,19 @@ var SyncHub = class {
|
|
|
491
562
|
});
|
|
492
563
|
if (!allowed) {
|
|
493
564
|
const correction = current ?? { id: record.id, version: record.version, editor: HUB_FROM };
|
|
494
|
-
|
|
565
|
+
this.sendToConnection(conn, HUB_FROM, layerRecordToOp(correction));
|
|
495
566
|
return;
|
|
496
567
|
}
|
|
497
568
|
}
|
|
498
569
|
if (current && !(0, import_sync2.isNewerLayerRecord)(record, current)) {
|
|
499
|
-
|
|
570
|
+
this.sendToConnection(conn, HUB_FROM, layerRecordToOp(current));
|
|
500
571
|
return;
|
|
501
572
|
}
|
|
502
573
|
await this.applyLayerRecord(conn.room, record);
|
|
503
574
|
await this.fanout.publish(
|
|
504
575
|
JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
|
|
505
576
|
);
|
|
506
|
-
this.
|
|
577
|
+
this.relayOpToRoom(conn.room, conn.id, conn.id, op);
|
|
507
578
|
}
|
|
508
579
|
layerBackend() {
|
|
509
580
|
const { layerRecords, getLayerRecord, applyLayerRecord } = this.backend;
|
|
@@ -537,6 +608,10 @@ var SyncHub = class {
|
|
|
537
608
|
}
|
|
538
609
|
map.set(record.id, record);
|
|
539
610
|
}
|
|
611
|
+
mayReadOwnerId(conn) {
|
|
612
|
+
if (!this.canReadOwnerId) return false;
|
|
613
|
+
return this.canReadOwnerId({ userId: conn.userId, role: conn.role, room: conn.room });
|
|
614
|
+
}
|
|
540
615
|
mayRead(conn, audience) {
|
|
541
616
|
if (!this.canRead) return true;
|
|
542
617
|
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
@@ -564,6 +639,91 @@ var SyncHub = class {
|
|
|
564
639
|
}
|
|
565
640
|
return sent;
|
|
566
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* Translates `op` for the peer and sends it. Returns false when the op is
|
|
644
|
+
* lossy for this peer (no legacy encoding) or the socket throws; neither
|
|
645
|
+
* may reject the room operation that produced it.
|
|
646
|
+
*/
|
|
647
|
+
sendToConnection(conn, from, op, encoded) {
|
|
648
|
+
const capabilities = this.peerCapabilities.get(conn.id) ?? (0, import_sync2.createLegacyCapabilities)();
|
|
649
|
+
const revealOwner = this.mayReadOwnerId(conn);
|
|
650
|
+
const profile = encoded ? encodingProfile(capabilities, revealOwner) : void 0;
|
|
651
|
+
let message = profile === void 0 ? void 0 : encoded?.get(profile);
|
|
652
|
+
if (message === void 0) {
|
|
653
|
+
message = this.encodeForPeer(from, op, capabilities, revealOwner);
|
|
654
|
+
if (profile !== void 0) encoded?.set(profile, message);
|
|
655
|
+
}
|
|
656
|
+
if (message === null) return false;
|
|
657
|
+
try {
|
|
658
|
+
conn.send(message);
|
|
659
|
+
return true;
|
|
660
|
+
} catch {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
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) {
|
|
670
|
+
try {
|
|
671
|
+
const translated = (0, import_sync2.translateOpForPeer)(
|
|
672
|
+
revealOwner ? op : stripOwnerId(op),
|
|
673
|
+
capabilities,
|
|
674
|
+
this.elementRegistry,
|
|
675
|
+
this.pluginRegistry.extensionDefinitions
|
|
676
|
+
);
|
|
677
|
+
return JSON.stringify({ from, op: translated });
|
|
678
|
+
} catch {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Normalize registered legacy wire elements before authorization, storage,
|
|
684
|
+
* and relay. A legacy type this hub has no adapter for is forwarded and
|
|
685
|
+
* stored verbatim — the hub is a relay, and the peers decide whether they
|
|
686
|
+
* understand it — so a hub deployed without domain adapters never erases
|
|
687
|
+
* the room's existing elements. Only a malformed registered element is dropped.
|
|
688
|
+
*/
|
|
689
|
+
normalizeElement(element) {
|
|
690
|
+
if ((0, import_sync2.isValidElement)(element)) return element;
|
|
691
|
+
const adapter = this.elementRegistry.getAdapterByLegacyType(element.type);
|
|
692
|
+
if (!adapter) return element;
|
|
693
|
+
try {
|
|
694
|
+
const raw = Object.fromEntries(Object.entries(element));
|
|
695
|
+
const envelope = adapter.decodeLegacy(raw);
|
|
696
|
+
if (!adapter.validateEnvelope(envelope)) return null;
|
|
697
|
+
return {
|
|
698
|
+
...envelope,
|
|
699
|
+
...typeof raw["audience"] === "string" ? { audience: raw["audience"] } : {},
|
|
700
|
+
...typeof raw["ownerId"] === "string" ? { ownerId: raw["ownerId"] } : {}
|
|
701
|
+
};
|
|
702
|
+
} catch {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
normalizeElements(elements) {
|
|
707
|
+
const normalized = [];
|
|
708
|
+
for (const element of elements) {
|
|
709
|
+
const runtime = this.normalizeElement(element);
|
|
710
|
+
if (runtime) normalized.push(runtime);
|
|
711
|
+
}
|
|
712
|
+
return normalized;
|
|
713
|
+
}
|
|
714
|
+
relayOpToRoom(room, excludeId, from, op) {
|
|
715
|
+
const members = this.rooms.get(room);
|
|
716
|
+
if (!members) return 0;
|
|
717
|
+
let sent = 0;
|
|
718
|
+
const encoded = /* @__PURE__ */ new Map();
|
|
719
|
+
for (const connectionId of members) {
|
|
720
|
+
if (connectionId === excludeId) continue;
|
|
721
|
+
const conn = this.conns.get(connectionId);
|
|
722
|
+
if (!conn) continue;
|
|
723
|
+
if (this.sendToConnection(conn, from, op, encoded)) sent += 1;
|
|
724
|
+
}
|
|
725
|
+
return sent;
|
|
726
|
+
}
|
|
567
727
|
broadcastClientPresence(conn, data) {
|
|
568
728
|
this.presenceConnections.add(conn.id);
|
|
569
729
|
const message = JSON.stringify({ from: conn.id, op: { kind: "presence", data } });
|
|
@@ -645,41 +805,33 @@ var SyncHub = class {
|
|
|
645
805
|
deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
|
|
646
806
|
const members = this.rooms.get(room);
|
|
647
807
|
if (!members) return;
|
|
648
|
-
const
|
|
649
|
-
try {
|
|
650
|
-
conn.send(msg);
|
|
651
|
-
} catch {
|
|
652
|
-
}
|
|
653
|
-
};
|
|
808
|
+
const encoded = /* @__PURE__ */ new Map();
|
|
654
809
|
if (op.kind === "upsert") {
|
|
655
810
|
const audience = op.element.audience;
|
|
656
|
-
const
|
|
657
|
-
const
|
|
658
|
-
from: HUB_FROM,
|
|
659
|
-
op: { kind: "remove", id: op.element.id }
|
|
660
|
-
});
|
|
811
|
+
const removeOp = { kind: "remove", id: op.element.id };
|
|
812
|
+
const encodedRemove = /* @__PURE__ */ new Map();
|
|
661
813
|
for (const cid of members) {
|
|
662
814
|
if (cid === excludeId) continue;
|
|
663
815
|
const conn = this.conns.get(cid);
|
|
664
816
|
if (!conn) continue;
|
|
665
|
-
if (this.mayRead(conn, audience))
|
|
666
|
-
else if (prevExisted && this.mayRead(conn, prevAudience))
|
|
817
|
+
if (this.mayRead(conn, audience)) this.sendToConnection(conn, from, op, encoded);
|
|
818
|
+
else if (prevExisted && this.mayRead(conn, prevAudience)) {
|
|
819
|
+
this.sendToConnection(conn, HUB_FROM, removeOp, encodedRemove);
|
|
820
|
+
}
|
|
667
821
|
}
|
|
668
822
|
} else if (op.kind === "remove") {
|
|
669
|
-
const removeMsg = JSON.stringify({ from, op });
|
|
670
823
|
for (const cid of members) {
|
|
671
824
|
if (cid === excludeId) continue;
|
|
672
825
|
const conn = this.conns.get(cid);
|
|
673
826
|
if (!conn) continue;
|
|
674
827
|
const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
|
|
675
|
-
if (wasVisible)
|
|
828
|
+
if (wasVisible) this.sendToConnection(conn, from, op, encoded);
|
|
676
829
|
}
|
|
677
830
|
} else if (op.kind === "clear") {
|
|
678
|
-
const clearMsg = JSON.stringify({ from, op });
|
|
679
831
|
for (const cid of members) {
|
|
680
832
|
if (cid === excludeId) continue;
|
|
681
833
|
const conn = this.conns.get(cid);
|
|
682
|
-
if (conn)
|
|
834
|
+
if (conn) this.sendToConnection(conn, from, op, encoded);
|
|
683
835
|
}
|
|
684
836
|
}
|
|
685
837
|
}
|
|
@@ -690,13 +842,14 @@ var SyncHub = class {
|
|
|
690
842
|
} else if (op.kind === "remove") {
|
|
691
843
|
correction = current ? this.mayRead(conn, current.audience) ? { kind: "upsert", element: current } : { kind: "remove", id: current.id } : void 0;
|
|
692
844
|
} else if (op.kind === "clear") {
|
|
693
|
-
const all = await this.backend.snapshot(conn.room);
|
|
845
|
+
const all = this.normalizeElements(await this.backend.snapshot(conn.room));
|
|
694
846
|
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
695
847
|
correction = { kind: "snapshot", to: from, elements };
|
|
696
848
|
}
|
|
697
|
-
if (correction)
|
|
849
|
+
if (correction) this.sendToConnection(conn, HUB_FROM, correction);
|
|
698
850
|
}
|
|
699
851
|
onFanout(payload) {
|
|
852
|
+
if (!hasJsonDepthAtMost(payload, this.maxJsonDepth)) return;
|
|
700
853
|
let env;
|
|
701
854
|
try {
|
|
702
855
|
env = JSON.parse(payload);
|
|
@@ -706,19 +859,30 @@ var SyncHub = class {
|
|
|
706
859
|
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
707
860
|
return;
|
|
708
861
|
if (env.o === this.instanceId) return;
|
|
709
|
-
const
|
|
862
|
+
const envelope = { from: env.from, op: env.op };
|
|
863
|
+
if (!(0, import_sync2.isValidEnvelope)(envelope)) return;
|
|
864
|
+
const op = envelope.op;
|
|
710
865
|
if (isPresenceOp(op)) {
|
|
866
|
+
if (op.kind === "presence" && !this.isPresenceWithinLimit(op.data)) return;
|
|
711
867
|
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
712
868
|
return;
|
|
713
869
|
}
|
|
714
870
|
if (isLayerOp(op)) {
|
|
715
871
|
void this.applyFanoutLayerOp(env.room, op).catch(() => {
|
|
716
872
|
});
|
|
717
|
-
this.
|
|
873
|
+
this.relayOpToRoom(env.room, void 0, env.from, op);
|
|
718
874
|
return;
|
|
719
875
|
}
|
|
720
|
-
|
|
721
|
-
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;
|
|
722
886
|
const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
|
|
723
887
|
const operation = previous.then(async () => {
|
|
724
888
|
const context = {
|
|
@@ -727,13 +891,9 @@ var SyncHub = class {
|
|
|
727
891
|
backend: this.backend,
|
|
728
892
|
backendPlugin: (key) => this.backend.getService?.(key)
|
|
729
893
|
};
|
|
730
|
-
const accepted =
|
|
894
|
+
const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
|
|
731
895
|
if (accepted) {
|
|
732
|
-
this.
|
|
733
|
-
env.room,
|
|
734
|
-
void 0,
|
|
735
|
-
JSON.stringify({ from: env.from, op: accepted })
|
|
736
|
-
);
|
|
896
|
+
this.relayOpToRoom(env.room, void 0, env.from, accepted);
|
|
737
897
|
}
|
|
738
898
|
});
|
|
739
899
|
this.roomQueues.set(
|
|
@@ -744,9 +904,17 @@ var SyncHub = class {
|
|
|
744
904
|
return;
|
|
745
905
|
}
|
|
746
906
|
if (!isFanoutOp(op)) return;
|
|
907
|
+
const runtimeOp = op.kind === "upsert" ? (() => {
|
|
908
|
+
const element = this.normalizeElement(op.element);
|
|
909
|
+
return element ? { ...op, element } : null;
|
|
910
|
+
})() : op;
|
|
911
|
+
if (!runtimeOp) return;
|
|
747
912
|
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
748
913
|
const prevExisted = env.existed === true;
|
|
749
|
-
this.deliverToRoom(env.room, void 0, env.from,
|
|
914
|
+
this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
|
|
915
|
+
}
|
|
916
|
+
isPresenceWithinLimit(data) {
|
|
917
|
+
return utf8ByteLength(JSON.stringify(data) ?? "") <= this.maxPresenceBytes;
|
|
750
918
|
}
|
|
751
919
|
async applyFanoutLayerOp(room, op) {
|
|
752
920
|
const record = layerOpToRecord(op);
|
|
@@ -763,6 +931,23 @@ var SyncHub = class {
|
|
|
763
931
|
// src/create-sync-server.ts
|
|
764
932
|
var import_ws = require("ws");
|
|
765
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
|
+
|
|
766
951
|
// src/heartbeat.ts
|
|
767
952
|
function startHeartbeat(wss, intervalMs) {
|
|
768
953
|
if (intervalMs <= 0) {
|
|
@@ -794,6 +979,9 @@ function startHeartbeat(wss, intervalMs) {
|
|
|
794
979
|
return { track, stop: () => clearInterval(interval) };
|
|
795
980
|
}
|
|
796
981
|
|
|
982
|
+
// src/create-sync-server.ts
|
|
983
|
+
var import_sync4 = require("@fieldnotes/sync");
|
|
984
|
+
|
|
797
985
|
// src/shutdown.ts
|
|
798
986
|
var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
|
|
799
987
|
function drainWebSocketServer(wss, graceMs) {
|
|
@@ -828,16 +1016,78 @@ function drainWebSocketServer(wss, graceMs) {
|
|
|
828
1016
|
});
|
|
829
1017
|
}
|
|
830
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
|
+
|
|
831
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
|
+
};
|
|
832
1046
|
function rawDataByteLength(data) {
|
|
833
1047
|
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
834
1048
|
return data.byteLength;
|
|
835
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
|
+
}
|
|
836
1062
|
function createSyncServer(options = {}) {
|
|
837
1063
|
const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
838
1064
|
if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
|
|
839
1065
|
throw new RangeError("shutdownGraceMs must be a non-negative finite number");
|
|
840
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
|
+
}
|
|
841
1091
|
const hub = new SyncHub({
|
|
842
1092
|
backend: options.backend,
|
|
843
1093
|
fanout: options.fanout,
|
|
@@ -846,16 +1096,34 @@ function createSyncServer(options = {}) {
|
|
|
846
1096
|
authorizeLayer: options.authorizeLayer,
|
|
847
1097
|
plugins: options.plugins,
|
|
848
1098
|
canRead: options.canRead,
|
|
1099
|
+
canReadOwnerId: options.canReadOwnerId,
|
|
1100
|
+
resolveAudience: options.resolveAudience,
|
|
849
1101
|
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
850
1102
|
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
|
|
851
|
-
maxPresenceLanes: options.maxPresenceLanes
|
|
1103
|
+
maxPresenceLanes: options.maxPresenceLanes,
|
|
1104
|
+
maxPresenceBytes: options.maxPresenceBytes,
|
|
1105
|
+
elementRegistry: options.elementRegistry
|
|
852
1106
|
});
|
|
853
1107
|
const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
854
|
-
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
|
+
});
|
|
855
1120
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
856
1121
|
let shuttingDown = false;
|
|
857
1122
|
let closePromise;
|
|
858
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);
|
|
859
1127
|
wss.on("connection", (ws, req) => {
|
|
860
1128
|
if (shuttingDown) {
|
|
861
1129
|
ws.close(1001, "server shutting down");
|
|
@@ -869,6 +1137,22 @@ function createSyncServer(options = {}) {
|
|
|
869
1137
|
ws.close(4400, "room required");
|
|
870
1138
|
return;
|
|
871
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
|
+
}
|
|
872
1156
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
873
1157
|
let state = "pending";
|
|
874
1158
|
let closed = false;
|
|
@@ -877,10 +1161,8 @@ function createSyncServer(options = {}) {
|
|
|
877
1161
|
let queuedBytes = 0;
|
|
878
1162
|
const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
|
|
879
1163
|
const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
|
|
880
|
-
const limiter = new MessageRateLimiter(
|
|
881
|
-
|
|
882
|
-
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
883
|
-
);
|
|
1164
|
+
const limiter = new MessageRateLimiter(messagesPerSecond, messageBurst);
|
|
1165
|
+
const byteLimiter = new MessageRateLimiter(bytesPerSecond, byteBurst);
|
|
884
1166
|
const send = (m) => {
|
|
885
1167
|
try {
|
|
886
1168
|
ws.send(m);
|
|
@@ -895,7 +1177,8 @@ function createSyncServer(options = {}) {
|
|
|
895
1177
|
ws.close(1009, "message too large");
|
|
896
1178
|
return;
|
|
897
1179
|
}
|
|
898
|
-
|
|
1180
|
+
const now = Date.now();
|
|
1181
|
+
if (!limiter.take(now) || !byteLimiter.take(now, messageBytes)) {
|
|
899
1182
|
state = "rejected";
|
|
900
1183
|
ws.close(4408, "rate limit exceeded");
|
|
901
1184
|
return;
|
|
@@ -915,9 +1198,13 @@ function createSyncServer(options = {}) {
|
|
|
915
1198
|
});
|
|
916
1199
|
ws.on("close", () => {
|
|
917
1200
|
closed = true;
|
|
1201
|
+
releaseIp();
|
|
1202
|
+
releaseRoom();
|
|
918
1203
|
if (admitted) hub.removeConnection(connId);
|
|
919
1204
|
});
|
|
920
|
-
Promise.resolve(
|
|
1205
|
+
Promise.resolve(
|
|
1206
|
+
options.authenticate ? options.authenticate({ req, room, token: readBearerToken(req) }) : { userId: connId }
|
|
1207
|
+
).then((result) => {
|
|
921
1208
|
if (closed || state === "rejected" || shuttingDown) return;
|
|
922
1209
|
if (!result) {
|
|
923
1210
|
state = "rejected";
|
|
@@ -958,8 +1245,11 @@ function createSyncServer(options = {}) {
|
|
|
958
1245
|
0 && (module.exports = {
|
|
959
1246
|
InMemoryHubFanout,
|
|
960
1247
|
MemoryHubBackend,
|
|
1248
|
+
ROOM_NAME_PATTERN,
|
|
961
1249
|
SyncHub,
|
|
962
1250
|
createSyncServer,
|
|
1251
|
+
isValidRoomName,
|
|
1252
|
+
readBearerToken,
|
|
963
1253
|
startHeartbeat
|
|
964
1254
|
});
|
|
965
1255
|
//# sourceMappingURL=index.cjs.map
|