@fieldnotes/sync-server 0.8.2 → 0.9.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 +26 -0
- package/dist/index.cjs +133 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +133 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,32 @@ createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
|
|
|
51
51
|
|
|
52
52
|
Browsers auto-pong at the protocol level, so **no client change** is needed.
|
|
53
53
|
|
|
54
|
+
## Resource limits
|
|
55
|
+
|
|
56
|
+
The reference server bounds work and memory per connection by default. Oversized WebSocket messages
|
|
57
|
+
close with code `1009`; message-rate and pending-auth queue violations close with code `4408`.
|
|
58
|
+
Messages with excessive JSON nesting are dropped. Presence sends immediately, then coalesces rapid
|
|
59
|
+
updates so the latest state is forwarded at most once per throttle interval.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
createSyncServer({
|
|
63
|
+
port: 8080,
|
|
64
|
+
maxMessageBytes: 1024 * 1024,
|
|
65
|
+
maxJsonDepth: 32,
|
|
66
|
+
maxPendingAuthMessages: 100,
|
|
67
|
+
maxPendingAuthBytes: 2 * 1024 * 1024,
|
|
68
|
+
messagesPerSecond: 120,
|
|
69
|
+
messageBurst: 240,
|
|
70
|
+
presenceThrottleMs: 50,
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
These values are the defaults. Tune them to the largest legitimate board operation and expected
|
|
75
|
+
client update rate. `maxMessageBytes` is enforced by the WebSocket parser before a complete message
|
|
76
|
+
is allocated, including fragmented messages. The pending-auth limits bound messages held while an
|
|
77
|
+
asynchronous `authenticate` hook is unresolved. Rate limits are per connection and use a token
|
|
78
|
+
bucket: `messageBurst` is the short spike allowance and `messagesPerSecond` is the refill rate.
|
|
79
|
+
|
|
54
80
|
## Authentication
|
|
55
81
|
|
|
56
82
|
Pass an `authenticate` hook to gate connections:
|
package/dist/index.cjs
CHANGED
|
@@ -75,6 +75,55 @@ var InMemoryHubFanout = class {
|
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
// src/resource-limits.ts
|
|
79
|
+
var DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
80
|
+
var DEFAULT_MAX_JSON_DEPTH = 32;
|
|
81
|
+
var DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;
|
|
82
|
+
var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
|
|
83
|
+
var DEFAULT_MESSAGES_PER_SECOND = 120;
|
|
84
|
+
var DEFAULT_MESSAGE_BURST = 240;
|
|
85
|
+
var DEFAULT_PRESENCE_THROTTLE_MS = 50;
|
|
86
|
+
function hasJsonDepthAtMost(message, maxDepth) {
|
|
87
|
+
let depth = 0;
|
|
88
|
+
let inString = false;
|
|
89
|
+
let escaped = false;
|
|
90
|
+
for (const char of message) {
|
|
91
|
+
if (inString) {
|
|
92
|
+
if (escaped) escaped = false;
|
|
93
|
+
else if (char === "\\") escaped = true;
|
|
94
|
+
else if (char === '"') inString = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (char === '"') inString = true;
|
|
98
|
+
else if (char === "{" || char === "[") {
|
|
99
|
+
depth += 1;
|
|
100
|
+
if (depth > maxDepth) return false;
|
|
101
|
+
} else if (char === "}" || char === "]") {
|
|
102
|
+
depth -= 1;
|
|
103
|
+
if (depth < 0) return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return depth === 0 && !inString;
|
|
107
|
+
}
|
|
108
|
+
var MessageRateLimiter = class {
|
|
109
|
+
constructor(ratePerSecond, burst, now = Date.now()) {
|
|
110
|
+
this.ratePerSecond = ratePerSecond;
|
|
111
|
+
this.burst = burst;
|
|
112
|
+
this.tokens = burst;
|
|
113
|
+
this.updatedAt = now;
|
|
114
|
+
}
|
|
115
|
+
tokens;
|
|
116
|
+
updatedAt;
|
|
117
|
+
take(now = Date.now()) {
|
|
118
|
+
const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
|
|
119
|
+
this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
|
|
120
|
+
this.updatedAt = now;
|
|
121
|
+
if (this.tokens < 1) return false;
|
|
122
|
+
this.tokens -= 1;
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
78
127
|
// src/sync-hub.ts
|
|
79
128
|
var HUB_FROM = "hub";
|
|
80
129
|
function generateInstanceId() {
|
|
@@ -107,12 +156,18 @@ var SyncHub = class {
|
|
|
107
156
|
fanoutUnsub;
|
|
108
157
|
authorize;
|
|
109
158
|
canRead;
|
|
159
|
+
maxJsonDepth;
|
|
160
|
+
presenceThrottleMs;
|
|
161
|
+
lastPresenceAt = /* @__PURE__ */ new Map();
|
|
162
|
+
pendingPresence = /* @__PURE__ */ new Map();
|
|
110
163
|
constructor(options = {}) {
|
|
111
164
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
112
165
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
113
166
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
114
167
|
this.authorize = options.authorize;
|
|
115
168
|
this.canRead = options.canRead;
|
|
169
|
+
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
170
|
+
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
116
171
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
117
172
|
}
|
|
118
173
|
addConnection(conn) {
|
|
@@ -130,6 +185,10 @@ var SyncHub = class {
|
|
|
130
185
|
this.conns.delete(connId);
|
|
131
186
|
const room = conn.room;
|
|
132
187
|
const hadPresence = this.presenceConnections.delete(connId);
|
|
188
|
+
this.lastPresenceAt.delete(connId);
|
|
189
|
+
const pendingPresence = this.pendingPresence.get(connId);
|
|
190
|
+
if (pendingPresence) clearTimeout(pendingPresence.timer);
|
|
191
|
+
this.pendingPresence.delete(connId);
|
|
133
192
|
const members = this.rooms.get(room);
|
|
134
193
|
if (members) {
|
|
135
194
|
members.delete(connId);
|
|
@@ -146,10 +205,11 @@ var SyncHub = class {
|
|
|
146
205
|
handleMessage(connId, message) {
|
|
147
206
|
const conn = this.conns.get(connId);
|
|
148
207
|
if (!conn) return Promise.resolve();
|
|
208
|
+
if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();
|
|
149
209
|
const env = (0, import_sync2.parseEnvelope)(message);
|
|
150
210
|
if (!env) return Promise.resolve();
|
|
151
211
|
if (env.op.kind === "presence") {
|
|
152
|
-
this.
|
|
212
|
+
this.schedulePresence(conn, env.op.data);
|
|
153
213
|
return Promise.resolve();
|
|
154
214
|
}
|
|
155
215
|
const room = conn.room;
|
|
@@ -244,6 +304,35 @@ var SyncHub = class {
|
|
|
244
304
|
})
|
|
245
305
|
);
|
|
246
306
|
}
|
|
307
|
+
schedulePresence(conn, data) {
|
|
308
|
+
if (this.presenceThrottleMs <= 0) {
|
|
309
|
+
this.broadcastPresence(conn, data);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
const lastSentAt = this.lastPresenceAt.get(conn.id);
|
|
314
|
+
if (lastSentAt === void 0 || now - lastSentAt >= this.presenceThrottleMs) {
|
|
315
|
+
this.lastPresenceAt.set(conn.id, now);
|
|
316
|
+
this.broadcastPresence(conn, data);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const existing = this.pendingPresence.get(conn.id);
|
|
320
|
+
if (existing) {
|
|
321
|
+
existing.data = data;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const timer = setTimeout(
|
|
325
|
+
() => {
|
|
326
|
+
const pending = this.pendingPresence.get(conn.id);
|
|
327
|
+
this.pendingPresence.delete(conn.id);
|
|
328
|
+
if (!pending || !this.conns.has(conn.id)) return;
|
|
329
|
+
this.lastPresenceAt.set(conn.id, Date.now());
|
|
330
|
+
this.broadcastPresence(conn, pending.data);
|
|
331
|
+
},
|
|
332
|
+
this.presenceThrottleMs - (now - lastSentAt)
|
|
333
|
+
);
|
|
334
|
+
this.pendingPresence.set(conn.id, { data, timer });
|
|
335
|
+
}
|
|
247
336
|
broadcastLeave(room, from) {
|
|
248
337
|
const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
|
|
249
338
|
this.relayToRoom(room, void 0, message);
|
|
@@ -325,6 +414,8 @@ var SyncHub = class {
|
|
|
325
414
|
this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
|
|
326
415
|
}
|
|
327
416
|
close() {
|
|
417
|
+
for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);
|
|
418
|
+
this.pendingPresence.clear();
|
|
328
419
|
this.fanoutUnsub();
|
|
329
420
|
}
|
|
330
421
|
};
|
|
@@ -364,19 +455,27 @@ function startHeartbeat(wss, intervalMs) {
|
|
|
364
455
|
}
|
|
365
456
|
|
|
366
457
|
// src/create-sync-server.ts
|
|
458
|
+
function rawDataByteLength(data) {
|
|
459
|
+
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
460
|
+
return data.byteLength;
|
|
461
|
+
}
|
|
367
462
|
function createSyncServer(options = {}) {
|
|
368
463
|
const hub = new SyncHub({
|
|
369
464
|
backend: options.backend,
|
|
370
465
|
fanout: options.fanout,
|
|
371
466
|
instanceId: options.instanceId,
|
|
372
467
|
authorize: options.authorize,
|
|
373
|
-
canRead: options.canRead
|
|
468
|
+
canRead: options.canRead,
|
|
469
|
+
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
470
|
+
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS
|
|
374
471
|
});
|
|
375
|
-
const
|
|
472
|
+
const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
473
|
+
const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new import_ws.WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
|
|
376
474
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
377
475
|
let counter = 0;
|
|
378
476
|
wss.on("connection", (ws, req) => {
|
|
379
477
|
heartbeat.track(ws);
|
|
478
|
+
ws.on("error", () => void 0);
|
|
380
479
|
const url = new URL(req.url ?? "", "http://localhost");
|
|
381
480
|
const room = url.searchParams.get("room");
|
|
382
481
|
if (!room) {
|
|
@@ -386,8 +485,15 @@ function createSyncServer(options = {}) {
|
|
|
386
485
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
387
486
|
let state = "pending";
|
|
388
487
|
let closed = false;
|
|
488
|
+
let admitted = false;
|
|
389
489
|
const queue = [];
|
|
390
|
-
|
|
490
|
+
let queuedBytes = 0;
|
|
491
|
+
const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
|
|
492
|
+
const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
|
|
493
|
+
const limiter = new MessageRateLimiter(
|
|
494
|
+
options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,
|
|
495
|
+
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
496
|
+
);
|
|
391
497
|
const send = (m) => {
|
|
392
498
|
try {
|
|
393
499
|
ws.send(m);
|
|
@@ -395,31 +501,50 @@ function createSyncServer(options = {}) {
|
|
|
395
501
|
}
|
|
396
502
|
};
|
|
397
503
|
ws.on("message", (data) => {
|
|
398
|
-
const msg = String(data);
|
|
399
504
|
if (state === "rejected") return;
|
|
505
|
+
const messageBytes = rawDataByteLength(data);
|
|
506
|
+
if (messageBytes > maxMessageBytes) {
|
|
507
|
+
state = "rejected";
|
|
508
|
+
ws.close(1009, "message too large");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (!limiter.take()) {
|
|
512
|
+
state = "rejected";
|
|
513
|
+
ws.close(4408, "rate limit exceeded");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const msg = String(data);
|
|
400
517
|
if (state === "pending") {
|
|
401
|
-
if (queue.length
|
|
518
|
+
if (queue.length >= maxPendingAuthMessages || queuedBytes + messageBytes > maxPendingAuthBytes) {
|
|
519
|
+
state = "rejected";
|
|
520
|
+
ws.close(4408, "authentication queue limit exceeded");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
queue.push(msg);
|
|
524
|
+
queuedBytes += messageBytes;
|
|
402
525
|
return;
|
|
403
526
|
}
|
|
404
527
|
void hub.handleMessage(connId, msg).catch((err) => console.error("[sync-server]", err));
|
|
405
528
|
});
|
|
406
529
|
ws.on("close", () => {
|
|
407
530
|
closed = true;
|
|
408
|
-
if (
|
|
531
|
+
if (admitted) hub.removeConnection(connId);
|
|
409
532
|
});
|
|
410
533
|
Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
|
|
411
|
-
if (closed) return;
|
|
534
|
+
if (closed || state === "rejected") return;
|
|
412
535
|
if (!result) {
|
|
413
536
|
state = "rejected";
|
|
414
537
|
ws.close(4401, "unauthorized");
|
|
415
538
|
return;
|
|
416
539
|
}
|
|
417
540
|
state = "ready";
|
|
541
|
+
admitted = true;
|
|
418
542
|
hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });
|
|
419
543
|
for (const m of queue) {
|
|
420
544
|
void hub.handleMessage(connId, m).catch((err) => console.error("[sync-server]", err));
|
|
421
545
|
}
|
|
422
546
|
queue.length = 0;
|
|
547
|
+
queuedBytes = 0;
|
|
423
548
|
}).catch(() => {
|
|
424
549
|
if (!closed) {
|
|
425
550
|
state = "rejected";
|
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, 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 presenceConnections = new Set<string>();\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 hadPresence = this.presenceConnections.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 (hadPresence) this.broadcastLeave(room, conn.id);\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.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 // `to` is a private correlation address for the requesting SyncClient. It is never\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\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, data: unknown): void {\n this.presenceConnections.add(conn.id);\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\n op: { kind: 'presence', data },\n }),\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":["import_sync"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/resource-limits.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';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_PRESENCE_THROTTLE_MS,\n hasJsonDepthAtMost,\n} from './resource-limits';\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 maxJsonDepth?: number;\n presenceThrottleMs?: number;\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 presenceConnections = new Set<string>();\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 private readonly maxJsonDepth: number;\n private readonly presenceThrottleMs: number;\n private readonly lastPresenceAt = new Map<string, number>();\n private readonly pendingPresence = new Map<\n string,\n { data: unknown; timer: ReturnType<typeof setTimeout> }\n >();\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.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;\n this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;\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 hadPresence = this.presenceConnections.delete(connId);\n this.lastPresenceAt.delete(connId);\n const pendingPresence = this.pendingPresence.get(connId);\n if (pendingPresence) clearTimeout(pendingPresence.timer);\n this.pendingPresence.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 (hadPresence) this.broadcastLeave(room, conn.id);\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 if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();\n const env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.schedulePresence(conn, env.op.data); // off-queue, throttled independently\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 // `to` is a private correlation address for the requesting SyncClient. It is never\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\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, data: unknown): void {\n this.presenceConnections.add(conn.id);\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\n op: { kind: 'presence', data },\n }),\n );\n }\n\n private schedulePresence(conn: Connection, data: unknown): void {\n if (this.presenceThrottleMs <= 0) {\n this.broadcastPresence(conn, data);\n return;\n }\n const now = Date.now();\n const lastSentAt = this.lastPresenceAt.get(conn.id);\n if (lastSentAt === undefined || now - lastSentAt >= this.presenceThrottleMs) {\n this.lastPresenceAt.set(conn.id, now);\n this.broadcastPresence(conn, data);\n return;\n }\n\n const existing = this.pendingPresence.get(conn.id);\n if (existing) {\n existing.data = data;\n return;\n }\n const timer = setTimeout(\n () => {\n const pending = this.pendingPresence.get(conn.id);\n this.pendingPresence.delete(conn.id);\n if (!pending || !this.conns.has(conn.id)) return;\n this.lastPresenceAt.set(conn.id, Date.now());\n this.broadcastPresence(conn, pending.data);\n },\n this.presenceThrottleMs - (now - lastSentAt),\n );\n this.pendingPresence.set(conn.id, { data, timer });\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 for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);\n this.pendingPresence.clear();\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","export const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;\nexport const DEFAULT_MAX_JSON_DEPTH = 32;\nexport const DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;\nexport const DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;\nexport const DEFAULT_MESSAGES_PER_SECOND = 120;\nexport const DEFAULT_MESSAGE_BURST = 240;\nexport const DEFAULT_PRESENCE_THROTTLE_MS = 50;\n\nexport function hasJsonDepthAtMost(message: string, maxDepth: number): boolean {\n let depth = 0;\n let inString = false;\n let escaped = false;\n\n for (const char of message) {\n if (inString) {\n if (escaped) escaped = false;\n else if (char === '\\\\') escaped = true;\n else if (char === '\"') inString = false;\n continue;\n }\n if (char === '\"') inString = true;\n else if (char === '{' || char === '[') {\n depth += 1;\n if (depth > maxDepth) return false;\n } else if (char === '}' || char === ']') {\n depth -= 1;\n if (depth < 0) return false;\n }\n }\n\n return depth === 0 && !inString;\n}\n\nexport class MessageRateLimiter {\n private tokens: number;\n private updatedAt: number;\n\n constructor(\n private readonly ratePerSecond: number,\n private readonly burst: number,\n now = Date.now(),\n ) {\n this.tokens = burst;\n this.updatedAt = now;\n }\n\n take(now = Date.now()): boolean {\n const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1000;\n this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);\n this.updatedAt = now;\n if (this.tokens < 1) return false;\n this.tokens -= 1;\n return true;\n }\n}\n","import { WebSocketServer, type RawData } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_MAX_MESSAGE_BYTES,\n DEFAULT_MAX_PENDING_AUTH_BYTES,\n DEFAULT_MAX_PENDING_AUTH_MESSAGES,\n DEFAULT_MESSAGE_BURST,\n DEFAULT_MESSAGES_PER_SECOND,\n DEFAULT_PRESENCE_THROTTLE_MS,\n MessageRateLimiter,\n} from './resource-limits';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n canRead?: CanRead;\n heartbeatIntervalMs?: number;\n maxMessageBytes?: number;\n maxJsonDepth?: number;\n maxPendingAuthMessages?: number;\n maxPendingAuthBytes?: number;\n messagesPerSecond?: number;\n messageBurst?: number;\n presenceThrottleMs?: number;\n}\n\nfunction rawDataByteLength(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);\n return data.byteLength;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n canRead: options.canRead,\n maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,\n presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,\n });\n const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;\n const wss = options.server\n ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes })\n : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n // Protocol/parser failures (including maxPayload) close the peer. Consuming the socket-level\n // error keeps hostile input from becoming an uncaught process error.\n ws.on('error', () => undefined);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n let admitted = false;\n const queue: string[] = [];\n let queuedBytes = 0;\n const maxPendingAuthMessages =\n options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;\n const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;\n const limiter = new MessageRateLimiter(\n options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,\n options.messageBurst ?? DEFAULT_MESSAGE_BURST,\n );\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n if (state === 'rejected') return;\n const messageBytes = rawDataByteLength(data);\n if (messageBytes > maxMessageBytes) {\n state = 'rejected';\n ws.close(1009, 'message too large');\n return;\n }\n if (!limiter.take()) {\n state = 'rejected';\n ws.close(4408, 'rate limit exceeded');\n return;\n }\n const msg = String(data);\n if (state === 'pending') {\n if (\n queue.length >= maxPendingAuthMessages ||\n queuedBytes + messageBytes > maxPendingAuthBytes\n ) {\n state = 'rejected';\n ws.close(4408, 'authentication queue limit exceeded');\n return;\n }\n queue.push(msg);\n queuedBytes += messageBytes;\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (admitted) hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed || state === 'rejected') return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n admitted = true;\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n queuedBytes = 0;\n })\n .catch(() => {\n if (!closed) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n return {\n hub,\n wss,\n close: () =>\n new Promise<void>((resolve) => {\n heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -53,6 +53,8 @@ interface SyncHubOptions {
|
|
|
53
53
|
instanceId?: string;
|
|
54
54
|
authorize?: Authorize;
|
|
55
55
|
canRead?: CanRead;
|
|
56
|
+
maxJsonDepth?: number;
|
|
57
|
+
presenceThrottleMs?: number;
|
|
56
58
|
}
|
|
57
59
|
declare class SyncHub {
|
|
58
60
|
private readonly backend;
|
|
@@ -65,6 +67,10 @@ declare class SyncHub {
|
|
|
65
67
|
private readonly fanoutUnsub;
|
|
66
68
|
private readonly authorize?;
|
|
67
69
|
private readonly canRead?;
|
|
70
|
+
private readonly maxJsonDepth;
|
|
71
|
+
private readonly presenceThrottleMs;
|
|
72
|
+
private readonly lastPresenceAt;
|
|
73
|
+
private readonly pendingPresence;
|
|
68
74
|
constructor(options?: SyncHubOptions);
|
|
69
75
|
addConnection(conn: Connection): void;
|
|
70
76
|
removeConnection(connId: string): void;
|
|
@@ -75,6 +81,7 @@ declare class SyncHub {
|
|
|
75
81
|
private safePublish;
|
|
76
82
|
private relayToRoom;
|
|
77
83
|
private broadcastPresence;
|
|
84
|
+
private schedulePresence;
|
|
78
85
|
private broadcastLeave;
|
|
79
86
|
private deliverToRoom;
|
|
80
87
|
private sendCorrection;
|
|
@@ -110,6 +117,13 @@ interface CreateSyncServerOptions {
|
|
|
110
117
|
authorize?: Authorize;
|
|
111
118
|
canRead?: CanRead;
|
|
112
119
|
heartbeatIntervalMs?: number;
|
|
120
|
+
maxMessageBytes?: number;
|
|
121
|
+
maxJsonDepth?: number;
|
|
122
|
+
maxPendingAuthMessages?: number;
|
|
123
|
+
maxPendingAuthBytes?: number;
|
|
124
|
+
messagesPerSecond?: number;
|
|
125
|
+
messageBurst?: number;
|
|
126
|
+
presenceThrottleMs?: number;
|
|
113
127
|
}
|
|
114
128
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
115
129
|
hub: SyncHub;
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ interface SyncHubOptions {
|
|
|
53
53
|
instanceId?: string;
|
|
54
54
|
authorize?: Authorize;
|
|
55
55
|
canRead?: CanRead;
|
|
56
|
+
maxJsonDepth?: number;
|
|
57
|
+
presenceThrottleMs?: number;
|
|
56
58
|
}
|
|
57
59
|
declare class SyncHub {
|
|
58
60
|
private readonly backend;
|
|
@@ -65,6 +67,10 @@ declare class SyncHub {
|
|
|
65
67
|
private readonly fanoutUnsub;
|
|
66
68
|
private readonly authorize?;
|
|
67
69
|
private readonly canRead?;
|
|
70
|
+
private readonly maxJsonDepth;
|
|
71
|
+
private readonly presenceThrottleMs;
|
|
72
|
+
private readonly lastPresenceAt;
|
|
73
|
+
private readonly pendingPresence;
|
|
68
74
|
constructor(options?: SyncHubOptions);
|
|
69
75
|
addConnection(conn: Connection): void;
|
|
70
76
|
removeConnection(connId: string): void;
|
|
@@ -75,6 +81,7 @@ declare class SyncHub {
|
|
|
75
81
|
private safePublish;
|
|
76
82
|
private relayToRoom;
|
|
77
83
|
private broadcastPresence;
|
|
84
|
+
private schedulePresence;
|
|
78
85
|
private broadcastLeave;
|
|
79
86
|
private deliverToRoom;
|
|
80
87
|
private sendCorrection;
|
|
@@ -110,6 +117,13 @@ interface CreateSyncServerOptions {
|
|
|
110
117
|
authorize?: Authorize;
|
|
111
118
|
canRead?: CanRead;
|
|
112
119
|
heartbeatIntervalMs?: number;
|
|
120
|
+
maxMessageBytes?: number;
|
|
121
|
+
maxJsonDepth?: number;
|
|
122
|
+
maxPendingAuthMessages?: number;
|
|
123
|
+
maxPendingAuthBytes?: number;
|
|
124
|
+
messagesPerSecond?: number;
|
|
125
|
+
messageBurst?: number;
|
|
126
|
+
presenceThrottleMs?: number;
|
|
113
127
|
}
|
|
114
128
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
115
129
|
hub: SyncHub;
|
package/dist/index.js
CHANGED
|
@@ -45,6 +45,55 @@ var InMemoryHubFanout = class {
|
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
// src/resource-limits.ts
|
|
49
|
+
var DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
50
|
+
var DEFAULT_MAX_JSON_DEPTH = 32;
|
|
51
|
+
var DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;
|
|
52
|
+
var DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;
|
|
53
|
+
var DEFAULT_MESSAGES_PER_SECOND = 120;
|
|
54
|
+
var DEFAULT_MESSAGE_BURST = 240;
|
|
55
|
+
var DEFAULT_PRESENCE_THROTTLE_MS = 50;
|
|
56
|
+
function hasJsonDepthAtMost(message, maxDepth) {
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let inString = false;
|
|
59
|
+
let escaped = false;
|
|
60
|
+
for (const char of message) {
|
|
61
|
+
if (inString) {
|
|
62
|
+
if (escaped) escaped = false;
|
|
63
|
+
else if (char === "\\") escaped = true;
|
|
64
|
+
else if (char === '"') inString = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === '"') inString = true;
|
|
68
|
+
else if (char === "{" || char === "[") {
|
|
69
|
+
depth += 1;
|
|
70
|
+
if (depth > maxDepth) return false;
|
|
71
|
+
} else if (char === "}" || char === "]") {
|
|
72
|
+
depth -= 1;
|
|
73
|
+
if (depth < 0) return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return depth === 0 && !inString;
|
|
77
|
+
}
|
|
78
|
+
var MessageRateLimiter = class {
|
|
79
|
+
constructor(ratePerSecond, burst, now = Date.now()) {
|
|
80
|
+
this.ratePerSecond = ratePerSecond;
|
|
81
|
+
this.burst = burst;
|
|
82
|
+
this.tokens = burst;
|
|
83
|
+
this.updatedAt = now;
|
|
84
|
+
}
|
|
85
|
+
tokens;
|
|
86
|
+
updatedAt;
|
|
87
|
+
take(now = Date.now()) {
|
|
88
|
+
const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1e3;
|
|
89
|
+
this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);
|
|
90
|
+
this.updatedAt = now;
|
|
91
|
+
if (this.tokens < 1) return false;
|
|
92
|
+
this.tokens -= 1;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
48
97
|
// src/sync-hub.ts
|
|
49
98
|
var HUB_FROM = "hub";
|
|
50
99
|
function generateInstanceId() {
|
|
@@ -77,12 +126,18 @@ var SyncHub = class {
|
|
|
77
126
|
fanoutUnsub;
|
|
78
127
|
authorize;
|
|
79
128
|
canRead;
|
|
129
|
+
maxJsonDepth;
|
|
130
|
+
presenceThrottleMs;
|
|
131
|
+
lastPresenceAt = /* @__PURE__ */ new Map();
|
|
132
|
+
pendingPresence = /* @__PURE__ */ new Map();
|
|
80
133
|
constructor(options = {}) {
|
|
81
134
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
82
135
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
83
136
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
84
137
|
this.authorize = options.authorize;
|
|
85
138
|
this.canRead = options.canRead;
|
|
139
|
+
this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
140
|
+
this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
|
|
86
141
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
87
142
|
}
|
|
88
143
|
addConnection(conn) {
|
|
@@ -100,6 +155,10 @@ var SyncHub = class {
|
|
|
100
155
|
this.conns.delete(connId);
|
|
101
156
|
const room = conn.room;
|
|
102
157
|
const hadPresence = this.presenceConnections.delete(connId);
|
|
158
|
+
this.lastPresenceAt.delete(connId);
|
|
159
|
+
const pendingPresence = this.pendingPresence.get(connId);
|
|
160
|
+
if (pendingPresence) clearTimeout(pendingPresence.timer);
|
|
161
|
+
this.pendingPresence.delete(connId);
|
|
103
162
|
const members = this.rooms.get(room);
|
|
104
163
|
if (members) {
|
|
105
164
|
members.delete(connId);
|
|
@@ -116,10 +175,11 @@ var SyncHub = class {
|
|
|
116
175
|
handleMessage(connId, message) {
|
|
117
176
|
const conn = this.conns.get(connId);
|
|
118
177
|
if (!conn) return Promise.resolve();
|
|
178
|
+
if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();
|
|
119
179
|
const env = parseEnvelope(message);
|
|
120
180
|
if (!env) return Promise.resolve();
|
|
121
181
|
if (env.op.kind === "presence") {
|
|
122
|
-
this.
|
|
182
|
+
this.schedulePresence(conn, env.op.data);
|
|
123
183
|
return Promise.resolve();
|
|
124
184
|
}
|
|
125
185
|
const room = conn.room;
|
|
@@ -214,6 +274,35 @@ var SyncHub = class {
|
|
|
214
274
|
})
|
|
215
275
|
);
|
|
216
276
|
}
|
|
277
|
+
schedulePresence(conn, data) {
|
|
278
|
+
if (this.presenceThrottleMs <= 0) {
|
|
279
|
+
this.broadcastPresence(conn, data);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
const lastSentAt = this.lastPresenceAt.get(conn.id);
|
|
284
|
+
if (lastSentAt === void 0 || now - lastSentAt >= this.presenceThrottleMs) {
|
|
285
|
+
this.lastPresenceAt.set(conn.id, now);
|
|
286
|
+
this.broadcastPresence(conn, data);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const existing = this.pendingPresence.get(conn.id);
|
|
290
|
+
if (existing) {
|
|
291
|
+
existing.data = data;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const timer = setTimeout(
|
|
295
|
+
() => {
|
|
296
|
+
const pending = this.pendingPresence.get(conn.id);
|
|
297
|
+
this.pendingPresence.delete(conn.id);
|
|
298
|
+
if (!pending || !this.conns.has(conn.id)) return;
|
|
299
|
+
this.lastPresenceAt.set(conn.id, Date.now());
|
|
300
|
+
this.broadcastPresence(conn, pending.data);
|
|
301
|
+
},
|
|
302
|
+
this.presenceThrottleMs - (now - lastSentAt)
|
|
303
|
+
);
|
|
304
|
+
this.pendingPresence.set(conn.id, { data, timer });
|
|
305
|
+
}
|
|
217
306
|
broadcastLeave(room, from) {
|
|
218
307
|
const message = JSON.stringify({ from, op: { kind: "presence-leave" } });
|
|
219
308
|
this.relayToRoom(room, void 0, message);
|
|
@@ -295,6 +384,8 @@ var SyncHub = class {
|
|
|
295
384
|
this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
|
|
296
385
|
}
|
|
297
386
|
close() {
|
|
387
|
+
for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);
|
|
388
|
+
this.pendingPresence.clear();
|
|
298
389
|
this.fanoutUnsub();
|
|
299
390
|
}
|
|
300
391
|
};
|
|
@@ -334,19 +425,27 @@ function startHeartbeat(wss, intervalMs) {
|
|
|
334
425
|
}
|
|
335
426
|
|
|
336
427
|
// src/create-sync-server.ts
|
|
428
|
+
function rawDataByteLength(data) {
|
|
429
|
+
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
430
|
+
return data.byteLength;
|
|
431
|
+
}
|
|
337
432
|
function createSyncServer(options = {}) {
|
|
338
433
|
const hub = new SyncHub({
|
|
339
434
|
backend: options.backend,
|
|
340
435
|
fanout: options.fanout,
|
|
341
436
|
instanceId: options.instanceId,
|
|
342
437
|
authorize: options.authorize,
|
|
343
|
-
canRead: options.canRead
|
|
438
|
+
canRead: options.canRead,
|
|
439
|
+
maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
|
|
440
|
+
presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS
|
|
344
441
|
});
|
|
345
|
-
const
|
|
442
|
+
const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
443
|
+
const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
|
|
346
444
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
347
445
|
let counter = 0;
|
|
348
446
|
wss.on("connection", (ws, req) => {
|
|
349
447
|
heartbeat.track(ws);
|
|
448
|
+
ws.on("error", () => void 0);
|
|
350
449
|
const url = new URL(req.url ?? "", "http://localhost");
|
|
351
450
|
const room = url.searchParams.get("room");
|
|
352
451
|
if (!room) {
|
|
@@ -356,8 +455,15 @@ function createSyncServer(options = {}) {
|
|
|
356
455
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
357
456
|
let state = "pending";
|
|
358
457
|
let closed = false;
|
|
458
|
+
let admitted = false;
|
|
359
459
|
const queue = [];
|
|
360
|
-
|
|
460
|
+
let queuedBytes = 0;
|
|
461
|
+
const maxPendingAuthMessages = options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;
|
|
462
|
+
const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;
|
|
463
|
+
const limiter = new MessageRateLimiter(
|
|
464
|
+
options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,
|
|
465
|
+
options.messageBurst ?? DEFAULT_MESSAGE_BURST
|
|
466
|
+
);
|
|
361
467
|
const send = (m) => {
|
|
362
468
|
try {
|
|
363
469
|
ws.send(m);
|
|
@@ -365,31 +471,50 @@ function createSyncServer(options = {}) {
|
|
|
365
471
|
}
|
|
366
472
|
};
|
|
367
473
|
ws.on("message", (data) => {
|
|
368
|
-
const msg = String(data);
|
|
369
474
|
if (state === "rejected") return;
|
|
475
|
+
const messageBytes = rawDataByteLength(data);
|
|
476
|
+
if (messageBytes > maxMessageBytes) {
|
|
477
|
+
state = "rejected";
|
|
478
|
+
ws.close(1009, "message too large");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!limiter.take()) {
|
|
482
|
+
state = "rejected";
|
|
483
|
+
ws.close(4408, "rate limit exceeded");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const msg = String(data);
|
|
370
487
|
if (state === "pending") {
|
|
371
|
-
if (queue.length
|
|
488
|
+
if (queue.length >= maxPendingAuthMessages || queuedBytes + messageBytes > maxPendingAuthBytes) {
|
|
489
|
+
state = "rejected";
|
|
490
|
+
ws.close(4408, "authentication queue limit exceeded");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
queue.push(msg);
|
|
494
|
+
queuedBytes += messageBytes;
|
|
372
495
|
return;
|
|
373
496
|
}
|
|
374
497
|
void hub.handleMessage(connId, msg).catch((err) => console.error("[sync-server]", err));
|
|
375
498
|
});
|
|
376
499
|
ws.on("close", () => {
|
|
377
500
|
closed = true;
|
|
378
|
-
if (
|
|
501
|
+
if (admitted) hub.removeConnection(connId);
|
|
379
502
|
});
|
|
380
503
|
Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
|
|
381
|
-
if (closed) return;
|
|
504
|
+
if (closed || state === "rejected") return;
|
|
382
505
|
if (!result) {
|
|
383
506
|
state = "rejected";
|
|
384
507
|
ws.close(4401, "unauthorized");
|
|
385
508
|
return;
|
|
386
509
|
}
|
|
387
510
|
state = "ready";
|
|
511
|
+
admitted = true;
|
|
388
512
|
hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });
|
|
389
513
|
for (const m of queue) {
|
|
390
514
|
void hub.handleMessage(connId, m).catch((err) => console.error("[sync-server]", err));
|
|
391
515
|
}
|
|
392
516
|
queue.length = 0;
|
|
517
|
+
queuedBytes = 0;
|
|
393
518
|
}).catch(() => {
|
|
394
519
|
if (!closed) {
|
|
395
520
|
state = "rejected";
|
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, 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 presenceConnections = new Set<string>();\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 hadPresence = this.presenceConnections.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 (hadPresence) this.broadcastLeave(room, conn.id);\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.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 // `to` is a private correlation address for the requesting SyncClient. It is never\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\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, data: unknown): void {\n this.presenceConnections.add(conn.id);\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\n op: { kind: 'presence', data },\n }),\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":[]}
|
|
1
|
+
{"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/resource-limits.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';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_PRESENCE_THROTTLE_MS,\n hasJsonDepthAtMost,\n} from './resource-limits';\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 maxJsonDepth?: number;\n presenceThrottleMs?: number;\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 presenceConnections = new Set<string>();\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 private readonly maxJsonDepth: number;\n private readonly presenceThrottleMs: number;\n private readonly lastPresenceAt = new Map<string, number>();\n private readonly pendingPresence = new Map<\n string,\n { data: unknown; timer: ReturnType<typeof setTimeout> }\n >();\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.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;\n this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;\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 hadPresence = this.presenceConnections.delete(connId);\n this.lastPresenceAt.delete(connId);\n const pendingPresence = this.pendingPresence.get(connId);\n if (pendingPresence) clearTimeout(pendingPresence.timer);\n this.pendingPresence.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 (hadPresence) this.broadcastLeave(room, conn.id);\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 if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();\n const env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.schedulePresence(conn, env.op.data); // off-queue, throttled independently\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 // `to` is a private correlation address for the requesting SyncClient. It is never\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\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, data: unknown): void {\n this.presenceConnections.add(conn.id);\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\n op: { kind: 'presence', data },\n }),\n );\n }\n\n private schedulePresence(conn: Connection, data: unknown): void {\n if (this.presenceThrottleMs <= 0) {\n this.broadcastPresence(conn, data);\n return;\n }\n const now = Date.now();\n const lastSentAt = this.lastPresenceAt.get(conn.id);\n if (lastSentAt === undefined || now - lastSentAt >= this.presenceThrottleMs) {\n this.lastPresenceAt.set(conn.id, now);\n this.broadcastPresence(conn, data);\n return;\n }\n\n const existing = this.pendingPresence.get(conn.id);\n if (existing) {\n existing.data = data;\n return;\n }\n const timer = setTimeout(\n () => {\n const pending = this.pendingPresence.get(conn.id);\n this.pendingPresence.delete(conn.id);\n if (!pending || !this.conns.has(conn.id)) return;\n this.lastPresenceAt.set(conn.id, Date.now());\n this.broadcastPresence(conn, pending.data);\n },\n this.presenceThrottleMs - (now - lastSentAt),\n );\n this.pendingPresence.set(conn.id, { data, timer });\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 for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);\n this.pendingPresence.clear();\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","export const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;\nexport const DEFAULT_MAX_JSON_DEPTH = 32;\nexport const DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;\nexport const DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;\nexport const DEFAULT_MESSAGES_PER_SECOND = 120;\nexport const DEFAULT_MESSAGE_BURST = 240;\nexport const DEFAULT_PRESENCE_THROTTLE_MS = 50;\n\nexport function hasJsonDepthAtMost(message: string, maxDepth: number): boolean {\n let depth = 0;\n let inString = false;\n let escaped = false;\n\n for (const char of message) {\n if (inString) {\n if (escaped) escaped = false;\n else if (char === '\\\\') escaped = true;\n else if (char === '\"') inString = false;\n continue;\n }\n if (char === '\"') inString = true;\n else if (char === '{' || char === '[') {\n depth += 1;\n if (depth > maxDepth) return false;\n } else if (char === '}' || char === ']') {\n depth -= 1;\n if (depth < 0) return false;\n }\n }\n\n return depth === 0 && !inString;\n}\n\nexport class MessageRateLimiter {\n private tokens: number;\n private updatedAt: number;\n\n constructor(\n private readonly ratePerSecond: number,\n private readonly burst: number,\n now = Date.now(),\n ) {\n this.tokens = burst;\n this.updatedAt = now;\n }\n\n take(now = Date.now()): boolean {\n const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1000;\n this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);\n this.updatedAt = now;\n if (this.tokens < 1) return false;\n this.tokens -= 1;\n return true;\n }\n}\n","import { WebSocketServer, type RawData } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_MAX_MESSAGE_BYTES,\n DEFAULT_MAX_PENDING_AUTH_BYTES,\n DEFAULT_MAX_PENDING_AUTH_MESSAGES,\n DEFAULT_MESSAGE_BURST,\n DEFAULT_MESSAGES_PER_SECOND,\n DEFAULT_PRESENCE_THROTTLE_MS,\n MessageRateLimiter,\n} from './resource-limits';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n canRead?: CanRead;\n heartbeatIntervalMs?: number;\n maxMessageBytes?: number;\n maxJsonDepth?: number;\n maxPendingAuthMessages?: number;\n maxPendingAuthBytes?: number;\n messagesPerSecond?: number;\n messageBurst?: number;\n presenceThrottleMs?: number;\n}\n\nfunction rawDataByteLength(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);\n return data.byteLength;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n canRead: options.canRead,\n maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,\n presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,\n });\n const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;\n const wss = options.server\n ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes })\n : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n // Protocol/parser failures (including maxPayload) close the peer. Consuming the socket-level\n // error keeps hostile input from becoming an uncaught process error.\n ws.on('error', () => undefined);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n let admitted = false;\n const queue: string[] = [];\n let queuedBytes = 0;\n const maxPendingAuthMessages =\n options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;\n const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;\n const limiter = new MessageRateLimiter(\n options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,\n options.messageBurst ?? DEFAULT_MESSAGE_BURST,\n );\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n if (state === 'rejected') return;\n const messageBytes = rawDataByteLength(data);\n if (messageBytes > maxMessageBytes) {\n state = 'rejected';\n ws.close(1009, 'message too large');\n return;\n }\n if (!limiter.take()) {\n state = 'rejected';\n ws.close(4408, 'rate limit exceeded');\n return;\n }\n const msg = String(data);\n if (state === 'pending') {\n if (\n queue.length >= maxPendingAuthMessages ||\n queuedBytes + messageBytes > maxPendingAuthBytes\n ) {\n state = 'rejected';\n ws.close(4408, 'authentication queue limit exceeded');\n return;\n }\n queue.push(msg);\n queuedBytes += messageBytes;\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (admitted) hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed || state === 'rejected') return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n admitted = true;\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n queuedBytes = 0;\n })\n .catch(() => {\n if (!closed) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n return {\n hub,\n wss,\n close: () =>\n new Promise<void>((resolve) => {\n heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\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":[]}
|