@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.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
// src/sync-hub.ts
|
|
2
2
|
import {
|
|
3
|
+
createCurrentCapabilities,
|
|
4
|
+
createLegacyCapabilities,
|
|
3
5
|
parseEnvelope,
|
|
6
|
+
isValidEnvelope,
|
|
4
7
|
isValidElement,
|
|
5
|
-
isNewerLayerRecord
|
|
8
|
+
isNewerLayerRecord,
|
|
9
|
+
translateOpForPeer
|
|
6
10
|
} from "@fieldnotes/sync";
|
|
11
|
+
import { getDefaultElementRegistry } from "@fieldnotes/core";
|
|
7
12
|
|
|
8
13
|
// src/memory-hub-backend.ts
|
|
9
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
applyOpToMap
|
|
16
|
+
} from "@fieldnotes/sync";
|
|
10
17
|
var MemoryHubBackend = class {
|
|
11
18
|
rooms = /* @__PURE__ */ new Map();
|
|
12
19
|
roomLayers = /* @__PURE__ */ new Map();
|
|
@@ -72,8 +79,10 @@ var ServerPluginRegistry = class {
|
|
|
72
79
|
byName = /* @__PURE__ */ new Map();
|
|
73
80
|
legacyOwners = /* @__PURE__ */ new Map();
|
|
74
81
|
extensions = /* @__PURE__ */ new Map();
|
|
82
|
+
definitions;
|
|
75
83
|
constructor(plugins) {
|
|
76
84
|
for (const plugin of plugins) this.register(plugin);
|
|
85
|
+
this.definitions = new Map([...this.extensions].map(([name, entry]) => [name, entry.kind]));
|
|
77
86
|
}
|
|
78
87
|
register(plugin) {
|
|
79
88
|
if (this.byName.has(plugin.name))
|
|
@@ -105,6 +114,12 @@ var ServerPluginRegistry = class {
|
|
|
105
114
|
extension(extensionKind) {
|
|
106
115
|
return this.extensions.get(extensionKind);
|
|
107
116
|
}
|
|
117
|
+
get extensionKinds() {
|
|
118
|
+
return [...this.extensions.keys()];
|
|
119
|
+
}
|
|
120
|
+
get extensionDefinitions() {
|
|
121
|
+
return this.definitions;
|
|
122
|
+
}
|
|
108
123
|
};
|
|
109
124
|
|
|
110
125
|
// src/resource-limits.ts
|
|
@@ -115,6 +130,11 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
|
|
|
115
130
|
var DEFAULT_MESSAGES_PER_SECOND = 120;
|
|
116
131
|
var DEFAULT_MESSAGE_BURST = 240;
|
|
117
132
|
var DEFAULT_PRESENCE_THROTTLE_MS = 50;
|
|
133
|
+
var DEFAULT_MAX_PRESENCE_BYTES = 4 * 1024;
|
|
134
|
+
var DEFAULT_BYTES_PER_SECOND = 4 * 1024 * 1024;
|
|
135
|
+
var DEFAULT_BYTE_BURST = 8 * 1024 * 1024;
|
|
136
|
+
var DEFAULT_MAX_CONNECTIONS_PER_IP = 64;
|
|
137
|
+
var DEFAULT_MAX_CONNECTIONS_PER_ROOM = 256;
|
|
118
138
|
var DEFAULT_MAX_PRESENCE_LANES = 16;
|
|
119
139
|
function hasJsonDepthAtMost(message, maxDepth) {
|
|
120
140
|
let depth = 0;
|
|
@@ -147,29 +167,30 @@ var MessageRateLimiter = class {
|
|
|
147
167
|
}
|
|
148
168
|
tokens;
|
|
149
169
|
updatedAt;
|
|
150
|
-
|
|
170
|
+
/** Charges `cost` tokens (frames or bytes); a cost above `burst` never fits. */
|
|
171
|
+
take(now = Date.now(), cost = 1) {
|
|
151
172
|
const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
|
|
152
173
|
this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
|
|
153
174
|
this.updatedAt = now;
|
|
154
|
-
if (this.tokens <
|
|
155
|
-
this.tokens -=
|
|
175
|
+
if (this.tokens < cost) return false;
|
|
176
|
+
this.tokens -= cost;
|
|
156
177
|
return true;
|
|
157
178
|
}
|
|
158
179
|
};
|
|
159
180
|
|
|
160
181
|
// src/sync-hub.ts
|
|
161
182
|
var HUB_FROM = "hub";
|
|
183
|
+
var utf8 = new TextEncoder();
|
|
184
|
+
function utf8ByteLength(text) {
|
|
185
|
+
return utf8.encode(text).byteLength;
|
|
186
|
+
}
|
|
162
187
|
function generateInstanceId() {
|
|
163
188
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
|
|
164
189
|
return crypto.randomUUID();
|
|
165
190
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
166
191
|
}
|
|
167
192
|
function isFanoutOp(op) {
|
|
168
|
-
|
|
169
|
-
const o = op;
|
|
170
|
-
if (o.kind === "upsert") return isValidElement(o.element);
|
|
171
|
-
if (o.kind === "remove") return typeof o.id === "string";
|
|
172
|
-
return o.kind === "clear";
|
|
193
|
+
return op.kind === "upsert" || op.kind === "remove" || op.kind === "clear";
|
|
173
194
|
}
|
|
174
195
|
var FALLBACK_PRESENCE_LANE = "";
|
|
175
196
|
var MAX_PRESENCE_LANE_LENGTH = 64;
|
|
@@ -182,9 +203,7 @@ function presenceLaneOf(data) {
|
|
|
182
203
|
return kind;
|
|
183
204
|
}
|
|
184
205
|
function isLayerOp(op) {
|
|
185
|
-
|
|
186
|
-
const k = op.kind;
|
|
187
|
-
return k === "layer-upsert" || k === "layer-remove";
|
|
206
|
+
return op.kind === "layer-upsert" || op.kind === "layer-remove";
|
|
188
207
|
}
|
|
189
208
|
function layerOpToRecord(op) {
|
|
190
209
|
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 };
|
|
@@ -197,10 +216,22 @@ function layerRecordToOp(record) {
|
|
|
197
216
|
editor: record.editor
|
|
198
217
|
} : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
|
|
199
218
|
}
|
|
219
|
+
function encodingProfile(capabilities, revealOwner) {
|
|
220
|
+
return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds, revealOwner]);
|
|
221
|
+
}
|
|
222
|
+
function withoutOwnerId(element) {
|
|
223
|
+
if (element.ownerId === void 0) return element;
|
|
224
|
+
const rest = { ...element };
|
|
225
|
+
delete rest.ownerId;
|
|
226
|
+
return rest;
|
|
227
|
+
}
|
|
228
|
+
function stripOwnerId(op) {
|
|
229
|
+
if (op.kind === "upsert") return { ...op, element: withoutOwnerId(op.element) };
|
|
230
|
+
if (op.kind === "snapshot") return { ...op, elements: op.elements.map(withoutOwnerId) };
|
|
231
|
+
return op;
|
|
232
|
+
}
|
|
200
233
|
function isPresenceOp(op) {
|
|
201
|
-
|
|
202
|
-
const k = op.kind;
|
|
203
|
-
return k === "presence" || k === "presence-leave";
|
|
234
|
+
return op.kind === "presence" || op.kind === "presence-leave";
|
|
204
235
|
}
|
|
205
236
|
var SyncHub = class {
|
|
206
237
|
backend;
|
|
@@ -216,11 +247,16 @@ var SyncHub = class {
|
|
|
216
247
|
authorize;
|
|
217
248
|
authorizeLayer;
|
|
218
249
|
pluginRegistry;
|
|
250
|
+
elementRegistry;
|
|
251
|
+
peerCapabilities = /* @__PURE__ */ new Map();
|
|
219
252
|
canRead;
|
|
253
|
+
canReadOwnerId;
|
|
254
|
+
resolveAudience;
|
|
220
255
|
memoryLayers = /* @__PURE__ */ new Map();
|
|
221
256
|
maxJsonDepth;
|
|
222
257
|
presenceThrottleMs;
|
|
223
258
|
maxPresenceLanes;
|
|
259
|
+
maxPresenceBytes;
|
|
224
260
|
/**
|
|
225
261
|
* Presence throttle state keyed by connection, then by lane. A lane is the
|
|
226
262
|
* payload's `kind` (a non-empty string of at most 64 chars) or the reserved
|
|
@@ -234,11 +270,14 @@ var SyncHub = class {
|
|
|
234
270
|
constructor(options = {}) {
|
|
235
271
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
236
272
|
this.pluginRegistry = new ServerPluginRegistry(options.plugins ?? []);
|
|
273
|
+
this.elementRegistry = options.elementRegistry ?? getDefaultElementRegistry();
|
|
237
274
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
238
275
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
239
276
|
this.authorize = options.authorize;
|
|
240
277
|
this.authorizeLayer = options.authorizeLayer;
|
|
241
278
|
this.canRead = options.canRead;
|
|
279
|
+
this.canReadOwnerId = options.canReadOwnerId;
|
|
280
|
+
this.resolveAudience = options.resolveAudience;
|
|
242
281
|
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
243
282
|
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
244
283
|
const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
|
|
@@ -246,6 +285,11 @@ var SyncHub = class {
|
|
|
246
285
|
throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
|
|
247
286
|
}
|
|
248
287
|
this.maxPresenceLanes = Math.floor(maxPresenceLanes);
|
|
288
|
+
const maxPresenceBytes = options.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
|
289
|
+
if (!Number.isFinite(maxPresenceBytes) || maxPresenceBytes < 0) {
|
|
290
|
+
throw new RangeError("maxPresenceBytes must be a non-negative finite number");
|
|
291
|
+
}
|
|
292
|
+
this.maxPresenceBytes = maxPresenceBytes;
|
|
249
293
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
250
294
|
}
|
|
251
295
|
addConnection(conn) {
|
|
@@ -261,6 +305,7 @@ var SyncHub = class {
|
|
|
261
305
|
const conn = this.conns.get(connId);
|
|
262
306
|
if (!conn) return;
|
|
263
307
|
this.conns.delete(connId);
|
|
308
|
+
this.peerCapabilities.delete(connId);
|
|
264
309
|
const room = conn.room;
|
|
265
310
|
const hadPresence = this.presenceConnections.delete(connId);
|
|
266
311
|
this.clearPresenceLanes(connId);
|
|
@@ -283,6 +328,7 @@ var SyncHub = class {
|
|
|
283
328
|
* forwards the same event to other instances on a best-effort basis.
|
|
284
329
|
*/
|
|
285
330
|
broadcastPresence(room, data) {
|
|
331
|
+
if (!this.isPresenceWithinLimit(data)) return 0;
|
|
286
332
|
const op = { kind: "presence", data };
|
|
287
333
|
const sent = this.relayToRoom(room, void 0, JSON.stringify({ from: HUB_FROM, op }));
|
|
288
334
|
this.safePublish(JSON.stringify({ o: this.instanceId, room, from: HUB_FROM, op }));
|
|
@@ -294,7 +340,16 @@ var SyncHub = class {
|
|
|
294
340
|
if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();
|
|
295
341
|
const env = parseEnvelope(message);
|
|
296
342
|
if (!env) return Promise.resolve();
|
|
343
|
+
if (env.op.kind === "capabilities") {
|
|
344
|
+
this.peerCapabilities.set(conn.id, env.op.capabilities);
|
|
345
|
+
this.sendToConnection(conn, HUB_FROM, {
|
|
346
|
+
kind: "capabilities",
|
|
347
|
+
capabilities: createCurrentCapabilities(this.pluginRegistry.extensionKinds)
|
|
348
|
+
});
|
|
349
|
+
return Promise.resolve();
|
|
350
|
+
}
|
|
297
351
|
if (env.op.kind === "presence") {
|
|
352
|
+
if (!this.isPresenceWithinLimit(env.op.data)) return Promise.resolve();
|
|
298
353
|
this.schedulePresence(conn, env.op.data);
|
|
299
354
|
return Promise.resolve();
|
|
300
355
|
}
|
|
@@ -309,9 +364,14 @@ var SyncHub = class {
|
|
|
309
364
|
return operation;
|
|
310
365
|
}
|
|
311
366
|
async process(conn, env) {
|
|
312
|
-
|
|
367
|
+
let op = env.op;
|
|
368
|
+
if (op.kind === "upsert") {
|
|
369
|
+
const element = this.normalizeElement(op.element);
|
|
370
|
+
if (!element) return;
|
|
371
|
+
op = { ...op, element };
|
|
372
|
+
}
|
|
313
373
|
if (op.kind === "request-snapshot") {
|
|
314
|
-
const all = await this.backend.snapshot(conn.room);
|
|
374
|
+
const all = this.normalizeElements(await this.backend.snapshot(conn.room));
|
|
315
375
|
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
316
376
|
const layers = await this.getLayerRecords(conn.room);
|
|
317
377
|
const snapshotOp = {
|
|
@@ -332,7 +392,7 @@ var SyncHub = class {
|
|
|
332
392
|
else extensions[plugin.name] = snapshot;
|
|
333
393
|
}
|
|
334
394
|
if (Object.keys(extensions).length > 0) snapshotOp["extensions"] = extensions;
|
|
335
|
-
|
|
395
|
+
this.sendToConnection(conn, HUB_FROM, snapshotOp);
|
|
336
396
|
} else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
|
|
337
397
|
await this.processLayerOp(conn, op);
|
|
338
398
|
} else if (op.kind === "extension") {
|
|
@@ -349,8 +409,22 @@ var SyncHub = class {
|
|
|
349
409
|
await this.deliverPluginResult(conn, result);
|
|
350
410
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
351
411
|
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
352
|
-
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
353
|
-
const
|
|
412
|
+
const needCurrent = (this.authorize || this.canRead || this.resolveAudience) && id !== void 0;
|
|
413
|
+
const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
414
|
+
const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
|
|
415
|
+
if (op.kind === "upsert" && this.resolveAudience) {
|
|
416
|
+
const audience = this.resolveAudience({
|
|
417
|
+
userId: conn.userId,
|
|
418
|
+
role: conn.role,
|
|
419
|
+
room: conn.room,
|
|
420
|
+
element: op.element,
|
|
421
|
+
currentElement: current
|
|
422
|
+
});
|
|
423
|
+
const element = { ...op.element };
|
|
424
|
+
if (audience === void 0) delete element.audience;
|
|
425
|
+
else element.audience = audience;
|
|
426
|
+
op = { kind: "upsert", element };
|
|
427
|
+
}
|
|
354
428
|
let outboundOp = op;
|
|
355
429
|
if (this.authorize) {
|
|
356
430
|
const allowed = await this.authorize({
|
|
@@ -374,7 +448,7 @@ var SyncHub = class {
|
|
|
374
448
|
const prevAudience = current?.audience;
|
|
375
449
|
const result = await this.runCorePlugins(conn, outboundOp);
|
|
376
450
|
for (const correction of result.corrections) {
|
|
377
|
-
|
|
451
|
+
this.sendToConnection(conn, HUB_FROM, correction);
|
|
378
452
|
}
|
|
379
453
|
const accepted = result.accepted;
|
|
380
454
|
if (accepted && (accepted.kind === "upsert" || accepted.kind === "remove" || accepted.kind === "clear")) {
|
|
@@ -430,7 +504,7 @@ var SyncHub = class {
|
|
|
430
504
|
}
|
|
431
505
|
async deliverPluginResult(conn, result) {
|
|
432
506
|
for (const correction of result.corrections) {
|
|
433
|
-
|
|
507
|
+
this.sendToConnection(conn, HUB_FROM, correction);
|
|
434
508
|
}
|
|
435
509
|
if (result.accepted) await this.publishPluginOp(conn, result.accepted, result.locality);
|
|
436
510
|
for (const broadcast of result.broadcast ?? []) {
|
|
@@ -443,7 +517,7 @@ var SyncHub = class {
|
|
|
443
517
|
JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
|
|
444
518
|
);
|
|
445
519
|
}
|
|
446
|
-
this.
|
|
520
|
+
this.relayOpToRoom(conn.room, conn.id, conn.id, op);
|
|
447
521
|
}
|
|
448
522
|
/**
|
|
449
523
|
* Applies a layer-definition edit on the room's serial queue. Convergence is
|
|
@@ -465,19 +539,19 @@ var SyncHub = class {
|
|
|
465
539
|
});
|
|
466
540
|
if (!allowed) {
|
|
467
541
|
const correction = current ?? { id: record.id, version: record.version, editor: HUB_FROM };
|
|
468
|
-
|
|
542
|
+
this.sendToConnection(conn, HUB_FROM, layerRecordToOp(correction));
|
|
469
543
|
return;
|
|
470
544
|
}
|
|
471
545
|
}
|
|
472
546
|
if (current && !isNewerLayerRecord(record, current)) {
|
|
473
|
-
|
|
547
|
+
this.sendToConnection(conn, HUB_FROM, layerRecordToOp(current));
|
|
474
548
|
return;
|
|
475
549
|
}
|
|
476
550
|
await this.applyLayerRecord(conn.room, record);
|
|
477
551
|
await this.fanout.publish(
|
|
478
552
|
JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
|
|
479
553
|
);
|
|
480
|
-
this.
|
|
554
|
+
this.relayOpToRoom(conn.room, conn.id, conn.id, op);
|
|
481
555
|
}
|
|
482
556
|
layerBackend() {
|
|
483
557
|
const { layerRecords, getLayerRecord, applyLayerRecord } = this.backend;
|
|
@@ -511,6 +585,10 @@ var SyncHub = class {
|
|
|
511
585
|
}
|
|
512
586
|
map.set(record.id, record);
|
|
513
587
|
}
|
|
588
|
+
mayReadOwnerId(conn) {
|
|
589
|
+
if (!this.canReadOwnerId) return false;
|
|
590
|
+
return this.canReadOwnerId({ userId: conn.userId, role: conn.role, room: conn.room });
|
|
591
|
+
}
|
|
514
592
|
mayRead(conn, audience) {
|
|
515
593
|
if (!this.canRead) return true;
|
|
516
594
|
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
@@ -538,6 +616,91 @@ var SyncHub = class {
|
|
|
538
616
|
}
|
|
539
617
|
return sent;
|
|
540
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Translates `op` for the peer and sends it. Returns false when the op is
|
|
621
|
+
* lossy for this peer (no legacy encoding) or the socket throws; neither
|
|
622
|
+
* may reject the room operation that produced it.
|
|
623
|
+
*/
|
|
624
|
+
sendToConnection(conn, from, op, encoded) {
|
|
625
|
+
const capabilities = this.peerCapabilities.get(conn.id) ?? createLegacyCapabilities();
|
|
626
|
+
const revealOwner = this.mayReadOwnerId(conn);
|
|
627
|
+
const profile = encoded ? encodingProfile(capabilities, revealOwner) : void 0;
|
|
628
|
+
let message = profile === void 0 ? void 0 : encoded?.get(profile);
|
|
629
|
+
if (message === void 0) {
|
|
630
|
+
message = this.encodeForPeer(from, op, capabilities, revealOwner);
|
|
631
|
+
if (profile !== void 0) encoded?.set(profile, message);
|
|
632
|
+
}
|
|
633
|
+
if (message === null) return false;
|
|
634
|
+
try {
|
|
635
|
+
conn.send(message);
|
|
636
|
+
return true;
|
|
637
|
+
} catch {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Returns the wire frame for `op` translated for `capabilities`, or null
|
|
643
|
+
* when lossy. The server-stamped `ownerId` is a server-side authorization
|
|
644
|
+
* fact: it leaves the hub only for peers `canReadOwnerId` admits.
|
|
645
|
+
*/
|
|
646
|
+
encodeForPeer(from, op, capabilities, revealOwner) {
|
|
647
|
+
try {
|
|
648
|
+
const translated = translateOpForPeer(
|
|
649
|
+
revealOwner ? op : stripOwnerId(op),
|
|
650
|
+
capabilities,
|
|
651
|
+
this.elementRegistry,
|
|
652
|
+
this.pluginRegistry.extensionDefinitions
|
|
653
|
+
);
|
|
654
|
+
return JSON.stringify({ from, op: translated });
|
|
655
|
+
} catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Normalize registered legacy wire elements before authorization, storage,
|
|
661
|
+
* and relay. A legacy type this hub has no adapter for is forwarded and
|
|
662
|
+
* stored verbatim — the hub is a relay, and the peers decide whether they
|
|
663
|
+
* understand it — so a hub deployed without domain adapters never erases
|
|
664
|
+
* the room's existing elements. Only a malformed registered element is dropped.
|
|
665
|
+
*/
|
|
666
|
+
normalizeElement(element) {
|
|
667
|
+
if (isValidElement(element)) return element;
|
|
668
|
+
const adapter = this.elementRegistry.getAdapterByLegacyType(element.type);
|
|
669
|
+
if (!adapter) return element;
|
|
670
|
+
try {
|
|
671
|
+
const raw = Object.fromEntries(Object.entries(element));
|
|
672
|
+
const envelope = adapter.decodeLegacy(raw);
|
|
673
|
+
if (!adapter.validateEnvelope(envelope)) return null;
|
|
674
|
+
return {
|
|
675
|
+
...envelope,
|
|
676
|
+
...typeof raw["audience"] === "string" ? { audience: raw["audience"] } : {},
|
|
677
|
+
...typeof raw["ownerId"] === "string" ? { ownerId: raw["ownerId"] } : {}
|
|
678
|
+
};
|
|
679
|
+
} catch {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
normalizeElements(elements) {
|
|
684
|
+
const normalized = [];
|
|
685
|
+
for (const element of elements) {
|
|
686
|
+
const runtime = this.normalizeElement(element);
|
|
687
|
+
if (runtime) normalized.push(runtime);
|
|
688
|
+
}
|
|
689
|
+
return normalized;
|
|
690
|
+
}
|
|
691
|
+
relayOpToRoom(room, excludeId, from, op) {
|
|
692
|
+
const members = this.rooms.get(room);
|
|
693
|
+
if (!members) return 0;
|
|
694
|
+
let sent = 0;
|
|
695
|
+
const encoded = /* @__PURE__ */ new Map();
|
|
696
|
+
for (const connectionId of members) {
|
|
697
|
+
if (connectionId === excludeId) continue;
|
|
698
|
+
const conn = this.conns.get(connectionId);
|
|
699
|
+
if (!conn) continue;
|
|
700
|
+
if (this.sendToConnection(conn, from, op, encoded)) sent += 1;
|
|
701
|
+
}
|
|
702
|
+
return sent;
|
|
703
|
+
}
|
|
541
704
|
broadcastClientPresence(conn, data) {
|
|
542
705
|
this.presenceConnections.add(conn.id);
|
|
543
706
|
const message = JSON.stringify({ from: conn.id, op: { kind: "presence", data } });
|
|
@@ -619,41 +782,33 @@ var SyncHub = class {
|
|
|
619
782
|
deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
|
|
620
783
|
const members = this.rooms.get(room);
|
|
621
784
|
if (!members) return;
|
|
622
|
-
const
|
|
623
|
-
try {
|
|
624
|
-
conn.send(msg);
|
|
625
|
-
} catch {
|
|
626
|
-
}
|
|
627
|
-
};
|
|
785
|
+
const encoded = /* @__PURE__ */ new Map();
|
|
628
786
|
if (op.kind === "upsert") {
|
|
629
787
|
const audience = op.element.audience;
|
|
630
|
-
const
|
|
631
|
-
const
|
|
632
|
-
from: HUB_FROM,
|
|
633
|
-
op: { kind: "remove", id: op.element.id }
|
|
634
|
-
});
|
|
788
|
+
const removeOp = { kind: "remove", id: op.element.id };
|
|
789
|
+
const encodedRemove = /* @__PURE__ */ new Map();
|
|
635
790
|
for (const cid of members) {
|
|
636
791
|
if (cid === excludeId) continue;
|
|
637
792
|
const conn = this.conns.get(cid);
|
|
638
793
|
if (!conn) continue;
|
|
639
|
-
if (this.mayRead(conn, audience))
|
|
640
|
-
else if (prevExisted && this.mayRead(conn, prevAudience))
|
|
794
|
+
if (this.mayRead(conn, audience)) this.sendToConnection(conn, from, op, encoded);
|
|
795
|
+
else if (prevExisted && this.mayRead(conn, prevAudience)) {
|
|
796
|
+
this.sendToConnection(conn, HUB_FROM, removeOp, encodedRemove);
|
|
797
|
+
}
|
|
641
798
|
}
|
|
642
799
|
} else if (op.kind === "remove") {
|
|
643
|
-
const removeMsg = JSON.stringify({ from, op });
|
|
644
800
|
for (const cid of members) {
|
|
645
801
|
if (cid === excludeId) continue;
|
|
646
802
|
const conn = this.conns.get(cid);
|
|
647
803
|
if (!conn) continue;
|
|
648
804
|
const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
|
|
649
|
-
if (wasVisible)
|
|
805
|
+
if (wasVisible) this.sendToConnection(conn, from, op, encoded);
|
|
650
806
|
}
|
|
651
807
|
} else if (op.kind === "clear") {
|
|
652
|
-
const clearMsg = JSON.stringify({ from, op });
|
|
653
808
|
for (const cid of members) {
|
|
654
809
|
if (cid === excludeId) continue;
|
|
655
810
|
const conn = this.conns.get(cid);
|
|
656
|
-
if (conn)
|
|
811
|
+
if (conn) this.sendToConnection(conn, from, op, encoded);
|
|
657
812
|
}
|
|
658
813
|
}
|
|
659
814
|
}
|
|
@@ -664,13 +819,14 @@ var SyncHub = class {
|
|
|
664
819
|
} else if (op.kind === "remove") {
|
|
665
820
|
correction = current ? this.mayRead(conn, current.audience) ? { kind: "upsert", element: current } : { kind: "remove", id: current.id } : void 0;
|
|
666
821
|
} else if (op.kind === "clear") {
|
|
667
|
-
const all = await this.backend.snapshot(conn.room);
|
|
822
|
+
const all = this.normalizeElements(await this.backend.snapshot(conn.room));
|
|
668
823
|
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
669
824
|
correction = { kind: "snapshot", to: from, elements };
|
|
670
825
|
}
|
|
671
|
-
if (correction)
|
|
826
|
+
if (correction) this.sendToConnection(conn, HUB_FROM, correction);
|
|
672
827
|
}
|
|
673
828
|
onFanout(payload) {
|
|
829
|
+
if (!hasJsonDepthAtMost(payload, this.maxJsonDepth)) return;
|
|
674
830
|
let env;
|
|
675
831
|
try {
|
|
676
832
|
env = JSON.parse(payload);
|
|
@@ -680,19 +836,30 @@ var SyncHub = class {
|
|
|
680
836
|
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
681
837
|
return;
|
|
682
838
|
if (env.o === this.instanceId) return;
|
|
683
|
-
const
|
|
839
|
+
const envelope = { from: env.from, op: env.op };
|
|
840
|
+
if (!isValidEnvelope(envelope)) return;
|
|
841
|
+
const op = envelope.op;
|
|
684
842
|
if (isPresenceOp(op)) {
|
|
843
|
+
if (op.kind === "presence" && !this.isPresenceWithinLimit(op.data)) return;
|
|
685
844
|
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
686
845
|
return;
|
|
687
846
|
}
|
|
688
847
|
if (isLayerOp(op)) {
|
|
689
848
|
void this.applyFanoutLayerOp(env.room, op).catch(() => {
|
|
690
849
|
});
|
|
691
|
-
this.
|
|
850
|
+
this.relayOpToRoom(env.room, void 0, env.from, op);
|
|
692
851
|
return;
|
|
693
852
|
}
|
|
694
|
-
|
|
695
|
-
if (
|
|
853
|
+
let plugin;
|
|
854
|
+
if (op.kind === "extension") {
|
|
855
|
+
const entry = this.pluginRegistry.extension(op.extensionKind);
|
|
856
|
+
if (!entry || !entry.kind.codec.validate(op.payload)) return;
|
|
857
|
+
plugin = entry.plugin;
|
|
858
|
+
} else {
|
|
859
|
+
plugin = this.pluginRegistry.ownerOf(op.kind);
|
|
860
|
+
}
|
|
861
|
+
if (plugin) {
|
|
862
|
+
const owner = plugin;
|
|
696
863
|
const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
|
|
697
864
|
const operation = previous.then(async () => {
|
|
698
865
|
const context = {
|
|
@@ -701,13 +868,9 @@ var SyncHub = class {
|
|
|
701
868
|
backend: this.backend,
|
|
702
869
|
backendPlugin: (key) => this.backend.getService?.(key)
|
|
703
870
|
};
|
|
704
|
-
const accepted =
|
|
871
|
+
const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
|
|
705
872
|
if (accepted) {
|
|
706
|
-
this.
|
|
707
|
-
env.room,
|
|
708
|
-
void 0,
|
|
709
|
-
JSON.stringify({ from: env.from, op: accepted })
|
|
710
|
-
);
|
|
873
|
+
this.relayOpToRoom(env.room, void 0, env.from, accepted);
|
|
711
874
|
}
|
|
712
875
|
});
|
|
713
876
|
this.roomQueues.set(
|
|
@@ -718,9 +881,17 @@ var SyncHub = class {
|
|
|
718
881
|
return;
|
|
719
882
|
}
|
|
720
883
|
if (!isFanoutOp(op)) return;
|
|
884
|
+
const runtimeOp = op.kind === "upsert" ? (() => {
|
|
885
|
+
const element = this.normalizeElement(op.element);
|
|
886
|
+
return element ? { ...op, element } : null;
|
|
887
|
+
})() : op;
|
|
888
|
+
if (!runtimeOp) return;
|
|
721
889
|
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
722
890
|
const prevExisted = env.existed === true;
|
|
723
|
-
this.deliverToRoom(env.room, void 0, env.from,
|
|
891
|
+
this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
|
|
892
|
+
}
|
|
893
|
+
isPresenceWithinLimit(data) {
|
|
894
|
+
return utf8ByteLength(JSON.stringify(data) ?? "") <= this.maxPresenceBytes;
|
|
724
895
|
}
|
|
725
896
|
async applyFanoutLayerOp(room, op) {
|
|
726
897
|
const record = layerOpToRecord(op);
|
|
@@ -737,6 +908,23 @@ var SyncHub = class {
|
|
|
737
908
|
// src/create-sync-server.ts
|
|
738
909
|
import { WebSocketServer } from "ws";
|
|
739
910
|
|
|
911
|
+
// src/authenticate.ts
|
|
912
|
+
import { readBearerSubprotocol } from "@fieldnotes/sync";
|
|
913
|
+
function readBearerToken(req) {
|
|
914
|
+
const fromProtocol = readBearerSubprotocol(headerValue(req.headers["sec-websocket-protocol"]));
|
|
915
|
+
if (fromProtocol) return fromProtocol;
|
|
916
|
+
const authorization = headerValue(req.headers["authorization"]);
|
|
917
|
+
if (authorization) {
|
|
918
|
+
const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
|
|
919
|
+
if (match?.[1]) return match[1];
|
|
920
|
+
}
|
|
921
|
+
const query = new URL(req.url ?? "", "http://localhost").searchParams.get("token");
|
|
922
|
+
return query || void 0;
|
|
923
|
+
}
|
|
924
|
+
function headerValue(value) {
|
|
925
|
+
return Array.isArray(value) ? value.join(",") : value;
|
|
926
|
+
}
|
|
927
|
+
|
|
740
928
|
// src/heartbeat.ts
|
|
741
929
|
function startHeartbeat(wss, intervalMs) {
|
|
742
930
|
if (intervalMs <= 0) {
|
|
@@ -768,6 +956,9 @@ function startHeartbeat(wss, intervalMs) {
|
|
|
768
956
|
return { track, stop: () => clearInterval(interval) };
|
|
769
957
|
}
|
|
770
958
|
|
|
959
|
+
// src/create-sync-server.ts
|
|
960
|
+
import { BEARER_SUBPROTOCOL_PREFIX, SYNC_WS_SUBPROTOCOL } from "@fieldnotes/sync";
|
|
961
|
+
|
|
771
962
|
// src/shutdown.ts
|
|
772
963
|
var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
|
|
773
964
|
function drainWebSocketServer(wss, graceMs) {
|
|
@@ -802,16 +993,78 @@ function drainWebSocketServer(wss, graceMs) {
|
|
|
802
993
|
});
|
|
803
994
|
}
|
|
804
995
|
|
|
996
|
+
// src/room-name.ts
|
|
997
|
+
var ROOM_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
998
|
+
function isValidRoomName(room) {
|
|
999
|
+
return typeof room === "string" && ROOM_NAME_PATTERN.test(room);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
805
1002
|
// src/create-sync-server.ts
|
|
1003
|
+
var ConcurrencyCounter = class {
|
|
1004
|
+
constructor(limit) {
|
|
1005
|
+
this.limit = limit;
|
|
1006
|
+
}
|
|
1007
|
+
counts = /* @__PURE__ */ new Map();
|
|
1008
|
+
/** Reserves a slot for `key`; returns a release function, or null when the cap is reached. */
|
|
1009
|
+
acquire(key) {
|
|
1010
|
+
const current = this.counts.get(key) ?? 0;
|
|
1011
|
+
if (current >= this.limit) return null;
|
|
1012
|
+
this.counts.set(key, current + 1);
|
|
1013
|
+
let released = false;
|
|
1014
|
+
return () => {
|
|
1015
|
+
if (released) return;
|
|
1016
|
+
released = true;
|
|
1017
|
+
const remaining = (this.counts.get(key) ?? 1) - 1;
|
|
1018
|
+
if (remaining <= 0) this.counts.delete(key);
|
|
1019
|
+
else this.counts.set(key, remaining);
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
806
1023
|
function rawDataByteLength(data) {
|
|
807
1024
|
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
808
1025
|
return data.byteLength;
|
|
809
1026
|
}
|
|
1027
|
+
function requirePositiveFinite(name, value) {
|
|
1028
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1029
|
+
throw new RangeError(`${name} must be a positive finite number`);
|
|
1030
|
+
}
|
|
1031
|
+
return value;
|
|
1032
|
+
}
|
|
1033
|
+
function requirePositiveIntegerOrInfinity(name, value) {
|
|
1034
|
+
if (value !== Infinity && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
1035
|
+
throw new RangeError(`${name} must be a positive safe integer or Infinity`);
|
|
1036
|
+
}
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
810
1039
|
function createSyncServer(options = {}) {
|
|
811
1040
|
const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
812
1041
|
if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
|
|
813
1042
|
throw new RangeError("shutdownGraceMs must be a non-negative finite number");
|
|
814
1043
|
}
|
|
1044
|
+
const messagesPerSecond = requirePositiveFinite(
|
|
1045
|
+
"messagesPerSecond",
|
|
1046
|
+
options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND
|
|
1047
|
+
);
|
|
1048
|
+
const messageBurst = requirePositiveFinite(
|
|
1049
|
+
"messageBurst",
|
|
1050
|
+
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
1051
|
+
);
|
|
1052
|
+
const bytesPerSecond = requirePositiveFinite(
|
|
1053
|
+
"bytesPerSecond",
|
|
1054
|
+
options.bytesPerSecond ?? DEFAULT_BYTES_PER_SECOND
|
|
1055
|
+
);
|
|
1056
|
+
const byteBurst = requirePositiveFinite("byteBurst", options.byteBurst ?? DEFAULT_BYTE_BURST);
|
|
1057
|
+
const maxConnectionsPerIp = requirePositiveIntegerOrInfinity(
|
|
1058
|
+
"maxConnectionsPerIp",
|
|
1059
|
+
options.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP
|
|
1060
|
+
);
|
|
1061
|
+
const maxConnectionsPerRoom = requirePositiveIntegerOrInfinity(
|
|
1062
|
+
"maxConnectionsPerRoom",
|
|
1063
|
+
options.maxConnectionsPerRoom ?? DEFAULT_MAX_CONNECTIONS_PER_ROOM
|
|
1064
|
+
);
|
|
1065
|
+
if (options.authorize && !options.authenticate) {
|
|
1066
|
+
throw new Error("createSyncServer: `authorize` requires an `authenticate` hook");
|
|
1067
|
+
}
|
|
815
1068
|
const hub = new SyncHub({
|
|
816
1069
|
backend: options.backend,
|
|
817
1070
|
fanout: options.fanout,
|
|
@@ -820,16 +1073,34 @@ function createSyncServer(options = {}) {
|
|
|
820
1073
|
authorizeLayer: options.authorizeLayer,
|
|
821
1074
|
plugins: options.plugins,
|
|
822
1075
|
canRead: options.canRead,
|
|
1076
|
+
canReadOwnerId: options.canReadOwnerId,
|
|
1077
|
+
resolveAudience: options.resolveAudience,
|
|
823
1078
|
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
824
1079
|
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
|
|
825
|
-
maxPresenceLanes: options.maxPresenceLanes
|
|
1080
|
+
maxPresenceLanes: options.maxPresenceLanes,
|
|
1081
|
+
maxPresenceBytes: options.maxPresenceBytes,
|
|
1082
|
+
elementRegistry: options.elementRegistry
|
|
826
1083
|
});
|
|
827
1084
|
const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
828
|
-
const
|
|
1085
|
+
const handleProtocols = (protocols) => {
|
|
1086
|
+
if (protocols.has(SYNC_WS_SUBPROTOCOL)) return SYNC_WS_SUBPROTOCOL;
|
|
1087
|
+
for (const protocol of protocols) {
|
|
1088
|
+
if (!protocol.startsWith(BEARER_SUBPROTOCOL_PREFIX)) return protocol;
|
|
1089
|
+
}
|
|
1090
|
+
return false;
|
|
1091
|
+
};
|
|
1092
|
+
const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes, handleProtocols }) : new WebSocketServer({
|
|
1093
|
+
port: options.port ?? 0,
|
|
1094
|
+
maxPayload: maxMessageBytes,
|
|
1095
|
+
handleProtocols
|
|
1096
|
+
});
|
|
829
1097
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
830
1098
|
let shuttingDown = false;
|
|
831
1099
|
let closePromise;
|
|
832
1100
|
let counter = 0;
|
|
1101
|
+
const perIp = new ConcurrencyCounter(maxConnectionsPerIp);
|
|
1102
|
+
const perRoom = new ConcurrencyCounter(maxConnectionsPerRoom);
|
|
1103
|
+
const clientAddress = options.clientAddress ?? ((req) => req.socket.remoteAddress);
|
|
833
1104
|
wss.on("connection", (ws, req) => {
|
|
834
1105
|
if (shuttingDown) {
|
|
835
1106
|
ws.close(1001, "server shutting down");
|
|
@@ -843,6 +1114,22 @@ function createSyncServer(options = {}) {
|
|
|
843
1114
|
ws.close(4400, "room required");
|
|
844
1115
|
return;
|
|
845
1116
|
}
|
|
1117
|
+
if (!isValidRoomName(room)) {
|
|
1118
|
+
ws.close(4400, "invalid room");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const address = clientAddress(req);
|
|
1122
|
+
const releaseIp = address ? perIp.acquire(address) : () => void 0;
|
|
1123
|
+
if (!releaseIp) {
|
|
1124
|
+
ws.close(4429, "too many connections");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const releaseRoom = perRoom.acquire(room);
|
|
1128
|
+
if (!releaseRoom) {
|
|
1129
|
+
releaseIp();
|
|
1130
|
+
ws.close(4429, "too many connections");
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
846
1133
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
847
1134
|
let state = "pending";
|
|
848
1135
|
let closed = false;
|
|
@@ -851,10 +1138,8 @@ function createSyncServer(options = {}) {
|
|
|
851
1138
|
let queuedBytes = 0;
|
|
852
1139
|
const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
|
|
853
1140
|
const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
|
|
854
|
-
const limiter = new MessageRateLimiter(
|
|
855
|
-
|
|
856
|
-
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
857
|
-
);
|
|
1141
|
+
const limiter = new MessageRateLimiter(messagesPerSecond, messageBurst);
|
|
1142
|
+
const byteLimiter = new MessageRateLimiter(bytesPerSecond, byteBurst);
|
|
858
1143
|
const send = (m) => {
|
|
859
1144
|
try {
|
|
860
1145
|
ws.send(m);
|
|
@@ -869,7 +1154,8 @@ function createSyncServer(options = {}) {
|
|
|
869
1154
|
ws.close(1009, "message too large");
|
|
870
1155
|
return;
|
|
871
1156
|
}
|
|
872
|
-
|
|
1157
|
+
const now = Date.now();
|
|
1158
|
+
if (!limiter.take(now) || !byteLimiter.take(now, messageBytes)) {
|
|
873
1159
|
state = "rejected";
|
|
874
1160
|
ws.close(4408, "rate limit exceeded");
|
|
875
1161
|
return;
|
|
@@ -889,9 +1175,13 @@ function createSyncServer(options = {}) {
|
|
|
889
1175
|
});
|
|
890
1176
|
ws.on("close", () => {
|
|
891
1177
|
closed = true;
|
|
1178
|
+
releaseIp();
|
|
1179
|
+
releaseRoom();
|
|
892
1180
|
if (admitted) hub.removeConnection(connId);
|
|
893
1181
|
});
|
|
894
|
-
Promise.resolve(
|
|
1182
|
+
Promise.resolve(
|
|
1183
|
+
options.authenticate ? options.authenticate({ req, room, token: readBearerToken(req) }) : { userId: connId }
|
|
1184
|
+
).then((result) => {
|
|
895
1185
|
if (closed || state === "rejected" || shuttingDown) return;
|
|
896
1186
|
if (!result) {
|
|
897
1187
|
state = "rejected";
|
|
@@ -931,8 +1221,11 @@ function createSyncServer(options = {}) {
|
|
|
931
1221
|
export {
|
|
932
1222
|
InMemoryHubFanout,
|
|
933
1223
|
MemoryHubBackend,
|
|
1224
|
+
ROOM_NAME_PATTERN,
|
|
934
1225
|
SyncHub,
|
|
935
1226
|
createSyncServer,
|
|
1227
|
+
isValidRoomName,
|
|
1228
|
+
readBearerToken,
|
|
936
1229
|
startHeartbeat
|
|
937
1230
|
};
|
|
938
1231
|
//# sourceMappingURL=index.js.map
|