@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.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 { applyOpToMap } from "@fieldnotes/sync";
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
- take(now = Date.now()) {
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 < 1) return false;
155
- this.tokens -= 1;
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
- if (typeof op !== "object" || op === null) return false;
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
- if (typeof op !== "object" || op === null) return false;
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
- if (typeof op !== "object" || op === null) return false;
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
- const op = env.op;
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
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 current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
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.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
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
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(correction) }));
542
+ this.sendToConnection(conn, HUB_FROM, layerRecordToOp(correction));
469
543
  return;
470
544
  }
471
545
  }
472
546
  if (current && !isNewerLayerRecord(record, current)) {
473
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(current) }));
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.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
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 send = (conn, msg) => {
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 upsertMsg = JSON.stringify({ from, op });
631
- const removeMsg = JSON.stringify({
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)) send(conn, upsertMsg);
640
- else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
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) send(conn, removeMsg);
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) send(conn, clearMsg);
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) conn.send(JSON.stringify({ from: HUB_FROM, op: 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 op = env.op;
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.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
850
+ this.relayOpToRoom(env.room, void 0, env.from, op);
692
851
  return;
693
852
  }
694
- 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;
695
- if (plugin && typeof op === "object" && op !== null) {
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 = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
871
+ const accepted = owner.applyFanout ? await owner.applyFanout(op, context) : op;
705
872
  if (accepted) {
706
- this.relayToRoom(
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, op, prevAudience, prevExisted);
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 wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
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
- options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,
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
- if (!limiter.take()) {
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(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
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