@classytic/arc 2.3.0 → 2.4.2

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 (175) hide show
  1. package/README.md +187 -18
  2. package/bin/arc.js +11 -3
  3. package/dist/BaseController-CkM5dUh_.mjs +1031 -0
  4. package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
  5. package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
  6. package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
  7. package/dist/adapters/index.d.mts +3 -5
  8. package/dist/adapters/index.mjs +2 -3
  9. package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
  10. package/dist/audit/index.d.mts +4 -7
  11. package/dist/audit/index.mjs +2 -29
  12. package/dist/audit/mongodb.d.mts +1 -4
  13. package/dist/audit/mongodb.mjs +2 -3
  14. package/dist/auth/index.d.mts +7 -9
  15. package/dist/auth/index.mjs +65 -63
  16. package/dist/auth/redis-session.d.mts +1 -1
  17. package/dist/auth/redis-session.mjs +1 -2
  18. package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
  19. package/dist/cache/index.d.mts +23 -23
  20. package/dist/cache/index.mjs +4 -6
  21. package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
  22. package/dist/chunk-BpYLSNr0.mjs +14 -0
  23. package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
  24. package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
  25. package/dist/cli/commands/describe.mjs +24 -7
  26. package/dist/cli/commands/docs.mjs +6 -7
  27. package/dist/cli/commands/doctor.d.mts +10 -0
  28. package/dist/cli/commands/doctor.mjs +156 -0
  29. package/dist/cli/commands/generate.mjs +66 -17
  30. package/dist/cli/commands/init.mjs +315 -45
  31. package/dist/cli/commands/introspect.mjs +2 -4
  32. package/dist/cli/index.d.mts +1 -10
  33. package/dist/cli/index.mjs +4 -153
  34. package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
  35. package/dist/core/index.d.mts +3 -5
  36. package/dist/core/index.mjs +5 -4
  37. package/dist/core-C1XCMtqM.mjs +185 -0
  38. package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
  39. package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
  40. package/dist/discovery/index.mjs +37 -5
  41. package/dist/docs/index.d.mts +6 -9
  42. package/dist/docs/index.mjs +3 -21
  43. package/dist/dynamic/index.d.mts +93 -0
  44. package/dist/dynamic/index.mjs +122 -0
  45. package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
  46. package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
  47. package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
  48. package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
  49. package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
  50. package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
  51. package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
  52. package/dist/events/index.d.mts +72 -7
  53. package/dist/events/index.mjs +216 -4
  54. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  55. package/dist/events/transports/redis-stream-entry.mjs +19 -7
  56. package/dist/events/transports/redis.d.mts +1 -1
  57. package/dist/events/transports/redis.mjs +3 -4
  58. package/dist/factory/index.d.mts +23 -9
  59. package/dist/factory/index.mjs +48 -3
  60. package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
  61. package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
  62. package/dist/hooks/index.d.mts +1 -3
  63. package/dist/hooks/index.mjs +2 -3
  64. package/dist/idempotency/index.d.mts +5 -5
  65. package/dist/idempotency/index.mjs +3 -7
  66. package/dist/idempotency/mongodb.d.mts +1 -1
  67. package/dist/idempotency/mongodb.mjs +4 -5
  68. package/dist/idempotency/redis.d.mts +1 -1
  69. package/dist/idempotency/redis.mjs +2 -5
  70. package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
  71. package/dist/index-Diqcm14c.d.mts +369 -0
  72. package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
  73. package/dist/index.d.mts +100 -105
  74. package/dist/index.mjs +85 -58
  75. package/dist/integrations/event-gateway.d.mts +1 -1
  76. package/dist/integrations/event-gateway.mjs +8 -4
  77. package/dist/integrations/index.d.mts +4 -2
  78. package/dist/integrations/index.mjs +1 -1
  79. package/dist/integrations/jobs.d.mts +2 -2
  80. package/dist/integrations/jobs.mjs +63 -14
  81. package/dist/integrations/mcp/index.d.mts +219 -0
  82. package/dist/integrations/mcp/index.mjs +572 -0
  83. package/dist/integrations/mcp/testing.d.mts +53 -0
  84. package/dist/integrations/mcp/testing.mjs +104 -0
  85. package/dist/integrations/streamline.mjs +39 -19
  86. package/dist/integrations/webhooks.d.mts +56 -0
  87. package/dist/integrations/webhooks.mjs +139 -0
  88. package/dist/integrations/websocket-redis.d.mts +46 -0
  89. package/dist/integrations/websocket-redis.mjs +50 -0
  90. package/dist/integrations/websocket.d.mts +68 -2
  91. package/dist/integrations/websocket.mjs +96 -13
  92. package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
  93. package/dist/interface-DGmPxakH.d.mts +2213 -0
  94. package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
  95. package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
  96. package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
  97. package/dist/metrics-Csh4nsvv.mjs +224 -0
  98. package/dist/migrations/index.d.mts +113 -44
  99. package/dist/migrations/index.mjs +84 -102
  100. package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
  101. package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
  102. package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
  103. package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
  104. package/dist/org/index.d.mts +12 -14
  105. package/dist/org/index.mjs +92 -119
  106. package/dist/org/types.d.mts +2 -2
  107. package/dist/org/types.mjs +1 -1
  108. package/dist/permissions/index.d.mts +4 -278
  109. package/dist/permissions/index.mjs +4 -579
  110. package/dist/permissions-CA5zg0yK.mjs +751 -0
  111. package/dist/plugins/index.d.mts +104 -107
  112. package/dist/plugins/index.mjs +203 -313
  113. package/dist/plugins/response-cache.mjs +4 -69
  114. package/dist/plugins/tracing-entry.d.mts +1 -1
  115. package/dist/plugins/tracing-entry.mjs +24 -11
  116. package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
  117. package/dist/policies/index.d.mts +2 -2
  118. package/dist/policies/index.mjs +80 -83
  119. package/dist/presets/index.d.mts +26 -19
  120. package/dist/presets/index.mjs +2 -142
  121. package/dist/presets/multiTenant.d.mts +1 -4
  122. package/dist/presets/multiTenant.mjs +4 -6
  123. package/dist/presets-C9QXJV1u.mjs +422 -0
  124. package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
  125. package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
  126. package/dist/queryParser-CgCtsjti.mjs +352 -0
  127. package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
  128. package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
  129. package/dist/registry/index.d.mts +1 -4
  130. package/dist/registry/index.mjs +3 -4
  131. package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
  132. package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
  133. package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
  134. package/dist/rpc/index.d.mts +90 -0
  135. package/dist/rpc/index.mjs +248 -0
  136. package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
  137. package/dist/schemas/index.d.mts +30 -30
  138. package/dist/schemas/index.mjs +2 -4
  139. package/dist/scope/index.d.mts +13 -2
  140. package/dist/scope/index.mjs +18 -5
  141. package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
  142. package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
  143. package/dist/testing/index.d.mts +551 -567
  144. package/dist/testing/index.mjs +1744 -1799
  145. package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
  146. package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
  147. package/dist/types/index.d.mts +4 -946
  148. package/dist/types/index.mjs +2 -4
  149. package/dist/types-BJmgxNbF.d.mts +275 -0
  150. package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
  151. package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
  152. package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
  153. package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
  154. package/dist/utils/index.d.mts +254 -351
  155. package/dist/utils/index.mjs +7 -6
  156. package/dist/utils-Dc0WhlIl.mjs +594 -0
  157. package/dist/versioning-BzfeHmhj.mjs +37 -0
  158. package/package.json +44 -10
  159. package/skills/arc/SKILL.md +518 -0
  160. package/skills/arc/references/auth.md +250 -0
  161. package/skills/arc/references/events.md +272 -0
  162. package/skills/arc/references/integrations.md +385 -0
  163. package/skills/arc/references/mcp.md +431 -0
  164. package/skills/arc/references/production.md +610 -0
  165. package/skills/arc/references/testing.md +183 -0
  166. package/dist/audited-CGdLiSlE.mjs +0 -140
  167. package/dist/chunk-C7Uep-_p.mjs +0 -20
  168. package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
  169. package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
  170. package/dist/interface-BtdYtQUA.d.mts +0 -1114
  171. package/dist/presets-BTeYbw7h.d.mts +0 -57
  172. package/dist/presets-CeFtfDR8.mjs +0 -119
  173. /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
  174. /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
  175. /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
@@ -0,0 +1,104 @@
1
+ import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-PMFE8HIv.mjs";
2
+ //#region src/integrations/mcp/testing.ts
3
+ /**
4
+ * @classytic/arc/mcp/testing — MCP Test Utilities
5
+ *
6
+ * Helpers for testing MCP tool integration without raw JSON-RPC parsing.
7
+ * Uses the MCP SDK's InMemoryTransport for fast, in-process testing.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { createTestMcpClient } from '@classytic/arc/mcp/testing';
12
+ *
13
+ * const client = await createTestMcpClient({
14
+ * pluginOptions: { resources: [productResource] },
15
+ * auth: { userId: 'test-user', organizationId: 'org-1' },
16
+ * });
17
+ *
18
+ * const tools = await client.listTools();
19
+ * const result = await client.callTool('list_products', { limit: 5 });
20
+ * await client.close();
21
+ * ```
22
+ */
23
+ /**
24
+ * Create an in-process MCP test client connected to an Arc MCP server.
25
+ *
26
+ * Pass resources and tools directly — no running Fastify server needed.
27
+ * For HTTP-level integration tests against a running server, use `app.inject()` instead.
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const client = await createTestMcpClient({
32
+ * pluginOptions: { resources: [productResource], extraTools: [myTool] },
33
+ * auth: { userId: 'test-user', organizationId: 'org-1' },
34
+ * });
35
+ *
36
+ * const tools = await client.listTools();
37
+ * expect(tools.map(t => t.name)).toContain('list_products');
38
+ *
39
+ * const result = await client.callTool('list_products', { limit: 5 });
40
+ * expect(result.isError).toBeFalsy();
41
+ *
42
+ * await client.close();
43
+ * ```
44
+ */
45
+ async function createTestMcpClient(options = {}) {
46
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
47
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
48
+ const pluginOpts = {
49
+ resources: [],
50
+ ...options.pluginOptions
51
+ };
52
+ const auth = options.auth ?? { userId: "test-user" };
53
+ const serverName = options.serverName ?? "test-mcp";
54
+ const overrides = pluginOpts.overrides ?? {};
55
+ let enabledResources = pluginOpts.resources ?? [];
56
+ if (pluginOpts.include) {
57
+ const includeSet = new Set(pluginOpts.include);
58
+ enabledResources = enabledResources.filter((r) => includeSet.has(r.name));
59
+ } else if (pluginOpts.exclude) {
60
+ const excludeSet = new Set(pluginOpts.exclude);
61
+ enabledResources = enabledResources.filter((r) => !excludeSet.has(r.name));
62
+ }
63
+ const tools = enabledResources.flatMap((r) => {
64
+ const resOverrides = overrides[r.name] ?? {};
65
+ return resourceToTools(r, {
66
+ ...resOverrides,
67
+ toolNamePrefix: resOverrides.toolNamePrefix ?? pluginOpts.toolNamePrefix
68
+ });
69
+ });
70
+ if (pluginOpts.extraTools) tools.push(...pluginOpts.extraTools);
71
+ const authRef = { current: auth };
72
+ const server = await createMcpServer({
73
+ name: serverName,
74
+ version: "1.0.0",
75
+ instructions: pluginOpts.instructions,
76
+ tools,
77
+ prompts: pluginOpts.extraPrompts
78
+ }, authRef);
79
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
80
+ const client = new Client({
81
+ name: "test-client",
82
+ version: "1.0"
83
+ });
84
+ await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
85
+ return {
86
+ async listTools() {
87
+ return (await client.listTools()).tools.map((t) => ({
88
+ name: t.name,
89
+ description: t.description
90
+ }));
91
+ },
92
+ async callTool(name, args) {
93
+ return await client.callTool({
94
+ name,
95
+ arguments: args ?? {}
96
+ });
97
+ },
98
+ async close() {
99
+ await client.close();
100
+ }
101
+ };
102
+ }
103
+ //#endregion
104
+ export { createTestMcpClient };
@@ -24,11 +24,18 @@ const streamlinePluginImpl = async (fastify, options) => {
24
24
  });
25
25
  const { input, meta } = request.body ?? {};
26
26
  const run = await wf.start(input, meta);
27
- if (bridgeEvents && fastify.events?.publish) await fastify.events.publish(`workflow.${id}.started`, {
28
- runId: run._id,
29
- workflowId: id,
30
- status: run.status
31
- });
27
+ if (bridgeEvents && fastify.events?.publish) try {
28
+ await fastify.events.publish(`workflow.${id}.started`, {
29
+ runId: run._id,
30
+ workflowId: id,
31
+ status: run.status
32
+ });
33
+ } catch (err) {
34
+ fastify.log.warn({
35
+ err,
36
+ workflowId: id
37
+ }, "Failed to publish workflow.started event");
38
+ }
32
39
  return reply.status(201).send({
33
40
  success: true,
34
41
  data: run
@@ -58,11 +65,18 @@ const streamlinePluginImpl = async (fastify, options) => {
58
65
  const { runId } = request.params;
59
66
  const { payload } = request.body ?? {};
60
67
  const run = await wf.resume(runId, payload);
61
- if (bridgeEvents && fastify.events?.publish) await fastify.events.publish(`workflow.${id}.resumed`, {
62
- runId: run._id,
63
- workflowId: id,
64
- status: run.status
65
- });
68
+ if (bridgeEvents && fastify.events?.publish) try {
69
+ await fastify.events.publish(`workflow.${id}.resumed`, {
70
+ runId: run._id,
71
+ workflowId: id,
72
+ status: run.status
73
+ });
74
+ } catch (err) {
75
+ fastify.log.warn({
76
+ err,
77
+ workflowId: id
78
+ }, "Failed to publish workflow.resumed event");
79
+ }
66
80
  return {
67
81
  success: true,
68
82
  data: run
@@ -75,20 +89,27 @@ const streamlinePluginImpl = async (fastify, options) => {
75
89
  });
76
90
  const { runId } = request.params;
77
91
  const run = await wf.cancel(runId);
78
- if (bridgeEvents && fastify.events?.publish) await fastify.events.publish(`workflow.${id}.cancelled`, {
79
- runId: run._id,
80
- workflowId: id
81
- });
92
+ if (bridgeEvents && fastify.events?.publish) try {
93
+ await fastify.events.publish(`workflow.${id}.cancelled`, {
94
+ runId: run._id,
95
+ workflowId: id
96
+ });
97
+ } catch (err) {
98
+ fastify.log.warn({
99
+ err,
100
+ workflowId: id
101
+ }, "Failed to publish workflow.cancelled event");
102
+ }
82
103
  return {
83
104
  success: true,
84
105
  data: run
85
106
  };
86
107
  });
87
- if (wf.engine.pause) fastify.post(`${routePrefix}/runs/:runId/pause`, { preHandler: authPreHandler }, async (request, reply) => {
108
+ if (wf.engine.pause) fastify.post(`${routePrefix}/runs/:runId/pause`, { preHandler: authPreHandler }, async (request, _reply) => {
88
109
  const { runId } = request.params;
89
110
  return {
90
111
  success: true,
91
- data: await wf.engine.pause(runId)
112
+ data: await wf.engine.pause?.(runId)
92
113
  };
93
114
  });
94
115
  if (wf.engine.rewindTo) fastify.post(`${routePrefix}/runs/:runId/rewind`, { preHandler: authPreHandler }, async (request, reply) => {
@@ -100,7 +121,7 @@ const streamlinePluginImpl = async (fastify, options) => {
100
121
  });
101
122
  return {
102
123
  success: true,
103
- data: await wf.engine.rewindTo(runId, stepId)
124
+ data: await wf.engine.rewindTo?.(runId, stepId)
104
125
  };
105
126
  });
106
127
  }
@@ -120,6 +141,5 @@ const streamlinePluginImpl = async (fastify, options) => {
120
141
  };
121
142
  /** Pluggable streamline integration for Arc */
122
143
  const streamlinePlugin = streamlinePluginImpl;
123
-
124
144
  //#endregion
125
- export { streamlinePlugin as default, streamlinePlugin };
145
+ export { streamlinePlugin as default, streamlinePlugin };
@@ -0,0 +1,56 @@
1
+ import { FastifyPluginAsync } from "fastify";
2
+
3
+ //#region src/integrations/webhooks.d.ts
4
+ interface WebhookSubscription {
5
+ /** Unique subscription ID */
6
+ id: string;
7
+ /** Delivery URL */
8
+ url: string;
9
+ /** Event patterns (e.g., 'order.created', 'order.*', '*') */
10
+ events: string[];
11
+ /** Shared secret for HMAC-SHA256 signing */
12
+ secret: string;
13
+ /** Optional metadata */
14
+ metadata?: Record<string, unknown>;
15
+ }
16
+ interface WebhookDeliveryRecord {
17
+ subscriptionId: string;
18
+ eventType: string;
19
+ success: boolean;
20
+ status?: number;
21
+ error?: string;
22
+ timestamp: Date;
23
+ }
24
+ /** Pluggable persistence — memory for dev, bring your own DB for prod */
25
+ interface WebhookStore {
26
+ readonly name: string;
27
+ getAll(): Promise<WebhookSubscription[]>;
28
+ save(sub: WebhookSubscription): Promise<void>;
29
+ remove(id: string): Promise<void>;
30
+ }
31
+ interface WebhookPluginOptions {
32
+ /** Custom store for persistent subscriptions (default: in-memory) */
33
+ store?: WebhookStore;
34
+ /** Custom fetch (for testing) */
35
+ fetch?: typeof globalThis.fetch;
36
+ /** Delivery timeout in ms (default: 10000) */
37
+ timeout?: number;
38
+ /** Max delivery log entries kept in memory (default: 1000) */
39
+ maxLogEntries?: number;
40
+ }
41
+ interface WebhookManager {
42
+ register(sub: WebhookSubscription): Promise<void> | void;
43
+ unregister(id: string): Promise<void> | void;
44
+ list(): WebhookSubscription[];
45
+ deliveryLog(limit?: number): WebhookDeliveryRecord[];
46
+ }
47
+ declare module "fastify" {
48
+ interface FastifyInstance {
49
+ webhooks: WebhookManager;
50
+ }
51
+ }
52
+ declare function signPayload(payload: string, secret: string): string;
53
+ declare const webhookPlugin: FastifyPluginAsync<WebhookPluginOptions>;
54
+ declare const _default: FastifyPluginAsync<WebhookPluginOptions>;
55
+ //#endregion
56
+ export { WebhookDeliveryRecord, WebhookManager, WebhookPluginOptions, WebhookStore, WebhookSubscription, _default as default, signPayload, webhookPlugin };
@@ -0,0 +1,139 @@
1
+ import { createHmac } from "node:crypto";
2
+ import fp from "fastify-plugin";
3
+ //#region src/integrations/webhooks.ts
4
+ /**
5
+ * @classytic/arc — Webhook Outbound Integration
6
+ *
7
+ * Fastify plugin that auto-dispatches Arc events to registered webhook
8
+ * endpoints with HMAC-SHA256 signing, delivery logging, and pluggable
9
+ * persistence via WebhookStore.
10
+ *
11
+ * This is a SEPARATE subpath import — only loaded when explicitly used:
12
+ * import { webhookPlugin } from '@classytic/arc/integrations/webhooks';
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { webhookPlugin } from '@classytic/arc/integrations/webhooks';
17
+ *
18
+ * await fastify.register(webhookPlugin);
19
+ *
20
+ * // Register a customer webhook
21
+ * app.webhooks.register({
22
+ * id: 'wh-1',
23
+ * url: 'https://customer.com/webhook',
24
+ * events: ['order.created', 'order.shipped'],
25
+ * secret: 'whsec_abc123',
26
+ * });
27
+ *
28
+ * // Events auto-dispatch — no manual wiring needed
29
+ * await app.events.publish('order.created', { orderId: '123' });
30
+ * // → POST https://customer.com/webhook with HMAC signature
31
+ * ```
32
+ */
33
+ function signPayload(payload, secret) {
34
+ const hmac = createHmac("sha256", secret);
35
+ hmac.update(payload);
36
+ return `sha256=${hmac.digest("hex")}`;
37
+ }
38
+ function matchesPattern(patterns, eventType) {
39
+ for (const pattern of patterns) {
40
+ if (pattern === "*") return true;
41
+ if (pattern === eventType) return true;
42
+ if (pattern.endsWith(".*")) {
43
+ const prefix = pattern.slice(0, -2);
44
+ if (eventType.startsWith(`${prefix}.`)) return true;
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+ var MemoryWebhookStore = class {
50
+ name = "memory";
51
+ subs = /* @__PURE__ */ new Map();
52
+ async getAll() {
53
+ return [...this.subs.values()];
54
+ }
55
+ async save(sub) {
56
+ this.subs.set(sub.id, sub);
57
+ }
58
+ async remove(id) {
59
+ this.subs.delete(id);
60
+ }
61
+ };
62
+ const webhookPlugin = async (fastify, opts = {}) => {
63
+ const store = opts.store ?? new MemoryWebhookStore();
64
+ const fetchFn = opts.fetch ?? globalThis.fetch;
65
+ const timeout = opts.timeout ?? 1e4;
66
+ const maxLogEntries = opts.maxLogEntries ?? 1e3;
67
+ let subscriptions = [];
68
+ const log = [];
69
+ subscriptions = await store.getAll();
70
+ async function dispatchEvent(event) {
71
+ const matching = subscriptions.filter((s) => matchesPattern(s.events, event.type));
72
+ if (matching.length === 0) return;
73
+ const body = JSON.stringify({
74
+ type: event.type,
75
+ payload: event.payload,
76
+ meta: event.meta
77
+ });
78
+ for (const sub of matching) {
79
+ const record = {
80
+ subscriptionId: sub.id,
81
+ eventType: event.type,
82
+ success: false,
83
+ timestamp: /* @__PURE__ */ new Date()
84
+ };
85
+ try {
86
+ const signature = signPayload(body, sub.secret);
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), timeout);
89
+ try {
90
+ const response = await fetchFn(sub.url, {
91
+ method: "POST",
92
+ headers: {
93
+ "content-type": "application/json",
94
+ "x-webhook-signature": signature,
95
+ "x-webhook-id": event.meta.id,
96
+ "x-webhook-event": event.type
97
+ },
98
+ body,
99
+ signal: controller.signal
100
+ });
101
+ record.success = response.ok;
102
+ record.status = response.status;
103
+ } finally {
104
+ clearTimeout(timer);
105
+ }
106
+ } catch (err) {
107
+ record.error = err instanceof Error ? err.message : String(err);
108
+ }
109
+ log.push(record);
110
+ if (log.length > maxLogEntries) log.splice(0, log.length - maxLogEntries);
111
+ }
112
+ }
113
+ if (fastify.events) await fastify.events.subscribe("*", dispatchEvent);
114
+ fastify.decorate("webhooks", {
115
+ async register(sub) {
116
+ await store.save(sub);
117
+ subscriptions = subscriptions.filter((s) => s.id !== sub.id);
118
+ subscriptions.push(sub);
119
+ },
120
+ async unregister(id) {
121
+ await store.remove(id);
122
+ subscriptions = subscriptions.filter((s) => s.id !== id);
123
+ },
124
+ list() {
125
+ return [...subscriptions];
126
+ },
127
+ deliveryLog(limit) {
128
+ if (limit) return log.slice(-limit);
129
+ return [...log];
130
+ }
131
+ });
132
+ };
133
+ var webhooks_default = fp(webhookPlugin, {
134
+ name: "arc-webhooks",
135
+ fastify: "5.x",
136
+ dependencies: ["arc-events"]
137
+ });
138
+ //#endregion
139
+ export { webhooks_default as default, signPayload, webhookPlugin };
@@ -0,0 +1,46 @@
1
+ import { WebSocketAdapter } from "./websocket.mjs";
2
+
3
+ //#region src/integrations/websocket-redis.d.ts
4
+ interface RedisLike {
5
+ publish(channel: string, message: string): Promise<number>;
6
+ subscribe(...channels: string[]): Promise<unknown>;
7
+ on(event: string, handler: (...args: unknown[]) => void): unknown;
8
+ duplicate(): RedisLike;
9
+ quit(): Promise<unknown>;
10
+ }
11
+ interface RedisWebSocketAdapterOptions {
12
+ /**
13
+ * Redis channel for WebSocket broadcasts.
14
+ * @default 'arc-ws'
15
+ */
16
+ channel?: string;
17
+ /**
18
+ * Unique instance ID to prevent echo (receiving own broadcasts).
19
+ * Auto-generated if not provided.
20
+ */
21
+ instanceId?: string;
22
+ }
23
+ /**
24
+ * Redis Pub/Sub adapter for cross-instance WebSocket broadcast.
25
+ *
26
+ * Architecture:
27
+ * 1. Instance A calls broadcastWithAdapter('products', message)
28
+ * 2. RoomManager broadcasts locally + calls adapter.publish()
29
+ * 3. Adapter publishes to Redis channel: { room, message, instanceId }
30
+ * 4. All instances (including A) receive the Redis message
31
+ * 5. Each instance checks instanceId — skips if it's own message (prevents double delivery)
32
+ * 6. Other instances call RoomManager.broadcast() to deliver to their local clients
33
+ */
34
+ declare class RedisWebSocketAdapter implements WebSocketAdapter {
35
+ readonly name = "redis";
36
+ private pub;
37
+ private sub;
38
+ private channel;
39
+ private instanceId;
40
+ constructor(redis: RedisLike, options?: RedisWebSocketAdapterOptions);
41
+ publish(room: string, message: string): Promise<void>;
42
+ subscribe(callback: (room: string, message: string) => void): Promise<void>;
43
+ close(): Promise<void>;
44
+ }
45
+ //#endregion
46
+ export { RedisLike, RedisWebSocketAdapter, RedisWebSocketAdapter as default, RedisWebSocketAdapterOptions };
@@ -0,0 +1,50 @@
1
+ //#region src/integrations/websocket-redis.ts
2
+ /**
3
+ * Redis Pub/Sub adapter for cross-instance WebSocket broadcast.
4
+ *
5
+ * Architecture:
6
+ * 1. Instance A calls broadcastWithAdapter('products', message)
7
+ * 2. RoomManager broadcasts locally + calls adapter.publish()
8
+ * 3. Adapter publishes to Redis channel: { room, message, instanceId }
9
+ * 4. All instances (including A) receive the Redis message
10
+ * 5. Each instance checks instanceId — skips if it's own message (prevents double delivery)
11
+ * 6. Other instances call RoomManager.broadcast() to deliver to their local clients
12
+ */
13
+ var RedisWebSocketAdapter = class {
14
+ name = "redis";
15
+ pub;
16
+ sub;
17
+ channel;
18
+ instanceId;
19
+ constructor(redis, options = {}) {
20
+ const { channel = "arc-ws", instanceId } = options;
21
+ this.channel = channel;
22
+ this.instanceId = instanceId ?? `arc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
23
+ this.pub = redis;
24
+ this.sub = redis.duplicate();
25
+ }
26
+ async publish(room, message) {
27
+ const envelope = JSON.stringify({
28
+ room,
29
+ message,
30
+ instanceId: this.instanceId
31
+ });
32
+ await this.pub.publish(this.channel, envelope);
33
+ }
34
+ async subscribe(callback) {
35
+ this.sub.on("message", (...args) => {
36
+ const [, raw] = args;
37
+ try {
38
+ const envelope = JSON.parse(raw);
39
+ if (envelope.instanceId === this.instanceId) return;
40
+ callback(envelope.room, envelope.message);
41
+ } catch {}
42
+ });
43
+ await this.sub.subscribe(this.channel);
44
+ }
45
+ async close() {
46
+ await this.sub.quit();
47
+ }
48
+ };
49
+ //#endregion
50
+ export { RedisWebSocketAdapter, RedisWebSocketAdapter as default };
@@ -51,24 +51,90 @@ interface WebSocketPluginOptions {
51
51
  maxMessageBytes?: number;
52
52
  /** Maximum subscriptions per client (default: 100). Prevents resource exhaustion. */
53
53
  maxSubscriptionsPerClient?: number;
54
+ /**
55
+ * Periodic re-authentication interval in ms (default: 0 = disabled).
56
+ * When set, the server periodically re-validates the client's auth token.
57
+ * If the token is expired/revoked, the client is disconnected with code 4003.
58
+ *
59
+ * Recommended: 300000 (5 minutes) for production.
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * websocketPlugin({ reauthInterval: 5 * 60 * 1000 }) // re-check every 5 min
64
+ * ```
65
+ */
66
+ reauthInterval?: number;
54
67
  /** Custom message handler */
55
68
  onMessage?: (client: WebSocketClient, message: WebSocketMessage) => void | Promise<void>;
56
69
  /** Called when a client connects */
57
70
  onConnect?: (client: WebSocketClient) => void | Promise<void>;
58
71
  /** Called when a client disconnects */
59
72
  onDisconnect?: (client: WebSocketClient) => void | Promise<void>;
73
+ /**
74
+ * Cross-instance broadcast adapter (default: LocalWebSocketAdapter — single-instance only).
75
+ * Provide a RedisWebSocketAdapter for multi-instance deployments.
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * import { RedisWebSocketAdapter } from '@classytic/arc/integrations/websocket';
80
+ * adapter: new RedisWebSocketAdapter(redis, { channel: 'arc-ws' })
81
+ * ```
82
+ */
83
+ adapter?: WebSocketAdapter;
84
+ }
85
+ /**
86
+ * Adapter interface for cross-instance WebSocket broadcast.
87
+ *
88
+ * - `publish()`: Send a message to all instances (via Redis, NATS, etc.)
89
+ * - `subscribe()`: Receive messages from other instances
90
+ * - `close()`: Clean up connections
91
+ *
92
+ * The adapter is NOT used for local broadcasts — RoomManager handles those.
93
+ * The adapter only handles the cross-instance relay.
94
+ */
95
+ interface WebSocketAdapter {
96
+ /** Adapter name for logging */
97
+ readonly name: string;
98
+ /** Publish a room broadcast to all other instances */
99
+ publish(room: string, message: string): Promise<void>;
100
+ /** Subscribe to broadcasts from other instances */
101
+ subscribe(callback: (room: string, message: string) => void): Promise<void>;
102
+ /** Close adapter connections */
103
+ close(): Promise<void>;
104
+ }
105
+ /**
106
+ * Default adapter — no cross-instance broadcast (single-instance only).
107
+ * All methods are no-ops. Used when no adapter is configured.
108
+ */
109
+ declare class LocalWebSocketAdapter implements WebSocketAdapter {
110
+ readonly name = "local";
111
+ publish(): Promise<void>;
112
+ subscribe(): Promise<void>;
113
+ close(): Promise<void>;
60
114
  }
61
115
  declare class RoomManager {
62
116
  private rooms;
63
117
  private clients;
64
118
  private maxPerRoom;
65
- constructor(maxPerRoom?: number);
119
+ private adapter?;
120
+ constructor(maxPerRoom?: number, adapter?: WebSocketAdapter);
66
121
  addClient(client: WebSocketClient): void;
67
122
  removeClient(clientId: string): void;
68
123
  subscribe(clientId: string, room: string): boolean;
69
124
  unsubscribe(clientId: string, room: string): void;
70
125
  broadcast(room: string, message: string, excludeClientId?: string): void;
71
126
  broadcastToOrg(organizationId: string, room: string, message: string): void;
127
+ /**
128
+ * Broadcast locally AND through adapter (for cross-instance delivery).
129
+ * Use this instead of broadcast() when multi-instance is possible.
130
+ */
131
+ broadcastWithAdapter(room: string, message: string, excludeClientId?: string): Promise<void>;
132
+ /**
133
+ * Org-scoped broadcast locally AND through adapter.
134
+ * Uses a namespaced room key for the adapter so other instances
135
+ * can filter by org when delivering locally.
136
+ */
137
+ broadcastToOrgWithAdapter(organizationId: string, room: string, message: string): Promise<void>;
72
138
  getClient(clientId: string): WebSocketClient | undefined;
73
139
  getStats(): {
74
140
  clients: number;
@@ -79,4 +145,4 @@ declare class RoomManager {
79
145
  /** Pluggable WebSocket integration for Arc */
80
146
  declare const websocketPlugin: FastifyPluginAsync<WebSocketPluginOptions>;
81
147
  //#endregion
82
- export { RoomManager, WebSocketClient, WebSocketMessage, WebSocketPluginOptions, websocketPlugin as default, websocketPlugin };
148
+ export { LocalWebSocketAdapter, RoomManager, WebSocketAdapter, WebSocketClient, WebSocketMessage, WebSocketPluginOptions, websocketPlugin as default, websocketPlugin };