@classytic/arc 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BaseController-Dmeh4Y_y.mjs → BaseController-DPwe89qs.mjs} +2 -2
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/context/index.d.mts +54 -1
- package/dist/context/index.mjs +65 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-DYJvu7aI.mjs → core-B41eZxyM.mjs} +9 -6
- package/dist/{createActionRouter-BHUMb1-y.mjs → createActionRouter-CRaIwB5S.mjs} +2 -2
- package/dist/{createAggregationRouter-BYVvhfc5.mjs → createAggregationRouter-n3O6WdLO.mjs} +1 -1
- package/dist/{createApp-CzCEEsEm.mjs → createApp-D56t2Dsj.mjs} +7 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-CnJCUyM0.mjs → eventPlugin-Dxp9JlKK.mjs} +1 -1
- package/dist/events/index.d.mts +48 -0
- package/dist/events/index.mjs +27 -5
- package/dist/factory/index.mjs +1 -1
- package/dist/index.mjs +4 -4
- package/dist/{init-DYzfniuX.mjs → init-DQ_rY1J7.mjs} +13 -13
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +42 -2
- package/dist/integrations/mcp/index.mjs +102 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/websocket-pushref-redis.d.mts +71 -0
- package/dist/integrations/websocket-pushref-redis.mjs +205 -0
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +3 -2
- package/dist/integrations/websocket.mjs +1142 -23
- package/dist/{mcpPlugin-hZE00H74.mjs → mcpPlugin-BrepZ0cV.mjs} +3 -3
- package/dist/{openapi-DKDs9NfD.mjs → openapi-CLZx9bwS.mjs} +1 -1
- package/dist/plugins/index.d.mts +16 -2
- package/dist/plugins/index.mjs +44 -3
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/pushref-registry-Bq_-EVsC.d.mts +435 -0
- package/dist/{requestContext-SSaaTgW8.mjs → requestContext-DDLS3NVU.mjs} +26 -1
- package/dist/{routerShared-nCyJGOKX.mjs → routerShared-Dl0A77Ba.mjs} +1 -1
- package/dist/{schemaIR-DAOvBWgT.mjs → schemaIR-Bk6Tns82.mjs} +17 -5
- package/dist/testing/index.mjs +1 -1
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/storage.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-CvD3glpM.mjs → utils-BMQK8mRl.mjs} +37 -25
- package/dist/websocket-BZMUP7Gr.d.mts +456 -0
- package/package.json +12 -1
- package/dist/websocket-BJ0w-3Dt.d.mts +0 -186
- /package/dist/{storage-HcAgP5Ni.d.mts → storage-BFmamN5m.d.mts} +0 -0
- /package/dist/{tracing-bRqqUxFO.d.mts → tracing-BYQ3hsbM.d.mts} +0 -0
- /package/dist/{types-CoMjNmkF.d.mts → types-D3HV0r5L.d.mts} +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import sjson from "secure-json-parse";
|
|
2
3
|
import fp from "fastify-plugin";
|
|
3
4
|
//#region src/integrations/websocket/adapter.ts
|
|
@@ -12,6 +13,87 @@ var LocalWebSocketAdapter = class {
|
|
|
12
13
|
async close() {}
|
|
13
14
|
};
|
|
14
15
|
//#endregion
|
|
16
|
+
//#region src/integrations/websocket/geo-room.ts
|
|
17
|
+
let h3Cached;
|
|
18
|
+
async function loadH3() {
|
|
19
|
+
if (h3Cached) return h3Cached;
|
|
20
|
+
try {
|
|
21
|
+
const mod = await import("h3-js");
|
|
22
|
+
const merged = {
|
|
23
|
+
...mod.default ?? {},
|
|
24
|
+
...mod
|
|
25
|
+
};
|
|
26
|
+
if (typeof merged.latLngToCell !== "function" || typeof merged.gridDisk !== "function") throw new Error("h3-js missing latLngToCell / gridDisk exports");
|
|
27
|
+
h3Cached = merged;
|
|
28
|
+
return h3Cached;
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error("[arc-websocket] geo-room support requires h3-js.\nInstall it: npm install h3-js");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A geo-aware subscription layer on top of `RoomManager`. Construct one
|
|
35
|
+
* per logical channel (e.g. one for `drivers`, one for `couriers`) so
|
|
36
|
+
* resolutions and prefixes don't cross-contaminate.
|
|
37
|
+
*/
|
|
38
|
+
var GeoRoom = class {
|
|
39
|
+
rooms;
|
|
40
|
+
resolution;
|
|
41
|
+
prefix;
|
|
42
|
+
h3;
|
|
43
|
+
constructor(rooms, opts = {}) {
|
|
44
|
+
this.rooms = rooms;
|
|
45
|
+
this.resolution = opts.resolution ?? 9;
|
|
46
|
+
this.prefix = opts.prefix ?? "geo:";
|
|
47
|
+
}
|
|
48
|
+
/** Subscribe a client to every cell within `ring` of `point`. */
|
|
49
|
+
async subscribe(clientId, point, opts = {}) {
|
|
50
|
+
const h3 = await this.h3lib();
|
|
51
|
+
const ring = opts.ring ?? 1;
|
|
52
|
+
const center = h3.latLngToCell(point.lat, point.lng, this.resolution);
|
|
53
|
+
const cells = h3.gridDisk(center, ring);
|
|
54
|
+
const rooms = [];
|
|
55
|
+
for (const cell of cells) {
|
|
56
|
+
const room = `${this.prefix}${cell}`;
|
|
57
|
+
if (this.rooms.subscribe(clientId, room)) rooms.push(room);
|
|
58
|
+
}
|
|
59
|
+
return rooms;
|
|
60
|
+
}
|
|
61
|
+
/** Symmetric — unsubscribe from every cell within `ring`. */
|
|
62
|
+
async unsubscribe(clientId, point, opts = {}) {
|
|
63
|
+
const h3 = await this.h3lib();
|
|
64
|
+
const ring = opts.ring ?? 1;
|
|
65
|
+
const center = h3.latLngToCell(point.lat, point.lng, this.resolution);
|
|
66
|
+
const cells = h3.gridDisk(center, ring);
|
|
67
|
+
for (const cell of cells) this.rooms.unsubscribe(clientId, `${this.prefix}${cell}`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Publish to every client subscribed to a cell within `ring` of `point`.
|
|
71
|
+
* Droppable by default — every recipient queue keeps only the latest
|
|
72
|
+
* per `coalesceKey` (defaults to the publisher's pushRef).
|
|
73
|
+
*/
|
|
74
|
+
async publish(publisher, point, payload, opts = {}) {
|
|
75
|
+
const h3 = await this.h3lib();
|
|
76
|
+
const ring = opts.ring ?? 1;
|
|
77
|
+
const center = h3.latLngToCell(point.lat, point.lng, this.resolution);
|
|
78
|
+
const cells = h3.gridDisk(center, ring);
|
|
79
|
+
const coalesceKey = opts.coalesceKey ?? publisher.pushRef;
|
|
80
|
+
const delivery = opts.delivery ?? "droppable";
|
|
81
|
+
for (const cell of cells) {
|
|
82
|
+
const room = `${this.prefix}${cell}`;
|
|
83
|
+
if (delivery === "critical") this.rooms.broadcast(room, payload);
|
|
84
|
+
else this.rooms.broadcastRealtime(room, payload, coalesceKey);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Compute the cell ID for a point without subscribing — useful for tests. */
|
|
88
|
+
async cellFor(point) {
|
|
89
|
+
return (await this.h3lib()).latLngToCell(point.lat, point.lng, this.resolution);
|
|
90
|
+
}
|
|
91
|
+
async h3lib() {
|
|
92
|
+
if (!this.h3) this.h3 = await loadH3();
|
|
93
|
+
return this.h3;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
15
97
|
//#region src/integrations/websocket/auth.ts
|
|
16
98
|
function createCaptureReply() {
|
|
17
99
|
let rejected = false;
|
|
@@ -77,8 +159,653 @@ function readIdField(obj, key) {
|
|
|
77
159
|
const value = obj[key];
|
|
78
160
|
return typeof value === "string" ? value : void 0;
|
|
79
161
|
}
|
|
162
|
+
var DeadQueue = class {
|
|
163
|
+
buffer;
|
|
164
|
+
head = 0;
|
|
165
|
+
count = 0;
|
|
166
|
+
capacity;
|
|
167
|
+
constructor(capacity = 128) {
|
|
168
|
+
this.capacity = capacity;
|
|
169
|
+
this.buffer = new Array(capacity);
|
|
170
|
+
}
|
|
171
|
+
push(entry) {
|
|
172
|
+
this.buffer[this.head] = entry;
|
|
173
|
+
this.head = (this.head + 1) % this.capacity;
|
|
174
|
+
if (this.count < this.capacity) this.count++;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Return entries with seq strictly greater than `lastSeq`, in order,
|
|
178
|
+
* OR `null` if the gap is too large (entry with seq = lastSeq + 1 has
|
|
179
|
+
* already been evicted from the ring — caller should signal `resume_gap`).
|
|
180
|
+
*/
|
|
181
|
+
drainAfter(lastSeq) {
|
|
182
|
+
if (this.count === 0) return [];
|
|
183
|
+
const tail = (this.head - this.count + this.capacity) % this.capacity;
|
|
184
|
+
const oldest = this.buffer[tail];
|
|
185
|
+
if (!oldest) return [];
|
|
186
|
+
if (oldest.seq > lastSeq + 1) return null;
|
|
187
|
+
const out = [];
|
|
188
|
+
for (let i = 0; i < this.count; i++) {
|
|
189
|
+
const idx = (tail + i) % this.capacity;
|
|
190
|
+
const e = this.buffer[idx];
|
|
191
|
+
if (e && e.seq > lastSeq) out.push(e);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
size() {
|
|
196
|
+
return this.count;
|
|
197
|
+
}
|
|
198
|
+
/** Highest seq currently in the ring (or 0 if empty). */
|
|
199
|
+
highestSeq() {
|
|
200
|
+
if (this.count === 0) return 0;
|
|
201
|
+
const lastIdx = (this.head - 1 + this.capacity) % this.capacity;
|
|
202
|
+
return this.buffer[lastIdx]?.seq ?? 0;
|
|
203
|
+
}
|
|
204
|
+
clear() {
|
|
205
|
+
this.buffer = new Array(this.capacity);
|
|
206
|
+
this.head = 0;
|
|
207
|
+
this.count = 0;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Return all entries in chronological order. Used by the registry's
|
|
211
|
+
* `persist()` to flush the live ring to the external store. Allocates
|
|
212
|
+
* a fresh array — caller owns it (mutations don't affect the ring).
|
|
213
|
+
*/
|
|
214
|
+
snapshot() {
|
|
215
|
+
if (this.count === 0) return [];
|
|
216
|
+
const tail = (this.head - this.count + this.capacity) % this.capacity;
|
|
217
|
+
const out = [];
|
|
218
|
+
for (let i = 0; i < this.count; i++) {
|
|
219
|
+
const idx = (tail + i) % this.capacity;
|
|
220
|
+
const e = this.buffer[idx];
|
|
221
|
+
if (e) out.push(e);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/integrations/websocket/envelope.ts
|
|
228
|
+
/**
|
|
229
|
+
* Per-pushRef envelope state. One instance per logical session — when
|
|
230
|
+
* a client reconnects under the same pushRef within TTL, the same
|
|
231
|
+
* EnvelopeWriter is rehydrated so its `nextSeq` counter and dead queue
|
|
232
|
+
* round-trip across reconnects.
|
|
233
|
+
*
|
|
234
|
+
* The constructor accepts `{ nextSeq }` for the rehydration path —
|
|
235
|
+
* `PushRefRegistry.hydrateEnvelope` passes the persisted counter so
|
|
236
|
+
* the next wrap() picks up where the previous socket left off (no
|
|
237
|
+
* seq collisions, no replay gaps).
|
|
238
|
+
*/
|
|
239
|
+
var EnvelopeWriter = class {
|
|
240
|
+
nextSeq;
|
|
241
|
+
dead;
|
|
242
|
+
constructor(deadQueue, opts = {}) {
|
|
243
|
+
this.dead = deadQueue;
|
|
244
|
+
this.nextSeq = opts.nextSeq ?? 1;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Wrap a payload, assign next seq, persist to dead queue. Returns the
|
|
248
|
+
* JSON-stringified envelope ready for the socket.
|
|
249
|
+
*/
|
|
250
|
+
wrap(payload) {
|
|
251
|
+
const seq = this.nextSeq++;
|
|
252
|
+
const serialized = JSON.stringify({
|
|
253
|
+
seq,
|
|
254
|
+
t: Date.now(),
|
|
255
|
+
msg: payload
|
|
256
|
+
});
|
|
257
|
+
this.dead.push({
|
|
258
|
+
seq,
|
|
259
|
+
payload: serialized
|
|
260
|
+
});
|
|
261
|
+
return serialized;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Replay entries strictly greater than `lastSeq`. Returns either the
|
|
265
|
+
* array of payloads to write, OR `null` if the gap is unrecoverable
|
|
266
|
+
* (caller emits `resume_gap`).
|
|
267
|
+
*/
|
|
268
|
+
drainAfter(lastSeq) {
|
|
269
|
+
const entries = this.dead.drainAfter(lastSeq);
|
|
270
|
+
if (entries === null) return null;
|
|
271
|
+
return entries.map((e) => e.payload);
|
|
272
|
+
}
|
|
273
|
+
highestSeq() {
|
|
274
|
+
return this.dead.highestSeq();
|
|
275
|
+
}
|
|
276
|
+
/** Peek the next sequence to be assigned (without consuming). */
|
|
277
|
+
peekNextSeq() {
|
|
278
|
+
return this.nextSeq;
|
|
279
|
+
}
|
|
280
|
+
/** Snapshot the dead queue contents for persistence. */
|
|
281
|
+
snapshotDeadQueue() {
|
|
282
|
+
return this.dead.snapshot();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/integrations/websocket/pushref-registry.ts
|
|
287
|
+
/**
|
|
288
|
+
* PushRef registry — owns the cross-connection state that has to outlive
|
|
289
|
+
* a single TCP socket: the per-pushRef envelope writer + dead queue
|
|
290
|
+
* (PR 3), and the principal binding that prevents another authenticated
|
|
291
|
+
* user from hijacking a pushRef.
|
|
292
|
+
*
|
|
293
|
+
* Single-instance default: state lives in an in-memory `Map`. Same
|
|
294
|
+
* shape as the original single-class registry — but the public surface
|
|
295
|
+
* is **async** so a Redis-backed store can plug in without touching
|
|
296
|
+
* connection.ts.
|
|
297
|
+
*
|
|
298
|
+
* Cross-instance: pass a `PushRefStore` (e.g. the Redis-backed one
|
|
299
|
+
* from `@classytic/arc/integrations/websocket-pushref-redis`). The
|
|
300
|
+
* envelope + principal binding then survive node-to-node reconnects —
|
|
301
|
+
* a client originating on Node A and reconnecting to Node B picks up
|
|
302
|
+
* the same dead queue and replays missed envelopes.
|
|
303
|
+
*
|
|
304
|
+
* Why every method returns a Promise even when the default store is
|
|
305
|
+
* synchronous: making the API async-uniform means the connection
|
|
306
|
+
* lifecycle code is identical for memory vs Redis. The microtask
|
|
307
|
+
* overhead on the hot path is in the nanoseconds — negligible next
|
|
308
|
+
* to the network I/O the Redis store actually does.
|
|
309
|
+
*
|
|
310
|
+
* Atomicity contract: `claim()` MUST be atomic relative to other
|
|
311
|
+
* `claim()` calls on the same pushRef. The in-memory store gets this
|
|
312
|
+
* for free (single-threaded event loop). Redis stores MUST use a
|
|
313
|
+
* Lua script or WATCH/MULTI/EXEC to test-and-set the principal.
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* Default in-memory store — the previous single-instance registry's
|
|
317
|
+
* behavior, repackaged behind the `PushRefStore` interface so the
|
|
318
|
+
* registry layer doesn't care whether it's local or distributed.
|
|
319
|
+
*
|
|
320
|
+
* All methods are technically synchronous but return promises to
|
|
321
|
+
* conform to the interface. Cost: one microtask per call. Negligible.
|
|
322
|
+
*/
|
|
323
|
+
var MemoryPushRefStore = class {
|
|
324
|
+
entries = /* @__PURE__ */ new Map();
|
|
325
|
+
async get(pushRef) {
|
|
326
|
+
return this.entries.get(pushRef);
|
|
327
|
+
}
|
|
328
|
+
async set(pushRef, entry) {
|
|
329
|
+
this.entries.set(pushRef, entry);
|
|
330
|
+
}
|
|
331
|
+
async delete(pushRef) {
|
|
332
|
+
this.entries.delete(pushRef);
|
|
333
|
+
}
|
|
334
|
+
async claim(args) {
|
|
335
|
+
const existing = this.entries.get(args.pushRef);
|
|
336
|
+
if (existing) if (!existing.active && existing.expiresAt <= args.now) this.entries.delete(args.pushRef);
|
|
337
|
+
else {
|
|
338
|
+
if (existing.principal !== args.principal) return { outcome: "rejected" };
|
|
339
|
+
const supersededActive = existing.active === true;
|
|
340
|
+
existing.active = true;
|
|
341
|
+
existing.expiresAt = args.now + args.ttlMs;
|
|
342
|
+
existing.generation = (existing.generation ?? 0) + 1;
|
|
343
|
+
this.entries.set(args.pushRef, existing);
|
|
344
|
+
return {
|
|
345
|
+
outcome: "resumed",
|
|
346
|
+
entry: existing,
|
|
347
|
+
supersededActive
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (this.entries.size >= args.maxEntries && !this.evictOldestInactive()) return { outcome: "rejected" };
|
|
351
|
+
const fresh = args.mintFresh();
|
|
352
|
+
this.entries.set(args.pushRef, fresh);
|
|
353
|
+
return {
|
|
354
|
+
outcome: "new",
|
|
355
|
+
entry: fresh
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
async releaseIfGen(args) {
|
|
359
|
+
const entry = this.entries.get(args.pushRef);
|
|
360
|
+
if (!entry) return "missing";
|
|
361
|
+
if (entry.generation !== args.expectedGeneration) return "stale";
|
|
362
|
+
entry.active = false;
|
|
363
|
+
entry.expiresAt = args.expiresAt;
|
|
364
|
+
if (args.snapshot) {
|
|
365
|
+
entry.nextSeq = args.snapshot.nextSeq;
|
|
366
|
+
entry.deadQueue = args.snapshot.deadQueue;
|
|
367
|
+
}
|
|
368
|
+
this.entries.set(args.pushRef, entry);
|
|
369
|
+
return "ok";
|
|
370
|
+
}
|
|
371
|
+
async persistIfGen(args) {
|
|
372
|
+
const entry = this.entries.get(args.pushRef);
|
|
373
|
+
if (!entry) return "missing";
|
|
374
|
+
if (entry.generation !== args.expectedGeneration) return "stale";
|
|
375
|
+
entry.nextSeq = args.snapshot.nextSeq;
|
|
376
|
+
entry.deadQueue = args.snapshot.deadQueue;
|
|
377
|
+
entry.expiresAt = args.expiresAt;
|
|
378
|
+
this.entries.set(args.pushRef, entry);
|
|
379
|
+
return "ok";
|
|
380
|
+
}
|
|
381
|
+
async stageIfInactive(args) {
|
|
382
|
+
const entry = this.entries.get(args.pushRef);
|
|
383
|
+
if (!entry) return "missing";
|
|
384
|
+
if (entry.active) return "active";
|
|
385
|
+
if (entry.envelopeMode !== "seq") return "raw";
|
|
386
|
+
const seq = entry.nextSeq;
|
|
387
|
+
entry.nextSeq = seq + 1;
|
|
388
|
+
const serialized = JSON.stringify({
|
|
389
|
+
seq,
|
|
390
|
+
t: args.timestamp,
|
|
391
|
+
msg: args.payload
|
|
392
|
+
});
|
|
393
|
+
entry.deadQueue.push({
|
|
394
|
+
seq,
|
|
395
|
+
payload: serialized
|
|
396
|
+
});
|
|
397
|
+
if (entry.deadQueue.length > entry.deadQueueSize) entry.deadQueue = entry.deadQueue.slice(-entry.deadQueueSize);
|
|
398
|
+
entry.expiresAt = args.expiresAt;
|
|
399
|
+
this.entries.set(args.pushRef, entry);
|
|
400
|
+
return "ok";
|
|
401
|
+
}
|
|
402
|
+
async gc(now) {
|
|
403
|
+
let removed = 0;
|
|
404
|
+
for (const [k, v] of this.entries) if (!v.active && v.expiresAt <= now) {
|
|
405
|
+
this.entries.delete(k);
|
|
406
|
+
removed++;
|
|
407
|
+
}
|
|
408
|
+
return removed;
|
|
409
|
+
}
|
|
410
|
+
async size() {
|
|
411
|
+
return this.entries.size;
|
|
412
|
+
}
|
|
413
|
+
/** Test-only inspection — not in the PushRefStore interface. */
|
|
414
|
+
inspect(pushRef) {
|
|
415
|
+
return this.entries.get(pushRef);
|
|
416
|
+
}
|
|
417
|
+
evictOldestInactive() {
|
|
418
|
+
let oldestKey;
|
|
419
|
+
let oldestTime = Infinity;
|
|
420
|
+
for (const [k, v] of this.entries) if (!v.active && v.expiresAt < oldestTime) {
|
|
421
|
+
oldestTime = v.expiresAt;
|
|
422
|
+
oldestKey = k;
|
|
423
|
+
}
|
|
424
|
+
if (oldestKey === void 0) return false;
|
|
425
|
+
this.entries.delete(oldestKey);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* Registry — the high-level interface connection.ts calls. Owns the
|
|
431
|
+
* envelope-mint policy (envelope writers are LIVE objects with mutable
|
|
432
|
+
* dead queues — they can't be reconstructed from a serialized snapshot
|
|
433
|
+
* exactly because the `nextSeq` counter and ring buffer must round-trip
|
|
434
|
+
* with full fidelity).
|
|
435
|
+
*
|
|
436
|
+
* The hot path:
|
|
437
|
+
*
|
|
438
|
+
* 1. `claim(pushRef, principal, deadQueueSize)` resolves the
|
|
439
|
+
* cross-connection state. On `new`, an EnvelopeWriter is minted
|
|
440
|
+
* around a fresh DeadQueue. On `resumed`, the EnvelopeWriter is
|
|
441
|
+
* reconstructed from the persisted nextSeq + deadQueue contents.
|
|
442
|
+
*
|
|
443
|
+
* 2. The connection lifecycle calls `release()` on close — starts the
|
|
444
|
+
* TTL countdown.
|
|
445
|
+
*
|
|
446
|
+
* 3. `stageForResume()` writes a payload into the orphan envelope
|
|
447
|
+
* when no live socket is attached (the offline-send path).
|
|
448
|
+
*
|
|
449
|
+
* 4. `persist()` flushes the in-memory EnvelopeWriter back to the
|
|
450
|
+
* store after each write — keeps the store in sync so a reconnect
|
|
451
|
+
* to a different node sees the latest dead queue. For the memory
|
|
452
|
+
* store this is a no-op; for Redis it's a HMSET write.
|
|
453
|
+
*/
|
|
454
|
+
var PushRefRegistry = class {
|
|
455
|
+
store;
|
|
456
|
+
ttlMs;
|
|
457
|
+
maxEntries;
|
|
458
|
+
now;
|
|
459
|
+
/**
|
|
460
|
+
* Live envelope writers indexed by pushRef. Stays in sync with the
|
|
461
|
+
* store via persist() after every wrap(). On `claim('resumed')`,
|
|
462
|
+
* rehydrated from the store's snapshot.
|
|
463
|
+
*/
|
|
464
|
+
liveEnvelopes = /* @__PURE__ */ new Map();
|
|
465
|
+
constructor(opts = {}) {
|
|
466
|
+
this.store = opts.store ?? new MemoryPushRefStore();
|
|
467
|
+
this.ttlMs = opts.ttlMs ?? 6e4;
|
|
468
|
+
this.maxEntries = opts.maxEntries ?? 1e4;
|
|
469
|
+
this.now = opts.now ?? Date.now;
|
|
470
|
+
}
|
|
471
|
+
async claim(pushRef, principal, opts) {
|
|
472
|
+
const now = this.now();
|
|
473
|
+
const result = await this.store.claim({
|
|
474
|
+
pushRef,
|
|
475
|
+
principal,
|
|
476
|
+
now,
|
|
477
|
+
ttlMs: this.ttlMs,
|
|
478
|
+
maxEntries: this.maxEntries,
|
|
479
|
+
countNonExpired: () => this.store.size(),
|
|
480
|
+
mintFresh: () => ({
|
|
481
|
+
principal,
|
|
482
|
+
nextSeq: 1,
|
|
483
|
+
deadQueue: [],
|
|
484
|
+
expiresAt: now + this.ttlMs,
|
|
485
|
+
active: true,
|
|
486
|
+
envelopeMode: opts.envelopeMode,
|
|
487
|
+
deadQueueSize: opts.deadQueueSize,
|
|
488
|
+
generation: 1
|
|
489
|
+
})
|
|
490
|
+
});
|
|
491
|
+
if (result.outcome === "rejected") return {
|
|
492
|
+
outcome: "rejected",
|
|
493
|
+
reason: "principal_mismatch"
|
|
494
|
+
};
|
|
495
|
+
if (!result.entry) return {
|
|
496
|
+
outcome: "rejected",
|
|
497
|
+
reason: "principal_mismatch"
|
|
498
|
+
};
|
|
499
|
+
const generation = result.entry.generation ?? 1;
|
|
500
|
+
const envelope = opts.envelopeMode === "seq" ? this.hydrateEnvelope(pushRef, result.entry) : void 0;
|
|
501
|
+
if (result.outcome === "new") return {
|
|
502
|
+
outcome: "new",
|
|
503
|
+
envelope,
|
|
504
|
+
generation
|
|
505
|
+
};
|
|
506
|
+
return {
|
|
507
|
+
outcome: "resumed",
|
|
508
|
+
envelope,
|
|
509
|
+
generation,
|
|
510
|
+
supersededActive: result.supersededActive === true
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Mark the connection inactive (TTL countdown starts). Caller MUST
|
|
515
|
+
* pass the generation they captured at claim time — the store does
|
|
516
|
+
* a compare-and-set and silently no-ops if the entry has since been
|
|
517
|
+
* superseded by a higher-generation claim (i.e. another node took
|
|
518
|
+
* ownership while our close handler was pending).
|
|
519
|
+
*
|
|
520
|
+
* This closes the race the 2.17.2 review surfaced: previously, a
|
|
521
|
+
* stale release could overwrite Node B's freshly-claimed entry with
|
|
522
|
+
* Node A's outdated snapshot.
|
|
523
|
+
*
|
|
524
|
+
* Returns the outcome for callers that want to log fenced writes.
|
|
525
|
+
*/
|
|
526
|
+
async release(pushRef, expectedGeneration) {
|
|
527
|
+
const env = this.liveEnvelopes.get(pushRef);
|
|
528
|
+
const snapshot = env ? {
|
|
529
|
+
nextSeq: env.peekNextSeq(),
|
|
530
|
+
deadQueue: env.snapshotDeadQueue()
|
|
531
|
+
} : void 0;
|
|
532
|
+
const outcome = await this.store.releaseIfGen({
|
|
533
|
+
pushRef,
|
|
534
|
+
expectedGeneration,
|
|
535
|
+
...snapshot ? { snapshot } : {},
|
|
536
|
+
expiresAt: this.now() + this.ttlMs
|
|
537
|
+
});
|
|
538
|
+
this.liveEnvelopes.delete(pushRef);
|
|
539
|
+
return outcome;
|
|
540
|
+
}
|
|
541
|
+
async evict(pushRef) {
|
|
542
|
+
await this.store.delete(pushRef);
|
|
543
|
+
this.liveEnvelopes.delete(pushRef);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Stage a payload into the orphan envelope when no live socket is
|
|
547
|
+
* attached. Atomic — routes through `store.stageIfInactive` which
|
|
548
|
+
* does the read + wrap + write under a single Lua EVAL (Redis) or
|
|
549
|
+
* synchronous event-loop turn (Memory). This closes the 2.17.2
|
|
550
|
+
* stage-vs-claim race where a get-then-set could overwrite a freshly
|
|
551
|
+
* claimed higher-generation entry with the stale inactive snapshot.
|
|
552
|
+
*
|
|
553
|
+
* Returns `true` only when the payload was actually staged. `false`
|
|
554
|
+
* for any of:
|
|
555
|
+
* - entry missing (unknown / GC'd pushRef)
|
|
556
|
+
* - entry is currently active (route via room manager instead)
|
|
557
|
+
* - envelope mode is `'raw'` (no dead queue to stage into)
|
|
558
|
+
*/
|
|
559
|
+
async stageForResume(pushRef, payload) {
|
|
560
|
+
return await this.store.stageIfInactive({
|
|
561
|
+
pushRef,
|
|
562
|
+
payload,
|
|
563
|
+
timestamp: this.now(),
|
|
564
|
+
expiresAt: this.now() + this.ttlMs
|
|
565
|
+
}) === "ok";
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Generation-fenced persist. Called by the room-manager hook after
|
|
569
|
+
* every wrap() so the persisted snapshot stays in sync with the live
|
|
570
|
+
* envelope. Caller passes the connection's generation; a stale
|
|
571
|
+
* persist (after the connection was fenced) silently no-ops at the
|
|
572
|
+
* store level — never overwrites the newer owner's state.
|
|
573
|
+
*
|
|
574
|
+
* Returns the outcome for callers that want to log fenced writes.
|
|
575
|
+
*/
|
|
576
|
+
async persist(pushRef, expectedGeneration) {
|
|
577
|
+
const env = this.liveEnvelopes.get(pushRef);
|
|
578
|
+
if (!env) return "missing";
|
|
579
|
+
return this.store.persistIfGen({
|
|
580
|
+
pushRef,
|
|
581
|
+
expectedGeneration,
|
|
582
|
+
snapshot: {
|
|
583
|
+
nextSeq: env.peekNextSeq(),
|
|
584
|
+
deadQueue: env.snapshotDeadQueue()
|
|
585
|
+
},
|
|
586
|
+
expiresAt: this.now() + this.ttlMs
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
async size() {
|
|
590
|
+
return this.store.size();
|
|
591
|
+
}
|
|
592
|
+
async gc() {
|
|
593
|
+
return this.store.gc(this.now());
|
|
594
|
+
}
|
|
595
|
+
async close() {
|
|
596
|
+
this.liveEnvelopes.clear();
|
|
597
|
+
await this.store.close?.();
|
|
598
|
+
}
|
|
599
|
+
/** Diagnostics — read-only. */
|
|
600
|
+
async inspect(pushRef) {
|
|
601
|
+
return this.store.get(pushRef);
|
|
602
|
+
}
|
|
603
|
+
/** Reconstruct an EnvelopeWriter from a persisted snapshot. */
|
|
604
|
+
hydrateEnvelope(pushRef, entry) {
|
|
605
|
+
const existing = this.liveEnvelopes.get(pushRef);
|
|
606
|
+
if (existing) return existing;
|
|
607
|
+
const dq = new DeadQueue(entry.deadQueueSize);
|
|
608
|
+
for (const e of entry.deadQueue) dq.push(e);
|
|
609
|
+
const writer = new EnvelopeWriter(dq, { nextSeq: entry.nextSeq });
|
|
610
|
+
this.liveEnvelopes.set(pushRef, writer);
|
|
611
|
+
return writer;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
/**
|
|
615
|
+
* Derive a stable principal string from a connection's identity. Used
|
|
616
|
+
* as the pushRef binding key — two connections sharing a principal can
|
|
617
|
+
* resume each other's pushRef state.
|
|
618
|
+
*
|
|
619
|
+
* Returns:
|
|
620
|
+
* - `'u:<userId>'` — interactive user session
|
|
621
|
+
* - `'c:<clientId>'` — service / machine-to-machine
|
|
622
|
+
* - `'anon'` — auth disabled (dev / test)
|
|
623
|
+
* - **`null`** — auth ENABLED but no stable identity
|
|
624
|
+
* (org-only credentials, etc.) — caller
|
|
625
|
+
* MUST refuse URL pushRef claims and mint
|
|
626
|
+
* a connection-bound fresh binding.
|
|
627
|
+
*/
|
|
628
|
+
function derivePrincipal(args) {
|
|
629
|
+
if (args.userId) return `u:${args.userId}`;
|
|
630
|
+
if (args.clientId) return `c:${args.clientId}`;
|
|
631
|
+
if (!args.authMode) return "anon";
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/integrations/websocket/safe-async.ts
|
|
636
|
+
function safeAsync(p, logger, op, ctx) {
|
|
637
|
+
p.catch((err) => {
|
|
638
|
+
logger.warn({
|
|
639
|
+
err,
|
|
640
|
+
op,
|
|
641
|
+
...ctx
|
|
642
|
+
}, `[arc-websocket] async op failed: ${op}`);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Per-socket bounded queue. Construct one per accepted connection;
|
|
647
|
+
* `dispose()` on socket close to drop refs.
|
|
648
|
+
*/
|
|
649
|
+
var SendQueue = class {
|
|
650
|
+
entries = [];
|
|
651
|
+
/** `coalesceKey → entries[] index` for O(1) replacement. */
|
|
652
|
+
coalesceIndex = /* @__PURE__ */ new Map();
|
|
653
|
+
socket;
|
|
654
|
+
capacity;
|
|
655
|
+
drainThreshold;
|
|
656
|
+
retryDelayMs;
|
|
657
|
+
/** One-shot retry timer set while waiting for backpressure to clear. */
|
|
658
|
+
retryTimer;
|
|
659
|
+
disposed = false;
|
|
660
|
+
constructor(socket, opts = {}) {
|
|
661
|
+
this.socket = socket;
|
|
662
|
+
this.capacity = opts.capacity ?? 256;
|
|
663
|
+
this.drainThreshold = opts.drainThreshold ?? 262144;
|
|
664
|
+
this.retryDelayMs = opts.retryDelayMs ?? 50;
|
|
665
|
+
}
|
|
666
|
+
/** Ordered, guaranteed-best-effort delivery. Overflow ⇒ terminate. */
|
|
667
|
+
send(payload) {
|
|
668
|
+
if (this.disposed) return;
|
|
669
|
+
if (this.entries.length >= this.capacity) {
|
|
670
|
+
this.terminate("Queue overflow");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
this.entries.push({
|
|
674
|
+
payload,
|
|
675
|
+
cls: "critical"
|
|
676
|
+
});
|
|
677
|
+
this.flush();
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Fire-and-forget, droppable. With `coalesceKey`, older entry for the
|
|
681
|
+
* same key is replaced in place (preserves queue order, keeps latest
|
|
682
|
+
* payload). Without a key, oldest droppable entry is shed on overflow.
|
|
683
|
+
*/
|
|
684
|
+
sendRealtime(payload, coalesceKey) {
|
|
685
|
+
if (this.disposed) return;
|
|
686
|
+
if (coalesceKey !== void 0) {
|
|
687
|
+
const idx = this.coalesceIndex.get(coalesceKey);
|
|
688
|
+
if (idx !== void 0 && this.entries[idx]) {
|
|
689
|
+
this.entries[idx] = {
|
|
690
|
+
payload,
|
|
691
|
+
cls: "droppable",
|
|
692
|
+
coalesceKey
|
|
693
|
+
};
|
|
694
|
+
this.flush();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (this.entries.length >= this.capacity) {
|
|
699
|
+
const shedIdx = this.entries.findIndex((e) => e.cls === "droppable");
|
|
700
|
+
if (shedIdx === -1) return;
|
|
701
|
+
const evicted = this.entries.splice(shedIdx, 1)[0];
|
|
702
|
+
if (evicted?.coalesceKey !== void 0) this.coalesceIndex.delete(evicted.coalesceKey);
|
|
703
|
+
this.reindexCoalesceFrom(shedIdx);
|
|
704
|
+
}
|
|
705
|
+
this.entries.push({
|
|
706
|
+
payload,
|
|
707
|
+
cls: "droppable",
|
|
708
|
+
coalesceKey
|
|
709
|
+
});
|
|
710
|
+
if (coalesceKey !== void 0) this.coalesceIndex.set(coalesceKey, this.entries.length - 1);
|
|
711
|
+
this.flush();
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Drain entries to the socket until either the queue empties or the
|
|
715
|
+
* socket's outbound buffer crosses the drain threshold. Idempotent —
|
|
716
|
+
* also runs on a self-scheduled retry timer when backpressure pauses
|
|
717
|
+
* draining (so callers never need to manually re-prod the queue).
|
|
718
|
+
*/
|
|
719
|
+
flush() {
|
|
720
|
+
if (this.disposed) return;
|
|
721
|
+
while (this.entries.length > 0 && this.socket.readyState === 1) {
|
|
722
|
+
if ((this.socket.bufferedAmount ?? 0) >= this.drainThreshold) {
|
|
723
|
+
this.scheduleRetry();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const entry = this.entries.shift();
|
|
727
|
+
if (!entry) return;
|
|
728
|
+
if (entry.coalesceKey !== void 0) this.coalesceIndex.delete(entry.coalesceKey);
|
|
729
|
+
if (this.coalesceIndex.size > 0) this.reindexCoalesceFrom(0);
|
|
730
|
+
try {
|
|
731
|
+
this.socket.send(entry.payload);
|
|
732
|
+
} catch {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
size() {
|
|
738
|
+
return this.entries.length;
|
|
739
|
+
}
|
|
740
|
+
dispose() {
|
|
741
|
+
this.disposed = true;
|
|
742
|
+
this.entries.length = 0;
|
|
743
|
+
this.coalesceIndex.clear();
|
|
744
|
+
if (this.retryTimer) {
|
|
745
|
+
clearTimeout(this.retryTimer);
|
|
746
|
+
this.retryTimer = void 0;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* One-shot retry timer. Called when `flush()` hits backpressure and
|
|
751
|
+
* leaves entries queued — without this, queued frames would stall
|
|
752
|
+
* indefinitely after the socket's buffer drains, because nothing
|
|
753
|
+
* else prods the queue. The timer self-clears on each fire so a
|
|
754
|
+
* single delay is the worst case between drain and resume.
|
|
755
|
+
*/
|
|
756
|
+
scheduleRetry() {
|
|
757
|
+
if (this.retryTimer !== void 0 || this.disposed) return;
|
|
758
|
+
this.retryTimer = setTimeout(() => {
|
|
759
|
+
this.retryTimer = void 0;
|
|
760
|
+
this.flush();
|
|
761
|
+
}, this.retryDelayMs);
|
|
762
|
+
this.retryTimer.unref?.();
|
|
763
|
+
}
|
|
764
|
+
terminate(reason) {
|
|
765
|
+
this.dispose();
|
|
766
|
+
if (typeof this.socket.terminate === "function") this.socket.terminate();
|
|
767
|
+
else if (typeof this.socket.close === "function") this.socket.close(1011, reason);
|
|
768
|
+
}
|
|
769
|
+
/** Rebuild the coalesce index from `start` onward after a splice/shift. */
|
|
770
|
+
reindexCoalesceFrom(start) {
|
|
771
|
+
for (let i = start; i < this.entries.length; i++) {
|
|
772
|
+
const k = this.entries[i]?.coalesceKey;
|
|
773
|
+
if (k !== void 0) this.coalesceIndex.set(k, i);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
};
|
|
80
777
|
//#endregion
|
|
81
778
|
//#region src/integrations/websocket/connection.ts
|
|
779
|
+
/**
|
|
780
|
+
* Per-connection lifecycle handler.
|
|
781
|
+
*
|
|
782
|
+
* Runs for every WebSocket upgrade accepted by the plugin's Fastify route.
|
|
783
|
+
* Orchestrates the 5-phase lifecycle:
|
|
784
|
+
*
|
|
785
|
+
* 1. authenticate (handshake) → accept or close(4001)
|
|
786
|
+
* 2. register client, fire onConnect
|
|
787
|
+
* 3. start heartbeat + reauth timers
|
|
788
|
+
* 4. wire message handler (subscribe / unsubscribe / pong / custom)
|
|
789
|
+
* 5. cleanup on close/error (clear timers, fire onDisconnect, remove from rooms)
|
|
790
|
+
*
|
|
791
|
+
* Keeps the plugin orchestrator (`plugin.ts`) thin — it just wires options,
|
|
792
|
+
* creates a `RoomManager`, subscribes to the event bus, and hands each
|
|
793
|
+
* connection off to `handleConnection`.
|
|
794
|
+
*
|
|
795
|
+
* The `socket` / `request` parameter types are `unknown`-boxed because
|
|
796
|
+
* @fastify/websocket isn't a typecheck-time dependency. The shapes used
|
|
797
|
+
* are documented inline.
|
|
798
|
+
*/
|
|
799
|
+
/** Extract a client-supplied pushRef from the upgrade URL, if any. */
|
|
800
|
+
function readPushRefFromRequest(request) {
|
|
801
|
+
const req = request;
|
|
802
|
+
const fromQuery = req?.query?.pushRef;
|
|
803
|
+
if (typeof fromQuery === "string" && fromQuery.length > 0 && fromQuery.length <= 128) return fromQuery;
|
|
804
|
+
if (req?.url) try {
|
|
805
|
+
const v = new URL(req.url, "http://x").searchParams.get("pushRef");
|
|
806
|
+
if (v && v.length > 0 && v.length <= 128) return v;
|
|
807
|
+
} catch {}
|
|
808
|
+
}
|
|
82
809
|
async function handleConnection(ctx, socket, request) {
|
|
83
810
|
const { fastify, rooms, options } = ctx;
|
|
84
811
|
const clientId = ctx.nextClientId();
|
|
@@ -97,28 +824,108 @@ async function handleConnection(ctx, socket, request) {
|
|
|
97
824
|
serviceClientId = result.clientId;
|
|
98
825
|
serviceScopes = result.scopes;
|
|
99
826
|
}
|
|
827
|
+
const principal = derivePrincipal({
|
|
828
|
+
userId,
|
|
829
|
+
clientId: serviceClientId,
|
|
830
|
+
authMode: options.auth
|
|
831
|
+
});
|
|
832
|
+
options.messageEnvelope;
|
|
833
|
+
const urlPushRef = readPushRefFromRequest(request);
|
|
834
|
+
let pushRef;
|
|
835
|
+
let envelope;
|
|
836
|
+
let resumed = false;
|
|
837
|
+
/**
|
|
838
|
+
* Connection generation — monotonic per-pushRef counter the registry
|
|
839
|
+
* bumps on every claim. Used by the fence path: after we claim with
|
|
840
|
+
* a higher generation than any prior live owner, we publish
|
|
841
|
+
* `fence:<pushRef>` so OTHER nodes close their stale socket too.
|
|
842
|
+
*/
|
|
843
|
+
let generation = 1;
|
|
844
|
+
let supersededActive = false;
|
|
845
|
+
const claimOpts = {
|
|
846
|
+
envelopeMode: options.messageEnvelope,
|
|
847
|
+
deadQueueSize: options.deadQueueSize
|
|
848
|
+
};
|
|
849
|
+
if (principal === null) {
|
|
850
|
+
pushRef = `ps_${randomUUID()}`;
|
|
851
|
+
const fresh = await ctx.pushRefRegistry.claim(pushRef, `conn:${pushRef}`, claimOpts);
|
|
852
|
+
if (fresh.outcome === "rejected") {
|
|
853
|
+
socket.close(1011, "pushRef registry capacity");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
envelope = fresh.envelope;
|
|
857
|
+
if (fresh.outcome === "new" || fresh.outcome === "resumed") generation = fresh.generation;
|
|
858
|
+
} else if (urlPushRef !== void 0) {
|
|
859
|
+
const claim = await ctx.pushRefRegistry.claim(urlPushRef, principal, claimOpts);
|
|
860
|
+
if (claim.outcome === "rejected") {
|
|
861
|
+
pushRef = `ps_${randomUUID()}`;
|
|
862
|
+
const fresh = await ctx.pushRefRegistry.claim(pushRef, principal, claimOpts);
|
|
863
|
+
if (fresh.outcome === "rejected") {
|
|
864
|
+
socket.close(1011, "pushRef registry capacity");
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
envelope = fresh.envelope;
|
|
868
|
+
if (fresh.outcome === "new" || fresh.outcome === "resumed") generation = fresh.generation;
|
|
869
|
+
} else {
|
|
870
|
+
pushRef = urlPushRef;
|
|
871
|
+
envelope = claim.envelope;
|
|
872
|
+
resumed = claim.outcome === "resumed";
|
|
873
|
+
generation = claim.generation;
|
|
874
|
+
if (claim.outcome === "resumed") supersededActive = claim.supersededActive;
|
|
875
|
+
}
|
|
876
|
+
} else {
|
|
877
|
+
pushRef = `ps_${randomUUID()}`;
|
|
878
|
+
const fresh = await ctx.pushRefRegistry.claim(pushRef, principal, claimOpts);
|
|
879
|
+
if (fresh.outcome === "rejected") {
|
|
880
|
+
socket.close(1011, "pushRef registry capacity");
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
envelope = fresh.envelope;
|
|
884
|
+
if (fresh.outcome === "new" || fresh.outcome === "resumed") generation = fresh.generation;
|
|
885
|
+
}
|
|
886
|
+
if (supersededActive) {
|
|
887
|
+
const stale = rooms.getClientByPushRef(pushRef);
|
|
888
|
+
if (stale && stale.socket !== socket) stale.socket.close(4011, "Superseded by newer connection");
|
|
889
|
+
ctx.fence?.(pushRef, generation);
|
|
890
|
+
}
|
|
891
|
+
const queue = new SendQueue(socket);
|
|
100
892
|
const client = {
|
|
101
893
|
id: clientId,
|
|
894
|
+
pushRef,
|
|
102
895
|
socket,
|
|
103
896
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
104
897
|
userId,
|
|
105
898
|
organizationId,
|
|
106
899
|
...serviceClientId ? { clientId: serviceClientId } : {},
|
|
107
|
-
...serviceScopes ? { scopes: serviceScopes } : {}
|
|
900
|
+
...serviceScopes ? { scopes: serviceScopes } : {},
|
|
901
|
+
queue,
|
|
902
|
+
...envelope ? { envelope } : {},
|
|
903
|
+
generation
|
|
108
904
|
};
|
|
109
905
|
rooms.addClient(client);
|
|
110
906
|
await options.onConnect?.(client);
|
|
111
907
|
socket.send(JSON.stringify({
|
|
112
908
|
type: "connected",
|
|
113
909
|
clientId,
|
|
114
|
-
|
|
910
|
+
pushRef,
|
|
911
|
+
resources: options.resources,
|
|
912
|
+
...envelope ? { envelope: "seq" } : {},
|
|
913
|
+
...resumed ? { resumed: true } : {}
|
|
115
914
|
}));
|
|
116
915
|
let heartbeatTimer;
|
|
916
|
+
let isAlive = true;
|
|
917
|
+
socket.on("pong", () => {
|
|
918
|
+
isAlive = true;
|
|
919
|
+
});
|
|
117
920
|
if (options.heartbeatInterval > 0) heartbeatTimer = setInterval(() => {
|
|
118
|
-
if (socket.readyState
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
921
|
+
if (socket.readyState !== 1) return;
|
|
922
|
+
if (!isAlive) {
|
|
923
|
+
if (typeof socket.terminate === "function") socket.terminate();
|
|
924
|
+
else socket.close(4008, "Heartbeat timeout");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
isAlive = false;
|
|
928
|
+
if (typeof socket.ping === "function") socket.ping();
|
|
122
929
|
}, options.heartbeatInterval);
|
|
123
930
|
let reauthTimer;
|
|
124
931
|
if (options.reauthInterval > 0 && options.auth) reauthTimer = setInterval(async () => {
|
|
@@ -190,7 +997,34 @@ async function handleConnection(ctx, socket, request) {
|
|
|
190
997
|
}
|
|
191
998
|
break;
|
|
192
999
|
}
|
|
193
|
-
case "
|
|
1000
|
+
case "resume": {
|
|
1001
|
+
const raw = msg;
|
|
1002
|
+
const lastSeq = typeof raw.lastSeq === "number" ? raw.lastSeq : -1;
|
|
1003
|
+
if (!envelope) {
|
|
1004
|
+
socket.send(JSON.stringify({
|
|
1005
|
+
type: "resumed",
|
|
1006
|
+
lastSeq,
|
|
1007
|
+
replayed: 0
|
|
1008
|
+
}));
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
const replay = envelope.drainAfter(lastSeq);
|
|
1012
|
+
if (replay === null) {
|
|
1013
|
+
socket.send(JSON.stringify({
|
|
1014
|
+
type: "resume_gap",
|
|
1015
|
+
lastSeq,
|
|
1016
|
+
highestSeq: envelope.highestSeq()
|
|
1017
|
+
}));
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
for (const payload of replay) socket.send(payload);
|
|
1021
|
+
socket.send(JSON.stringify({
|
|
1022
|
+
type: "resumed",
|
|
1023
|
+
lastSeq,
|
|
1024
|
+
replayed: replay.length
|
|
1025
|
+
}));
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
194
1028
|
default:
|
|
195
1029
|
await options.onMessage?.(client, msg);
|
|
196
1030
|
break;
|
|
@@ -199,13 +1033,25 @@ async function handleConnection(ctx, socket, request) {
|
|
|
199
1033
|
socket.on("close", async () => {
|
|
200
1034
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
201
1035
|
if (reauthTimer) clearInterval(reauthTimer);
|
|
202
|
-
|
|
203
|
-
|
|
1036
|
+
if (rooms.getClient(clientId)?.socket === socket) {
|
|
1037
|
+
await options.onDisconnect?.(client);
|
|
1038
|
+
rooms.removeClient(clientId);
|
|
1039
|
+
}
|
|
1040
|
+
queue.dispose();
|
|
1041
|
+
if (!rooms.getClientByPushRef(pushRef)) safeAsync(ctx.pushRefRegistry.release(pushRef, generation), fastify.log, "registry.release", {
|
|
1042
|
+
pushRef,
|
|
1043
|
+
generation
|
|
1044
|
+
});
|
|
204
1045
|
});
|
|
205
1046
|
socket.on("error", () => {
|
|
206
1047
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
207
1048
|
if (reauthTimer) clearInterval(reauthTimer);
|
|
208
|
-
rooms.removeClient(clientId);
|
|
1049
|
+
if (rooms.getClient(clientId)?.socket === socket) rooms.removeClient(clientId);
|
|
1050
|
+
queue.dispose();
|
|
1051
|
+
if (!rooms.getClientByPushRef(pushRef)) safeAsync(ctx.pushRefRegistry.release(pushRef, generation), fastify.log, "registry.release", {
|
|
1052
|
+
pushRef,
|
|
1053
|
+
generation
|
|
1054
|
+
});
|
|
209
1055
|
});
|
|
210
1056
|
}
|
|
211
1057
|
//#endregion
|
|
@@ -269,18 +1115,105 @@ function registerStatsRoute(fastify, rooms, path, expose) {
|
|
|
269
1115
|
} else fastify.log.warn("arc-websocket: exposeStats is \"authenticated\" but fastify.authenticate is not registered — stats endpoint skipped");
|
|
270
1116
|
}
|
|
271
1117
|
//#endregion
|
|
1118
|
+
//#region src/integrations/websocket/outbound-truncate.ts
|
|
1119
|
+
/**
|
|
1120
|
+
* Outbound payload truncation — mirrors n8n's pattern of capping any
|
|
1121
|
+
* single server→client frame at a sane upper bound, replacing oversize
|
|
1122
|
+
* payloads with a placeholder envelope the client knows to handle.
|
|
1123
|
+
*
|
|
1124
|
+
* The 2.17.2 review (NEW-LOW) flagged that an earlier draft used a
|
|
1125
|
+
* module-global cap mutated by `resetMaxOutboundBytes(maxOutboundBytes)`
|
|
1126
|
+
* at plugin construction. Two simultaneous plugin registrations with
|
|
1127
|
+
* different caps would surprise the host (last-write-wins). The
|
|
1128
|
+
* current shape is purely instance-scoped: every plugin instance owns
|
|
1129
|
+
* its own `truncate` closure, callers pass the cap at the call site.
|
|
1130
|
+
*
|
|
1131
|
+
* - `truncateOutbound(json, max)` — cheap byte check; returns the
|
|
1132
|
+
* placeholder JSON when oversize.
|
|
1133
|
+
*
|
|
1134
|
+
* - `createTruncator(max)` — convenience: returns a function bound
|
|
1135
|
+
* to a specific cap. The plugin holds one of these per registered
|
|
1136
|
+
* instance, eliminating module state.
|
|
1137
|
+
*/
|
|
1138
|
+
/** 5 MiB default — same as n8n's `relayViaPubSub` ceiling. */
|
|
1139
|
+
const DEFAULT_MAX_OUTBOUND_BYTES = 5 * 1024 * 1024;
|
|
1140
|
+
/**
|
|
1141
|
+
* Returns either the original payload (when within `max`) or a
|
|
1142
|
+
* placeholder JSON string (when oversize). The placeholder carries the
|
|
1143
|
+
* original size so the client can surface a meaningful error / retry.
|
|
1144
|
+
*
|
|
1145
|
+
* Byte length is measured via Buffer.byteLength (UTF-8) — same metric
|
|
1146
|
+
* the underlying WebSocket frame uses for the payload-length header.
|
|
1147
|
+
*/
|
|
1148
|
+
function truncateOutbound(payload, max = DEFAULT_MAX_OUTBOUND_BYTES) {
|
|
1149
|
+
const bytes = Buffer.byteLength(payload, "utf8");
|
|
1150
|
+
if (bytes <= max) return payload;
|
|
1151
|
+
return JSON.stringify({
|
|
1152
|
+
type: "truncated",
|
|
1153
|
+
originalBytes: bytes,
|
|
1154
|
+
maxBytes: max,
|
|
1155
|
+
hint: "Server-side payload exceeded the outbound cap. Refetch via REST or narrow your subscription filter."
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Bind a cap into a closure — the plugin uses one of these per
|
|
1160
|
+
* registered instance so no module state crosses plugin boundaries.
|
|
1161
|
+
* Hosts running two WebSocket plugins with different caps get the
|
|
1162
|
+
* exact cap each one declared, not last-writer-wins.
|
|
1163
|
+
*/
|
|
1164
|
+
function createTruncator(max = DEFAULT_MAX_OUTBOUND_BYTES) {
|
|
1165
|
+
return (payload) => truncateOutbound(payload, max);
|
|
1166
|
+
}
|
|
1167
|
+
//#endregion
|
|
272
1168
|
//#region src/integrations/websocket/room-manager.ts
|
|
1169
|
+
/**
|
|
1170
|
+
* RoomManager — subscription bookkeeping for the WebSocket plugin.
|
|
1171
|
+
*
|
|
1172
|
+
* Every connected client lives in zero or more "rooms" (resource channels,
|
|
1173
|
+
* arbitrary topic names). The manager indexes in both directions:
|
|
1174
|
+
* - `rooms: room → Set<clientId>` for fan-out
|
|
1175
|
+
* - `clients: clientId → WebSocketClient` for lookup + cleanup
|
|
1176
|
+
*
|
|
1177
|
+
* Local broadcast delivers to sockets on THIS instance; adapter-aware
|
|
1178
|
+
* variants also publish through the optional cross-instance adapter so
|
|
1179
|
+
* other instances fan out to their local sockets.
|
|
1180
|
+
*/
|
|
1181
|
+
/** Drop fan-out to a client whose outbound buffer exceeds this threshold. */
|
|
1182
|
+
const WS_BACKPRESSURE_LIMIT = 256 * 1024;
|
|
273
1183
|
var RoomManager = class {
|
|
274
1184
|
rooms = /* @__PURE__ */ new Map();
|
|
275
1185
|
clients = /* @__PURE__ */ new Map();
|
|
1186
|
+
/**
|
|
1187
|
+
* Secondary index: `pushRef → clientId`. A pushRef is stable across
|
|
1188
|
+
* reconnects (one per browser tab/device), where `clientId` is per-socket.
|
|
1189
|
+
* Lets the host send to a logical session ("this tab") without caring
|
|
1190
|
+
* which TCP connection currently backs it.
|
|
1191
|
+
*/
|
|
1192
|
+
clientIdByPushRef = /* @__PURE__ */ new Map();
|
|
1193
|
+
/**
|
|
1194
|
+
* Tertiary index: `userId → Set<clientId>`. Lets `sendToUser(userId, msg)`
|
|
1195
|
+
* fan out to every open tab/device for that user without scanning the
|
|
1196
|
+
* full client set.
|
|
1197
|
+
*/
|
|
1198
|
+
clientIdsByUserId = /* @__PURE__ */ new Map();
|
|
276
1199
|
maxPerRoom;
|
|
277
1200
|
adapter;
|
|
278
|
-
|
|
1201
|
+
registry;
|
|
1202
|
+
logger = { warn: () => {} };
|
|
1203
|
+
constructor(maxPerRoom = 1e4, adapter, registry, logger) {
|
|
279
1204
|
this.maxPerRoom = maxPerRoom;
|
|
280
1205
|
this.adapter = adapter;
|
|
1206
|
+
this.registry = registry;
|
|
1207
|
+
if (logger) this.logger = logger;
|
|
281
1208
|
}
|
|
282
1209
|
addClient(client) {
|
|
283
1210
|
this.clients.set(client.id, client);
|
|
1211
|
+
this.clientIdByPushRef.set(client.pushRef, client.id);
|
|
1212
|
+
if (client.userId) {
|
|
1213
|
+
const set = this.clientIdsByUserId.get(client.userId) ?? /* @__PURE__ */ new Set();
|
|
1214
|
+
set.add(client.id);
|
|
1215
|
+
this.clientIdsByUserId.set(client.userId, set);
|
|
1216
|
+
}
|
|
284
1217
|
}
|
|
285
1218
|
removeClient(clientId) {
|
|
286
1219
|
const client = this.clients.get(clientId);
|
|
@@ -292,9 +1225,33 @@ var RoomManager = class {
|
|
|
292
1225
|
if (members.size === 0) this.rooms.delete(room);
|
|
293
1226
|
}
|
|
294
1227
|
}
|
|
1228
|
+
if (this.clientIdByPushRef.get(client.pushRef) === clientId) this.clientIdByPushRef.delete(client.pushRef);
|
|
1229
|
+
if (client.userId) {
|
|
1230
|
+
const set = this.clientIdsByUserId.get(client.userId);
|
|
1231
|
+
if (set) {
|
|
1232
|
+
set.delete(clientId);
|
|
1233
|
+
if (set.size === 0) this.clientIdsByUserId.delete(client.userId);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
295
1236
|
client.subscriptions.clear();
|
|
296
1237
|
this.clients.delete(clientId);
|
|
297
1238
|
}
|
|
1239
|
+
/** Resolve a pushRef to its current backing client, if any. */
|
|
1240
|
+
getClientByPushRef(pushRef) {
|
|
1241
|
+
const id = this.clientIdByPushRef.get(pushRef);
|
|
1242
|
+
return id ? this.clients.get(id) : void 0;
|
|
1243
|
+
}
|
|
1244
|
+
/** Snapshot of all clients currently owned by `userId`. */
|
|
1245
|
+
getClientsByUserId(userId) {
|
|
1246
|
+
const ids = this.clientIdsByUserId.get(userId);
|
|
1247
|
+
if (!ids) return [];
|
|
1248
|
+
const out = [];
|
|
1249
|
+
for (const id of ids) {
|
|
1250
|
+
const c = this.clients.get(id);
|
|
1251
|
+
if (c) out.push(c);
|
|
1252
|
+
}
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
298
1255
|
subscribe(clientId, room) {
|
|
299
1256
|
const client = this.clients.get(clientId);
|
|
300
1257
|
if (!client) return false;
|
|
@@ -318,10 +1275,37 @@ var RoomManager = class {
|
|
|
318
1275
|
broadcast(room, message, excludeClientId) {
|
|
319
1276
|
const members = this.rooms.get(room);
|
|
320
1277
|
if (!members) return;
|
|
1278
|
+
const dead = [];
|
|
321
1279
|
for (const clientId of members) {
|
|
322
1280
|
if (clientId === excludeClientId) continue;
|
|
323
1281
|
const client = this.clients.get(clientId);
|
|
324
|
-
if (client
|
|
1282
|
+
if (!client || client.socket.readyState !== 1) continue;
|
|
1283
|
+
if ((client.socket.bufferedAmount ?? 0) > WS_BACKPRESSURE_LIMIT) continue;
|
|
1284
|
+
try {
|
|
1285
|
+
client.socket.send(message);
|
|
1286
|
+
} catch {
|
|
1287
|
+
dead.push(clientId);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
for (const id of dead) this.removeClient(id);
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Droppable, coalesce-by-key broadcast to a room. Mirrors `broadcast()`
|
|
1294
|
+
* but routes through each client's `SendQueue.sendRealtime` so high-
|
|
1295
|
+
* frequency streams (GPS, cursors, presence) collapse to one queued
|
|
1296
|
+
* frame per recipient.
|
|
1297
|
+
*
|
|
1298
|
+
* Used by `GeoRoom.publish` for hyperlocal fan-out where the latest
|
|
1299
|
+
* snapshot is the only one that matters.
|
|
1300
|
+
*/
|
|
1301
|
+
broadcastRealtime(room, message, coalesceKey) {
|
|
1302
|
+
const members = this.rooms.get(room);
|
|
1303
|
+
if (!members) return;
|
|
1304
|
+
for (const clientId of members) {
|
|
1305
|
+
const client = this.clients.get(clientId);
|
|
1306
|
+
if (!client || client.socket.readyState !== 1) continue;
|
|
1307
|
+
if (client.queue) client.queue.sendRealtime(message, coalesceKey);
|
|
1308
|
+
else if ((client.socket.bufferedAmount ?? 0) <= WS_BACKPRESSURE_LIMIT) try {
|
|
325
1309
|
client.socket.send(message);
|
|
326
1310
|
} catch {}
|
|
327
1311
|
}
|
|
@@ -329,12 +1313,18 @@ var RoomManager = class {
|
|
|
329
1313
|
broadcastToOrg(organizationId, room, message) {
|
|
330
1314
|
const members = this.rooms.get(room);
|
|
331
1315
|
if (!members) return;
|
|
1316
|
+
const dead = [];
|
|
332
1317
|
for (const clientId of members) {
|
|
333
1318
|
const client = this.clients.get(clientId);
|
|
334
|
-
if (client
|
|
1319
|
+
if (!client || client.organizationId !== organizationId || client.socket.readyState !== 1) continue;
|
|
1320
|
+
if ((client.socket.bufferedAmount ?? 0) > WS_BACKPRESSURE_LIMIT) continue;
|
|
1321
|
+
try {
|
|
335
1322
|
client.socket.send(message);
|
|
336
|
-
} catch {
|
|
1323
|
+
} catch {
|
|
1324
|
+
dead.push(clientId);
|
|
1325
|
+
}
|
|
337
1326
|
}
|
|
1327
|
+
for (const id of dead) this.removeClient(id);
|
|
338
1328
|
}
|
|
339
1329
|
/**
|
|
340
1330
|
* Broadcast locally AND through adapter (for cross-instance delivery).
|
|
@@ -353,6 +1343,84 @@ var RoomManager = class {
|
|
|
353
1343
|
this.broadcastToOrg(organizationId, room, message);
|
|
354
1344
|
if (this.adapter) await this.adapter.publish(`org:${organizationId}:${room}`, message);
|
|
355
1345
|
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Addressed sends across instances. Wire format reuses the existing
|
|
1348
|
+
* room-prefix convention (`pushref:<id>`, `userid:<id>`) so the adapter
|
|
1349
|
+
* interface stays unchanged. Receiver-side plugin code dispatches on the
|
|
1350
|
+
* prefix and calls `sendToPushRef` / `sendToUser` locally.
|
|
1351
|
+
*
|
|
1352
|
+
* Critical guarantee (n8n shouldRelayViaPubSub pattern): the local call
|
|
1353
|
+
* is a no-op on every instance EXCEPT the one that owns the pushRef —
|
|
1354
|
+
* the address lookup itself is the ownership check.
|
|
1355
|
+
*/
|
|
1356
|
+
async sendToPushRefWithAdapter(pushRef, message) {
|
|
1357
|
+
this.sendToPushRef(pushRef, message);
|
|
1358
|
+
if (this.adapter) await this.adapter.publish(`pushref:${pushRef}`, message);
|
|
1359
|
+
}
|
|
1360
|
+
async sendRealtimeToPushRefWithAdapter(pushRef, message, coalesceKey) {
|
|
1361
|
+
this.sendRealtimeToPushRef(pushRef, message, coalesceKey);
|
|
1362
|
+
if (this.adapter) await this.adapter.publish(`pushref-rt:${pushRef}`, message);
|
|
1363
|
+
}
|
|
1364
|
+
async sendToUserWithAdapter(userId, message) {
|
|
1365
|
+
this.sendToUser(userId, message);
|
|
1366
|
+
if (this.adapter) await this.adapter.publish(`userid:${userId}`, message);
|
|
1367
|
+
}
|
|
1368
|
+
async sendRealtimeToUserWithAdapter(userId, message, coalesceKey) {
|
|
1369
|
+
this.sendRealtimeToUser(userId, message, coalesceKey);
|
|
1370
|
+
if (this.adapter) await this.adapter.publish(`userid-rt:${userId}`, message);
|
|
1371
|
+
}
|
|
1372
|
+
/** Ordered send to a single pushRef. Returns true if delivered/queued. */
|
|
1373
|
+
sendToPushRef(pushRef, message) {
|
|
1374
|
+
const c = this.getClientByPushRef(pushRef);
|
|
1375
|
+
return c ? this.deliver(c, message, "critical") : false;
|
|
1376
|
+
}
|
|
1377
|
+
/** Droppable send to a single pushRef. */
|
|
1378
|
+
sendRealtimeToPushRef(pushRef, message, coalesceKey) {
|
|
1379
|
+
const c = this.getClientByPushRef(pushRef);
|
|
1380
|
+
return c ? this.deliver(c, message, "droppable", coalesceKey) : false;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Ordered fan-out to every open connection for `userId` (all tabs/devices).
|
|
1384
|
+
* Returns the count successfully queued.
|
|
1385
|
+
*/
|
|
1386
|
+
sendToUser(userId, message) {
|
|
1387
|
+
let n = 0;
|
|
1388
|
+
for (const c of this.getClientsByUserId(userId)) if (this.deliver(c, message, "critical")) n++;
|
|
1389
|
+
return n;
|
|
1390
|
+
}
|
|
1391
|
+
/** Droppable fan-out to every open connection for `userId`. */
|
|
1392
|
+
sendRealtimeToUser(userId, message, coalesceKey) {
|
|
1393
|
+
let n = 0;
|
|
1394
|
+
for (const c of this.getClientsByUserId(userId)) if (this.deliver(c, message, "droppable", coalesceKey)) n++;
|
|
1395
|
+
return n;
|
|
1396
|
+
}
|
|
1397
|
+
deliver(client, message, cls, coalesceKey) {
|
|
1398
|
+
if (client.socket.readyState !== 1) return false;
|
|
1399
|
+
const queue = client.queue;
|
|
1400
|
+
if (queue) {
|
|
1401
|
+
if (cls === "critical") if (client.envelope) try {
|
|
1402
|
+
const parsed = JSON.parse(message);
|
|
1403
|
+
queue.send(client.envelope.wrap(parsed));
|
|
1404
|
+
if (this.registry) safeAsync(this.registry.persist(client.pushRef, client.generation), this.logger, "registry.persist", {
|
|
1405
|
+
pushRef: client.pushRef,
|
|
1406
|
+
generation: client.generation
|
|
1407
|
+
});
|
|
1408
|
+
} catch {
|
|
1409
|
+
queue.send(message);
|
|
1410
|
+
}
|
|
1411
|
+
else queue.send(message);
|
|
1412
|
+
else queue.sendRealtime(message, coalesceKey);
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
if ((client.socket.bufferedAmount ?? 0) > WS_BACKPRESSURE_LIMIT) return false;
|
|
1416
|
+
try {
|
|
1417
|
+
client.socket.send(message);
|
|
1418
|
+
return true;
|
|
1419
|
+
} catch {
|
|
1420
|
+
this.removeClient(client.id);
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
356
1424
|
getClient(clientId) {
|
|
357
1425
|
return this.clients.get(clientId);
|
|
358
1426
|
}
|
|
@@ -369,9 +1437,21 @@ var RoomManager = class {
|
|
|
369
1437
|
//#endregion
|
|
370
1438
|
//#region src/integrations/websocket/plugin.ts
|
|
371
1439
|
const websocketPluginImpl = async (fastify, options) => {
|
|
372
|
-
const { path = "/ws", auth = true, resources = [], heartbeatInterval = 3e4, authenticate: customAuth, maxClientsPerRoom = 1e4, roomPolicy, maxMessageBytes = 16384, maxSubscriptionsPerClient = 100, reauthInterval = 0, adapter, exposeStats = false, onMessage, onConnect, onDisconnect } = options;
|
|
1440
|
+
const { path = "/ws", auth = true, resources = [], heartbeatInterval = 3e4, authenticate: customAuth, maxClientsPerRoom = 1e4, roomPolicy, maxMessageBytes = 16384, maxSubscriptionsPerClient = 100, reauthInterval = 0, adapter, exposeStats = false, onMessage, onConnect, onDisconnect, messageEnvelope = "raw", deadQueueSize = 128, pushRefTtlMs = 6e4, pushRefMaxEntries = 1e4, maxOutboundBytes = DEFAULT_MAX_OUTBOUND_BYTES } = options;
|
|
1441
|
+
const truncate = createTruncator(maxOutboundBytes);
|
|
1442
|
+
if (!fastify.hasDecorator("websocketServer")) try {
|
|
1443
|
+
const wsPlugin = await import("@fastify/websocket");
|
|
1444
|
+
await fastify.register(wsPlugin.default ?? wsPlugin);
|
|
1445
|
+
} catch {
|
|
1446
|
+
throw new Error("[arc-websocket] WebSocket support requires @fastify/websocket.\nInstall it: npm install @fastify/websocket");
|
|
1447
|
+
}
|
|
373
1448
|
if (auth && !customAuth && !fastify.hasDecorator("authenticate")) throw new Error("[arc-websocket] auth is true but fastify.authenticate is not registered. Register an auth plugin before WebSocket, provide a custom authenticate function, or set auth: false.");
|
|
374
|
-
const
|
|
1449
|
+
const pushRefRegistry = new PushRefRegistry({
|
|
1450
|
+
ttlMs: pushRefTtlMs,
|
|
1451
|
+
maxEntries: pushRefMaxEntries,
|
|
1452
|
+
...options.pushRefStore ? { store: options.pushRefStore } : {}
|
|
1453
|
+
});
|
|
1454
|
+
const rooms = new RoomManager(maxClientsPerRoom, adapter ?? new LocalWebSocketAdapter(), pushRefRegistry, fastify.log);
|
|
375
1455
|
if (adapter) await adapter.subscribe((room, message) => {
|
|
376
1456
|
if (room.startsWith("org:")) {
|
|
377
1457
|
const parts = room.split(":");
|
|
@@ -379,26 +1459,59 @@ const websocketPluginImpl = async (fastify, options) => {
|
|
|
379
1459
|
if (!orgId) return;
|
|
380
1460
|
const actualRoom = parts.slice(2).join(":");
|
|
381
1461
|
rooms.broadcastToOrg(orgId, actualRoom, message);
|
|
382
|
-
} else rooms.
|
|
1462
|
+
} else if (room.startsWith("pushref-rt:")) rooms.sendRealtimeToPushRef(room.slice(11), message);
|
|
1463
|
+
else if (room.startsWith("pushref:")) {
|
|
1464
|
+
const id = room.slice(8);
|
|
1465
|
+
if (!rooms.sendToPushRef(id, message)) try {
|
|
1466
|
+
safeAsync(pushRefRegistry.stageForResume(id, JSON.parse(message)), fastify.log, "registry.stageForResume.relay", { pushRef: id });
|
|
1467
|
+
} catch {}
|
|
1468
|
+
} else if (room.startsWith("fence:")) {
|
|
1469
|
+
const id = room.slice(6);
|
|
1470
|
+
const stale = rooms.getClientByPushRef(id);
|
|
1471
|
+
if (stale) stale.socket.close(4011, "Superseded by newer connection");
|
|
1472
|
+
} else if (room.startsWith("userid-rt:")) rooms.sendRealtimeToUser(room.slice(10), message);
|
|
1473
|
+
else if (room.startsWith("userid:")) rooms.sendToUser(room.slice(7), message);
|
|
1474
|
+
else rooms.broadcast(room, message);
|
|
383
1475
|
});
|
|
384
1476
|
if (!fastify.hasDecorator("ws")) fastify.decorate("ws", {
|
|
385
1477
|
rooms,
|
|
386
1478
|
broadcast: (room, data) => {
|
|
387
|
-
const msg = JSON.stringify({
|
|
1479
|
+
const msg = truncate(JSON.stringify({
|
|
388
1480
|
type: "broadcast",
|
|
389
1481
|
channel: room,
|
|
390
1482
|
data
|
|
391
|
-
});
|
|
1483
|
+
}));
|
|
392
1484
|
rooms.broadcastWithAdapter(room, msg);
|
|
393
1485
|
},
|
|
394
1486
|
broadcastToOrg: (orgId, room, data) => {
|
|
395
|
-
const msg = JSON.stringify({
|
|
1487
|
+
const msg = truncate(JSON.stringify({
|
|
396
1488
|
type: "broadcast",
|
|
397
1489
|
channel: room,
|
|
398
1490
|
data
|
|
399
|
-
});
|
|
1491
|
+
}));
|
|
400
1492
|
rooms.broadcastToOrgWithAdapter(orgId, room, msg);
|
|
401
1493
|
},
|
|
1494
|
+
send: (target, data) => {
|
|
1495
|
+
const payload = {
|
|
1496
|
+
type: "direct",
|
|
1497
|
+
data
|
|
1498
|
+
};
|
|
1499
|
+
const msg = truncate(JSON.stringify(payload));
|
|
1500
|
+
if (target.pushRef) {
|
|
1501
|
+
if (!rooms.sendToPushRef(target.pushRef, msg)) safeAsync(pushRefRegistry.stageForResume(target.pushRef, payload), fastify.log, "registry.stageForResume", { pushRef: target.pushRef });
|
|
1502
|
+
if (adapter) safeAsync(adapter.publish(`pushref:${target.pushRef}`, msg), fastify.log, "adapter.publish", { kind: "pushref" });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (target.userId) return void rooms.sendToUserWithAdapter(target.userId, msg);
|
|
1506
|
+
},
|
|
1507
|
+
sendRealtime: (target, data, coalesceKey) => {
|
|
1508
|
+
const msg = truncate(JSON.stringify({
|
|
1509
|
+
type: "direct",
|
|
1510
|
+
data
|
|
1511
|
+
}));
|
|
1512
|
+
if (target.pushRef) return void rooms.sendRealtimeToPushRefWithAdapter(target.pushRef, msg, coalesceKey);
|
|
1513
|
+
if (target.userId) return void rooms.sendRealtimeToUserWithAdapter(target.userId, msg, coalesceKey);
|
|
1514
|
+
},
|
|
402
1515
|
getStats: () => rooms.getStats()
|
|
403
1516
|
});
|
|
404
1517
|
const eventUnsubscribers = await wireResourceEvents(fastify, rooms, resources);
|
|
@@ -406,7 +1519,11 @@ const websocketPluginImpl = async (fastify, options) => {
|
|
|
406
1519
|
const ctx = {
|
|
407
1520
|
fastify,
|
|
408
1521
|
rooms,
|
|
1522
|
+
pushRefRegistry,
|
|
409
1523
|
nextClientId: () => `ws_${++clientCounter}_${Date.now()}`,
|
|
1524
|
+
fence: (pushRef, _generation) => {
|
|
1525
|
+
if (adapter) safeAsync(adapter.publish(`fence:${pushRef}`, ""), fastify.log, "fence.publish", { pushRef });
|
|
1526
|
+
},
|
|
410
1527
|
options: {
|
|
411
1528
|
auth,
|
|
412
1529
|
resources,
|
|
@@ -419,7 +1536,9 @@ const websocketPluginImpl = async (fastify, options) => {
|
|
|
419
1536
|
roomPolicy,
|
|
420
1537
|
onConnect,
|
|
421
1538
|
onDisconnect,
|
|
422
|
-
onMessage
|
|
1539
|
+
onMessage,
|
|
1540
|
+
messageEnvelope,
|
|
1541
|
+
deadQueueSize
|
|
423
1542
|
}
|
|
424
1543
|
};
|
|
425
1544
|
fastify.get(path, { websocket: true }, async (socket, request) => {
|
|
@@ -438,4 +1557,4 @@ const websocketPlugin = fp(websocketPluginImpl, {
|
|
|
438
1557
|
fastify: "5.x"
|
|
439
1558
|
});
|
|
440
1559
|
//#endregion
|
|
441
|
-
export { LocalWebSocketAdapter, RoomManager, websocketPlugin };
|
|
1560
|
+
export { GeoRoom, LocalWebSocketAdapter, MemoryPushRefStore, PushRefRegistry, RoomManager, websocketPlugin };
|