@fieldnotes/sync-server 0.11.0 → 0.13.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.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CanvasElement } from '@fieldnotes/core';
2
- import { SyncOp } from '@fieldnotes/sync';
2
+ import { SyncOp, LayerRecord } from '@fieldnotes/sync';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { IncomingMessage, Server } from 'http';
5
5
 
@@ -18,6 +18,17 @@ interface HubBackend {
18
18
  snapshot(room: string): Promise<CanvasElement[]>;
19
19
  get(room: string, id: string): Promise<CanvasElement | undefined>;
20
20
  apply(room: string, op: SyncOp): Promise<void>;
21
+ /**
22
+ * Optional versioned layer-definition persistence. The three methods are
23
+ * additive and must be implemented together; when absent, the hub keeps
24
+ * per-room layer records in its own memory (they then live and die with the
25
+ * hub instance, while elements keep whatever durability the backend has).
26
+ * Records include removal tombstones. An element `clear` op never touches
27
+ * layer records.
28
+ */
29
+ layerRecords?(room: string): Promise<LayerRecord[]>;
30
+ getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
31
+ applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
21
32
  }
22
33
 
23
34
  type OwnedElement = CanvasElement & {
@@ -32,6 +43,23 @@ interface AuthorizeContext {
32
43
  currentElement?: OwnedElement;
33
44
  }
34
45
  type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
46
+ interface AuthorizeLayerContext {
47
+ userId?: string;
48
+ role?: string;
49
+ room: string;
50
+ op: Extract<SyncOp, {
51
+ kind: 'layer-upsert' | 'layer-remove';
52
+ }>;
53
+ /** The hub's current record for the target layer, tombstones included. */
54
+ currentRecord?: LayerRecord;
55
+ }
56
+ /**
57
+ * Authorizes layer-definition edits. Without a hook every room member may
58
+ * edit layer definitions. A denied edit is answered with an authoritative
59
+ * hub correction to the sender only, so the sender's local ledger converges
60
+ * back to the room state.
61
+ */
62
+ type AuthorizeLayer = (ctx: AuthorizeLayerContext) => boolean | Promise<boolean>;
35
63
  interface ReadContext {
36
64
  userId?: string;
37
65
  role?: string;
@@ -52,9 +80,11 @@ interface SyncHubOptions {
52
80
  fanout?: HubFanout;
53
81
  instanceId?: string;
54
82
  authorize?: Authorize;
83
+ authorizeLayer?: AuthorizeLayer;
55
84
  canRead?: CanRead;
56
85
  maxJsonDepth?: number;
57
86
  presenceThrottleMs?: number;
87
+ maxPresenceLanes?: number;
58
88
  }
59
89
  declare class SyncHub {
60
90
  private readonly backend;
@@ -66,11 +96,23 @@ declare class SyncHub {
66
96
  private readonly fanout;
67
97
  private readonly fanoutUnsub;
68
98
  private readonly authorize?;
99
+ private readonly authorizeLayer?;
69
100
  private readonly canRead?;
101
+ /** Fallback layer-record store for backends without layer persistence. */
102
+ private readonly memoryLayers;
70
103
  private readonly maxJsonDepth;
71
104
  private readonly presenceThrottleMs;
72
- private readonly lastPresenceAt;
73
- private readonly pendingPresence;
105
+ private readonly maxPresenceLanes;
106
+ /**
107
+ * Presence throttle state keyed by connection, then by lane. A lane is the
108
+ * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
109
+ * fallback lane `''`, so a rapid stream of one kind (awareness cursors) can
110
+ * never replace a pending frame of another kind (a ping, a path `cleared`).
111
+ * Within a lane the newest payload wins. The lane count per connection is
112
+ * capped by `maxPresenceLanes`, counting the fallback lane, so a client
113
+ * cannot mint timers by varying `kind`.
114
+ */
115
+ private readonly presenceLanes;
74
116
  constructor(options?: SyncHubOptions);
75
117
  addConnection(conn: Connection): void;
76
118
  removeConnection(connId: string): void;
@@ -83,24 +125,44 @@ declare class SyncHub {
83
125
  broadcastPresence<T>(room: string, data: T): number;
84
126
  handleMessage(connId: string, message: string): Promise<void>;
85
127
  private process;
128
+ /**
129
+ * Applies a layer-definition edit on the room's serial queue. Convergence is
130
+ * last-writer-wins under the deterministic (version, editor) ordering — the
131
+ * same rule every client applies — so arrival order never decides a race. A
132
+ * stale or denied edit is answered with an authoritative correction to the
133
+ * sender only.
134
+ */
135
+ private processLayerOp;
136
+ private layerBackend;
137
+ private getLayerRecords;
138
+ private getLayerRecord;
139
+ private applyLayerRecord;
86
140
  private mayRead;
87
141
  private safePublish;
88
142
  private relayToRoom;
89
143
  private broadcastClientPresence;
144
+ private clearPresenceLanes;
90
145
  private schedulePresence;
146
+ private presenceLaneFor;
91
147
  private broadcastLeave;
92
148
  private deliverToRoom;
93
149
  private sendCorrection;
94
150
  private onFanout;
151
+ private applyFanoutLayerOp;
95
152
  close(): void;
96
153
  }
97
154
 
98
155
  declare class MemoryHubBackend implements HubBackend {
99
156
  private rooms;
157
+ private roomLayers;
100
158
  private room;
159
+ private layers;
101
160
  snapshot(room: string): Promise<CanvasElement[]>;
102
161
  get(room: string, id: string): Promise<CanvasElement | undefined>;
103
162
  apply(room: string, op: SyncOp): Promise<void>;
163
+ layerRecords(room: string): Promise<LayerRecord[]>;
164
+ getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
165
+ applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
104
166
  }
105
167
 
106
168
  interface AuthInfo {
@@ -121,6 +183,7 @@ interface CreateSyncServerOptions {
121
183
  instanceId?: string;
122
184
  authenticate?: Authenticate;
123
185
  authorize?: Authorize;
186
+ authorizeLayer?: AuthorizeLayer;
124
187
  canRead?: CanRead;
125
188
  heartbeatIntervalMs?: number;
126
189
  maxMessageBytes?: number;
@@ -130,6 +193,7 @@ interface CreateSyncServerOptions {
130
193
  messagesPerSecond?: number;
131
194
  messageBurst?: number;
132
195
  presenceThrottleMs?: number;
196
+ maxPresenceLanes?: number;
133
197
  shutdownGraceMs?: number;
134
198
  }
135
199
  declare function createSyncServer(options?: CreateSyncServerOptions): {
@@ -152,4 +216,4 @@ interface Heartbeat {
152
216
  }
153
217
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
154
218
 
155
- export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
219
+ export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CanvasElement } from '@fieldnotes/core';
2
- import { SyncOp } from '@fieldnotes/sync';
2
+ import { SyncOp, LayerRecord } from '@fieldnotes/sync';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { IncomingMessage, Server } from 'http';
5
5
 
@@ -18,6 +18,17 @@ interface HubBackend {
18
18
  snapshot(room: string): Promise<CanvasElement[]>;
19
19
  get(room: string, id: string): Promise<CanvasElement | undefined>;
20
20
  apply(room: string, op: SyncOp): Promise<void>;
21
+ /**
22
+ * Optional versioned layer-definition persistence. The three methods are
23
+ * additive and must be implemented together; when absent, the hub keeps
24
+ * per-room layer records in its own memory (they then live and die with the
25
+ * hub instance, while elements keep whatever durability the backend has).
26
+ * Records include removal tombstones. An element `clear` op never touches
27
+ * layer records.
28
+ */
29
+ layerRecords?(room: string): Promise<LayerRecord[]>;
30
+ getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
31
+ applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
21
32
  }
22
33
 
23
34
  type OwnedElement = CanvasElement & {
@@ -32,6 +43,23 @@ interface AuthorizeContext {
32
43
  currentElement?: OwnedElement;
33
44
  }
34
45
  type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
46
+ interface AuthorizeLayerContext {
47
+ userId?: string;
48
+ role?: string;
49
+ room: string;
50
+ op: Extract<SyncOp, {
51
+ kind: 'layer-upsert' | 'layer-remove';
52
+ }>;
53
+ /** The hub's current record for the target layer, tombstones included. */
54
+ currentRecord?: LayerRecord;
55
+ }
56
+ /**
57
+ * Authorizes layer-definition edits. Without a hook every room member may
58
+ * edit layer definitions. A denied edit is answered with an authoritative
59
+ * hub correction to the sender only, so the sender's local ledger converges
60
+ * back to the room state.
61
+ */
62
+ type AuthorizeLayer = (ctx: AuthorizeLayerContext) => boolean | Promise<boolean>;
35
63
  interface ReadContext {
36
64
  userId?: string;
37
65
  role?: string;
@@ -52,9 +80,11 @@ interface SyncHubOptions {
52
80
  fanout?: HubFanout;
53
81
  instanceId?: string;
54
82
  authorize?: Authorize;
83
+ authorizeLayer?: AuthorizeLayer;
55
84
  canRead?: CanRead;
56
85
  maxJsonDepth?: number;
57
86
  presenceThrottleMs?: number;
87
+ maxPresenceLanes?: number;
58
88
  }
59
89
  declare class SyncHub {
60
90
  private readonly backend;
@@ -66,11 +96,23 @@ declare class SyncHub {
66
96
  private readonly fanout;
67
97
  private readonly fanoutUnsub;
68
98
  private readonly authorize?;
99
+ private readonly authorizeLayer?;
69
100
  private readonly canRead?;
101
+ /** Fallback layer-record store for backends without layer persistence. */
102
+ private readonly memoryLayers;
70
103
  private readonly maxJsonDepth;
71
104
  private readonly presenceThrottleMs;
72
- private readonly lastPresenceAt;
73
- private readonly pendingPresence;
105
+ private readonly maxPresenceLanes;
106
+ /**
107
+ * Presence throttle state keyed by connection, then by lane. A lane is the
108
+ * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
109
+ * fallback lane `''`, so a rapid stream of one kind (awareness cursors) can
110
+ * never replace a pending frame of another kind (a ping, a path `cleared`).
111
+ * Within a lane the newest payload wins. The lane count per connection is
112
+ * capped by `maxPresenceLanes`, counting the fallback lane, so a client
113
+ * cannot mint timers by varying `kind`.
114
+ */
115
+ private readonly presenceLanes;
74
116
  constructor(options?: SyncHubOptions);
75
117
  addConnection(conn: Connection): void;
76
118
  removeConnection(connId: string): void;
@@ -83,24 +125,44 @@ declare class SyncHub {
83
125
  broadcastPresence<T>(room: string, data: T): number;
84
126
  handleMessage(connId: string, message: string): Promise<void>;
85
127
  private process;
128
+ /**
129
+ * Applies a layer-definition edit on the room's serial queue. Convergence is
130
+ * last-writer-wins under the deterministic (version, editor) ordering — the
131
+ * same rule every client applies — so arrival order never decides a race. A
132
+ * stale or denied edit is answered with an authoritative correction to the
133
+ * sender only.
134
+ */
135
+ private processLayerOp;
136
+ private layerBackend;
137
+ private getLayerRecords;
138
+ private getLayerRecord;
139
+ private applyLayerRecord;
86
140
  private mayRead;
87
141
  private safePublish;
88
142
  private relayToRoom;
89
143
  private broadcastClientPresence;
144
+ private clearPresenceLanes;
90
145
  private schedulePresence;
146
+ private presenceLaneFor;
91
147
  private broadcastLeave;
92
148
  private deliverToRoom;
93
149
  private sendCorrection;
94
150
  private onFanout;
151
+ private applyFanoutLayerOp;
95
152
  close(): void;
96
153
  }
97
154
 
98
155
  declare class MemoryHubBackend implements HubBackend {
99
156
  private rooms;
157
+ private roomLayers;
100
158
  private room;
159
+ private layers;
101
160
  snapshot(room: string): Promise<CanvasElement[]>;
102
161
  get(room: string, id: string): Promise<CanvasElement | undefined>;
103
162
  apply(room: string, op: SyncOp): Promise<void>;
163
+ layerRecords(room: string): Promise<LayerRecord[]>;
164
+ getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
165
+ applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
104
166
  }
105
167
 
106
168
  interface AuthInfo {
@@ -121,6 +183,7 @@ interface CreateSyncServerOptions {
121
183
  instanceId?: string;
122
184
  authenticate?: Authenticate;
123
185
  authorize?: Authorize;
186
+ authorizeLayer?: AuthorizeLayer;
124
187
  canRead?: CanRead;
125
188
  heartbeatIntervalMs?: number;
126
189
  maxMessageBytes?: number;
@@ -130,6 +193,7 @@ interface CreateSyncServerOptions {
130
193
  messagesPerSecond?: number;
131
194
  messageBurst?: number;
132
195
  presenceThrottleMs?: number;
196
+ maxPresenceLanes?: number;
133
197
  shutdownGraceMs?: number;
134
198
  }
135
199
  declare function createSyncServer(options?: CreateSyncServerOptions): {
@@ -152,4 +216,4 @@ interface Heartbeat {
152
216
  }
153
217
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
154
218
 
155
- export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
219
+ export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
package/dist/index.js CHANGED
@@ -1,10 +1,15 @@
1
1
  // src/sync-hub.ts
2
- import { parseEnvelope, isValidElement } from "@fieldnotes/sync";
2
+ import {
3
+ parseEnvelope,
4
+ isValidElement,
5
+ isNewerLayerRecord
6
+ } from "@fieldnotes/sync";
3
7
 
4
8
  // src/memory-hub-backend.ts
5
9
  import { applyOpToMap } from "@fieldnotes/sync";
6
10
  var MemoryHubBackend = class {
7
11
  rooms = /* @__PURE__ */ new Map();
12
+ roomLayers = /* @__PURE__ */ new Map();
8
13
  room(id) {
9
14
  let r = this.rooms.get(id);
10
15
  if (!r) {
@@ -13,6 +18,14 @@ var MemoryHubBackend = class {
13
18
  }
14
19
  return r;
15
20
  }
21
+ layers(room) {
22
+ let r = this.roomLayers.get(room);
23
+ if (!r) {
24
+ r = /* @__PURE__ */ new Map();
25
+ this.roomLayers.set(room, r);
26
+ }
27
+ return r;
28
+ }
16
29
  async snapshot(room) {
17
30
  return [...this.room(room).values()];
18
31
  }
@@ -26,6 +39,15 @@ var MemoryHubBackend = class {
26
39
  }
27
40
  applyOpToMap(this.room(room), op);
28
41
  }
42
+ async layerRecords(room) {
43
+ return [...this.layers(room).values()];
44
+ }
45
+ async getLayerRecord(room, id) {
46
+ return this.layers(room).get(id);
47
+ }
48
+ async applyLayerRecord(room, record) {
49
+ this.layers(room).set(record.id, record);
50
+ }
29
51
  };
30
52
 
31
53
  // src/hub-fanout.ts
@@ -53,6 +75,7 @@ var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
53
75
  var DEFAULT_MESSAGES_PER_SECOND = 120;
54
76
  var DEFAULT_MESSAGE_BURST = 240;
55
77
  var DEFAULT_PRESENCE_THROTTLE_MS = 50;
78
+ var DEFAULT_MAX_PRESENCE_LANES = 16;
56
79
  function hasJsonDepthAtMost(message, maxDepth) {
57
80
  let depth = 0;
58
81
  let inString = false;
@@ -108,6 +131,32 @@ function isFanoutOp(op) {
108
131
  if (o.kind === "remove") return typeof o.id === "string";
109
132
  return o.kind === "clear";
110
133
  }
134
+ var FALLBACK_PRESENCE_LANE = "";
135
+ var MAX_PRESENCE_LANE_LENGTH = 64;
136
+ function presenceLaneOf(data) {
137
+ if (typeof data !== "object" || data === null) return FALLBACK_PRESENCE_LANE;
138
+ const kind = data.kind;
139
+ if (typeof kind !== "string" || kind.length === 0 || kind.length > MAX_PRESENCE_LANE_LENGTH) {
140
+ return FALLBACK_PRESENCE_LANE;
141
+ }
142
+ return kind;
143
+ }
144
+ function isLayerOp(op) {
145
+ if (typeof op !== "object" || op === null) return false;
146
+ const k = op.kind;
147
+ return k === "layer-upsert" || k === "layer-remove";
148
+ }
149
+ function layerOpToRecord(op) {
150
+ return op.kind === "layer-upsert" ? { id: op.layer.id, version: op.version, editor: op.editor, definition: op.layer } : { id: op.id, version: op.version, editor: op.editor };
151
+ }
152
+ function layerRecordToOp(record) {
153
+ return record.definition ? {
154
+ kind: "layer-upsert",
155
+ layer: record.definition,
156
+ version: record.version,
157
+ editor: record.editor
158
+ } : { kind: "layer-remove", id: record.id, version: record.version, editor: record.editor };
159
+ }
111
160
  function isPresenceOp(op) {
112
161
  if (typeof op !== "object" || op === null) return false;
113
162
  const k = op.kind;
@@ -125,19 +174,37 @@ var SyncHub = class {
125
174
  fanout;
126
175
  fanoutUnsub;
127
176
  authorize;
177
+ authorizeLayer;
128
178
  canRead;
179
+ /** Fallback layer-record store for backends without layer persistence. */
180
+ memoryLayers = /* @__PURE__ */ new Map();
129
181
  maxJsonDepth;
130
182
  presenceThrottleMs;
131
- lastPresenceAt = /* @__PURE__ */ new Map();
132
- pendingPresence = /* @__PURE__ */ new Map();
183
+ maxPresenceLanes;
184
+ /**
185
+ * Presence throttle state keyed by connection, then by lane. A lane is the
186
+ * payload's `kind` (a non-empty string of at most 64 chars) or the reserved
187
+ * fallback lane `''`, so a rapid stream of one kind (awareness cursors) can
188
+ * never replace a pending frame of another kind (a ping, a path `cleared`).
189
+ * Within a lane the newest payload wins. The lane count per connection is
190
+ * capped by `maxPresenceLanes`, counting the fallback lane, so a client
191
+ * cannot mint timers by varying `kind`.
192
+ */
193
+ presenceLanes = /* @__PURE__ */ new Map();
133
194
  constructor(options = {}) {
134
195
  this.backend = options.backend ?? new MemoryHubBackend();
135
196
  this.instanceId = options.instanceId ?? generateInstanceId();
136
197
  this.fanout = options.fanout ?? new InMemoryHubFanout();
137
198
  this.authorize = options.authorize;
199
+ this.authorizeLayer = options.authorizeLayer;
138
200
  this.canRead = options.canRead;
139
201
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
140
202
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
203
+ const maxPresenceLanes = options.maxPresenceLanes ?? DEFAULT_MAX_PRESENCE_LANES;
204
+ if (!Number.isFinite(maxPresenceLanes) || maxPresenceLanes < 1) {
205
+ throw new RangeError("maxPresenceLanes must be a finite number of at least 1");
206
+ }
207
+ this.maxPresenceLanes = Math.floor(maxPresenceLanes);
141
208
  this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
142
209
  }
143
210
  addConnection(conn) {
@@ -155,10 +222,7 @@ var SyncHub = class {
155
222
  this.conns.delete(connId);
156
223
  const room = conn.room;
157
224
  const hadPresence = this.presenceConnections.delete(connId);
158
- this.lastPresenceAt.delete(connId);
159
- const pendingPresence = this.pendingPresence.get(connId);
160
- if (pendingPresence) clearTimeout(pendingPresence.timer);
161
- this.pendingPresence.delete(connId);
225
+ this.clearPresenceLanes(connId);
162
226
  const members = this.rooms.get(room);
163
227
  if (members) {
164
228
  members.delete(connId);
@@ -208,11 +272,17 @@ var SyncHub = class {
208
272
  if (op.kind === "request-snapshot") {
209
273
  const all = await this.backend.snapshot(conn.room);
210
274
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
275
+ const layers = await this.getLayerRecords(conn.room);
211
276
  conn.send(
212
277
  // `to` is a private correlation address for the requesting SyncClient. It is never
213
278
  // broadcast; every public sender identity below comes from the server-owned connection.
214
- JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
279
+ JSON.stringify({
280
+ from: HUB_FROM,
281
+ op: layers.length > 0 ? { kind: "snapshot", to: env.from, elements, layers } : { kind: "snapshot", to: env.from, elements }
282
+ })
215
283
  );
284
+ } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
285
+ await this.processLayerOp(conn, op);
216
286
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
217
287
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
218
288
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
@@ -252,6 +322,72 @@ var SyncHub = class {
252
322
  this.deliverToRoom(conn.room, conn.id, conn.id, outboundOp, prevAudience, prevExisted);
253
323
  }
254
324
  }
325
+ /**
326
+ * Applies a layer-definition edit on the room's serial queue. Convergence is
327
+ * last-writer-wins under the deterministic (version, editor) ordering — the
328
+ * same rule every client applies — so arrival order never decides a race. A
329
+ * stale or denied edit is answered with an authoritative correction to the
330
+ * sender only.
331
+ */
332
+ async processLayerOp(conn, op) {
333
+ const record = layerOpToRecord(op);
334
+ const current = await this.getLayerRecord(conn.room, record.id);
335
+ if (this.authorizeLayer) {
336
+ const allowed = await this.authorizeLayer({
337
+ userId: conn.userId,
338
+ role: conn.role,
339
+ room: conn.room,
340
+ op,
341
+ currentRecord: current
342
+ });
343
+ if (!allowed) {
344
+ const correction = current ?? { id: record.id, version: record.version, editor: HUB_FROM };
345
+ conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(correction) }));
346
+ return;
347
+ }
348
+ }
349
+ if (current && !isNewerLayerRecord(record, current)) {
350
+ conn.send(JSON.stringify({ from: HUB_FROM, op: layerRecordToOp(current) }));
351
+ return;
352
+ }
353
+ await this.applyLayerRecord(conn.room, record);
354
+ await this.fanout.publish(
355
+ JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
356
+ );
357
+ this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
358
+ }
359
+ layerBackend() {
360
+ const { layerRecords, getLayerRecord, applyLayerRecord } = this.backend;
361
+ if (!layerRecords || !getLayerRecord || !applyLayerRecord) return null;
362
+ return {
363
+ layerRecords: layerRecords.bind(this.backend),
364
+ getLayerRecord: getLayerRecord.bind(this.backend),
365
+ applyLayerRecord: applyLayerRecord.bind(this.backend)
366
+ };
367
+ }
368
+ async getLayerRecords(room) {
369
+ const backend = this.layerBackend();
370
+ if (backend) return backend.layerRecords(room);
371
+ return [...this.memoryLayers.get(room)?.values() ?? []];
372
+ }
373
+ async getLayerRecord(room, id) {
374
+ const backend = this.layerBackend();
375
+ if (backend) return backend.getLayerRecord(room, id);
376
+ return this.memoryLayers.get(room)?.get(id);
377
+ }
378
+ async applyLayerRecord(room, record) {
379
+ const backend = this.layerBackend();
380
+ if (backend) {
381
+ await backend.applyLayerRecord(room, record);
382
+ return;
383
+ }
384
+ let map = this.memoryLayers.get(room);
385
+ if (!map) {
386
+ map = /* @__PURE__ */ new Map();
387
+ this.memoryLayers.set(room, map);
388
+ }
389
+ map.set(record.id, record);
390
+ }
255
391
  mayRead(conn, audience) {
256
392
  if (!this.canRead) return true;
257
393
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -292,34 +428,63 @@ var SyncHub = class {
292
428
  })
293
429
  );
294
430
  }
431
+ clearPresenceLanes(connId) {
432
+ const lanes = this.presenceLanes.get(connId);
433
+ if (!lanes) return;
434
+ for (const lane of lanes.values()) {
435
+ if (lane.pending) clearTimeout(lane.pending.timer);
436
+ }
437
+ this.presenceLanes.delete(connId);
438
+ }
295
439
  schedulePresence(conn, data) {
296
440
  if (this.presenceThrottleMs <= 0) {
297
441
  this.broadcastClientPresence(conn, data);
298
442
  return;
299
443
  }
444
+ const lane = this.presenceLaneFor(conn.id, presenceLaneOf(data));
300
445
  const now = Date.now();
301
- const lastSentAt = this.lastPresenceAt.get(conn.id);
302
- if (lastSentAt === void 0 || now - lastSentAt >= this.presenceThrottleMs) {
303
- this.lastPresenceAt.set(conn.id, now);
446
+ if (lane.lastSentAt === void 0 || now - lane.lastSentAt >= this.presenceThrottleMs) {
447
+ if (lane.pending) {
448
+ clearTimeout(lane.pending.timer);
449
+ lane.pending = null;
450
+ }
451
+ lane.lastSentAt = now;
304
452
  this.broadcastClientPresence(conn, data);
305
453
  return;
306
454
  }
307
- const existing = this.pendingPresence.get(conn.id);
308
- if (existing) {
309
- existing.data = data;
455
+ if (lane.pending) {
456
+ lane.pending.data = data;
310
457
  return;
311
458
  }
312
459
  const timer = setTimeout(
313
460
  () => {
314
- const pending = this.pendingPresence.get(conn.id);
315
- this.pendingPresence.delete(conn.id);
461
+ const pending = lane.pending;
462
+ lane.pending = null;
316
463
  if (!pending || !this.conns.has(conn.id)) return;
317
- this.lastPresenceAt.set(conn.id, Date.now());
464
+ lane.lastSentAt = Date.now();
318
465
  this.broadcastClientPresence(conn, pending.data);
319
466
  },
320
- this.presenceThrottleMs - (now - lastSentAt)
467
+ this.presenceThrottleMs - (now - lane.lastSentAt)
321
468
  );
322
- this.pendingPresence.set(conn.id, { data, timer });
469
+ lane.pending = { data, timer };
470
+ }
471
+ presenceLaneFor(connId, requested) {
472
+ let lanes = this.presenceLanes.get(connId);
473
+ if (!lanes) {
474
+ lanes = /* @__PURE__ */ new Map();
475
+ this.presenceLanes.set(connId, lanes);
476
+ }
477
+ let key = requested;
478
+ if (key !== FALLBACK_PRESENCE_LANE && !lanes.has(key)) {
479
+ const named = lanes.size - (lanes.has(FALLBACK_PRESENCE_LANE) ? 1 : 0);
480
+ if (named >= this.maxPresenceLanes - 1) key = FALLBACK_PRESENCE_LANE;
481
+ }
482
+ let lane = lanes.get(key);
483
+ if (!lane) {
484
+ lane = { lastSentAt: void 0, pending: null };
485
+ lanes.set(key, lane);
486
+ }
487
+ return lane;
323
488
  }
324
489
  broadcastLeave(room, from) {
325
490
  const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
@@ -397,14 +562,25 @@ var SyncHub = class {
397
562
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
398
563
  return;
399
564
  }
565
+ if (isLayerOp(op)) {
566
+ void this.applyFanoutLayerOp(env.room, op).catch(() => {
567
+ });
568
+ this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
569
+ return;
570
+ }
400
571
  if (!isFanoutOp(op)) return;
401
572
  const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
402
573
  const prevExisted = env.existed === true;
403
574
  this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
404
575
  }
576
+ async applyFanoutLayerOp(room, op) {
577
+ const record = layerOpToRecord(op);
578
+ const current = await this.getLayerRecord(room, record.id);
579
+ if (current && !isNewerLayerRecord(record, current)) return;
580
+ await this.applyLayerRecord(room, record);
581
+ }
405
582
  close() {
406
- for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);
407
- this.pendingPresence.clear();
583
+ for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
408
584
  this.fanoutUnsub();
409
585
  }
410
586
  };
@@ -492,9 +668,11 @@ function createSyncServer(options = {}) {
492
668
  fanout: options.fanout,
493
669
  instanceId: options.instanceId,
494
670
  authorize: options.authorize,
671
+ authorizeLayer: options.authorizeLayer,
495
672
  canRead: options.canRead,
496
673
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
497
- presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS
674
+ presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
675
+ maxPresenceLanes: options.maxPresenceLanes
498
676
  });
499
677
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
500
678
  const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });