@fieldnotes/sync-server 0.17.1 → 0.18.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 CHANGED
@@ -184,13 +184,13 @@ authorize(ctx) => boolean | Promise<boolean>
184
184
  userId?: string; // the connection's authenticated user (from authenticate)
185
185
  role?: string; // the connection's role (from authenticate)
186
186
  room: string;
187
- op: SyncOp; // the incoming upsert / remove / clear
187
+ op: WireSyncOp; // the incoming upsert / remove / clear
188
188
  currentElement?: OwnedElement; // the STORED element, if this id already exists
189
189
  }
190
190
  ```
191
191
 
192
192
  `currentElement` is the element currently in room state for an `upsert`/`remove` of an
193
- **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
193
+ **existing** id (typed `OwnedElement = WireSyncElement`), and
194
194
  `undefined` for a new/absent id.
195
195
 
196
196
  **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
package/dist/index.cjs CHANGED
@@ -30,6 +30,7 @@ module.exports = __toCommonJS(index_exports);
30
30
 
31
31
  // src/sync-hub.ts
32
32
  var import_sync2 = require("@fieldnotes/sync");
33
+ var import_core = require("@fieldnotes/core");
33
34
 
34
35
  // src/memory-hub-backend.ts
35
36
  var import_sync = require("@fieldnotes/sync");
@@ -98,8 +99,10 @@ var ServerPluginRegistry = class {
98
99
  byName = /* @__PURE__ */ new Map();
99
100
  legacyOwners = /* @__PURE__ */ new Map();
100
101
  extensions = /* @__PURE__ */ new Map();
102
+ definitions;
101
103
  constructor(plugins) {
102
104
  for (const plugin of plugins) this.register(plugin);
105
+ this.definitions = new Map([...this.extensions].map(([name, entry]) => [name, entry.kind]));
103
106
  }
104
107
  register(plugin) {
105
108
  if (this.byName.has(plugin.name))
@@ -131,6 +134,12 @@ var ServerPluginRegistry = class {
131
134
  extension(extensionKind) {
132
135
  return this.extensions.get(extensionKind);
133
136
  }
137
+ get extensionKinds() {
138
+ return [...this.extensions.keys()];
139
+ }
140
+ get extensionDefinitions() {
141
+ return this.definitions;
142
+ }
134
143
  };
135
144
 
136
145
  // src/resource-limits.ts
@@ -193,7 +202,7 @@ function generateInstanceId() {
193
202
  function isFanoutOp(op) {
194
203
  if (typeof op !== "object" || op === null) return false;
195
204
  const o = op;
196
- if (o.kind === "upsert") return (0, import_sync2.isValidElement)(o.element);
205
+ if (o.kind === "upsert") return (0, import_sync2.isValidWireElement)(o.element);
197
206
  if (o.kind === "remove") return typeof o.id === "string";
198
207
  return o.kind === "clear";
199
208
  }
@@ -223,6 +232,9 @@ function layerRecordToOp(record) {
223
232
  editor: record.editor
224
233
  } : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
225
234
  }
235
+ function capabilityProfile(capabilities) {
236
+ return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds]);
237
+ }
226
238
  function isPresenceOp(op) {
227
239
  if (typeof op !== "object" || op === null) return false;
228
240
  const k = op.kind;
@@ -242,6 +254,8 @@ var SyncHub = class {
242
254
  authorize;
243
255
  authorizeLayer;
244
256
  pluginRegistry;
257
+ elementRegistry;
258
+ peerCapabilities = /* @__PURE__ */ new Map();
245
259
  canRead;
246
260
  memoryLayers = /* @__PURE__ */ new Map();
247
261
  maxJsonDepth;
@@ -260,6 +274,7 @@ var SyncHub = class {
260
274
  constructor(options = {}) {
261
275
  this.backend = options.backend ?? new MemoryHubBackend();
262
276
  this.pluginRegistry = new ServerPluginRegistry(options.plugins ?? []);
277
+ this.elementRegistry = options.elementRegistry ?? (0, import_core.getDefaultElementRegistry)();
263
278
  this.instanceId = options.instanceId ?? generateInstanceId();
264
279
  this.fanout = options.fanout ?? new InMemoryHubFanout();
265
280
  this.authorize = options.authorize;
@@ -287,6 +302,7 @@ var SyncHub = class {
287
302
  const conn = this.conns.get(connId);
288
303
  if (!conn) return;
289
304
  this.conns.delete(connId);
305
+ this.peerCapabilities.delete(connId);
290
306
  const room = conn.room;
291
307
  const hadPresence = this.presenceConnections.delete(connId);
292
308
  this.clearPresenceLanes(connId);
@@ -320,6 +336,14 @@ var SyncHub = class {
320
336
  if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();
321
337
  const env = (0, import_sync2.parseEnvelope)(message);
322
338
  if (!env) return Promise.resolve();
339
+ if (env.op.kind === "capabilities") {
340
+ this.peerCapabilities.set(conn.id, env.op.capabilities);
341
+ this.sendToConnection(conn, HUB_FROM, {
342
+ kind: "capabilities",
343
+ capabilities: (0, import_sync2.createCurrentCapabilities)(this.pluginRegistry.extensionKinds)
344
+ });
345
+ return Promise.resolve();
346
+ }
323
347
  if (env.op.kind === "presence") {
324
348
  this.schedulePresence(conn, env.op.data);
325
349
  return Promise.resolve();
@@ -335,9 +359,14 @@ var SyncHub = class {
335
359
  return operation;
336
360
  }
337
361
  async process(conn, env) {
338
- const op = env.op;
362
+ let op = env.op;
363
+ if (op.kind === "upsert") {
364
+ const element = this.normalizeElement(op.element);
365
+ if (!element) return;
366
+ op = { ...op, element };
367
+ }
339
368
  if (op.kind === "request-snapshot") {
340
- const all = await this.backend.snapshot(conn.room);
369
+ const all = this.normalizeElements(await this.backend.snapshot(conn.room));
341
370
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
342
371
  const layers = await this.getLayerRecords(conn.room);
343
372
  const snapshotOp = {
@@ -358,7 +387,7 @@ var SyncHub = class {
358
387
  else extensions[plugin.name] = snapshot;
359
388
  }
360
389
  if (Object.keys(extensions).length > 0) snapshotOp["extensions"] = extensions;
361
- conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
390
+ this.sendToConnection(conn, HUB_FROM, snapshotOp);
362
391
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
363
392
  await this.processLayerOp(conn, op);
364
393
  } else if (op.kind === "extension") {
@@ -376,7 +405,8 @@ var SyncHub = class {
376
405
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
377
406
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
378
407
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
379
- const current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
408
+ const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
409
+ const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
380
410
  let outboundOp = op;
381
411
  if (this.authorize) {
382
412
  const allowed = await this.authorize({
@@ -400,7 +430,7 @@ var SyncHub = class {
400
430
  const prevAudience = current?.audience;
401
431
  const result = await this.runCorePlugins(conn, outboundOp);
402
432
  for (const correction of result.corrections) {
403
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
433
+ this.sendToConnection(conn, HUB_FROM, correction);
404
434
  }
405
435
  const accepted = result.accepted;
406
436
  if (accepted && (accepted.kind === "upsert" || accepted.kind === "remove" || accepted.kind === "clear")) {
@@ -456,7 +486,7 @@ var SyncHub = class {
456
486
  }
457
487
  async deliverPluginResult(conn, result) {
458
488
  for (const correction of result.corrections) {
459
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
489
+ this.sendToConnection(conn, HUB_FROM, correction);
460
490
  }
461
491
  if (result.accepted) await this.publishPluginOp(conn, result.accepted, result.locality);
462
492
  for (const broadcast of result.broadcast ?? []) {
@@ -469,7 +499,7 @@ var SyncHub = class {
469
499
  JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
470
500
  );
471
501
  }
472
- this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
502
+ this.relayOpToRoom(conn.room, conn.id, conn.id, op);
473
503
  }
474
504
  /**
475
505
  * Applies a layer-definition edit on the room's serial queue. Convergence is
@@ -491,19 +521,19 @@ var SyncHub = class {
491
521
  });
492
522
  if (!allowed) {
493
523
  const correction = current ?? { id: record.id, version: record.version, editor: HUB_FROM };
494
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(correction) }));
524
+ this.sendToConnection(conn, HUB_FROM, layerRecordToOp(correction));
495
525
  return;
496
526
  }
497
527
  }
498
528
  if (current && !(0, import_sync2.isNewerLayerRecord)(record, current)) {
499
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(current) }));
529
+ this.sendToConnection(conn, HUB_FROM, layerRecordToOp(current));
500
530
  return;
501
531
  }
502
532
  await this.applyLayerRecord(conn.room, record);
503
533
  await this.fanout.publish(
504
534
  JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
505
535
  );
506
- this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
536
+ this.relayOpToRoom(conn.room, conn.id, conn.id, op);
507
537
  }
508
538
  layerBackend() {
509
539
  const { layerRecords, getLayerRecord, applyLayerRecord } = this.backend;
@@ -564,6 +594,86 @@ var SyncHub = class {
564
594
  }
565
595
  return sent;
566
596
  }
597
+ /**
598
+ * Translates `op` for the peer and sends it. Returns false when the op is
599
+ * lossy for this peer (no legacy encoding) or the socket throws; neither
600
+ * may reject the room operation that produced it.
601
+ */
602
+ sendToConnection(conn, from, op, encoded) {
603
+ const capabilities = this.peerCapabilities.get(conn.id) ?? (0, import_sync2.createLegacyCapabilities)();
604
+ const profile = encoded ? capabilityProfile(capabilities) : void 0;
605
+ let message = profile === void 0 ? void 0 : encoded?.get(profile);
606
+ if (message === void 0) {
607
+ message = this.encodeForPeer(from, op, capabilities);
608
+ if (profile !== void 0) encoded?.set(profile, message);
609
+ }
610
+ if (message === null) return false;
611
+ try {
612
+ conn.send(message);
613
+ return true;
614
+ } catch {
615
+ return false;
616
+ }
617
+ }
618
+ /** Returns the wire frame for `op` translated for `capabilities`, or null when lossy. */
619
+ encodeForPeer(from, op, capabilities) {
620
+ try {
621
+ const translated = (0, import_sync2.translateOpForPeer)(
622
+ op,
623
+ capabilities,
624
+ this.elementRegistry,
625
+ this.pluginRegistry.extensionDefinitions
626
+ );
627
+ return JSON.stringify({ from, op: translated });
628
+ } catch {
629
+ return null;
630
+ }
631
+ }
632
+ /**
633
+ * Normalize registered legacy wire elements before authorization, storage,
634
+ * and relay. A legacy type this hub has no adapter for is forwarded and
635
+ * stored verbatim — the hub is a relay, and the peers decide whether they
636
+ * understand it — so a hub deployed without domain adapters never erases
637
+ * the room's existing elements. Only a malformed registered element is dropped.
638
+ */
639
+ normalizeElement(element) {
640
+ if ((0, import_sync2.isValidElement)(element)) return element;
641
+ const adapter = this.elementRegistry.getAdapterByLegacyType(element.type);
642
+ if (!adapter) return element;
643
+ try {
644
+ const raw = Object.fromEntries(Object.entries(element));
645
+ const envelope = adapter.decodeLegacy(raw);
646
+ if (!adapter.validateEnvelope(envelope)) return null;
647
+ return {
648
+ ...envelope,
649
+ ...typeof raw["audience"] === "string" ? { audience: raw["audience"] } : {},
650
+ ...typeof raw["ownerId"] === "string" ? { ownerId: raw["ownerId"] } : {}
651
+ };
652
+ } catch {
653
+ return null;
654
+ }
655
+ }
656
+ normalizeElements(elements) {
657
+ const normalized = [];
658
+ for (const element of elements) {
659
+ const runtime = this.normalizeElement(element);
660
+ if (runtime) normalized.push(runtime);
661
+ }
662
+ return normalized;
663
+ }
664
+ relayOpToRoom(room, excludeId, from, op) {
665
+ const members = this.rooms.get(room);
666
+ if (!members) return 0;
667
+ let sent = 0;
668
+ const encoded = /* @__PURE__ */ new Map();
669
+ for (const connectionId of members) {
670
+ if (connectionId === excludeId) continue;
671
+ const conn = this.conns.get(connectionId);
672
+ if (!conn) continue;
673
+ if (this.sendToConnection(conn, from, op, encoded)) sent += 1;
674
+ }
675
+ return sent;
676
+ }
567
677
  broadcastClientPresence(conn, data) {
568
678
  this.presenceConnections.add(conn.id);
569
679
  const message = JSON.stringify({ from: conn.id, op: { kind: "presence", data } });
@@ -645,41 +755,33 @@ var SyncHub = class {
645
755
  deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
646
756
  const members = this.rooms.get(room);
647
757
  if (!members) return;
648
- const send = (conn, msg) => {
649
- try {
650
- conn.send(msg);
651
- } catch {
652
- }
653
- };
758
+ const encoded = /* @__PURE__ */ new Map();
654
759
  if (op.kind === "upsert") {
655
760
  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
- });
761
+ const removeOp = { kind: "remove", id: op.element.id };
762
+ const encodedRemove = /* @__PURE__ */ new Map();
661
763
  for (const cid of members) {
662
764
  if (cid === excludeId) continue;
663
765
  const conn = this.conns.get(cid);
664
766
  if (!conn) continue;
665
- if (this.mayRead(conn, audience)) send(conn, upsertMsg);
666
- else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
767
+ if (this.mayRead(conn, audience)) this.sendToConnection(conn, from, op, encoded);
768
+ else if (prevExisted && this.mayRead(conn, prevAudience)) {
769
+ this.sendToConnection(conn, HUB_FROM, removeOp, encodedRemove);
770
+ }
667
771
  }
668
772
  } else if (op.kind === "remove") {
669
- const removeMsg = JSON.stringify({ from, op });
670
773
  for (const cid of members) {
671
774
  if (cid === excludeId) continue;
672
775
  const conn = this.conns.get(cid);
673
776
  if (!conn) continue;
674
777
  const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
675
- if (wasVisible) send(conn, removeMsg);
778
+ if (wasVisible) this.sendToConnection(conn, from, op, encoded);
676
779
  }
677
780
  } else if (op.kind === "clear") {
678
- const clearMsg = JSON.stringify({ from, op });
679
781
  for (const cid of members) {
680
782
  if (cid === excludeId) continue;
681
783
  const conn = this.conns.get(cid);
682
- if (conn) send(conn, clearMsg);
784
+ if (conn) this.sendToConnection(conn, from, op, encoded);
683
785
  }
684
786
  }
685
787
  }
@@ -690,11 +792,11 @@ var SyncHub = class {
690
792
  } else if (op.kind === "remove") {
691
793
  correction = current ? this.mayRead(conn, current.audience) ? { kind: "upsert", element: current } : { kind: "remove", id: current.id } : void 0;
692
794
  } else if (op.kind === "clear") {
693
- const all = await this.backend.snapshot(conn.room);
795
+ const all = this.normalizeElements(await this.backend.snapshot(conn.room));
694
796
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
695
797
  correction = { kind: "snapshot", to: from, elements };
696
798
  }
697
- if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
799
+ if (correction) this.sendToConnection(conn, HUB_FROM, correction);
698
800
  }
699
801
  onFanout(payload) {
700
802
  let env;
@@ -714,7 +816,7 @@ var SyncHub = class {
714
816
  if (isLayerOp(op)) {
715
817
  void this.applyFanoutLayerOp(env.room, op).catch(() => {
716
818
  });
717
- this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
819
+ this.relayOpToRoom(env.room, void 0, env.from, op);
718
820
  return;
719
821
  }
720
822
  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;
@@ -729,11 +831,7 @@ var SyncHub = class {
729
831
  };
730
832
  const accepted = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
731
833
  if (accepted) {
732
- this.relayToRoom(
733
- env.room,
734
- void 0,
735
- JSON.stringify({ from: env.from, op: accepted })
736
- );
834
+ this.relayOpToRoom(env.room, void 0, env.from, accepted);
737
835
  }
738
836
  });
739
837
  this.roomQueues.set(
@@ -744,9 +842,14 @@ var SyncHub = class {
744
842
  return;
745
843
  }
746
844
  if (!isFanoutOp(op)) return;
845
+ const runtimeOp = op.kind === "upsert" ? (() => {
846
+ const element = this.normalizeElement(op.element);
847
+ return element ? { ...op, element } : null;
848
+ })() : op;
849
+ if (!runtimeOp) return;
747
850
  const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
748
851
  const prevExisted = env.existed === true;
749
- this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
852
+ this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
750
853
  }
751
854
  async applyFanoutLayerOp(room, op) {
752
855
  const record = layerOpToRecord(op);
@@ -848,7 +951,8 @@ function createSyncServer(options = {}) {
848
951
  canRead: options.canRead,
849
952
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
850
953
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
851
- maxPresenceLanes: options.maxPresenceLanes
954
+ maxPresenceLanes: options.maxPresenceLanes,
955
+ elementRegistry: options.elementRegistry
852
956
  });
853
957
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
854
958
  const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new import_ws.WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });