@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/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
- take(now = Date.now()) {
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 < 1) return false;
181
- this.tokens -= 1;
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
- if (typeof op !== "object" || op === null) return false;
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
- if (typeof op !== "object" || op === null) return false;
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
- if (typeof op !== "object" || op === null) return false;
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
- const op = env.op;
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
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 current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
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.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(correction) }));
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(current) }));
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.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
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 send = (conn, msg) => {
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 upsertMsg = JSON.stringify({ from, op });
657
- const removeMsg = JSON.stringify({
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)) send(conn, upsertMsg);
666
- else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
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) send(conn, removeMsg);
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) send(conn, clearMsg);
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) conn.send(JSON.stringify({ from: HUB_FROM, op: 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 op = env.op;
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.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
873
+ this.relayOpToRoom(env.room, void 0, env.from, op);
718
874
  return;
719
875
  }
720
- const plugin = typeof op === "object" && op !== null && op.kind === "extension" ? this.pluginRegistry.extension(op.extensionKind ?? "")?.plugin : typeof op === "object" && op !== null && typeof op.kind === "string" ? this.pluginRegistry.ownerOf(op.kind) : void 0;
721
- if (plugin && typeof op === "object" && op !== null) {
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 = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
894
+ const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
731
895
  if (accepted) {
732
- this.relayToRoom(
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, op, prevAudience, prevExisted);
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 wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new import_ws.WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
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
- options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,
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
- if (!limiter.take()) {
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(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
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