@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/README.md +291 -283
- package/dist/index.cjs +145 -180
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -69
- package/dist/index.d.ts +43 -69
- package/dist/index.js +147 -186
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { CanvasElement } from '@fieldnotes/core';
|
|
2
|
-
import { SyncOp, LayerRecord,
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|