@classytic/arc 2.17.1 → 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 (49) hide show
  1. package/dist/{BaseController-CgHomZ4d.mjs → BaseController-DPwe89qs.mjs} +1 -1
  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-D8tpQ4ZL.mjs → core-B41eZxyM.mjs} +8 -5
  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-C6FViZXw.mjs → mcpPlugin-BrepZ0cV.mjs} +3 -3
  31. package/dist/{openapi-DKDs9NfD.mjs → openapi-CLZx9bwS.mjs} +1 -1
  32. package/dist/plugins/index.d.mts +15 -1
  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/websocket-BZMUP7Gr.d.mts +456 -0
  45. package/package.json +12 -1
  46. package/dist/websocket-BJ0w-3Dt.d.mts +0 -186
  47. /package/dist/{storage-HcAgP5Ni.d.mts → storage-BFmamN5m.d.mts} +0 -0
  48. /package/dist/{tracing-bRqqUxFO.d.mts → tracing-BYQ3hsbM.d.mts} +0 -0
  49. /package/dist/{types-CoMjNmkF.d.mts → types-D3HV0r5L.d.mts} +0 -0
@@ -56,7 +56,7 @@ function normalizeSchemaIR(raw) {
56
56
  required: []
57
57
  };
58
58
  const properties = converted.properties ?? {};
59
- const required = Array.isArray(converted.required) ? converted.required : [];
59
+ const required = (Array.isArray(converted.required) ? converted.required : []).filter((field) => !("default" in (properties[field] ?? {})));
60
60
  const additionalProperties = converted.additionalProperties;
61
61
  return {
62
62
  properties,
@@ -73,14 +73,26 @@ function normalizeSchemaIR(raw) {
73
73
  * reach AJV intact, so HTTP validation rejects unknown fields before the
74
74
  * handler runs. This closes the bug where the documented strict-mode
75
75
  * escape hatch silently no-opped because normalization dropped the flag.
76
+ *
77
+ * `default` is stripped from each property schema. Zod v4's `z.toJSONSchema()`
78
+ * emits `default` for `.default()` fields, but AJV with `useDefaults: true`
79
+ * cannot apply defaults inside `oneOf` branches and throws a strict-mode error
80
+ * (`strict mode: default is ignored for: <path>`). Runtime defaults are applied
81
+ * by Zod parsing at the handler layer anyway, so the keyword is a no-op for AJV.
82
+ * The OpenAPI doc path uses a separate `openapi-3.0` target and is unaffected.
76
83
  */
77
84
  function schemaIRToJsonSchemaBranch(ir, extras = {}) {
85
+ const mergedProperties = {};
86
+ for (const [key, prop] of Object.entries({
87
+ ...extras.properties ?? {},
88
+ ...ir.properties
89
+ })) if (typeof prop === "object" && prop !== null && "default" in prop) {
90
+ const { default: _dropped, ...rest } = prop;
91
+ mergedProperties[key] = rest;
92
+ } else mergedProperties[key] = prop;
78
93
  return {
79
94
  type: "object",
80
- properties: {
81
- ...extras.properties ?? {},
82
- ...ir.properties
83
- },
95
+ properties: mergedProperties,
84
96
  required: [...extras.required ?? [], ...ir.required.filter((f) => !(extras.required ?? []).includes(f))],
85
97
  ...ir.additionalProperties !== void 0 ? { additionalProperties: ir.additionalProperties } : {}
86
98
  };
@@ -1036,7 +1036,7 @@ function pickDefaultAuth(authMode, callerAuth) {
1036
1036
  };
1037
1037
  }
1038
1038
  async function createTestApp(options = {}) {
1039
- const { createApp } = await import("../createApp-CzCEEsEm.mjs").then((n) => n.r);
1039
+ const { createApp } = await import("../createApp-D56t2Dsj.mjs").then((n) => n.r);
1040
1040
  const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
1041
1041
  let dbHandle;
1042
1042
  let dbUri;
@@ -1,4 +1,4 @@
1
- import { t as Storage } from "../storage-HcAgP5Ni.mjs";
1
+ import { t as Storage } from "../storage-BFmamN5m.mjs";
2
2
 
3
3
  //#region src/testing/storageContract.d.ts
4
4
  interface StorageContractSetupResult {
@@ -1,2 +1,2 @@
1
- import { a as StorageReadResult, i as StorageReadRange, n as StorageContext, o as StorageUploadInput, r as StorageFile, t as Storage } from "../storage-HcAgP5Ni.mjs";
1
+ import { a as StorageReadResult, i as StorageReadRange, n as StorageContext, o as StorageUploadInput, r as StorageFile, t as Storage } from "../storage-BFmamN5m.mjs";
2
2
  export { Storage, StorageContext, StorageFile, StorageReadRange, StorageReadResult, StorageUploadInput };
@@ -0,0 +1,456 @@
1
+ import { a as PushRefRegistry, l as EnvelopeWriter, s as PushRefStore } from "./pushref-registry-Bq_-EVsC.mjs";
2
+ import { FastifyBaseLogger, FastifyPluginAsync } from "fastify";
3
+
4
+ //#region src/integrations/websocket/adapter.d.ts
5
+ /**
6
+ * WebSocket cross-instance adapter contract.
7
+ *
8
+ * The adapter is NOT used for local broadcasts — `RoomManager` handles those.
9
+ * The adapter only handles the cross-instance relay (Redis pub/sub, NATS, etc.)
10
+ * so a message broadcast on instance A is also delivered to clients connected
11
+ * to instance B.
12
+ *
13
+ * Implementations:
14
+ * - `LocalWebSocketAdapter` (here) — no-op, single-instance only
15
+ * - `RedisWebSocketAdapter` (@classytic/arc/integrations/websocket-redis)
16
+ *
17
+ * Custom adapters just need to satisfy the interface.
18
+ */
19
+ /**
20
+ * Adapter interface for cross-instance WebSocket broadcast.
21
+ *
22
+ * - `publish()`: Send a message to all instances (via Redis, NATS, etc.)
23
+ * - `subscribe()`: Receive messages from other instances
24
+ * - `close()`: Clean up connections
25
+ */
26
+ interface WebSocketAdapter {
27
+ /** Adapter name for logging */
28
+ readonly name: string;
29
+ /** Publish a room broadcast to all other instances */
30
+ publish(room: string, message: string): Promise<void>;
31
+ /** Subscribe to broadcasts from other instances */
32
+ subscribe(callback: (room: string, message: string) => void): Promise<void>;
33
+ /** Close adapter connections */
34
+ close(): Promise<void>;
35
+ }
36
+ /**
37
+ * Default adapter — no cross-instance broadcast (single-instance only).
38
+ * All methods are no-ops. Used when no adapter is configured.
39
+ */
40
+ declare class LocalWebSocketAdapter implements WebSocketAdapter {
41
+ readonly name = "local";
42
+ publish(): Promise<void>;
43
+ subscribe(): Promise<void>;
44
+ close(): Promise<void>;
45
+ }
46
+ //#endregion
47
+ //#region src/integrations/websocket/safe-async.d.ts
48
+ type SafeAsyncLogger = Pick<FastifyBaseLogger, "warn">;
49
+ //#endregion
50
+ //#region src/integrations/websocket/send-queue.d.ts
51
+ interface SendQueueSocket {
52
+ send(data: string): void;
53
+ readyState: number;
54
+ bufferedAmount?: number;
55
+ terminate?(): void;
56
+ close?(code?: number, reason?: string): void;
57
+ }
58
+ interface SendQueueOptions {
59
+ capacity?: number;
60
+ drainThreshold?: number;
61
+ /** Auto-flush retry delay in ms when backpressure pauses draining. */
62
+ retryDelayMs?: number;
63
+ }
64
+ /**
65
+ * Per-socket bounded queue. Construct one per accepted connection;
66
+ * `dispose()` on socket close to drop refs.
67
+ */
68
+ declare class SendQueue {
69
+ private entries;
70
+ /** `coalesceKey → entries[] index` for O(1) replacement. */
71
+ private coalesceIndex;
72
+ private socket;
73
+ private capacity;
74
+ private drainThreshold;
75
+ private retryDelayMs;
76
+ /** One-shot retry timer set while waiting for backpressure to clear. */
77
+ private retryTimer?;
78
+ private disposed;
79
+ constructor(socket: SendQueueSocket, opts?: SendQueueOptions);
80
+ /** Ordered, guaranteed-best-effort delivery. Overflow ⇒ terminate. */
81
+ send(payload: string): void;
82
+ /**
83
+ * Fire-and-forget, droppable. With `coalesceKey`, older entry for the
84
+ * same key is replaced in place (preserves queue order, keeps latest
85
+ * payload). Without a key, oldest droppable entry is shed on overflow.
86
+ */
87
+ sendRealtime(payload: string, coalesceKey?: string): void;
88
+ /**
89
+ * Drain entries to the socket until either the queue empties or the
90
+ * socket's outbound buffer crosses the drain threshold. Idempotent —
91
+ * also runs on a self-scheduled retry timer when backpressure pauses
92
+ * draining (so callers never need to manually re-prod the queue).
93
+ */
94
+ flush(): void;
95
+ size(): number;
96
+ dispose(): void;
97
+ /**
98
+ * One-shot retry timer. Called when `flush()` hits backpressure and
99
+ * leaves entries queued — without this, queued frames would stall
100
+ * indefinitely after the socket's buffer drains, because nothing
101
+ * else prods the queue. The timer self-clears on each fire so a
102
+ * single delay is the worst case between drain and resume.
103
+ */
104
+ private scheduleRetry;
105
+ private terminate;
106
+ /** Rebuild the coalesce index from `start` onward after a splice/shift. */
107
+ private reindexCoalesceFrom;
108
+ }
109
+ //#endregion
110
+ //#region src/integrations/websocket/types.d.ts
111
+ /**
112
+ * A connected WebSocket client — one entry per TCP socket.
113
+ *
114
+ * `subscriptions` is mutated by `RoomManager`; other fields are set once at
115
+ * handshake time and treated as effectively immutable for the lifetime of
116
+ * the connection.
117
+ */
118
+ interface WebSocketClient {
119
+ /**
120
+ * Transport-level connection ID (one per TCP socket). Stable for the
121
+ * lifetime of THIS connection only — a reconnect mints a new `id`.
122
+ */
123
+ id: string;
124
+ /**
125
+ * Session reference — stable across reconnects, distinct from `userId`.
126
+ * One user with three browser tabs = three `pushRef`s, one `userId`.
127
+ * Routing layer keys off this for "send to THIS tab" semantics.
128
+ *
129
+ * Clients MAY supply their own pushRef via the `?pushRef=` query param
130
+ * on the upgrade URL (e.g. resumed reconnect); otherwise the server mints
131
+ * one at handshake time.
132
+ */
133
+ pushRef: string;
134
+ socket: {
135
+ send(data: string): void;
136
+ close(code?: number, reason?: string): void;
137
+ readyState: number;
138
+ bufferedAmount?: number; /** RFC 6455 §5.5.2 ping control frame. Browsers auto-respond at the protocol layer. */
139
+ ping?(data?: unknown): void; /** Immediate socket destroy — no Close frame, no flush. Use for zombies. */
140
+ terminate?(): void;
141
+ };
142
+ subscriptions: Set<string>;
143
+ userId?: string;
144
+ organizationId?: string;
145
+ /** OAuth client ID — present for service/machine-to-machine connections */
146
+ clientId?: string;
147
+ /** OAuth scopes — present for service/machine-to-machine connections */
148
+ scopes?: readonly string[];
149
+ metadata?: Record<string, unknown>;
150
+ /**
151
+ * Outbound send queue — created at connect time, disposed on close.
152
+ * `RoomManager.send/sendRealtime` enqueue via this; the raw
153
+ * `client.socket.send()` path bypasses queue/coalesce/overflow policy
154
+ * and should only be used for the connection-lifecycle handshake.
155
+ */
156
+ queue?: SendQueue;
157
+ /**
158
+ * Sequence-numbered envelope writer + dead queue. Present iff the
159
+ * plugin was started with `messageEnvelope: 'seq'`. Critical sends are
160
+ * wrapped via this so the client can RESUME after reconnect.
161
+ */
162
+ envelope?: EnvelopeWriter;
163
+ /**
164
+ * Connection generation captured at claim time. The fence + CAS
165
+ * write paths use this — every release/persist passes it so the
166
+ * store can refuse stale writes from a connection that's been
167
+ * superseded by a newer claim on another node.
168
+ */
169
+ generation: number;
170
+ }
171
+ interface WebSocketMessage {
172
+ type: string;
173
+ resource?: string;
174
+ channel?: string;
175
+ data?: unknown;
176
+ }
177
+ /**
178
+ * Result of a successful authentication. The plugin's handshake and the
179
+ * optional re-auth loop both return this shape so the downstream code
180
+ * doesn't branch on auth mode. `null` means rejected.
181
+ */
182
+ interface AuthResult {
183
+ userId?: string;
184
+ organizationId?: string;
185
+ /** Set for machine-to-machine / service account auth */
186
+ clientId?: string;
187
+ /** OAuth scopes for service accounts */
188
+ scopes?: readonly string[];
189
+ }
190
+ interface WebSocketPluginOptions {
191
+ /** WebSocket endpoint path (default: '/ws') */
192
+ path?: string;
193
+ /** Require authentication for WebSocket connections (default: true) */
194
+ auth?: boolean;
195
+ /** Resources to auto-broadcast CRUD events for */
196
+ resources?: string[];
197
+ /** Heartbeat interval in ms (default: 30000). Set 0 to disable. */
198
+ heartbeatInterval?: number;
199
+ /** Custom authentication function for WebSocket upgrade */
200
+ authenticate?: (request: unknown) => Promise<AuthResult | null>;
201
+ /** Max clients per resource subscription (default: 10000) */
202
+ maxClientsPerRoom?: number;
203
+ /**
204
+ * Expose a stats endpoint at `{path}/stats`.
205
+ * - `false` (default): stats endpoint is not registered
206
+ * - `true`: registered without auth
207
+ * - `'authenticated'`: guarded by `fastify.authenticate` if available
208
+ */
209
+ exposeStats?: boolean | "authenticated";
210
+ /**
211
+ * Authorize room subscriptions. Return true to allow, false to deny.
212
+ * Called before every subscribe. If not provided, all rooms are allowed.
213
+ */
214
+ roomPolicy?: (client: WebSocketClient, room: string) => boolean | Promise<boolean>;
215
+ /** Maximum message size in bytes from client (default: 16384 = 16KB). Messages exceeding this are dropped. */
216
+ maxMessageBytes?: number;
217
+ /** Maximum subscriptions per client (default: 100). Prevents resource exhaustion. */
218
+ maxSubscriptionsPerClient?: number;
219
+ /**
220
+ * Periodic re-authentication interval in ms (default: 0 = disabled).
221
+ * When set, the server periodically re-validates the client's auth token.
222
+ * If the token is expired/revoked, the client is disconnected with code 4003.
223
+ *
224
+ * Recommended: 300000 (5 minutes) for production.
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * websocketPlugin({ reauthInterval: 5 * 60 * 1000 }) // re-check every 5 min
229
+ * ```
230
+ */
231
+ reauthInterval?: number;
232
+ /** Custom message handler */
233
+ onMessage?: (client: WebSocketClient, message: WebSocketMessage) => void | Promise<void>;
234
+ /** Called when a client connects */
235
+ onConnect?: (client: WebSocketClient) => void | Promise<void>;
236
+ /** Called when a client disconnects */
237
+ onDisconnect?: (client: WebSocketClient) => void | Promise<void>;
238
+ /**
239
+ * Cross-instance broadcast adapter (default: LocalWebSocketAdapter — single-instance only).
240
+ * Provide a RedisWebSocketAdapter for multi-instance deployments.
241
+ *
242
+ * @example
243
+ * ```typescript
244
+ * import { RedisWebSocketAdapter } from '@classytic/arc/integrations/websocket-redis';
245
+ * adapter: new RedisWebSocketAdapter(redis, { channel: 'arc-ws' })
246
+ * ```
247
+ */
248
+ adapter?: WebSocketAdapter;
249
+ /**
250
+ * Outbound message wire format.
251
+ * - `'raw'` (default): write payloads as-is — no envelope, no replay.
252
+ * - `'seq'`: wrap every critical send in `{seq, t, msg}`. The dead
253
+ * queue of the last N envelopes is owned by the **PushRefRegistry**
254
+ * (per-pushRef / per-session, NOT per-connection) so a client
255
+ * reconnecting under the same pushRef within `pushRefTtlMs` can
256
+ * RESUME via `{type:'resume', lastSeq}` and replay missed messages
257
+ * across the disconnect — not just within a single socket.
258
+ *
259
+ * Opt-in to preserve compatibility with existing clients. Set to
260
+ * `'seq'` for chat, ride-status, payment flows — anything where a
261
+ * message lost mid-disconnect is a real bug.
262
+ */
263
+ messageEnvelope?: "raw" | "seq";
264
+ /** Dead queue size (per pushRef) when `messageEnvelope: 'seq'`. Defaults to 128. */
265
+ deadQueueSize?: number;
266
+ /**
267
+ * Time (ms) a released pushRef stays in the registry waiting for a
268
+ * reconnect to RESUME against its envelope + dead queue. Past this
269
+ * window the entry is GC'd. Default `60_000` (60 s) — typical mobile
270
+ * lock-screen + carrier-handoff reconnect window.
271
+ */
272
+ pushRefTtlMs?: number;
273
+ /**
274
+ * Hard cap on pushRef registry entries. When full, the longest-inactive
275
+ * entry is evicted to make room. Default `10_000` — sized for a single
276
+ * Node process supporting tens of thousands of concurrent users with
277
+ * tab-level granularity.
278
+ */
279
+ pushRefMaxEntries?: number;
280
+ /**
281
+ * Maximum bytes per outbound frame. Payloads exceeding this are
282
+ * replaced with a `{type:'truncated', originalBytes, hint}` placeholder
283
+ * so a misbehaving server-side emitter can't pin socket buffers across
284
+ * the fleet. Default `5 * 1024 * 1024` (5 MiB) — same as n8n's
285
+ * `relayViaPubSub` ceiling. Hosts may tighten for low-bandwidth tiers.
286
+ */
287
+ maxOutboundBytes?: number;
288
+ /**
289
+ * Optional external store for cross-instance pushRef state. When
290
+ * provided, the envelope + dead queue survive reconnects to ANY node
291
+ * in the cluster (not just the originator). See
292
+ * `@classytic/arc/integrations/websocket-pushref-redis` for the
293
+ * Redis-backed implementation.
294
+ *
295
+ * Without this, the in-memory registry is per-process — fine for
296
+ * single-node deployments OR sticky-session load balancing, but a
297
+ * client reconnecting through a different node loses dead-queue
298
+ * state.
299
+ */
300
+ pushRefStore?: PushRefStore;
301
+ }
302
+ //#endregion
303
+ //#region src/integrations/websocket/room-manager.d.ts
304
+ declare class RoomManager {
305
+ private rooms;
306
+ private clients;
307
+ /**
308
+ * Secondary index: `pushRef → clientId`. A pushRef is stable across
309
+ * reconnects (one per browser tab/device), where `clientId` is per-socket.
310
+ * Lets the host send to a logical session ("this tab") without caring
311
+ * which TCP connection currently backs it.
312
+ */
313
+ private clientIdByPushRef;
314
+ /**
315
+ * Tertiary index: `userId → Set<clientId>`. Lets `sendToUser(userId, msg)`
316
+ * fan out to every open tab/device for that user without scanning the
317
+ * full client set.
318
+ */
319
+ private clientIdsByUserId;
320
+ private maxPerRoom;
321
+ private adapter?;
322
+ private registry?;
323
+ private logger;
324
+ constructor(maxPerRoom?: number, adapter?: WebSocketAdapter, registry?: PushRefRegistry, logger?: SafeAsyncLogger);
325
+ addClient(client: WebSocketClient): void;
326
+ removeClient(clientId: string): void;
327
+ /** Resolve a pushRef to its current backing client, if any. */
328
+ getClientByPushRef(pushRef: string): WebSocketClient | undefined;
329
+ /** Snapshot of all clients currently owned by `userId`. */
330
+ getClientsByUserId(userId: string): WebSocketClient[];
331
+ subscribe(clientId: string, room: string): boolean;
332
+ unsubscribe(clientId: string, room: string): void;
333
+ broadcast(room: string, message: string, excludeClientId?: string): void;
334
+ /**
335
+ * Droppable, coalesce-by-key broadcast to a room. Mirrors `broadcast()`
336
+ * but routes through each client's `SendQueue.sendRealtime` so high-
337
+ * frequency streams (GPS, cursors, presence) collapse to one queued
338
+ * frame per recipient.
339
+ *
340
+ * Used by `GeoRoom.publish` for hyperlocal fan-out where the latest
341
+ * snapshot is the only one that matters.
342
+ */
343
+ broadcastRealtime(room: string, message: string, coalesceKey: string): void;
344
+ broadcastToOrg(organizationId: string, room: string, message: string): void;
345
+ /**
346
+ * Broadcast locally AND through adapter (for cross-instance delivery).
347
+ * Use this instead of broadcast() when multi-instance is possible.
348
+ */
349
+ broadcastWithAdapter(room: string, message: string, excludeClientId?: string): Promise<void>;
350
+ /**
351
+ * Org-scoped broadcast locally AND through adapter.
352
+ * Uses a namespaced room key for the adapter so other instances
353
+ * can filter by org when delivering locally.
354
+ */
355
+ broadcastToOrgWithAdapter(organizationId: string, room: string, message: string): Promise<void>;
356
+ /**
357
+ * Addressed sends across instances. Wire format reuses the existing
358
+ * room-prefix convention (`pushref:<id>`, `userid:<id>`) so the adapter
359
+ * interface stays unchanged. Receiver-side plugin code dispatches on the
360
+ * prefix and calls `sendToPushRef` / `sendToUser` locally.
361
+ *
362
+ * Critical guarantee (n8n shouldRelayViaPubSub pattern): the local call
363
+ * is a no-op on every instance EXCEPT the one that owns the pushRef —
364
+ * the address lookup itself is the ownership check.
365
+ */
366
+ sendToPushRefWithAdapter(pushRef: string, message: string): Promise<void>;
367
+ sendRealtimeToPushRefWithAdapter(pushRef: string, message: string, coalesceKey?: string): Promise<void>;
368
+ sendToUserWithAdapter(userId: string, message: string): Promise<void>;
369
+ sendRealtimeToUserWithAdapter(userId: string, message: string, coalesceKey?: string): Promise<void>;
370
+ /** Ordered send to a single pushRef. Returns true if delivered/queued. */
371
+ sendToPushRef(pushRef: string, message: string): boolean;
372
+ /** Droppable send to a single pushRef. */
373
+ sendRealtimeToPushRef(pushRef: string, message: string, coalesceKey?: string): boolean;
374
+ /**
375
+ * Ordered fan-out to every open connection for `userId` (all tabs/devices).
376
+ * Returns the count successfully queued.
377
+ */
378
+ sendToUser(userId: string, message: string): number;
379
+ /** Droppable fan-out to every open connection for `userId`. */
380
+ sendRealtimeToUser(userId: string, message: string, coalesceKey?: string): number;
381
+ private deliver;
382
+ getClient(clientId: string): WebSocketClient | undefined;
383
+ getStats(): {
384
+ clients: number;
385
+ rooms: number;
386
+ subscriptions: Record<string, number>;
387
+ };
388
+ }
389
+ //#endregion
390
+ //#region src/integrations/websocket/geo-room.d.ts
391
+ interface GeoRoomOptions {
392
+ /** H3 resolution. 9 ≈ 100 m edge (ride-hailing default); 10 ≈ 40 m. */
393
+ resolution?: number;
394
+ /**
395
+ * Room-key prefix so geo-rooms don't collide with arbitrary topic names.
396
+ * Defaults to `geo:`. Resulting room: `geo:<h3cell>`.
397
+ */
398
+ prefix?: string;
399
+ }
400
+ interface GeoPoint {
401
+ lat: number;
402
+ lng: number;
403
+ }
404
+ interface GeoPublishOpts {
405
+ /** k-ring radius — `0` = just the cell, `1` = cell + 6 neighbors, etc. */
406
+ ring?: number;
407
+ /**
408
+ * Coalesce key for queue-side keep-latest-snapshot. Defaults to the
409
+ * client's pushRef so a driver streaming 5 Hz GPS collapses to one
410
+ * queued frame per recipient.
411
+ */
412
+ coalesceKey?: string;
413
+ /**
414
+ * Delivery class. `'droppable'` (default) routes through `sendRealtime`
415
+ * — correct for high-frequency location streams. `'critical'` routes
416
+ * through `send` — use sparingly for dispatch events like "ride accepted".
417
+ */
418
+ delivery?: "droppable" | "critical";
419
+ }
420
+ /**
421
+ * A geo-aware subscription layer on top of `RoomManager`. Construct one
422
+ * per logical channel (e.g. one for `drivers`, one for `couriers`) so
423
+ * resolutions and prefixes don't cross-contaminate.
424
+ */
425
+ declare class GeoRoom {
426
+ private rooms;
427
+ private resolution;
428
+ private prefix;
429
+ private h3?;
430
+ constructor(rooms: RoomManager, opts?: GeoRoomOptions);
431
+ /** Subscribe a client to every cell within `ring` of `point`. */
432
+ subscribe(clientId: string, point: GeoPoint, opts?: {
433
+ ring?: number;
434
+ }): Promise<string[]>;
435
+ /** Symmetric — unsubscribe from every cell within `ring`. */
436
+ unsubscribe(clientId: string, point: GeoPoint, opts?: {
437
+ ring?: number;
438
+ }): Promise<void>;
439
+ /**
440
+ * Publish to every client subscribed to a cell within `ring` of `point`.
441
+ * Droppable by default — every recipient queue keeps only the latest
442
+ * per `coalesceKey` (defaults to the publisher's pushRef).
443
+ */
444
+ publish(publisher: {
445
+ pushRef: string;
446
+ }, point: GeoPoint, payload: string, opts?: GeoPublishOpts): Promise<void>;
447
+ /** Compute the cell ID for a point without subscribing — useful for tests. */
448
+ cellFor(point: GeoPoint): Promise<string>;
449
+ private h3lib;
450
+ }
451
+ //#endregion
452
+ //#region src/integrations/websocket/plugin.d.ts
453
+ /** Pluggable WebSocket integration for Arc */
454
+ declare const websocketPlugin: FastifyPluginAsync<WebSocketPluginOptions>;
455
+ //#endregion
456
+ export { GeoRoomOptions as a, WebSocketClient as c, LocalWebSocketAdapter as d, WebSocketAdapter as f, GeoRoom as i, WebSocketMessage as l, GeoPoint as n, RoomManager as o, GeoPublishOpts as r, AuthResult as s, websocketPlugin as t, WebSocketPluginOptions as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc",
3
- "version": "2.17.1",
3
+ "version": "2.18.0",
4
4
  "description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -156,6 +156,10 @@
156
156
  "types": "./dist/integrations/websocket-redis.d.mts",
157
157
  "default": "./dist/integrations/websocket-redis.mjs"
158
158
  },
159
+ "./integrations/websocket-pushref-redis": {
160
+ "types": "./dist/integrations/websocket-pushref-redis.d.mts",
161
+ "default": "./dist/integrations/websocket-pushref-redis.mjs"
162
+ },
159
163
  "./integrations/jobs": {
160
164
  "types": "./dist/integrations/jobs.d.mts",
161
165
  "default": "./dist/integrations/jobs.mjs"
@@ -227,6 +231,8 @@
227
231
  "test:coverage": "vitest run --coverage",
228
232
  "test:e2e": "vitest run tests/e2e",
229
233
  "test:unit": "vitest run tests/core tests/hooks tests/utils tests/plugins",
234
+ "test:smoke:ws": "vitest run tests/integrations/websocket/smoke.test.ts",
235
+ "test:smoke:redis": "node scripts/smoke-ws-redis.mjs",
230
236
  "smoke": "node scripts/smoke-test.mjs",
231
237
  "push": "classytic-push",
232
238
  "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
@@ -261,6 +267,7 @@
261
267
  "bullmq": ">=5.0.0",
262
268
  "fastify": "^5.8.5",
263
269
  "fastify-raw-body": ">=5.0.0",
270
+ "h3-js": ">=4.0.0",
264
271
  "ioredis": ">=5.0.0",
265
272
  "pino-pretty": ">=13.0.0",
266
273
  "zod": ">=4.0.0"
@@ -296,6 +303,9 @@
296
303
  "fastify-raw-body": {
297
304
  "optional": true
298
305
  },
306
+ "h3-js": {
307
+ "optional": true
308
+ },
299
309
  "pino-pretty": {
300
310
  "optional": true
301
311
  },
@@ -378,6 +388,7 @@
378
388
  "fast-check": "^4.8.0",
379
389
  "fastify": "^5.8.5",
380
390
  "fastify-raw-body": "^5.0.0",
391
+ "h3-js": "^4.4.0",
381
392
  "ioredis": "^5.10.1",
382
393
  "jsonwebtoken": "^9.0.0",
383
394
  "knip": "^6.14.1",