@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/README.md +283 -283
- package/dist/index.cjs +195 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -4
- package/dist/index.d.ts +68 -4
- package/dist/index.js +200 -22
- 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 } 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
|
|
73
|
-
|
|
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
|
|
73
|
-
|
|
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 {
|
|
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
|
-
|
|
132
|
-
|
|
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.
|
|
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({
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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 =
|
|
315
|
-
|
|
461
|
+
const pending = lane.pending;
|
|
462
|
+
lane.pending = null;
|
|
316
463
|
if (!pending || !this.conns.has(conn.id)) return;
|
|
317
|
-
|
|
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
|
-
|
|
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
|
|
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 });
|