@classytic/arc 2.10.3 → 2.11.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 (153) hide show
  1. package/README.md +1 -1
  2. package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
  3. package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
  4. package/dist/actionPermissions-C8YYU92K.mjs +22 -0
  5. package/dist/adapters/index.d.mts +2 -2
  6. package/dist/adapters/index.mjs +1 -1
  7. package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
  8. package/dist/audit/index.d.mts +2 -2
  9. package/dist/audit/index.mjs +15 -17
  10. package/dist/auth/index.d.mts +4 -4
  11. package/dist/auth/index.mjs +3 -3
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
  14. package/dist/cache/index.d.mts +3 -2
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/cli/commands/docs.mjs +2 -2
  17. package/dist/cli/commands/generate.mjs +37 -27
  18. package/dist/cli/commands/init.mjs +47 -34
  19. package/dist/cli/commands/introspect.mjs +1 -1
  20. package/dist/context/index.d.mts +58 -0
  21. package/dist/context/index.mjs +2 -0
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -3
  24. package/dist/core-DXdSSFW-.mjs +1037 -0
  25. package/dist/createActionRouter-BwaSM0No.mjs +166 -0
  26. package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +1 -1
  29. package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
  30. package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
  31. package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
  32. package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.d.mts} +1 -1
  33. package/dist/events/index.d.mts +4 -4
  34. package/dist/events/index.mjs +69 -51
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis.d.mts +1 -1
  37. package/dist/factory/index.d.mts +1 -1
  38. package/dist/factory/index.mjs +2 -2
  39. package/dist/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
  40. package/dist/hooks/index.d.mts +1 -1
  41. package/dist/hooks/index.mjs +1 -1
  42. package/dist/idempotency/index.d.mts +3 -3
  43. package/dist/idempotency/index.mjs +38 -27
  44. package/dist/idempotency/redis.d.mts +1 -1
  45. package/dist/{index-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
  46. package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
  47. package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
  48. package/dist/index-DsJ1MNfC.d.mts +1179 -0
  49. package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
  50. package/dist/index.d.mts +7 -251
  51. package/dist/index.mjs +8 -128
  52. package/dist/integrations/event-gateway.d.mts +2 -2
  53. package/dist/integrations/event-gateway.mjs +1 -1
  54. package/dist/integrations/index.d.mts +2 -2
  55. package/dist/integrations/mcp/index.d.mts +2 -2
  56. package/dist/integrations/mcp/index.mjs +1 -1
  57. package/dist/integrations/mcp/testing.d.mts +1 -1
  58. package/dist/integrations/mcp/testing.mjs +1 -1
  59. package/dist/integrations/streamline.d.mts +46 -5
  60. package/dist/integrations/streamline.mjs +50 -21
  61. package/dist/integrations/websocket-redis.d.mts +1 -1
  62. package/dist/integrations/websocket.d.mts +2 -154
  63. package/dist/integrations/websocket.mjs +292 -224
  64. package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
  65. package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -1
  66. package/dist/logger/index.d.mts +81 -0
  67. package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
  68. package/dist/middleware/index.d.mts +109 -0
  69. package/dist/middleware/index.mjs +70 -0
  70. package/dist/multipartBody-CvTR1Un6.mjs +123 -0
  71. package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/permissions/index.d.mts +2 -2
  74. package/dist/permissions/index.mjs +1 -3
  75. package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
  76. package/dist/pipe-DVoIheVC.mjs +62 -0
  77. package/dist/pipeline/index.d.mts +62 -0
  78. package/dist/pipeline/index.mjs +53 -0
  79. package/dist/plugins/index.d.mts +25 -5
  80. package/dist/plugins/index.mjs +10 -10
  81. package/dist/plugins/response-cache.mjs +1 -1
  82. package/dist/plugins/tracing-entry.d.mts +1 -1
  83. package/dist/plugins/tracing-entry.mjs +42 -24
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +255 -1
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +2 -2
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +48 -8
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +1 -1
  92. package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
  93. package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
  94. package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
  95. package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
  96. package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.d.mts} +1 -1
  97. package/dist/registry/index.d.mts +1 -1
  98. package/dist/registry/index.mjs +2 -2
  99. package/dist/{requestContext-xHIKedG6.mjs → requestContext-CfRkaxwf.mjs} +1 -1
  100. package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
  101. package/dist/routerShared-DeESFp4a.mjs +515 -0
  102. package/dist/schemaIR-BlG9bY7v.mjs +137 -0
  103. package/dist/scope/index.d.mts +2 -2
  104. package/dist/scope/index.mjs +1 -1
  105. package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
  106. package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
  107. package/dist/testing/index.d.mts +367 -711
  108. package/dist/testing/index.mjs +646 -1434
  109. package/dist/testing/storageContract.d.mts +1 -1
  110. package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
  111. package/dist/types/index.d.mts +5 -5
  112. package/dist/types/index.mjs +1 -3
  113. package/dist/types/storage.d.mts +1 -1
  114. package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
  115. package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -898
  117. package/dist/utils/index.mjs +4 -5
  118. package/dist/utils-D3Yxnrwr.mjs +1639 -0
  119. package/dist/versioning-M9lNLhO8.d.mts +117 -0
  120. package/dist/websocket-CyJ1VIFI.d.mts +186 -0
  121. package/package.json +26 -8
  122. package/skills/arc/SKILL.md +124 -39
  123. package/skills/arc/references/testing.md +212 -183
  124. package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
  125. package/dist/core-CcR01lup.mjs +0 -1411
  126. package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
  127. package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
  128. package/dist/errors-CCSsMpXE.d.mts +0 -140
  129. package/dist/fields-bxkeltzz.mjs +0 -126
  130. package/dist/filesUpload-t21LS-py.mjs +0 -377
  131. package/dist/queryParser-DBqBB6AC.mjs +0 -352
  132. package/dist/types-Csi3FLfq.mjs +0 -27
  133. package/dist/utils-B2fNOD_i.mjs +0 -929
  134. /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
  135. /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
  136. /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
  137. /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
  138. /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
  139. /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
  140. /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
  141. /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
  142. /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
  143. /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
  144. /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
  145. /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
  146. /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
  147. /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
  148. /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
  149. /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
  150. /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
  151. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  152. /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
  153. /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
@@ -1,5 +1,5 @@
1
1
  import fp from "fastify-plugin";
2
- //#region src/integrations/websocket.ts
2
+ //#region src/integrations/websocket/adapter.ts
3
3
  /**
4
4
  * Default adapter — no cross-instance broadcast (single-instance only).
5
5
  * All methods are no-ops. Used when no adapter is configured.
@@ -10,6 +10,271 @@ var LocalWebSocketAdapter = class {
10
10
  async subscribe() {}
11
11
  async close() {}
12
12
  };
13
+ //#endregion
14
+ //#region src/integrations/websocket/auth.ts
15
+ function createCaptureReply() {
16
+ let rejected = false;
17
+ let capturedStatus;
18
+ const reply = {
19
+ code(statusCode) {
20
+ rejected = true;
21
+ capturedStatus = statusCode;
22
+ return reply;
23
+ },
24
+ send() {
25
+ return reply;
26
+ },
27
+ get rejected() {
28
+ return rejected;
29
+ },
30
+ get statusCode() {
31
+ return capturedStatus;
32
+ },
33
+ sent: false
34
+ };
35
+ return reply;
36
+ }
37
+ /**
38
+ * Run authentication against a request and return a uniform `AuthResult`
39
+ * (or `null` if denied). Handles both modes:
40
+ *
41
+ * - `customAuth` provided → call it directly; trust its return shape.
42
+ * - Otherwise → invoke `fastify.authenticate(request, captureReply)` to
43
+ * populate `request.user` / `request.scope`, then read IDs off those.
44
+ *
45
+ * This is the single source of truth for "did this request authenticate
46
+ * successfully, and what identity did it establish". The handshake path
47
+ * calls it once; the re-auth loop calls it on every interval.
48
+ */
49
+ async function authenticateWebSocket(fastify, request, customAuth) {
50
+ if (customAuth) try {
51
+ return await customAuth(request);
52
+ } catch {
53
+ return null;
54
+ }
55
+ const authenticate = fastify.authenticate;
56
+ if (!authenticate) return null;
57
+ const reply = createCaptureReply();
58
+ try {
59
+ await authenticate(request, reply);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (reply.rejected) return null;
64
+ const shape = request;
65
+ const user = shape.user;
66
+ const scope = shape.scope;
67
+ if (!user) return null;
68
+ const userId = readIdField(user, "id") ?? readIdField(user, "sub");
69
+ const organizationId = scope ? readIdField(scope, "organizationId") : void 0;
70
+ return {
71
+ ...userId !== void 0 ? { userId } : {},
72
+ ...organizationId !== void 0 ? { organizationId } : {}
73
+ };
74
+ }
75
+ function readIdField(obj, key) {
76
+ const value = obj[key];
77
+ return typeof value === "string" ? value : void 0;
78
+ }
79
+ //#endregion
80
+ //#region src/integrations/websocket/connection.ts
81
+ async function handleConnection(ctx, socket, request) {
82
+ const { fastify, rooms, options } = ctx;
83
+ const clientId = ctx.nextClientId();
84
+ let userId;
85
+ let organizationId;
86
+ let serviceClientId;
87
+ let serviceScopes;
88
+ if (options.auth) {
89
+ const result = await authenticateWebSocket(fastify, request, options.authenticate);
90
+ if (!result) {
91
+ socket.close(4001, "Unauthorized");
92
+ return;
93
+ }
94
+ userId = result.userId;
95
+ organizationId = result.organizationId;
96
+ serviceClientId = result.clientId;
97
+ serviceScopes = result.scopes;
98
+ }
99
+ const client = {
100
+ id: clientId,
101
+ socket,
102
+ subscriptions: /* @__PURE__ */ new Set(),
103
+ userId,
104
+ organizationId,
105
+ ...serviceClientId ? { clientId: serviceClientId } : {},
106
+ ...serviceScopes ? { scopes: serviceScopes } : {}
107
+ };
108
+ rooms.addClient(client);
109
+ await options.onConnect?.(client);
110
+ socket.send(JSON.stringify({
111
+ type: "connected",
112
+ clientId,
113
+ resources: options.resources
114
+ }));
115
+ let heartbeatTimer;
116
+ if (options.heartbeatInterval > 0) heartbeatTimer = setInterval(() => {
117
+ if (socket.readyState === 1) socket.send(JSON.stringify({
118
+ type: "ping",
119
+ timestamp: Date.now()
120
+ }));
121
+ }, options.heartbeatInterval);
122
+ let reauthTimer;
123
+ if (options.reauthInterval > 0 && options.auth) reauthTimer = setInterval(async () => {
124
+ if (socket.readyState !== 1) return;
125
+ if (!await authenticateWebSocket(fastify, request, options.authenticate)) {
126
+ socket.send(JSON.stringify({
127
+ type: "error",
128
+ error: "Session expired"
129
+ }));
130
+ socket.close(4003, "Session expired");
131
+ }
132
+ }, options.reauthInterval);
133
+ socket.on("message", async (raw) => {
134
+ if ((typeof raw === "string" ? Buffer.byteLength(raw) : raw.length) > options.maxMessageBytes) {
135
+ socket.send(JSON.stringify({
136
+ type: "error",
137
+ error: "Message too large"
138
+ }));
139
+ return;
140
+ }
141
+ let msg;
142
+ try {
143
+ msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
144
+ } catch {
145
+ socket.send(JSON.stringify({
146
+ type: "error",
147
+ error: "Invalid message format"
148
+ }));
149
+ return;
150
+ }
151
+ switch (msg.type) {
152
+ case "subscribe": {
153
+ const room = msg.resource ?? msg.channel;
154
+ if (!room) break;
155
+ if (client.subscriptions.size >= options.maxSubscriptionsPerClient) {
156
+ socket.send(JSON.stringify({
157
+ type: "error",
158
+ channel: room,
159
+ error: "Subscription limit reached"
160
+ }));
161
+ break;
162
+ }
163
+ if (options.roomPolicy) {
164
+ if (!await options.roomPolicy(client, room)) {
165
+ socket.send(JSON.stringify({
166
+ type: "error",
167
+ channel: room,
168
+ error: "Subscription denied"
169
+ }));
170
+ break;
171
+ }
172
+ }
173
+ const ok = rooms.subscribe(clientId, room);
174
+ socket.send(JSON.stringify({
175
+ type: ok ? "subscribed" : "error",
176
+ channel: room,
177
+ ...ok ? {} : { error: "Room at capacity" }
178
+ }));
179
+ break;
180
+ }
181
+ case "unsubscribe": {
182
+ const room = msg.resource ?? msg.channel;
183
+ if (room) {
184
+ rooms.unsubscribe(clientId, room);
185
+ socket.send(JSON.stringify({
186
+ type: "unsubscribed",
187
+ channel: room
188
+ }));
189
+ }
190
+ break;
191
+ }
192
+ case "pong": break;
193
+ default:
194
+ await options.onMessage?.(client, msg);
195
+ break;
196
+ }
197
+ });
198
+ socket.on("close", async () => {
199
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
200
+ if (reauthTimer) clearInterval(reauthTimer);
201
+ await options.onDisconnect?.(client);
202
+ rooms.removeClient(clientId);
203
+ });
204
+ socket.on("error", () => {
205
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
206
+ if (reauthTimer) clearInterval(reauthTimer);
207
+ rooms.removeClient(clientId);
208
+ });
209
+ }
210
+ //#endregion
211
+ //#region src/integrations/websocket/event-bridge.ts
212
+ /**
213
+ * Subscribe to `<resource>.created|updated|deleted` events for every
214
+ * resource in `resources` and fan them out to the matching room (same
215
+ * name as the resource). Returns the unsubscriber list so the caller can
216
+ * clean up on `onClose`.
217
+ *
218
+ * Org-scoped events (events carrying `meta.organizationId`) broadcast
219
+ * only to clients in that org; unscoped events broadcast to every
220
+ * subscriber of the room.
221
+ */
222
+ async function wireResourceEvents(fastify, rooms, resources) {
223
+ const unsubscribers = [];
224
+ const events = fastify.events;
225
+ if (!events?.subscribe) return unsubscribers;
226
+ if (resources.length === 0) return unsubscribers;
227
+ const subscribe = events.subscribe;
228
+ for (const resourceName of resources) for (const op of [
229
+ "created",
230
+ "updated",
231
+ "deleted"
232
+ ]) {
233
+ const unsub = await subscribe(`${resourceName}.${op}`, (event) => {
234
+ const room = resourceName;
235
+ const payload = JSON.stringify({
236
+ type: `${resourceName}.${op}`,
237
+ data: event.payload,
238
+ meta: {
239
+ timestamp: event.meta?.timestamp,
240
+ userId: event.meta?.userId,
241
+ organizationId: event.meta?.organizationId
242
+ }
243
+ });
244
+ if (event.meta?.organizationId) rooms.broadcastToOrgWithAdapter(event.meta.organizationId, room, payload);
245
+ else rooms.broadcastWithAdapter(room, payload);
246
+ });
247
+ unsubscribers.push(unsub);
248
+ }
249
+ return unsubscribers;
250
+ }
251
+ /**
252
+ * Register the optional `{path}/stats` endpoint.
253
+ *
254
+ * - `false` (default): no registration
255
+ * - `true`: open endpoint
256
+ * - `'authenticated'`: guarded by `fastify.authenticate` if registered;
257
+ * silently skipped with a warn when it isn't (doesn't fail boot — the
258
+ * stats endpoint is diagnostic, not load-bearing)
259
+ */
260
+ function registerStatsRoute(fastify, rooms, path, expose) {
261
+ if (expose === true) {
262
+ fastify.get(`${path}/stats`, async () => ({
263
+ success: true,
264
+ data: rooms.getStats()
265
+ }));
266
+ return;
267
+ }
268
+ if (expose === "authenticated") if (fastify.hasDecorator("authenticate")) {
269
+ const authenticate = fastify.authenticate;
270
+ fastify.get(`${path}/stats`, { preHandler: authenticate }, async () => ({
271
+ success: true,
272
+ data: rooms.getStats()
273
+ }));
274
+ } else fastify.log.warn("arc-websocket: exposeStats is \"authenticated\" but fastify.authenticate is not registered — stats endpoint skipped");
275
+ }
276
+ //#endregion
277
+ //#region src/integrations/websocket/room-manager.ts
13
278
  var RoomManager = class {
14
279
  rooms = /* @__PURE__ */ new Map();
15
280
  clients = /* @__PURE__ */ new Map();
@@ -106,11 +371,12 @@ var RoomManager = class {
106
371
  };
107
372
  }
108
373
  };
374
+ //#endregion
375
+ //#region src/integrations/websocket/plugin.ts
109
376
  const websocketPluginImpl = async (fastify, options) => {
110
- let clientCounter = 0;
111
377
  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;
112
378
  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.");
113
- const rooms = new RoomManager(maxClientsPerRoom, adapter);
379
+ const rooms = new RoomManager(maxClientsPerRoom, adapter ?? new LocalWebSocketAdapter());
114
380
  if (adapter) await adapter.subscribe((room, message) => {
115
381
  if (room.startsWith("org:")) {
116
382
  const parts = room.split(":");
@@ -139,229 +405,31 @@ const websocketPluginImpl = async (fastify, options) => {
139
405
  },
140
406
  getStats: () => rooms.getStats()
141
407
  });
142
- const eventUnsubscribers = [];
143
- if (resources.length > 0 && fastify.events?.subscribe) for (const resourceName of resources) for (const op of [
144
- "created",
145
- "updated",
146
- "deleted"
147
- ]) {
148
- const unsub = await fastify.events.subscribe(`${resourceName}.${op}`, async (event) => {
149
- const room = resourceName;
150
- const payload = JSON.stringify({
151
- type: `${resourceName}.${op}`,
152
- data: event.payload,
153
- meta: {
154
- timestamp: event.meta?.timestamp,
155
- userId: event.meta?.userId,
156
- organizationId: event.meta?.organizationId
157
- }
158
- });
159
- if (event.meta?.organizationId) rooms.broadcastToOrgWithAdapter(event.meta.organizationId, room, payload);
160
- else rooms.broadcastWithAdapter(room, payload);
161
- });
162
- eventUnsubscribers.push(unsub);
163
- }
164
- fastify.get(path, { websocket: true }, async (socket, request) => {
165
- const clientId = `ws_${++clientCounter}_${Date.now()}`;
166
- let userId;
167
- let organizationId;
168
- let serviceClientId;
169
- let serviceScopes;
170
- if (auth) if (customAuth) {
171
- const result = await customAuth(request);
172
- if (!result) {
173
- socket.close(4001, "Unauthorized");
174
- return;
175
- }
176
- userId = result.userId;
177
- organizationId = result.organizationId;
178
- serviceClientId = result.clientId;
179
- serviceScopes = result.scopes;
180
- } else {
181
- if (fastify.authenticate) try {
182
- let rejected = false;
183
- const fakeReply = {
184
- code(_statusCode) {
185
- rejected = true;
186
- return fakeReply;
187
- },
188
- send() {
189
- return fakeReply;
190
- },
191
- sent: false
192
- };
193
- await fastify.authenticate(request, fakeReply);
194
- if (rejected) {
195
- socket.close(4001, "Unauthorized");
196
- return;
197
- }
198
- } catch {
199
- socket.close(4001, "Unauthorized");
200
- return;
201
- }
202
- if (request.user) {
203
- userId = request.user.id ?? request.user.sub;
204
- organizationId = request.scope?.organizationId;
205
- } else {
206
- socket.close(4001, "Unauthorized");
207
- return;
208
- }
408
+ const eventUnsubscribers = await wireResourceEvents(fastify, rooms, resources);
409
+ let clientCounter = 0;
410
+ const ctx = {
411
+ fastify,
412
+ rooms,
413
+ nextClientId: () => `ws_${++clientCounter}_${Date.now()}`,
414
+ options: {
415
+ auth,
416
+ resources,
417
+ heartbeatInterval,
418
+ maxClientsPerRoom,
419
+ maxMessageBytes,
420
+ maxSubscriptionsPerClient,
421
+ reauthInterval,
422
+ authenticate: customAuth,
423
+ roomPolicy,
424
+ onConnect,
425
+ onDisconnect,
426
+ onMessage
209
427
  }
210
- const client = {
211
- id: clientId,
212
- socket,
213
- subscriptions: /* @__PURE__ */ new Set(),
214
- userId,
215
- organizationId,
216
- ...serviceClientId ? { clientId: serviceClientId } : {},
217
- ...serviceScopes ? { scopes: serviceScopes } : {}
218
- };
219
- rooms.addClient(client);
220
- await onConnect?.(client);
221
- socket.send(JSON.stringify({
222
- type: "connected",
223
- clientId,
224
- resources
225
- }));
226
- let heartbeatTimer;
227
- if (heartbeatInterval > 0) heartbeatTimer = setInterval(() => {
228
- if (socket.readyState === 1) socket.send(JSON.stringify({
229
- type: "ping",
230
- timestamp: Date.now()
231
- }));
232
- }, heartbeatInterval);
233
- let reauthTimer;
234
- if (reauthInterval > 0 && auth) reauthTimer = setInterval(async () => {
235
- if (socket.readyState !== 1) return;
236
- try {
237
- if (customAuth) {
238
- if (!await customAuth(request)) {
239
- socket.send(JSON.stringify({
240
- type: "error",
241
- error: "Session expired"
242
- }));
243
- socket.close(4003, "Session expired");
244
- return;
245
- }
246
- } else if (fastify.authenticate) {
247
- let rejected = false;
248
- const fakeReply = {
249
- code() {
250
- rejected = true;
251
- return fakeReply;
252
- },
253
- send() {
254
- return fakeReply;
255
- },
256
- sent: false
257
- };
258
- await fastify.authenticate(request, fakeReply);
259
- if (rejected) {
260
- socket.send(JSON.stringify({
261
- type: "error",
262
- error: "Session expired"
263
- }));
264
- socket.close(4003, "Session expired");
265
- return;
266
- }
267
- }
268
- } catch {
269
- socket.send(JSON.stringify({
270
- type: "error",
271
- error: "Session expired"
272
- }));
273
- socket.close(4003, "Session expired");
274
- }
275
- }, reauthInterval);
276
- socket.on("message", async (raw) => {
277
- if ((typeof raw === "string" ? Buffer.byteLength(raw) : raw.length) > maxMessageBytes) {
278
- socket.send(JSON.stringify({
279
- type: "error",
280
- error: "Message too large"
281
- }));
282
- return;
283
- }
284
- try {
285
- const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
286
- switch (msg.type) {
287
- case "subscribe": {
288
- const room = msg.resource ?? msg.channel;
289
- if (room) {
290
- if (client.subscriptions.size >= maxSubscriptionsPerClient) {
291
- socket.send(JSON.stringify({
292
- type: "error",
293
- channel: room,
294
- error: "Subscription limit reached"
295
- }));
296
- break;
297
- }
298
- if (roomPolicy) {
299
- if (!await roomPolicy(client, room)) {
300
- socket.send(JSON.stringify({
301
- type: "error",
302
- channel: room,
303
- error: "Subscription denied"
304
- }));
305
- break;
306
- }
307
- }
308
- const ok = rooms.subscribe(clientId, room);
309
- socket.send(JSON.stringify({
310
- type: ok ? "subscribed" : "error",
311
- channel: room,
312
- ...ok ? {} : { error: "Room at capacity" }
313
- }));
314
- }
315
- break;
316
- }
317
- case "unsubscribe": {
318
- const room = msg.resource ?? msg.channel;
319
- if (room) {
320
- rooms.unsubscribe(clientId, room);
321
- socket.send(JSON.stringify({
322
- type: "unsubscribed",
323
- channel: room
324
- }));
325
- }
326
- break;
327
- }
328
- case "pong": break;
329
- default:
330
- await onMessage?.(client, msg);
331
- break;
332
- }
333
- } catch {
334
- socket.send(JSON.stringify({
335
- type: "error",
336
- error: "Invalid message format"
337
- }));
338
- }
339
- });
340
- socket.on("close", async () => {
341
- if (heartbeatTimer) clearInterval(heartbeatTimer);
342
- if (reauthTimer) clearInterval(reauthTimer);
343
- await onDisconnect?.(client);
344
- rooms.removeClient(clientId);
345
- });
346
- socket.on("error", () => {
347
- if (heartbeatTimer) clearInterval(heartbeatTimer);
348
- if (reauthTimer) clearInterval(reauthTimer);
349
- rooms.removeClient(clientId);
350
- });
351
- });
352
- if (exposeStats === true) fastify.get(`${path}/stats`, async () => {
353
- return {
354
- success: true,
355
- data: rooms.getStats()
356
- };
357
- });
358
- else if (exposeStats === "authenticated") if (fastify.hasDecorator("authenticate")) fastify.get(`${path}/stats`, { preHandler: fastify.authenticate }, async () => {
359
- return {
360
- success: true,
361
- data: rooms.getStats()
362
- };
428
+ };
429
+ fastify.get(path, { websocket: true }, async (socket, request) => {
430
+ await handleConnection(ctx, socket, request);
363
431
  });
364
- else fastify.log.warn("arc-websocket: exposeStats is \"authenticated\" but fastify.authenticate is not registered — stats endpoint skipped");
432
+ registerStatsRoute(fastify, rooms, path, exposeStats);
365
433
  fastify.addHook("onClose", async () => {
366
434
  for (const unsub of eventUnsubscribers) unsub();
367
435
  eventUnsubscribers.length = 0;
@@ -30,6 +30,8 @@ function stableStringify(value) {
30
30
  if (value === null || value === void 0) return "";
31
31
  if (typeof value !== "object") return String(value);
32
32
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
33
+ if (value instanceof RegExp) return `/${value.source}/${value.flags}`;
34
+ if (value instanceof Date) return `d${value.getTime()}`;
33
35
  return "{" + Object.keys(value).sort().map((k) => `${k}:${stableStringify(value[k])}`).join(",") + "}";
34
36
  }
35
37
  function djb2(str) {
@@ -59,7 +59,8 @@ var loadResources_exports = /* @__PURE__ */ __exportAll({ loadResources: () => l
59
59
  */
60
60
  async function loadResources(dir, options = {}) {
61
61
  const { suffix = ".resource", recursive = true, exclude, include, silent = false } = options;
62
- const files = await collectFiles(resolve(dir.startsWith("file://") ? dirname(fileURLToPath(dir)) : dir), new RegExp(`${escapeRegex(suffix)}\\.(ts|js|mts|mjs)$`), recursive);
62
+ const absDir = resolve(dir.startsWith("file://") ? dirname(fileURLToPath(dir)) : dir);
63
+ const files = await collectFiles(absDir, new RegExp(`${escapeRegex(suffix)}\\.(ts|js|mts|mjs)$`), recursive);
63
64
  files.sort();
64
65
  const includeSet = include ? new Set(include) : null;
65
66
  const excludeSet = exclude ? new Set(exclude) : null;
@@ -128,6 +129,7 @@ async function loadResources(dir, options = {}) {
128
129
  log.warn(`[arc] loadResources: ${skipped.length} file(s) skipped (no default export with toPlugin):`);
129
130
  for (const f of skipped) log.warn(` - ${f}`);
130
131
  }
132
+ if (resources.length === 0 && files.length === 0) log.warn(`[arc] loadResources: 0 matching files found at "${absDir}" (pattern: *${suffix}.{ts,js,mts,mjs}). Check the path and runtime layout (src/ vs dist/).`);
131
133
  }
132
134
  return resources;
133
135
  }
@@ -0,0 +1,81 @@
1
+ //#region src/logger/index.d.ts
2
+ /**
3
+ * Arc Logger — Centralized debug & warning system
4
+ *
5
+ * Lightweight, zero-dependency logger for Arc framework internals.
6
+ * Inspired by the `debug` npm package — disabled by default, opt-in via
7
+ * environment variable or `createApp({ debug })` option.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * // Enable via env var
12
+ * ARC_DEBUG=1 node server.js // all modules
13
+ * ARC_DEBUG=scope,elevation node server.js // specific modules
14
+ *
15
+ * // Enable via createApp
16
+ * const app = await createApp({ debug: true });
17
+ * const app = await createApp({ debug: 'scope,elevation' });
18
+ *
19
+ * // Suppress warnings (not recommended)
20
+ * ARC_SUPPRESS_WARNINGS=1 node server.js
21
+ *
22
+ * // Framework internals use:
23
+ * import { arcLog } from '../logger/index.js';
24
+ * const log = arcLog('elevation');
25
+ * log.debug('Elevation applied', { userId });
26
+ * log.warn('Something unexpected');
27
+ * ```
28
+ */
29
+ interface ArcLoggerOptions {
30
+ /**
31
+ * Enable debug output.
32
+ * - `true` or `'*'` — all modules
33
+ * - `string` — comma-separated module names (e.g., `'scope,elevation'`)
34
+ * - `false` — disabled (default)
35
+ */
36
+ debug?: boolean | string;
37
+ /**
38
+ * Custom log writer. Defaults to `console`.
39
+ * Useful for routing Arc logs into Fastify's pino logger or test fixtures.
40
+ */
41
+ writer?: ArcLogWriter;
42
+ }
43
+ interface ArcLogWriter {
44
+ debug: (...args: unknown[]) => void;
45
+ info: (...args: unknown[]) => void;
46
+ warn: (...args: unknown[]) => void;
47
+ error: (...args: unknown[]) => void;
48
+ }
49
+ interface ArcLogger {
50
+ debug: (...args: unknown[]) => void;
51
+ info: (...args: unknown[]) => void;
52
+ warn: (...args: unknown[]) => void;
53
+ error: (...args: unknown[]) => void;
54
+ }
55
+ /**
56
+ * Configure the Arc logger globally.
57
+ *
58
+ * Called automatically by `createApp({ debug })`, but can also be
59
+ * called manually for standalone usage outside of `createApp`.
60
+ */
61
+ declare function configureArcLogger(options: ArcLoggerOptions): void;
62
+ /**
63
+ * Create a module-scoped logger.
64
+ *
65
+ * Debug and info messages are gated by the `ARC_DEBUG` env var or
66
+ * `createApp({ debug })` option. Warnings always show (unless
67
+ * `ARC_SUPPRESS_WARNINGS=1`). Errors always show.
68
+ *
69
+ * @param module - Module name (e.g., 'scope', 'elevation', 'sse', 'preset')
70
+ * @returns Logger instance for that module
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * const log = arcLog('elevation');
75
+ * log.debug('Checking elevation header');
76
+ * log.warn('No authenticate decorator found');
77
+ * ```
78
+ */
79
+ declare function arcLog(module: string): ArcLogger;
80
+ //#endregion
81
+ export { ArcLogWriter, ArcLogger, ArcLoggerOptions, arcLog, configureArcLogger };
@@ -1,9 +1,4 @@
1
- import { t as __exportAll } from "./chunk-BpYLSNr0.mjs";
2
1
  //#region src/logger/index.ts
3
- var logger_exports = /* @__PURE__ */ __exportAll({
4
- arcLog: () => arcLog,
5
- configureArcLogger: () => configureArcLogger
6
- });
7
2
  let globalOptions = {};
8
3
  /**
9
4
  * Configure the Arc logger globally.
@@ -73,4 +68,4 @@ function isSuppressed() {
73
68
  return env === "1" || env === "true";
74
69
  }
75
70
  //#endregion
76
- export { configureArcLogger as n, logger_exports as r, arcLog as t };
71
+ export { arcLog, configureArcLogger };