@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.
- package/README.md +187 -18
- package/bin/arc.js +11 -3
- package/dist/BaseController-CkM5dUh_.mjs +1031 -0
- package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
- package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
- package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
- package/dist/adapters/index.d.mts +3 -5
- package/dist/adapters/index.mjs +2 -3
- package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
- package/dist/audit/index.d.mts +4 -7
- package/dist/audit/index.mjs +2 -29
- package/dist/audit/mongodb.d.mts +1 -4
- package/dist/audit/mongodb.mjs +2 -3
- package/dist/auth/index.d.mts +7 -9
- package/dist/auth/index.mjs +65 -63
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +1 -2
- package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
- package/dist/cache/index.d.mts +23 -23
- package/dist/cache/index.mjs +4 -6
- package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
- package/dist/chunk-BpYLSNr0.mjs +14 -0
- package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
- package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
- package/dist/cli/commands/describe.mjs +24 -7
- package/dist/cli/commands/docs.mjs +6 -7
- package/dist/cli/commands/doctor.d.mts +10 -0
- package/dist/cli/commands/doctor.mjs +156 -0
- package/dist/cli/commands/generate.mjs +66 -17
- package/dist/cli/commands/init.mjs +315 -45
- package/dist/cli/commands/introspect.mjs +2 -4
- package/dist/cli/index.d.mts +1 -10
- package/dist/cli/index.mjs +4 -153
- package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
- package/dist/core/index.d.mts +3 -5
- package/dist/core/index.mjs +5 -4
- package/dist/core-C1XCMtqM.mjs +185 -0
- package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
- package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
- package/dist/discovery/index.mjs +37 -5
- package/dist/docs/index.d.mts +6 -9
- package/dist/docs/index.mjs +3 -21
- package/dist/dynamic/index.d.mts +93 -0
- package/dist/dynamic/index.mjs +122 -0
- package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
- package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
- package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
- package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
- package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
- package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
- package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
- package/dist/events/index.d.mts +72 -7
- package/dist/events/index.mjs +216 -4
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +19 -7
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +3 -4
- package/dist/factory/index.d.mts +23 -9
- package/dist/factory/index.mjs +48 -3
- package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
- package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
- package/dist/hooks/index.d.mts +1 -3
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +5 -5
- package/dist/idempotency/index.mjs +3 -7
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/mongodb.mjs +4 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +2 -5
- package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
- package/dist/index.d.mts +100 -105
- package/dist/index.mjs +85 -58
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +8 -4
- package/dist/integrations/index.d.mts +4 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs.d.mts +2 -2
- package/dist/integrations/jobs.mjs +63 -14
- package/dist/integrations/mcp/index.d.mts +219 -0
- package/dist/integrations/mcp/index.mjs +572 -0
- package/dist/integrations/mcp/testing.d.mts +53 -0
- package/dist/integrations/mcp/testing.mjs +104 -0
- package/dist/integrations/streamline.mjs +39 -19
- package/dist/integrations/webhooks.d.mts +56 -0
- package/dist/integrations/webhooks.mjs +139 -0
- package/dist/integrations/websocket-redis.d.mts +46 -0
- package/dist/integrations/websocket-redis.mjs +50 -0
- package/dist/integrations/websocket.d.mts +68 -2
- package/dist/integrations/websocket.mjs +96 -13
- package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
- package/dist/interface-DGmPxakH.d.mts +2213 -0
- package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
- package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
- package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
- package/dist/metrics-Csh4nsvv.mjs +224 -0
- package/dist/migrations/index.d.mts +113 -44
- package/dist/migrations/index.mjs +84 -102
- package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
- package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
- package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
- package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
- package/dist/org/index.d.mts +12 -14
- package/dist/org/index.mjs +92 -119
- package/dist/org/types.d.mts +2 -2
- package/dist/org/types.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -278
- package/dist/permissions/index.mjs +4 -579
- package/dist/permissions-CA5zg0yK.mjs +751 -0
- package/dist/plugins/index.d.mts +104 -107
- package/dist/plugins/index.mjs +203 -313
- package/dist/plugins/response-cache.mjs +4 -69
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +24 -11
- package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
- package/dist/policies/index.d.mts +2 -2
- package/dist/policies/index.mjs +80 -83
- package/dist/presets/index.d.mts +26 -19
- package/dist/presets/index.mjs +2 -142
- package/dist/presets/multiTenant.d.mts +1 -4
- package/dist/presets/multiTenant.mjs +4 -6
- package/dist/presets-C9QXJV1u.mjs +422 -0
- package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
- package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
- package/dist/queryParser-CgCtsjti.mjs +352 -0
- package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
- package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
- package/dist/registry/index.d.mts +1 -4
- package/dist/registry/index.mjs +3 -4
- package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
- package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
- package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
- package/dist/rpc/index.d.mts +90 -0
- package/dist/rpc/index.mjs +248 -0
- package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
- package/dist/schemas/index.d.mts +30 -30
- package/dist/schemas/index.mjs +2 -4
- package/dist/scope/index.d.mts +13 -2
- package/dist/scope/index.mjs +18 -5
- package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
- package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
- package/dist/testing/index.d.mts +551 -567
- package/dist/testing/index.mjs +1744 -1799
- package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
- package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
- package/dist/types/index.d.mts +4 -946
- package/dist/types/index.mjs +2 -4
- package/dist/types-BJmgxNbF.d.mts +275 -0
- package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
- package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
- package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +254 -351
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +44 -10
- package/skills/arc/SKILL.md +518 -0
- package/skills/arc/references/auth.md +250 -0
- package/skills/arc/references/events.md +272 -0
- package/skills/arc/references/integrations.md +385 -0
- package/skills/arc/references/mcp.md +431 -0
- package/skills/arc/references/production.md +610 -0
- package/skills/arc/references/testing.md +183 -0
- package/dist/audited-CGdLiSlE.mjs +0 -140
- package/dist/chunk-C7Uep-_p.mjs +0 -20
- package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-BtdYtQUA.d.mts +0 -1114
- package/dist/presets-BTeYbw7h.d.mts +0 -57
- package/dist/presets-CeFtfDR8.mjs +0 -119
- /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
- /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
- /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)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
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
|
-
|
|
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 };
|