@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.js CHANGED
@@ -2,14 +2,19 @@
2
2
  import {
3
3
  parseEnvelope,
4
4
  isValidElement,
5
- isNewerLayerRecord
5
+ isNewerLayerRecord,
6
+ FogLedger as FogLedger2
6
7
  } from "@fieldnotes/sync";
7
8
 
8
9
  // src/memory-hub-backend.ts
9
- import { applyOpToMap } from "@fieldnotes/sync";
10
+ import {
11
+ applyOpToMap,
12
+ FogLedger
13
+ } from "@fieldnotes/sync";
10
14
  var MemoryHubBackend = class {
11
15
  rooms = /* @__PURE__ */ new Map();
12
16
  roomLayers = /* @__PURE__ */ new Map();
17
+ roomFog = /* @__PURE__ */ new Map();
13
18
  room(id) {
14
19
  let r = this.rooms.get(id);
15
20
  if (!r) {
@@ -48,6 +53,26 @@ var MemoryHubBackend = class {
48
53
  async applyLayerRecord(room, record) {
49
54
  this.layers(room).set(record.id, record);
50
55
  }
56
+ fog(room) {
57
+ let ledger = this.roomFog.get(room);
58
+ if (!ledger) {
59
+ ledger = new FogLedger();
60
+ this.roomFog.set(room, ledger);
61
+ }
62
+ return ledger;
63
+ }
64
+ async fogSnapshot(room) {
65
+ return this.fog(room).snapshot();
66
+ }
67
+ async applyFogMeta(room, record) {
68
+ return this.fog(room).applyMeta(record);
69
+ }
70
+ async applyFogTile(room, record) {
71
+ return this.fog(room).applyTile(record);
72
+ }
73
+ async applyFogPatch(room, records) {
74
+ return this.fog(room).applyPatch(records);
75
+ }
51
76
  };
52
77
 
53
78
  // src/hub-fanout.ts
@@ -75,6 +100,7 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
75
100
  var DEFAULT_MESSAGES_PER_SECOND = 120;
76
101
  var DEFAULT_MESSAGE_BURST = 240;
77
102
  var DEFAULT_PRESENCE_THROTTLE_MS = 50;
103
+ var DEFAULT_MAX_PRESENCE_LANES = 16;
78
104
  function hasJsonDepthAtMost(message, maxDepth) {
79
105
  let depth = 0;
80
106
  let inString = false;
@@ -130,6 +156,16 @@ function isFanoutOp(op) {
130
156
  if (o.kind === "remove") return typeof o.id === "string";
131
157
  return o.kind === "clear";
132
158
  }
159
+ var FALLBACK_PRESENCE_LANE = "";
160
+ var MAX_PRESENCE_LANE_LENGTH = 64;
161
+ function presenceLaneOf(data) {
162
+ if (typeof data !== "object" || data === null) return FALLBACK_PRESENCE_LANE;
163
+ const kind = data.kind;
164
+ if (typeof kind !== "string" || kind.length === 0 || kind.length > MAX_PRESENCE_LANE_LENGTH) {
165
+ return FALLBACK_PRESENCE_LANE;
166
+ }
167
+ return kind;
168
+ }
133
169
  function isLayerOp(op) {
134
170
  if (typeof op !== "object" || op === null) return false;
135
171
  const k = op.kind;
@@ -151,6 +187,11 @@ function isPresenceOp(op) {
151
187
  const k = op.kind;
152
188
  return k === "presence" || k === "presence-leave";
153
189
  }
190
+ function isFogOp(op) {
191
+ if (typeof op !== "object" || op === null) return false;
192
+ const k = op.kind;
193
+ return k === "fog-meta" || k === "fog-patch";
194
+ }
154
195
  var SyncHub = class {
155
196
  backend;
156
197
  conns = /* @__PURE__ */ new Map();
@@ -164,22 +205,48 @@ var SyncHub = class {
164
205
  fanoutUnsub;
165
206
  authorize;
166
207
  authorizeLayer;
208
+ authorizeFog;
167
209
  canRead;
168
- /** Fallback layer-record store for backends without layer persistence. */
169
210
  memoryLayers = /* @__PURE__ */ new Map();
211
+ memoryFog = /* @__PURE__ */ new Map();
170
212
  maxJsonDepth;
171
213
  presenceThrottleMs;
172
- lastPresenceAt = /* @__PURE__ */ new Map();
173
- pendingPresence = /* @__PURE__ */ new Map();
214
+ maxPresenceLanes;
215
+ /**
216
+ * Presence throttle state keyed by connection, then by lane. A lane is the
217
+ * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
218
+ * fallback lane `''`, so a rapid stream of one kind (awareness cursors) can
219
+ * never replace a pending frame of another kind (a ping, a path `cleared`).
220
+ * Within a lane the newest payload wins. The lane count per connection is
221
+ * capped by `maxPresenceLanes`, counting the fallback lane, so a client
222
+ * cannot mint timers by varying `kind`.
223
+ */
224
+ presenceLanes = /* @__PURE__ */ new Map();
174
225
  constructor(options = {}) {
175
226
  this.backend = options.backend ?? new MemoryHubBackend();
227
+ const fogMethods = [
228
+ this.backend.fogSnapshot,
229
+ this.backend.applyFogMeta,
230
+ this.backend.applyFogPatch
231
+ ];
232
+ if ((fogMethods.some(Boolean) || Boolean(this.backend.applyFogTile)) && !fogMethods.every(Boolean)) {
233
+ throw new Error(
234
+ "HubBackend fog support is an all-or-none capability: fogSnapshot, applyFogMeta, and applyFogPatch are required"
235
+ );
236
+ }
176
237
  this.instanceId = options.instanceId ?? generateInstanceId();
177
238
  this.fanout = options.fanout ?? new InMemoryHubFanout();
178
239
  this.authorize = options.authorize;
179
240
  this.authorizeLayer = options.authorizeLayer;
241
+ this.authorizeFog = options.authorizeFog;
180
242
  this.canRead = options.canRead;
181
243
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
182
244
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
245
+ const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
246
+ if (!Number.isFinite(maxPresenceLanes) || maxPresenceLanes < 1) {
247
+ throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
248
+ }
249
+ this.maxPresenceLanes = Math.floor(maxPresenceLanes);
183
250
  this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
184
251
  }
185
252
  addConnection(conn) {
@@ -197,10 +264,7 @@ var SyncHub = class {
197
264
  this.conns.delete(connId);
198
265
  const room = conn.room;
199
266
  const hadPresence = this.presenceConnections.delete(connId);
200
- this.lastPresenceAt.delete(connId);
201
- const pendingPresence = this.pendingPresence.get(connId);
202
- if (pendingPresence) clearTimeout(pendingPresence.timer);
203
- this.pendingPresence.delete(connId);
267
+ this.clearPresenceLanes(connId);
204
268
  const members = this.rooms.get(room);
205
269
  if (members) {
206
270
  members.delete(connId);
@@ -251,16 +315,19 @@ var SyncHub = class {
251
315
  const all = await this.backend.snapshot(conn.room);
252
316
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
253
317
  const layers = await this.getLayerRecords(conn.room);
254
- conn.send(
255
- // `to` is a private correlation address for the requesting SyncClient. It is never
256
- // broadcast; every public sender identity below comes from the server-owned connection.
257
- JSON.stringify({
258
- from: HUB_FROM,
259
- op: layers.length > 0 ? { kind: "snapshot", to: env.from, elements, layers } : { kind: "snapshot", to: env.from, elements }
260
- })
261
- );
318
+ const fog = await this.getFogSnapshot(conn.room);
319
+ const snapshotOp = {
320
+ kind: "snapshot",
321
+ to: env.from,
322
+ elements
323
+ };
324
+ if (layers.length > 0) snapshotOp["layers"] = layers;
325
+ if (fog) snapshotOp["fog"] = fog;
326
+ conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
262
327
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
263
328
  await this.processLayerOp(conn, op);
329
+ } else if (op.kind === "fog-meta" || op.kind === "fog-patch") {
330
+ await this.processFogOp(conn, op);
264
331
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
265
332
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
266
333
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
@@ -366,6 +433,121 @@ var SyncHub = class {
366
433
  }
367
434
  map.set(record.id, record);
368
435
  }
436
+ // ── Fog processing ──
437
+ fogBackend() {
438
+ const { fogSnapshot, applyFogMeta, applyFogPatch } = this.backend;
439
+ if (!fogSnapshot || !applyFogMeta || !applyFogPatch) return null;
440
+ return {
441
+ fogSnapshot: fogSnapshot.bind(this.backend),
442
+ applyFogMeta: applyFogMeta.bind(this.backend),
443
+ applyFogPatch: applyFogPatch.bind(this.backend)
444
+ };
445
+ }
446
+ getFogLedger(room) {
447
+ let ledger = this.memoryFog.get(room);
448
+ if (!ledger) {
449
+ ledger = new FogLedger2();
450
+ this.memoryFog.set(room, ledger);
451
+ }
452
+ return ledger;
453
+ }
454
+ async getFogSnapshot(room) {
455
+ const backend = this.fogBackend();
456
+ if (backend) return backend.fogSnapshot(room);
457
+ return this.getFogLedger(room).snapshot();
458
+ }
459
+ async applyFogMeta(room, record) {
460
+ const backend = this.fogBackend();
461
+ if (backend) return backend.applyFogMeta(room, record);
462
+ return this.getFogLedger(room).applyMeta(record);
463
+ }
464
+ async applyFogPatch(room, records) {
465
+ const backend = this.fogBackend();
466
+ if (backend) return backend.applyFogPatch(room, records);
467
+ return this.getFogLedger(room).applyPatch(records);
468
+ }
469
+ async processFogOp(conn, op) {
470
+ const current = await this.getFogSnapshot(conn.room);
471
+ if (this.authorizeFog) {
472
+ const allowed = await this.authorizeFog({
473
+ userId: conn.userId,
474
+ role: conn.role,
475
+ room: conn.room,
476
+ op,
477
+ current
478
+ });
479
+ if (!allowed) {
480
+ if (op.kind === "fog-meta") {
481
+ const correction = current?.meta ?? { version: 1, editor: HUB_FROM };
482
+ conn.send(
483
+ JSON.stringify({ from: HUB_FROM, op: { kind: "fog-meta", record: correction } })
484
+ );
485
+ } else if (current?.meta.definition) {
486
+ const corrections = op.tiles.map((t) => {
487
+ const existing = current.tiles.find((ct) => ct.x === t.x && ct.y === t.y);
488
+ return existing ?? {
489
+ generation: current.meta.definition?.generation ?? op.generation,
490
+ x: t.x,
491
+ y: t.y,
492
+ version: 1,
493
+ editor: HUB_FROM
494
+ };
495
+ });
496
+ conn.send(
497
+ JSON.stringify({
498
+ from: HUB_FROM,
499
+ op: {
500
+ kind: "fog-patch",
501
+ generation: current.meta.definition.generation,
502
+ tiles: corrections
503
+ }
504
+ })
505
+ );
506
+ } else {
507
+ conn.send(
508
+ JSON.stringify({
509
+ from: HUB_FROM,
510
+ op: {
511
+ kind: "fog-meta",
512
+ record: current?.meta ?? { version: 1, editor: HUB_FROM }
513
+ }
514
+ })
515
+ );
516
+ }
517
+ return;
518
+ }
519
+ }
520
+ let outbound;
521
+ if (op.kind === "fog-meta") {
522
+ const result = await this.applyFogMeta(conn.room, op.record);
523
+ if (!result.accepted) {
524
+ if (result.correction) {
525
+ conn.send(
526
+ JSON.stringify({ from: HUB_FROM, op: { kind: "fog-meta", record: result.correction } })
527
+ );
528
+ }
529
+ return;
530
+ }
531
+ outbound = op;
532
+ } else {
533
+ const { accepted, corrections } = await this.applyFogPatch(conn.room, op.tiles);
534
+ if (corrections.length > 0) {
535
+ const correctionGeneration = corrections[0]?.generation ?? op.generation;
536
+ conn.send(
537
+ JSON.stringify({
538
+ from: HUB_FROM,
539
+ op: { kind: "fog-patch", generation: correctionGeneration, tiles: corrections }
540
+ })
541
+ );
542
+ }
543
+ if (accepted.length === 0) return;
544
+ outbound = { kind: "fog-patch", generation: op.generation, tiles: accepted };
545
+ }
546
+ await this.fanout.publish(
547
+ JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op: outbound })
548
+ );
549
+ this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op: outbound }));
550
+ }
369
551
  mayRead(conn, audience) {
370
552
  if (!this.canRead) return true;
371
553
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -406,34 +588,63 @@ var SyncHub = class {
406
588
  })
407
589
  );
408
590
  }
591
+ clearPresenceLanes(connId) {
592
+ const lanes = this.presenceLanes.get(connId);
593
+ if (!lanes) return;
594
+ for (const lane of lanes.values()) {
595
+ if (lane.pending) clearTimeout(lane.pending.timer);
596
+ }
597
+ this.presenceLanes.delete(connId);
598
+ }
409
599
  schedulePresence(conn, data) {
410
600
  if (this.presenceThrottleMs <= 0) {
411
601
  this.broadcastClientPresence(conn, data);
412
602
  return;
413
603
  }
604
+ const lane = this.presenceLaneFor(conn.id, presenceLaneOf(data));
414
605
  const now = Date.now();
415
- const lastSentAt = this.lastPresenceAt.get(conn.id);
416
- if (lastSentAt === void 0 || now - lastSentAt >= this.presenceThrottleMs) {
417
- this.lastPresenceAt.set(conn.id, now);
606
+ if (lane.lastSentAt === void 0 || now - lane.lastSentAt >= this.presenceThrottleMs) {
607
+ if (lane.pending) {
608
+ clearTimeout(lane.pending.timer);
609
+ lane.pending = null;
610
+ }
611
+ lane.lastSentAt = now;
418
612
  this.broadcastClientPresence(conn, data);
419
613
  return;
420
614
  }
421
- const existing = this.pendingPresence.get(conn.id);
422
- if (existing) {
423
- existing.data = data;
615
+ if (lane.pending) {
616
+ lane.pending.data = data;
424
617
  return;
425
618
  }
426
619
  const timer = setTimeout(
427
620
  () => {
428
- const pending = this.pendingPresence.get(conn.id);
429
- this.pendingPresence.delete(conn.id);
621
+ const pending = lane.pending;
622
+ lane.pending = null;
430
623
  if (!pending || !this.conns.has(conn.id)) return;
431
- this.lastPresenceAt.set(conn.id, Date.now());
624
+ lane.lastSentAt = Date.now();
432
625
  this.broadcastClientPresence(conn, pending.data);
433
626
  },
434
- this.presenceThrottleMs - (now - lastSentAt)
627
+ this.presenceThrottleMs - (now - lane.lastSentAt)
435
628
  );
436
- this.pendingPresence.set(conn.id, { data, timer });
629
+ lane.pending = { data, timer };
630
+ }
631
+ presenceLaneFor(connId, requested) {
632
+ let lanes = this.presenceLanes.get(connId);
633
+ if (!lanes) {
634
+ lanes = /* @__PURE__ */ new Map();
635
+ this.presenceLanes.set(connId, lanes);
636
+ }
637
+ let key = requested;
638
+ if (key !== FALLBACK_PRESENCE_LANE && !lanes.has(key)) {
639
+ const named = lanes.size - (lanes.has(FALLBACK_PRESENCE_LANE) ? 1 : 0);
640
+ if (named >= this.maxPresenceLanes - 1) key = FALLBACK_PRESENCE_LANE;
641
+ }
642
+ let lane = lanes.get(key);
643
+ if (!lane) {
644
+ lane = { lastSentAt: void 0, pending: null };
645
+ lanes.set(key, lane);
646
+ }
647
+ return lane;
437
648
  }
438
649
  broadcastLeave(room, from) {
439
650
  const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
@@ -517,6 +728,25 @@ var SyncHub = class {
517
728
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
518
729
  return;
519
730
  }
731
+ if (isFogOp(op)) {
732
+ const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
733
+ const operation = previous.then(async () => {
734
+ const accepted = await this.applyFanoutFogOp(env.room, op);
735
+ if (accepted) {
736
+ this.relayToRoom(
737
+ env.room,
738
+ void 0,
739
+ JSON.stringify({ from: env.from, op: accepted })
740
+ );
741
+ }
742
+ });
743
+ this.roomQueues.set(
744
+ env.room,
745
+ operation.catch(() => {
746
+ })
747
+ );
748
+ return;
749
+ }
520
750
  if (!isFanoutOp(op)) return;
521
751
  const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
522
752
  const prevExisted = env.existed === true;
@@ -528,9 +758,17 @@ var SyncHub = class {
528
758
  if (current && !isNewerLayerRecord(record, current)) return;
529
759
  await this.applyLayerRecord(room, record);
530
760
  }
761
+ async applyFanoutFogOp(room, op) {
762
+ if (this.backend.sharedAcrossInstances === true && this.fogBackend()) return op;
763
+ if (op.kind === "fog-meta") {
764
+ const result = await this.applyFogMeta(room, op.record);
765
+ return result.accepted ? op : null;
766
+ }
767
+ const { accepted } = await this.applyFogPatch(room, op.tiles);
768
+ return accepted.length > 0 ? { ...op, tiles: accepted } : null;
769
+ }
531
770
  close() {
532
- for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);
533
- this.pendingPresence.clear();
771
+ for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
534
772
  this.fanoutUnsub();
535
773
  }
536
774
  };
@@ -619,9 +857,11 @@ function createSyncServer(options = {}) {
619
857
  instanceId: options.instanceId,
620
858
  authorize: options.authorize,
621
859
  authorizeLayer: options.authorizeLayer,
860
+ authorizeFog: options.authorizeFog,
622
861
  canRead: options.canRead,
623
862
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
624
- presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS
863
+ presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
864
+ maxPresenceLanes: options.maxPresenceLanes
625
865
  });
626
866
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
627
867
  const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });