@fieldnotes/sync-server 0.16.0 → 0.17.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
@@ -36,7 +36,6 @@ var import_sync = require("@fieldnotes/sync");
36
36
  var MemoryHubBackend = class {
37
37
  rooms = /* @__PURE__ */ new Map();
38
38
  roomLayers = /* @__PURE__ */ new Map();
39
- roomFog = /* @__PURE__ */ new Map();
40
39
  room(id) {
41
40
  let r = this.rooms.get(id);
42
41
  if (!r) {
@@ -75,26 +74,6 @@ var MemoryHubBackend = class {
75
74
  async applyLayerRecord(room, record) {
76
75
  this.layers(room).set(record.id, record);
77
76
  }
78
- fog(room) {
79
- let ledger = this.roomFog.get(room);
80
- if (!ledger) {
81
- ledger = new import_sync.FogLedger();
82
- this.roomFog.set(room, ledger);
83
- }
84
- return ledger;
85
- }
86
- async fogSnapshot(room) {
87
- return this.fog(room).snapshot();
88
- }
89
- async applyFogMeta(room, record) {
90
- return this.fog(room).applyMeta(record);
91
- }
92
- async applyFogTile(room, record) {
93
- return this.fog(room).applyTile(record);
94
- }
95
- async applyFogPatch(room, records) {
96
- return this.fog(room).applyPatch(records);
97
- }
98
77
  };
99
78
 
100
79
  // src/hub-fanout.ts
@@ -114,6 +93,46 @@ var InMemoryHubFanout = class {
114
93
  }
115
94
  };
116
95
 
96
+ // src/sync-plugin.ts
97
+ var ServerPluginRegistry = class {
98
+ byName = /* @__PURE__ */ new Map();
99
+ legacyOwners = /* @__PURE__ */ new Map();
100
+ extensions = /* @__PURE__ */ new Map();
101
+ constructor(plugins) {
102
+ for (const plugin of plugins) this.register(plugin);
103
+ }
104
+ register(plugin) {
105
+ if (this.byName.has(plugin.name))
106
+ throw new Error(`Server plugin "${plugin.name}" is duplicated`);
107
+ this.byName.set(plugin.name, plugin);
108
+ for (const kind of plugin.ownedLegacyKinds ?? []) {
109
+ if (this.legacyOwners.has(kind)) throw new Error(`Sync op kind "${kind}" has two owners`);
110
+ this.legacyOwners.set(kind, plugin);
111
+ }
112
+ plugin.registerExtensionKinds?.({
113
+ register: (kind, handler) => {
114
+ if (this.extensions.has(kind.extensionKind)) {
115
+ throw new Error(`Extension kind "${kind.extensionKind}" has two owners`);
116
+ }
117
+ this.extensions.set(kind.extensionKind, {
118
+ kind,
119
+ plugin,
120
+ handler
121
+ });
122
+ }
123
+ });
124
+ }
125
+ get plugins() {
126
+ return [...this.byName.values()];
127
+ }
128
+ ownerOf(kind) {
129
+ return this.legacyOwners.get(kind);
130
+ }
131
+ extension(extensionKind) {
132
+ return this.extensions.get(extensionKind);
133
+ }
134
+ };
135
+
117
136
  // src/resource-limits.ts
118
137
  var DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
119
138
  var DEFAULT_MAX_JSON_DEPTH = 32;
@@ -209,11 +228,6 @@ function isPresenceOp(op) {
209
228
  const k = op.kind;
210
229
  return k === "presence" || k === "presence-leave";
211
230
  }
212
- function isFogOp(op) {
213
- if (typeof op !== "object" || op === null) return false;
214
- const k = op.kind;
215
- return k === "fog-meta" || k === "fog-patch";
216
- }
217
231
  var SyncHub = class {
218
232
  backend;
219
233
  conns = /* @__PURE__ */ new Map();
@@ -227,10 +241,9 @@ var SyncHub = class {
227
241
  fanoutUnsub;
228
242
  authorize;
229
243
  authorizeLayer;
230
- authorizeFog;
244
+ pluginRegistry;
231
245
  canRead;
232
246
  memoryLayers = /* @__PURE__ */ new Map();
233
- memoryFog = /* @__PURE__ */ new Map();
234
247
  maxJsonDepth;
235
248
  presenceThrottleMs;
236
249
  maxPresenceLanes;
@@ -246,21 +259,11 @@ var SyncHub = class {
246
259
  presenceLanes = /* @__PURE__ */ new Map();
247
260
  constructor(options = {}) {
248
261
  this.backend = options.backend ?? new MemoryHubBackend();
249
- const fogMethods = [
250
- this.backend.fogSnapshot,
251
- this.backend.applyFogMeta,
252
- this.backend.applyFogPatch
253
- ];
254
- if ((fogMethods.some(Boolean) || Boolean(this.backend.applyFogTile)) && !fogMethods.every(Boolean)) {
255
- throw new Error(
256
- "HubBackend fog support is an all-or-none capability: fogSnapshot, applyFogMeta, and applyFogPatch are required"
257
- );
258
- }
262
+ this.pluginRegistry = new ServerPluginRegistry(options.plugins ?? []);
259
263
  this.instanceId = options.instanceId ?? generateInstanceId();
260
264
  this.fanout = options.fanout ?? new InMemoryHubFanout();
261
265
  this.authorize = options.authorize;
262
266
  this.authorizeLayer = options.authorizeLayer;
263
- this.authorizeFog = options.authorizeFog;
264
267
  this.canRead = options.canRead;
265
268
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
266
269
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
@@ -337,19 +340,39 @@ var SyncHub = class {
337
340
  const all = await this.backend.snapshot(conn.room);
338
341
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
339
342
  const layers = await this.getLayerRecords(conn.room);
340
- const fog = await this.getFogSnapshot(conn.room);
341
343
  const snapshotOp = {
342
344
  kind: "snapshot",
343
345
  to: env.from,
344
346
  elements
345
347
  };
346
348
  if (layers.length > 0) snapshotOp["layers"] = layers;
347
- if (fog) snapshotOp["fog"] = fog;
349
+ const extensions = {};
350
+ for (const plugin of this.pluginRegistry.plugins) {
351
+ let snapshot = await plugin.snapshot?.(conn.room, this.backend);
352
+ if (!snapshot) continue;
353
+ if (plugin.filterSnapshot) {
354
+ snapshot = plugin.filterSnapshot(snapshot, { userId: conn.userId, role: conn.role }) ?? void 0;
355
+ }
356
+ if (!snapshot) continue;
357
+ if (plugin.legacySnapshotKey) snapshotOp[plugin.legacySnapshotKey] = snapshot.data;
358
+ else extensions[plugin.name] = snapshot;
359
+ }
360
+ if (Object.keys(extensions).length > 0) snapshotOp["extensions"] = extensions;
348
361
  conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
349
362
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
350
363
  await this.processLayerOp(conn, op);
351
- } else if (op.kind === "fog-meta" || op.kind === "fog-patch") {
352
- await this.processFogOp(conn, op);
364
+ } else if (op.kind === "extension") {
365
+ const entry = this.pluginRegistry.extension(op.extensionKind);
366
+ if (!entry || !entry.kind.codec.validate(op.payload)) return;
367
+ await this.deliverPluginResult(conn, await entry.handler(op, this.pluginContext(conn)));
368
+ } else if (this.pluginRegistry.ownerOf(op.kind)) {
369
+ const owner = this.pluginRegistry.ownerOf(op.kind);
370
+ if (!owner?.process) return;
371
+ const result = await owner.process(op, this.pluginContext(conn), async () => ({
372
+ accepted: null,
373
+ corrections: []
374
+ }));
375
+ await this.deliverPluginResult(conn, result);
353
376
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
354
377
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
355
378
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
@@ -373,21 +396,80 @@ var SyncHub = class {
373
396
  outboundOp = { kind: "upsert", element: stampedElement };
374
397
  }
375
398
  }
376
- await this.backend.apply(conn.room, outboundOp);
377
399
  const prevExisted = current !== void 0;
378
400
  const prevAudience = current?.audience;
401
+ const result = await this.runCorePlugins(conn, outboundOp);
402
+ for (const correction of result.corrections) {
403
+ conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
404
+ }
405
+ const accepted = result.accepted;
406
+ if (accepted && (accepted.kind === "upsert" || accepted.kind === "remove" || accepted.kind === "clear")) {
407
+ if (result.locality !== "local") {
408
+ await this.fanout.publish(
409
+ JSON.stringify({
410
+ o: this.instanceId,
411
+ room: conn.room,
412
+ from: conn.id,
413
+ op: accepted,
414
+ prev: prevAudience,
415
+ existed: prevExisted
416
+ })
417
+ );
418
+ }
419
+ this.deliverToRoom(conn.room, conn.id, conn.id, accepted, prevAudience, prevExisted);
420
+ }
421
+ for (const broadcast of result.broadcast ?? []) {
422
+ await this.publishPluginOp(conn, broadcast, result.locality);
423
+ }
424
+ }
425
+ }
426
+ pluginContext(conn) {
427
+ return {
428
+ room: conn.room,
429
+ connectionId: conn.id,
430
+ userId: conn.userId,
431
+ role: conn.role,
432
+ backend: this.backend,
433
+ backendPlugin: (key) => this.backend.getService?.(key)
434
+ };
435
+ }
436
+ async runCorePlugins(conn, op) {
437
+ const middleware = this.pluginRegistry.plugins.filter((plugin) => plugin.process);
438
+ const context = this.pluginContext(conn);
439
+ const dispatch = async (index, current) => {
440
+ const plugin = middleware[index];
441
+ if (!plugin?.process) {
442
+ await this.backend.apply(conn.room, current);
443
+ return { accepted: current, corrections: [] };
444
+ }
445
+ let called = false;
446
+ return plugin.process(current, context, async (nextOp, nextContext) => {
447
+ if (called) throw new Error(`Server plugin "${plugin.name}" called next() more than once`);
448
+ if (nextContext !== context) {
449
+ throw new Error(`Server plugin "${plugin.name}" replaced the operation context`);
450
+ }
451
+ called = true;
452
+ return dispatch(index + 1, nextOp);
453
+ });
454
+ };
455
+ return dispatch(0, op);
456
+ }
457
+ async deliverPluginResult(conn, result) {
458
+ for (const correction of result.corrections) {
459
+ conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
460
+ }
461
+ if (result.accepted) await this.publishPluginOp(conn, result.accepted, result.locality);
462
+ for (const broadcast of result.broadcast ?? []) {
463
+ await this.publishPluginOp(conn, broadcast, result.locality);
464
+ }
465
+ }
466
+ async publishPluginOp(conn, op, locality) {
467
+ if (locality !== "local") {
379
468
  await this.fanout.publish(
380
- JSON.stringify({
381
- o: this.instanceId,
382
- room: conn.room,
383
- from: conn.id,
384
- op: outboundOp,
385
- prev: prevAudience,
386
- existed: prevExisted
387
- })
469
+ JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
388
470
  );
389
- this.deliverToRoom(conn.room, conn.id, conn.id, outboundOp, prevAudience, prevExisted);
390
471
  }
472
+ this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
391
473
  }
392
474
  /**
393
475
  * Applies a layer-definition edit on the room's serial queue. Convergence is
@@ -455,121 +537,6 @@ var SyncHub = class {
455
537
  }
456
538
  map.set(record.id, record);
457
539
  }
458
- // ── Fog processing ──
459
- fogBackend() {
460
- const { fogSnapshot, applyFogMeta, applyFogPatch } = this.backend;
461
- if (!fogSnapshot || !applyFogMeta || !applyFogPatch) return null;
462
- return {
463
- fogSnapshot: fogSnapshot.bind(this.backend),
464
- applyFogMeta: applyFogMeta.bind(this.backend),
465
- applyFogPatch: applyFogPatch.bind(this.backend)
466
- };
467
- }
468
- getFogLedger(room) {
469
- let ledger = this.memoryFog.get(room);
470
- if (!ledger) {
471
- ledger = new import_sync2.FogLedger();
472
- this.memoryFog.set(room, ledger);
473
- }
474
- return ledger;
475
- }
476
- async getFogSnapshot(room) {
477
- const backend = this.fogBackend();
478
- if (backend) return backend.fogSnapshot(room);
479
- return this.getFogLedger(room).snapshot();
480
- }
481
- async applyFogMeta(room, record) {
482
- const backend = this.fogBackend();
483
- if (backend) return backend.applyFogMeta(room, record);
484
- return this.getFogLedger(room).applyMeta(record);
485
- }
486
- async applyFogPatch(room, records) {
487
- const backend = this.fogBackend();
488
- if (backend) return backend.applyFogPatch(room, records);
489
- return this.getFogLedger(room).applyPatch(records);
490
- }
491
- async processFogOp(conn, op) {
492
- const current = await this.getFogSnapshot(conn.room);
493
- if (this.authorizeFog) {
494
- const allowed = await this.authorizeFog({
495
- userId: conn.userId,
496
- role: conn.role,
497
- room: conn.room,
498
- op,
499
- current
500
- });
501
- if (!allowed) {
502
- if (op.kind === "fog-meta") {
503
- const correction = current?.meta ?? { version: 1, editor: HUB_FROM };
504
- conn.send(
505
- JSON.stringify({ from: HUB_FROM, op: { kind: "fog-meta", record: correction } })
506
- );
507
- } else if (current?.meta.definition) {
508
- const corrections = op.tiles.map((t) => {
509
- const existing = current.tiles.find((ct) => ct.x === t.x && ct.y === t.y);
510
- return existing ?? {
511
- generation: current.meta.definition?.generation ?? op.generation,
512
- x: t.x,
513
- y: t.y,
514
- version: 1,
515
- editor: HUB_FROM
516
- };
517
- });
518
- conn.send(
519
- JSON.stringify({
520
- from: HUB_FROM,
521
- op: {
522
- kind: "fog-patch",
523
- generation: current.meta.definition.generation,
524
- tiles: corrections
525
- }
526
- })
527
- );
528
- } else {
529
- conn.send(
530
- JSON.stringify({
531
- from: HUB_FROM,
532
- op: {
533
- kind: "fog-meta",
534
- record: current?.meta ?? { version: 1, editor: HUB_FROM }
535
- }
536
- })
537
- );
538
- }
539
- return;
540
- }
541
- }
542
- let outbound;
543
- if (op.kind === "fog-meta") {
544
- const result = await this.applyFogMeta(conn.room, op.record);
545
- if (!result.accepted) {
546
- if (result.correction) {
547
- conn.send(
548
- JSON.stringify({ from: HUB_FROM, op: { kind: "fog-meta", record: result.correction } })
549
- );
550
- }
551
- return;
552
- }
553
- outbound = op;
554
- } else {
555
- const { accepted, corrections } = await this.applyFogPatch(conn.room, op.tiles);
556
- if (corrections.length > 0) {
557
- const correctionGeneration = corrections[0]?.generation ?? op.generation;
558
- conn.send(
559
- JSON.stringify({
560
- from: HUB_FROM,
561
- op: { kind: "fog-patch", generation: correctionGeneration, tiles: corrections }
562
- })
563
- );
564
- }
565
- if (accepted.length === 0) return;
566
- outbound = { kind: "fog-patch", generation: op.generation, tiles: accepted };
567
- }
568
- await this.fanout.publish(
569
- JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op: outbound })
570
- );
571
- this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op: outbound }));
572
- }
573
540
  mayRead(conn, audience) {
574
541
  if (!this.canRead) return true;
575
542
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -750,10 +717,17 @@ var SyncHub = class {
750
717
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
751
718
  return;
752
719
  }
753
- if (isFogOp(op)) {
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) {
754
722
  const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
755
723
  const operation = previous.then(async () => {
756
- const accepted = await this.applyFanoutFogOp(env.room, op);
724
+ const context = {
725
+ room: env.room,
726
+ connectionId: env.from,
727
+ backend: this.backend,
728
+ backendPlugin: (key) => this.backend.getService?.(key)
729
+ };
730
+ const accepted = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
757
731
  if (accepted) {
758
732
  this.relayToRoom(
759
733
  env.room,
@@ -780,15 +754,6 @@ var SyncHub = class {
780
754
  if (current && !(0, import_sync2.isNewerLayerRecord)(record, current)) return;
781
755
  await this.applyLayerRecord(room, record);
782
756
  }
783
- async applyFanoutFogOp(room, op) {
784
- if (this.backend.sharedAcrossInstances === true && this.fogBackend()) return op;
785
- if (op.kind === "fog-meta") {
786
- const result = await this.applyFogMeta(room, op.record);
787
- return result.accepted ? op : null;
788
- }
789
- const { accepted } = await this.applyFogPatch(room, op.tiles);
790
- return accepted.length > 0 ? { ...op, tiles: accepted } : null;
791
- }
792
757
  close() {
793
758
  for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
794
759
  this.fanoutUnsub();
@@ -879,7 +844,7 @@ function createSyncServer(options = {}) {
879
844
  instanceId: options.instanceId,
880
845
  authorize: options.authorize,
881
846
  authorizeLayer: options.authorizeLayer,
882
- authorizeFog: options.authorizeFog,
847
+ plugins: options.plugins,
883
848
  canRead: options.canRead,
884
849
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
885
850
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,