@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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { CanvasElement, ServiceKey } from '@fieldnotes/core';
2
- import { SyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
1
+ import { ServiceKey, ElementRegistry } from '@fieldnotes/core';
2
+ import { WireSyncElement, WireSyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
3
3
  export { PluginSnapshot } from '@fieldnotes/sync';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { IncomingMessage, Server } from 'http';
@@ -18,24 +18,21 @@ declare class InMemoryHubFanout implements HubFanout {
18
18
  interface HubBackend {
19
19
  /** True when every hub instance addresses the same atomic backing state (for example Redis). */
20
20
  readonly sharedAcrossInstances?: boolean;
21
- snapshot(room: string): Promise<CanvasElement[]>;
22
- get(room: string, id: string): Promise<CanvasElement | undefined>;
23
- apply(room: string, op: SyncOp): Promise<void>;
21
+ snapshot(room: string): Promise<WireSyncElement[]>;
22
+ get(room: string, id: string): Promise<WireSyncElement | undefined>;
23
+ apply(room: string, op: WireSyncOp): Promise<void>;
24
24
  layerRecords?(room: string): Promise<LayerRecord[]>;
25
25
  getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
26
26
  applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
27
27
  getService?<T>(key: ServiceKey<T>): T | undefined;
28
28
  }
29
29
 
30
- type OwnedElement = CanvasElement & {
31
- ownerId?: string;
32
- audience?: string;
33
- };
30
+ type OwnedElement = WireSyncElement;
34
31
  interface AuthorizeContext {
35
32
  userId?: string;
36
33
  role?: string;
37
34
  room: string;
38
- op: SyncOp;
35
+ op: WireSyncOp;
39
36
  currentElement?: OwnedElement;
40
37
  }
41
38
  type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
@@ -43,7 +40,7 @@ interface AuthorizeLayerContext {
43
40
  userId?: string;
44
41
  role?: string;
45
42
  room: string;
46
- op: Extract<SyncOp, {
43
+ op: Extract<WireSyncOp, {
47
44
  kind: 'layer-upsert' | 'layer-remove';
48
45
  }>;
49
46
  /** The hub's current record for the target layer, tombstones included. */
@@ -65,9 +62,9 @@ interface ReadContext {
65
62
  type CanRead = (ctx: ReadContext) => boolean;
66
63
 
67
64
  interface ApplyResult {
68
- readonly accepted: SyncOp | null;
69
- readonly corrections: SyncOp[];
70
- readonly broadcast?: SyncOp[];
65
+ readonly accepted: WireSyncOp | null;
66
+ readonly corrections: WireSyncOp[];
67
+ readonly broadcast?: WireSyncOp[];
71
68
  readonly locality?: 'shared' | 'local';
72
69
  }
73
70
  interface ServerOpContext {
@@ -78,7 +75,7 @@ interface ServerOpContext {
78
75
  readonly backend: HubBackend;
79
76
  backendPlugin<T>(key: ServiceKey<T>): T | undefined;
80
77
  }
81
- type ServerNext = (op: SyncOp, context: ServerOpContext) => Promise<ApplyResult>;
78
+ type ServerNext = (op: WireSyncOp, context: ServerOpContext) => Promise<ApplyResult>;
82
79
  interface ServerExtensionRegistry {
83
80
  register<TPayload>(kind: ExtensionKind<TPayload>, handler: (op: TypedExtensionOp<TPayload>, context: ServerOpContext) => Promise<ApplyResult>): void;
84
81
  }
@@ -86,8 +83,8 @@ interface ServerSyncPlugin {
86
83
  readonly name: string;
87
84
  readonly ownedLegacyKinds?: readonly string[];
88
85
  readonly legacySnapshotKey?: string;
89
- process?(op: SyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
90
- applyFanout?(op: SyncOp, context: ServerOpContext): Promise<SyncOp | null>;
86
+ process?(op: WireSyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
87
+ applyFanout?(op: WireSyncOp, context: ServerOpContext): Promise<WireSyncOp | null>;
91
88
  registerExtensionKinds?(registry: ServerExtensionRegistry): void;
92
89
  snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot | undefined>;
93
90
  filterSnapshot?(snapshot: PluginSnapshot, viewer: {
@@ -114,6 +111,8 @@ interface SyncHubOptions {
114
111
  maxJsonDepth?: number;
115
112
  presenceThrottleMs?: number;
116
113
  maxPresenceLanes?: number;
114
+ /** Registry used to translate extension elements for legacy peers. */
115
+ elementRegistry?: ElementRegistry;
117
116
  }
118
117
  declare class SyncHub {
119
118
  private readonly backend;
@@ -127,6 +126,8 @@ declare class SyncHub {
127
126
  private readonly authorize?;
128
127
  private readonly authorizeLayer?;
129
128
  private readonly pluginRegistry;
129
+ private readonly elementRegistry;
130
+ private readonly peerCapabilities;
130
131
  private readonly canRead?;
131
132
  private readonly memoryLayers;
132
133
  private readonly maxJsonDepth;
@@ -173,6 +174,24 @@ declare class SyncHub {
173
174
  private mayRead;
174
175
  private safePublish;
175
176
  private relayToRoom;
177
+ /**
178
+ * Translates `op` for the peer and sends it. Returns false when the op is
179
+ * lossy for this peer (no legacy encoding) or the socket throws; neither
180
+ * may reject the room operation that produced it.
181
+ */
182
+ private sendToConnection;
183
+ /** Returns the wire frame for `op` translated for `capabilities`, or null when lossy. */
184
+ private encodeForPeer;
185
+ /**
186
+ * Normalize registered legacy wire elements before authorization, storage,
187
+ * and relay. A legacy type this hub has no adapter for is forwarded and
188
+ * stored verbatim — the hub is a relay, and the peers decide whether they
189
+ * understand it — so a hub deployed without domain adapters never erases
190
+ * the room's existing elements. Only a malformed registered element is dropped.
191
+ */
192
+ private normalizeElement;
193
+ private normalizeElements;
194
+ private relayOpToRoom;
176
195
  private broadcastClientPresence;
177
196
  private clearPresenceLanes;
178
197
  private schedulePresence;
@@ -190,9 +209,9 @@ declare class MemoryHubBackend implements HubBackend {
190
209
  private roomLayers;
191
210
  private room;
192
211
  private layers;
193
- snapshot(room: string): Promise<CanvasElement[]>;
194
- get(room: string, id: string): Promise<CanvasElement | undefined>;
195
- apply(room: string, op: SyncOp): Promise<void>;
212
+ snapshot(room: string): Promise<WireSyncElement[]>;
213
+ get(room: string, id: string): Promise<WireSyncElement | undefined>;
214
+ apply(room: string, op: WireSyncOp): Promise<void>;
196
215
  layerRecords(room: string): Promise<LayerRecord[]>;
197
216
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
198
217
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
@@ -229,6 +248,12 @@ interface CreateSyncServerOptions {
229
248
  presenceThrottleMs?: number;
230
249
  maxPresenceLanes?: number;
231
250
  shutdownGraceMs?: number;
251
+ /**
252
+ * Registry used to translate extension envelopes for legacy peers. Without
253
+ * it the hub relays unknown legacy element types verbatim and cannot encode
254
+ * envelopes for pre-envelope clients.
255
+ */
256
+ elementRegistry?: ElementRegistry;
232
257
  }
233
258
  declare function createSyncServer(options?: CreateSyncServerOptions): {
234
259
  hub: SyncHub;
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,
4
6
  isValidElement,
5
- isNewerLayerRecord
7
+ isValidWireElement,
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
@@ -167,7 +182,7 @@ function generateInstanceId() {
167
182
  function isFanoutOp(op) {
168
183
  if (typeof op !== "object" || op === null) return false;
169
184
  const o = op;
170
- if (o.kind === "upsert") return isValidElement(o.element);
185
+ if (o.kind === "upsert") return isValidWireElement(o.element);
171
186
  if (o.kind === "remove") return typeof o.id === "string";
172
187
  return o.kind === "clear";
173
188
  }
@@ -197,6 +212,9 @@ function layerRecordToOp(record) {
197
212
  editor: record.editor
198
213
  } : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
199
214
  }
215
+ function capabilityProfile(capabilities) {
216
+ return JSON.stringify([capabilities.elementEnvelope, capabilities.extensionKinds]);
217
+ }
200
218
  function isPresenceOp(op) {
201
219
  if (typeof op !== "object" || op === null) return false;
202
220
  const k = op.kind;
@@ -216,6 +234,8 @@ var SyncHub = class {
216
234
  authorize;
217
235
  authorizeLayer;
218
236
  pluginRegistry;
237
+ elementRegistry;
238
+ peerCapabilities = /* @__PURE__ */ new Map();
219
239
  canRead;
220
240
  memoryLayers = /* @__PURE__ */ new Map();
221
241
  maxJsonDepth;
@@ -234,6 +254,7 @@ var SyncHub = class {
234
254
  constructor(options = {}) {
235
255
  this.backend = options.backend ?? new MemoryHubBackend();
236
256
  this.pluginRegistry = new ServerPluginRegistry(options.plugins ?? []);
257
+ this.elementRegistry = options.elementRegistry ?? getDefaultElementRegistry();
237
258
  this.instanceId = options.instanceId ?? generateInstanceId();
238
259
  this.fanout = options.fanout ?? new InMemoryHubFanout();
239
260
  this.authorize = options.authorize;
@@ -261,6 +282,7 @@ var SyncHub = class {
261
282
  const conn = this.conns.get(connId);
262
283
  if (!conn) return;
263
284
  this.conns.delete(connId);
285
+ this.peerCapabilities.delete(connId);
264
286
  const room = conn.room;
265
287
  const hadPresence = this.presenceConnections.delete(connId);
266
288
  this.clearPresenceLanes(connId);
@@ -294,6 +316,14 @@ var SyncHub = class {
294
316
  if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();
295
317
  const env = parseEnvelope(message);
296
318
  if (!env) return Promise.resolve();
319
+ if (env.op.kind === "capabilities") {
320
+ this.peerCapabilities.set(conn.id, env.op.capabilities);
321
+ this.sendToConnection(conn, HUB_FROM, {
322
+ kind: "capabilities",
323
+ capabilities: createCurrentCapabilities(this.pluginRegistry.extensionKinds)
324
+ });
325
+ return Promise.resolve();
326
+ }
297
327
  if (env.op.kind === "presence") {
298
328
  this.schedulePresence(conn, env.op.data);
299
329
  return Promise.resolve();
@@ -309,9 +339,14 @@ var SyncHub = class {
309
339
  return operation;
310
340
  }
311
341
  async process(conn, env) {
312
- const op = env.op;
342
+ let op = env.op;
343
+ if (op.kind === "upsert") {
344
+ const element = this.normalizeElement(op.element);
345
+ if (!element) return;
346
+ op = { ...op, element };
347
+ }
313
348
  if (op.kind === "request-snapshot") {
314
- const all = await this.backend.snapshot(conn.room);
349
+ const all = this.normalizeElements(await this.backend.snapshot(conn.room));
315
350
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
316
351
  const layers = await this.getLayerRecords(conn.room);
317
352
  const snapshotOp = {
@@ -332,7 +367,7 @@ var SyncHub = class {
332
367
  else extensions[plugin.name] = snapshot;
333
368
  }
334
369
  if (Object.keys(extensions).length > 0) snapshotOp["extensions"] = extensions;
335
- conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
370
+ this.sendToConnection(conn, HUB_FROM, snapshotOp);
336
371
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
337
372
  await this.processLayerOp(conn, op);
338
373
  } else if (op.kind === "extension") {
@@ -350,7 +385,8 @@ var SyncHub = class {
350
385
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
351
386
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
352
387
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
353
- const current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
388
+ const storedCurrent = needCurrent ? await this.backend.get(conn.room, id) : void 0;
389
+ const current = storedCurrent ? this.normalizeElement(storedCurrent) ?? void 0 : void 0;
354
390
  let outboundOp = op;
355
391
  if (this.authorize) {
356
392
  const allowed = await this.authorize({
@@ -374,7 +410,7 @@ var SyncHub = class {
374
410
  const prevAudience = current?.audience;
375
411
  const result = await this.runCorePlugins(conn, outboundOp);
376
412
  for (const correction of result.corrections) {
377
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
413
+ this.sendToConnection(conn, HUB_FROM, correction);
378
414
  }
379
415
  const accepted = result.accepted;
380
416
  if (accepted && (accepted.kind === "upsert" || accepted.kind === "remove" || accepted.kind === "clear")) {
@@ -430,7 +466,7 @@ var SyncHub = class {
430
466
  }
431
467
  async deliverPluginResult(conn, result) {
432
468
  for (const correction of result.corrections) {
433
- conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
469
+ this.sendToConnection(conn, HUB_FROM, correction);
434
470
  }
435
471
  if (result.accepted) await this.publishPluginOp(conn, result.accepted, result.locality);
436
472
  for (const broadcast of result.broadcast ?? []) {
@@ -443,7 +479,7 @@ var SyncHub = class {
443
479
  JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
444
480
  );
445
481
  }
446
- this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
482
+ this.relayOpToRoom(conn.room, conn.id, conn.id, op);
447
483
  }
448
484
  /**
449
485
  * Applies a layer-definition edit on the room's serial queue. Convergence is
@@ -465,19 +501,19 @@ var SyncHub = class {
465
501
  });
466
502
  if (!allowed) {
467
503
  const correction = current ?? { id: record.id, version: record.version, editor: HUB_FROM };
468
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(correction) }));
504
+ this.sendToConnection(conn, HUB_FROM, layerRecordToOp(correction));
469
505
  return;
470
506
  }
471
507
  }
472
508
  if (current && !isNewerLayerRecord(record, current)) {
473
- conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(current) }));
509
+ this.sendToConnection(conn, HUB_FROM, layerRecordToOp(current));
474
510
  return;
475
511
  }
476
512
  await this.applyLayerRecord(conn.room, record);
477
513
  await this.fanout.publish(
478
514
  JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
479
515
  );
480
- this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
516
+ this.relayOpToRoom(conn.room, conn.id, conn.id, op);
481
517
  }
482
518
  layerBackend() {
483
519
  const { layerRecords, getLayerRecord, applyLayerRecord } = this.backend;
@@ -538,6 +574,86 @@ var SyncHub = class {
538
574
  }
539
575
  return sent;
540
576
  }
577
+ /**
578
+ * Translates `op` for the peer and sends it. Returns false when the op is
579
+ * lossy for this peer (no legacy encoding) or the socket throws; neither
580
+ * may reject the room operation that produced it.
581
+ */
582
+ sendToConnection(conn, from, op, encoded) {
583
+ const capabilities = this.peerCapabilities.get(conn.id) ?? createLegacyCapabilities();
584
+ const profile = encoded ? capabilityProfile(capabilities) : void 0;
585
+ let message = profile === void 0 ? void 0 : encoded?.get(profile);
586
+ if (message === void 0) {
587
+ message = this.encodeForPeer(from, op, capabilities);
588
+ if (profile !== void 0) encoded?.set(profile, message);
589
+ }
590
+ if (message === null) return false;
591
+ try {
592
+ conn.send(message);
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+ /** Returns the wire frame for `op` translated for `capabilities`, or null when lossy. */
599
+ encodeForPeer(from, op, capabilities) {
600
+ try {
601
+ const translated = translateOpForPeer(
602
+ op,
603
+ capabilities,
604
+ this.elementRegistry,
605
+ this.pluginRegistry.extensionDefinitions
606
+ );
607
+ return JSON.stringify({ from, op: translated });
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+ /**
613
+ * Normalize registered legacy wire elements before authorization, storage,
614
+ * and relay. A legacy type this hub has no adapter for is forwarded and
615
+ * stored verbatim — the hub is a relay, and the peers decide whether they
616
+ * understand it — so a hub deployed without domain adapters never erases
617
+ * the room's existing elements. Only a malformed registered element is dropped.
618
+ */
619
+ normalizeElement(element) {
620
+ if (isValidElement(element)) return element;
621
+ const adapter = this.elementRegistry.getAdapterByLegacyType(element.type);
622
+ if (!adapter) return element;
623
+ try {
624
+ const raw = Object.fromEntries(Object.entries(element));
625
+ const envelope = adapter.decodeLegacy(raw);
626
+ if (!adapter.validateEnvelope(envelope)) return null;
627
+ return {
628
+ ...envelope,
629
+ ...typeof raw["audience"] === "string" ? { audience: raw["audience"] } : {},
630
+ ...typeof raw["ownerId"] === "string" ? { ownerId: raw["ownerId"] } : {}
631
+ };
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+ normalizeElements(elements) {
637
+ const normalized = [];
638
+ for (const element of elements) {
639
+ const runtime = this.normalizeElement(element);
640
+ if (runtime) normalized.push(runtime);
641
+ }
642
+ return normalized;
643
+ }
644
+ relayOpToRoom(room, excludeId, from, op) {
645
+ const members = this.rooms.get(room);
646
+ if (!members) return 0;
647
+ let sent = 0;
648
+ const encoded = /* @__PURE__ */ new Map();
649
+ for (const connectionId of members) {
650
+ if (connectionId === excludeId) continue;
651
+ const conn = this.conns.get(connectionId);
652
+ if (!conn) continue;
653
+ if (this.sendToConnection(conn, from, op, encoded)) sent += 1;
654
+ }
655
+ return sent;
656
+ }
541
657
  broadcastClientPresence(conn, data) {
542
658
  this.presenceConnections.add(conn.id);
543
659
  const message = JSON.stringify({ from: conn.id, op: { kind: "presence", data } });
@@ -619,41 +735,33 @@ var SyncHub = class {
619
735
  deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
620
736
  const members = this.rooms.get(room);
621
737
  if (!members) return;
622
- const send = (conn, msg) => {
623
- try {
624
- conn.send(msg);
625
- } catch {
626
- }
627
- };
738
+ const encoded = /* @__PURE__ */ new Map();
628
739
  if (op.kind === "upsert") {
629
740
  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
- });
741
+ const removeOp = { kind: "remove", id: op.element.id };
742
+ const encodedRemove = /* @__PURE__ */ new Map();
635
743
  for (const cid of members) {
636
744
  if (cid === excludeId) continue;
637
745
  const conn = this.conns.get(cid);
638
746
  if (!conn) continue;
639
- if (this.mayRead(conn, audience)) send(conn, upsertMsg);
640
- else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
747
+ if (this.mayRead(conn, audience)) this.sendToConnection(conn, from, op, encoded);
748
+ else if (prevExisted && this.mayRead(conn, prevAudience)) {
749
+ this.sendToConnection(conn, HUB_FROM, removeOp, encodedRemove);
750
+ }
641
751
  }
642
752
  } else if (op.kind === "remove") {
643
- const removeMsg = JSON.stringify({ from, op });
644
753
  for (const cid of members) {
645
754
  if (cid === excludeId) continue;
646
755
  const conn = this.conns.get(cid);
647
756
  if (!conn) continue;
648
757
  const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
649
- if (wasVisible) send(conn, removeMsg);
758
+ if (wasVisible) this.sendToConnection(conn, from, op, encoded);
650
759
  }
651
760
  } else if (op.kind === "clear") {
652
- const clearMsg = JSON.stringify({ from, op });
653
761
  for (const cid of members) {
654
762
  if (cid === excludeId) continue;
655
763
  const conn = this.conns.get(cid);
656
- if (conn) send(conn, clearMsg);
764
+ if (conn) this.sendToConnection(conn, from, op, encoded);
657
765
  }
658
766
  }
659
767
  }
@@ -664,11 +772,11 @@ var SyncHub = class {
664
772
  } else if (op.kind === "remove") {
665
773
  correction = current ? this.mayRead(conn, current.audience) ? { kind: "upsert", element: current } : { kind: "remove", id: current.id } : void 0;
666
774
  } else if (op.kind === "clear") {
667
- const all = await this.backend.snapshot(conn.room);
775
+ const all = this.normalizeElements(await this.backend.snapshot(conn.room));
668
776
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
669
777
  correction = { kind: "snapshot", to: from, elements };
670
778
  }
671
- if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
779
+ if (correction) this.sendToConnection(conn, HUB_FROM, correction);
672
780
  }
673
781
  onFanout(payload) {
674
782
  let env;
@@ -688,7 +796,7 @@ var SyncHub = class {
688
796
  if (isLayerOp(op)) {
689
797
  void this.applyFanoutLayerOp(env.room, op).catch(() => {
690
798
  });
691
- this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
799
+ this.relayOpToRoom(env.room, void 0, env.from, op);
692
800
  return;
693
801
  }
694
802
  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;
@@ -703,11 +811,7 @@ var SyncHub = class {
703
811
  };
704
812
  const accepted = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
705
813
  if (accepted) {
706
- this.relayToRoom(
707
- env.room,
708
- void 0,
709
- JSON.stringify({ from: env.from, op: accepted })
710
- );
814
+ this.relayOpToRoom(env.room, void 0, env.from, accepted);
711
815
  }
712
816
  });
713
817
  this.roomQueues.set(
@@ -718,9 +822,14 @@ var SyncHub = class {
718
822
  return;
719
823
  }
720
824
  if (!isFanoutOp(op)) return;
825
+ const runtimeOp = op.kind === "upsert" ? (() => {
826
+ const element = this.normalizeElement(op.element);
827
+ return element ? { ...op, element } : null;
828
+ })() : op;
829
+ if (!runtimeOp) return;
721
830
  const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
722
831
  const prevExisted = env.existed === true;
723
- this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
832
+ this.deliverToRoom(env.room, void 0, env.from, runtimeOp, prevAudience, prevExisted);
724
833
  }
725
834
  async applyFanoutLayerOp(room, op) {
726
835
  const record = layerOpToRecord(op);
@@ -822,7 +931,8 @@ function createSyncServer(options = {}) {
822
931
  canRead: options.canRead,
823
932
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
824
933
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
825
- maxPresenceLanes: options.maxPresenceLanes
934
+ maxPresenceLanes: options.maxPresenceLanes,
935
+ elementRegistry: options.elementRegistry
826
936
  });
827
937
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
828
938
  const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });