@fieldnotes/sync-server 0.6.0 → 0.8.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 +43 -0
- package/dist/index.cjs +137 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +138 -31
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -158,4 +158,47 @@ createSyncServer({
|
|
|
158
158
|
stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
|
|
159
159
|
kinds and is sent only to the offending connection.
|
|
160
160
|
|
|
161
|
+
## Read filtering
|
|
162
|
+
|
|
163
|
+
Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
|
|
164
|
+
hiding, the hub filters ops before they leave the server, so hidden elements never reach an
|
|
165
|
+
unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
canRead({ userId, role, room, audience }) => boolean
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
|
|
172
|
+
`resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
|
|
173
|
+
everyone sees everything.
|
|
174
|
+
|
|
175
|
+
A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// relay
|
|
179
|
+
createSyncServer({
|
|
180
|
+
port: 8080,
|
|
181
|
+
authenticate: /* … supplies userId + role … */,
|
|
182
|
+
canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// client (@fieldnotes/sync)
|
|
186
|
+
new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
|
|
190
|
+
gets a synthetic **remove**, one who gains it gets an **add**.
|
|
191
|
+
|
|
192
|
+
**Preconditions:**
|
|
193
|
+
|
|
194
|
+
- **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
|
|
195
|
+
no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
|
|
196
|
+
- **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
|
|
197
|
+
instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
|
|
198
|
+
local members — so all relay instances must inject the **same** `canRead`, alongside the existing
|
|
199
|
+
shared-backend + shared-fanout requirement.
|
|
200
|
+
- **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
|
|
201
|
+
the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
|
|
202
|
+
control.
|
|
203
|
+
|
|
161
204
|
A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
|
package/dist/index.cjs
CHANGED
|
@@ -82,6 +82,18 @@ function generateInstanceId() {
|
|
|
82
82
|
return crypto.randomUUID();
|
|
83
83
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
84
84
|
}
|
|
85
|
+
function isFanoutOp(op) {
|
|
86
|
+
if (typeof op !== "object" || op === null) return false;
|
|
87
|
+
const o = op;
|
|
88
|
+
if (o.kind === "upsert") return (0, import_sync2.isValidElement)(o.element);
|
|
89
|
+
if (o.kind === "remove") return typeof o.id === "string";
|
|
90
|
+
return o.kind === "clear";
|
|
91
|
+
}
|
|
92
|
+
function isPresenceOp(op) {
|
|
93
|
+
if (typeof op !== "object" || op === null) return false;
|
|
94
|
+
const k = op.kind;
|
|
95
|
+
return k === "presence" || k === "presence-leave";
|
|
96
|
+
}
|
|
85
97
|
var SyncHub = class {
|
|
86
98
|
backend;
|
|
87
99
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -89,15 +101,19 @@ var SyncHub = class {
|
|
|
89
101
|
// room → connIds
|
|
90
102
|
roomQueues = /* @__PURE__ */ new Map();
|
|
91
103
|
// room → serial tail
|
|
104
|
+
presenceIds = /* @__PURE__ */ new Map();
|
|
105
|
+
// connId → last clientId that sent presence
|
|
92
106
|
instanceId;
|
|
93
107
|
fanout;
|
|
94
108
|
fanoutUnsub;
|
|
95
109
|
authorize;
|
|
110
|
+
canRead;
|
|
96
111
|
constructor(options = {}) {
|
|
97
112
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
98
113
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
99
114
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
100
115
|
this.authorize = options.authorize;
|
|
116
|
+
this.canRead = options.canRead;
|
|
101
117
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
102
118
|
}
|
|
103
119
|
addConnection(conn) {
|
|
@@ -113,14 +129,18 @@ var SyncHub = class {
|
|
|
113
129
|
const conn = this.conns.get(connId);
|
|
114
130
|
if (!conn) return;
|
|
115
131
|
this.conns.delete(connId);
|
|
116
|
-
const
|
|
132
|
+
const room = conn.room;
|
|
133
|
+
const clientId = this.presenceIds.get(connId);
|
|
134
|
+
this.presenceIds.delete(connId);
|
|
135
|
+
const members = this.rooms.get(room);
|
|
117
136
|
if (members) {
|
|
118
137
|
members.delete(connId);
|
|
119
138
|
if (members.size === 0) {
|
|
120
|
-
this.rooms.delete(
|
|
121
|
-
this.roomQueues.delete(
|
|
139
|
+
this.rooms.delete(room);
|
|
140
|
+
this.roomQueues.delete(room);
|
|
122
141
|
}
|
|
123
142
|
}
|
|
143
|
+
if (clientId !== void 0) this.broadcastLeave(room, clientId);
|
|
124
144
|
}
|
|
125
145
|
roomCount() {
|
|
126
146
|
return this.rooms.size;
|
|
@@ -128,28 +148,33 @@ var SyncHub = class {
|
|
|
128
148
|
handleMessage(connId, message) {
|
|
129
149
|
const conn = this.conns.get(connId);
|
|
130
150
|
if (!conn) return Promise.resolve();
|
|
151
|
+
const env = (0, import_sync2.parseEnvelope)(message);
|
|
152
|
+
if (!env) return Promise.resolve();
|
|
153
|
+
if (env.op.kind === "presence") {
|
|
154
|
+
this.broadcastPresence(conn, env.from, env.op.data);
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
131
157
|
const room = conn.room;
|
|
132
158
|
const prev = this.roomQueues.get(room) ?? Promise.resolve();
|
|
133
|
-
const next = prev.then(() => this.process(conn,
|
|
159
|
+
const next = prev.then(() => this.process(conn, env)).catch(() => {
|
|
134
160
|
});
|
|
135
161
|
this.roomQueues.set(room, next);
|
|
136
162
|
return next;
|
|
137
163
|
}
|
|
138
|
-
async process(conn,
|
|
139
|
-
const env = (0, import_sync2.parseEnvelope)(message);
|
|
140
|
-
if (!env) return;
|
|
164
|
+
async process(conn, env) {
|
|
141
165
|
const op = env.op;
|
|
142
166
|
if (op.kind === "request-snapshot") {
|
|
143
|
-
const
|
|
167
|
+
const all = await this.backend.snapshot(conn.room);
|
|
168
|
+
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
144
169
|
conn.send(
|
|
145
170
|
JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
|
|
146
171
|
);
|
|
147
172
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
173
|
+
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
174
|
+
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
175
|
+
const current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
148
176
|
let outboundOp = op;
|
|
149
|
-
let outboundMessage = message;
|
|
150
177
|
if (this.authorize) {
|
|
151
|
-
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
152
|
-
const current = id !== void 0 ? await this.backend.get(conn.room, id) : void 0;
|
|
153
178
|
const allowed = await this.authorize({
|
|
154
179
|
userId: conn.userId,
|
|
155
180
|
role: conn.role,
|
|
@@ -165,22 +190,103 @@ var SyncHub = class {
|
|
|
165
190
|
const ownerId = current?.ownerId ?? conn.userId;
|
|
166
191
|
const stampedElement = { ...op.element, ownerId };
|
|
167
192
|
outboundOp = { kind: "upsert", element: stampedElement };
|
|
168
|
-
outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });
|
|
169
193
|
}
|
|
170
194
|
}
|
|
171
195
|
await this.backend.apply(conn.room, outboundOp);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (id === conn.id) continue;
|
|
176
|
-
this.conns.get(id)?.send(outboundMessage);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
196
|
+
const prevExisted = current !== void 0;
|
|
197
|
+
const prevAudience = current?.audience;
|
|
198
|
+
this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);
|
|
179
199
|
this.fanout.publish(
|
|
180
|
-
JSON.stringify({
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
o: this.instanceId,
|
|
202
|
+
room: conn.room,
|
|
203
|
+
from: env.from,
|
|
204
|
+
op: outboundOp,
|
|
205
|
+
prev: prevAudience,
|
|
206
|
+
existed: prevExisted
|
|
207
|
+
})
|
|
181
208
|
);
|
|
182
209
|
}
|
|
183
210
|
}
|
|
211
|
+
mayRead(conn, audience) {
|
|
212
|
+
if (!this.canRead) return true;
|
|
213
|
+
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
214
|
+
}
|
|
215
|
+
safePublish(payload) {
|
|
216
|
+
try {
|
|
217
|
+
this.fanout.publish(payload);
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
relayToRoom(room, excludeId, message) {
|
|
222
|
+
const members = this.rooms.get(room);
|
|
223
|
+
if (!members) return;
|
|
224
|
+
for (const cid of members) {
|
|
225
|
+
if (cid === excludeId) continue;
|
|
226
|
+
const conn = this.conns.get(cid);
|
|
227
|
+
if (!conn) continue;
|
|
228
|
+
try {
|
|
229
|
+
conn.send(message);
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
broadcastPresence(conn, from, data) {
|
|
235
|
+
this.presenceIds.set(conn.id, from);
|
|
236
|
+
const message = JSON.stringify({ from, op: { kind: "presence", data } });
|
|
237
|
+
this.relayToRoom(conn.room, conn.id, message);
|
|
238
|
+
this.safePublish(
|
|
239
|
+
JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: "presence", data } })
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
broadcastLeave(room, from) {
|
|
243
|
+
const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
|
|
244
|
+
this.relayToRoom(room, void 0, message);
|
|
245
|
+
this.safePublish(
|
|
246
|
+
JSON.stringify({ o: this.instanceId, room, from, op: { kind: "presence-leave" } })
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
|
|
250
|
+
const members = this.rooms.get(room);
|
|
251
|
+
if (!members) return;
|
|
252
|
+
const send = (conn, msg) => {
|
|
253
|
+
try {
|
|
254
|
+
conn.send(msg);
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
if (op.kind === "upsert") {
|
|
259
|
+
const audience = op.element.audience;
|
|
260
|
+
const upsertMsg = JSON.stringify({ from, op });
|
|
261
|
+
const removeMsg = JSON.stringify({
|
|
262
|
+
from: HUB_FROM,
|
|
263
|
+
op: { kind: "remove", id: op.element.id }
|
|
264
|
+
});
|
|
265
|
+
for (const cid of members) {
|
|
266
|
+
if (cid === excludeId) continue;
|
|
267
|
+
const conn = this.conns.get(cid);
|
|
268
|
+
if (!conn) continue;
|
|
269
|
+
if (this.mayRead(conn, audience)) send(conn, upsertMsg);
|
|
270
|
+
else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
|
|
271
|
+
}
|
|
272
|
+
} else if (op.kind === "remove") {
|
|
273
|
+
const removeMsg = JSON.stringify({ from, op });
|
|
274
|
+
for (const cid of members) {
|
|
275
|
+
if (cid === excludeId) continue;
|
|
276
|
+
const conn = this.conns.get(cid);
|
|
277
|
+
if (!conn) continue;
|
|
278
|
+
const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
|
|
279
|
+
if (wasVisible) send(conn, removeMsg);
|
|
280
|
+
}
|
|
281
|
+
} else if (op.kind === "clear") {
|
|
282
|
+
const clearMsg = JSON.stringify({ from, op });
|
|
283
|
+
for (const cid of members) {
|
|
284
|
+
if (cid === excludeId) continue;
|
|
285
|
+
const conn = this.conns.get(cid);
|
|
286
|
+
if (conn) send(conn, clearMsg);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
184
290
|
async sendCorrection(conn, from, op, current) {
|
|
185
291
|
let correction;
|
|
186
292
|
if (op.kind === "upsert") {
|
|
@@ -200,18 +306,18 @@ var SyncHub = class {
|
|
|
200
306
|
} catch {
|
|
201
307
|
return;
|
|
202
308
|
}
|
|
203
|
-
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.
|
|
309
|
+
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
204
310
|
return;
|
|
205
311
|
if (env.o === this.instanceId) return;
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
this.conns.get(id)?.send(m);
|
|
212
|
-
} catch {
|
|
213
|
-
}
|
|
312
|
+
const op = env.op;
|
|
313
|
+
if (isPresenceOp(op)) {
|
|
314
|
+
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
315
|
+
return;
|
|
214
316
|
}
|
|
317
|
+
if (!isFanoutOp(op)) return;
|
|
318
|
+
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
319
|
+
const prevExisted = env.existed === true;
|
|
320
|
+
this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
|
|
215
321
|
}
|
|
216
322
|
close() {
|
|
217
323
|
this.fanoutUnsub();
|
|
@@ -258,7 +364,8 @@ function createSyncServer(options = {}) {
|
|
|
258
364
|
backend: options.backend,
|
|
259
365
|
fanout: options.fanout,
|
|
260
366
|
instanceId: options.instanceId,
|
|
261
|
-
authorize: options.authorize
|
|
367
|
+
authorize: options.authorize,
|
|
368
|
+
canRead: options.canRead
|
|
262
369
|
});
|
|
263
370
|
const wss = options.server ? new import_ws.WebSocketServer({ server: options.server }) : new import_ws.WebSocketServer({ port: options.port ?? 0 });
|
|
264
371
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["export { SyncHub } from './sync-hub';\r\nexport type { SyncHubOptions, Connection } from './sync-hub';\r\nexport { MemoryHubBackend } from './memory-hub-backend';\r\nexport type { HubBackend } from './hub-backend';\r\nexport { createSyncServer } from './create-sync-server';\r\nexport type { CreateSyncServerOptions } from './create-sync-server';\r\nexport { InMemoryHubFanout } from './hub-fanout';\r\nexport type { HubFanout } from './hub-fanout';\r\nexport type { AuthInfo, AuthResult, Authenticate } from './authenticate';\r\nexport type { Authorize, AuthorizeContext, OwnedElement } from './authorize';\r\nexport { startHeartbeat } from './heartbeat';\r\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\r\n","import { parseEnvelope, type SyncOp } from '@fieldnotes/sync';\r\nimport { MemoryHubBackend } from './memory-hub-backend';\r\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { Authorize, OwnedElement } from './authorize';\r\n\r\nexport interface Connection {\r\n id: string;\r\n room: string;\r\n userId?: string;\r\n role?: string;\r\n send(message: string): void;\r\n}\r\n\r\nexport interface SyncHubOptions {\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authorize?: Authorize;\r\n}\r\n\r\nconst HUB_FROM = 'hub';\r\n\r\nfunction generateInstanceId(): string {\r\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\r\n return crypto.randomUUID();\r\n return `i-${Math.random().toString(36).slice(2)}`;\r\n}\r\n\r\nexport class SyncHub {\r\n private readonly backend: HubBackend;\r\n private readonly conns = new Map<string, Connection>();\r\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\r\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\r\n private readonly instanceId: string;\r\n private readonly fanout: HubFanout;\r\n private readonly fanoutUnsub: () => void;\r\n private readonly authorize?: Authorize;\r\n\r\n constructor(options: SyncHubOptions = {}) {\r\n this.backend = options.backend ?? new MemoryHubBackend();\r\n this.instanceId = options.instanceId ?? generateInstanceId();\r\n this.fanout = options.fanout ?? new InMemoryHubFanout();\r\n this.authorize = options.authorize;\r\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\r\n }\r\n\r\n addConnection(conn: Connection): void {\r\n this.conns.set(conn.id, conn);\r\n let set = this.rooms.get(conn.room);\r\n if (!set) {\r\n set = new Set();\r\n this.rooms.set(conn.room, set);\r\n }\r\n set.add(conn.id);\r\n }\r\n\r\n removeConnection(connId: string): void {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return;\r\n this.conns.delete(connId);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n members.delete(connId);\r\n if (members.size === 0) {\r\n this.rooms.delete(conn.room);\r\n this.roomQueues.delete(conn.room);\r\n }\r\n }\r\n }\r\n\r\n roomCount(): number {\r\n return this.rooms.size;\r\n }\r\n\r\n handleMessage(connId: string, message: string): Promise<void> {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return Promise.resolve();\r\n const room = conn.room;\r\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\r\n const next = prev\r\n .then(() => this.process(conn, message))\r\n .catch(() => {\r\n // swallow so one failed message never wedges the room's serial queue\r\n });\r\n this.roomQueues.set(room, next);\r\n return next;\r\n }\r\n\r\n private async process(conn: Connection, message: string): Promise<void> {\r\n const env = parseEnvelope(message);\r\n if (!env) return;\r\n const op = env.op;\r\n if (op.kind === 'request-snapshot') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n conn.send(\r\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\r\n );\r\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\r\n let outboundOp: SyncOp = op;\r\n let outboundMessage = message;\r\n if (this.authorize) {\r\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\r\n const current: OwnedElement | undefined =\r\n id !== undefined ? await this.backend.get(conn.room, id) : undefined;\r\n const allowed = await this.authorize({\r\n userId: conn.userId,\r\n role: conn.role,\r\n room: conn.room,\r\n op,\r\n currentElement: current,\r\n });\r\n if (!allowed) {\r\n await this.sendCorrection(conn, env.from, op, current);\r\n return;\r\n }\r\n if (op.kind === 'upsert') {\r\n const ownerId = current?.ownerId ?? conn.userId;\r\n const stampedElement: OwnedElement = { ...op.element, ownerId };\r\n outboundOp = { kind: 'upsert', element: stampedElement };\r\n outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });\r\n }\r\n }\r\n await this.backend.apply(conn.room, outboundOp);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n for (const id of members) {\r\n if (id === conn.id) continue;\r\n this.conns.get(id)?.send(outboundMessage);\r\n }\r\n }\r\n this.fanout.publish(\r\n JSON.stringify({ o: this.instanceId, room: conn.room, m: outboundMessage }),\r\n );\r\n }\r\n // 'snapshot' from a client → ignored\r\n }\r\n\r\n private async sendCorrection(\r\n conn: Connection,\r\n from: string,\r\n op: SyncOp,\r\n current: OwnedElement | undefined,\r\n ): Promise<void> {\r\n let correction: SyncOp | undefined;\r\n if (op.kind === 'upsert') {\r\n correction = current\r\n ? { kind: 'upsert', element: current }\r\n : { kind: 'remove', id: op.element.id };\r\n } else if (op.kind === 'remove') {\r\n correction = current ? { kind: 'upsert', element: current } : undefined;\r\n } else if (op.kind === 'clear') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n correction = { kind: 'snapshot', to: from, elements };\r\n }\r\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\r\n }\r\n\r\n private onFanout(payload: string): void {\r\n // Off the serial queue on purpose: forward-only (no backend re-apply — the origin already applied to the\r\n // SHARED backend), and delivery is already ordered. Do not wrap this in roomQueues.\r\n let env: { o?: unknown; room?: unknown; m?: unknown };\r\n try {\r\n env = JSON.parse(payload);\r\n } catch {\r\n return;\r\n }\r\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.m !== 'string')\r\n return;\r\n if (env.o === this.instanceId) return; // our own publish — already forwarded locally\r\n const members = this.rooms.get(env.room);\r\n if (!members) return;\r\n const m = env.m;\r\n for (const id of members) {\r\n try {\r\n this.conns.get(id)?.send(m);\r\n } catch {\r\n /* a throwing socket must not break the fanout loop */\r\n }\r\n }\r\n }\r\n\r\n close(): void {\r\n this.fanoutUnsub();\r\n }\r\n}\r\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":["import_sync"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["export { SyncHub } from './sync-hub';\r\nexport type { SyncHubOptions, Connection } from './sync-hub';\r\nexport { MemoryHubBackend } from './memory-hub-backend';\r\nexport type { HubBackend } from './hub-backend';\r\nexport { createSyncServer } from './create-sync-server';\r\nexport type { CreateSyncServerOptions } from './create-sync-server';\r\nexport { InMemoryHubFanout } from './hub-fanout';\r\nexport type { HubFanout } from './hub-fanout';\r\nexport type { AuthInfo, AuthResult, Authenticate } from './authenticate';\r\nexport type { Authorize, AuthorizeContext, OwnedElement, ReadContext, CanRead } from './authorize';\r\nexport { startHeartbeat } from './heartbeat';\r\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\r\n","import { parseEnvelope, isValidElement, type SyncOp, type SyncEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\nimport type { HubBackend } from './hub-backend';\nimport type { Authorize, CanRead, OwnedElement } from './authorize';\n\nexport interface Connection {\n id: string;\n room: string;\n userId?: string;\n role?: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authorize?: Authorize;\n canRead?: CanRead;\n}\n\nconst HUB_FROM = 'hub';\n\nfunction generateInstanceId(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\n return crypto.randomUUID();\n return `i-${Math.random().toString(36).slice(2)}`;\n}\n\nfunction isFanoutOp(op: unknown): op is Extract<SyncOp, { kind: 'upsert' | 'remove' | 'clear' }> {\n if (typeof op !== 'object' || op === null) return false;\n const o = op as { kind?: unknown; element?: unknown; id?: unknown };\n if (o.kind === 'upsert') return isValidElement(o.element);\n if (o.kind === 'remove') return typeof o.id === 'string';\n return o.kind === 'clear';\n}\n\nfunction isPresenceOp(\n op: unknown,\n): op is { kind: 'presence'; data: unknown } | { kind: 'presence-leave' } {\n if (typeof op !== 'object' || op === null) return false;\n const k = (op as { kind?: unknown }).kind;\n return k === 'presence' || k === 'presence-leave';\n}\n\nexport class SyncHub {\n private readonly backend: HubBackend;\n private readonly conns = new Map<string, Connection>();\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\n private readonly presenceIds = new Map<string, string>(); // connId → last clientId that sent presence\n private readonly instanceId: string;\n private readonly fanout: HubFanout;\n private readonly fanoutUnsub: () => void;\n private readonly authorize?: Authorize;\n private readonly canRead?: CanRead;\n\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\n this.instanceId = options.instanceId ?? generateInstanceId();\n this.fanout = options.fanout ?? new InMemoryHubFanout();\n this.authorize = options.authorize;\n this.canRead = options.canRead;\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\n }\n\n addConnection(conn: Connection): void {\n this.conns.set(conn.id, conn);\n let set = this.rooms.get(conn.room);\n if (!set) {\n set = new Set();\n this.rooms.set(conn.room, set);\n }\n set.add(conn.id);\n }\n\n removeConnection(connId: string): void {\n const conn = this.conns.get(connId);\n if (!conn) return;\n this.conns.delete(connId);\n const room = conn.room;\n const clientId = this.presenceIds.get(connId);\n this.presenceIds.delete(connId);\n const members = this.rooms.get(room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(room);\n this.roomQueues.delete(room);\n }\n }\n if (clientId !== undefined) this.broadcastLeave(room, clientId);\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n\n handleMessage(connId: string, message: string): Promise<void> {\n const conn = this.conns.get(connId);\n if (!conn) return Promise.resolve();\n const env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.broadcastPresence(conn, env.from, env.op.data); // off-queue, synchronous\n return Promise.resolve();\n }\n const room = conn.room;\n // The per-room serial queue is the single total-order authority: ops apply in arrival order\n // (arrival-order LWW — no per-element seq; see D3 / TD-12). Different rooms run independently.\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\n const next = prev\n .then(() => this.process(conn, env))\n .catch(() => {\n // swallow so one failed message never wedges the room's serial queue\n });\n this.roomQueues.set(room, next);\n return next;\n }\n\n private async process(conn: Connection, env: SyncEnvelope): Promise<void> {\n const op = env.op;\n if (op.kind === 'request-snapshot') {\n const all = (await this.backend.snapshot(conn.room)) as OwnedElement[];\n const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;\n conn.send(\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\n );\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\n const needCurrent = (this.authorize || this.canRead) && id !== undefined;\n const current: OwnedElement | undefined = needCurrent\n ? await this.backend.get(conn.room, id)\n : undefined;\n\n let outboundOp: SyncOp = op;\n if (this.authorize) {\n const allowed = await this.authorize({\n userId: conn.userId,\n role: conn.role,\n room: conn.room,\n op,\n currentElement: current,\n });\n if (!allowed) {\n await this.sendCorrection(conn, env.from, op, current);\n return;\n }\n if (op.kind === 'upsert') {\n const ownerId = current?.ownerId ?? conn.userId;\n const stampedElement: OwnedElement = { ...op.element, ownerId };\n outboundOp = { kind: 'upsert', element: stampedElement };\n }\n }\n\n await this.backend.apply(conn.room, outboundOp);\n\n const prevExisted = current !== undefined;\n const prevAudience = current?.audience;\n\n this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: env.from,\n op: outboundOp,\n prev: prevAudience,\n existed: prevExisted,\n }),\n );\n }\n // 'snapshot' from a client → ignored\n }\n\n private mayRead(conn: Connection, audience: string | undefined): boolean {\n if (!this.canRead) return true;\n return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });\n }\n\n private safePublish(payload: string): void {\n try {\n this.fanout.publish(payload);\n } catch {\n /* a broken fanout publisher must not break the un-queued presence relay */\n }\n }\n\n private relayToRoom(room: string, excludeId: string | undefined, message: string): void {\n const members = this.rooms.get(room);\n if (!members) return;\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n try {\n conn.send(message);\n } catch {\n /* a throwing socket must not break the relay loop */\n }\n }\n }\n\n private broadcastPresence(conn: Connection, from: string, data: unknown): void {\n this.presenceIds.set(conn.id, from);\n const message = JSON.stringify({ from, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: 'presence', data } }),\n );\n }\n\n private broadcastLeave(room: string, from: string): void {\n const message = JSON.stringify({ from, op: { kind: 'presence-leave' } });\n this.relayToRoom(room, undefined, message);\n this.safePublish(\n JSON.stringify({ o: this.instanceId, room, from, op: { kind: 'presence-leave' } }),\n );\n }\n\n private deliverToRoom(\n room: string,\n excludeId: string | undefined,\n from: string,\n op: SyncOp,\n prevAudience: string | undefined,\n prevExisted: boolean,\n ): void {\n const members = this.rooms.get(room);\n if (!members) return;\n const send = (conn: Connection, msg: string): void => {\n try {\n conn.send(msg);\n } catch {\n /* a throwing socket must not break the delivery loop */\n }\n };\n if (op.kind === 'upsert') {\n const audience = (op.element as OwnedElement).audience;\n const upsertMsg = JSON.stringify({ from, op });\n const removeMsg = JSON.stringify({\n from: HUB_FROM,\n op: { kind: 'remove', id: op.element.id },\n });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n if (this.mayRead(conn, audience)) send(conn, upsertMsg);\n else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);\n }\n } else if (op.kind === 'remove') {\n const removeMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n // No read filter → forward to all (today's behavior; current/prevExisted aren't fetched without\n // a hook). With canRead, only recipients who could see the removed element get it.\n const wasVisible = !this.canRead || (prevExisted && this.mayRead(conn, prevAudience));\n if (wasVisible) send(conn, removeMsg);\n }\n } else if (op.kind === 'clear') {\n const clearMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (conn) send(conn, clearMsg);\n }\n }\n }\n\n private async sendCorrection(\n conn: Connection,\n from: string,\n op: SyncOp,\n current: OwnedElement | undefined,\n ): Promise<void> {\n let correction: SyncOp | undefined;\n if (op.kind === 'upsert') {\n correction = current\n ? { kind: 'upsert', element: current }\n : { kind: 'remove', id: op.element.id };\n } else if (op.kind === 'remove') {\n correction = current ? { kind: 'upsert', element: current } : undefined;\n } else if (op.kind === 'clear') {\n const elements = await this.backend.snapshot(conn.room);\n correction = { kind: 'snapshot', to: from, elements };\n }\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\n }\n\n private onFanout(payload: string): void {\n // Off the serial queue on purpose: forward-only (the origin already applied to the SHARED backend),\n // and delivery is already ordered. Re-filter per local member (canRead runs on EVERY instance).\n let env: {\n o?: unknown;\n room?: unknown;\n from?: unknown;\n op?: unknown;\n prev?: unknown;\n existed?: unknown;\n };\n try {\n env = JSON.parse(payload);\n } catch {\n return;\n }\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.from !== 'string')\n return;\n if (env.o === this.instanceId) return; // our own publish — already delivered locally\n const op = env.op;\n if (isPresenceOp(op)) {\n // presence/leave: raw forward to all local members (the sender lives on the origin instance),\n // no backend, no canRead filter.\n this.relayToRoom(env.room, undefined, JSON.stringify({ from: env.from, op }));\n return;\n }\n if (!isFanoutOp(op)) return;\n const prevAudience = typeof env.prev === 'string' ? env.prev : undefined;\n const prevExisted = env.existed === true;\n this.deliverToRoom(env.room, undefined, env.from, op, prevAudience, prevExisted);\n }\n\n close(): void {\n this.fanoutUnsub();\n }\n}\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize, CanRead } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n canRead?: CanRead;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n canRead: options.canRead,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,IAAAA,eAA8E;;;ACA9E,kBAA0C;AAInC,IAAM,mBAAN,MAA6C;AAAA,EAC1C,QAAQ,oBAAI,IAAwC;AAAA,EAEpD,KAAK,IAAwC;AACnD,QAAI,IAAI,KAAK,MAAM,IAAI,EAAE;AACzB,QAAI,CAAC,GAAG;AACN,UAAI,oBAAI,IAAI;AACZ,WAAK,MAAM,IAAI,IAAI,CAAC;AAAA,IACtB;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAS,MAAwC;AACrD,WAAO,CAAC,GAAG,KAAK,KAAK,IAAI,EAAE,OAAO,CAAC;AAAA,EACrC;AAAA,EAEA,MAAM,IAAI,MAAc,IAAgD;AACtE,WAAO,KAAK,KAAK,IAAI,EAAE,IAAI,EAAE;AAAA,EAC/B;AAAA,EAEA,MAAM,MAAM,MAAc,IAA2B;AACnD,QAAI,GAAG,SAAS,SAAS;AACvB,WAAK,MAAM,OAAO,IAAI;AACtB;AAAA,IACF;AACA,kCAAa,KAAK,KAAK,IAAI,GAAG,EAAE;AAAA,EAClC;AACF;;;ACzBO,IAAM,oBAAN,MAA6C;AAAA,EACjC,WAAW,oBAAI,IAA+B;AAAA,EAE/D,QAAQ,SAAuB;AAC7B,eAAW,KAAK,KAAK,UAAU;AAC7B,UAAI;AACF,UAAE,OAAO;AAAA,MACX,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;;;AFDA,IAAM,WAAW;AAEjB,SAAS,qBAA6B;AACpC,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe;AAChE,WAAO,OAAO,WAAW;AAC3B,SAAO,KAAK,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,CAAC;AACjD;AAEA,SAAS,WAAW,IAA6E;AAC/F,MAAI,OAAO,OAAO,YAAY,OAAO,KAAM,QAAO;AAClD,QAAM,IAAI;AACV,MAAI,EAAE,SAAS,SAAU,YAAO,6BAAe,EAAE,OAAO;AACxD,MAAI,EAAE,SAAS,SAAU,QAAO,OAAO,EAAE,OAAO;AAChD,SAAO,EAAE,SAAS;AACpB;AAEA,SAAS,aACP,IACwE;AACxE,MAAI,OAAO,OAAO,YAAY,OAAO,KAAM,QAAO;AAClD,QAAM,IAAK,GAA0B;AACrC,SAAO,MAAM,cAAc,MAAM;AACnC;AAEO,IAAM,UAAN,MAAc;AAAA,EACF;AAAA,EACA,QAAQ,oBAAI,IAAwB;AAAA,EACpC,QAAQ,oBAAI,IAAyB;AAAA;AAAA,EACrC,aAAa,oBAAI,IAA2B;AAAA;AAAA,EAC5C,cAAc,oBAAI,IAAoB;AAAA;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,UAA0B,CAAC,GAAG;AACxC,SAAK,UAAU,QAAQ,WAAW,IAAI,iBAAiB;AACvD,SAAK,aAAa,QAAQ,cAAc,mBAAmB;AAC3D,SAAK,SAAS,QAAQ,UAAU,IAAI,kBAAkB;AACtD,SAAK,YAAY,QAAQ;AACzB,SAAK,UAAU,QAAQ;AACvB,SAAK,cAAc,KAAK,OAAO,UAAU,CAAC,YAAY,KAAK,SAAS,OAAO,CAAC;AAAA,EAC9E;AAAA,EAEA,cAAc,MAAwB;AACpC,SAAK,MAAM,IAAI,KAAK,IAAI,IAAI;AAC5B,QAAI,MAAM,KAAK,MAAM,IAAI,KAAK,IAAI;AAClC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,MAAM,IAAI,KAAK,MAAM,GAAG;AAAA,IAC/B;AACA,QAAI,IAAI,KAAK,EAAE;AAAA,EACjB;AAAA,EAEA,iBAAiB,QAAsB;AACrC,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM;AAClC,QAAI,CAAC,KAAM;AACX,SAAK,MAAM,OAAO,MAAM;AACxB,UAAM,OAAO,KAAK;AAClB,UAAM,WAAW,KAAK,YAAY,IAAI,MAAM;AAC5C,SAAK,YAAY,OAAO,MAAM;AAC9B,UAAM,UAAU,KAAK,MAAM,IAAI,IAAI;AACnC,QAAI,SAAS;AACX,cAAQ,OAAO,MAAM;AACrB,UAAI,QAAQ,SAAS,GAAG;AACtB,aAAK,MAAM,OAAO,IAAI;AACtB,aAAK,WAAW,OAAO,IAAI;AAAA,MAC7B;AAAA,IACF;AACA,QAAI,aAAa,OAAW,MAAK,eAAe,MAAM,QAAQ;AAAA,EAChE;AAAA,EAEA,YAAoB;AAClB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA,EAEA,cAAc,QAAgB,SAAgC;AAC5D,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM;AAClC,QAAI,CAAC,KAAM,QAAO,QAAQ,QAAQ;AAClC,UAAM,UAAM,4BAAc,OAAO;AACjC,QAAI,CAAC,IAAK,QAAO,QAAQ,QAAQ;AACjC,QAAI,IAAI,GAAG,SAAS,YAAY;AAC9B,WAAK,kBAAkB,MAAM,IAAI,MAAM,IAAI,GAAG,IAAI;AAClD,aAAO,QAAQ,QAAQ;AAAA,IACzB;AACA,UAAM,OAAO,KAAK;AAGlB,UAAM,OAAO,KAAK,WAAW,IAAI,IAAI,KAAK,QAAQ,QAAQ;AAC1D,UAAM,OAAO,KACV,KAAK,MAAM,KAAK,QAAQ,MAAM,GAAG,CAAC,EAClC,MAAM,MAAM;AAAA,IAEb,CAAC;AACH,SAAK,WAAW,IAAI,MAAM,IAAI;AAC9B,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,QAAQ,MAAkB,KAAkC;AACxE,UAAM,KAAK,IAAI;AACf,QAAI,GAAG,SAAS,oBAAoB;AAClC,YAAM,MAAO,MAAM,KAAK,QAAQ,SAAS,KAAK,IAAI;AAClD,YAAM,WAAW,KAAK,UAAU,IAAI,OAAO,CAAC,OAAO,KAAK,QAAQ,MAAM,GAAG,QAAQ,CAAC,IAAI;AACtF,WAAK;AAAA,QACH,KAAK,UAAU,EAAE,MAAM,UAAU,IAAI,EAAE,MAAM,YAAY,IAAI,IAAI,MAAM,SAAS,EAAE,CAAC;AAAA,MACrF;AAAA,IACF,WAAW,GAAG,SAAS,YAAY,GAAG,SAAS,YAAY,GAAG,SAAS,SAAS;AAC9E,YAAM,KAAK,GAAG,SAAS,WAAW,GAAG,QAAQ,KAAK,GAAG,SAAS,WAAW,GAAG,KAAK;AACjF,YAAM,eAAe,KAAK,aAAa,KAAK,YAAY,OAAO;AAC/D,YAAM,UAAoC,cACtC,MAAM,KAAK,QAAQ,IAAI,KAAK,MAAM,EAAE,IACpC;AAEJ,UAAI,aAAqB;AACzB,UAAI,KAAK,WAAW;AAClB,cAAM,UAAU,MAAM,KAAK,UAAU;AAAA,UACnC,QAAQ,KAAK;AAAA,UACb,MAAM,KAAK;AAAA,UACX,MAAM,KAAK;AAAA,UACX;AAAA,UACA,gBAAgB;AAAA,QAClB,CAAC;AACD,YAAI,CAAC,SAAS;AACZ,gBAAM,KAAK,eAAe,MAAM,IAAI,MAAM,IAAI,OAAO;AACrD;AAAA,QACF;AACA,YAAI,GAAG,SAAS,UAAU;AACxB,gBAAM,UAAU,SAAS,WAAW,KAAK;AACzC,gBAAM,iBAA+B,EAAE,GAAG,GAAG,SAAS,QAAQ;AAC9D,uBAAa,EAAE,MAAM,UAAU,SAAS,eAAe;AAAA,QACzD;AAAA,MACF;AAEA,YAAM,KAAK,QAAQ,MAAM,KAAK,MAAM,UAAU;AAE9C,YAAM,cAAc,YAAY;AAChC,YAAM,eAAe,SAAS;AAE9B,WAAK,cAAc,KAAK,MAAM,KAAK,IAAI,IAAI,MAAM,YAAY,cAAc,WAAW;AAEtF,WAAK,OAAO;AAAA,QACV,KAAK,UAAU;AAAA,UACb,GAAG,KAAK;AAAA,UACR,MAAM,KAAK;AAAA,UACX,MAAM,IAAI;AAAA,UACV,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,SAAS;AAAA,QACX,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EAEF;AAAA,EAEQ,QAAQ,MAAkB,UAAuC;AACvE,QAAI,CAAC,KAAK,QAAS,QAAO;AAC1B,WAAO,KAAK,QAAQ,EAAE,QAAQ,KAAK,QAAQ,MAAM,KAAK,MAAM,MAAM,KAAK,MAAM,SAAS,CAAC;AAAA,EACzF;AAAA,EAEQ,YAAY,SAAuB;AACzC,QAAI;AACF,WAAK,OAAO,QAAQ,OAAO;AAAA,IAC7B,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEQ,YAAY,MAAc,WAA+B,SAAuB;AACtF,UAAM,UAAU,KAAK,MAAM,IAAI,IAAI;AACnC,QAAI,CAAC,QAAS;AACd,eAAW,OAAO,SAAS;AACzB,UAAI,QAAQ,UAAW;AACvB,YAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,UAAI,CAAC,KAAM;AACX,UAAI;AACF,aAAK,KAAK,OAAO;AAAA,MACnB,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,kBAAkB,MAAkB,MAAc,MAAqB;AAC7E,SAAK,YAAY,IAAI,KAAK,IAAI,IAAI;AAClC,UAAM,UAAU,KAAK,UAAU,EAAE,MAAM,IAAI,EAAE,MAAM,YAAY,KAAK,EAAE,CAAC;AACvE,SAAK,YAAY,KAAK,MAAM,KAAK,IAAI,OAAO;AAC5C,SAAK;AAAA,MACH,KAAK,UAAU,EAAE,GAAG,KAAK,YAAY,MAAM,KAAK,MAAM,MAAM,IAAI,EAAE,MAAM,YAAY,KAAK,EAAE,CAAC;AAAA,IAC9F;AAAA,EACF;AAAA,EAEQ,eAAe,MAAc,MAAoB;AACvD,UAAM,UAAU,KAAK,UAAU,EAAE,MAAM,IAAI,EAAE,MAAM,iBAAiB,EAAE,CAAC;AACvE,SAAK,YAAY,MAAM,QAAW,OAAO;AACzC,SAAK;AAAA,MACH,KAAK,UAAU,EAAE,GAAG,KAAK,YAAY,MAAM,MAAM,IAAI,EAAE,MAAM,iBAAiB,EAAE,CAAC;AAAA,IACnF;AAAA,EACF;AAAA,EAEQ,cACN,MACA,WACA,MACA,IACA,cACA,aACM;AACN,UAAM,UAAU,KAAK,MAAM,IAAI,IAAI;AACnC,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,CAAC,MAAkB,QAAsB;AACpD,UAAI;AACF,aAAK,KAAK,GAAG;AAAA,MACf,QAAQ;AAAA,MAER;AAAA,IACF;AACA,QAAI,GAAG,SAAS,UAAU;AACxB,YAAM,WAAY,GAAG,QAAyB;AAC9C,YAAM,YAAY,KAAK,UAAU,EAAE,MAAM,GAAG,CAAC;AAC7C,YAAM,YAAY,KAAK,UAAU;AAAA,QAC/B,MAAM;AAAA,QACN,IAAI,EAAE,MAAM,UAAU,IAAI,GAAG,QAAQ,GAAG;AAAA,MAC1C,CAAC;AACD,iBAAW,OAAO,SAAS;AACzB,YAAI,QAAQ,UAAW;AACvB,cAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,YAAI,CAAC,KAAM;AACX,YAAI,KAAK,QAAQ,MAAM,QAAQ,EAAG,MAAK,MAAM,SAAS;AAAA,iBAC7C,eAAe,KAAK,QAAQ,MAAM,YAAY,EAAG,MAAK,MAAM,SAAS;AAAA,MAChF;AAAA,IACF,WAAW,GAAG,SAAS,UAAU;AAC/B,YAAM,YAAY,KAAK,UAAU,EAAE,MAAM,GAAG,CAAC;AAC7C,iBAAW,OAAO,SAAS;AACzB,YAAI,QAAQ,UAAW;AACvB,cAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,YAAI,CAAC,KAAM;AAGX,cAAM,aAAa,CAAC,KAAK,WAAY,eAAe,KAAK,QAAQ,MAAM,YAAY;AACnF,YAAI,WAAY,MAAK,MAAM,SAAS;AAAA,MACtC;AAAA,IACF,WAAW,GAAG,SAAS,SAAS;AAC9B,YAAM,WAAW,KAAK,UAAU,EAAE,MAAM,GAAG,CAAC;AAC5C,iBAAW,OAAO,SAAS;AACzB,YAAI,QAAQ,UAAW;AACvB,cAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,YAAI,KAAM,MAAK,MAAM,QAAQ;AAAA,MAC/B;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,eACZ,MACA,MACA,IACA,SACe;AACf,QAAI;AACJ,QAAI,GAAG,SAAS,UAAU;AACxB,mBAAa,UACT,EAAE,MAAM,UAAU,SAAS,QAAQ,IACnC,EAAE,MAAM,UAAU,IAAI,GAAG,QAAQ,GAAG;AAAA,IAC1C,WAAW,GAAG,SAAS,UAAU;AAC/B,mBAAa,UAAU,EAAE,MAAM,UAAU,SAAS,QAAQ,IAAI;AAAA,IAChE,WAAW,GAAG,SAAS,SAAS;AAC9B,YAAM,WAAW,MAAM,KAAK,QAAQ,SAAS,KAAK,IAAI;AACtD,mBAAa,EAAE,MAAM,YAAY,IAAI,MAAM,SAAS;AAAA,IACtD;AACA,QAAI,WAAY,MAAK,KAAK,KAAK,UAAU,EAAE,MAAM,UAAU,IAAI,WAAW,CAAC,CAAC;AAAA,EAC9E;AAAA,EAEQ,SAAS,SAAuB;AAGtC,QAAI;AAQJ,QAAI;AACF,YAAM,KAAK,MAAM,OAAO;AAAA,IAC1B,QAAQ;AACN;AAAA,IACF;AACA,QAAI,OAAO,IAAI,MAAM,YAAY,OAAO,IAAI,SAAS,YAAY,OAAO,IAAI,SAAS;AACnF;AACF,QAAI,IAAI,MAAM,KAAK,WAAY;AAC/B,UAAM,KAAK,IAAI;AACf,QAAI,aAAa,EAAE,GAAG;AAGpB,WAAK,YAAY,IAAI,MAAM,QAAW,KAAK,UAAU,EAAE,MAAM,IAAI,MAAM,GAAG,CAAC,CAAC;AAC5E;AAAA,IACF;AACA,QAAI,CAAC,WAAW,EAAE,EAAG;AACrB,UAAM,eAAe,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO;AAC/D,UAAM,cAAc,IAAI,YAAY;AACpC,SAAK,cAAc,IAAI,MAAM,QAAW,IAAI,MAAM,IAAI,cAAc,WAAW;AAAA,EACjF;AAAA,EAEA,QAAc;AACZ,SAAK,YAAY;AAAA,EACnB;AACF;;;AGzUA,gBAAgC;;;ACazB,SAAS,eAAe,KAAsB,YAA+B;AAClF,MAAI,cAAc,GAAG;AACnB,WAAO;AAAA,MACL,OAAO,MAAM;AAAA,MAEb;AAAA,MACA,MAAM,MAAM;AAAA,MAEZ;AAAA,IACF;AAAA,EACF;AACA,QAAM,QAAQ,oBAAI,QAAkC;AACpD,QAAM,QAAQ,CAAC,OAA8B;AAC3C,UAAM,IAAI,IAAI,IAAI;AAClB,OAAG,GAAG,QAAQ,MAAM,MAAM,IAAI,IAAI,IAAI,CAAC;AAAA,EACzC;AACA,QAAM,WAAW,YAAY,MAAM;AACjC,eAAW,MAAM,IAAI,SAAS;AAC5B,UAAI;AACF,YAAI,MAAM,IAAI,EAAE,MAAM,OAAO;AAC3B,aAAG,UAAU;AACb;AAAA,QACF;AACA,cAAM,IAAI,IAAI,KAAK;AACnB,WAAG,KAAK;AAAA,MACV,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF,GAAG,UAAU;AACb,SAAO,EAAE,OAAO,MAAM,MAAM,cAAc,QAAQ,EAAE;AACtD;;;ADvBO,SAAS,iBAAiB,UAAmC,CAAC,GAInE;AACA,QAAM,MAAM,IAAI,QAAQ;AAAA,IACtB,SAAS,QAAQ;AAAA,IACjB,QAAQ,QAAQ;AAAA,IAChB,YAAY,QAAQ;AAAA,IACpB,WAAW,QAAQ;AAAA,IACnB,SAAS,QAAQ;AAAA,EACnB,CAAC;AACD,QAAM,MAAM,QAAQ,SAChB,IAAI,0BAAgB,EAAE,QAAQ,QAAQ,OAAO,CAAC,IAC9C,IAAI,0BAAgB,EAAE,MAAM,QAAQ,QAAQ,EAAE,CAAC;AACnD,QAAM,YAAY,eAAe,KAAK,QAAQ,uBAAuB,GAAK;AAC1E,MAAI,UAAU;AACd,MAAI,GAAG,cAAc,CAAC,IAAI,QAAQ;AAChC,cAAU,MAAM,EAAE;AAClB,UAAM,MAAM,IAAI,IAAI,IAAI,OAAO,IAAI,kBAAkB;AACrD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,QAAI,CAAC,MAAM;AACT,SAAG,MAAM,MAAM,eAAe;AAC9B;AAAA,IACF;AACA,UAAM,SAAS,IAAI,EAAE,OAAO,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AAEtE,QAAI,QAA0C;AAC9C,QAAI,SAAS;AACb,UAAM,QAAkB,CAAC;AACzB,UAAM,YAAY;AAElB,UAAM,OAAO,CAAC,MAAc;AAC1B,UAAI;AACF,WAAG,KAAK,CAAC;AAAA,MACX,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,OAAG,GAAG,WAAW,CAAC,SAAS;AACzB,YAAM,MAAM,OAAO,IAAI;AACvB,UAAI,UAAU,WAAY;AAC1B,UAAI,UAAU,WAAW;AACvB,YAAI,MAAM,SAAS,UAAW,OAAM,KAAK,GAAG;AAC5C;AAAA,MACF;AACA,WAAK,IAAI,cAAc,QAAQ,GAAG,EAAE,MAAM,CAAC,QAAQ,QAAQ,MAAM,iBAAiB,GAAG,CAAC;AAAA,IACxF,CAAC;AACD,OAAG,GAAG,SAAS,MAAM;AACnB,eAAS;AACT,UAAI,UAAU,QAAS,KAAI,iBAAiB,MAAM;AAAA,IACpD,CAAC;AAED,YAAQ,QAAQ,QAAQ,eAAe,QAAQ,aAAa,EAAE,KAAK,KAAK,CAAC,IAAI,EAAE,QAAQ,OAAO,CAAC,EAC5F,KAAK,CAAC,WAAW;AAChB,UAAI,OAAQ;AACZ,UAAI,CAAC,QAAQ;AACX,gBAAQ;AACR,WAAG,MAAM,MAAM,cAAc;AAC7B;AAAA,MACF;AACA,cAAQ;AACR,UAAI,cAAc,EAAE,IAAI,QAAQ,MAAM,QAAQ,OAAO,QAAQ,MAAM,OAAO,MAAM,KAAK,CAAC;AACtF,iBAAW,KAAK,OAAO;AACrB,aAAK,IAAI,cAAc,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,QAAQ,MAAM,iBAAiB,GAAG,CAAC;AAAA,MACtF;AACA,YAAM,SAAS;AAAA,IACjB,CAAC,EACA,MAAM,MAAM;AACX,UAAI,CAAC,QAAQ;AACX,gBAAQ;AACR,WAAG,MAAM,MAAM,cAAc;AAAA,MAC/B;AAAA,IACF,CAAC;AAAA,EACL,CAAC;AACD,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,OAAO,MACL,IAAI,QAAc,CAAC,YAAY;AAC7B,gBAAU,KAAK;AACf,UAAI,MAAM;AACV,UAAI,MAAM,MAAM,QAAQ,CAAC;AAAA,IAC3B,CAAC;AAAA,EACL;AACF;","names":["import_sync"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -22,6 +22,7 @@ interface HubBackend {
|
|
|
22
22
|
|
|
23
23
|
type OwnedElement = CanvasElement & {
|
|
24
24
|
ownerId?: string;
|
|
25
|
+
audience?: string;
|
|
25
26
|
};
|
|
26
27
|
interface AuthorizeContext {
|
|
27
28
|
userId?: string;
|
|
@@ -31,6 +32,13 @@ interface AuthorizeContext {
|
|
|
31
32
|
currentElement?: OwnedElement;
|
|
32
33
|
}
|
|
33
34
|
type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
|
|
35
|
+
interface ReadContext {
|
|
36
|
+
userId?: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
room: string;
|
|
39
|
+
audience: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
type CanRead = (ctx: ReadContext) => boolean;
|
|
34
42
|
|
|
35
43
|
interface Connection {
|
|
36
44
|
id: string;
|
|
@@ -44,22 +52,31 @@ interface SyncHubOptions {
|
|
|
44
52
|
fanout?: HubFanout;
|
|
45
53
|
instanceId?: string;
|
|
46
54
|
authorize?: Authorize;
|
|
55
|
+
canRead?: CanRead;
|
|
47
56
|
}
|
|
48
57
|
declare class SyncHub {
|
|
49
58
|
private readonly backend;
|
|
50
59
|
private readonly conns;
|
|
51
60
|
private readonly rooms;
|
|
52
61
|
private readonly roomQueues;
|
|
62
|
+
private readonly presenceIds;
|
|
53
63
|
private readonly instanceId;
|
|
54
64
|
private readonly fanout;
|
|
55
65
|
private readonly fanoutUnsub;
|
|
56
66
|
private readonly authorize?;
|
|
67
|
+
private readonly canRead?;
|
|
57
68
|
constructor(options?: SyncHubOptions);
|
|
58
69
|
addConnection(conn: Connection): void;
|
|
59
70
|
removeConnection(connId: string): void;
|
|
60
71
|
roomCount(): number;
|
|
61
72
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
62
73
|
private process;
|
|
74
|
+
private mayRead;
|
|
75
|
+
private safePublish;
|
|
76
|
+
private relayToRoom;
|
|
77
|
+
private broadcastPresence;
|
|
78
|
+
private broadcastLeave;
|
|
79
|
+
private deliverToRoom;
|
|
63
80
|
private sendCorrection;
|
|
64
81
|
private onFanout;
|
|
65
82
|
close(): void;
|
|
@@ -91,6 +108,7 @@ interface CreateSyncServerOptions {
|
|
|
91
108
|
instanceId?: string;
|
|
92
109
|
authenticate?: Authenticate;
|
|
93
110
|
authorize?: Authorize;
|
|
111
|
+
canRead?: CanRead;
|
|
94
112
|
heartbeatIntervalMs?: number;
|
|
95
113
|
}
|
|
96
114
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
@@ -113,4 +131,4 @@ interface Heartbeat {
|
|
|
113
131
|
}
|
|
114
132
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
115
133
|
|
|
116
|
-
export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
|
134
|
+
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ interface HubBackend {
|
|
|
22
22
|
|
|
23
23
|
type OwnedElement = CanvasElement & {
|
|
24
24
|
ownerId?: string;
|
|
25
|
+
audience?: string;
|
|
25
26
|
};
|
|
26
27
|
interface AuthorizeContext {
|
|
27
28
|
userId?: string;
|
|
@@ -31,6 +32,13 @@ interface AuthorizeContext {
|
|
|
31
32
|
currentElement?: OwnedElement;
|
|
32
33
|
}
|
|
33
34
|
type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
|
|
35
|
+
interface ReadContext {
|
|
36
|
+
userId?: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
room: string;
|
|
39
|
+
audience: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
type CanRead = (ctx: ReadContext) => boolean;
|
|
34
42
|
|
|
35
43
|
interface Connection {
|
|
36
44
|
id: string;
|
|
@@ -44,22 +52,31 @@ interface SyncHubOptions {
|
|
|
44
52
|
fanout?: HubFanout;
|
|
45
53
|
instanceId?: string;
|
|
46
54
|
authorize?: Authorize;
|
|
55
|
+
canRead?: CanRead;
|
|
47
56
|
}
|
|
48
57
|
declare class SyncHub {
|
|
49
58
|
private readonly backend;
|
|
50
59
|
private readonly conns;
|
|
51
60
|
private readonly rooms;
|
|
52
61
|
private readonly roomQueues;
|
|
62
|
+
private readonly presenceIds;
|
|
53
63
|
private readonly instanceId;
|
|
54
64
|
private readonly fanout;
|
|
55
65
|
private readonly fanoutUnsub;
|
|
56
66
|
private readonly authorize?;
|
|
67
|
+
private readonly canRead?;
|
|
57
68
|
constructor(options?: SyncHubOptions);
|
|
58
69
|
addConnection(conn: Connection): void;
|
|
59
70
|
removeConnection(connId: string): void;
|
|
60
71
|
roomCount(): number;
|
|
61
72
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
62
73
|
private process;
|
|
74
|
+
private mayRead;
|
|
75
|
+
private safePublish;
|
|
76
|
+
private relayToRoom;
|
|
77
|
+
private broadcastPresence;
|
|
78
|
+
private broadcastLeave;
|
|
79
|
+
private deliverToRoom;
|
|
63
80
|
private sendCorrection;
|
|
64
81
|
private onFanout;
|
|
65
82
|
close(): void;
|
|
@@ -91,6 +108,7 @@ interface CreateSyncServerOptions {
|
|
|
91
108
|
instanceId?: string;
|
|
92
109
|
authenticate?: Authenticate;
|
|
93
110
|
authorize?: Authorize;
|
|
111
|
+
canRead?: CanRead;
|
|
94
112
|
heartbeatIntervalMs?: number;
|
|
95
113
|
}
|
|
96
114
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
@@ -113,4 +131,4 @@ interface Heartbeat {
|
|
|
113
131
|
}
|
|
114
132
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
115
133
|
|
|
116
|
-
export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
|
134
|
+
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 };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/sync-hub.ts
|
|
2
|
-
import { parseEnvelope } from "@fieldnotes/sync";
|
|
2
|
+
import { parseEnvelope, isValidElement } from "@fieldnotes/sync";
|
|
3
3
|
|
|
4
4
|
// src/memory-hub-backend.ts
|
|
5
5
|
import { applyOpToMap } from "@fieldnotes/sync";
|
|
@@ -52,6 +52,18 @@ function generateInstanceId() {
|
|
|
52
52
|
return crypto.randomUUID();
|
|
53
53
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
54
54
|
}
|
|
55
|
+
function isFanoutOp(op) {
|
|
56
|
+
if (typeof op !== "object" || op === null) return false;
|
|
57
|
+
const o = op;
|
|
58
|
+
if (o.kind === "upsert") return isValidElement(o.element);
|
|
59
|
+
if (o.kind === "remove") return typeof o.id === "string";
|
|
60
|
+
return o.kind === "clear";
|
|
61
|
+
}
|
|
62
|
+
function isPresenceOp(op) {
|
|
63
|
+
if (typeof op !== "object" || op === null) return false;
|
|
64
|
+
const k = op.kind;
|
|
65
|
+
return k === "presence" || k === "presence-leave";
|
|
66
|
+
}
|
|
55
67
|
var SyncHub = class {
|
|
56
68
|
backend;
|
|
57
69
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -59,15 +71,19 @@ var SyncHub = class {
|
|
|
59
71
|
// room → connIds
|
|
60
72
|
roomQueues = /* @__PURE__ */ new Map();
|
|
61
73
|
// room → serial tail
|
|
74
|
+
presenceIds = /* @__PURE__ */ new Map();
|
|
75
|
+
// connId → last clientId that sent presence
|
|
62
76
|
instanceId;
|
|
63
77
|
fanout;
|
|
64
78
|
fanoutUnsub;
|
|
65
79
|
authorize;
|
|
80
|
+
canRead;
|
|
66
81
|
constructor(options = {}) {
|
|
67
82
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
68
83
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
69
84
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
70
85
|
this.authorize = options.authorize;
|
|
86
|
+
this.canRead = options.canRead;
|
|
71
87
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
72
88
|
}
|
|
73
89
|
addConnection(conn) {
|
|
@@ -83,14 +99,18 @@ var SyncHub = class {
|
|
|
83
99
|
const conn = this.conns.get(connId);
|
|
84
100
|
if (!conn) return;
|
|
85
101
|
this.conns.delete(connId);
|
|
86
|
-
const
|
|
102
|
+
const room = conn.room;
|
|
103
|
+
const clientId = this.presenceIds.get(connId);
|
|
104
|
+
this.presenceIds.delete(connId);
|
|
105
|
+
const members = this.rooms.get(room);
|
|
87
106
|
if (members) {
|
|
88
107
|
members.delete(connId);
|
|
89
108
|
if (members.size === 0) {
|
|
90
|
-
this.rooms.delete(
|
|
91
|
-
this.roomQueues.delete(
|
|
109
|
+
this.rooms.delete(room);
|
|
110
|
+
this.roomQueues.delete(room);
|
|
92
111
|
}
|
|
93
112
|
}
|
|
113
|
+
if (clientId !== void 0) this.broadcastLeave(room, clientId);
|
|
94
114
|
}
|
|
95
115
|
roomCount() {
|
|
96
116
|
return this.rooms.size;
|
|
@@ -98,28 +118,33 @@ var SyncHub = class {
|
|
|
98
118
|
handleMessage(connId, message) {
|
|
99
119
|
const conn = this.conns.get(connId);
|
|
100
120
|
if (!conn) return Promise.resolve();
|
|
121
|
+
const env = parseEnvelope(message);
|
|
122
|
+
if (!env) return Promise.resolve();
|
|
123
|
+
if (env.op.kind === "presence") {
|
|
124
|
+
this.broadcastPresence(conn, env.from, env.op.data);
|
|
125
|
+
return Promise.resolve();
|
|
126
|
+
}
|
|
101
127
|
const room = conn.room;
|
|
102
128
|
const prev = this.roomQueues.get(room) ?? Promise.resolve();
|
|
103
|
-
const next = prev.then(() => this.process(conn,
|
|
129
|
+
const next = prev.then(() => this.process(conn, env)).catch(() => {
|
|
104
130
|
});
|
|
105
131
|
this.roomQueues.set(room, next);
|
|
106
132
|
return next;
|
|
107
133
|
}
|
|
108
|
-
async process(conn,
|
|
109
|
-
const env = parseEnvelope(message);
|
|
110
|
-
if (!env) return;
|
|
134
|
+
async process(conn, env) {
|
|
111
135
|
const op = env.op;
|
|
112
136
|
if (op.kind === "request-snapshot") {
|
|
113
|
-
const
|
|
137
|
+
const all = await this.backend.snapshot(conn.room);
|
|
138
|
+
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
114
139
|
conn.send(
|
|
115
140
|
JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
|
|
116
141
|
);
|
|
117
142
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
143
|
+
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
144
|
+
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
145
|
+
const current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
118
146
|
let outboundOp = op;
|
|
119
|
-
let outboundMessage = message;
|
|
120
147
|
if (this.authorize) {
|
|
121
|
-
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
122
|
-
const current = id !== void 0 ? await this.backend.get(conn.room, id) : void 0;
|
|
123
148
|
const allowed = await this.authorize({
|
|
124
149
|
userId: conn.userId,
|
|
125
150
|
role: conn.role,
|
|
@@ -135,22 +160,103 @@ var SyncHub = class {
|
|
|
135
160
|
const ownerId = current?.ownerId ?? conn.userId;
|
|
136
161
|
const stampedElement = { ...op.element, ownerId };
|
|
137
162
|
outboundOp = { kind: "upsert", element: stampedElement };
|
|
138
|
-
outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });
|
|
139
163
|
}
|
|
140
164
|
}
|
|
141
165
|
await this.backend.apply(conn.room, outboundOp);
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (id === conn.id) continue;
|
|
146
|
-
this.conns.get(id)?.send(outboundMessage);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
166
|
+
const prevExisted = current !== void 0;
|
|
167
|
+
const prevAudience = current?.audience;
|
|
168
|
+
this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);
|
|
149
169
|
this.fanout.publish(
|
|
150
|
-
JSON.stringify({
|
|
170
|
+
JSON.stringify({
|
|
171
|
+
o: this.instanceId,
|
|
172
|
+
room: conn.room,
|
|
173
|
+
from: env.from,
|
|
174
|
+
op: outboundOp,
|
|
175
|
+
prev: prevAudience,
|
|
176
|
+
existed: prevExisted
|
|
177
|
+
})
|
|
151
178
|
);
|
|
152
179
|
}
|
|
153
180
|
}
|
|
181
|
+
mayRead(conn, audience) {
|
|
182
|
+
if (!this.canRead) return true;
|
|
183
|
+
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
184
|
+
}
|
|
185
|
+
safePublish(payload) {
|
|
186
|
+
try {
|
|
187
|
+
this.fanout.publish(payload);
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
relayToRoom(room, excludeId, message) {
|
|
192
|
+
const members = this.rooms.get(room);
|
|
193
|
+
if (!members) return;
|
|
194
|
+
for (const cid of members) {
|
|
195
|
+
if (cid === excludeId) continue;
|
|
196
|
+
const conn = this.conns.get(cid);
|
|
197
|
+
if (!conn) continue;
|
|
198
|
+
try {
|
|
199
|
+
conn.send(message);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
broadcastPresence(conn, from, data) {
|
|
205
|
+
this.presenceIds.set(conn.id, from);
|
|
206
|
+
const message = JSON.stringify({ from, op: { kind: "presence", data } });
|
|
207
|
+
this.relayToRoom(conn.room, conn.id, message);
|
|
208
|
+
this.safePublish(
|
|
209
|
+
JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: "presence", data } })
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
broadcastLeave(room, from) {
|
|
213
|
+
const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
|
|
214
|
+
this.relayToRoom(room, void 0, message);
|
|
215
|
+
this.safePublish(
|
|
216
|
+
JSON.stringify({ o: this.instanceId, room, from, op: { kind: "presence-leave" } })
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
|
|
220
|
+
const members = this.rooms.get(room);
|
|
221
|
+
if (!members) return;
|
|
222
|
+
const send = (conn, msg) => {
|
|
223
|
+
try {
|
|
224
|
+
conn.send(msg);
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
if (op.kind === "upsert") {
|
|
229
|
+
const audience = op.element.audience;
|
|
230
|
+
const upsertMsg = JSON.stringify({ from, op });
|
|
231
|
+
const removeMsg = JSON.stringify({
|
|
232
|
+
from: HUB_FROM,
|
|
233
|
+
op: { kind: "remove", id: op.element.id }
|
|
234
|
+
});
|
|
235
|
+
for (const cid of members) {
|
|
236
|
+
if (cid === excludeId) continue;
|
|
237
|
+
const conn = this.conns.get(cid);
|
|
238
|
+
if (!conn) continue;
|
|
239
|
+
if (this.mayRead(conn, audience)) send(conn, upsertMsg);
|
|
240
|
+
else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
|
|
241
|
+
}
|
|
242
|
+
} else if (op.kind === "remove") {
|
|
243
|
+
const removeMsg = JSON.stringify({ from, op });
|
|
244
|
+
for (const cid of members) {
|
|
245
|
+
if (cid === excludeId) continue;
|
|
246
|
+
const conn = this.conns.get(cid);
|
|
247
|
+
if (!conn) continue;
|
|
248
|
+
const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
|
|
249
|
+
if (wasVisible) send(conn, removeMsg);
|
|
250
|
+
}
|
|
251
|
+
} else if (op.kind === "clear") {
|
|
252
|
+
const clearMsg = JSON.stringify({ from, op });
|
|
253
|
+
for (const cid of members) {
|
|
254
|
+
if (cid === excludeId) continue;
|
|
255
|
+
const conn = this.conns.get(cid);
|
|
256
|
+
if (conn) send(conn, clearMsg);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
154
260
|
async sendCorrection(conn, from, op, current) {
|
|
155
261
|
let correction;
|
|
156
262
|
if (op.kind === "upsert") {
|
|
@@ -170,18 +276,18 @@ var SyncHub = class {
|
|
|
170
276
|
} catch {
|
|
171
277
|
return;
|
|
172
278
|
}
|
|
173
|
-
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.
|
|
279
|
+
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
174
280
|
return;
|
|
175
281
|
if (env.o === this.instanceId) return;
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
this.conns.get(id)?.send(m);
|
|
182
|
-
} catch {
|
|
183
|
-
}
|
|
282
|
+
const op = env.op;
|
|
283
|
+
if (isPresenceOp(op)) {
|
|
284
|
+
this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
|
|
285
|
+
return;
|
|
184
286
|
}
|
|
287
|
+
if (!isFanoutOp(op)) return;
|
|
288
|
+
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
289
|
+
const prevExisted = env.existed === true;
|
|
290
|
+
this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
|
|
185
291
|
}
|
|
186
292
|
close() {
|
|
187
293
|
this.fanoutUnsub();
|
|
@@ -228,7 +334,8 @@ function createSyncServer(options = {}) {
|
|
|
228
334
|
backend: options.backend,
|
|
229
335
|
fanout: options.fanout,
|
|
230
336
|
instanceId: options.instanceId,
|
|
231
|
-
authorize: options.authorize
|
|
337
|
+
authorize: options.authorize,
|
|
338
|
+
canRead: options.canRead
|
|
232
339
|
});
|
|
233
340
|
const wss = options.server ? new WebSocketServer({ server: options.server }) : new WebSocketServer({ port: options.port ?? 0 });
|
|
234
341
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["import { parseEnvelope, type SyncOp } from '@fieldnotes/sync';\r\nimport { MemoryHubBackend } from './memory-hub-backend';\r\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { Authorize, OwnedElement } from './authorize';\r\n\r\nexport interface Connection {\r\n id: string;\r\n room: string;\r\n userId?: string;\r\n role?: string;\r\n send(message: string): void;\r\n}\r\n\r\nexport interface SyncHubOptions {\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authorize?: Authorize;\r\n}\r\n\r\nconst HUB_FROM = 'hub';\r\n\r\nfunction generateInstanceId(): string {\r\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\r\n return crypto.randomUUID();\r\n return `i-${Math.random().toString(36).slice(2)}`;\r\n}\r\n\r\nexport class SyncHub {\r\n private readonly backend: HubBackend;\r\n private readonly conns = new Map<string, Connection>();\r\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\r\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\r\n private readonly instanceId: string;\r\n private readonly fanout: HubFanout;\r\n private readonly fanoutUnsub: () => void;\r\n private readonly authorize?: Authorize;\r\n\r\n constructor(options: SyncHubOptions = {}) {\r\n this.backend = options.backend ?? new MemoryHubBackend();\r\n this.instanceId = options.instanceId ?? generateInstanceId();\r\n this.fanout = options.fanout ?? new InMemoryHubFanout();\r\n this.authorize = options.authorize;\r\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\r\n }\r\n\r\n addConnection(conn: Connection): void {\r\n this.conns.set(conn.id, conn);\r\n let set = this.rooms.get(conn.room);\r\n if (!set) {\r\n set = new Set();\r\n this.rooms.set(conn.room, set);\r\n }\r\n set.add(conn.id);\r\n }\r\n\r\n removeConnection(connId: string): void {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return;\r\n this.conns.delete(connId);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n members.delete(connId);\r\n if (members.size === 0) {\r\n this.rooms.delete(conn.room);\r\n this.roomQueues.delete(conn.room);\r\n }\r\n }\r\n }\r\n\r\n roomCount(): number {\r\n return this.rooms.size;\r\n }\r\n\r\n handleMessage(connId: string, message: string): Promise<void> {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return Promise.resolve();\r\n const room = conn.room;\r\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\r\n const next = prev\r\n .then(() => this.process(conn, message))\r\n .catch(() => {\r\n // swallow so one failed message never wedges the room's serial queue\r\n });\r\n this.roomQueues.set(room, next);\r\n return next;\r\n }\r\n\r\n private async process(conn: Connection, message: string): Promise<void> {\r\n const env = parseEnvelope(message);\r\n if (!env) return;\r\n const op = env.op;\r\n if (op.kind === 'request-snapshot') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n conn.send(\r\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\r\n );\r\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\r\n let outboundOp: SyncOp = op;\r\n let outboundMessage = message;\r\n if (this.authorize) {\r\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\r\n const current: OwnedElement | undefined =\r\n id !== undefined ? await this.backend.get(conn.room, id) : undefined;\r\n const allowed = await this.authorize({\r\n userId: conn.userId,\r\n role: conn.role,\r\n room: conn.room,\r\n op,\r\n currentElement: current,\r\n });\r\n if (!allowed) {\r\n await this.sendCorrection(conn, env.from, op, current);\r\n return;\r\n }\r\n if (op.kind === 'upsert') {\r\n const ownerId = current?.ownerId ?? conn.userId;\r\n const stampedElement: OwnedElement = { ...op.element, ownerId };\r\n outboundOp = { kind: 'upsert', element: stampedElement };\r\n outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });\r\n }\r\n }\r\n await this.backend.apply(conn.room, outboundOp);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n for (const id of members) {\r\n if (id === conn.id) continue;\r\n this.conns.get(id)?.send(outboundMessage);\r\n }\r\n }\r\n this.fanout.publish(\r\n JSON.stringify({ o: this.instanceId, room: conn.room, m: outboundMessage }),\r\n );\r\n }\r\n // 'snapshot' from a client → ignored\r\n }\r\n\r\n private async sendCorrection(\r\n conn: Connection,\r\n from: string,\r\n op: SyncOp,\r\n current: OwnedElement | undefined,\r\n ): Promise<void> {\r\n let correction: SyncOp | undefined;\r\n if (op.kind === 'upsert') {\r\n correction = current\r\n ? { kind: 'upsert', element: current }\r\n : { kind: 'remove', id: op.element.id };\r\n } else if (op.kind === 'remove') {\r\n correction = current ? { kind: 'upsert', element: current } : undefined;\r\n } else if (op.kind === 'clear') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n correction = { kind: 'snapshot', to: from, elements };\r\n }\r\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\r\n }\r\n\r\n private onFanout(payload: string): void {\r\n // Off the serial queue on purpose: forward-only (no backend re-apply — the origin already applied to the\r\n // SHARED backend), and delivery is already ordered. Do not wrap this in roomQueues.\r\n let env: { o?: unknown; room?: unknown; m?: unknown };\r\n try {\r\n env = JSON.parse(payload);\r\n } catch {\r\n return;\r\n }\r\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.m !== 'string')\r\n return;\r\n if (env.o === this.instanceId) return; // our own publish — already forwarded locally\r\n const members = this.rooms.get(env.room);\r\n if (!members) return;\r\n const m = env.m;\r\n for (const id of members) {\r\n try {\r\n this.conns.get(id)?.send(m);\r\n } catch {\r\n /* a throwing socket must not break the fanout loop */\r\n }\r\n }\r\n }\r\n\r\n close(): void {\r\n this.fanoutUnsub();\r\n }\r\n}\r\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["import { parseEnvelope, isValidElement, type SyncOp, type SyncEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\nimport type { HubBackend } from './hub-backend';\nimport type { Authorize, CanRead, OwnedElement } from './authorize';\n\nexport interface Connection {\n id: string;\n room: string;\n userId?: string;\n role?: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authorize?: Authorize;\n canRead?: CanRead;\n}\n\nconst HUB_FROM = 'hub';\n\nfunction generateInstanceId(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\n return crypto.randomUUID();\n return `i-${Math.random().toString(36).slice(2)}`;\n}\n\nfunction isFanoutOp(op: unknown): op is Extract<SyncOp, { kind: 'upsert' | 'remove' | 'clear' }> {\n if (typeof op !== 'object' || op === null) return false;\n const o = op as { kind?: unknown; element?: unknown; id?: unknown };\n if (o.kind === 'upsert') return isValidElement(o.element);\n if (o.kind === 'remove') return typeof o.id === 'string';\n return o.kind === 'clear';\n}\n\nfunction isPresenceOp(\n op: unknown,\n): op is { kind: 'presence'; data: unknown } | { kind: 'presence-leave' } {\n if (typeof op !== 'object' || op === null) return false;\n const k = (op as { kind?: unknown }).kind;\n return k === 'presence' || k === 'presence-leave';\n}\n\nexport class SyncHub {\n private readonly backend: HubBackend;\n private readonly conns = new Map<string, Connection>();\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\n private readonly presenceIds = new Map<string, string>(); // connId → last clientId that sent presence\n private readonly instanceId: string;\n private readonly fanout: HubFanout;\n private readonly fanoutUnsub: () => void;\n private readonly authorize?: Authorize;\n private readonly canRead?: CanRead;\n\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\n this.instanceId = options.instanceId ?? generateInstanceId();\n this.fanout = options.fanout ?? new InMemoryHubFanout();\n this.authorize = options.authorize;\n this.canRead = options.canRead;\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\n }\n\n addConnection(conn: Connection): void {\n this.conns.set(conn.id, conn);\n let set = this.rooms.get(conn.room);\n if (!set) {\n set = new Set();\n this.rooms.set(conn.room, set);\n }\n set.add(conn.id);\n }\n\n removeConnection(connId: string): void {\n const conn = this.conns.get(connId);\n if (!conn) return;\n this.conns.delete(connId);\n const room = conn.room;\n const clientId = this.presenceIds.get(connId);\n this.presenceIds.delete(connId);\n const members = this.rooms.get(room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(room);\n this.roomQueues.delete(room);\n }\n }\n if (clientId !== undefined) this.broadcastLeave(room, clientId);\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n\n handleMessage(connId: string, message: string): Promise<void> {\n const conn = this.conns.get(connId);\n if (!conn) return Promise.resolve();\n const env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.broadcastPresence(conn, env.from, env.op.data); // off-queue, synchronous\n return Promise.resolve();\n }\n const room = conn.room;\n // The per-room serial queue is the single total-order authority: ops apply in arrival order\n // (arrival-order LWW — no per-element seq; see D3 / TD-12). Different rooms run independently.\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\n const next = prev\n .then(() => this.process(conn, env))\n .catch(() => {\n // swallow so one failed message never wedges the room's serial queue\n });\n this.roomQueues.set(room, next);\n return next;\n }\n\n private async process(conn: Connection, env: SyncEnvelope): Promise<void> {\n const op = env.op;\n if (op.kind === 'request-snapshot') {\n const all = (await this.backend.snapshot(conn.room)) as OwnedElement[];\n const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;\n conn.send(\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\n );\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\n const needCurrent = (this.authorize || this.canRead) && id !== undefined;\n const current: OwnedElement | undefined = needCurrent\n ? await this.backend.get(conn.room, id)\n : undefined;\n\n let outboundOp: SyncOp = op;\n if (this.authorize) {\n const allowed = await this.authorize({\n userId: conn.userId,\n role: conn.role,\n room: conn.room,\n op,\n currentElement: current,\n });\n if (!allowed) {\n await this.sendCorrection(conn, env.from, op, current);\n return;\n }\n if (op.kind === 'upsert') {\n const ownerId = current?.ownerId ?? conn.userId;\n const stampedElement: OwnedElement = { ...op.element, ownerId };\n outboundOp = { kind: 'upsert', element: stampedElement };\n }\n }\n\n await this.backend.apply(conn.room, outboundOp);\n\n const prevExisted = current !== undefined;\n const prevAudience = current?.audience;\n\n this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: env.from,\n op: outboundOp,\n prev: prevAudience,\n existed: prevExisted,\n }),\n );\n }\n // 'snapshot' from a client → ignored\n }\n\n private mayRead(conn: Connection, audience: string | undefined): boolean {\n if (!this.canRead) return true;\n return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });\n }\n\n private safePublish(payload: string): void {\n try {\n this.fanout.publish(payload);\n } catch {\n /* a broken fanout publisher must not break the un-queued presence relay */\n }\n }\n\n private relayToRoom(room: string, excludeId: string | undefined, message: string): void {\n const members = this.rooms.get(room);\n if (!members) return;\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n try {\n conn.send(message);\n } catch {\n /* a throwing socket must not break the relay loop */\n }\n }\n }\n\n private broadcastPresence(conn: Connection, from: string, data: unknown): void {\n this.presenceIds.set(conn.id, from);\n const message = JSON.stringify({ from, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: 'presence', data } }),\n );\n }\n\n private broadcastLeave(room: string, from: string): void {\n const message = JSON.stringify({ from, op: { kind: 'presence-leave' } });\n this.relayToRoom(room, undefined, message);\n this.safePublish(\n JSON.stringify({ o: this.instanceId, room, from, op: { kind: 'presence-leave' } }),\n );\n }\n\n private deliverToRoom(\n room: string,\n excludeId: string | undefined,\n from: string,\n op: SyncOp,\n prevAudience: string | undefined,\n prevExisted: boolean,\n ): void {\n const members = this.rooms.get(room);\n if (!members) return;\n const send = (conn: Connection, msg: string): void => {\n try {\n conn.send(msg);\n } catch {\n /* a throwing socket must not break the delivery loop */\n }\n };\n if (op.kind === 'upsert') {\n const audience = (op.element as OwnedElement).audience;\n const upsertMsg = JSON.stringify({ from, op });\n const removeMsg = JSON.stringify({\n from: HUB_FROM,\n op: { kind: 'remove', id: op.element.id },\n });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n if (this.mayRead(conn, audience)) send(conn, upsertMsg);\n else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);\n }\n } else if (op.kind === 'remove') {\n const removeMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n // No read filter → forward to all (today's behavior; current/prevExisted aren't fetched without\n // a hook). With canRead, only recipients who could see the removed element get it.\n const wasVisible = !this.canRead || (prevExisted && this.mayRead(conn, prevAudience));\n if (wasVisible) send(conn, removeMsg);\n }\n } else if (op.kind === 'clear') {\n const clearMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (conn) send(conn, clearMsg);\n }\n }\n }\n\n private async sendCorrection(\n conn: Connection,\n from: string,\n op: SyncOp,\n current: OwnedElement | undefined,\n ): Promise<void> {\n let correction: SyncOp | undefined;\n if (op.kind === 'upsert') {\n correction = current\n ? { kind: 'upsert', element: current }\n : { kind: 'remove', id: op.element.id };\n } else if (op.kind === 'remove') {\n correction = current ? { kind: 'upsert', element: current } : undefined;\n } else if (op.kind === 'clear') {\n const elements = await this.backend.snapshot(conn.room);\n correction = { kind: 'snapshot', to: from, elements };\n }\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\n }\n\n private onFanout(payload: string): void {\n // Off the serial queue on purpose: forward-only (the origin already applied to the SHARED backend),\n // and delivery is already ordered. Re-filter per local member (canRead runs on EVERY instance).\n let env: {\n o?: unknown;\n room?: unknown;\n from?: unknown;\n op?: unknown;\n prev?: unknown;\n existed?: unknown;\n };\n try {\n env = JSON.parse(payload);\n } catch {\n return;\n }\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.from !== 'string')\n return;\n if (env.o === this.instanceId) return; // our own publish — already delivered locally\n const op = env.op;\n if (isPresenceOp(op)) {\n // presence/leave: raw forward to all local members (the sender lives on the origin instance),\n // no backend, no canRead filter.\n this.relayToRoom(env.room, undefined, JSON.stringify({ from: env.from, op }));\n return;\n }\n if (!isFanoutOp(op)) return;\n const prevAudience = typeof env.prev === 'string' ? env.prev : undefined;\n const prevExisted = env.existed === true;\n this.deliverToRoom(env.room, undefined, env.from, op, prevAudience, prevExisted);\n }\n\n close(): void {\n this.fanoutUnsub();\n }\n}\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize, CanRead } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n canRead?: CanRead;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n canRead: options.canRead,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fieldnotes/sync-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Authoritative WebSocket relay server for Field Notes real-time sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"ws": "^8.18.0",
|
|
41
|
-
"@fieldnotes/sync": "0.
|
|
41
|
+
"@fieldnotes/sync": "0.7.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/ws": "^8.5.12",
|