@fieldnotes/sync-server 0.15.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.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { CanvasElement } from '@fieldnotes/core';
2
- import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
1
+ import { CanvasElement, ServiceKey } from '@fieldnotes/core';
2
+ import { SyncOp, LayerRecord, ExtensionKind, TypedExtensionOp, PluginSnapshot } from '@fieldnotes/sync';
3
+ export { PluginSnapshot } from '@fieldnotes/sync';
3
4
  import { WebSocketServer } from 'ws';
4
5
  import { IncomingMessage, Server } from 'http';
5
6
 
@@ -14,14 +15,6 @@ declare class InMemoryHubFanout implements HubFanout {
14
15
  subscribe(handler: (payload: string) => void): () => void;
15
16
  }
16
17
 
17
- interface FogApplyResult<T> {
18
- readonly accepted: boolean;
19
- readonly correction?: T;
20
- }
21
- interface FogPatchApplyResult {
22
- readonly accepted: FogTileRecord[];
23
- readonly corrections: FogTileRecord[];
24
- }
25
18
  interface HubBackend {
26
19
  /** True when every hub instance addresses the same atomic backing state (for example Redis). */
27
20
  readonly sharedAcrossInstances?: boolean;
@@ -31,10 +24,7 @@ interface HubBackend {
31
24
  layerRecords?(room: string): Promise<LayerRecord[]>;
32
25
  getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
33
26
  applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
34
- fogSnapshot?(room: string): Promise<FogSnapshot | undefined>;
35
- applyFogMeta?(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
36
- applyFogTile?(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
37
- applyFogPatch?(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
27
+ getService?<T>(key: ServiceKey<T>): T | undefined;
38
28
  }
39
29
 
40
30
  type OwnedElement = CanvasElement & {
@@ -73,16 +63,38 @@ interface ReadContext {
73
63
  audience: string | undefined;
74
64
  }
75
65
  type CanRead = (ctx: ReadContext) => boolean;
76
- interface AuthorizeFogContext {
77
- userId?: string;
78
- role?: string;
79
- room: string;
80
- op: Extract<SyncOp, {
81
- kind: 'fog-meta' | 'fog-patch';
82
- }>;
83
- current: FogSnapshot | undefined;
66
+
67
+ interface ApplyResult {
68
+ readonly accepted: SyncOp | null;
69
+ readonly corrections: SyncOp[];
70
+ readonly broadcast?: SyncOp[];
71
+ readonly locality?: 'shared' | 'local';
72
+ }
73
+ interface ServerOpContext {
74
+ readonly room: string;
75
+ readonly connectionId: string;
76
+ readonly userId?: string;
77
+ readonly role?: string;
78
+ readonly backend: HubBackend;
79
+ backendPlugin<T>(key: ServiceKey<T>): T | undefined;
80
+ }
81
+ type ServerNext = (op: SyncOp, context: ServerOpContext) => Promise<ApplyResult>;
82
+ interface ServerExtensionRegistry {
83
+ register<TPayload>(kind: ExtensionKind<TPayload>, handler: (op: TypedExtensionOp<TPayload>, context: ServerOpContext) => Promise<ApplyResult>): void;
84
+ }
85
+ interface ServerSyncPlugin {
86
+ readonly name: string;
87
+ readonly ownedLegacyKinds?: readonly string[];
88
+ readonly legacySnapshotKey?: string;
89
+ process?(op: SyncOp, context: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
90
+ applyFanout?(op: SyncOp, context: ServerOpContext): Promise<SyncOp | null>;
91
+ registerExtensionKinds?(registry: ServerExtensionRegistry): void;
92
+ snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot | undefined>;
93
+ filterSnapshot?(snapshot: PluginSnapshot, viewer: {
94
+ userId?: string;
95
+ role?: string;
96
+ }): PluginSnapshot | null;
84
97
  }
85
- type AuthorizeFog = (ctx: AuthorizeFogContext) => boolean | Promise<boolean>;
86
98
 
87
99
  interface Connection {
88
100
  id: string;
@@ -97,7 +109,7 @@ interface SyncHubOptions {
97
109
  instanceId?: string;
98
110
  authorize?: Authorize;
99
111
  authorizeLayer?: AuthorizeLayer;
100
- authorizeFog?: AuthorizeFog;
112
+ plugins?: readonly ServerSyncPlugin[];
101
113
  canRead?: CanRead;
102
114
  maxJsonDepth?: number;
103
115
  presenceThrottleMs?: number;
@@ -114,10 +126,9 @@ declare class SyncHub {
114
126
  private readonly fanoutUnsub;
115
127
  private readonly authorize?;
116
128
  private readonly authorizeLayer?;
117
- private readonly authorizeFog?;
129
+ private readonly pluginRegistry;
118
130
  private readonly canRead?;
119
131
  private readonly memoryLayers;
120
- private readonly memoryFog;
121
132
  private readonly maxJsonDepth;
122
133
  private readonly presenceThrottleMs;
123
134
  private readonly maxPresenceLanes;
@@ -143,6 +154,10 @@ declare class SyncHub {
143
154
  broadcastPresence<T>(room: string, data: T): number;
144
155
  handleMessage(connId: string, message: string): Promise<void>;
145
156
  private process;
157
+ private pluginContext;
158
+ private runCorePlugins;
159
+ private deliverPluginResult;
160
+ private publishPluginOp;
146
161
  /**
147
162
  * Applies a layer-definition edit on the room's serial queue. Convergence is
148
163
  * last-writer-wins under the deterministic (version, editor) ordering — the
@@ -155,12 +170,6 @@ declare class SyncHub {
155
170
  private getLayerRecords;
156
171
  private getLayerRecord;
157
172
  private applyLayerRecord;
158
- private fogBackend;
159
- private getFogLedger;
160
- private getFogSnapshot;
161
- private applyFogMeta;
162
- private applyFogPatch;
163
- private processFogOp;
164
173
  private mayRead;
165
174
  private safePublish;
166
175
  private relayToRoom;
@@ -173,14 +182,12 @@ declare class SyncHub {
173
182
  private sendCorrection;
174
183
  private onFanout;
175
184
  private applyFanoutLayerOp;
176
- private applyFanoutFogOp;
177
185
  close(): void;
178
186
  }
179
187
 
180
188
  declare class MemoryHubBackend implements HubBackend {
181
189
  private rooms;
182
190
  private roomLayers;
183
- private roomFog;
184
191
  private room;
185
192
  private layers;
186
193
  snapshot(room: string): Promise<CanvasElement[]>;
@@ -189,11 +196,6 @@ declare class MemoryHubBackend implements HubBackend {
189
196
  layerRecords(room: string): Promise<LayerRecord[]>;
190
197
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
191
198
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
192
- private fog;
193
- fogSnapshot(room: string): Promise<FogSnapshot | undefined>;
194
- applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
195
- applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
196
- applyFogPatch(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
197
199
  }
198
200
 
199
201
  interface AuthInfo {
@@ -215,7 +217,7 @@ interface CreateSyncServerOptions {
215
217
  authenticate?: Authenticate;
216
218
  authorize?: Authorize;
217
219
  authorizeLayer?: AuthorizeLayer;
218
- authorizeFog?: AuthorizeFog;
220
+ plugins?: readonly ServerSyncPlugin[];
219
221
  canRead?: CanRead;
220
222
  heartbeatIntervalMs?: number;
221
223
  maxMessageBytes?: number;
@@ -248,32 +250,4 @@ interface Heartbeat {
248
250
  }
249
251
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
250
252
 
251
- interface ApplyResult {
252
- accepted: SyncOp | null;
253
- corrections: SyncOp[];
254
- broadcast?: SyncOp[];
255
- locality?: 'shared' | 'local';
256
- }
257
- interface ServerOpContext {
258
- readonly room: string;
259
- readonly sender: string;
260
- readonly backend: HubBackend;
261
- }
262
- type ServerNext = () => Promise<ApplyResult>;
263
- interface ServerSyncPlugin {
264
- readonly name: string;
265
- readonly ownedLegacyKinds?: string[];
266
- process?(op: SyncOp, ctx: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
267
- snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot>;
268
- filterSnapshot?(snapshot: PluginSnapshot, viewer: {
269
- userId: string;
270
- role: string;
271
- }): PluginSnapshot | null;
272
- }
273
- interface PluginSnapshot {
274
- readonly pluginName: string;
275
- readonly version: number;
276
- readonly data: unknown;
277
- }
278
-
279
- export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeFog, type AuthorizeFogContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type FogApplyResult, type FogPatchApplyResult, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type PluginSnapshot, type ReadContext, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
253
+ export { type ApplyResult, 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, type ServerExtensionRegistry, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
package/dist/index.js CHANGED
@@ -2,19 +2,14 @@
2
2
  import {
3
3
  parseEnvelope,
4
4
  isValidElement,
5
- isNewerLayerRecord,
6
- FogLedger as FogLedger2
5
+ isNewerLayerRecord
7
6
  } from "@fieldnotes/sync";
8
7
 
9
8
  // src/memory-hub-backend.ts
10
- import {
11
- applyOpToMap,
12
- FogLedger
13
- } from "@fieldnotes/sync";
9
+ import { applyOpToMap } from "@fieldnotes/sync";
14
10
  var MemoryHubBackend = class {
15
11
  rooms = /* @__PURE__ */ new Map();
16
12
  roomLayers = /* @__PURE__ */ new Map();
17
- roomFog = /* @__PURE__ */ new Map();
18
13
  room(id) {
19
14
  let r = this.rooms.get(id);
20
15
  if (!r) {
@@ -53,26 +48,6 @@ var MemoryHubBackend = class {
53
48
  async applyLayerRecord(room, record) {
54
49
  this.layers(room).set(record.id, record);
55
50
  }
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
- }
76
51
  };
77
52
 
78
53
  // src/hub-fanout.ts
@@ -92,6 +67,46 @@ var InMemoryHubFanout = class {
92
67
  }
93
68
  };
94
69
 
70
+ // src/sync-plugin.ts
71
+ var ServerPluginRegistry = class {
72
+ byName = /* @__PURE__ */ new Map();
73
+ legacyOwners = /* @__PURE__ */ new Map();
74
+ extensions = /* @__PURE__ */ new Map();
75
+ constructor(plugins) {
76
+ for (const plugin of plugins) this.register(plugin);
77
+ }
78
+ register(plugin) {
79
+ if (this.byName.has(plugin.name))
80
+ throw new Error(`Server plugin "${plugin.name}" is duplicated`);
81
+ this.byName.set(plugin.name, plugin);
82
+ for (const kind of plugin.ownedLegacyKinds ?? []) {
83
+ if (this.legacyOwners.has(kind)) throw new Error(`Sync op kind "${kind}" has two owners`);
84
+ this.legacyOwners.set(kind, plugin);
85
+ }
86
+ plugin.registerExtensionKinds?.({
87
+ register: (kind, handler) => {
88
+ if (this.extensions.has(kind.extensionKind)) {
89
+ throw new Error(`Extension kind "${kind.extensionKind}" has two owners`);
90
+ }
91
+ this.extensions.set(kind.extensionKind, {
92
+ kind,
93
+ plugin,
94
+ handler
95
+ });
96
+ }
97
+ });
98
+ }
99
+ get plugins() {
100
+ return [...this.byName.values()];
101
+ }
102
+ ownerOf(kind) {
103
+ return this.legacyOwners.get(kind);
104
+ }
105
+ extension(extensionKind) {
106
+ return this.extensions.get(extensionKind);
107
+ }
108
+ };
109
+
95
110
  // src/resource-limits.ts
96
111
  var DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
97
112
  var DEFAULT_MAX_JSON_DEPTH = 32;
@@ -187,11 +202,6 @@ function isPresenceOp(op) {
187
202
  const k = op.kind;
188
203
  return k === "presence" || k === "presence-leave";
189
204
  }
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
- }
195
205
  var SyncHub = class {
196
206
  backend;
197
207
  conns = /* @__PURE__ */ new Map();
@@ -205,10 +215,9 @@ var SyncHub = class {
205
215
  fanoutUnsub;
206
216
  authorize;
207
217
  authorizeLayer;
208
- authorizeFog;
218
+ pluginRegistry;
209
219
  canRead;
210
220
  memoryLayers = /* @__PURE__ */ new Map();
211
- memoryFog = /* @__PURE__ */ new Map();
212
221
  maxJsonDepth;
213
222
  presenceThrottleMs;
214
223
  maxPresenceLanes;
@@ -224,21 +233,11 @@ var SyncHub = class {
224
233
  presenceLanes = /* @__PURE__ */ new Map();
225
234
  constructor(options = {}) {
226
235
  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
- }
236
+ this.pluginRegistry = new ServerPluginRegistry(options.plugins ?? []);
237
237
  this.instanceId = options.instanceId ?? generateInstanceId();
238
238
  this.fanout = options.fanout ?? new InMemoryHubFanout();
239
239
  this.authorize = options.authorize;
240
240
  this.authorizeLayer = options.authorizeLayer;
241
- this.authorizeFog = options.authorizeFog;
242
241
  this.canRead = options.canRead;
243
242
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
244
243
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
@@ -315,19 +314,39 @@ var SyncHub = class {
315
314
  const all = await this.backend.snapshot(conn.room);
316
315
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
317
316
  const layers = await this.getLayerRecords(conn.room);
318
- const fog = await this.getFogSnapshot(conn.room);
319
317
  const snapshotOp = {
320
318
  kind: "snapshot",
321
319
  to: env.from,
322
320
  elements
323
321
  };
324
322
  if (layers.length > 0) snapshotOp["layers"] = layers;
325
- if (fog) snapshotOp["fog"] = fog;
323
+ const extensions = {};
324
+ for (const plugin of this.pluginRegistry.plugins) {
325
+ let snapshot = await plugin.snapshot?.(conn.room, this.backend);
326
+ if (!snapshot) continue;
327
+ if (plugin.filterSnapshot) {
328
+ snapshot = plugin.filterSnapshot(snapshot, { userId: conn.userId, role: conn.role }) ?? void 0;
329
+ }
330
+ if (!snapshot) continue;
331
+ if (plugin.legacySnapshotKey) snapshotOp[plugin.legacySnapshotKey] = snapshot.data;
332
+ else extensions[plugin.name] = snapshot;
333
+ }
334
+ if (Object.keys(extensions).length > 0) snapshotOp["extensions"] = extensions;
326
335
  conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
327
336
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
328
337
  await this.processLayerOp(conn, op);
329
- } else if (op.kind === "fog-meta" || op.kind === "fog-patch") {
330
- await this.processFogOp(conn, op);
338
+ } else if (op.kind === "extension") {
339
+ const entry = this.pluginRegistry.extension(op.extensionKind);
340
+ if (!entry || !entry.kind.codec.validate(op.payload)) return;
341
+ await this.deliverPluginResult(conn, await entry.handler(op, this.pluginContext(conn)));
342
+ } else if (this.pluginRegistry.ownerOf(op.kind)) {
343
+ const owner = this.pluginRegistry.ownerOf(op.kind);
344
+ if (!owner?.process) return;
345
+ const result = await owner.process(op, this.pluginContext(conn), async () => ({
346
+ accepted: null,
347
+ corrections: []
348
+ }));
349
+ await this.deliverPluginResult(conn, result);
331
350
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
332
351
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
333
352
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
@@ -351,21 +370,80 @@ var SyncHub = class {
351
370
  outboundOp = { kind: "upsert", element: stampedElement };
352
371
  }
353
372
  }
354
- await this.backend.apply(conn.room, outboundOp);
355
373
  const prevExisted = current !== void 0;
356
374
  const prevAudience = current?.audience;
375
+ const result = await this.runCorePlugins(conn, outboundOp);
376
+ for (const correction of result.corrections) {
377
+ conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
378
+ }
379
+ const accepted = result.accepted;
380
+ if (accepted && (accepted.kind === "upsert" || accepted.kind === "remove" || accepted.kind === "clear")) {
381
+ if (result.locality !== "local") {
382
+ await this.fanout.publish(
383
+ JSON.stringify({
384
+ o: this.instanceId,
385
+ room: conn.room,
386
+ from: conn.id,
387
+ op: accepted,
388
+ prev: prevAudience,
389
+ existed: prevExisted
390
+ })
391
+ );
392
+ }
393
+ this.deliverToRoom(conn.room, conn.id, conn.id, accepted, prevAudience, prevExisted);
394
+ }
395
+ for (const broadcast of result.broadcast ?? []) {
396
+ await this.publishPluginOp(conn, broadcast, result.locality);
397
+ }
398
+ }
399
+ }
400
+ pluginContext(conn) {
401
+ return {
402
+ room: conn.room,
403
+ connectionId: conn.id,
404
+ userId: conn.userId,
405
+ role: conn.role,
406
+ backend: this.backend,
407
+ backendPlugin: (key) => this.backend.getService?.(key)
408
+ };
409
+ }
410
+ async runCorePlugins(conn, op) {
411
+ const middleware = this.pluginRegistry.plugins.filter((plugin) => plugin.process);
412
+ const context = this.pluginContext(conn);
413
+ const dispatch = async (index, current) => {
414
+ const plugin = middleware[index];
415
+ if (!plugin?.process) {
416
+ await this.backend.apply(conn.room, current);
417
+ return { accepted: current, corrections: [] };
418
+ }
419
+ let called = false;
420
+ return plugin.process(current, context, async (nextOp, nextContext) => {
421
+ if (called) throw new Error(`Server plugin "${plugin.name}" called next() more than once`);
422
+ if (nextContext !== context) {
423
+ throw new Error(`Server plugin "${plugin.name}" replaced the operation context`);
424
+ }
425
+ called = true;
426
+ return dispatch(index + 1, nextOp);
427
+ });
428
+ };
429
+ return dispatch(0, op);
430
+ }
431
+ async deliverPluginResult(conn, result) {
432
+ for (const correction of result.corrections) {
433
+ conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
434
+ }
435
+ if (result.accepted) await this.publishPluginOp(conn, result.accepted, result.locality);
436
+ for (const broadcast of result.broadcast ?? []) {
437
+ await this.publishPluginOp(conn, broadcast, result.locality);
438
+ }
439
+ }
440
+ async publishPluginOp(conn, op, locality) {
441
+ if (locality !== "local") {
357
442
  await this.fanout.publish(
358
- JSON.stringify({
359
- o: this.instanceId,
360
- room: conn.room,
361
- from: conn.id,
362
- op: outboundOp,
363
- prev: prevAudience,
364
- existed: prevExisted
365
- })
443
+ JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op })
366
444
  );
367
- this.deliverToRoom(conn.room, conn.id, conn.id, outboundOp, prevAudience, prevExisted);
368
445
  }
446
+ this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op }));
369
447
  }
370
448
  /**
371
449
  * Applies a layer-definition edit on the room's serial queue. Convergence is
@@ -433,121 +511,6 @@ var SyncHub = class {
433
511
  }
434
512
  map.set(record.id, record);
435
513
  }
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
- }
551
514
  mayRead(conn, audience) {
552
515
  if (!this.canRead) return true;
553
516
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -728,10 +691,17 @@ var SyncHub = class {
728
691
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
729
692
  return;
730
693
  }
731
- if (isFogOp(op)) {
694
+ 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;
695
+ if (plugin && typeof op === "object" && op !== null) {
732
696
  const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
733
697
  const operation = previous.then(async () => {
734
- const accepted = await this.applyFanoutFogOp(env.room, op);
698
+ const context = {
699
+ room: env.room,
700
+ connectionId: env.from,
701
+ backend: this.backend,
702
+ backendPlugin: (key) => this.backend.getService?.(key)
703
+ };
704
+ const accepted = plugin.applyFanout ? await plugin.applyFanout(op, context) : op;
735
705
  if (accepted) {
736
706
  this.relayToRoom(
737
707
  env.room,
@@ -758,15 +728,6 @@ var SyncHub = class {
758
728
  if (current && !isNewerLayerRecord(record, current)) return;
759
729
  await this.applyLayerRecord(room, record);
760
730
  }
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
- }
770
731
  close() {
771
732
  for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
772
733
  this.fanoutUnsub();
@@ -857,7 +818,7 @@ function createSyncServer(options = {}) {
857
818
  instanceId: options.instanceId,
858
819
  authorize: options.authorize,
859
820
  authorizeLayer: options.authorizeLayer,
860
- authorizeFog: options.authorizeFog,
821
+ plugins: options.plugins,
861
822
  canRead: options.canRead,
862
823
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
863
824
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,