@fieldnotes/sync-server 0.12.0 → 0.14.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,6 +36,7 @@ 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();
39
40
  room(id) {
40
41
  let r = this.rooms.get(id);
41
42
  if (!r) {
@@ -74,6 +75,26 @@ var MemoryHubBackend = class {
74
75
  async applyLayerRecord(room, record) {
75
76
  this.layers(room).set(record.id, record);
76
77
  }
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
+ }
77
98
  };
78
99
 
79
100
  // src/hub-fanout.ts
@@ -101,6 +122,7 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
101
122
  var DEFAULT_MESSAGES_PER_SECOND = 120;
102
123
  var DEFAULT_MESSAGE_BURST = 240;
103
124
  var DEFAULT_PRESENCE_THROTTLE_MS = 50;
125
+ var DEFAULT_MAX_PRESENCE_LANES = 16;
104
126
  function hasJsonDepthAtMost(message, maxDepth) {
105
127
  let depth = 0;
106
128
  let inString = false;
@@ -156,6 +178,16 @@ function isFanoutOp(op) {
156
178
  if (o.kind === "remove") return typeof o.id === "string";
157
179
  return o.kind === "clear";
158
180
  }
181
+ var FALLBACK_PRESENCE_LANE = "";
182
+ var MAX_PRESENCE_LANE_LENGTH = 64;
183
+ function presenceLaneOf(data) {
184
+ if (typeof data !== "object" || data === null) return FALLBACK_PRESENCE_LANE;
185
+ const kind = data.kind;
186
+ if (typeof kind !== "string" || kind.length === 0 || kind.length > MAX_PRESENCE_LANE_LENGTH) {
187
+ return FALLBACK_PRESENCE_LANE;
188
+ }
189
+ return kind;
190
+ }
159
191
  function isLayerOp(op) {
160
192
  if (typeof op !== "object" || op === null) return false;
161
193
  const k = op.kind;
@@ -177,6 +209,11 @@ function isPresenceOp(op) {
177
209
  const k = op.kind;
178
210
  return k === "presence" || k === "presence-leave";
179
211
  }
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
+ }
180
217
  var SyncHub = class {
181
218
  backend;
182
219
  conns = /* @__PURE__ */ new Map();
@@ -190,22 +227,48 @@ var SyncHub = class {
190
227
  fanoutUnsub;
191
228
  authorize;
192
229
  authorizeLayer;
230
+ authorizeFog;
193
231
  canRead;
194
- /** Fallback layer-record store for backends without layer persistence. */
195
232
  memoryLayers = /* @__PURE__ */ new Map();
233
+ memoryFog = /* @__PURE__ */ new Map();
196
234
  maxJsonDepth;
197
235
  presenceThrottleMs;
198
- lastPresenceAt = /* @__PURE__ */ new Map();
199
- pendingPresence = /* @__PURE__ */ new Map();
236
+ maxPresenceLanes;
237
+ /**
238
+ * Presence throttle state keyed by connection, then by lane. A lane is the
239
+ * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
240
+ * fallback lane `''`, so a rapid stream of one kind (awareness cursors) can
241
+ * never replace a pending frame of another kind (a ping, a path `cleared`).
242
+ * Within a lane the newest payload wins. The lane count per connection is
243
+ * capped by `maxPresenceLanes`, counting the fallback lane, so a client
244
+ * cannot mint timers by varying `kind`.
245
+ */
246
+ presenceLanes = /* @__PURE__ */ new Map();
200
247
  constructor(options = {}) {
201
248
  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
+ }
202
259
  this.instanceId = options.instanceId ?? generateInstanceId();
203
260
  this.fanout = options.fanout ?? new InMemoryHubFanout();
204
261
  this.authorize = options.authorize;
205
262
  this.authorizeLayer = options.authorizeLayer;
263
+ this.authorizeFog = options.authorizeFog;
206
264
  this.canRead = options.canRead;
207
265
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
208
266
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
267
+ const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
268
+ if (!Number.isFinite(maxPresenceLanes) || maxPresenceLanes < 1) {
269
+ throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
270
+ }
271
+ this.maxPresenceLanes = Math.floor(maxPresenceLanes);
209
272
  this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
210
273
  }
211
274
  addConnection(conn) {
@@ -223,10 +286,7 @@ var SyncHub = class {
223
286
  this.conns.delete(connId);
224
287
  const room = conn.room;
225
288
  const hadPresence = this.presenceConnections.delete(connId);
226
- this.lastPresenceAt.delete(connId);
227
- const pendingPresence = this.pendingPresence.get(connId);
228
- if (pendingPresence) clearTimeout(pendingPresence.timer);
229
- this.pendingPresence.delete(connId);
289
+ this.clearPresenceLanes(connId);
230
290
  const members = this.rooms.get(room);
231
291
  if (members) {
232
292
  members.delete(connId);
@@ -277,16 +337,19 @@ var SyncHub = class {
277
337
  const all = await this.backend.snapshot(conn.room);
278
338
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
279
339
  const layers = await this.getLayerRecords(conn.room);
280
- conn.send(
281
- // `to` is a private correlation address for the requesting SyncClient. It is never
282
- // broadcast; every public sender identity below comes from the server-owned connection.
283
- JSON.stringify({
284
- from: HUB_FROM,
285
- op: layers.length > 0 ? { kind: "snapshot", to: env.from, elements, layers } : { kind: "snapshot", to: env.from, elements }
286
- })
287
- );
340
+ const fog = await this.getFogSnapshot(conn.room);
341
+ const snapshotOp = {
342
+ kind: "snapshot",
343
+ to: env.from,
344
+ elements
345
+ };
346
+ if (layers.length > 0) snapshotOp["layers"] = layers;
347
+ if (fog) snapshotOp["fog"] = fog;
348
+ conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
288
349
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
289
350
  await this.processLayerOp(conn, op);
351
+ } else if (op.kind === "fog-meta" || op.kind === "fog-patch") {
352
+ await this.processFogOp(conn, op);
290
353
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
291
354
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
292
355
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
@@ -392,6 +455,121 @@ var SyncHub = class {
392
455
  }
393
456
  map.set(record.id, record);
394
457
  }
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
+ }
395
573
  mayRead(conn, audience) {
396
574
  if (!this.canRead) return true;
397
575
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -432,34 +610,63 @@ var SyncHub = class {
432
610
  })
433
611
  );
434
612
  }
613
+ clearPresenceLanes(connId) {
614
+ const lanes = this.presenceLanes.get(connId);
615
+ if (!lanes) return;
616
+ for (const lane of lanes.values()) {
617
+ if (lane.pending) clearTimeout(lane.pending.timer);
618
+ }
619
+ this.presenceLanes.delete(connId);
620
+ }
435
621
  schedulePresence(conn, data) {
436
622
  if (this.presenceThrottleMs <= 0) {
437
623
  this.broadcastClientPresence(conn, data);
438
624
  return;
439
625
  }
626
+ const lane = this.presenceLaneFor(conn.id, presenceLaneOf(data));
440
627
  const now = Date.now();
441
- const lastSentAt = this.lastPresenceAt.get(conn.id);
442
- if (lastSentAt === void 0 || now - lastSentAt >= this.presenceThrottleMs) {
443
- this.lastPresenceAt.set(conn.id, now);
628
+ if (lane.lastSentAt === void 0 || now - lane.lastSentAt >= this.presenceThrottleMs) {
629
+ if (lane.pending) {
630
+ clearTimeout(lane.pending.timer);
631
+ lane.pending = null;
632
+ }
633
+ lane.lastSentAt = now;
444
634
  this.broadcastClientPresence(conn, data);
445
635
  return;
446
636
  }
447
- const existing = this.pendingPresence.get(conn.id);
448
- if (existing) {
449
- existing.data = data;
637
+ if (lane.pending) {
638
+ lane.pending.data = data;
450
639
  return;
451
640
  }
452
641
  const timer = setTimeout(
453
642
  () => {
454
- const pending = this.pendingPresence.get(conn.id);
455
- this.pendingPresence.delete(conn.id);
643
+ const pending = lane.pending;
644
+ lane.pending = null;
456
645
  if (!pending || !this.conns.has(conn.id)) return;
457
- this.lastPresenceAt.set(conn.id, Date.now());
646
+ lane.lastSentAt = Date.now();
458
647
  this.broadcastClientPresence(conn, pending.data);
459
648
  },
460
- this.presenceThrottleMs - (now - lastSentAt)
649
+ this.presenceThrottleMs - (now - lane.lastSentAt)
461
650
  );
462
- this.pendingPresence.set(conn.id, { data, timer });
651
+ lane.pending = { data, timer };
652
+ }
653
+ presenceLaneFor(connId, requested) {
654
+ let lanes = this.presenceLanes.get(connId);
655
+ if (!lanes) {
656
+ lanes = /* @__PURE__ */ new Map();
657
+ this.presenceLanes.set(connId, lanes);
658
+ }
659
+ let key = requested;
660
+ if (key !== FALLBACK_PRESENCE_LANE && !lanes.has(key)) {
661
+ const named = lanes.size - (lanes.has(FALLBACK_PRESENCE_LANE) ? 1 : 0);
662
+ if (named >= this.maxPresenceLanes - 1) key = FALLBACK_PRESENCE_LANE;
663
+ }
664
+ let lane = lanes.get(key);
665
+ if (!lane) {
666
+ lane = { lastSentAt: void 0, pending: null };
667
+ lanes.set(key, lane);
668
+ }
669
+ return lane;
463
670
  }
464
671
  broadcastLeave(room, from) {
465
672
  const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
@@ -543,6 +750,25 @@ var SyncHub = class {
543
750
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
544
751
  return;
545
752
  }
753
+ if (isFogOp(op)) {
754
+ const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
755
+ const operation = previous.then(async () => {
756
+ const accepted = await this.applyFanoutFogOp(env.room, op);
757
+ if (accepted) {
758
+ this.relayToRoom(
759
+ env.room,
760
+ void 0,
761
+ JSON.stringify({ from: env.from, op: accepted })
762
+ );
763
+ }
764
+ });
765
+ this.roomQueues.set(
766
+ env.room,
767
+ operation.catch(() => {
768
+ })
769
+ );
770
+ return;
771
+ }
546
772
  if (!isFanoutOp(op)) return;
547
773
  const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
548
774
  const prevExisted = env.existed === true;
@@ -554,9 +780,17 @@ var SyncHub = class {
554
780
  if (current && !(0, import_sync2.isNewerLayerRecord)(record, current)) return;
555
781
  await this.applyLayerRecord(room, record);
556
782
  }
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
+ }
557
792
  close() {
558
- for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);
559
- this.pendingPresence.clear();
793
+ for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
560
794
  this.fanoutUnsub();
561
795
  }
562
796
  };
@@ -645,9 +879,11 @@ function createSyncServer(options = {}) {
645
879
  instanceId: options.instanceId,
646
880
  authorize: options.authorize,
647
881
  authorizeLayer: options.authorizeLayer,
882
+ authorizeFog: options.authorizeFog,
648
883
  canRead: options.canRead,
649
884
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
650
- presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS
885
+ presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
886
+ maxPresenceLanes: options.maxPresenceLanes
651
887
  });
652
888
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
653
889
  const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new import_ws.WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });