@fieldnotes/sync-server 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +194 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -11
- package/dist/index.d.ts +71 -11
- package/dist/index.js +200 -11
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CanvasElement } from '@fieldnotes/core';
|
|
2
|
-
import { SyncOp, LayerRecord } from '@fieldnotes/sync';
|
|
2
|
+
import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
4
|
import { IncomingMessage, Server } from 'http';
|
|
5
5
|
|
|
@@ -14,21 +14,27 @@ declare class InMemoryHubFanout implements HubFanout {
|
|
|
14
14
|
subscribe(handler: (payload: string) => void): () => void;
|
|
15
15
|
}
|
|
16
16
|
|
|
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
|
+
}
|
|
17
25
|
interface HubBackend {
|
|
26
|
+
/** True when every hub instance addresses the same atomic backing state (for example Redis). */
|
|
27
|
+
readonly sharedAcrossInstances?: boolean;
|
|
18
28
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
19
29
|
get(room: string, id: string): Promise<CanvasElement | undefined>;
|
|
20
30
|
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
31
|
layerRecords?(room: string): Promise<LayerRecord[]>;
|
|
30
32
|
getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
|
|
31
33
|
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>;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
type OwnedElement = CanvasElement & {
|
|
@@ -67,6 +73,16 @@ interface ReadContext {
|
|
|
67
73
|
audience: string | undefined;
|
|
68
74
|
}
|
|
69
75
|
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;
|
|
84
|
+
}
|
|
85
|
+
type AuthorizeFog = (ctx: AuthorizeFogContext) => boolean | Promise<boolean>;
|
|
70
86
|
|
|
71
87
|
interface Connection {
|
|
72
88
|
id: string;
|
|
@@ -81,6 +97,7 @@ interface SyncHubOptions {
|
|
|
81
97
|
instanceId?: string;
|
|
82
98
|
authorize?: Authorize;
|
|
83
99
|
authorizeLayer?: AuthorizeLayer;
|
|
100
|
+
authorizeFog?: AuthorizeFog;
|
|
84
101
|
canRead?: CanRead;
|
|
85
102
|
maxJsonDepth?: number;
|
|
86
103
|
presenceThrottleMs?: number;
|
|
@@ -97,9 +114,10 @@ declare class SyncHub {
|
|
|
97
114
|
private readonly fanoutUnsub;
|
|
98
115
|
private readonly authorize?;
|
|
99
116
|
private readonly authorizeLayer?;
|
|
117
|
+
private readonly authorizeFog?;
|
|
100
118
|
private readonly canRead?;
|
|
101
|
-
/** Fallback layer-record store for backends without layer persistence. */
|
|
102
119
|
private readonly memoryLayers;
|
|
120
|
+
private readonly memoryFog;
|
|
103
121
|
private readonly maxJsonDepth;
|
|
104
122
|
private readonly presenceThrottleMs;
|
|
105
123
|
private readonly maxPresenceLanes;
|
|
@@ -137,6 +155,12 @@ declare class SyncHub {
|
|
|
137
155
|
private getLayerRecords;
|
|
138
156
|
private getLayerRecord;
|
|
139
157
|
private applyLayerRecord;
|
|
158
|
+
private fogBackend;
|
|
159
|
+
private getFogLedger;
|
|
160
|
+
private getFogSnapshot;
|
|
161
|
+
private applyFogMeta;
|
|
162
|
+
private applyFogPatch;
|
|
163
|
+
private processFogOp;
|
|
140
164
|
private mayRead;
|
|
141
165
|
private safePublish;
|
|
142
166
|
private relayToRoom;
|
|
@@ -149,12 +173,14 @@ declare class SyncHub {
|
|
|
149
173
|
private sendCorrection;
|
|
150
174
|
private onFanout;
|
|
151
175
|
private applyFanoutLayerOp;
|
|
176
|
+
private applyFanoutFogOp;
|
|
152
177
|
close(): void;
|
|
153
178
|
}
|
|
154
179
|
|
|
155
180
|
declare class MemoryHubBackend implements HubBackend {
|
|
156
181
|
private rooms;
|
|
157
182
|
private roomLayers;
|
|
183
|
+
private roomFog;
|
|
158
184
|
private room;
|
|
159
185
|
private layers;
|
|
160
186
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
@@ -163,6 +189,11 @@ declare class MemoryHubBackend implements HubBackend {
|
|
|
163
189
|
layerRecords(room: string): Promise<LayerRecord[]>;
|
|
164
190
|
getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
|
|
165
191
|
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>;
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
interface AuthInfo {
|
|
@@ -184,6 +215,7 @@ interface CreateSyncServerOptions {
|
|
|
184
215
|
authenticate?: Authenticate;
|
|
185
216
|
authorize?: Authorize;
|
|
186
217
|
authorizeLayer?: AuthorizeLayer;
|
|
218
|
+
authorizeFog?: AuthorizeFog;
|
|
187
219
|
canRead?: CanRead;
|
|
188
220
|
heartbeatIntervalMs?: number;
|
|
189
221
|
maxMessageBytes?: number;
|
|
@@ -216,4 +248,32 @@ interface Heartbeat {
|
|
|
216
248
|
}
|
|
217
249
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
218
250
|
|
|
219
|
-
|
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CanvasElement } from '@fieldnotes/core';
|
|
2
|
-
import { SyncOp, LayerRecord } from '@fieldnotes/sync';
|
|
2
|
+
import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
4
|
import { IncomingMessage, Server } from 'http';
|
|
5
5
|
|
|
@@ -14,21 +14,27 @@ declare class InMemoryHubFanout implements HubFanout {
|
|
|
14
14
|
subscribe(handler: (payload: string) => void): () => void;
|
|
15
15
|
}
|
|
16
16
|
|
|
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
|
+
}
|
|
17
25
|
interface HubBackend {
|
|
26
|
+
/** True when every hub instance addresses the same atomic backing state (for example Redis). */
|
|
27
|
+
readonly sharedAcrossInstances?: boolean;
|
|
18
28
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
19
29
|
get(room: string, id: string): Promise<CanvasElement | undefined>;
|
|
20
30
|
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
31
|
layerRecords?(room: string): Promise<LayerRecord[]>;
|
|
30
32
|
getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
|
|
31
33
|
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>;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
type OwnedElement = CanvasElement & {
|
|
@@ -67,6 +73,16 @@ interface ReadContext {
|
|
|
67
73
|
audience: string | undefined;
|
|
68
74
|
}
|
|
69
75
|
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;
|
|
84
|
+
}
|
|
85
|
+
type AuthorizeFog = (ctx: AuthorizeFogContext) => boolean | Promise<boolean>;
|
|
70
86
|
|
|
71
87
|
interface Connection {
|
|
72
88
|
id: string;
|
|
@@ -81,6 +97,7 @@ interface SyncHubOptions {
|
|
|
81
97
|
instanceId?: string;
|
|
82
98
|
authorize?: Authorize;
|
|
83
99
|
authorizeLayer?: AuthorizeLayer;
|
|
100
|
+
authorizeFog?: AuthorizeFog;
|
|
84
101
|
canRead?: CanRead;
|
|
85
102
|
maxJsonDepth?: number;
|
|
86
103
|
presenceThrottleMs?: number;
|
|
@@ -97,9 +114,10 @@ declare class SyncHub {
|
|
|
97
114
|
private readonly fanoutUnsub;
|
|
98
115
|
private readonly authorize?;
|
|
99
116
|
private readonly authorizeLayer?;
|
|
117
|
+
private readonly authorizeFog?;
|
|
100
118
|
private readonly canRead?;
|
|
101
|
-
/** Fallback layer-record store for backends without layer persistence. */
|
|
102
119
|
private readonly memoryLayers;
|
|
120
|
+
private readonly memoryFog;
|
|
103
121
|
private readonly maxJsonDepth;
|
|
104
122
|
private readonly presenceThrottleMs;
|
|
105
123
|
private readonly maxPresenceLanes;
|
|
@@ -137,6 +155,12 @@ declare class SyncHub {
|
|
|
137
155
|
private getLayerRecords;
|
|
138
156
|
private getLayerRecord;
|
|
139
157
|
private applyLayerRecord;
|
|
158
|
+
private fogBackend;
|
|
159
|
+
private getFogLedger;
|
|
160
|
+
private getFogSnapshot;
|
|
161
|
+
private applyFogMeta;
|
|
162
|
+
private applyFogPatch;
|
|
163
|
+
private processFogOp;
|
|
140
164
|
private mayRead;
|
|
141
165
|
private safePublish;
|
|
142
166
|
private relayToRoom;
|
|
@@ -149,12 +173,14 @@ declare class SyncHub {
|
|
|
149
173
|
private sendCorrection;
|
|
150
174
|
private onFanout;
|
|
151
175
|
private applyFanoutLayerOp;
|
|
176
|
+
private applyFanoutFogOp;
|
|
152
177
|
close(): void;
|
|
153
178
|
}
|
|
154
179
|
|
|
155
180
|
declare class MemoryHubBackend implements HubBackend {
|
|
156
181
|
private rooms;
|
|
157
182
|
private roomLayers;
|
|
183
|
+
private roomFog;
|
|
158
184
|
private room;
|
|
159
185
|
private layers;
|
|
160
186
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
@@ -163,6 +189,11 @@ declare class MemoryHubBackend implements HubBackend {
|
|
|
163
189
|
layerRecords(room: string): Promise<LayerRecord[]>;
|
|
164
190
|
getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
|
|
165
191
|
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>;
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
interface AuthInfo {
|
|
@@ -184,6 +215,7 @@ interface CreateSyncServerOptions {
|
|
|
184
215
|
authenticate?: Authenticate;
|
|
185
216
|
authorize?: Authorize;
|
|
186
217
|
authorizeLayer?: AuthorizeLayer;
|
|
218
|
+
authorizeFog?: AuthorizeFog;
|
|
187
219
|
canRead?: CanRead;
|
|
188
220
|
heartbeatIntervalMs?: number;
|
|
189
221
|
maxMessageBytes?: number;
|
|
@@ -216,4 +248,32 @@ interface Heartbeat {
|
|
|
216
248
|
}
|
|
217
249
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
218
250
|
|
|
219
|
-
|
|
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 };
|
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 {
|
|
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
|
|
@@ -162,6 +187,11 @@ function isPresenceOp(op) {
|
|
|
162
187
|
const k = op.kind;
|
|
163
188
|
return k === "presence" || k === "presence-leave";
|
|
164
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
|
+
}
|
|
165
195
|
var SyncHub = class {
|
|
166
196
|
backend;
|
|
167
197
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -175,9 +205,10 @@ var SyncHub = class {
|
|
|
175
205
|
fanoutUnsub;
|
|
176
206
|
authorize;
|
|
177
207
|
authorizeLayer;
|
|
208
|
+
authorizeFog;
|
|
178
209
|
canRead;
|
|
179
|
-
/** Fallback layer-record store for backends without layer persistence. */
|
|
180
210
|
memoryLayers = /* @__PURE__ */ new Map();
|
|
211
|
+
memoryFog = /* @__PURE__ */ new Map();
|
|
181
212
|
maxJsonDepth;
|
|
182
213
|
presenceThrottleMs;
|
|
183
214
|
maxPresenceLanes;
|
|
@@ -193,10 +224,21 @@ var SyncHub = class {
|
|
|
193
224
|
presenceLanes = /* @__PURE__ */ new Map();
|
|
194
225
|
constructor(options = {}) {
|
|
195
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
|
+
}
|
|
196
237
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
197
238
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
198
239
|
this.authorize = options.authorize;
|
|
199
240
|
this.authorizeLayer = options.authorizeLayer;
|
|
241
|
+
this.authorizeFog = options.authorizeFog;
|
|
200
242
|
this.canRead = options.canRead;
|
|
201
243
|
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
202
244
|
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
@@ -273,16 +315,19 @@ var SyncHub = class {
|
|
|
273
315
|
const all = await this.backend.snapshot(conn.room);
|
|
274
316
|
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
275
317
|
const layers = await this.getLayerRecords(conn.room);
|
|
276
|
-
conn.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
);
|
|
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 }));
|
|
284
327
|
} else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
|
|
285
328
|
await this.processLayerOp(conn, op);
|
|
329
|
+
} else if (op.kind === "fog-meta" || op.kind === "fog-patch") {
|
|
330
|
+
await this.processFogOp(conn, op);
|
|
286
331
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
287
332
|
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
288
333
|
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
@@ -388,6 +433,121 @@ var SyncHub = class {
|
|
|
388
433
|
}
|
|
389
434
|
map.set(record.id, record);
|
|
390
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
|
+
}
|
|
391
551
|
mayRead(conn, audience) {
|
|
392
552
|
if (!this.canRead) return true;
|
|
393
553
|
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
@@ -568,6 +728,25 @@ var SyncHub = class {
|
|
|
568
728
|
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
569
729
|
return;
|
|
570
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
|
+
}
|
|
571
750
|
if (!isFanoutOp(op)) return;
|
|
572
751
|
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
573
752
|
const prevExisted = env.existed === true;
|
|
@@ -579,6 +758,15 @@ var SyncHub = class {
|
|
|
579
758
|
if (current && !isNewerLayerRecord(record, current)) return;
|
|
580
759
|
await this.applyLayerRecord(room, record);
|
|
581
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
|
+
}
|
|
582
770
|
close() {
|
|
583
771
|
for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
|
|
584
772
|
this.fanoutUnsub();
|
|
@@ -669,6 +857,7 @@ function createSyncServer(options = {}) {
|
|
|
669
857
|
instanceId: options.instanceId,
|
|
670
858
|
authorize: options.authorize,
|
|
671
859
|
authorizeLayer: options.authorizeLayer,
|
|
860
|
+
authorizeFog: options.authorizeFog,
|
|
672
861
|
canRead: options.canRead,
|
|
673
862
|
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
674
863
|
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,
|