@classytic/arc 1.1.0 → 2.1.3
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 +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BkUDYZEb.d.mts +99 -0
- package/dist/HookSystem-BsGV-j2l.mjs +404 -0
- package/dist/ResourceRegistry-7Ic20ZMw.mjs +249 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +81 -0
- package/dist/audit/index.mjs +275 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-CGdLiSlE.mjs +140 -0
- package/dist/auth/index.d.mts +188 -0
- package/dist/auth/index.mjs +1096 -0
- package/dist/auth/redis-session.d.mts +43 -0
- package/dist/auth/redis-session.mjs +75 -0
- package/dist/betterAuthOpenApi-DjWDddNc.mjs +249 -0
- package/dist/cache/index.d.mts +145 -0
- package/dist/cache/index.mjs +91 -0
- package/dist/caching-GSDJcA6-.mjs +93 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DYhWBW_D.mjs +1096 -0
- package/dist/cli/commands/describe.d.mts +18 -0
- package/dist/cli/commands/describe.mjs +238 -0
- package/dist/cli/commands/docs.d.mts +13 -0
- package/dist/cli/commands/docs.mjs +52 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -2
- package/dist/cli/commands/generate.mjs +357 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +11 -8
- package/dist/cli/commands/{init.js → init.mjs} +807 -617
- package/dist/cli/commands/introspect.d.mts +10 -0
- package/dist/cli/commands/introspect.mjs +75 -0
- package/dist/cli/index.d.mts +16 -0
- package/dist/cli/index.mjs +156 -0
- package/dist/constants-DdXFXQtN.mjs +84 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-D2D5XXaV.mjs +559 -0
- package/dist/defineResource-PXzSJ15_.mjs +2197 -0
- package/dist/discovery/index.d.mts +46 -0
- package/dist/discovery/index.mjs +109 -0
- package/dist/docs/index.d.mts +162 -0
- package/dist/docs/index.mjs +74 -0
- package/dist/elevation-DGo5shaX.d.mts +87 -0
- package/dist/elevation-DSTbVvYj.mjs +113 -0
- package/dist/errorHandler-C3GY3_ow.mjs +108 -0
- package/dist/errorHandler-CW3OOeYq.d.mts +72 -0
- package/dist/errors-DAWRdiYP.d.mts +124 -0
- package/dist/errors-DBANPbGr.mjs +211 -0
- package/dist/eventPlugin-BEOvaDqo.mjs +229 -0
- package/dist/eventPlugin-H6wDDjGO.d.mts +124 -0
- package/dist/events/index.d.mts +53 -0
- package/dist/events/index.mjs +51 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +177 -0
- package/dist/events/transports/redis.d.mts +76 -0
- package/dist/events/transports/redis.mjs +124 -0
- package/dist/externalPaths-SyPF2tgK.d.mts +50 -0
- package/dist/factory/index.d.mts +63 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-C8DlE0YH.d.mts +216 -0
- package/dist/fields-Bi_AVKSo.d.mts +109 -0
- package/dist/fields-CTd_CrKr.mjs +114 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +96 -0
- package/dist/idempotency/index.mjs +319 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +114 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +103 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +104 -0
- package/dist/integrations/event-gateway.d.mts +46 -0
- package/dist/integrations/event-gateway.mjs +43 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +103 -0
- package/dist/integrations/jobs.mjs +123 -0
- package/dist/integrations/streamline.d.mts +60 -0
- package/dist/integrations/streamline.mjs +125 -0
- package/dist/integrations/websocket.d.mts +82 -0
- package/dist/integrations/websocket.mjs +288 -0
- package/dist/interface-CSNjltAc.d.mts +77 -0
- package/dist/interface-DTbsvIWe.d.mts +54 -0
- package/dist/interface-e9XfSsUV.d.mts +1097 -0
- package/dist/introspectionPlugin-B3JkrjwU.mjs +53 -0
- package/dist/keys-DhqDRxv3.mjs +42 -0
- package/dist/logger-ByrvQWZO.mjs +78 -0
- package/dist/memory-B2v7KrCB.mjs +143 -0
- package/dist/migrations/index.d.mts +156 -0
- package/dist/migrations/index.mjs +260 -0
- package/dist/mongodb-ClykrfGo.d.mts +118 -0
- package/dist/mongodb-DNKEExbf.mjs +93 -0
- package/dist/mongodb-Dg8O_gvd.d.mts +71 -0
- package/dist/openapi-9nB_kiuR.mjs +525 -0
- package/dist/org/index.d.mts +68 -0
- package/dist/org/index.mjs +513 -0
- package/dist/org/types.d.mts +82 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +278 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/plugins/index.d.mts +172 -0
- package/dist/plugins/index.mjs +522 -0
- package/dist/plugins/response-cache.d.mts +87 -0
- package/dist/plugins/response-cache.mjs +283 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +185 -0
- package/dist/pluralize-CM-jZg7p.mjs +86 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -170
- package/dist/policies/index.mjs +321 -0
- package/dist/presets/{index.d.ts → index.d.mts} +62 -131
- package/dist/presets/index.mjs +143 -0
- package/dist/presets/multiTenant.d.mts +24 -0
- package/dist/presets/multiTenant.mjs +113 -0
- package/dist/presets-BTeYbw7h.d.mts +57 -0
- package/dist/presets-CeFtfDR8.mjs +119 -0
- package/dist/prisma-C3iornoK.d.mts +274 -0
- package/dist/prisma-DJbMt3yf.mjs +627 -0
- package/dist/queryCachePlugin-B6R0d4av.mjs +138 -0
- package/dist/queryCachePlugin-Q6SYuHZ6.d.mts +71 -0
- package/dist/redis-UwjEp8Ea.d.mts +49 -0
- package/dist/redis-stream-CBg0upHI.d.mts +103 -0
- package/dist/registry/index.d.mts +11 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-xi6OKBL-.mjs +55 -0
- package/dist/schemaConverter-Dtg0Kt9T.mjs +98 -0
- package/dist/schemas/index.d.mts +63 -0
- package/dist/schemas/index.mjs +82 -0
- package/dist/scope/index.d.mts +21 -0
- package/dist/scope/index.mjs +65 -0
- package/dist/sessionManager-D_iEHjQl.d.mts +186 -0
- package/dist/sse-DkqQ1uxb.mjs +123 -0
- package/dist/testing/index.d.mts +907 -0
- package/dist/testing/index.mjs +1976 -0
- package/dist/tracing-8CEbhF0w.d.mts +70 -0
- package/dist/typeGuards-DwxA1t_L.mjs +9 -0
- package/dist/types/index.d.mts +946 -0
- package/dist/types/index.mjs +14 -0
- package/dist/types-B0dhNrnd.d.mts +445 -0
- package/dist/types-Beqn1Un7.mjs +38 -0
- package/dist/types-DelU6kln.mjs +25 -0
- package/dist/types-RLkFVgaw.d.mts +101 -0
- package/dist/utils/index.d.mts +747 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//#region src/integrations/jobs.ts
|
|
2
|
+
/**
|
|
3
|
+
* Define a background job with typed data and configuration.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* const processImage = defineJob({
|
|
7
|
+
* name: 'process-image',
|
|
8
|
+
* handler: async (data: { url: string; width: number }) => {
|
|
9
|
+
* return await sharp(data.url).resize(data.width).toBuffer();
|
|
10
|
+
* },
|
|
11
|
+
* retries: 3,
|
|
12
|
+
* timeout: 60000,
|
|
13
|
+
* });
|
|
14
|
+
*/
|
|
15
|
+
function defineJob(definition) {
|
|
16
|
+
return definition;
|
|
17
|
+
}
|
|
18
|
+
const jobsPluginImpl = async (fastify, options) => {
|
|
19
|
+
const { connection, jobs, prefix = "/jobs", bridgeEvents = true, defaults = {} } = options;
|
|
20
|
+
let Queue;
|
|
21
|
+
let Worker;
|
|
22
|
+
try {
|
|
23
|
+
const bullmq = await import("bullmq");
|
|
24
|
+
Queue = bullmq.Queue;
|
|
25
|
+
Worker = bullmq.Worker;
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error("@classytic/arc/integrations/jobs requires \"bullmq\" package.\nInstall it: npm install bullmq");
|
|
28
|
+
}
|
|
29
|
+
const queues = /* @__PURE__ */ new Map();
|
|
30
|
+
const workers = /* @__PURE__ */ new Map();
|
|
31
|
+
for (const job of jobs) {
|
|
32
|
+
const queueName = job.name;
|
|
33
|
+
const queue = new Queue(queueName, { connection });
|
|
34
|
+
queues.set(queueName, queue);
|
|
35
|
+
const worker = new Worker(queueName, async (bullJob) => {
|
|
36
|
+
const meta = {
|
|
37
|
+
jobId: bullJob.id,
|
|
38
|
+
attemptsMade: bullJob.attemptsMade,
|
|
39
|
+
timestamp: Date.now()
|
|
40
|
+
};
|
|
41
|
+
const result = await job.handler(bullJob.data, meta);
|
|
42
|
+
if (bridgeEvents && fastify.events?.publish) await fastify.events.publish(`job.${queueName}.completed`, {
|
|
43
|
+
jobId: bullJob.id,
|
|
44
|
+
data: bullJob.data,
|
|
45
|
+
result
|
|
46
|
+
});
|
|
47
|
+
return result;
|
|
48
|
+
}, {
|
|
49
|
+
connection,
|
|
50
|
+
concurrency: job.concurrency ?? 1,
|
|
51
|
+
limiter: job.rateLimit ? {
|
|
52
|
+
max: job.rateLimit.max,
|
|
53
|
+
duration: job.rateLimit.duration
|
|
54
|
+
} : void 0
|
|
55
|
+
});
|
|
56
|
+
worker.on("failed", async (bullJob, error) => {
|
|
57
|
+
if (bridgeEvents && fastify.events?.publish) await fastify.events.publish(`job.${queueName}.failed`, {
|
|
58
|
+
jobId: bullJob?.id,
|
|
59
|
+
data: bullJob?.data,
|
|
60
|
+
error: error.message,
|
|
61
|
+
attemptsMade: bullJob?.attemptsMade
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
workers.set(queueName, worker);
|
|
65
|
+
}
|
|
66
|
+
const dispatcher = {
|
|
67
|
+
async dispatch(name, data, opts = {}) {
|
|
68
|
+
const queue = queues.get(name);
|
|
69
|
+
if (!queue) throw new Error(`Job queue '${name}' not registered. Available: ${Array.from(queues.keys()).join(", ")}`);
|
|
70
|
+
const jobDef = jobs.find((j) => j.name === name);
|
|
71
|
+
return { jobId: (await queue.add(name, data, {
|
|
72
|
+
delay: opts.delay,
|
|
73
|
+
priority: opts.priority,
|
|
74
|
+
jobId: opts.jobId,
|
|
75
|
+
removeOnComplete: opts.removeOnComplete ?? defaults.removeOnComplete ?? 100,
|
|
76
|
+
removeOnFail: opts.removeOnFail ?? defaults.removeOnFail ?? 500,
|
|
77
|
+
attempts: jobDef?.retries ?? defaults.retries ?? 3,
|
|
78
|
+
backoff: jobDef?.backoff ?? defaults.backoff ?? {
|
|
79
|
+
type: "exponential",
|
|
80
|
+
delay: 1e3
|
|
81
|
+
}
|
|
82
|
+
})).id };
|
|
83
|
+
},
|
|
84
|
+
getQueue(name) {
|
|
85
|
+
return queues.get(name) ?? null;
|
|
86
|
+
},
|
|
87
|
+
async getStats() {
|
|
88
|
+
const stats = {};
|
|
89
|
+
for (const [name, queue] of queues) {
|
|
90
|
+
const counts = await queue.getJobCounts();
|
|
91
|
+
stats[name] = {
|
|
92
|
+
waiting: counts.waiting ?? 0,
|
|
93
|
+
active: counts.active ?? 0,
|
|
94
|
+
completed: counts.completed ?? 0,
|
|
95
|
+
failed: counts.failed ?? 0,
|
|
96
|
+
delayed: counts.delayed ?? 0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return stats;
|
|
100
|
+
},
|
|
101
|
+
async close() {
|
|
102
|
+
const closePromises = [];
|
|
103
|
+
for (const worker of workers.values()) closePromises.push(worker.close());
|
|
104
|
+
for (const queue of queues.values()) closePromises.push(queue.close());
|
|
105
|
+
await Promise.all(closePromises);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
if (!fastify.hasDecorator("jobs")) fastify.decorate("jobs", dispatcher);
|
|
109
|
+
fastify.get(`${prefix}/stats`, async () => {
|
|
110
|
+
return {
|
|
111
|
+
success: true,
|
|
112
|
+
data: await dispatcher.getStats()
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
fastify.addHook("onClose", async () => {
|
|
116
|
+
await dispatcher.close();
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
/** Pluggable BullMQ job queue integration for Arc */
|
|
120
|
+
const jobsPlugin = jobsPluginImpl;
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
export { jobsPlugin as default, jobsPlugin, defineJob };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from "fastify";
|
|
2
|
+
|
|
3
|
+
//#region src/integrations/streamline.d.ts
|
|
4
|
+
/** Minimal workflow interface — matches @classytic/streamline's createWorkflow() return */
|
|
5
|
+
interface WorkflowLike {
|
|
6
|
+
definition: {
|
|
7
|
+
id: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
steps: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
engine: {
|
|
12
|
+
start(input: unknown, meta?: unknown): Promise<WorkflowRunLike>;
|
|
13
|
+
execute(runId: string): Promise<WorkflowRunLike>;
|
|
14
|
+
resume(runId: string, payload?: unknown): Promise<WorkflowRunLike>;
|
|
15
|
+
cancel(runId: string): Promise<WorkflowRunLike>;
|
|
16
|
+
pause?(runId: string): Promise<WorkflowRunLike>;
|
|
17
|
+
rewindTo?(runId: string, stepId: string): Promise<WorkflowRunLike>;
|
|
18
|
+
get(runId: string): Promise<WorkflowRunLike | null>;
|
|
19
|
+
shutdown?(): void;
|
|
20
|
+
};
|
|
21
|
+
start(input: unknown, meta?: unknown): Promise<WorkflowRunLike>;
|
|
22
|
+
resume(runId: string, payload?: unknown): Promise<WorkflowRunLike>;
|
|
23
|
+
cancel(runId: string): Promise<WorkflowRunLike>;
|
|
24
|
+
get(runId: string): Promise<WorkflowRunLike | null>;
|
|
25
|
+
shutdown?(): void;
|
|
26
|
+
}
|
|
27
|
+
interface WorkflowRunLike {
|
|
28
|
+
_id: string;
|
|
29
|
+
workflowId: string;
|
|
30
|
+
status: string;
|
|
31
|
+
context?: unknown;
|
|
32
|
+
input?: unknown;
|
|
33
|
+
steps?: Record<string, unknown>;
|
|
34
|
+
error?: unknown;
|
|
35
|
+
createdAt?: Date;
|
|
36
|
+
updatedAt?: Date;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
interface StreamlinePluginOptions {
|
|
40
|
+
/** Array of workflows created with createWorkflow() */
|
|
41
|
+
workflows: WorkflowLike[];
|
|
42
|
+
/** URL prefix for workflow endpoints (default: '/workflows') */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/** Require authentication for all workflow endpoints (default: true) */
|
|
45
|
+
auth?: boolean;
|
|
46
|
+
/** Connect workflow events to Arc's event bus (default: true) */
|
|
47
|
+
bridgeEvents?: boolean;
|
|
48
|
+
/** Custom permission check for workflow operations */
|
|
49
|
+
permissions?: {
|
|
50
|
+
start?: (request: unknown) => boolean | Promise<boolean>;
|
|
51
|
+
resume?: (request: unknown) => boolean | Promise<boolean>;
|
|
52
|
+
cancel?: (request: unknown) => boolean | Promise<boolean>;
|
|
53
|
+
list?: (request: unknown) => boolean | Promise<boolean>;
|
|
54
|
+
get?: (request: unknown) => boolean | Promise<boolean>;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Pluggable streamline integration for Arc */
|
|
58
|
+
declare const streamlinePlugin: FastifyPluginAsync<StreamlinePluginOptions>;
|
|
59
|
+
//#endregion
|
|
60
|
+
export { StreamlinePluginOptions, WorkflowLike, WorkflowRunLike, streamlinePlugin as default, streamlinePlugin };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
//#region src/integrations/streamline.ts
|
|
2
|
+
const streamlinePluginImpl = async (fastify, options) => {
|
|
3
|
+
const { workflows, prefix = "/workflows", auth = true, bridgeEvents = true, permissions: perms } = options;
|
|
4
|
+
const registry = /* @__PURE__ */ new Map();
|
|
5
|
+
for (const wf of workflows) {
|
|
6
|
+
const id = wf.definition.id;
|
|
7
|
+
if (registry.has(id)) throw new Error(`Duplicate workflow ID: '${id}'`);
|
|
8
|
+
registry.set(id, wf);
|
|
9
|
+
}
|
|
10
|
+
if (!fastify.hasDecorator("workflows")) fastify.decorate("workflows", registry);
|
|
11
|
+
if (!fastify.hasDecorator("getWorkflow")) fastify.decorate("getWorkflow", (id) => registry.get(id) ?? null);
|
|
12
|
+
const authPreHandler = auth && typeof fastify.authenticate === "function" ? [fastify.authenticate] : [];
|
|
13
|
+
const checkPerm = async (op, request) => {
|
|
14
|
+
const check = perms?.[op];
|
|
15
|
+
if (!check) return true;
|
|
16
|
+
return check(request);
|
|
17
|
+
};
|
|
18
|
+
for (const [id, wf] of registry) {
|
|
19
|
+
const routePrefix = `${prefix}/${id}`;
|
|
20
|
+
fastify.post(`${routePrefix}/start`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
21
|
+
if (!await checkPerm("start", request)) return reply.status(403).send({
|
|
22
|
+
success: false,
|
|
23
|
+
error: "Forbidden"
|
|
24
|
+
});
|
|
25
|
+
const { input, meta } = request.body ?? {};
|
|
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
|
+
});
|
|
32
|
+
return reply.status(201).send({
|
|
33
|
+
success: true,
|
|
34
|
+
data: run
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
fastify.get(`${routePrefix}/runs/:runId`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
38
|
+
if (!await checkPerm("get", request)) return reply.status(403).send({
|
|
39
|
+
success: false,
|
|
40
|
+
error: "Forbidden"
|
|
41
|
+
});
|
|
42
|
+
const { runId } = request.params;
|
|
43
|
+
const run = await wf.get(runId);
|
|
44
|
+
if (!run) return reply.status(404).send({
|
|
45
|
+
success: false,
|
|
46
|
+
error: "Workflow run not found"
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
data: run
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
fastify.post(`${routePrefix}/runs/:runId/resume`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
54
|
+
if (!await checkPerm("resume", request)) return reply.status(403).send({
|
|
55
|
+
success: false,
|
|
56
|
+
error: "Forbidden"
|
|
57
|
+
});
|
|
58
|
+
const { runId } = request.params;
|
|
59
|
+
const { payload } = request.body ?? {};
|
|
60
|
+
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
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
data: run
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
fastify.post(`${routePrefix}/runs/:runId/cancel`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
72
|
+
if (!await checkPerm("cancel", request)) return reply.status(403).send({
|
|
73
|
+
success: false,
|
|
74
|
+
error: "Forbidden"
|
|
75
|
+
});
|
|
76
|
+
const { runId } = request.params;
|
|
77
|
+
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
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
success: true,
|
|
84
|
+
data: run
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
if (wf.engine.pause) fastify.post(`${routePrefix}/runs/:runId/pause`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
88
|
+
const { runId } = request.params;
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
data: await wf.engine.pause(runId)
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
if (wf.engine.rewindTo) fastify.post(`${routePrefix}/runs/:runId/rewind`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
95
|
+
const { runId } = request.params;
|
|
96
|
+
const { stepId } = request.body ?? {};
|
|
97
|
+
if (!stepId) return reply.status(400).send({
|
|
98
|
+
success: false,
|
|
99
|
+
error: "stepId is required"
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
data: await wf.engine.rewindTo(runId, stepId)
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
fastify.get(prefix, { preHandler: authPreHandler }, async () => {
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
data: Array.from(registry.entries()).map(([id, wf]) => ({
|
|
111
|
+
id,
|
|
112
|
+
name: wf.definition.name ?? id,
|
|
113
|
+
steps: Object.keys(wf.definition.steps)
|
|
114
|
+
}))
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
fastify.addHook("onClose", async () => {
|
|
118
|
+
for (const wf of registry.values()) wf.shutdown?.();
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
/** Pluggable streamline integration for Arc */
|
|
122
|
+
const streamlinePlugin = streamlinePluginImpl;
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
export { streamlinePlugin as default, streamlinePlugin };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from "fastify";
|
|
2
|
+
|
|
3
|
+
//#region src/integrations/websocket.d.ts
|
|
4
|
+
interface WebSocketClient {
|
|
5
|
+
id: string;
|
|
6
|
+
socket: {
|
|
7
|
+
send(data: string): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
readyState: number;
|
|
10
|
+
};
|
|
11
|
+
subscriptions: Set<string>;
|
|
12
|
+
userId?: string;
|
|
13
|
+
organizationId?: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
interface WebSocketMessage {
|
|
17
|
+
type: string;
|
|
18
|
+
resource?: string;
|
|
19
|
+
channel?: string;
|
|
20
|
+
data?: unknown;
|
|
21
|
+
}
|
|
22
|
+
interface WebSocketPluginOptions {
|
|
23
|
+
/** WebSocket endpoint path (default: '/ws') */
|
|
24
|
+
path?: string;
|
|
25
|
+
/** Require authentication for WebSocket connections (default: true) */
|
|
26
|
+
auth?: boolean;
|
|
27
|
+
/** Resources to auto-broadcast CRUD events for */
|
|
28
|
+
resources?: string[];
|
|
29
|
+
/** Heartbeat interval in ms (default: 30000). Set 0 to disable. */
|
|
30
|
+
heartbeatInterval?: number;
|
|
31
|
+
/** Custom authentication function for WebSocket upgrade */
|
|
32
|
+
authenticate?: (request: unknown) => Promise<{
|
|
33
|
+
userId?: string;
|
|
34
|
+
organizationId?: string;
|
|
35
|
+
} | null>;
|
|
36
|
+
/** Max clients per resource subscription (default: 10000) */
|
|
37
|
+
maxClientsPerRoom?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Expose a stats endpoint at `{path}/stats`.
|
|
40
|
+
* - `false` (default): stats endpoint is not registered
|
|
41
|
+
* - `true`: registered without auth
|
|
42
|
+
* - `'authenticated'`: guarded by `fastify.authenticate` if available
|
|
43
|
+
*/
|
|
44
|
+
exposeStats?: boolean | "authenticated";
|
|
45
|
+
/**
|
|
46
|
+
* Authorize room subscriptions. Return true to allow, false to deny.
|
|
47
|
+
* Called before every subscribe. If not provided, all rooms are allowed.
|
|
48
|
+
*/
|
|
49
|
+
roomPolicy?: (client: WebSocketClient, room: string) => boolean | Promise<boolean>;
|
|
50
|
+
/** Maximum message size in bytes from client (default: 16384 = 16KB). Messages exceeding this are dropped. */
|
|
51
|
+
maxMessageBytes?: number;
|
|
52
|
+
/** Maximum subscriptions per client (default: 100). Prevents resource exhaustion. */
|
|
53
|
+
maxSubscriptionsPerClient?: number;
|
|
54
|
+
/** Custom message handler */
|
|
55
|
+
onMessage?: (client: WebSocketClient, message: WebSocketMessage) => void | Promise<void>;
|
|
56
|
+
/** Called when a client connects */
|
|
57
|
+
onConnect?: (client: WebSocketClient) => void | Promise<void>;
|
|
58
|
+
/** Called when a client disconnects */
|
|
59
|
+
onDisconnect?: (client: WebSocketClient) => void | Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
declare class RoomManager {
|
|
62
|
+
private rooms;
|
|
63
|
+
private clients;
|
|
64
|
+
private maxPerRoom;
|
|
65
|
+
constructor(maxPerRoom?: number);
|
|
66
|
+
addClient(client: WebSocketClient): void;
|
|
67
|
+
removeClient(clientId: string): void;
|
|
68
|
+
subscribe(clientId: string, room: string): boolean;
|
|
69
|
+
unsubscribe(clientId: string, room: string): void;
|
|
70
|
+
broadcast(room: string, message: string, excludeClientId?: string): void;
|
|
71
|
+
broadcastToOrg(organizationId: string, room: string, message: string): void;
|
|
72
|
+
getClient(clientId: string): WebSocketClient | undefined;
|
|
73
|
+
getStats(): {
|
|
74
|
+
clients: number;
|
|
75
|
+
rooms: number;
|
|
76
|
+
subscriptions: Record<string, number>;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Pluggable WebSocket integration for Arc */
|
|
80
|
+
declare const websocketPlugin: FastifyPluginAsync<WebSocketPluginOptions>;
|
|
81
|
+
//#endregion
|
|
82
|
+
export { RoomManager, WebSocketClient, WebSocketMessage, WebSocketPluginOptions, websocketPlugin as default, websocketPlugin };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import fp from "fastify-plugin";
|
|
2
|
+
|
|
3
|
+
//#region src/integrations/websocket.ts
|
|
4
|
+
var RoomManager = class {
|
|
5
|
+
rooms = /* @__PURE__ */ new Map();
|
|
6
|
+
clients = /* @__PURE__ */ new Map();
|
|
7
|
+
maxPerRoom;
|
|
8
|
+
constructor(maxPerRoom = 1e4) {
|
|
9
|
+
this.maxPerRoom = maxPerRoom;
|
|
10
|
+
}
|
|
11
|
+
addClient(client) {
|
|
12
|
+
this.clients.set(client.id, client);
|
|
13
|
+
}
|
|
14
|
+
removeClient(clientId) {
|
|
15
|
+
const client = this.clients.get(clientId);
|
|
16
|
+
if (!client) return;
|
|
17
|
+
for (const room of client.subscriptions) {
|
|
18
|
+
const members = this.rooms.get(room);
|
|
19
|
+
if (members) {
|
|
20
|
+
members.delete(clientId);
|
|
21
|
+
if (members.size === 0) this.rooms.delete(room);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
client.subscriptions.clear();
|
|
25
|
+
this.clients.delete(clientId);
|
|
26
|
+
}
|
|
27
|
+
subscribe(clientId, room) {
|
|
28
|
+
const client = this.clients.get(clientId);
|
|
29
|
+
if (!client) return false;
|
|
30
|
+
const members = this.rooms.get(room);
|
|
31
|
+
if (members && members.size >= this.maxPerRoom) return false;
|
|
32
|
+
if (!this.rooms.has(room)) this.rooms.set(room, /* @__PURE__ */ new Set());
|
|
33
|
+
this.rooms.get(room).add(clientId);
|
|
34
|
+
client.subscriptions.add(room);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
unsubscribe(clientId, room) {
|
|
38
|
+
const client = this.clients.get(clientId);
|
|
39
|
+
if (!client) return;
|
|
40
|
+
const members = this.rooms.get(room);
|
|
41
|
+
if (members) {
|
|
42
|
+
members.delete(clientId);
|
|
43
|
+
if (members.size === 0) this.rooms.delete(room);
|
|
44
|
+
}
|
|
45
|
+
client.subscriptions.delete(room);
|
|
46
|
+
}
|
|
47
|
+
broadcast(room, message, excludeClientId) {
|
|
48
|
+
const members = this.rooms.get(room);
|
|
49
|
+
if (!members) return;
|
|
50
|
+
for (const clientId of members) {
|
|
51
|
+
if (clientId === excludeClientId) continue;
|
|
52
|
+
const client = this.clients.get(clientId);
|
|
53
|
+
if (client && client.socket.readyState === 1) try {
|
|
54
|
+
client.socket.send(message);
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
broadcastToOrg(organizationId, room, message) {
|
|
59
|
+
const members = this.rooms.get(room);
|
|
60
|
+
if (!members) return;
|
|
61
|
+
for (const clientId of members) {
|
|
62
|
+
const client = this.clients.get(clientId);
|
|
63
|
+
if (client && client.organizationId === organizationId && client.socket.readyState === 1) try {
|
|
64
|
+
client.socket.send(message);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
getClient(clientId) {
|
|
69
|
+
return this.clients.get(clientId);
|
|
70
|
+
}
|
|
71
|
+
getStats() {
|
|
72
|
+
const subscriptions = {};
|
|
73
|
+
for (const [room, members] of this.rooms) subscriptions[room] = members.size;
|
|
74
|
+
return {
|
|
75
|
+
clients: this.clients.size,
|
|
76
|
+
rooms: this.rooms.size,
|
|
77
|
+
subscriptions
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const websocketPluginImpl = async (fastify, options) => {
|
|
82
|
+
let clientCounter = 0;
|
|
83
|
+
const { path = "/ws", auth = true, resources = [], heartbeatInterval = 3e4, authenticate: customAuth, maxClientsPerRoom = 1e4, roomPolicy, maxMessageBytes = 16384, maxSubscriptionsPerClient = 100, exposeStats = false, onMessage, onConnect, onDisconnect } = options;
|
|
84
|
+
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.");
|
|
85
|
+
const rooms = new RoomManager(maxClientsPerRoom);
|
|
86
|
+
if (!fastify.hasDecorator("ws")) fastify.decorate("ws", {
|
|
87
|
+
rooms,
|
|
88
|
+
broadcast: (room, data) => {
|
|
89
|
+
rooms.broadcast(room, JSON.stringify({
|
|
90
|
+
type: "broadcast",
|
|
91
|
+
channel: room,
|
|
92
|
+
data
|
|
93
|
+
}));
|
|
94
|
+
},
|
|
95
|
+
broadcastToOrg: (orgId, room, data) => {
|
|
96
|
+
rooms.broadcastToOrg(orgId, room, JSON.stringify({
|
|
97
|
+
type: "broadcast",
|
|
98
|
+
channel: room,
|
|
99
|
+
data
|
|
100
|
+
}));
|
|
101
|
+
},
|
|
102
|
+
getStats: () => rooms.getStats()
|
|
103
|
+
});
|
|
104
|
+
const eventUnsubscribers = [];
|
|
105
|
+
if (resources.length > 0 && fastify.events?.subscribe) for (const resourceName of resources) for (const op of [
|
|
106
|
+
"created",
|
|
107
|
+
"updated",
|
|
108
|
+
"deleted"
|
|
109
|
+
]) {
|
|
110
|
+
const unsub = await fastify.events.subscribe(`${resourceName}.${op}`, async (event) => {
|
|
111
|
+
const room = resourceName;
|
|
112
|
+
const payload = JSON.stringify({
|
|
113
|
+
type: `${resourceName}.${op}`,
|
|
114
|
+
data: event.payload,
|
|
115
|
+
meta: {
|
|
116
|
+
timestamp: event.meta?.timestamp,
|
|
117
|
+
userId: event.meta?.userId,
|
|
118
|
+
organizationId: event.meta?.organizationId
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (event.meta?.organizationId) rooms.broadcastToOrg(event.meta.organizationId, room, payload);
|
|
122
|
+
else rooms.broadcast(room, payload);
|
|
123
|
+
});
|
|
124
|
+
eventUnsubscribers.push(unsub);
|
|
125
|
+
}
|
|
126
|
+
fastify.get(path, { websocket: true }, async (socket, request) => {
|
|
127
|
+
const clientId = `ws_${++clientCounter}_${Date.now()}`;
|
|
128
|
+
let userId;
|
|
129
|
+
let organizationId;
|
|
130
|
+
if (auth) if (customAuth) {
|
|
131
|
+
const result = await customAuth(request);
|
|
132
|
+
if (!result) {
|
|
133
|
+
socket.close(4001, "Unauthorized");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
userId = result.userId;
|
|
137
|
+
organizationId = result.organizationId;
|
|
138
|
+
} else {
|
|
139
|
+
if (fastify.authenticate) try {
|
|
140
|
+
let rejected = false;
|
|
141
|
+
const fakeReply = {
|
|
142
|
+
code(_statusCode) {
|
|
143
|
+
rejected = true;
|
|
144
|
+
return fakeReply;
|
|
145
|
+
},
|
|
146
|
+
send() {
|
|
147
|
+
return fakeReply;
|
|
148
|
+
},
|
|
149
|
+
sent: false
|
|
150
|
+
};
|
|
151
|
+
await fastify.authenticate(request, fakeReply);
|
|
152
|
+
if (rejected) {
|
|
153
|
+
socket.close(4001, "Unauthorized");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
socket.close(4001, "Unauthorized");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (request.user) {
|
|
161
|
+
userId = request.user.id ?? request.user.sub;
|
|
162
|
+
organizationId = request.scope?.organizationId;
|
|
163
|
+
} else {
|
|
164
|
+
socket.close(4001, "Unauthorized");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const client = {
|
|
169
|
+
id: clientId,
|
|
170
|
+
socket,
|
|
171
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
172
|
+
userId,
|
|
173
|
+
organizationId
|
|
174
|
+
};
|
|
175
|
+
rooms.addClient(client);
|
|
176
|
+
await onConnect?.(client);
|
|
177
|
+
socket.send(JSON.stringify({
|
|
178
|
+
type: "connected",
|
|
179
|
+
clientId,
|
|
180
|
+
resources
|
|
181
|
+
}));
|
|
182
|
+
let heartbeatTimer;
|
|
183
|
+
if (heartbeatInterval > 0) heartbeatTimer = setInterval(() => {
|
|
184
|
+
if (socket.readyState === 1) socket.send(JSON.stringify({
|
|
185
|
+
type: "ping",
|
|
186
|
+
timestamp: Date.now()
|
|
187
|
+
}));
|
|
188
|
+
}, heartbeatInterval);
|
|
189
|
+
socket.on("message", async (raw) => {
|
|
190
|
+
if ((typeof raw === "string" ? Buffer.byteLength(raw) : raw.length) > maxMessageBytes) {
|
|
191
|
+
socket.send(JSON.stringify({
|
|
192
|
+
type: "error",
|
|
193
|
+
error: "Message too large"
|
|
194
|
+
}));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
|
|
199
|
+
switch (msg.type) {
|
|
200
|
+
case "subscribe": {
|
|
201
|
+
const room = msg.resource ?? msg.channel;
|
|
202
|
+
if (room) {
|
|
203
|
+
if (client.subscriptions.size >= maxSubscriptionsPerClient) {
|
|
204
|
+
socket.send(JSON.stringify({
|
|
205
|
+
type: "error",
|
|
206
|
+
channel: room,
|
|
207
|
+
error: "Subscription limit reached"
|
|
208
|
+
}));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
if (roomPolicy) {
|
|
212
|
+
if (!await roomPolicy(client, room)) {
|
|
213
|
+
socket.send(JSON.stringify({
|
|
214
|
+
type: "error",
|
|
215
|
+
channel: room,
|
|
216
|
+
error: "Subscription denied"
|
|
217
|
+
}));
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const ok = rooms.subscribe(clientId, room);
|
|
222
|
+
socket.send(JSON.stringify({
|
|
223
|
+
type: ok ? "subscribed" : "error",
|
|
224
|
+
channel: room,
|
|
225
|
+
...ok ? {} : { error: "Room at capacity" }
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case "unsubscribe": {
|
|
231
|
+
const room = msg.resource ?? msg.channel;
|
|
232
|
+
if (room) {
|
|
233
|
+
rooms.unsubscribe(clientId, room);
|
|
234
|
+
socket.send(JSON.stringify({
|
|
235
|
+
type: "unsubscribed",
|
|
236
|
+
channel: room
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case "pong": break;
|
|
242
|
+
default:
|
|
243
|
+
await onMessage?.(client, msg);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
socket.send(JSON.stringify({
|
|
248
|
+
type: "error",
|
|
249
|
+
error: "Invalid message format"
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
socket.on("close", async () => {
|
|
254
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
255
|
+
await onDisconnect?.(client);
|
|
256
|
+
rooms.removeClient(clientId);
|
|
257
|
+
});
|
|
258
|
+
socket.on("error", () => {
|
|
259
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
260
|
+
rooms.removeClient(clientId);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
if (exposeStats === true) fastify.get(`${path}/stats`, async () => {
|
|
264
|
+
return {
|
|
265
|
+
success: true,
|
|
266
|
+
data: rooms.getStats()
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
else if (exposeStats === "authenticated") if (fastify.hasDecorator("authenticate")) fastify.get(`${path}/stats`, { preHandler: fastify.authenticate }, async () => {
|
|
270
|
+
return {
|
|
271
|
+
success: true,
|
|
272
|
+
data: rooms.getStats()
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
else fastify.log.warn("arc-websocket: exposeStats is \"authenticated\" but fastify.authenticate is not registered — stats endpoint skipped");
|
|
276
|
+
fastify.addHook("onClose", async () => {
|
|
277
|
+
for (const unsub of eventUnsubscribers) unsub();
|
|
278
|
+
eventUnsubscribers.length = 0;
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
/** Pluggable WebSocket integration for Arc */
|
|
282
|
+
const websocketPlugin = fp(websocketPluginImpl, {
|
|
283
|
+
name: "arc-websocket",
|
|
284
|
+
fastify: "5.x"
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
export { RoomManager, websocketPlugin as default, websocketPlugin };
|