@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.
Files changed (51) hide show
  1. package/dist/{BaseController-Dmeh4Y_y.mjs → BaseController-DPwe89qs.mjs} +2 -2
  2. package/dist/cli/commands/docs.mjs +1 -1
  3. package/dist/cli/commands/init.mjs +1 -1
  4. package/dist/cli/index.mjs +1 -1
  5. package/dist/context/index.d.mts +54 -1
  6. package/dist/context/index.mjs +65 -2
  7. package/dist/core/index.mjs +3 -3
  8. package/dist/{core-DYJvu7aI.mjs → core-B41eZxyM.mjs} +9 -6
  9. package/dist/{createActionRouter-BHUMb1-y.mjs → createActionRouter-CRaIwB5S.mjs} +2 -2
  10. package/dist/{createAggregationRouter-BYVvhfc5.mjs → createAggregationRouter-n3O6WdLO.mjs} +1 -1
  11. package/dist/{createApp-CzCEEsEm.mjs → createApp-D56t2Dsj.mjs} +7 -2
  12. package/dist/docs/index.mjs +1 -1
  13. package/dist/{eventPlugin-CnJCUyM0.mjs → eventPlugin-Dxp9JlKK.mjs} +1 -1
  14. package/dist/events/index.d.mts +48 -0
  15. package/dist/events/index.mjs +27 -5
  16. package/dist/factory/index.mjs +1 -1
  17. package/dist/index.mjs +4 -4
  18. package/dist/{init-DYzfniuX.mjs → init-DQ_rY1J7.mjs} +13 -13
  19. package/dist/integrations/event-gateway.d.mts +1 -1
  20. package/dist/integrations/index.d.mts +2 -2
  21. package/dist/integrations/mcp/index.d.mts +42 -2
  22. package/dist/integrations/mcp/index.mjs +102 -2
  23. package/dist/integrations/mcp/testing.d.mts +1 -1
  24. package/dist/integrations/mcp/testing.mjs +1 -1
  25. package/dist/integrations/websocket-pushref-redis.d.mts +71 -0
  26. package/dist/integrations/websocket-pushref-redis.mjs +205 -0
  27. package/dist/integrations/websocket-redis.d.mts +1 -1
  28. package/dist/integrations/websocket.d.mts +3 -2
  29. package/dist/integrations/websocket.mjs +1142 -23
  30. package/dist/{mcpPlugin-hZE00H74.mjs → mcpPlugin-BrepZ0cV.mjs} +3 -3
  31. package/dist/{openapi-DKDs9NfD.mjs → openapi-CLZx9bwS.mjs} +1 -1
  32. package/dist/plugins/index.d.mts +16 -2
  33. package/dist/plugins/index.mjs +44 -3
  34. package/dist/plugins/tracing-entry.d.mts +1 -1
  35. package/dist/plugins/tracing-entry.mjs +1 -1
  36. package/dist/presets/filesUpload.d.mts +1 -1
  37. package/dist/pushref-registry-Bq_-EVsC.d.mts +435 -0
  38. package/dist/{requestContext-SSaaTgW8.mjs → requestContext-DDLS3NVU.mjs} +26 -1
  39. package/dist/{routerShared-nCyJGOKX.mjs → routerShared-Dl0A77Ba.mjs} +1 -1
  40. package/dist/{schemaIR-DAOvBWgT.mjs → schemaIR-Bk6Tns82.mjs} +17 -5
  41. package/dist/testing/index.mjs +1 -1
  42. package/dist/testing/storageContract.d.mts +1 -1
  43. package/dist/types/storage.d.mts +1 -1
  44. package/dist/utils/index.mjs +1 -1
  45. package/dist/{utils-CvD3glpM.mjs → utils-BMQK8mRl.mjs} +37 -25
  46. package/dist/websocket-BZMUP7Gr.d.mts +456 -0
  47. package/package.json +12 -1
  48. package/dist/websocket-BJ0w-3Dt.d.mts +0 -186
  49. /package/dist/{storage-HcAgP5Ni.d.mts → storage-BFmamN5m.d.mts} +0 -0
  50. /package/dist/{tracing-bRqqUxFO.d.mts → tracing-BYQ3hsbM.d.mts} +0 -0
  51. /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
- resources: options.resources
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 === 1) socket.send(JSON.stringify({
119
- type: "ping",
120
- timestamp: Date.now()
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 "pong": break;
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
- await options.onDisconnect?.(client);
203
- rooms.removeClient(clientId);
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
- constructor(maxPerRoom = 1e4, adapter) {
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 && client.socket.readyState === 1) try {
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 && client.organizationId === organizationId && client.socket.readyState === 1) try {
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 rooms = new RoomManager(maxClientsPerRoom, adapter ?? new LocalWebSocketAdapter());
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.broadcast(room, message);
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 };