From 9eadad2f69e8d1a9e9e31264175fcd632c564d9a Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 01/25] feat(node): OpenTelemetry tracing middleware --- sdks/node/package.json | 14 +++ sdks/node/pnpm-lock.yaml | 14 ++- sdks/node/src/contrib/otel.ts | 75 ++++++++++++++ sdks/node/test/contrib-otel.test.ts | 146 ++++++++++++++++++++++++++++ sdks/node/tsup.config.ts | 6 +- 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 sdks/node/src/contrib/otel.ts create mode 100644 sdks/node/test/contrib-otel.test.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index 19837be4..6cb1224a 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -19,6 +19,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./contrib/otel": { + "types": "./dist/contrib/otel.d.ts", + "import": "./dist/contrib/otel.js", + "require": "./dist/contrib/otel.cjs" } }, "files": [ @@ -60,6 +65,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.16", "@napi-rs/cli": "^2.18.4", + "@opentelemetry/api": "^1.9.1", "@types/node": "^25.9.2", "tsup": "^8.5.0", "typescript": "^6.0.3", @@ -68,5 +74,13 @@ "dependencies": { "@msgpack/msgpack": "^3.1.3", "commander": "^15.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index e7d1dd9f..04bbf663 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@napi-rs/cli': specifier: ^2.18.4 version: 2.18.4 + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 '@types/node': specifier: ^25.9.2 version: 25.9.3 @@ -32,7 +35,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) packages: @@ -286,6 +289,10 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -1168,6 +1175,8 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@opentelemetry/api@1.9.1': {} + '@oxc-project/types@0.133.0': {} '@rolldown/binding-android-arm64@1.0.3': @@ -1700,7 +1709,7 @@ snapshots: esbuild: 0.27.7 fsevents: 2.3.3 - vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) @@ -1723,6 +1732,7 @@ snapshots: vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.9.3 transitivePeerDependencies: - msw diff --git a/sdks/node/src/contrib/otel.ts b/sdks/node/src/contrib/otel.ts new file mode 100644 index 00000000..60f5b3cd --- /dev/null +++ b/sdks/node/src/contrib/otel.ts @@ -0,0 +1,75 @@ +// OpenTelemetry tracing for Taskito task execution. Optional integration — +// import from `taskito/contrib/otel`; requires `@opentelemetry/api` as a peer. +// +// Register with `queue.use(otelMiddleware())`. Each execution attempt becomes one +// span (`taskito.execute.`); a retry is a fresh attempt and thus a new span. + +import { type Attributes, type Span, SpanStatusCode, trace } from "@opentelemetry/api"; +import type { Middleware, TaskContext } from "../middleware"; + +/** Options for {@link otelMiddleware}. */ +export interface OtelMiddlewareOptions { + /** Tracer name passed to `trace.getTracer` (default `"taskito"`). */ + tracerName?: string; + /** Prefix for span attribute keys (default `"taskito"`). */ + attributePrefix?: string; + /** Override the span name (default `".execute."`). */ + spanName?: (ctx: TaskContext) => string; + /** Extra attributes merged onto the span at start. */ + extraAttributes?: (ctx: TaskContext) => Attributes; + /** Only trace tasks for which this returns true (default: all). */ + taskFilter?: (taskName: string) => boolean; +} + +/** + * Build {@link Middleware} that wraps each task execution in an OpenTelemetry span. + * The span starts in `before`, ends `OK` in `after`, and ends `ERROR` (recording the + * exception) in `onError`. + */ +export function otelMiddleware(options: OtelMiddlewareOptions = {}): Middleware { + const tracerName = options.tracerName ?? "taskito"; + const prefix = options.attributePrefix ?? "taskito"; + const tracer = trace.getTracer(tracerName); + const spans = new Map(); + + const tracked = (taskName: string): boolean => options.taskFilter?.(taskName) ?? true; + + return { + before(ctx) { + if (!tracked(ctx.taskName)) { + return; + } + const name = options.spanName?.(ctx) ?? `${prefix}.execute.${ctx.taskName}`; + const span = tracer.startSpan(name); + span.setAttribute(`${prefix}.job_id`, ctx.jobId); + span.setAttribute(`${prefix}.task_name`, ctx.taskName); + const extra = options.extraAttributes?.(ctx); + if (extra) { + span.setAttributes(extra); + } + spans.set(ctx.jobId, span); + }, + + after(ctx) { + const span = spans.get(ctx.jobId); + if (!span) { + return; + } + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + spans.delete(ctx.jobId); + }, + + onError(ctx, error) { + const span = spans.get(ctx.jobId); + if (!span) { + return; + } + const message = error instanceof Error ? error.message : String(error); + span.recordException(error instanceof Error ? error : message); + span.setStatus({ code: SpanStatusCode.ERROR, message }); + span.end(); + spans.delete(ctx.jobId); + }, + }; +} diff --git a/sdks/node/test/contrib-otel.test.ts b/sdks/node/test/contrib-otel.test.ts new file mode 100644 index 00000000..1661ca11 --- /dev/null +++ b/sdks/node/test/contrib-otel.test.ts @@ -0,0 +1,146 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Attributes, + type SpanStatus, + SpanStatusCode, + type TracerProvider, + trace, +} from "@opentelemetry/api"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { otelMiddleware } from "../src/contrib/otel"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +interface RecordedSpan { + name: string; + attributes: Attributes; + status?: SpanStatus; + exceptions: unknown[]; + ended: boolean; +} + +/** A minimal recording tracer provider installed globally so the middleware's + * `trace.getTracer()` resolves to spans we can inspect. */ +function installRecorder(): RecordedSpan[] { + const spans: RecordedSpan[] = []; + const provider = { + getTracer: () => ({ + startSpan(name: string) { + const span: RecordedSpan = { name, attributes: {}, exceptions: [], ended: false }; + spans.push(span); + return { + setAttribute(key: string, value: unknown) { + span.attributes[key] = value as Attributes[string]; + return this; + }, + setAttributes(attrs: Attributes) { + Object.assign(span.attributes, attrs); + return this; + }, + setStatus(status: SpanStatus) { + span.status = status; + return this; + }, + recordException(exception: unknown) { + span.exceptions.push(exception); + }, + end() { + span.ended = true; + }, + }; + }, + }), + } as unknown as TracerProvider; + trace.setGlobalTracerProvider(provider); + return spans; +} + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-otel-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +beforeEach(() => { + trace.disable(); +}); + +afterEach(() => { + worker?.stop(); + worker = undefined; + trace.disable(); +}); + +it("opens an OK span per successful task execution", async () => { + const spans = installRecorder(); + const queue = newQueue(); + queue.use(otelMiddleware()); + queue.task("add", (a: number, b: number) => a + b); + + queue.enqueue("add", [2, 3]); + worker = queue.runWorker(); + + expect(await waitFor(() => spans.length > 0 && spans[0]?.ended === true)).toBe(true); + const span = spans[0]; + expect(span?.name).toBe("taskito.execute.add"); + expect(span?.attributes["taskito.task_name"]).toBe("add"); + expect(typeof span?.attributes["taskito.job_id"]).toBe("string"); + expect(span?.status?.code).toBe(SpanStatusCode.OK); +}); + +it("records the exception and ERROR status when a task throws", async () => { + const spans = installRecorder(); + const queue = newQueue(); + queue.use(otelMiddleware()); + queue.task( + "boom", + () => { + throw new Error("kaboom"); + }, + { maxRetries: 0 }, + ); + + queue.enqueue("boom", []); + worker = queue.runWorker(); + + expect(await waitFor(() => spans.length > 0 && spans[0]?.ended === true)).toBe(true); + const span = spans[0]; + expect(span?.status?.code).toBe(SpanStatusCode.ERROR); + expect(span?.status?.message).toBe("kaboom"); + expect(span?.exceptions[0]).toBeInstanceOf(Error); +}); + +it("honors taskFilter and a custom span name", async () => { + const spans = installRecorder(); + const queue = newQueue(); + queue.use( + otelMiddleware({ + taskFilter: (name) => name === "traced", + spanName: (ctx) => `job:${ctx.taskName}`, + extraAttributes: () => ({ "app.tier": "batch" }), + }), + ); + queue.task("traced", () => 1); + queue.task("ignored", () => 2); + + queue.enqueue("ignored", []); + queue.enqueue("traced", []); + worker = queue.runWorker(); + + expect(await waitFor(() => spans.some((s) => s.name === "job:traced"))).toBe(true); + expect(spans.every((s) => s.name !== "ignored" && !s.name.includes("ignored"))).toBe(true); + const traced = spans.find((s) => s.name === "job:traced"); + expect(traced?.attributes["app.tier"]).toBe("batch"); +}); diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index e1ac2b62..48bea017 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: { index: "src/index.ts", cli: "src/cli/index.ts" }, + entry: { + index: "src/index.ts", + cli: "src/cli/index.ts", + "contrib/otel": "src/contrib/otel.ts", + }, format: ["esm", "cjs"], dts: true, shims: true, From cfb70d7878f6d12986c6e22e54f921f9d146bb2b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 02/25] feat(node): Prometheus metrics middleware + stats collector --- sdks/node/package.json | 11 +- sdks/node/src/contrib/prometheus.ts | 189 ++++++++++++++++++++++ sdks/node/test/contrib-prometheus.test.ts | 93 +++++++++++ sdks/node/tsup.config.ts | 1 + 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 sdks/node/src/contrib/prometheus.ts create mode 100644 sdks/node/test/contrib-prometheus.test.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index 6cb1224a..fac82716 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -24,6 +24,11 @@ "types": "./dist/contrib/otel.d.ts", "import": "./dist/contrib/otel.js", "require": "./dist/contrib/otel.cjs" + }, + "./contrib/prometheus": { + "types": "./dist/contrib/prometheus.d.ts", + "import": "./dist/contrib/prometheus.js", + "require": "./dist/contrib/prometheus.cjs" } }, "files": [ @@ -76,11 +81,15 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.9.0" + "@opentelemetry/api": "^1.9.0", + "prom-client": "^15.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true + }, + "prom-client": { + "optional": true } } } diff --git a/sdks/node/src/contrib/prometheus.ts b/sdks/node/src/contrib/prometheus.ts new file mode 100644 index 00000000..ccad657e --- /dev/null +++ b/sdks/node/src/contrib/prometheus.ts @@ -0,0 +1,189 @@ +// Prometheus metrics for Taskito. Optional integration — import from +// `taskito/contrib/prometheus`; requires `prom-client` as a peer. +// +// Register the middleware with `queue.use(prometheusMiddleware())` to record per-job +// counters/histograms, and run a `PrometheusStatsCollector` to poll queue depth / DLQ +// size. Expose `register.metrics()` from your HTTP server. + +import { Counter, register as defaultRegister, Gauge, Histogram, type Registry } from "prom-client"; +import type { Middleware } from "../middleware"; +import type { Queue } from "../queue"; + +/** The per-(registry, namespace) metric bundle, created once and reused. */ +interface TaskitoMetrics { + jobsTotal: Counter<"task" | "status">; + jobDuration: Histogram<"task">; + activeWorkers: Gauge; + retriesTotal: Counter<"task">; + queueDepth: Gauge<"queue">; + dlqSize: Gauge; +} + +// prom-client throws on duplicate registration, so a namespace's metrics must be built +// exactly once per registry. Cache them — mirrors the Python contrib's `_metric_stores`. +const stores = new WeakMap>(); + +function getMetrics(register: Registry, namespace: string, buckets?: number[]): TaskitoMetrics { + let byNamespace = stores.get(register); + if (!byNamespace) { + byNamespace = new Map(); + stores.set(register, byNamespace); + } + const cached = byNamespace.get(namespace); + if (cached) { + return cached; + } + const registers = [register]; + const metrics: TaskitoMetrics = { + jobsTotal: new Counter({ + name: `${namespace}_jobs_total`, + help: "Total finished jobs by task and outcome.", + labelNames: ["task", "status"], + registers, + }), + jobDuration: new Histogram({ + name: `${namespace}_job_duration_seconds`, + help: "Task execution duration in seconds.", + labelNames: ["task"], + ...(buckets ? { buckets } : {}), + registers, + }), + activeWorkers: new Gauge({ + name: `${namespace}_active_workers`, + help: "Jobs currently executing.", + registers, + }), + retriesTotal: new Counter({ + name: `${namespace}_retries_total`, + help: "Total job retries by task.", + labelNames: ["task"], + registers, + }), + queueDepth: new Gauge({ + name: `${namespace}_queue_depth`, + help: "Pending jobs per queue.", + labelNames: ["queue"], + registers, + }), + dlqSize: new Gauge({ + name: `${namespace}_dlq_size`, + help: "Total jobs in the dead-letter queue.", + registers, + }), + }; + byNamespace.set(namespace, metrics); + return metrics; +} + +/** Options shared by the middleware and the stats collector. */ +interface CommonOptions { + /** Metric name prefix (default `"taskito"`). */ + namespace?: string; + /** Registry to register on (default the prom-client global `register`). */ + register?: Registry; +} + +/** Options for {@link prometheusMiddleware}. */ +export interface PrometheusMiddlewareOptions extends CommonOptions { + /** Only record tasks for which this returns true (default: all). */ + taskFilter?: (taskName: string) => boolean; + /** Custom histogram buckets (seconds) for job duration. */ + buckets?: number[]; +} + +/** + * Build {@link Middleware} that records job counters and duration histograms. Tracks + * in-flight jobs as a gauge and retries as a counter. + */ +export function prometheusMiddleware(options: PrometheusMiddlewareOptions = {}): Middleware { + const namespace = options.namespace ?? "taskito"; + const register = options.register ?? defaultRegister; + const metrics = getMetrics(register, namespace, options.buckets); + const tracked = (taskName: string): boolean => options.taskFilter?.(taskName) ?? true; + const starts = new Map(); + + const finish = (jobId: string, task: string, status: "completed" | "failed"): void => { + const start = starts.get(jobId); + if (start === undefined) { + return; + } + starts.delete(jobId); + metrics.activeWorkers.dec(); + metrics.jobDuration.observe({ task }, (performance.now() - start) / 1000); + metrics.jobsTotal.inc({ task, status }); + }; + + return { + before(ctx) { + if (!tracked(ctx.taskName)) { + return; + } + metrics.activeWorkers.inc(); + starts.set(ctx.jobId, performance.now()); + }, + after(ctx) { + finish(ctx.jobId, ctx.taskName, "completed"); + }, + onError(ctx) { + finish(ctx.jobId, ctx.taskName, "failed"); + }, + onRetry(event) { + if (tracked(event.taskName)) { + metrics.retriesTotal.inc({ task: event.taskName }); + } + }, + }; +} + +/** Options for {@link PrometheusStatsCollector}. */ +export interface PrometheusStatsCollectorOptions extends CommonOptions { + /** Poll interval in milliseconds (default 10000). */ + intervalMs?: number; +} + +/** + * Periodically samples queue depth and dead-letter size into gauges. Call {@link start} + * to begin polling and {@link stop} to end it (the timer is unref'd, so it never keeps + * the process alive on its own). + */ +export class PrometheusStatsCollector { + private readonly metrics: TaskitoMetrics; + private readonly intervalMs: number; + private timer: ReturnType | undefined; + + constructor( + private readonly queue: Queue, + options: PrometheusStatsCollectorOptions = {}, + ) { + const namespace = options.namespace ?? "taskito"; + const register = options.register ?? defaultRegister; + this.metrics = getMetrics(register, namespace); + this.intervalMs = options.intervalMs ?? 10_000; + } + + /** Begin polling. Sampling runs immediately, then every `intervalMs`. */ + start(): void { + if (this.timer) { + return; + } + this.sample(); + this.timer = setInterval(() => this.sample(), this.intervalMs); + this.timer.unref(); + } + + /** Stop polling. */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + /** Read current queue/DLQ stats into the gauges. Public for one-shot scrapes. */ + sample(): void { + for (const [name, stats] of Object.entries(this.queue.statsAllQueues())) { + this.metrics.queueDepth.set({ queue: name }, stats.pending); + } + this.metrics.dlqSize.set(this.queue.stats().dead); + } +} diff --git a/sdks/node/test/contrib-prometheus.test.ts b/sdks/node/test/contrib-prometheus.test.ts new file mode 100644 index 00000000..c6590c07 --- /dev/null +++ b/sdks/node/test/contrib-prometheus.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Registry } from "prom-client"; +import { afterEach, expect, it } from "vitest"; +import { PrometheusStatsCollector, prometheusMiddleware } from "../src/contrib/prometheus"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-prom-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("records job counters and a duration histogram", async () => { + const register = new Registry(); + const queue = newQueue(); + queue.use(prometheusMiddleware({ register })); + queue.task("add", (a: number, b: number) => a + b); + queue.task( + "boom", + () => { + throw new Error("nope"); + }, + { maxRetries: 0 }, + ); + + queue.enqueue("add", [1, 2]); + queue.enqueue("boom", []); + worker = queue.runWorker(); + + expect(await waitFor(() => queue.stats().completed >= 1 && queue.stats().dead >= 1)).toBe(true); + + const text = await register.metrics(); + expect(text).toContain('taskito_jobs_total{task="add",status="completed"} 1'); + expect(text).toContain('taskito_jobs_total{task="boom",status="failed"} 1'); + expect(text).toContain("taskito_job_duration_seconds_count"); + expect(text).toContain("taskito_active_workers 0"); +}); + +it("shares one metric store per (registry, namespace)", () => { + const register = new Registry(); + // Two middlewares on the same registry/namespace must not double-register. + expect(() => { + prometheusMiddleware({ register }); + prometheusMiddleware({ register }); + }).not.toThrow(); +}); + +it("samples queue depth and DLQ size", async () => { + const register = new Registry(); + const queue = newQueue(); + queue.task( + "boom", + () => { + throw new Error("nope"); + }, + { maxRetries: 0 }, + ); + + // Pending jobs (no worker yet) → queue depth. + queue.enqueue("boom", []); + queue.enqueue("boom", []); + + const collector = new PrometheusStatsCollector(queue, { register, intervalMs: 60_000 }); + collector.start(); + let text = await register.metrics(); + expect(text).toContain('taskito_queue_depth{queue="default"} 2'); + + // Drain to the DLQ, then re-sample. + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().dead >= 2)).toBe(true); + collector.sample(); + text = await register.metrics(); + expect(text).toContain("taskito_dlq_size 2"); + collector.stop(); +}); diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index 48bea017..5f8319c7 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ index: "src/index.ts", cli: "src/cli/index.ts", "contrib/otel": "src/contrib/otel.ts", + "contrib/prometheus": "src/contrib/prometheus.ts", }, format: ["esm", "cjs"], dts: true, From 65745c7ce86335ee457db1ae72d527fa8215ba37 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 03/25] refactor(node): extract createDashboardHandler for mounting --- sdks/node/src/dashboard/index.ts | 2 +- sdks/node/src/dashboard/server.ts | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index ef977f78..01399bb3 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -27,4 +27,4 @@ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Se return server; } -export { createDashboardServer } from "./server"; +export { createDashboardHandler, createDashboardServer } from "./server"; diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 7d0a5b8f..add41982 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -12,17 +12,30 @@ const log = createLogger("dashboard"); /** Max request body size (1 MiB) — reject larger payloads to bound memory. */ const MAX_BODY_BYTES = 1024 * 1024; -/** Build (but do not start) the dashboard server over `queue`, serving the SPA from `staticDir`. */ -export function createDashboardServer(queue: Queue, staticDir: string): Server { +/** + * Build a Node `http` request handler that serves the dashboard SPA from `staticDir` + * plus the `/api/*` JSON contract over `queue`. Use this to mount the dashboard into an + * existing server (e.g. an Express or Fastify app); {@link createDashboardServer} wraps + * it in a standalone server. + */ +export function createDashboardHandler( + queue: Queue, + staticDir: string, +): (req: IncomingMessage, res: ServerResponse) => void { const assets = new StaticAssets(staticDir); - return createServer((req, res) => { + return (req, res) => { void dispatch(queue, assets, req, res).catch((error) => { log.error(() => "dashboard dispatch failed", error); if (!res.headersSent) { sendJson(res, 500, { error: "internal server error" }); } }); - }); + }; +} + +/** Build (but do not start) the dashboard server over `queue`, serving the SPA from `staticDir`. */ +export function createDashboardServer(queue: Queue, staticDir: string): Server { + return createServer(createDashboardHandler(queue, staticDir)); } async function dispatch( From 087168c8365c10a1101e75c7b68dc92d383260e7 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 04/25] feat(node): Express integration (REST router + dashboard mount) --- sdks/node/package.json | 11 + sdks/node/pnpm-lock.yaml | 604 +++++++++++++++++++++++++ sdks/node/src/contrib/express.ts | 85 ++++ sdks/node/src/contrib/rest.ts | 161 +++++++ sdks/node/test/contrib-express.test.ts | 103 +++++ sdks/node/tsup.config.ts | 1 + 6 files changed, 965 insertions(+) create mode 100644 sdks/node/src/contrib/express.ts create mode 100644 sdks/node/src/contrib/rest.ts create mode 100644 sdks/node/test/contrib-express.test.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index fac82716..4826f965 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -29,6 +29,11 @@ "types": "./dist/contrib/prometheus.d.ts", "import": "./dist/contrib/prometheus.js", "require": "./dist/contrib/prometheus.cjs" + }, + "./contrib/express": { + "types": "./dist/contrib/express.d.ts", + "import": "./dist/contrib/express.js", + "require": "./dist/contrib/express.cjs" } }, "files": [ @@ -71,7 +76,9 @@ "@biomejs/biome": "^2.4.16", "@napi-rs/cli": "^2.18.4", "@opentelemetry/api": "^1.9.1", + "@types/express": "^5.0.6", "@types/node": "^25.9.2", + "express": "^5.2.1", "tsup": "^8.5.0", "typescript": "^6.0.3", "vitest": "^4.1.8" @@ -82,12 +89,16 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", + "express": "^4.18.0 || ^5.0.0", "prom-client": "^15.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true }, + "express": { + "optional": true + }, "prom-client": { "optional": true } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index 04bbf663..16b04cf9 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -24,9 +24,15 @@ importers: '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: ^25.9.2 version: 25.9.3 + express: + specifier: ^5.2.1 + version: 5.2.1 tsup: specifier: ^8.5.0 version: 8.5.1(postcss@8.5.15)(typescript@6.0.3) @@ -538,18 +544,45 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -579,6 +612,10 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -591,16 +628,32 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -624,9 +677,29 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -636,25 +709,63 @@ packages: supports-color: optional: true + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -664,14 +775,67 @@ packages: picomatch: optional: true + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -764,6 +928,26 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -778,14 +962,36 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -825,6 +1031,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -843,6 +1065,40 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -857,6 +1113,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -890,6 +1150,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -919,6 +1183,10 @@ packages: typescript: optional: true + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -930,6 +1198,14 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1019,6 +1295,9 @@ packages: engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + snapshots: '@biomejs/biome@2.5.0': @@ -1312,19 +1591,56 @@ snapshots: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.9.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.9.3 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.9.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.9.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.9.3 + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -1366,19 +1682,50 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn@8.17.0: {} any-promise@1.3.0: {} assertion-error@2.0.1: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@6.2.2: {} chokidar@4.0.3: @@ -1393,16 +1740,46 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + debug@4.4.3: dependencies: ms: 2.1.3 + depd@2.0.0: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -1432,25 +1809,123 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + expect-type@1.3.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.2 rollup: 4.62.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + joycon@3.1.1: {} lightningcss-android-arm64@1.32.0: @@ -1512,6 +1987,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -1529,10 +2016,26 @@ snapshots: nanoid@3.3.12: {} + negotiator@1.0.0: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obug@2.1.3: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1559,6 +2062,24 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -1615,6 +2136,73 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1623,6 +2211,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} sucrase@3.35.1: @@ -1656,6 +2246,8 @@ snapshots: tinyrainbow@3.1.0: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -1691,12 +2283,22 @@ snapshots: - tsx - yaml + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@6.0.3: {} ufo@1.6.4: {} undici-types@7.24.6: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7): dependencies: lightningcss: 1.32.0 @@ -1741,3 +2343,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + wrappy@1.0.2: {} diff --git a/sdks/node/src/contrib/express.ts b/sdks/node/src/contrib/express.ts new file mode 100644 index 00000000..018caabe --- /dev/null +++ b/sdks/node/src/contrib/express.ts @@ -0,0 +1,85 @@ +// Express integration for Taskito. Optional — import from `taskito/contrib/express`; +// requires `express` as a peer. +// +// app.use("/tasks", taskitoRouter(queue)); // JSON API (enqueue + inspection) +// app.use("/admin", taskitoDashboard(queue)); // the dashboard SPA + /api/* + +import { fileURLToPath } from "node:url"; +import express, { type RequestHandler, type Router } from "express"; +import { createDashboardHandler } from "../dashboard"; +import type { Queue } from "../queue"; +import { buildRestRoutes, type RestOptions, type RestRequest } from "./rest"; + +/** Options for {@link taskitoRouter}. */ +export type TaskitoRouterOptions = RestOptions; + +/** Options for {@link taskitoDashboard}. */ +export interface TaskitoDashboardOptions { + /** Path to the built SPA assets (defaults to the package's bundled `static/dashboard`). */ + staticDir?: string; +} + +/** Flatten Express's parsed query into plain `string | undefined` values. */ +function flattenQuery(query: unknown): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(query as Record)) { + if (typeof value === "string") { + out[key] = value; + } else if (Array.isArray(value) && typeof value[0] === "string") { + out[key] = value[0]; + } + } + return out; +} + +/** + * Build an Express {@link Router} exposing the Taskito REST API (enqueue, stats, job + * lookup, cancel, dead-letters). JSON body parsing is mounted on the router itself. + */ +export function taskitoRouter(queue: Queue, options: TaskitoRouterOptions = {}): Router { + const router = express.Router(); + router.use(express.json()); + + for (const route of buildRestRoutes(options)) { + const handler: RequestHandler = async (req, res) => { + const request: RestRequest = { + params: req.params as Record, + query: flattenQuery(req.query), + body: req.body, + }; + try { + const result = await route.handle(queue, request); + res.status(result.status).json(result.body); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + }; + if (route.method === "GET") { + router.get(route.path, handler); + } else { + router.post(route.path, handler); + } + } + return router; +} + +// Resolved relative to this entry's location in dist/contrib/ → package `static/dashboard`. +const STATIC_REL = ["..", "..", "static", "dashboard"].join("/"); + +function defaultStaticDir(): string { + return fileURLToPath(new URL(STATIC_REL, import.meta.url)); +} + +/** + * Build an Express middleware that serves the Taskito dashboard (SPA + `/api/*`). Mount + * it under a path — Express strips the mount prefix, which the dashboard handler expects. + */ +export function taskitoDashboard( + queue: Queue, + options: TaskitoDashboardOptions = {}, +): RequestHandler { + const handler = createDashboardHandler(queue, options.staticDir ?? defaultStaticDir()); + return (req, res) => { + handler(req, res); + }; +} diff --git a/sdks/node/src/contrib/rest.ts b/sdks/node/src/contrib/rest.ts new file mode 100644 index 00000000..1feb1463 --- /dev/null +++ b/sdks/node/src/contrib/rest.ts @@ -0,0 +1,161 @@ +// Framework-neutral REST route table shared by the Express and Fastify helpers. +// Each route maps a plain request (params/query/body) to a status + JSON body, so the +// adapters only translate framework request/response objects — no logic duplicated. + +import type { EnqueueOptions } from "../native"; +import type { Queue } from "../queue"; + +/** Options controlling which routes a helper exposes. */ +export interface RestOptions { + /** Expose only these route names (mutually exclusive with `excludeRoutes`). */ + includeRoutes?: string[]; + /** Expose all routes except these. */ + excludeRoutes?: string[]; + /** Max ms `GET /jobs/:id/result` blocks for a terminal state (default 5000). */ + resultTimeoutMs?: number; +} + +/** A framework-neutral request. */ +export interface RestRequest { + params: Record; + query: Record; + body: unknown; +} + +/** A framework-neutral response: HTTP status + a JSON-serializable body. */ +export interface RestResponse { + status: number; + body: unknown; +} + +/** One REST route. `path` uses `:param` syntax understood by both Express and Fastify. */ +export interface RestRoute { + name: string; + method: "GET" | "POST"; + path: string; + handle: (queue: Queue, req: RestRequest) => RestResponse | Promise; +} + +function toInt(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** Drop the raw result Buffer from a job view — use the result route for the value. */ +function jobView(job: NonNullable>): Record { + const { result: _result, ...rest } = job; + return rest; +} + +function defineRoutes(resultTimeoutMs: number): RestRoute[] { + return [ + { + name: "enqueue", + method: "POST", + path: "/enqueue", + handle: (queue, { body }) => { + const input = body as { task?: unknown; args?: unknown; options?: unknown } | undefined; + if (!input || typeof input.task !== "string") { + return { status: 400, body: { error: "body must include a 'task' string" } }; + } + const args = Array.isArray(input.args) ? input.args : []; + const options = (input.options as EnqueueOptions | undefined) ?? undefined; + const jobId = queue.enqueue(input.task, args, options); + return { status: 201, body: { jobId } }; + }, + }, + { + name: "stats", + method: "GET", + path: "/stats", + handle: (queue) => ({ status: 200, body: queue.stats() }), + }, + { + name: "queue-stats", + method: "GET", + path: "/stats/queues", + handle: (queue, { query }) => { + const body = query.queue ? queue.statsByQueue(query.queue) : queue.statsAllQueues(); + return { status: 200, body }; + }, + }, + { + name: "job", + method: "GET", + path: "/jobs/:id", + handle: (queue, { params }) => { + const job = queue.getJob(params.id ?? ""); + return job + ? { status: 200, body: jobView(job) } + : { status: 404, body: { error: "not found" } }; + }, + }, + { + name: "job-errors", + method: "GET", + path: "/jobs/:id/errors", + handle: (queue, { params }) => ({ status: 200, body: queue.getJobErrors(params.id ?? "") }), + }, + { + name: "job-result", + method: "GET", + path: "/jobs/:id/result", + handle: async (queue, { params, query }) => { + const id = params.id ?? ""; + const timeoutMs = toInt(query.timeoutMs) ?? resultTimeoutMs; + try { + const result = await queue.result(id, { timeoutMs }); + return { status: 200, body: { jobId: id, status: "completed", result } }; + } catch (err) { + const status = queue.getJob(id)?.status ?? "pending"; + const error = err instanceof Error ? err.message : String(err); + return { status: 200, body: { jobId: id, status, error } }; + } + }, + }, + { + name: "cancel", + method: "POST", + path: "/jobs/:id/cancel", + handle: (queue, { params }) => ({ + status: 200, + body: { cancelled: queue.requestCancel(params.id ?? "") }, + }), + }, + { + name: "dead-letters", + method: "GET", + path: "/dead-letters", + handle: (queue, { query }) => ({ + status: 200, + body: queue.deadLetters(toInt(query.limit), toInt(query.offset)), + }), + }, + { + name: "retry-dead", + method: "POST", + path: "/dead-letters/:id/retry", + handle: (queue, { params }) => ({ + status: 200, + body: { jobId: queue.retryDead(params.id ?? "") }, + }), + }, + ]; +} + +/** Build the route set for a helper, applying `includeRoutes`/`excludeRoutes`. */ +export function buildRestRoutes(options: RestOptions = {}): RestRoute[] { + const routes = defineRoutes(options.resultTimeoutMs ?? 5000); + if (options.includeRoutes) { + const allow = new Set(options.includeRoutes); + return routes.filter((route) => allow.has(route.name)); + } + if (options.excludeRoutes) { + const deny = new Set(options.excludeRoutes); + return routes.filter((route) => !deny.has(route.name)); + } + return routes; +} diff --git a/sdks/node/test/contrib-express.test.ts b/sdks/node/test/contrib-express.test.ts new file mode 100644 index 00000000..ce34066c --- /dev/null +++ b/sdks/node/test/contrib-express.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express from "express"; +import { afterEach, expect, it } from "vitest"; +import { taskitoDashboard, taskitoRouter } from "../src/contrib/express"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; +let close: (() => Promise) | undefined; + +afterEach(async () => { + worker?.stop(); + worker = undefined; + await close?.(); + close = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-exp-")), "q.db") }); +} + +/** Mount `configure` on a fresh Express app and return its base URL. */ +async function serve(configure: (app: express.Express) => void): Promise { + const app = express(); + configure(app); + const server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + close = () => new Promise((resolve) => server.close(() => resolve())); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("enqueues, runs, and reports results over the REST router", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + const base = await serve((app) => app.use("/tasks", taskitoRouter(queue))); + + const enqueued = await fetch(`${base}/tasks/enqueue`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ task: "add", args: [2, 3] }), + }); + expect(enqueued.status).toBe(201); + const { jobId } = (await enqueued.json()) as { jobId: string }; + expect(typeof jobId).toBe("string"); + + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + + const result = await (await fetch(`${base}/tasks/jobs/${jobId}/result`)).json(); + expect(result).toMatchObject({ jobId, status: "completed", result: 5 }); + + const stats = (await (await fetch(`${base}/tasks/stats`)).json()) as { completed: number }; + expect(stats.completed).toBeGreaterThanOrEqual(1); + + const job = (await (await fetch(`${base}/tasks/jobs/${jobId}`)).json()) as Record< + string, + unknown + >; + expect(job.taskName).toBe("add"); + expect(job).not.toHaveProperty("result"); // raw buffer stripped from the job view +}); + +it("rejects an enqueue without a task name", async () => { + const queue = newQueue(); + const base = await serve((app) => app.use("/tasks", taskitoRouter(queue))); + const res = await fetch(`${base}/tasks/enqueue`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ args: [1] }), + }); + expect(res.status).toBe(400); +}); + +it("honors excludeRoutes", async () => { + const queue = newQueue(); + const base = await serve((app) => + app.use("/tasks", taskitoRouter(queue, { excludeRoutes: ["stats"] })), + ); + expect((await fetch(`${base}/tasks/stats`)).status).toBe(404); + expect((await fetch(`${base}/tasks/dead-letters`)).status).toBe(200); +}); + +it("mounts the dashboard JSON API", async () => { + const queue = newQueue(); + const base = await serve((app) => app.use("/admin", taskitoDashboard(queue))); + const res = await fetch(`${base}/admin/api/auth/status`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ setup_required: false }); +}); diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index 5f8319c7..23f2518b 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ cli: "src/cli/index.ts", "contrib/otel": "src/contrib/otel.ts", "contrib/prometheus": "src/contrib/prometheus.ts", + "contrib/express": "src/contrib/express.ts", }, format: ["esm", "cjs"], dts: true, From 1f61873e1c8c24bf432a6f0461b433c954e41710 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 05/25] feat(node): Fastify integration (REST router + dashboard mount) --- sdks/node/package.json | 10 + sdks/node/pnpm-lock.yaml | 344 +++++++++++++++++++++++++ sdks/node/src/contrib/fastify.ts | 97 +++++++ sdks/node/test/contrib-fastify.test.ts | 83 ++++++ sdks/node/tsup.config.ts | 1 + 5 files changed, 535 insertions(+) create mode 100644 sdks/node/src/contrib/fastify.ts create mode 100644 sdks/node/test/contrib-fastify.test.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index 4826f965..5fb16cbf 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -34,6 +34,11 @@ "types": "./dist/contrib/express.d.ts", "import": "./dist/contrib/express.js", "require": "./dist/contrib/express.cjs" + }, + "./contrib/fastify": { + "types": "./dist/contrib/fastify.d.ts", + "import": "./dist/contrib/fastify.js", + "require": "./dist/contrib/fastify.cjs" } }, "files": [ @@ -79,6 +84,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.9.2", "express": "^5.2.1", + "fastify": "^5.8.5", "tsup": "^8.5.0", "typescript": "^6.0.3", "vitest": "^4.1.8" @@ -90,6 +96,7 @@ "peerDependencies": { "@opentelemetry/api": "^1.9.0", "express": "^4.18.0 || ^5.0.0", + "fastify": "^4.0.0 || ^5.0.0", "prom-client": "^15.0.0" }, "peerDependenciesMeta": { @@ -99,6 +106,9 @@ "express": { "optional": true }, + "fastify": { + "optional": true + }, "prom-client": { "optional": true } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index 16b04cf9..b592ff92 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + fastify: + specifier: ^5.8.5 + version: 5.8.5 tsup: specifier: ^8.5.0 version: 8.5.1(postcss@8.5.15)(typescript@6.0.3) @@ -267,6 +270,24 @@ packages: cpu: [x64] os: [win32] + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -302,6 +323,9 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -612,6 +636,9 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -621,6 +648,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -628,6 +666,13 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -700,6 +745,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -713,6 +762,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -766,6 +819,27 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -779,6 +853,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -833,6 +911,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -840,6 +922,15 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -978,6 +1069,10 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1002,6 +1097,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -1031,6 +1136,12 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1039,6 +1150,9 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1051,10 +1165,32 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1069,9 +1205,25 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -1080,6 +1232,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -1102,6 +1257,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1110,6 +1268,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1132,6 +1294,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1150,6 +1316,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1429,6 +1599,29 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1458,6 +1651,8 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@pinojs/redact@0.4.0': {} + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -1682,6 +1877,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abstract-logging@2.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -1689,10 +1886,28 @@ snapshots: acorn@8.17.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -1752,12 +1967,16 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} dunder-proto@1.0.1: @@ -1852,6 +2071,47 @@ snapshots: transitivePeerDependencies: - supports-color + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.2: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.4 + toad-cache: 3.7.1 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1867,6 +2127,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -1924,10 +2190,24 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.4.0: {} + is-promise@4.0.0: {} joycon@3.1.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: optional: true @@ -2024,6 +2304,8 @@ snapshots: obug@2.1.3: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -2042,6 +2324,26 @@ snapshots: picomatch@4.0.4: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} pkg-types@1.3.1: @@ -2062,6 +2364,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2071,6 +2377,8 @@ snapshots: dependencies: side-channel: 1.1.1 + quick-format-unescaped@4.0.4: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -2082,8 +2390,20 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -2146,8 +2466,18 @@ snapshots: transitivePeerDependencies: - supports-color + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + secure-json-parse@4.1.0: {} + + semver@7.8.4: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -2173,6 +2503,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} side-channel-list@1.0.1: @@ -2205,10 +2537,16 @@ snapshots: siginfo@2.0.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} + split2@4.2.0: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -2233,6 +2571,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2246,6 +2588,8 @@ snapshots: tinyrainbow@3.1.0: {} + toad-cache@3.7.1: {} + toidentifier@1.0.1: {} tree-kill@1.2.2: {} diff --git a/sdks/node/src/contrib/fastify.ts b/sdks/node/src/contrib/fastify.ts new file mode 100644 index 00000000..70a29964 --- /dev/null +++ b/sdks/node/src/contrib/fastify.ts @@ -0,0 +1,97 @@ +// Fastify integration for Taskito. Optional — import from `taskito/contrib/fastify`; +// requires `fastify` as a peer. +// +// app.register(taskitoFastify, { queue, prefix: "/tasks" }); // JSON API +// app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); // dashboard SPA + /api/* + +import { fileURLToPath } from "node:url"; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { createDashboardHandler } from "../dashboard"; +import type { Queue } from "../queue"; +import { buildRestRoutes, type RestOptions, type RestRequest } from "./rest"; + +/** Options for {@link taskitoFastify}. */ +export interface TaskitoFastifyOptions extends RestOptions { + /** The queue to operate over. */ + queue: Queue; +} + +/** Options for {@link taskitoDashboardPlugin}. */ +export interface TaskitoDashboardPluginOptions { + /** The queue to operate over. */ + queue: Queue; + /** Path to the built SPA assets (defaults to the package's bundled `static/dashboard`). */ + staticDir?: string; +} + +/** Flatten Fastify's parsed query into plain `string | undefined` values. */ +function flattenQuery(query: unknown): Record { + const out: Record = {}; + for (const [key, value] of Object.entries((query as Record) ?? {})) { + if (typeof value === "string") { + out[key] = value; + } else if (Array.isArray(value) && typeof value[0] === "string") { + out[key] = value[0]; + } + } + return out; +} + +/** + * Fastify plugin exposing the Taskito REST API (enqueue, stats, job lookup, cancel, + * dead-letters). Register with a `prefix` to mount the routes under a base path. + */ +export const taskitoFastify: FastifyPluginAsync = async ( + fastify, + options, +) => { + const { queue } = options; + for (const route of buildRestRoutes(options)) { + fastify.route({ + method: route.method, + url: route.path, + handler: async (request: FastifyRequest, reply: FastifyReply) => { + const request_: RestRequest = { + params: request.params as Record, + query: flattenQuery(request.query), + body: request.body, + }; + const result = await route.handle(queue, request_); + reply.code(result.status).send(result.body); + }, + }); + } +}; + +// Resolved relative to this entry's location in dist/contrib/ → package `static/dashboard`. +const STATIC_REL = ["..", "..", "static", "dashboard"].join("/"); + +function defaultStaticDir(): string { + return fileURLToPath(new URL(STATIC_REL, import.meta.url)); +} + +/** + * Fastify plugin that serves the Taskito dashboard (SPA + `/api/*`). Register with a + * `prefix`; the plugin strips it from the raw URL so the dashboard handler sees `/api/*` + * and SPA paths, then hands the raw request/response to the handler via `reply.hijack()`. + */ +export const taskitoDashboardPlugin: FastifyPluginAsync = async ( + fastify, + options, +) => { + const handler = createDashboardHandler(options.queue, options.staticDir ?? defaultStaticDir()); + const prefix = fastify.prefix; + + // Leave the request body stream intact so the dashboard handler can read POST bodies. + fastify.addContentTypeParser("*", (_request, payload, done) => done(null, payload)); + + const serve = (request: FastifyRequest, reply: FastifyReply): void => { + const url = request.raw.url ?? "/"; + request.raw.url = url.startsWith(prefix) ? url.slice(prefix.length) || "/" : url; + reply.hijack(); + handler(request.raw, reply.raw); + }; + + fastify.all("/", serve); + fastify.all("/*", serve); +}; diff --git a/sdks/node/test/contrib-fastify.test.ts b/sdks/node/test/contrib-fastify.test.ts new file mode 100644 index 00000000..9b81f2d2 --- /dev/null +++ b/sdks/node/test/contrib-fastify.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Fastify from "fastify"; +import { afterEach, expect, it } from "vitest"; +import { taskitoDashboardPlugin, taskitoFastify } from "../src/contrib/fastify"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-fastify-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("serves the REST API through a prefixed plugin", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + const app = Fastify(); + await app.register(taskitoFastify, { queue, prefix: "/tasks" }); + await app.ready(); + + const enqueued = await app.inject({ + method: "POST", + url: "/tasks/enqueue", + payload: { task: "add", args: [4, 5] }, + }); + expect(enqueued.statusCode).toBe(201); + const { jobId } = enqueued.json() as { jobId: string }; + + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + + const result = await app.inject({ method: "GET", url: `/tasks/jobs/${jobId}/result` }); + expect(result.json()).toMatchObject({ jobId, status: "completed", result: 9 }); + + const stats = await app.inject({ method: "GET", url: "/tasks/stats" }); + expect((stats.json() as { completed: number }).completed).toBeGreaterThanOrEqual(1); + + await app.close(); +}); + +it("applies excludeRoutes", async () => { + const queue = newQueue(); + const app = Fastify(); + await app.register(taskitoFastify, { queue, prefix: "/tasks", excludeRoutes: ["stats"] }); + await app.ready(); + + expect((await app.inject({ method: "GET", url: "/tasks/stats" })).statusCode).toBe(404); + expect((await app.inject({ method: "GET", url: "/tasks/dead-letters" })).statusCode).toBe(200); + + await app.close(); +}); + +it("mounts the dashboard JSON API under a prefix", async () => { + const queue = newQueue(); + const app = Fastify(); + await app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); + await app.listen({ port: 0, host: "127.0.0.1" }); + const { port } = app.server.address() as AddressInfo; + + const res = await fetch(`http://127.0.0.1:${port}/admin/api/auth/status`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ setup_required: false }); + + await app.close(); +}); diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index 23f2518b..9234c23d 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "contrib/otel": "src/contrib/otel.ts", "contrib/prometheus": "src/contrib/prometheus.ts", "contrib/express": "src/contrib/express.ts", + "contrib/fastify": "src/contrib/fastify.ts", }, format: ["esm", "cjs"], dts: true, From 46e74b761d0bb3526eaaa8ad60acebabc7d1fe3d Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 06/25] feat(node): NestJS module + injectable TaskitoService --- sdks/node/biome.json | 3 + sdks/node/package.json | 20 ++- sdks/node/pnpm-lock.yaml | 197 +++++++++++++++++++++++++++- sdks/node/src/contrib/nest.ts | 72 ++++++++++ sdks/node/test/contrib-nest.test.ts | 50 +++++++ sdks/node/tsconfig.json | 1 + sdks/node/tsup.config.ts | 1 + 7 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 sdks/node/src/contrib/nest.ts create mode 100644 sdks/node/test/contrib-nest.test.ts diff --git a/sdks/node/biome.json b/sdks/node/biome.json index 5bb6bbe5..0d638626 100644 --- a/sdks/node/biome.json +++ b/sdks/node/biome.json @@ -16,6 +16,9 @@ } }, "javascript": { + "parser": { + "unsafeParameterDecoratorsEnabled": true + }, "formatter": { "quoteStyle": "double", "semicolons": "always" diff --git a/sdks/node/package.json b/sdks/node/package.json index 5fb16cbf..7f3a987e 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -39,6 +39,11 @@ "types": "./dist/contrib/fastify.d.ts", "import": "./dist/contrib/fastify.js", "require": "./dist/contrib/fastify.cjs" + }, + "./contrib/nest": { + "types": "./dist/contrib/nest.d.ts", + "import": "./dist/contrib/nest.js", + "require": "./dist/contrib/nest.cjs" } }, "files": [ @@ -80,11 +85,16 @@ "devDependencies": { "@biomejs/biome": "^2.4.16", "@napi-rs/cli": "^2.18.4", + "@nestjs/common": "^11.1.27", + "@nestjs/core": "^11.1.27", + "@nestjs/testing": "^11.1.27", "@opentelemetry/api": "^1.9.1", "@types/express": "^5.0.6", "@types/node": "^25.9.2", "express": "^5.2.1", "fastify": "^5.8.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "tsup": "^8.5.0", "typescript": "^6.0.3", "vitest": "^4.1.8" @@ -94,12 +104,17 @@ "commander": "^15.0.0" }, "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", "@opentelemetry/api": "^1.9.0", "express": "^4.18.0 || ^5.0.0", "fastify": "^4.0.0 || ^5.0.0", - "prom-client": "^15.0.0" + "prom-client": "^15.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" }, "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, "@opentelemetry/api": { "optional": true }, @@ -111,6 +126,9 @@ }, "prom-client": { "optional": true + }, + "reflect-metadata": { + "optional": true } } } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index b592ff92..fb2bdc46 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -21,6 +21,15 @@ importers: '@napi-rs/cli': specifier: ^2.18.4 version: 2.18.4 + '@nestjs/common': + specifier: ^11.1.27 + version: 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/testing': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -36,6 +45,12 @@ importers: fastify: specifier: ^5.8.5 version: 5.8.5 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 tsup: specifier: ^8.5.0 version: 8.5.1(postcss@8.5.15)(typescript@6.0.3) @@ -105,6 +120,9 @@ packages: cpu: [x64] os: [win32] + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -301,6 +319,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@msgpack/msgpack@3.1.3': resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} engines: {node: '>= 18'} @@ -316,6 +338,50 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs/common@11.1.27': + resolution: {integrity: sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.1.27': + resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/testing@11.1.27': + resolution: {integrity: sha512-I35po13UHZZeGenLWJ3QYwh77RsLau5RcFKWBZ4waVHeARpwjtC7v7n7lGh98swLQdGmZgTnbvKaZ0B5dsUIKA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -565,6 +631,13 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -831,6 +904,9 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -849,6 +925,10 @@ packages: picomatch: optional: true + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -904,6 +984,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -918,6 +1001,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1012,6 +1099,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1172,6 +1263,9 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1205,6 +1299,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -1282,6 +1379,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1324,6 +1425,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1365,6 +1470,14 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -1505,6 +1618,8 @@ snapshots: '@biomejs/cli-win32-x64@2.5.0': optional: true + '@borewit/text-codec@0.2.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1636,6 +1751,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/csprng@1.1.0': {} + '@msgpack/msgpack@3.1.3': {} '@napi-rs/cli@2.18.4': {} @@ -1647,6 +1764,35 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + + '@nestjs/testing@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + '@opentelemetry/api@1.9.1': {} '@oxc-project/types@0.133.0': {} @@ -1781,6 +1927,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -2088,6 +2243,8 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.2: {} fastify@5.8.5: @@ -2116,6 +2273,15 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -2186,6 +2352,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -2194,6 +2362,8 @@ snapshots: is-promise@4.0.0: {} + iterare@1.2.1: {} + joycon@3.1.1: {} json-schema-ref-resolver@3.0.0: @@ -2261,6 +2431,8 @@ snapshots: lines-and-columns@1.2.4: {} + load-esm@1.0.3: {} + load-tsconfig@0.2.5: {} magic-string@0.30.21: @@ -2394,6 +2566,8 @@ snapshots: real-require@1.0.0: {} + reflect-metadata@0.2.2: {} + require-from-string@2.0.2: {} resolve-from@5.0.0: {} @@ -2466,6 +2640,10 @@ snapshots: transitivePeerDependencies: - supports-color + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -2553,6 +2731,10 @@ snapshots: std-env@4.1.0: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2592,12 +2774,17 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsup@8.5.1(postcss@8.5.15)(typescript@6.0.3): dependencies: @@ -2637,6 +2824,12 @@ snapshots: ufo@1.6.4: {} + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + undici-types@7.24.6: {} unpipe@1.0.0: {} diff --git a/sdks/node/src/contrib/nest.ts b/sdks/node/src/contrib/nest.ts new file mode 100644 index 00000000..478c883f --- /dev/null +++ b/sdks/node/src/contrib/nest.ts @@ -0,0 +1,72 @@ +// NestJS integration for Taskito. Optional — import from `taskito/contrib/nest`; +// requires `@nestjs/common` (and `reflect-metadata`) as peers. +// +// @Module({ imports: [TaskitoModule.forRoot(queue)] }) +// export class AppModule {} +// +// // then inject anywhere: +// constructor(private readonly tasks: TaskitoService) {} + +import "reflect-metadata"; +import { type DynamicModule, Inject, Injectable, Module } from "@nestjs/common"; +import type { Queue } from "../queue"; +import type { DeadJob, EnqueueOptions, Job, ResultOptions, Stats } from "../types"; + +/** DI token for the underlying {@link Queue}. Provided by {@link TaskitoModule.forRoot}. */ +export const TASKITO_QUEUE = Symbol("TASKITO_QUEUE"); + +/** + * Injectable wrapper over a Taskito {@link Queue}. Exposes the common producer/inspection + * methods; reach the full API via {@link TaskitoService.queue}. + */ +@Injectable() +export class TaskitoService { + constructor(@Inject(TASKITO_QUEUE) readonly queue: Queue) {} + + /** Enqueue `task` with positional `args`. Returns the job id. */ + enqueue(task: string, args?: unknown[], options?: EnqueueOptions): string { + return this.queue.enqueue(task, args ?? [], options); + } + + /** Await a job's terminal result. */ + result(id: string, options?: ResultOptions): Promise { + return this.queue.result(id, options); + } + + /** Fetch a job by id, or `null` if unknown. */ + getJob(id: string): Job | null { + return this.queue.getJob(id); + } + + /** Aggregate counts across all queues. */ + stats(): Stats { + return this.queue.stats(); + } + + /** Request cooperative cancellation of a job. */ + requestCancel(id: string): boolean { + return this.queue.requestCancel(id); + } + + /** List dead-letter entries. */ + deadLetters(limit?: number, offset?: number): DeadJob[] { + return this.queue.deadLetters(limit, offset); + } +} + +/** + * Dynamic Nest module that provides {@link TaskitoService} bound to a queue. Register it + * once at the root with {@link TaskitoModule.forRoot}. + */ +@Module({}) +// biome-ignore lint/complexity/noStaticOnlyClass: Nest dynamic modules are decorated classes with a static forRoot factory. +export class TaskitoModule { + /** Provide a {@link TaskitoService} backed by `queue`. */ + static forRoot(queue: Queue): DynamicModule { + return { + module: TaskitoModule, + providers: [{ provide: TASKITO_QUEUE, useValue: queue }, TaskitoService], + exports: [TaskitoService], + }; + } +} diff --git a/sdks/node/test/contrib-nest.test.ts b/sdks/node/test/contrib-nest.test.ts new file mode 100644 index 00000000..9029ef50 --- /dev/null +++ b/sdks/node/test/contrib-nest.test.ts @@ -0,0 +1,50 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Test } from "@nestjs/testing"; +import { afterEach, expect, it } from "vitest"; +import { TaskitoModule, TaskitoService } from "../src/contrib/nest"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-nest-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("injects TaskitoService bound to the queue", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + + const moduleRef = await Test.createTestingModule({ + imports: [TaskitoModule.forRoot(queue)], + }).compile(); + const service = moduleRef.get(TaskitoService); + + expect(service.queue).toBe(queue); + + const id = service.enqueue("add", [6, 7]); + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + + expect(await service.result(id)).toBe(13); + expect(service.stats().completed).toBeGreaterThanOrEqual(1); + + await moduleRef.close(); +}); diff --git a/sdks/node/tsconfig.json b/sdks/node/tsconfig.json index 4a1c74c3..c800ae79 100644 --- a/sdks/node/tsconfig.json +++ b/sdks/node/tsconfig.json @@ -9,6 +9,7 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "verbatimModuleSyntax": true, + "experimentalDecorators": true, "ignoreDeprecations": "6.0", "noEmit": true, "skipLibCheck": true, diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index 9234c23d..ca3298fc 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ "contrib/prometheus": "src/contrib/prometheus.ts", "contrib/express": "src/contrib/express.ts", "contrib/fastify": "src/contrib/fastify.ts", + "contrib/nest": "src/contrib/nest.ts", }, format: ["esm", "cjs"], dts: true, From 1911b784dd6a29e0d518c07626cb52fc0436210e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 07/25] fix(node): add prom-client to devDependencies --- sdks/node/package.json | 1 + sdks/node/pnpm-lock.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/sdks/node/package.json b/sdks/node/package.json index 7f3a987e..6b6446e3 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -93,6 +93,7 @@ "@types/node": "^25.9.2", "express": "^5.2.1", "fastify": "^5.8.5", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tsup": "^8.5.0", diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index fb2bdc46..f5476b5d 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: fastify: specifier: ^5.8.5 version: 5.8.5 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -746,6 +749,9 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -1233,6 +1239,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1388,6 +1398,9 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2063,6 +2076,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + bintrees@1.0.2: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -2540,6 +2555,11 @@ snapshots: process-warning@5.0.0: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2745,6 +2765,10 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 From 73128e479c6c862601a691190c47eab6d10fe790 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:39 +0530 Subject: [PATCH 08/25] docs(node): document contrib integrations --- NODE_SDK_TODO.md | 28 +-- docs/content/docs/node/contrib/index.mdx | 41 +++++ docs/content/docs/node/contrib/meta.json | 4 + .../docs/node/contrib/observability.mdx | 128 +++++++++++++ .../docs/node/contrib/web-frameworks.mdx | 173 ++++++++++++++++++ docs/content/docs/node/index.mdx | 5 + docs/content/docs/node/meta.json | 2 +- sdks/node/README.md | 60 +++++- 8 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 docs/content/docs/node/contrib/index.mdx create mode 100644 docs/content/docs/node/contrib/meta.json create mode 100644 docs/content/docs/node/contrib/observability.mdx create mode 100644 docs/content/docs/node/contrib/web-frameworks.mdx diff --git a/NODE_SDK_TODO.md b/NODE_SDK_TODO.md index 6d1fef83..a5d0fbeb 100644 --- a/NODE_SDK_TODO.md +++ b/NODE_SDK_TODO.md @@ -34,6 +34,7 @@ SDK, zero Python dependency, over the shared Rust core. | **Periodic + circuit-breaker** | `a4cc9a4` | `queue.registerPeriodic`; per-task `circuitBreaker` config | | **Workflows: fan-out / fan-in** | (this branch) | TS `WorkflowTracker` brain (`src/workflows/tracker.ts`) driven by the outcome callback; `.fanOut()` / `.fanIn()` builder steps; napi primitives `expandFanOut`/`checkFanOutCompletion`/`createDeferredJob`/`finalizeRunIfTerminal`/`getWorkflowRunPlan`/`workflowNodeForJob`/`cascadeSkipPending`; deferred-node submit. Storage-reconstructable (no submit-time registration) | | **Dashboard DAG completeness** | (this branch) | `JsWorkflowNode` carries `fanOutCount` + `compensation_*`; `/dag` handler enriches the raw graph with per-node `deps[]` + live `status` + job-id `id` so the SPA visualizer renders edges/colours/links | +| **Contrib integrations** | (this branch) | `src/contrib/{otel,prometheus,express,fastify,nest}.ts` — each an optional subpath export (`taskito/contrib/*`) with an optional peer dep. OTel + Prometheus = `Middleware` over the events layer; Express/Fastify = REST router (shared `rest.ts` table) + dashboard mount (via extracted `createDashboardHandler`); Nest = `TaskitoModule.forRoot` + injectable `TaskitoService`. 14 new vitest tests | **Verify everything green:** ```bash @@ -68,20 +69,20 @@ context managers) — do **not** port 1:1. Design Node-native equivalents: **Effort:** large (design-heavy). **Recommendation:** scope a minimal resource DI first (worker-scoped singletons + task-scoped factories); defer proxies entirely. -### 2. Contrib integrations — MEDIUM +### 2. Contrib integrations — DONE (Sentry remains) -Python ships `contrib/` (OpenTelemetry, Sentry, Prometheus, Flask, Django, -FastAPI). Node equivalents: +Shipped: OpenTelemetry + Prometheus middleware, Express + Fastify (REST router + +dashboard mount), and a NestJS module (`src/contrib/{otel,prometheus,express,fastify,nest}.ts`). +Each is an optional `taskito/contrib/*` subpath export with an optional peer dependency, +its own tsup entry, and tests. The REST logic is shared via a framework-neutral +`src/contrib/rest.ts` route table; the dashboard mount reuses +`createDashboardHandler` (extracted from `dashboard/server.ts`). NestJS uses an +explicit `@Inject(TASKITO_QUEUE)` token to avoid relying on esbuild decorator-metadata +emission (`experimentalDecorators` on; biome `unsafeParameterDecoratorsEnabled`). -- **Observability:** OpenTelemetry (`@opentelemetry/api`), Prometheus - (`prom-client`) — implement as middleware over the existing events/middleware - layer (`src/middleware.ts`). Each = a `Middleware` that records spans/metrics. -- **Web frameworks:** Express / Fastify / Nest helpers (enqueue from a request, - mount the dashboard). Python's Flask/Django/FastAPI map to these. - -**Where:** new `sdks/node/src/contrib/` (one file per integration, optional peer -deps). **Effort:** medium. Each integration is small and independent → **one -commit each**. +**Still open:** a **Sentry** integration (Python ships one) — small, a `Middleware` +that captures exceptions on `onError`/`onDeadLetter` via `@sentry/node`. One more +commit when wanted. ### 3. Advanced workflow features — LARGE (fan-out DONE; gates / sub-workflows / saga remain) @@ -239,7 +240,7 @@ pnpm run build:native # napi build, all features pnpm run build:ts # tsup dual ESM/CJS + .d.ts pnpm typecheck # tsc --noEmit (includes test/) pnpm lint # biome -pnpm test # vitest (48 tests) +pnpm test # vitest (66 tests) pnpm run build:dashboard # build the React SPA into static/dashboard ``` @@ -261,6 +262,7 @@ sdks/node/src/ webhooks/{store,deliverer,manager,types,index}.ts serializers/{serializer,json,msgpack,index}.ts dashboard/{server,routes,handlers,contract,static,metrics,api,index}.ts + contrib/{otel,prometheus,express,fastify,nest,rest}.ts # optional subpath exports cli/{index,connect,output,commands/*}.ts sdks/node/test/*.test.ts # grouped by feature area ``` diff --git a/docs/content/docs/node/contrib/index.mdx b/docs/content/docs/node/contrib/index.mdx new file mode 100644 index 00000000..59357bbc --- /dev/null +++ b/docs/content/docs/node/contrib/index.mdx @@ -0,0 +1,41 @@ +--- +title: Contrib Integrations +description: "Optional helpers for OpenTelemetry, Prometheus, Express, Fastify, and NestJS." +--- + +import { Cards, Card } from "fumadocs-ui/components/card"; +import { Callout } from "fumadocs-ui/components/callout"; + +Contrib modules are optional integrations that ship alongside the core SDK but +are **not exported from the main `taskito` barrel**. Each lives behind its own +subpath import and requires you to install its peer dependency separately. + +```bash +# Only install what you use. +pnpm add @opentelemetry/api # for taskito/contrib/otel +pnpm add prom-client # for taskito/contrib/prometheus +pnpm add express # for taskito/contrib/express +pnpm add fastify # for taskito/contrib/fastify +pnpm add @nestjs/common reflect-metadata # for taskito/contrib/nest +``` + + + Contrib modules never auto-initialize. Nothing runs until you call the export + and register it with your framework or queue. If a peer dependency is missing, + importing its subpath fails with a standard module-not-found error. + + +## What's in contrib + + + + + diff --git a/docs/content/docs/node/contrib/meta.json b/docs/content/docs/node/contrib/meta.json new file mode 100644 index 00000000..0547d2e9 --- /dev/null +++ b/docs/content/docs/node/contrib/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Contrib", + "pages": ["index", "observability", "web-frameworks"] +} diff --git a/docs/content/docs/node/contrib/observability.mdx b/docs/content/docs/node/contrib/observability.mdx new file mode 100644 index 00000000..b3f2add0 --- /dev/null +++ b/docs/content/docs/node/contrib/observability.mdx @@ -0,0 +1,128 @@ +--- +title: Observability +description: "OpenTelemetry tracing and Prometheus metrics for the Node.js SDK." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +## OpenTelemetry + +Peer dependency: `@opentelemetry/api`. + +```ts +import { otelMiddleware } from "taskito/contrib/otel"; + +queue.use(otelMiddleware()); +``` + +One span is created per execution attempt, named +`taskito.execute.`. The span ends with status `OK` on success and +`ERROR` (with the exception recorded) on throw. A retry is a new attempt, so +it produces a new span. + +### Span attributes + +| Attribute | Value | +|---|---| +| `taskito.job_id` | The job's UUID | +| `taskito.task_name` | Registered task name | + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `tracerName` | `string` | `"taskito"` | Name passed to `trace.getTracer()` | +| `attributePrefix` | `string` | `"taskito"` | Prefix applied to span attribute keys | +| `spanName` | `(ctx) => string` | — | Override the span name per execution | +| `extraAttributes` | `(ctx) => Attributes` | — | Attach additional attributes to every span | +| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip tracing for a task | + +```ts +queue.use( + otelMiddleware({ + tracerName: "my-service", + attributePrefix: "app", + spanName: (ctx) => `job.${ctx.taskName}`, + extraAttributes: (ctx) => ({ "app.env": process.env.NODE_ENV ?? "dev" }), + taskFilter: (name) => !name.startsWith("internal."), + }), +); +``` + + + `otelMiddleware` does not configure an exporter or a tracer provider. Set + those up with the OpenTelemetry SDK before calling `queue.runWorker()`. + + +--- + +## Prometheus + +Peer dependency: `prom-client`. + +```ts +import { + prometheusMiddleware, + PrometheusStatsCollector, +} from "taskito/contrib/prometheus"; +import { register } from "prom-client"; +import express from "express"; + +// 1. Instrument job executions. +queue.use(prometheusMiddleware()); + +// 2. Poll queue/DLQ depth gauges every 10 s. +const collector = new PrometheusStatsCollector(queue); +collector.start(); + +// 3. Expose metrics. +const app = express(); +app.get("/metrics", async (_req, res) => { + res.set("Content-Type", register.contentType); + res.end(await register.metrics()); +}); +``` + +### Metrics + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `taskito_jobs_total` | Counter | `task`, `status` | Finished executions by outcome (`status` = `completed` or `failed`) | +| `taskito_job_duration_seconds` | Histogram | `task` | Execution duration | +| `taskito_active_workers` | Gauge | — | Workers currently running | +| `taskito_retries_total` | Counter | `task` | Retry attempts | +| `taskito_queue_depth` | Gauge | `queue` | Pending jobs per queue (polled by `PrometheusStatsCollector`) | +| `taskito_dlq_size` | Gauge | — | Dead-letter queue size (polled) | + +### `prometheusMiddleware` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `namespace` | `string` | `"taskito"` | Metric name prefix | +| `register` | `Registry` | global `register` | prom-client registry to register metrics into | +| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task | +| `buckets` | `number[]` | prom-client defaults | Histogram bucket boundaries (seconds) | + +### `PrometheusStatsCollector` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `namespace` | `string` | `"taskito"` | Metric name prefix | +| `register` | `Registry` | global `register` | Registry to write gauges into | +| `intervalMs` | `number` | `10000` | Poll interval in milliseconds | + +The internal timer is unref'd so it does not prevent Node from exiting. +Call `.stop()` on graceful shutdown. + +```ts +process.on("SIGTERM", () => { + collector.stop(); + worker.stop(); +}); +``` + + + Each `prometheusMiddleware()` and `PrometheusStatsCollector` instance + registers its metrics into the supplied registry — pass the same `register` + instance to both if you want all metrics in one registry. + diff --git a/docs/content/docs/node/contrib/web-frameworks.mdx b/docs/content/docs/node/contrib/web-frameworks.mdx new file mode 100644 index 00000000..2669b101 --- /dev/null +++ b/docs/content/docs/node/contrib/web-frameworks.mdx @@ -0,0 +1,173 @@ +--- +title: Web Frameworks +description: "REST routers and dashboard mounts for Express, Fastify, and NestJS." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +## Express + +Peer dependency: `express`. + +```ts +import express from "express"; +import { taskitoRouter, taskitoDashboard } from "taskito/contrib/express"; + +const app = express(); + +// REST API — mounted at /tasks +app.use("/tasks", taskitoRouter(queue)); + +// Dashboard SPA — mounted at /admin +app.use("/admin", taskitoDashboard(queue)); + +app.listen(3000); +``` + +### REST routes + +JSON body parsing is mounted internally on the router. + +| Method | Path | Description | +|---|---|---| +| `POST` | `/enqueue` | Enqueue a job. Body: `{ task, args?, options? }`. Returns `{ jobId }` with status 201. | +| `GET` | `/stats` | Overall queue statistics. | +| `GET` | `/stats/queues?queue=` | Per-queue statistics. | +| `GET` | `/jobs/:id` | Fetch a job by ID. | +| `GET` | `/jobs/:id/errors` | Error history for a job. | +| `GET` | `/jobs/:id/result?timeoutMs=` | Wait for a result (long-poll). | +| `POST` | `/jobs/:id/cancel` | Request cancellation. | +| `GET` | `/dead-letters?limit=&offset=` | List dead-lettered jobs. | +| `POST` | `/dead-letters/:id/retry` | Retry a dead-lettered job. | + +### `taskitoRouter` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `includeRoutes` | `string[]` | all | Route names to expose (see below). | +| `excludeRoutes` | `string[]` | none | Route names to suppress. | +| `resultTimeoutMs` | `number` | `5000` | Max wait for `/jobs/:id/result`. | + +Route names: `enqueue`, `stats`, `queue-stats`, `job`, `job-errors`, +`job-result`, `cancel`, `dead-letters`, `retry-dead`. + +### Dashboard + +`taskitoDashboard(queue, { staticDir? })` returns a request handler that serves +the built dashboard SPA and proxies `/api/*` to the queue. Mount it under a +path prefix: + +```ts +app.use("/admin", taskitoDashboard(queue)); +``` + + + The SPA assets must be built first: `pnpm build:dashboard`. The built files + land in `node_modules/taskito/static/dashboard` by default; pass `staticDir` + to point elsewhere. + + +--- + +## Fastify + +Peer dependency: `fastify`. + +```ts +import Fastify from "fastify"; +import { + taskitoFastify, + taskitoDashboardPlugin, +} from "taskito/contrib/fastify"; + +const app = Fastify(); + +// REST API +await app.register(taskitoFastify, { queue, prefix: "/tasks" }); + +// Dashboard SPA +await app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); + +await app.listen({ port: 3000 }); +``` + +`taskitoFastify` is a `FastifyPluginAsync`. It exposes the same nine routes as +the Express router (see the table above), mounted under `prefix`. + +The same `includeRoutes`, `excludeRoutes`, and `resultTimeoutMs` options are +accepted and passed in the register options object alongside `queue`: + +```ts +await app.register(taskitoFastify, { + queue, + prefix: "/tasks", + excludeRoutes: ["enqueue"], // read-only API + resultTimeoutMs: 10_000, +}); +``` + +`taskitoDashboardPlugin` accepts `queue`, `prefix`, and an optional +`staticDir`: + +```ts +await app.register(taskitoDashboardPlugin, { + queue, + prefix: "/admin", + staticDir: "/app/dashboard-assets", +}); +``` + +--- + +## NestJS + +Peer dependencies: `@nestjs/common` + `reflect-metadata`. + +```ts +import { Module } from "@nestjs/common"; +import { TaskitoModule } from "taskito/contrib/nest"; + +@Module({ + imports: [TaskitoModule.forRoot(queue)], +}) +export class AppModule {} +``` + +`TaskitoModule.forRoot(queue)` returns a `DynamicModule` that exports +`TaskitoService` to any module that imports it. + +### Injecting `TaskitoService` + +```ts +import { Injectable } from "@nestjs/common"; +import { TaskitoService } from "taskito/contrib/nest"; + +@Injectable() +export class ReportService { + constructor(private readonly tasks: TaskitoService) {} + + scheduleReport(reportId: string): string { + return this.tasks.enqueue("generateReport", [reportId], { + maxRetries: 2, + timeoutMs: 60_000, + }); + } +} +``` + +### `TaskitoService` methods + +| Method | Description | +|---|---| +| `enqueue(task, args?, options?)` | Enqueue a job, returns the job ID. | +| `result(id, options?)` | Wait for a result. | +| `getJob(id)` | Fetch job details by ID. | +| `stats()` | Overall queue statistics. | +| `requestCancel(id)` | Request cancellation of a running job. | +| `deadLetters(limit?, offset?)` | List dead-lettered jobs. | +| `tasks.queue` | The raw `Queue` instance for the full API surface. | + + + `TaskitoService` wraps the most common operations. For anything not listed — + workflows, distributed locks, metrics, etc. — access `tasks.queue` directly. + diff --git a/docs/content/docs/node/index.mdx b/docs/content/docs/node/index.mdx index 24bb8227..6114b85f 100644 --- a/docs/content/docs/node/index.mdx +++ b/docs/content/docs/node/index.mdx @@ -64,6 +64,11 @@ new Queue({ backend: "redis", dsn: "redis://localhost" }); // Redis href="/node/workflows/fan-out" description="Split a step's result into parallel children, collect results into a downstream step." /> + For the full API surface — serializers, cancellation, events, middleware, diff --git a/docs/content/docs/node/meta.json b/docs/content/docs/node/meta.json index ecc9dcb8..c503e54c 100644 --- a/docs/content/docs/node/meta.json +++ b/docs/content/docs/node/meta.json @@ -1,5 +1,5 @@ { "title": "Node SDK", "root": true, - "pages": ["index", "workflows"] + "pages": ["index", "workflows", "contrib"] } diff --git a/sdks/node/README.md b/sdks/node/README.md index 74cff6c4..6fcbbe97 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -324,6 +324,61 @@ queue.runWorker({ Other tunables: `bindAddr`, `advertiseAddr` (NAT), `affinityWeight`, `localBuffer`, `stealBatch`, `stealThreshold`, `virtualNodes`, `stealRateLimit`. +## Contrib integrations + +Optional integrations live under the `taskito/contrib/*` subpaths. Each requires its +framework as a peer dependency you install yourself; none are pulled in by the main +package or exported from the `taskito` barrel. + +### Observability + +```ts +import { otelMiddleware } from "taskito/contrib/otel"; // peer: @opentelemetry/api +import { prometheusMiddleware, PrometheusStatsCollector } from "taskito/contrib/prometheus"; // peer: prom-client + +queue.use(otelMiddleware()); // one span per execution: taskito.execute. +queue.use(prometheusMiddleware()); // taskito_jobs_total, _job_duration_seconds, _active_workers, _retries_total + +const collector = new PrometheusStatsCollector(queue); // polls queue depth + DLQ size +collector.start(); +// expose `await register.metrics()` from your HTTP server +``` + +OTel options: `tracerName`, `attributePrefix`, `spanName(ctx)`, `extraAttributes(ctx)`, +`taskFilter(name)`. Prometheus options: `namespace`, `register`, `taskFilter`, `buckets` +(metrics for one namespace are built once per registry, so multiple middlewares are safe). + +### Web frameworks + +`taskitoRouter` / the Fastify plugin expose a JSON API (enqueue + inspection); a separate +helper mounts the dashboard (SPA + `/api/*`) into your app. + +```ts +import { taskitoRouter, taskitoDashboard } from "taskito/contrib/express"; // peer: express +app.use("/tasks", taskitoRouter(queue)); // POST /enqueue, GET /stats, /jobs/:id, ... +app.use("/admin", taskitoDashboard(queue)); // dashboard SPA + /api/* + +import { taskitoFastify, taskitoDashboardPlugin } from "taskito/contrib/fastify"; // peer: fastify +app.register(taskitoFastify, { queue, prefix: "/tasks" }); +app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); +``` + +Both routers take `includeRoutes` / `excludeRoutes` (route names: `enqueue`, `stats`, +`queue-stats`, `job`, `job-errors`, `job-result`, `cancel`, `dead-letters`, `retry-dead`) +and `resultTimeoutMs`. + +NestJS exposes an injectable service: + +```ts +import { TaskitoModule, TaskitoService } from "taskito/contrib/nest"; // peers: @nestjs/common, reflect-metadata + +@Module({ imports: [TaskitoModule.forRoot(queue)] }) +export class AppModule {} + +// constructor(private readonly tasks: TaskitoService) {} +// this.tasks.enqueue("add", [2, 3]); this.tasks.queue gives the full API +``` + ## Development ```bash @@ -341,6 +396,5 @@ compiled in via `--features postgres,redis`. ## Not yet covered Advanced workflow features (gates, sub-workflows, saga compensation — fan-out is -covered above), resources/proxies/interception, contrib integrations, prebuilt -platform binaries + npm publish (host-only build for now), and Python⇄Node -cross-language interop. +covered above), resources/proxies/interception, prebuilt platform binaries + npm +publish (host-only build for now), and Python⇄Node cross-language interop. From 276743ce3f67aef6b3efba89bb227644f8810eac Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 09/25] feat(node): Sentry error-reporting middleware --- sdks/node/package.json | 10 ++ sdks/node/pnpm-lock.yaml | 196 ++++++++++++++++++++++++++ sdks/node/src/contrib/sentry.ts | 95 +++++++++++++ sdks/node/test/contrib-sentry.test.ts | 104 ++++++++++++++ sdks/node/tsup.config.ts | 1 + 5 files changed, 406 insertions(+) create mode 100644 sdks/node/src/contrib/sentry.ts create mode 100644 sdks/node/test/contrib-sentry.test.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index 6b6446e3..002d1abc 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -44,6 +44,11 @@ "types": "./dist/contrib/nest.d.ts", "import": "./dist/contrib/nest.js", "require": "./dist/contrib/nest.cjs" + }, + "./contrib/sentry": { + "types": "./dist/contrib/sentry.d.ts", + "import": "./dist/contrib/sentry.js", + "require": "./dist/contrib/sentry.cjs" } }, "files": [ @@ -89,6 +94,7 @@ "@nestjs/core": "^11.1.27", "@nestjs/testing": "^11.1.27", "@opentelemetry/api": "^1.9.1", + "@sentry/node": "^10.58.0", "@types/express": "^5.0.6", "@types/node": "^25.9.2", "express": "^5.2.1", @@ -107,6 +113,7 @@ "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@opentelemetry/api": "^1.9.0", + "@sentry/node": "^8.0.0 || ^9.0.0 || ^10.0.0", "express": "^4.18.0 || ^5.0.0", "fastify": "^4.0.0 || ^5.0.0", "prom-client": "^15.0.0", @@ -119,6 +126,9 @@ "@opentelemetry/api": { "optional": true }, + "@sentry/node": { + "optional": true + }, "express": { "optional": true }, diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index f5476b5d..372b15b6 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 + '@sentry/node': + specifier: ^10.58.0 + version: 10.58.0 '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -385,10 +388,42 @@ packages: '@nestjs/platform-express': optional: true + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -631,6 +666,51 @@ packages: cpu: [x64] os: [win32] + '@sentry/core@10.58.0': + resolution: {integrity: sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==} + engines: {node: '>=18'} + + '@sentry/node-core@10.58.0': + resolution: {integrity: sha512-7dTbYuoaSwSmF2GWDl7KK+sXQL8iqaZeZ2I/aFm+SvPZLckZF3OGFb2VsluWsSXQLnxtxPX9QP93viyK+VZsuA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@opentelemetry/semantic-conventions': + optional: true + + '@sentry/node@10.58.0': + resolution: {integrity: sha512-KICgacBS+I/eWzFlAembutSwFwy0WVSrGp8UMV9n1XZqqu4EBTlALRsbLNlDSv61UgH85L9L3vk91tgq6nJXAA==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.58.0': + resolution: {integrity: sha512-qKOGVmt02wDaq7E70VekG8Z9XM641trJPoTHSeVUfGaXVcmGc46ZldTNtfWbxJq/8f/fge2pap60gn066ido2Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + + '@sentry/server-utils@10.58.0': + resolution: {integrity: sha512-PywIl2jvl+tO5R4j+n72Lcf3ItanHcaMN/oL1U9ZHE8icaT2zpo2W4uOaslpQeQvqPC24HGZ3BW2etzsCFQbag==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -719,6 +799,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -786,6 +871,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -993,6 +1081,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-in-the-middle@3.1.0: + resolution: {integrity: sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1139,6 +1231,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1280,6 +1375,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1806,8 +1905,41 @@ snapshots: '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': {} + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.1.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.133.0': {} '@pinojs/redact@0.4.0': {} @@ -1938,6 +2070,48 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true + '@sentry/core@10.58.0': {} + + '@sentry/node-core@10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)': + dependencies: + '@sentry/core': 10.58.0 + '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) + import-in-the-middle: 3.1.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@sentry/node@10.58.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@sentry/core': 10.58.0 + '@sentry/node-core': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) + '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) + '@sentry/server-utils': 10.58.0 + import-in-the-middle: 3.1.0 + transitivePeerDependencies: + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@sentry/core': 10.58.0 + + '@sentry/server-utils@10.58.0': + dependencies: + '@sentry/core': 10.58.0 + '@standard-schema/spec@1.1.0': {} '@tokenizer/inflate@0.4.1': @@ -2052,6 +2226,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn@8.17.0: {} ajv-formats@3.0.1(ajv@8.20.0): @@ -2117,6 +2295,8 @@ snapshots: dependencies: readdirp: 4.1.2 + cjs-module-lexer@2.2.0: {} + commander@15.0.0: {} commander@4.1.1: {} @@ -2369,6 +2549,13 @@ snapshots: ieee754@1.2.1: {} + import-in-the-middle@3.1.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -2473,6 +2660,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + module-details-from-path@1.0.4: {} + ms@2.1.3: {} mz@2.7.0: @@ -2590,6 +2779,13 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@5.0.0: {} ret@0.5.0: {} diff --git a/sdks/node/src/contrib/sentry.ts b/sdks/node/src/contrib/sentry.ts new file mode 100644 index 00000000..54f578a3 --- /dev/null +++ b/sdks/node/src/contrib/sentry.ts @@ -0,0 +1,95 @@ +// Sentry error reporting for Taskito. Optional integration — import from +// `taskito/contrib/sentry`; requires `@sentry/node` as a peer (and `Sentry.init(...)` +// called by your app first). +// +// Register with `queue.use(sentryMiddleware())`. The real exception (with its stack) is +// captured from `onError` and reported when the job dead-letters — one event per dead +// job. Set `captureRetries` to also report each intermediate failure as a warning. + +import type { SeverityLevel } from "@sentry/node"; +import * as Sentry from "@sentry/node"; +import type { OutcomeEvent } from "../events"; +import type { Middleware } from "../middleware"; + +/** Options for {@link sentryMiddleware}. */ +export interface SentryMiddlewareOptions { + /** Prefix for Sentry tag keys (default `"taskito"`). */ + tagPrefix?: string; + /** Also capture each retried failure as a `"warning"` event (default false). */ + captureRetries?: boolean; + /** Severity level for the terminal dead-letter capture (default `"error"`). */ + level?: SeverityLevel; + /** Extra tags merged onto the captured event. */ + extraTags?: (event: OutcomeEvent) => Record; + /** Only report tasks for which this returns true (default: all). */ + taskFilter?: (taskName: string) => boolean; +} + +/** + * Build {@link Middleware} that reports failing tasks to Sentry. The exception captured + * in `onError` is held per job and sent when the job dead-letters, so each dead job + * yields a single event carrying the original stack trace plus job/queue tags. + */ +export function sentryMiddleware(options: SentryMiddlewareOptions = {}): Middleware { + const prefix = options.tagPrefix ?? "taskito"; + const deadLevel = options.level ?? "error"; + const captureRetries = options.captureRetries ?? false; + const tracked = (taskName: string): boolean => options.taskFilter?.(taskName) ?? true; + + // The latest real exception per in-flight job, captured in onError (it carries the + // stack; the outcome events only carry a message string). + const lastError = new Map(); + + const tagsFor = (event: OutcomeEvent): Record => { + const tags: Record = { + [`${prefix}.task_name`]: event.taskName, + [`${prefix}.job_id`]: event.jobId, + }; + if (event.queue) { + tags[`${prefix}.queue`] = event.queue; + } + if (event.retryCount !== undefined) { + tags[`${prefix}.retry_count`] = String(event.retryCount); + } + if (event.timedOut) { + tags[`${prefix}.timed_out`] = "true"; + } + return { ...tags, ...options.extraTags?.(event) }; + }; + + const capture = (error: unknown, event: OutcomeEvent, level: SeverityLevel): void => { + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setTags(tagsFor(event)); + Sentry.captureException(error); + }); + }; + + const errorFor = (event: OutcomeEvent, fallback: string): unknown => + lastError.get(event.jobId) ?? new Error(event.error ?? fallback); + + return { + onError(ctx, error) { + if (tracked(ctx.taskName)) { + lastError.set(ctx.jobId, error); + } + }, + onRetry(event) { + if (captureRetries && tracked(event.taskName)) { + capture(errorFor(event, `${event.taskName} failed`), event, "warning"); + } + }, + onDeadLetter(event) { + if (tracked(event.taskName)) { + capture(errorFor(event, `${event.taskName} dead-lettered`), event, deadLevel); + } + lastError.delete(event.jobId); + }, + onCompleted(event) { + lastError.delete(event.jobId); + }, + onCancel(event) { + lastError.delete(event.jobId); + }, + }; +} diff --git a/sdks/node/test/contrib-sentry.test.ts b/sdks/node/test/contrib-sentry.test.ts new file mode 100644 index 00000000..9e3f4011 --- /dev/null +++ b/sdks/node/test/contrib-sentry.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ErrorEvent } from "@sentry/node"; +import * as Sentry from "@sentry/node"; +import { afterEach, expect, it } from "vitest"; +import { sentryMiddleware } from "../src/contrib/sentry"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(async () => { + worker?.stop(); + worker = undefined; + await Sentry.close(); +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-sentry-")), "q.db") }); +} + +/** Init Sentry with a beforeSend hook that records events instead of sending them. */ +function captureEvents(): ErrorEvent[] { + const events: ErrorEvent[] = []; + Sentry.init({ + dsn: "https://examplePublicKey@o0.ingest.sentry.io/0", + beforeSend(event) { + events.push(event); + return null; // drop — never hits the network + }, + }); + return events; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("captures one event with the stack and tags when a job dead-letters", async () => { + const events = captureEvents(); + const queue = newQueue(); + queue.use(sentryMiddleware()); + queue.task( + "boom", + () => { + throw new Error("kaboom"); + }, + { maxRetries: 0 }, + ); + + queue.enqueue("boom", []); + worker = queue.runWorker(); + + expect(await waitFor(() => events.length > 0)).toBe(true); + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.level).toBe("error"); + expect(event?.tags?.["taskito.task_name"]).toBe("boom"); + expect(typeof event?.tags?.["taskito.job_id"]).toBe("string"); + expect(event?.exception?.values?.[0]?.value).toBe("kaboom"); +}); + +it("does not capture successful jobs", async () => { + const events = captureEvents(); + const queue = newQueue(); + queue.use(sentryMiddleware()); + queue.task("ok", () => 1); + + queue.enqueue("ok", []); + worker = queue.runWorker(); + + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events).toHaveLength(0); +}); + +it("reports retries as warnings when captureRetries is set", async () => { + const events = captureEvents(); + const queue = newQueue(); + queue.use(sentryMiddleware({ captureRetries: true })); + queue.task( + "flaky", + () => { + throw new Error("nope"); + }, + { maxRetries: 1, retryBackoff: { baseMs: 1, maxMs: 5 } }, + ); + + queue.enqueue("flaky", []); + worker = queue.runWorker(); + + // One retry (warning) + one dead-letter (error). + expect(await waitFor(() => events.length >= 2)).toBe(true); + const levels = events.map((e) => e.level); + expect(levels).toContain("warning"); + expect(levels).toContain("error"); +}); diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index ca3298fc..0ddf7c72 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "contrib/express": "src/contrib/express.ts", "contrib/fastify": "src/contrib/fastify.ts", "contrib/nest": "src/contrib/nest.ts", + "contrib/sentry": "src/contrib/sentry.ts", }, format: ["esm", "cjs"], dts: true, From 8b742d2d94debe6b2427acc302e4e1c9e877b854 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 10/25] docs(node): document Sentry contrib middleware --- NODE_SDK_TODO.md | 34 +++++++------- docs/content/docs/node/contrib/index.mdx | 3 +- .../docs/node/contrib/observability.mdx | 44 ++++++++++++++++++- sdks/node/README.md | 10 +++++ 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/NODE_SDK_TODO.md b/NODE_SDK_TODO.md index a5d0fbeb..2dc7c970 100644 --- a/NODE_SDK_TODO.md +++ b/NODE_SDK_TODO.md @@ -34,7 +34,7 @@ SDK, zero Python dependency, over the shared Rust core. | **Periodic + circuit-breaker** | `a4cc9a4` | `queue.registerPeriodic`; per-task `circuitBreaker` config | | **Workflows: fan-out / fan-in** | (this branch) | TS `WorkflowTracker` brain (`src/workflows/tracker.ts`) driven by the outcome callback; `.fanOut()` / `.fanIn()` builder steps; napi primitives `expandFanOut`/`checkFanOutCompletion`/`createDeferredJob`/`finalizeRunIfTerminal`/`getWorkflowRunPlan`/`workflowNodeForJob`/`cascadeSkipPending`; deferred-node submit. Storage-reconstructable (no submit-time registration) | | **Dashboard DAG completeness** | (this branch) | `JsWorkflowNode` carries `fanOutCount` + `compensation_*`; `/dag` handler enriches the raw graph with per-node `deps[]` + live `status` + job-id `id` so the SPA visualizer renders edges/colours/links | -| **Contrib integrations** | (this branch) | `src/contrib/{otel,prometheus,express,fastify,nest}.ts` — each an optional subpath export (`taskito/contrib/*`) with an optional peer dep. OTel + Prometheus = `Middleware` over the events layer; Express/Fastify = REST router (shared `rest.ts` table) + dashboard mount (via extracted `createDashboardHandler`); Nest = `TaskitoModule.forRoot` + injectable `TaskitoService`. 14 new vitest tests | +| **Contrib integrations** | (this branch) | `src/contrib/{otel,prometheus,express,fastify,nest,sentry}.ts` — each an optional subpath export (`taskito/contrib/*`) with an optional peer dep. OTel + Prometheus + Sentry = `Middleware` over the events layer; Express/Fastify = REST router (shared `rest.ts` table) + dashboard mount (via extracted `createDashboardHandler`); Nest = `TaskitoModule.forRoot` + injectable `TaskitoService`. 17 new vitest tests | **Verify everything green:** ```bash @@ -69,20 +69,18 @@ context managers) — do **not** port 1:1. Design Node-native equivalents: **Effort:** large (design-heavy). **Recommendation:** scope a minimal resource DI first (worker-scoped singletons + task-scoped factories); defer proxies entirely. -### 2. Contrib integrations — DONE (Sentry remains) +### 2. Contrib integrations — DONE -Shipped: OpenTelemetry + Prometheus middleware, Express + Fastify (REST router + -dashboard mount), and a NestJS module (`src/contrib/{otel,prometheus,express,fastify,nest}.ts`). -Each is an optional `taskito/contrib/*` subpath export with an optional peer dependency, -its own tsup entry, and tests. The REST logic is shared via a framework-neutral -`src/contrib/rest.ts` route table; the dashboard mount reuses -`createDashboardHandler` (extracted from `dashboard/server.ts`). NestJS uses an -explicit `@Inject(TASKITO_QUEUE)` token to avoid relying on esbuild decorator-metadata -emission (`experimentalDecorators` on; biome `unsafeParameterDecoratorsEnabled`). - -**Still open:** a **Sentry** integration (Python ships one) — small, a `Middleware` -that captures exceptions on `onError`/`onDeadLetter` via `@sentry/node`. One more -commit when wanted. +Shipped: OpenTelemetry + Prometheus + Sentry middleware, Express + Fastify (REST router + +dashboard mount), and a NestJS module +(`src/contrib/{otel,prometheus,sentry,express,fastify,nest}.ts`). Each is an optional +`taskito/contrib/*` subpath export with an optional peer dependency, its own tsup entry, +and tests. The REST logic is shared via a framework-neutral `src/contrib/rest.ts` route +table; the dashboard mount reuses `createDashboardHandler` (extracted from +`dashboard/server.ts`). NestJS uses an explicit `@Inject(TASKITO_QUEUE)` token to avoid +relying on esbuild decorator-metadata emission (`experimentalDecorators` on; biome +`unsafeParameterDecoratorsEnabled`). Sentry captures the real exception from `onError` +and reports it on dead-letter (one event/dead job), optional per-retry warnings. ### 3. Advanced workflow features — LARGE (fan-out DONE; gates / sub-workflows / saga remain) @@ -240,7 +238,7 @@ pnpm run build:native # napi build, all features pnpm run build:ts # tsup dual ESM/CJS + .d.ts pnpm typecheck # tsc --noEmit (includes test/) pnpm lint # biome -pnpm test # vitest (66 tests) +pnpm test # vitest (69 tests) pnpm run build:dashboard # build the React SPA into static/dashboard ``` @@ -262,9 +260,13 @@ sdks/node/src/ webhooks/{store,deliverer,manager,types,index}.ts serializers/{serializer,json,msgpack,index}.ts dashboard/{server,routes,handlers,contract,static,metrics,api,index}.ts - contrib/{otel,prometheus,express,fastify,nest,rest}.ts # optional subpath exports + contrib/{otel,prometheus,sentry,express,fastify,nest,rest}.ts # optional subpath exports cli/{index,connect,output,commands/*}.ts +<<<<<<< HEAD sdks/node/test/*.test.ts # grouped by feature area +======= +sdks/node/test/*.test.ts # 22 files, 69 tests +>>>>>>> 9394bcb (docs(node): document Sentry contrib middleware) ``` Memory: see `.claude/memory/session-history.md` (Node SDK section) for the running diff --git a/docs/content/docs/node/contrib/index.mdx b/docs/content/docs/node/contrib/index.mdx index 59357bbc..e9924a2f 100644 --- a/docs/content/docs/node/contrib/index.mdx +++ b/docs/content/docs/node/contrib/index.mdx @@ -14,6 +14,7 @@ subpath import and requires you to install its peer dependency separately. # Only install what you use. pnpm add @opentelemetry/api # for taskito/contrib/otel pnpm add prom-client # for taskito/contrib/prometheus +pnpm add @sentry/node # for taskito/contrib/sentry pnpm add express # for taskito/contrib/express pnpm add fastify # for taskito/contrib/fastify pnpm add @nestjs/common reflect-metadata # for taskito/contrib/nest @@ -31,7 +32,7 @@ pnpm add @nestjs/common reflect-metadata # for taskito/contrib/nest { registers its metrics into the supplied registry — pass the same `register` instance to both if you want all metrics in one registry. + +--- + +## Sentry + +Peer dependency: `@sentry/node`. Call `Sentry.init(...)` in your app before +registering the middleware. + +```ts +import * as Sentry from "@sentry/node"; +import { sentryMiddleware } from "taskito/contrib/sentry"; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +queue.use(sentryMiddleware()); +``` + +The real exception (with its stack trace) is captured in `onError` and held +per job, then reported to Sentry when the job **dead-letters** — so each dead +job produces a single event carrying the original stack plus task/job/queue +tags. Successful jobs and jobs that recover on retry are never reported. + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `tagPrefix` | `string` | `"taskito"` | Prefix for Sentry tag keys | +| `captureRetries` | `boolean` | `false` | Also report each retried failure as a `"warning"` event | +| `level` | `SeverityLevel` | `"error"` | Severity for the terminal dead-letter event | +| `extraTags` | `(event) => Record` | — | Extra tags merged onto the event | +| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task | + +Tags set on every event: `taskito.task_name`, `taskito.job_id`, +`taskito.queue`, `taskito.retry_count`, and `taskito.timed_out` (when the job +timed out). + + + `sentryMiddleware` never calls `Sentry.init` — configure the DSN, environment, + and sampling with the Sentry SDK yourself. If a task dead-letters from a + timeout without throwing, a synthetic error is captured so the failure is + still recorded. + diff --git a/sdks/node/README.md b/sdks/node/README.md index 6fcbbe97..82ee0ce2 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -348,6 +348,16 @@ OTel options: `tracerName`, `attributePrefix`, `spanName(ctx)`, `extraAttributes `taskFilter(name)`. Prometheus options: `namespace`, `register`, `taskFilter`, `buckets` (metrics for one namespace are built once per registry, so multiple middlewares are safe). +```ts +import { sentryMiddleware } from "taskito/contrib/sentry"; // peer: @sentry/node +queue.use(sentryMiddleware()); // call Sentry.init(...) yourself first +``` + +The exception (with its stack) is captured from `onError` and reported when the job +dead-letters — one event per dead job, tagged with task/job/queue. Set `captureRetries` +to also report each intermediate failure as a warning. Other options: `tagPrefix`, +`level`, `extraTags(event)`, `taskFilter`. + ### Web frameworks `taskitoRouter` / the Fastify plugin expose a JSON API (enqueue + inspection); a separate From 1a3e06bae68c090239f7a072bd5eeaedcad9cf73 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 11/25] feat(node): workflow step conditions (on_success/on_failure/always) --- crates/taskito-node/src/queue/workflows.rs | 21 ++++ sdks/node/src/workflows/builder.ts | 6 + sdks/node/src/workflows/index.ts | 1 + sdks/node/src/workflows/tracker.ts | 49 +++++++- sdks/node/src/workflows/types.ts | 12 ++ sdks/node/test/workflows-conditions.test.ts | 122 ++++++++++++++++++++ 6 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 sdks/node/test/workflows-conditions.test.ts diff --git a/crates/taskito-node/src/queue/workflows.rs b/crates/taskito-node/src/queue/workflows.rs index 93556469..fb4f4877 100644 --- a/crates/taskito-node/src/queue/workflows.rs +++ b/crates/taskito-node/src/queue/workflows.rs @@ -555,6 +555,27 @@ impl JsQueue { })) } + /// Skip a single node whose condition evaluated false: cancel any backing + /// job (best-effort) and mark it `Skipped`. The tracker propagates the skip + /// to the node's successors so a not-taken branch settles cleanly. + #[napi] + pub fn skip_workflow_node(&self, run_id: String, node_name: String) -> Result<()> { + let wf = self.workflow_store()?; + if let Some(node) = wf + .get_workflow_node(&run_id, &node_name) + .map_err(to_napi_err)? + { + if let Some(job_id) = &node.job_id { + if let Err(e) = self.storage.cancel_job(job_id) { + log::warn!("[taskito-node] cancel_job({job_id}) during skip: {e}"); + } + } + } + wf.update_workflow_node_status(&run_id, &node_name, WorkflowNodeStatus::Skipped) + .map_err(to_napi_err)?; + Ok(()) + } + /// Fail-fast cascade: skip every still-pending node of a run and cancel its /// job. Called by the tracker when a managed run's node fails. #[napi] diff --git a/sdks/node/src/workflows/builder.ts b/sdks/node/src/workflows/builder.ts index 20bf578f..c531f88d 100644 --- a/sdks/node/src/workflows/builder.ts +++ b/sdks/node/src/workflows/builder.ts @@ -42,8 +42,14 @@ export class WorkflowBuilder { max_retries: options.maxRetries, timeout_ms: options.timeoutMs, priority: options.priority, + condition: options.condition, }; this.stepArgs[name] = options.args ?? []; + // A conditioned step is enqueued by the tracker once its predecessors + // settle (so it can evaluate the condition), not statically at submit. + if (options.condition) { + this.deferredSeeds.add(name); + } return this; } diff --git a/sdks/node/src/workflows/index.ts b/sdks/node/src/workflows/index.ts index 546b1507..ccab290a 100644 --- a/sdks/node/src/workflows/index.ts +++ b/sdks/node/src/workflows/index.ts @@ -5,6 +5,7 @@ export type { FanInStepOptions, FanOutStepOptions, WorkflowAdvance, + WorkflowCondition, WorkflowHandle, WorkflowNode, WorkflowRun, diff --git a/sdks/node/src/workflows/tracker.ts b/sdks/node/src/workflows/tracker.ts index 41ca5d81..2b5f1629 100644 --- a/sdks/node/src/workflows/tracker.ts +++ b/sdks/node/src/workflows/tracker.ts @@ -22,6 +22,9 @@ const TERMINAL_NODE_STATUS = new Set([ /** A fan-out child node name (`parent[3]`) → its parent (`parent`), else null. */ const FAN_OUT_CHILD = /^(.+)\[\d+\]$/; +/** Predecessor outcomes that satisfy an `on_success` condition. */ +const SUCCEEDED_STATUS = new Set(["completed", "cache_hit"]); + /** snake_case step metadata as stored in the workflow definition. */ interface StepMeta { task_name: string; @@ -32,6 +35,8 @@ interface StepMeta { args_template?: string; fan_out?: string; fan_in?: string; + /** `"on_success"` (default) | `"on_failure"` | `"always"` — gates node entry. */ + condition?: string; } /** The reconstructed structure + config of a workflow run. */ @@ -110,10 +115,11 @@ export class WorkflowTracker { const parent = fanOutParentOf(nodeName); if (parent !== null && isFanOut(plan, parent)) { this.onFanOutChild(runId, parent, plan); - } else if (succeeded) { - this.evaluateSuccessors(runId, nodeName, plan); } else { - this.native.cascadeSkipPending(runId); // fail-fast + // Success and failure both flow through successor evaluation: each + // successor's condition decides whether it runs or is skipped, so an + // `on_failure` handler fires and an `on_success` step skips after a fault. + this.evaluateSuccessors(runId, nodeName, plan); } this.evictIfTerminal(runId, this.native.finalizeRunIfTerminal(runId)); @@ -135,7 +141,9 @@ export class WorkflowTracker { if (completion.succeeded) { this.onFanOutParentDone(runId, parent, completion.childJobIds, plan); } else { - this.native.cascadeSkipPending(runId); // a child failed → fail-fast + // A child failed → the parent is now `failed`; evaluating its successors + // skips the (on_success) fan-in and anything downstream. + this.evaluateSuccessors(runId, parent, plan); } } @@ -217,12 +225,18 @@ export class WorkflowTracker { ); } - /** Enqueue/expand any deferred successor whose predecessors are now all terminal. */ + /** Enqueue/expand/skip any deferred successor whose predecessors are now all terminal. */ private evaluateSuccessors(runId: string, nodeName: string, plan: RunPlan): void { for (const succ of plan.successors.get(nodeName) ?? []) { if (!plan.deferred.has(succ) || !this.allPredecessorsTerminal(runId, succ, plan)) { continue; } + // Evaluate the condition first: a not-taken branch is skipped (and the + // skip propagates) before we consider what kind of node it is. + if (!this.shouldExecute(runId, succ, plan)) { + this.skipNode(runId, succ, plan); + continue; + } const meta = plan.meta.get(succ); if (meta?.fan_in) { continue; // fan-in jobs are created from the fan-out completion path @@ -235,6 +249,27 @@ export class WorkflowTracker { } } + /** Whether a node's condition is satisfied by its predecessors' outcomes. */ + private shouldExecute(runId: string, node: string, plan: RunPlan): boolean { + const condition = plan.meta.get(node)?.condition ?? "on_success"; + const preds = plan.predecessors.get(node) ?? []; + if (preds.length === 0 || condition === "always") { + return true; + } + const status = this.nodeStatuses(runId); + const predStatuses = preds.map((p) => status.get(p) ?? ""); + if (condition === "on_failure") { + return predStatuses.some((s) => s === "failed"); + } + return predStatuses.every((s) => SUCCEEDED_STATUS.has(s)); // on_success + } + + /** Mark a not-taken node skipped, then propagate the skip to its successors. */ + private skipNode(runId: string, node: string, plan: RunPlan): void { + this.native.skipWorkflowNode(runId, node); + this.evaluateSuccessors(runId, node, plan); + } + /** Enqueue a plain deferred node using its persisted args. */ private createDeferredJob(runId: string, node: string, plan: RunPlan): void { const meta = plan.meta.get(node); @@ -316,7 +351,9 @@ export class WorkflowTracker { Object.entries(JSON.parse(raw.stepMetadata) as Record), ); const successors = successorsOf(edges); - const seeds = [...meta].filter(([, m]) => m.fan_out || m.fan_in).map(([name]) => name); + const seeds = [...meta] + .filter(([, m]) => m.fan_out || m.fan_in || m.condition) + .map(([name]) => name); const plan: RunPlan = { successors, predecessors: predecessorsOf(edges), diff --git a/sdks/node/src/workflows/types.ts b/sdks/node/src/workflows/types.ts index cec02768..714aa243 100644 --- a/sdks/node/src/workflows/types.ts +++ b/sdks/node/src/workflows/types.ts @@ -20,6 +20,14 @@ export type WorkflowRunState = | "compensated" | "compensation_failed"; +/** + * When a step runs, based on its predecessors' outcomes: + * - `on_success` (default) — every predecessor completed + * - `on_failure` — at least one predecessor failed (an error handler) + * - `always` — regardless of predecessor outcomes + */ +export type WorkflowCondition = "on_success" | "on_failure" | "always"; + /** Per-step options when building a workflow. */ export interface WorkflowStepOptions { /** Predecessor step name(s) this step runs after. */ @@ -31,6 +39,8 @@ export interface WorkflowStepOptions { maxRetries?: number; timeoutMs?: number; priority?: number; + /** Gate this step on its predecessors' outcomes (default `on_success`). */ + condition?: WorkflowCondition; } /** Common per-step task config shared by every step kind. */ @@ -80,6 +90,8 @@ export interface StepMetadataJson { fan_out?: string; /** JSON `{from}` marking a fan-in node (the fan-out it collects). */ fan_in?: string; + /** Entry condition: `on_success` | `on_failure` | `always`. */ + condition?: string; } /** A built workflow definition, ready to submit. */ diff --git a/sdks/node/test/workflows-conditions.test.ts b/sdks/node/test/workflows-conditions.test.ts new file mode 100644 index 00000000..1c095456 --- /dev/null +++ b/sdks/node/test/workflows-conditions.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-cond-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +it("runs an on_failure handler and skips on_success siblings when a step fails", async () => { + const queue = freshQueue(); + const ran: string[] = []; + + queue.task("risky", () => { + throw new Error("boom"); + }); + queue.task("recover", () => ran.push("recover")); + queue.task("celebrate", () => ran.push("celebrate")); + + const handle = queue.workflows + .define("cond-handler") + .step("risky", "risky", { maxRetries: 0 }) + .step("recover", "recover", { after: "risky", condition: "on_failure" }) + .step("celebrate", "celebrate", { after: "risky", condition: "on_success" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); // a node failed, even though it was handled + const nodes = nodesByName(handle); + expect(nodes.risky?.status).toBe("failed"); + expect(nodes.recover?.status).toBe("completed"); + expect(nodes.celebrate?.status).toBe("skipped"); + expect(ran).toEqual(["recover"]); +}); + +it("runs an `always` step regardless of upstream failure", async () => { + const queue = freshQueue(); + let cleaned = false; + + queue.task("risky", () => { + throw new Error("boom"); + }); + queue.task("cleanup", () => { + cleaned = true; + }); + + const handle = queue.workflows + .define("cond-always") + .step("risky", "risky", { maxRetries: 0 }) + .step("cleanup", "cleanup", { after: "risky", condition: "always" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + await handle.wait({ timeoutMs: 10_000 }); + + expect(cleaned).toBe(true); + expect(nodesByName(handle).cleanup?.status).toBe("completed"); +}); + +it("propagates a skip to downstream steps", async () => { + const queue = freshQueue(); + + queue.task("risky", () => { + throw new Error("boom"); + }); + queue.task("noop", () => 1); + + const handle = queue.workflows + .define("cond-propagate") + .step("risky", "risky", { maxRetries: 0 }) + .step("mid", "noop", { after: "risky", condition: "on_success" }) + .step("tail", "noop", { after: "mid" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); + const nodes = nodesByName(handle); + expect(nodes.mid?.status).toBe("skipped"); + expect(nodes.tail?.status).toBe("skipped"); // skip propagated through `mid` +}); + +it("runs an on_success step when its predecessor completes", async () => { + const queue = freshQueue(); + let value = 0; + + queue.task("produce", () => 21); + queue.task("consume", () => { + value = 42; + }); + + const handle = queue.workflows + .define("cond-happy") + .step("produce", "produce") + .step("consume", "consume", { after: "produce", condition: "on_success" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("completed"); + expect(value).toBe(42); + expect(nodesByName(handle).consume?.status).toBe("completed"); +}); From a22fc9e060920edc85d43f133aa05430867d4d6f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 12/25] feat(node): workflow approval gates --- crates/taskito-node/src/queue/workflows.rs | 38 ++++++ .../src/py_queue/workflow_ops/test_helpers.rs | 10 +- crates/taskito-python/src/py_workflow/mod.rs | 1 + crates/taskito-workflows/src/definition.rs | 25 ++-- crates/taskito-workflows/src/tests.rs | 10 +- .../tests/storage_contract.rs | 10 +- sdks/node/src/workflows/builder.ts | 24 ++++ sdks/node/src/workflows/index.ts | 1 + sdks/node/src/workflows/manager.ts | 20 ++++ sdks/node/src/workflows/tracker.ts | 72 ++++++++++- sdks/node/src/workflows/types.ts | 19 +++ sdks/node/test/workflows-gates.test.ts | 113 ++++++++++++++++++ 12 files changed, 301 insertions(+), 42 deletions(-) create mode 100644 sdks/node/test/workflows-gates.test.ts diff --git a/crates/taskito-node/src/queue/workflows.rs b/crates/taskito-node/src/queue/workflows.rs index fb4f4877..056bd3e6 100644 --- a/crates/taskito-node/src/queue/workflows.rs +++ b/crates/taskito-node/src/queue/workflows.rs @@ -576,6 +576,44 @@ impl JsQueue { Ok(()) } + /// Park a gate node at `WaitingApproval`. The run pauses here until + /// `resolve_workflow_gate` approves or rejects it (or a tracker timeout does). + #[napi] + pub fn set_workflow_node_waiting_approval( + &self, + run_id: String, + node_name: String, + ) -> Result<()> { + let wf = self.workflow_store()?; + wf.update_workflow_node_status(&run_id, &node_name, WorkflowNodeStatus::WaitingApproval) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Resolve a waiting gate: approve → mark the node `Completed`; reject → + /// mark it `Failed` with `error`. The tracker then advances or skips the + /// gate's successors. Also used to resolve a parent node when its + /// sub-workflow child finalizes. + #[napi] + pub fn resolve_workflow_gate( + &self, + run_id: String, + node_name: String, + approved: bool, + error: Option, + ) -> Result<()> { + let wf = self.workflow_store()?; + if approved { + wf.set_workflow_node_completed(&run_id, &node_name, now_millis(), None) + .map_err(to_napi_err)?; + } else { + let msg = error.unwrap_or_else(|| "rejected".to_string()); + wf.set_workflow_node_error(&run_id, &node_name, &msg) + .map_err(to_napi_err)?; + } + Ok(()) + } + /// Fail-fast cascade: skip every still-pending node of a run and cancel its /// job. Called by the tracker when a managed run's node fails. #[napi] diff --git a/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs b/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs index fc963dd7..370427dd 100644 --- a/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs +++ b/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs @@ -226,15 +226,7 @@ pub(crate) fn make_step_metadata_json(steps: &[(&str, &str)]) -> String { (*node).to_string(), StepMetadata { task_name: (*task).to_string(), - queue: None, - args_template: None, - kwargs_template: None, - max_retries: None, - timeout_ms: None, - priority: None, - fan_out: None, - fan_in: None, - condition: None, + ..Default::default() }, ) }) diff --git a/crates/taskito-python/src/py_workflow/mod.rs b/crates/taskito-python/src/py_workflow/mod.rs index 4aeac44b..f2a97081 100644 --- a/crates/taskito-python/src/py_workflow/mod.rs +++ b/crates/taskito-python/src/py_workflow/mod.rs @@ -92,6 +92,7 @@ impl PyWorkflowBuilder { fan_out, fan_in, condition, + ..Default::default() }, ); self.step_order.push(name.to_string()); diff --git a/crates/taskito-workflows/src/definition.rs b/crates/taskito-workflows/src/definition.rs index 16de2c62..1c759a10 100644 --- a/crates/taskito-workflows/src/definition.rs +++ b/crates/taskito-workflows/src/definition.rs @@ -3,27 +3,32 @@ use serde::{Deserialize, Serialize}; /// Metadata for a single step in a workflow definition. /// /// Stored alongside the DAG structure to map node names to task queue details. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Every optional field is `#[serde(default)]` so the JSON blob stays +/// backward-compatible as new step kinds are added (no schema migration). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StepMetadata { pub task_name: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub queue: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub args_template: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub kwargs_template: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub priority: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub fan_out: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub fan_in: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub condition: Option, + /// JSON `{timeoutMs, onTimeout, message}` marking an approval gate node. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate: Option, } /// A persisted workflow definition: the DAG structure plus per-step metadata. diff --git a/crates/taskito-workflows/src/tests.rs b/crates/taskito-workflows/src/tests.rs index d1e70147..1234e668 100644 --- a/crates/taskito-workflows/src/tests.rs +++ b/crates/taskito-workflows/src/tests.rs @@ -122,15 +122,7 @@ fn test_get_ready_nodes_diamond_dag() { name.to_string(), StepMetadata { task_name: format!("task_{name}"), - queue: None, - args_template: None, - kwargs_template: None, - max_retries: None, - timeout_ms: None, - priority: None, - fan_out: None, - fan_in: None, - condition: None, + ..Default::default() }, ); } diff --git a/crates/taskito-workflows/tests/storage_contract.rs b/crates/taskito-workflows/tests/storage_contract.rs index ee12e69e..c704de89 100644 --- a/crates/taskito-workflows/tests/storage_contract.rs +++ b/crates/taskito-workflows/tests/storage_contract.rs @@ -26,15 +26,7 @@ use taskito_workflows::{ fn make_step(task_name: &str) -> StepMetadata { StepMetadata { task_name: task_name.to_string(), - queue: None, - args_template: None, - kwargs_template: None, - max_retries: None, - timeout_ms: None, - priority: None, - fan_out: None, - fan_in: None, - condition: None, + ..Default::default() } } diff --git a/sdks/node/src/workflows/builder.ts b/sdks/node/src/workflows/builder.ts index c531f88d..74031fce 100644 --- a/sdks/node/src/workflows/builder.ts +++ b/sdks/node/src/workflows/builder.ts @@ -2,12 +2,16 @@ import { successorsOf, transitiveDeferred } from "./plan"; import type { FanInStepOptions, FanOutStepOptions, + GateStepOptions, StepMetadataJson, WorkflowHandle, WorkflowSpec, WorkflowStepOptions, } from "./types"; +/** Sentinel task name for nodes that run no job (gates, sub-workflows). */ +const GATE_TASK = "__gate__"; + /** * Fluent builder for a workflow DAG. Each {@link WorkflowBuilder.step} adds a * node; `after` declares edges. Steps may reference predecessors that are added @@ -91,6 +95,26 @@ export class WorkflowBuilder { return this; } + /** + * Add an approval gate. The run pauses here (status `waiting_approval`) until + * resolved via `queue.workflows.resolveGate`/`approveGate`/`rejectGate`, or + * until `timeoutMs` elapses (then `onTimeout` decides). + */ + gate(name: string, options: GateStepOptions): this { + this.addNode(name, options.after); + this.stepMetadata[name] = { + task_name: GATE_TASK, + gate: JSON.stringify({ + timeoutMs: options.timeoutMs, + onTimeout: options.onTimeout ?? "reject", + message: options.message, + }), + }; + this.stepArgs[name] = []; + this.deferredSeeds.add(name); + return this; + } + /** Materialize the validated spec without submitting. */ build(): WorkflowSpec { for (const edge of this.edges) { diff --git a/sdks/node/src/workflows/index.ts b/sdks/node/src/workflows/index.ts index ccab290a..0a66492f 100644 --- a/sdks/node/src/workflows/index.ts +++ b/sdks/node/src/workflows/index.ts @@ -4,6 +4,7 @@ export { WorkflowTracker } from "./tracker"; export type { FanInStepOptions, FanOutStepOptions, + GateStepOptions, WorkflowAdvance, WorkflowCondition, WorkflowHandle, diff --git a/sdks/node/src/workflows/manager.ts b/sdks/node/src/workflows/manager.ts index f3dcb519..d8c8dddf 100644 --- a/sdks/node/src/workflows/manager.ts +++ b/sdks/node/src/workflows/manager.ts @@ -1,6 +1,7 @@ import type { NativeQueue } from "../native"; import type { Serializer } from "../serializers"; import { WorkflowBuilder } from "./builder"; +import { WorkflowTracker } from "./tracker"; import type { WorkflowHandle, WorkflowNode, @@ -71,6 +72,25 @@ export class WorkflowManager { return this.native.getWorkflowChildren(runId); } + /** + * Resolve a workflow gate that is `waiting_approval`, then advance (or skip) + * its successors. Works from any process — the run plan is read from storage. + * Idempotent: resolving an already-resolved gate is a no-op. + */ + resolveGate(runId: string, nodeName: string, approved: boolean, error?: string): void { + new WorkflowTracker(this.native, this.serializer).resolveGate(runId, nodeName, approved, error); + } + + /** Approve a waiting gate (shorthand for `resolveGate(runId, node, true)`). */ + approveGate(runId: string, nodeName: string): void { + this.resolveGate(runId, nodeName, true); + } + + /** Reject a waiting gate (shorthand for `resolveGate(runId, node, false, error)`). */ + rejectGate(runId: string, nodeName: string, error?: string): void { + this.resolveGate(runId, nodeName, false, error); + } + /** List runs, optionally filtered by definition name and/or state. */ list(options?: { definitionName?: string; diff --git a/sdks/node/src/workflows/tracker.ts b/sdks/node/src/workflows/tracker.ts index 2b5f1629..be26b2db 100644 --- a/sdks/node/src/workflows/tracker.ts +++ b/sdks/node/src/workflows/tracker.ts @@ -37,6 +37,15 @@ interface StepMeta { fan_in?: string; /** `"on_success"` (default) | `"on_failure"` | `"always"` — gates node entry. */ condition?: string; + /** JSON `{timeoutMs?, onTimeout?, message?}` marking an approval gate node. */ + gate?: string; +} + +/** Parsed gate config from a node's `gate` metadata. */ +interface GateConfig { + timeoutMs?: number; + onTimeout?: "approve" | "reject"; + message?: string; } /** The reconstructed structure + config of a workflow run. */ @@ -63,6 +72,8 @@ interface RunPlan { */ export class WorkflowTracker { private readonly plans = new Map(); + /** Pending gate-timeout timers, keyed `runId\0node` (unref'd). */ + private readonly gateTimers = new Map>(); constructor( private readonly native: NativeQueue, @@ -238,10 +249,10 @@ export class WorkflowTracker { continue; } const meta = plan.meta.get(succ); - if (meta?.fan_in) { - continue; // fan-in jobs are created from the fan-out completion path - } - if (meta?.fan_out) { + if (meta?.gate) { + this.enterGate(runId, succ, meta); + } else if (meta?.fan_in) { + } else if (meta?.fan_out) { this.expandFanOut(runId, succ, plan); } else { this.createDeferredJob(runId, succ, plan); @@ -249,6 +260,52 @@ export class WorkflowTracker { } } + /** Park a gate node at `waiting_approval` and arm its timeout, if any. */ + private enterGate(runId: string, node: string, meta: StepMeta): void { + this.native.setWorkflowNodeWaitingApproval(runId, node); + const gate: GateConfig = meta.gate ? JSON.parse(meta.gate) : {}; + if (!gate.timeoutMs || gate.timeoutMs <= 0) { + return; + } + const key = gateKey(runId, node); + const approved = gate.onTimeout === "approve"; + const timer = setTimeout(() => { + this.gateTimers.delete(key); + this.resolveGate(runId, node, approved, approved ? undefined : "gate timeout"); + }, gate.timeoutMs); + timer.unref(); // never keep the process alive waiting on an approval + this.gateTimers.set(key, timer); + } + + /** + * Approve or reject a waiting gate, then advance (or skip) its successors. + * Safe to call from any process and idempotent: a gate already resolved (by + * the other of manual-vs-timeout) is a no-op. + */ + resolveGate(runId: string, node: string, approved: boolean, error?: string): void { + this.clearGateTimer(runId, node); + const current = this.nodeStatuses(runId).get(node); + if (current === undefined || TERMINAL_NODE_STATUS.has(current)) { + return; // gone, or already resolved by the timeout / another caller + } + this.native.resolveWorkflowGate(runId, node, approved, error ?? null); + const plan = this.plan(runId); + if (!plan) { + return; + } + this.evaluateSuccessors(runId, node, plan); + this.evictIfTerminal(runId, this.native.finalizeRunIfTerminal(runId)); + } + + private clearGateTimer(runId: string, node: string): void { + const key = gateKey(runId, node); + const timer = this.gateTimers.get(key); + if (timer) { + clearTimeout(timer); + this.gateTimers.delete(key); + } + } + /** Whether a node's condition is satisfied by its predecessors' outcomes. */ private shouldExecute(runId: string, node: string, plan: RunPlan): boolean { const condition = plan.meta.get(node)?.condition ?? "on_success"; @@ -352,7 +409,7 @@ export class WorkflowTracker { ); const successors = successorsOf(edges); const seeds = [...meta] - .filter(([, m]) => m.fan_out || m.fan_in || m.condition) + .filter(([, m]) => m.fan_out || m.fan_in || m.condition || m.gate) .map(([name]) => name); const plan: RunPlan = { successors, @@ -365,6 +422,11 @@ export class WorkflowTracker { } } +/** Stable key for a run's gate node in the timer map. */ +function gateKey(runId: string, node: string): string { + return `${runId}${node}`; +} + /** `parent[3]` → `parent`; a non-child name → null. */ function fanOutParentOf(nodeName: string): string | null { return FAN_OUT_CHILD.exec(nodeName)?.[1] ?? null; diff --git a/sdks/node/src/workflows/types.ts b/sdks/node/src/workflows/types.ts index 714aa243..408fd48c 100644 --- a/sdks/node/src/workflows/types.ts +++ b/sdks/node/src/workflows/types.ts @@ -77,6 +77,23 @@ export interface FanInStepOptions extends StepTaskOptions { task: string; } +/** + * Options for an approval gate. The gate runs no task: the run pauses at the + * gate (status `waiting_approval`) until resolved via + * `queue.workflows.resolveGate` / `approveGate` / `rejectGate`, or until + * `timeoutMs` elapses (then `onTimeout` decides the outcome). + */ +export interface GateStepOptions { + /** Predecessor step name(s) the gate runs after. */ + after: string | string[]; + /** Auto-resolve after this many ms (no timeout if omitted). */ + timeoutMs?: number; + /** What a timeout does — `"approve"` or `"reject"` (default `"reject"`). */ + onTimeout?: "approve" | "reject"; + /** Human-readable reason shown to approvers (recorded in metadata). */ + message?: string; +} + /** snake_case step metadata, matching the Rust `StepMetadata` shape. */ export interface StepMetadataJson { task_name: string; @@ -92,6 +109,8 @@ export interface StepMetadataJson { fan_in?: string; /** Entry condition: `on_success` | `on_failure` | `always`. */ condition?: string; + /** JSON `{timeoutMs?, onTimeout?, message?}` marking an approval gate node. */ + gate?: string; } /** A built workflow definition, ready to submit. */ diff --git a/sdks/node/test/workflows-gates.test.ts b/sdks/node/test/workflows-gates.test.ts new file mode 100644 index 00000000..d7e552b8 --- /dev/null +++ b/sdks/node/test/workflows-gates.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-gate-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 6000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("pauses at a gate and runs downstream once approved", async () => { + const queue = freshQueue(); + let released = false; + + queue.task("prepare", () => 1); + queue.task("ship", () => { + released = true; + }); + + const handle = queue.workflows + .define("gate-approve") + .step("prepare", "prepare") + .gate("review", { after: "prepare" }) + .step("ship", "ship", { after: "review" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + + // The run parks at the gate until approved. + expect(await waitFor(() => nodesByName(handle).review?.status === "waiting_approval")).toBe(true); + expect(released).toBe(false); + expect(handle.status()?.state).toBe("running"); + + queue.workflows.approveGate(handle.runId, "review"); + + const run = await handle.wait({ timeoutMs: 8000 }); + expect(run.state).toBe("completed"); + expect(released).toBe(true); + expect(nodesByName(handle).review?.status).toBe("completed"); +}); + +it("skips downstream and fails the run when a gate is rejected", async () => { + const queue = freshQueue(); + queue.task("prepare", () => 1); + queue.task("ship", () => 2); + + const handle = queue.workflows + .define("gate-reject") + .step("prepare", "prepare") + .gate("review", { after: "prepare" }) + .step("ship", "ship", { after: "review" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + expect(await waitFor(() => nodesByName(handle).review?.status === "waiting_approval")).toBe(true); + + queue.workflows.rejectGate(handle.runId, "review", "not allowed"); + + const run = await handle.wait({ timeoutMs: 8000 }); + expect(run.state).toBe("failed"); + const nodes = nodesByName(handle); + expect(nodes.review?.status).toBe("failed"); + expect(nodes.ship?.status).toBe("skipped"); +}); + +it("auto-resolves a gate on timeout per onTimeout", async () => { + const queue = freshQueue(); + let shipped = false; + + queue.task("prepare", () => 1); + queue.task("ship", () => { + shipped = true; + }); + + const handle = queue.workflows + .define("gate-timeout") + .step("prepare", "prepare") + .gate("review", { after: "prepare", timeoutMs: 200, onTimeout: "approve" }) + .step("ship", "ship", { after: "review" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 8000 }); + + expect(run.state).toBe("completed"); // timeout approved the gate + expect(shipped).toBe(true); + expect(nodesByName(handle).review?.status).toBe("completed"); +}); From 916644992d5be879b985712d44a073ad9998dc29 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 13/25] feat(node): nested sub-workflows --- crates/taskito-node/src/queue/workflows.rs | 27 ++++-- crates/taskito-workflows/src/definition.rs | 4 + sdks/node/src/workflows/builder.ts | 21 ++++- sdks/node/src/workflows/index.ts | 1 + sdks/node/src/workflows/manager.ts | 79 ++++++++++++----- sdks/node/src/workflows/tracker.ts | 78 ++++++++++++++++- sdks/node/src/workflows/types.ts | 33 +++++++ sdks/node/test/workflows-subworkflows.test.ts | 87 +++++++++++++++++++ 8 files changed, 294 insertions(+), 36 deletions(-) create mode 100644 sdks/node/test/workflows-subworkflows.test.ts diff --git a/crates/taskito-node/src/queue/workflows.rs b/crates/taskito-node/src/queue/workflows.rs index 056bd3e6..16191b29 100644 --- a/crates/taskito-node/src/queue/workflows.rs +++ b/crates/taskito-node/src/queue/workflows.rs @@ -1,11 +1,11 @@ //! Workflow submission, advancement, and queries. //! //! Static DAG steps are pre-enqueued with a `depends_on` chain so the core -//! scheduler runs them in topological order. Fan-out / fan-in steps are -//! deferred — submitted without a job — and the worker-side tracker (see -//! `sdks/node/src/workflows/tracker.ts`) expands and enqueues them at runtime -//! via the primitives below. Gates, sub-workflows, and saga compensation are -//! not yet bound. +//! scheduler runs them in topological order. Deferred steps — fan-out / fan-in, +//! conditioned steps, gates, and sub-workflows — are submitted without a job; +//! the worker-side tracker (see `sdks/node/src/workflows/tracker.ts`) expands, +//! resolves, and enqueues them at runtime via the primitives below. Saga +//! compensation is not yet bound. use std::collections::{HashMap, HashSet}; @@ -57,6 +57,8 @@ impl JsQueue { queue_default: Option, params_json: Option, deferred_node_names: Option>, + parent_run_id: Option, + parent_node_name: Option, ) -> Result { let wf = self.workflow_store()?; let dag = dag_bytes.to_vec(); @@ -109,8 +111,8 @@ impl JsQueue { started_at: Some(now), completed_at: None, error: None, - parent_run_id: None, - parent_node_name: None, + parent_run_id, + parent_node_name, created_at: now, }; wf.create_workflow_run(&run).map_err(to_napi_err)?; @@ -614,6 +616,17 @@ impl JsQueue { Ok(()) } + /// Promote a node to `Running` — used when its sub-workflow child has been + /// submitted, so the parent node reflects in-flight work until the child + /// finalizes and resolves it. + #[napi] + pub fn set_workflow_node_running(&self, run_id: String, node_name: String) -> Result<()> { + let wf = self.workflow_store()?; + wf.set_workflow_node_running(&run_id, &node_name, now_millis()) + .map_err(to_napi_err)?; + Ok(()) + } + /// Fail-fast cascade: skip every still-pending node of a run and cancel its /// job. Called by the tracker when a managed run's node fails. #[napi] diff --git a/crates/taskito-workflows/src/definition.rs b/crates/taskito-workflows/src/definition.rs index 1c759a10..c59b5c8d 100644 --- a/crates/taskito-workflows/src/definition.rs +++ b/crates/taskito-workflows/src/definition.rs @@ -29,6 +29,10 @@ pub struct StepMetadata { /// JSON `{timeoutMs, onTimeout, message}` marking an approval gate node. #[serde(default, skip_serializing_if = "Option::is_none")] pub gate: Option, + /// Serialized child-workflow spec marking a sub-workflow node (the tracker + /// submits it as a child run and resolves this node when the child finalizes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sub_workflow: Option, } /// A persisted workflow definition: the DAG structure plus per-step metadata. diff --git a/sdks/node/src/workflows/builder.ts b/sdks/node/src/workflows/builder.ts index 74031fce..3502db97 100644 --- a/sdks/node/src/workflows/builder.ts +++ b/sdks/node/src/workflows/builder.ts @@ -4,13 +4,15 @@ import type { FanOutStepOptions, GateStepOptions, StepMetadataJson, + SubWorkflowStepOptions, WorkflowHandle, WorkflowSpec, WorkflowStepOptions, } from "./types"; -/** Sentinel task name for nodes that run no job (gates, sub-workflows). */ +/** Sentinel task names for nodes that run no job of their own. */ const GATE_TASK = "__gate__"; +const SUBWORKFLOW_TASK = "__subworkflow__"; /** * Fluent builder for a workflow DAG. Each {@link WorkflowBuilder.step} adds a @@ -30,6 +32,8 @@ export class WorkflowBuilder { private readonly seen = new Set(); /** Nodes that run no static job — expanded/enqueued by the tracker. */ private readonly deferredSeeds = new Set(); + /** Child workflows keyed by their sub-workflow node name. */ + private readonly subWorkflows: Record = {}; constructor( private readonly name: string, @@ -115,6 +119,20 @@ export class WorkflowBuilder { return this; } + /** + * Add a sub-workflow step. At runtime the tracker submits `options.workflow` + * as a child run and resolves this node when the child finalizes. Build the + * child with `queue.workflows.define(...)….build()`. + */ + subWorkflow(name: string, options: SubWorkflowStepOptions): this { + this.addNode(name, options.after); + this.stepMetadata[name] = { task_name: SUBWORKFLOW_TASK }; + this.stepArgs[name] = []; + this.subWorkflows[name] = options.workflow; + this.deferredSeeds.add(name); + return this; + } + /** Materialize the validated spec without submitting. */ build(): WorkflowSpec { for (const edge of this.edges) { @@ -144,6 +162,7 @@ export class WorkflowBuilder { stepMetadata: { ...this.stepMetadata }, stepArgs: { ...this.stepArgs }, deferredNodeNames: [...deferred], + subWorkflows: { ...this.subWorkflows }, }; } diff --git a/sdks/node/src/workflows/index.ts b/sdks/node/src/workflows/index.ts index 0a66492f..ad2876b7 100644 --- a/sdks/node/src/workflows/index.ts +++ b/sdks/node/src/workflows/index.ts @@ -5,6 +5,7 @@ export type { FanInStepOptions, FanOutStepOptions, GateStepOptions, + SubWorkflowStepOptions, WorkflowAdvance, WorkflowCondition, WorkflowHandle, diff --git a/sdks/node/src/workflows/manager.ts b/sdks/node/src/workflows/manager.ts index d8c8dddf..6bf191b9 100644 --- a/sdks/node/src/workflows/manager.ts +++ b/sdks/node/src/workflows/manager.ts @@ -3,6 +3,8 @@ import type { Serializer } from "../serializers"; import { WorkflowBuilder } from "./builder"; import { WorkflowTracker } from "./tracker"; import type { + StepMetadataJson, + SubWorkflowTransport, WorkflowHandle, WorkflowNode, WorkflowRun, @@ -107,41 +109,70 @@ export class WorkflowManager { } private submitSpec(spec: WorkflowSpec, options?: WorkflowSubmitOptions): WorkflowHandle { - const dag = { - nodes: spec.nodes.map((name) => ({ name })), - edges: spec.edges.map((edge) => ({ from: edge.from, to: edge.to, weight: 1.0 })), - }; - const dagBytes = Buffer.from(JSON.stringify(dag)); - - // Static nodes are enqueued by `submitWorkflow` from these payloads. For - // deferred nodes the tracker enqueues later, so we persist their args in - // `args_template` (base64) — the only storage-reconstructable channel. - const deferred = new Set(spec.deferredNodeNames); - const stepMetadata = { ...spec.stepMetadata }; + const transport = this.toTransport(spec); const nodePayloads: Record = {}; - for (const name of spec.nodes) { - const payload = Buffer.from(this.serializer.serialize(spec.stepArgs[name] ?? [])); - nodePayloads[name] = payload; - const meta = stepMetadata[name]; - if (deferred.has(name) && meta) { - stepMetadata[name] = { ...meta, args_template: payload.toString("base64") }; - } + for (const [name, b64] of Object.entries(transport.nodePayloads)) { + nodePayloads[name] = Buffer.from(b64, "base64"); } - const paramsJson = options?.params === undefined ? null : JSON.stringify(options.params); const runId = this.native.submitWorkflow( - spec.name, - spec.version, - dagBytes, - JSON.stringify(stepMetadata), + transport.name, + transport.version, + Buffer.from(transport.dag, "base64"), + transport.stepMetadata, nodePayloads, options?.queueDefault ?? null, paramsJson, - spec.deferredNodeNames, + transport.deferredNodeNames, + null, + null, ); return this.makeHandle(runId); } + /** + * Flatten a spec (and any nested sub-workflows) to its base64/JSON transport + * form: serialize each node's args once, stamp deferred nodes' `args_template` + * (the only storage-reconstructable channel for the tracker), and embed each + * child workflow into its parent node's `sub_workflow` metadata. + */ + private toTransport(spec: WorkflowSpec): SubWorkflowTransport { + const dag = { + nodes: spec.nodes.map((name) => ({ name })), + edges: spec.edges.map((edge) => ({ from: edge.from, to: edge.to, weight: 1.0 })), + }; + const deferred = new Set(spec.deferredNodeNames); + const stepMetadata: Record = {}; + const nodePayloads: Record = {}; + for (const name of spec.nodes) { + const base = spec.stepMetadata[name]; + if (!base) { + throw new Error(`workflow step '${name}' is missing metadata`); + } + const b64 = Buffer.from(this.serializer.serialize(spec.stepArgs[name] ?? [])).toString( + "base64", + ); + nodePayloads[name] = b64; + const meta: StepMetadataJson = { ...base }; + if (deferred.has(name)) { + meta.args_template = b64; + } + const child = spec.subWorkflows?.[name]; + if (child) { + meta.sub_workflow = JSON.stringify(this.toTransport(child)); + } + stepMetadata[name] = meta; + } + return { + name: spec.name, + version: spec.version, + dag: Buffer.from(JSON.stringify(dag)).toString("base64"), + stepMetadata: JSON.stringify(stepMetadata), + nodePayloads, + deferredNodeNames: spec.deferredNodeNames, + }; + } + private makeHandle(runId: string): WorkflowHandle { return { runId, diff --git a/sdks/node/src/workflows/tracker.ts b/sdks/node/src/workflows/tracker.ts index be26b2db..c0b59a45 100644 --- a/sdks/node/src/workflows/tracker.ts +++ b/sdks/node/src/workflows/tracker.ts @@ -2,6 +2,7 @@ import type { JsOutcome, NativeQueue } from "../native"; import type { Serializer } from "../serializers"; import { createLogger } from "../utils"; import { predecessorsOf, successorsOf, transitiveDeferred } from "./plan"; +import type { SubWorkflowTransport } from "./types"; const log = createLogger("workflow-tracker"); @@ -39,6 +40,8 @@ interface StepMeta { condition?: string; /** JSON `{timeoutMs?, onTimeout?, message?}` marking an approval gate node. */ gate?: string; + /** JSON `SubWorkflowTransport` marking a sub-workflow node. */ + sub_workflow?: string; } /** Parsed gate config from a node's `gate` metadata. */ @@ -119,7 +122,7 @@ export class WorkflowTracker { // Record the node's outcome. For plain DAGs the core also cascades + finalizes. const advance = this.native.markWorkflowNodeResult(jobId, succeeded, error, managed); if (!managed) { - this.evictIfTerminal(runId, advance?.finalState ?? null); + this.settle(runId, advance?.finalState ?? null); return; } @@ -133,7 +136,45 @@ export class WorkflowTracker { this.evaluateSuccessors(runId, nodeName, plan); } - this.evictIfTerminal(runId, this.native.finalizeRunIfTerminal(runId)); + this.settle(runId, this.native.finalizeRunIfTerminal(runId)); + } + + /** Evict a terminal run's plan and, if it's a sub-workflow child, resolve its parent. */ + private settle(runId: string, finalState: string | null): void { + this.evictIfTerminal(runId, finalState); + if (finalState !== null) { + this.resolveParentIfChild(runId, finalState); + } + } + + /** + * If `childRunId` is a sub-workflow child, resolve its parent node (success → + * complete, failure → fail), advance the parent's successors, and recurse for + * nested parents. Idempotent: a parent node already resolved is left alone. + */ + private resolveParentIfChild(childRunId: string, finalState: string): void { + const child = this.native.getWorkflowRun(childRunId); + const parentRunId = child?.parentRunId; + const parentNode = child?.parentNodeName; + if (!parentRunId || !parentNode) { + return; + } + const parentStatus = this.nodeStatuses(parentRunId).get(parentNode); + if (parentStatus === undefined || TERMINAL_NODE_STATUS.has(parentStatus)) { + return; // parent gone, or already resolved (e.g. by a racing worker) + } + const succeeded = finalState === "completed"; + this.native.resolveWorkflowGate( + parentRunId, + parentNode, + succeeded, + succeeded ? null : `sub-workflow ${finalState}`, + ); + const parentPlan = this.plan(parentRunId); + if (parentPlan) { + this.evaluateSuccessors(parentRunId, parentNode, parentPlan); + } + this.settle(parentRunId, this.native.finalizeRunIfTerminal(parentRunId)); } /** Drop a finished run's cached plan so the cache doesn't grow unbounded. */ @@ -249,9 +290,13 @@ export class WorkflowTracker { continue; } const meta = plan.meta.get(succ); + if (meta?.fan_in) { + continue; // fan-in jobs are created from the fan-out completion path + } if (meta?.gate) { this.enterGate(runId, succ, meta); - } else if (meta?.fan_in) { + } else if (meta?.sub_workflow) { + this.submitSubWorkflow(runId, succ, meta); } else if (meta?.fan_out) { this.expandFanOut(runId, succ, plan); } else { @@ -260,6 +305,31 @@ export class WorkflowTracker { } } + /** Submit a node's child workflow as a linked run; mark the node `running`. */ + private submitSubWorkflow(runId: string, node: string, meta: StepMeta): void { + if (!meta.sub_workflow) { + return; + } + const child: SubWorkflowTransport = JSON.parse(meta.sub_workflow); + const payloads: Record = {}; + for (const [name, b64] of Object.entries(child.nodePayloads)) { + payloads[name] = Buffer.from(b64, "base64"); + } + this.native.submitWorkflow( + child.name, + child.version, + Buffer.from(child.dag, "base64"), + child.stepMetadata, + payloads, + null, + null, + child.deferredNodeNames, + runId, + node, + ); + this.native.setWorkflowNodeRunning(runId, node); + } + /** Park a gate node at `waiting_approval` and arm its timeout, if any. */ private enterGate(runId: string, node: string, meta: StepMeta): void { this.native.setWorkflowNodeWaitingApproval(runId, node); @@ -409,7 +479,7 @@ export class WorkflowTracker { ); const successors = successorsOf(edges); const seeds = [...meta] - .filter(([, m]) => m.fan_out || m.fan_in || m.condition || m.gate) + .filter(([, m]) => m.fan_out || m.fan_in || m.condition || m.gate || m.sub_workflow) .map(([name]) => name); const plan: RunPlan = { successors, diff --git a/sdks/node/src/workflows/types.ts b/sdks/node/src/workflows/types.ts index 408fd48c..2d60bf3a 100644 --- a/sdks/node/src/workflows/types.ts +++ b/sdks/node/src/workflows/types.ts @@ -111,6 +111,37 @@ export interface StepMetadataJson { condition?: string; /** JSON `{timeoutMs?, onTimeout?, message?}` marking an approval gate node. */ gate?: string; + /** JSON {@link SubWorkflowTransport} marking a sub-workflow node. */ + sub_workflow?: string; +} + +/** + * Options for a sub-workflow step. The step runs no task of its own; at runtime + * the tracker submits `workflow` as a child run and resolves this node when the + * child finalizes (success → completed, failure → failed). + */ +export interface SubWorkflowStepOptions { + /** Predecessor step name(s) the sub-workflow runs after. */ + after: string | string[]; + /** The child workflow to run — build it with `queue.workflows.define(...).build()`. */ + workflow: WorkflowSpec; +} + +/** + * A child workflow flattened to base64/JSON so it round-trips through storage + * (in a parent node's `sub_workflow` metadata) and can be submitted as a child + * run by the tracker without re-serializing user args. + */ +export interface SubWorkflowTransport { + name: string; + version: number; + /** base64 of the serialized DAG (`SerializableGraph` JSON). */ + dag: string; + /** JSON-encoded `Record`. */ + stepMetadata: string; + /** Node name → base64 of the node's serialized args payload. */ + nodePayloads: Record; + deferredNodeNames: string[]; } /** A built workflow definition, ready to submit. */ @@ -123,6 +154,8 @@ export interface WorkflowSpec { stepArgs: Record; /** Nodes the tracker enqueues on demand (fan-out/fan-in ∪ their descendants). */ deferredNodeNames: string[]; + /** Child workflows keyed by their sub-workflow node name. */ + subWorkflows?: Record; } /** Submit-time options. */ diff --git a/sdks/node/test/workflows-subworkflows.test.ts b/sdks/node/test/workflows-subworkflows.test.ts new file mode 100644 index 00000000..b2943051 --- /dev/null +++ b/sdks/node/test/workflows-subworkflows.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-subwf-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +it("runs a child workflow as a node and advances the parent on completion", async () => { + const queue = freshQueue(); + const ran: string[] = []; + + queue.task("prep", () => ran.push("prep")); + queue.task("childA", () => ran.push("childA")); + queue.task("childB", () => ran.push("childB")); + queue.task("finish", () => ran.push("finish")); + + const child = queue.workflows + .define("child-pipeline") + .step("a", "childA") + .step("b", "childB", { after: "a" }) + .build(); + + const handle = queue.workflows + .define("parent-pipeline") + .step("prep", "prep") + .subWorkflow("sub", { after: "prep", workflow: child }) + .step("finish", "finish", { after: "sub" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("completed"); + expect(ran).toEqual(["prep", "childA", "childB", "finish"]); + const nodes = nodesByName(handle); + expect(nodes.sub?.status).toBe("completed"); + expect(nodes.finish?.status).toBe("completed"); + + const children = queue.workflows.children(handle.runId); + expect(children).toHaveLength(1); + expect(children[0]?.state).toBe("completed"); +}); + +it("fails the parent node and skips downstream when the child fails", async () => { + const queue = freshQueue(); + + queue.task("prep", () => 1); + queue.task("boom", () => { + throw new Error("child boom"); + }); + queue.task("finish", () => 2); + + const child = queue.workflows.define("child-fail").step("a", "boom", { maxRetries: 0 }).build(); + + const handle = queue.workflows + .define("parent-fail") + .step("prep", "prep") + .subWorkflow("sub", { after: "prep", workflow: child }) + .step("finish", "finish", { after: "sub" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); + const nodes = nodesByName(handle); + expect(nodes.sub?.status).toBe("failed"); + expect(nodes.finish?.status).toBe("skipped"); + expect(queue.workflows.children(handle.runId)[0]?.state).toBe("failed"); +}); From 8a6e5eb0ae912e6ccf355dc15c1b619316397456 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 14/25] feat(node): saga compensation on workflow failure --- crates/taskito-node/src/queue/workflows.rs | 143 +++++++++++++++++- crates/taskito-workflows/src/definition.rs | 4 + sdks/node/src/workflows/builder.ts | 1 + sdks/node/src/workflows/tracker.ts | 159 ++++++++++++++++++++- sdks/node/src/workflows/types.ts | 7 + sdks/node/test/workflows-saga.test.ts | 99 +++++++++++++ 6 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 sdks/node/test/workflows-saga.test.ts diff --git a/crates/taskito-node/src/queue/workflows.rs b/crates/taskito-node/src/queue/workflows.rs index 16191b29..5721c7ff 100644 --- a/crates/taskito-node/src/queue/workflows.rs +++ b/crates/taskito-node/src/queue/workflows.rs @@ -4,8 +4,8 @@ //! scheduler runs them in topological order. Deferred steps — fan-out / fan-in, //! conditioned steps, gates, and sub-workflows — are submitted without a job; //! the worker-side tracker (see `sdks/node/src/workflows/tracker.ts`) expands, -//! resolves, and enqueues them at runtime via the primitives below. Saga -//! compensation is not yet bound. +//! resolves, and enqueues them at runtime via the primitives below. On failure +//! the tracker can drive saga compensation, rolling nodes back in reverse order. use std::collections::{HashMap, HashSet}; @@ -627,6 +627,111 @@ impl JsQueue { Ok(()) } + /// Set a run's state (and optional error + completion timestamp). Used by the + /// saga tracker to transition through `compensating` → `compensated` / + /// `compensation_failed`. + #[napi] + pub fn set_workflow_run_state( + &self, + run_id: String, + state: String, + error: Option, + completed_at: Option, + ) -> Result<()> { + let wf = self.workflow_store()?; + let state = WorkflowState::from_str_val(&state) + .ok_or_else(|| reason(format!("unknown workflow state '{state}'")))?; + wf.update_workflow_run_state(&run_id, state, error.as_deref()) + .map_err(to_napi_err)?; + if let Some(ts) = completed_at { + wf.set_workflow_run_completed(&run_id, ts) + .map_err(to_napi_err)?; + } + Ok(()) + } + + /// Mark a node's compensation succeeded (status `Compensated`). + #[napi] + pub fn set_workflow_node_compensated( + &self, + run_id: String, + node_name: String, + completed_at: i64, + ) -> Result<()> { + let wf = self.workflow_store()?; + wf.set_workflow_node_compensated(&run_id, &node_name, completed_at) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Mark a node's compensation failed (status `CompensationFailed`). + #[napi] + pub fn set_workflow_node_compensation_failed( + &self, + run_id: String, + node_name: String, + error: String, + completed_at: i64, + ) -> Result<()> { + let wf = self.workflow_store()?; + wf.set_workflow_node_compensation_failed(&run_id, &node_name, &error, completed_at) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Enqueue a node's rollback (compensation) job and bind it to the node + /// (status `Compensating`). The job carries a compensation marker so its + /// outcome routes to saga handling, and a dedup `unique_key` so concurrent + /// workers never double-compensate the same node. Returns the job id. + #[napi] + #[allow(clippy::too_many_arguments)] + pub fn enqueue_compensation( + &self, + run_id: String, + node_name: String, + payload: Buffer, + task_name: String, + queue: String, + max_retries: i32, + timeout_ms: i64, + priority: i32, + ) -> Result { + let wf = self.workflow_store()?; + let now = now_millis(); + let new_job = NewJob { + queue, + task_name, + payload: payload.to_vec(), + priority, + scheduled_at: now, + max_retries, + timeout_ms, + unique_key: Some(format!("compensation:{run_id}:{node_name}")), + metadata: Some(compensation_metadata_json(&run_id, &node_name)), + notes: None, + depends_on: vec![], + expires_at: None, + result_ttl_ms: None, + namespace: self.namespace.clone(), + }; + let job = self.storage.enqueue(new_job).map_err(to_napi_err)?; + wf.set_workflow_node_compensation_job(&run_id, &node_name, &job.id, now) + .map_err(to_napi_err)?; + Ok(job.id) + } + + /// The run + node a compensation job belongs to, or `null` if `job_id` is not + /// a compensation job. The tracker checks this before normal node handling so + /// a rollback outcome advances the saga rather than the forward run. + #[napi] + pub fn compensation_node_for_job(&self, job_id: String) -> Result> { + let Some(job) = self.storage.get_job(&job_id).map_err(to_napi_err)? else { + return Ok(None); + }; + Ok(parse_compensation_metadata(job.metadata.as_deref()) + .map(|(run_id, node_name)| JsWorkflowNodeRef { run_id, node_name })) + } + /// Fail-fast cascade: skip every still-pending node of a run and cancel its /// job. Called by the tracker when a managed run's node fails. #[napi] @@ -730,9 +835,43 @@ fn workflow_metadata_json(run_id: &str, node_name: &str) -> String { .to_string() } +/// Like `workflow_metadata_json` but flags a node's rollback (compensation) job. +fn compensation_metadata_json(run_id: &str, node_name: &str) -> String { + serde_json::json!({ + "workflow_run_id": run_id, + "workflow_node_name": node_name, + "compensation": true, + }) + .to_string() +} + /// Parse `{workflow_run_id, workflow_node_name}` from a job's metadata blob. +/// Returns `None` for compensation jobs so they never advance the forward run. fn parse_workflow_metadata(metadata: Option<&str>) -> Option<(String, String)> { let parsed: serde_json::Value = serde_json::from_str(metadata?).ok()?; + if parsed + .get("compensation") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + return None; + } + let run_id = parsed.get("workflow_run_id")?.as_str()?.to_string(); + let node_name = parsed.get("workflow_node_name")?.as_str()?.to_string(); + Some((run_id, node_name)) +} + +/// Parse a compensation job's `{workflow_run_id, workflow_node_name}` — `None` +/// unless the metadata carries `compensation: true`. +fn parse_compensation_metadata(metadata: Option<&str>) -> Option<(String, String)> { + let parsed: serde_json::Value = serde_json::from_str(metadata?).ok()?; + if parsed + .get("compensation") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } let run_id = parsed.get("workflow_run_id")?.as_str()?.to_string(); let node_name = parsed.get("workflow_node_name")?.as_str()?.to_string(); Some((run_id, node_name)) diff --git a/crates/taskito-workflows/src/definition.rs b/crates/taskito-workflows/src/definition.rs index c59b5c8d..77bbedf3 100644 --- a/crates/taskito-workflows/src/definition.rs +++ b/crates/taskito-workflows/src/definition.rs @@ -33,6 +33,10 @@ pub struct StepMetadata { /// submits it as a child run and resolves this node when the child finalizes). #[serde(default, skip_serializing_if = "Option::is_none")] pub sub_workflow: Option, + /// Rollback task name — if the run fails, the tracker compensates this node + /// (in reverse-dependency order) by running this task with the node's result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compensate: Option, } /// A persisted workflow definition: the DAG structure plus per-step metadata. diff --git a/sdks/node/src/workflows/builder.ts b/sdks/node/src/workflows/builder.ts index 3502db97..c57cdf30 100644 --- a/sdks/node/src/workflows/builder.ts +++ b/sdks/node/src/workflows/builder.ts @@ -51,6 +51,7 @@ export class WorkflowBuilder { timeout_ms: options.timeoutMs, priority: options.priority, condition: options.condition, + compensate: options.compensate, }; this.stepArgs[name] = options.args ?? []; // A conditioned step is enqueued by the tracker once its predecessors diff --git a/sdks/node/src/workflows/tracker.ts b/sdks/node/src/workflows/tracker.ts index c0b59a45..8085e2e3 100644 --- a/sdks/node/src/workflows/tracker.ts +++ b/sdks/node/src/workflows/tracker.ts @@ -42,6 +42,8 @@ interface StepMeta { gate?: string; /** JSON `SubWorkflowTransport` marking a sub-workflow node. */ sub_workflow?: string; + /** Rollback task name run if the workflow fails (saga compensation). */ + compensate?: string; } /** Parsed gate config from a node's `gate` metadata. */ @@ -57,8 +59,13 @@ interface RunPlan { predecessors: Map; deferred: Set; meta: Map; + /** True if any node declares a compensator (the run can run a saga rollback). */ + hasCompensation: boolean; } +/** Node statuses still awaiting (or undergoing) compensation rollback. */ +const COMPENSABLE_STATUS = new Set(["completed", "cache_hit"]); + /** * The runtime workflow brain. Driven from the worker's outcome callback, it * advances a run as each node-job settles. For plain DAGs the core scheduler @@ -95,6 +102,12 @@ export class WorkflowTracker { } try { + // A rollback job advances the saga, not the forward run — check it first. + const comp = this.native.compensationNodeForJob(outcome.jobId); + if (comp) { + this.onCompensationOutcome(comp.runId, comp.nodeName, succeeded, outcome.error ?? null); + return; + } const ref = this.native.workflowNodeForJob(outcome.jobId); if (!ref) { return; // not a workflow job @@ -139,8 +152,15 @@ export class WorkflowTracker { this.settle(runId, this.native.finalizeRunIfTerminal(runId)); } - /** Evict a terminal run's plan and, if it's a sub-workflow child, resolve its parent. */ + /** + * Wrap up after a node settles. A failed run with compensable nodes starts a + * saga rollback instead of finishing; otherwise the plan is evicted and, if + * this run is a sub-workflow child, its parent node is resolved. + */ private settle(runId: string, finalState: string | null): void { + if (finalState === "failed" && this.startCompensationIfEligible(runId)) { + return; // run moved to `compensating`; it finalizes when rollback completes + } this.evictIfTerminal(runId, finalState); if (finalState !== null) { this.resolveParentIfChild(runId, finalState); @@ -177,6 +197,142 @@ export class WorkflowTracker { this.settle(parentRunId, this.native.finalizeRunIfTerminal(parentRunId)); } + /** Whether a node declares a compensator (saga rollback task). */ + private isCompensable(node: string, plan: RunPlan): boolean { + return Boolean(plan.meta.get(node)?.compensate); + } + + /** + * Begin saga compensation for a failed run that has completed compensable + * nodes: move the run to `compensating` and dispatch the first rollback wave. + * Returns false (run finalizes normally) when there's nothing to roll back. + */ + private startCompensationIfEligible(runId: string): boolean { + const plan = this.plan(runId); + if (!plan?.hasCompensation) { + return false; + } + const eligible = this.native + .getWorkflowNodes(runId) + .some((n) => this.isCompensable(n.nodeName, plan) && COMPENSABLE_STATUS.has(n.status)); + if (!eligible) { + return false; + } + this.native.setWorkflowRunState(runId, "compensating", null, null); + this.advanceCompensation(runId, plan); + return true; + } + + /** + * Drive one step of compensation: dispatch every compensable node that's ready + * (all its compensable successors already rolled back) in reverse-dependency + * order, then finalize the run once nothing is in flight. Storage-driven and + * idempotent — re-derived from node status on every rollback outcome. + */ + private advanceCompensation(runId: string, plan: RunPlan): void { + let nodes = this.native.getWorkflowNodes(runId); + const compensable = (node: { nodeName: string }) => this.isCompensable(node.nodeName, plan); + const anyFailed = nodes.some((n) => compensable(n) && n.status === "compensation_failed"); + + if (!anyFailed) { + const status = new Map(nodes.map((n) => [n.nodeName, n.status])); + for (const node of nodes) { + if (!compensable(node) || !COMPENSABLE_STATUS.has(node.status)) { + continue; + } + if (this.compensationReady(node.nodeName, status, plan)) { + this.enqueueCompensation(runId, node.nodeName, node.jobId ?? null, plan); + } + } + nodes = this.native.getWorkflowNodes(runId); // re-read after dispatch + } + + // Still working while any rollback is in flight, or (absent a failure) any + // compensable node is still awaiting rollback. + const working = nodes.some((n) => { + if (!compensable(n)) { + return false; + } + return n.status === "compensating" || (!anyFailed && COMPENSABLE_STATUS.has(n.status)); + }); + if (working) { + return; + } + + const failed = nodes.some((n) => compensable(n) && n.status === "compensation_failed"); + const now = Date.now(); + if (failed) { + this.native.setWorkflowRunState(runId, "compensation_failed", "compensation failed", now); + } else { + this.native.setWorkflowRunState(runId, "compensated", null, now); + } + this.plans.delete(runId); + } + + /** A node is ready to roll back once all its compensable successors have. */ + private compensationReady(node: string, status: Map, plan: RunPlan): boolean { + for (const succ of plan.successors.get(node) ?? []) { + if (!this.isCompensable(succ, plan)) { + continue; // a non-compensable successor did nothing to undo + } + const s = status.get(succ); + if (s !== "compensated" && s !== "compensation_failed") { + return false; + } + } + return true; + } + + /** Enqueue a node's rollback job, passing the node's forward result as its arg. */ + private enqueueCompensation( + runId: string, + node: string, + jobId: string | null, + plan: RunPlan, + ): void { + const meta = plan.meta.get(node); + if (!meta?.compensate) { + return; + } + const job = jobId ? this.native.getJob(jobId) : null; + const result = job?.result ? this.serializer.deserialize(job.result) : null; + const payload = Buffer.from(this.serializer.serialize([result])); + this.native.enqueueCompensation( + runId, + node, + payload, + meta.compensate, + meta.queue ?? DEFAULT_QUEUE, + meta.max_retries ?? DEFAULT_MAX_RETRIES, + meta.timeout_ms ?? DEFAULT_TIMEOUT_MS, + meta.priority ?? 0, + ); + } + + /** Record a rollback's outcome and advance (or finalize) the saga. */ + private onCompensationOutcome( + runId: string, + node: string, + succeeded: boolean, + error: string | null, + ): void { + const now = Date.now(); + if (succeeded) { + this.native.setWorkflowNodeCompensated(runId, node, now); + } else { + this.native.setWorkflowNodeCompensationFailed( + runId, + node, + error ?? "compensation failed", + now, + ); + } + const plan = this.plan(runId); + if (plan) { + this.advanceCompensation(runId, plan); + } + } + /** Drop a finished run's cached plan so the cache doesn't grow unbounded. */ private evictIfTerminal(runId: string, finalState: string | null): void { if (finalState !== null) { @@ -486,6 +642,7 @@ export class WorkflowTracker { predecessors: predecessorsOf(edges), deferred: transitiveDeferred(seeds, successors), meta, + hasCompensation: [...meta.values()].some((m) => m.compensate), }; this.plans.set(runId, plan); return plan; diff --git a/sdks/node/src/workflows/types.ts b/sdks/node/src/workflows/types.ts index 2d60bf3a..bb75fc3d 100644 --- a/sdks/node/src/workflows/types.ts +++ b/sdks/node/src/workflows/types.ts @@ -41,6 +41,11 @@ export interface WorkflowStepOptions { priority?: number; /** Gate this step on its predecessors' outcomes (default `on_success`). */ condition?: WorkflowCondition; + /** + * Rollback task name for saga compensation. If the run fails, this task is run + * with the step's result as its single argument, in reverse-dependency order. + */ + compensate?: string; } /** Common per-step task config shared by every step kind. */ @@ -113,6 +118,8 @@ export interface StepMetadataJson { gate?: string; /** JSON {@link SubWorkflowTransport} marking a sub-workflow node. */ sub_workflow?: string; + /** Rollback task name for saga compensation. */ + compensate?: string; } /** diff --git a/sdks/node/test/workflows-saga.test.ts b/sdks/node/test/workflows-saga.test.ts new file mode 100644 index 00000000..8d1e382b --- /dev/null +++ b/sdks/node/test/workflows-saga.test.ts @@ -0,0 +1,99 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-saga-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +it("compensates completed steps in reverse order when a later step fails", async () => { + const queue = freshQueue(); + const rollbacks: string[] = []; + + queue.task("reserve", () => "reservation"); + queue.task("charge", () => "charge-id"); + queue.task("ship", () => { + throw new Error("out of stock"); + }); + queue.task("unreserve", (forward: string) => rollbacks.push(`unreserve:${forward}`)); + queue.task("refund", (forward: string) => rollbacks.push(`refund:${forward}`)); + + const handle = queue.workflows + .define("saga-order") + .step("reserve", "reserve", { compensate: "unreserve" }) + .step("charge", "charge", { after: "reserve", compensate: "refund" }) + .step("ship", "ship", { after: "charge", maxRetries: 0 }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("compensated"); + // charge rolls back before reserve (reverse dependency order). + expect(rollbacks).toEqual(["refund:charge-id", "unreserve:reservation"]); + const nodes = nodesByName(handle); + expect(nodes.reserve?.status).toBe("compensated"); + expect(nodes.charge?.status).toBe("compensated"); + expect(nodes.ship?.status).toBe("failed"); +}); + +it("ends in compensation_failed when a compensator fails", async () => { + const queue = freshQueue(); + + queue.task("reserve", () => 1); + queue.task("ship", () => { + throw new Error("nope"); + }); + queue.task("unreserve", () => { + throw new Error("rollback failed"); + }); + + const handle = queue.workflows + .define("saga-bad-rollback") + .step("reserve", "reserve", { compensate: "unreserve", maxRetries: 0 }) + .step("ship", "ship", { after: "reserve", maxRetries: 0 }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("compensation_failed"); + expect(nodesByName(handle).reserve?.status).toBe("compensation_failed"); +}); + +it("fails normally with no compensation when no step declares a compensator", async () => { + const queue = freshQueue(); + + queue.task("reserve", () => 1); + queue.task("ship", () => { + throw new Error("nope"); + }); + + const handle = queue.workflows + .define("saga-none") + .step("reserve", "reserve") + .step("ship", "ship", { after: "reserve", maxRetries: 0 }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); + expect(nodesByName(handle).reserve?.status).toBe("completed"); // not rolled back +}); From ca6ace48f63eed372cf017aa121c0a9d8c09295a Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 15/25] test(node): group tests by feature area (Python-style) --- sdks/node/test/{ => core}/cancel.test.ts | 2 +- sdks/node/test/{ => core}/inspection.test.ts | 2 +- sdks/node/test/{ => core}/locks.test.ts | 2 +- sdks/node/test/{ => core}/logger.test.ts | 2 +- sdks/node/test/{ => core}/options.test.ts | 2 +- sdks/node/test/{ => core}/roundtrip.test.ts | 2 +- sdks/node/test/{ => core}/serializer.test.ts | 2 +- sdks/node/test/{ => core}/typed.test.ts | 2 +- sdks/node/test/{ => core}/webhooks.test.ts | 2 +- .../test/{dashboard.test.ts => dashboard/server.test.ts} | 4 ++-- sdks/node/test/{ => integrations}/cli.test.ts | 6 +++--- .../express.test.ts} | 4 ++-- .../fastify.test.ts} | 4 ++-- .../{contrib-nest.test.ts => integrations/nest.test.ts} | 4 ++-- .../{contrib-otel.test.ts => observability/otel.test.ts} | 4 ++-- .../prometheus.test.ts} | 4 ++-- .../sentry.test.ts} | 4 ++-- sdks/node/test/{ => worker}/events.test.ts | 2 +- sdks/node/test/{ => worker}/mesh.test.ts | 2 +- sdks/node/test/{ => worker}/periodic.test.ts | 2 +- .../conditions.test.ts} | 2 +- sdks/node/test/{workflows.test.ts => workflows/dag.test.ts} | 2 +- .../{workflows-fanout.test.ts => workflows/fanout.test.ts} | 2 +- .../{workflows-gates.test.ts => workflows/gates.test.ts} | 2 +- .../test/{workflows-saga.test.ts => workflows/saga.test.ts} | 2 +- .../subworkflows.test.ts} | 2 +- 26 files changed, 35 insertions(+), 35 deletions(-) rename sdks/node/test/{ => core}/cancel.test.ts (96%) rename sdks/node/test/{ => core}/inspection.test.ts (99%) rename sdks/node/test/{ => core}/locks.test.ts (97%) rename sdks/node/test/{ => core}/logger.test.ts (93%) rename sdks/node/test/{ => core}/options.test.ts (97%) rename sdks/node/test/{ => core}/roundtrip.test.ts (95%) rename sdks/node/test/{ => core}/serializer.test.ts (98%) rename sdks/node/test/{ => core}/typed.test.ts (93%) rename sdks/node/test/{ => core}/webhooks.test.ts (97%) rename sdks/node/test/{dashboard.test.ts => dashboard/server.test.ts} (98%) rename sdks/node/test/{ => integrations}/cli.test.ts (92%) rename sdks/node/test/{contrib-express.test.ts => integrations/express.test.ts} (96%) rename sdks/node/test/{contrib-fastify.test.ts => integrations/fastify.test.ts} (95%) rename sdks/node/test/{contrib-nest.test.ts => integrations/nest.test.ts} (91%) rename sdks/node/test/{contrib-otel.test.ts => observability/otel.test.ts} (97%) rename sdks/node/test/{contrib-prometheus.test.ts => observability/prometheus.test.ts} (96%) rename sdks/node/test/{contrib-sentry.test.ts => observability/sentry.test.ts} (96%) rename sdks/node/test/{ => worker}/events.test.ts (98%) rename sdks/node/test/{ => worker}/mesh.test.ts (94%) rename sdks/node/test/{ => worker}/periodic.test.ts (96%) rename sdks/node/test/{workflows-conditions.test.ts => workflows/conditions.test.ts} (98%) rename sdks/node/test/{workflows.test.ts => workflows/dag.test.ts} (97%) rename sdks/node/test/{workflows-fanout.test.ts => workflows/fanout.test.ts} (98%) rename sdks/node/test/{workflows-gates.test.ts => workflows/gates.test.ts} (98%) rename sdks/node/test/{workflows-saga.test.ts => workflows/saga.test.ts} (98%) rename sdks/node/test/{workflows-subworkflows.test.ts => workflows/subworkflows.test.ts} (98%) diff --git a/sdks/node/test/cancel.test.ts b/sdks/node/test/core/cancel.test.ts similarity index 96% rename from sdks/node/test/cancel.test.ts rename to sdks/node/test/core/cancel.test.ts index a8da0b24..1b19b776 100644 --- a/sdks/node/test/cancel.test.ts +++ b/sdks/node/test/core/cancel.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { currentJob, Queue, type Worker } from "../src/index"; +import { currentJob, Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/inspection.test.ts b/sdks/node/test/core/inspection.test.ts similarity index 99% rename from sdks/node/test/inspection.test.ts rename to sdks/node/test/core/inspection.test.ts index 3cc9485c..97dfeea6 100644 --- a/sdks/node/test/inspection.test.ts +++ b/sdks/node/test/core/inspection.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { type DeadJob, JobFailedError, Queue, type Worker } from "../src/index"; +import { type DeadJob, JobFailedError, Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/locks.test.ts b/sdks/node/test/core/locks.test.ts similarity index 97% rename from sdks/node/test/locks.test.ts rename to sdks/node/test/core/locks.test.ts index 0f9b3875..97b179fb 100644 --- a/sdks/node/test/locks.test.ts +++ b/sdks/node/test/core/locks.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { LockNotAcquiredError, Queue } from "../src/index"; +import { LockNotAcquiredError, Queue } from "../../src/index"; function freshQueue(): Queue { const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-lock-")), "queue.db"); diff --git a/sdks/node/test/logger.test.ts b/sdks/node/test/core/logger.test.ts similarity index 93% rename from sdks/node/test/logger.test.ts rename to sdks/node/test/core/logger.test.ts index d5078d42..7db018a7 100644 --- a/sdks/node/test/logger.test.ts +++ b/sdks/node/test/core/logger.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, it } from "vitest"; -import { createLogger, setLogLevel, setLogSink } from "../src/index"; +import { createLogger, setLogLevel, setLogSink } from "../../src/index"; let lines: string[]; diff --git a/sdks/node/test/options.test.ts b/sdks/node/test/core/options.test.ts similarity index 97% rename from sdks/node/test/options.test.ts rename to sdks/node/test/core/options.test.ts index 50a80e3c..d08ecd0e 100644 --- a/sdks/node/test/options.test.ts +++ b/sdks/node/test/core/options.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/roundtrip.test.ts b/sdks/node/test/core/roundtrip.test.ts similarity index 95% rename from sdks/node/test/roundtrip.test.ts rename to sdks/node/test/core/roundtrip.test.ts index 7ee514d9..c2ee6c66 100644 --- a/sdks/node/test/roundtrip.test.ts +++ b/sdks/node/test/core/roundtrip.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/serializer.test.ts b/sdks/node/test/core/serializer.test.ts similarity index 98% rename from sdks/node/test/serializer.test.ts rename to sdks/node/test/core/serializer.test.ts index f7eaa650..215a407f 100644 --- a/sdks/node/test/serializer.test.ts +++ b/sdks/node/test/core/serializer.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { JsonSerializer, MsgpackSerializer, Queue, type Worker } from "../src/index"; +import { JsonSerializer, MsgpackSerializer, Queue, type Worker } from "../../src/index"; describe("JsonSerializer", () => { it("round-trips structured values", () => { diff --git a/sdks/node/test/typed.test.ts b/sdks/node/test/core/typed.test.ts similarity index 93% rename from sdks/node/test/typed.test.ts rename to sdks/node/test/core/typed.test.ts index 9cbc76e2..8be14435 100644 --- a/sdks/node/test/typed.test.ts +++ b/sdks/node/test/core/typed.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/webhooks.test.ts b/sdks/node/test/core/webhooks.test.ts similarity index 97% rename from sdks/node/test/webhooks.test.ts rename to sdks/node/test/core/webhooks.test.ts index 489a0d6f..98548f5b 100644 --- a/sdks/node/test/webhooks.test.ts +++ b/sdks/node/test/core/webhooks.test.ts @@ -5,7 +5,7 @@ import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, WebhookValidationError, type Worker } from "../src/index"; +import { Queue, WebhookValidationError, type Worker } from "../../src/index"; let worker: Worker | undefined; let target: Server | undefined; diff --git a/sdks/node/test/dashboard.test.ts b/sdks/node/test/dashboard/server.test.ts similarity index 98% rename from sdks/node/test/dashboard.test.ts rename to sdks/node/test/dashboard/server.test.ts index 8f3f00c8..924b9316 100644 --- a/sdks/node/test/dashboard.test.ts +++ b/sdks/node/test/dashboard/server.test.ts @@ -7,9 +7,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; -import { Queue, serveDashboard, type Worker } from "../src/index"; +import { Queue, serveDashboard, type Worker } from "../../src/index"; -const pkgRoot = fileURLToPath(new URL("..", import.meta.url)); +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); const staticDir = join(pkgRoot, "static", "dashboard"); beforeAll(() => { diff --git a/sdks/node/test/cli.test.ts b/sdks/node/test/integrations/cli.test.ts similarity index 92% rename from sdks/node/test/cli.test.ts rename to sdks/node/test/integrations/cli.test.ts index 4d0cd43c..74817d32 100644 --- a/sdks/node/test/cli.test.ts +++ b/sdks/node/test/integrations/cli.test.ts @@ -5,9 +5,9 @@ import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; -const pkgRoot = fileURLToPath(new URL("..", import.meta.url)); -const cliPath = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); -const indexUrl = pathToFileURL(fileURLToPath(new URL("../dist/index.js", import.meta.url))).href; +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const cliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url)); +const indexUrl = pathToFileURL(fileURLToPath(new URL("../../dist/index.js", import.meta.url))).href; beforeAll(() => { if (!existsSync(cliPath)) { diff --git a/sdks/node/test/contrib-express.test.ts b/sdks/node/test/integrations/express.test.ts similarity index 96% rename from sdks/node/test/contrib-express.test.ts rename to sdks/node/test/integrations/express.test.ts index ce34066c..ee84ef37 100644 --- a/sdks/node/test/contrib-express.test.ts +++ b/sdks/node/test/integrations/express.test.ts @@ -4,8 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import express from "express"; import { afterEach, expect, it } from "vitest"; -import { taskitoDashboard, taskitoRouter } from "../src/contrib/express"; -import { Queue, type Worker } from "../src/index"; +import { taskitoDashboard, taskitoRouter } from "../../src/contrib/express"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; let close: (() => Promise) | undefined; diff --git a/sdks/node/test/contrib-fastify.test.ts b/sdks/node/test/integrations/fastify.test.ts similarity index 95% rename from sdks/node/test/contrib-fastify.test.ts rename to sdks/node/test/integrations/fastify.test.ts index 9b81f2d2..8eff36df 100644 --- a/sdks/node/test/contrib-fastify.test.ts +++ b/sdks/node/test/integrations/fastify.test.ts @@ -4,8 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import Fastify from "fastify"; import { afterEach, expect, it } from "vitest"; -import { taskitoDashboardPlugin, taskitoFastify } from "../src/contrib/fastify"; -import { Queue, type Worker } from "../src/index"; +import { taskitoDashboardPlugin, taskitoFastify } from "../../src/contrib/fastify"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/contrib-nest.test.ts b/sdks/node/test/integrations/nest.test.ts similarity index 91% rename from sdks/node/test/contrib-nest.test.ts rename to sdks/node/test/integrations/nest.test.ts index 9029ef50..63ce878d 100644 --- a/sdks/node/test/contrib-nest.test.ts +++ b/sdks/node/test/integrations/nest.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Test } from "@nestjs/testing"; import { afterEach, expect, it } from "vitest"; -import { TaskitoModule, TaskitoService } from "../src/contrib/nest"; -import { Queue, type Worker } from "../src/index"; +import { TaskitoModule, TaskitoService } from "../../src/contrib/nest"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/contrib-otel.test.ts b/sdks/node/test/observability/otel.test.ts similarity index 97% rename from sdks/node/test/contrib-otel.test.ts rename to sdks/node/test/observability/otel.test.ts index 1661ca11..ce1dd014 100644 --- a/sdks/node/test/contrib-otel.test.ts +++ b/sdks/node/test/observability/otel.test.ts @@ -9,8 +9,8 @@ import { trace, } from "@opentelemetry/api"; import { afterEach, beforeEach, expect, it } from "vitest"; -import { otelMiddleware } from "../src/contrib/otel"; -import { Queue, type Worker } from "../src/index"; +import { otelMiddleware } from "../../src/contrib/otel"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/contrib-prometheus.test.ts b/sdks/node/test/observability/prometheus.test.ts similarity index 96% rename from sdks/node/test/contrib-prometheus.test.ts rename to sdks/node/test/observability/prometheus.test.ts index c6590c07..cdba77d5 100644 --- a/sdks/node/test/contrib-prometheus.test.ts +++ b/sdks/node/test/observability/prometheus.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Registry } from "prom-client"; import { afterEach, expect, it } from "vitest"; -import { PrometheusStatsCollector, prometheusMiddleware } from "../src/contrib/prometheus"; -import { Queue, type Worker } from "../src/index"; +import { PrometheusStatsCollector, prometheusMiddleware } from "../../src/contrib/prometheus"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/contrib-sentry.test.ts b/sdks/node/test/observability/sentry.test.ts similarity index 96% rename from sdks/node/test/contrib-sentry.test.ts rename to sdks/node/test/observability/sentry.test.ts index 9e3f4011..4b54f02a 100644 --- a/sdks/node/test/contrib-sentry.test.ts +++ b/sdks/node/test/observability/sentry.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import type { ErrorEvent } from "@sentry/node"; import * as Sentry from "@sentry/node"; import { afterEach, expect, it } from "vitest"; -import { sentryMiddleware } from "../src/contrib/sentry"; -import { Queue, type Worker } from "../src/index"; +import { sentryMiddleware } from "../../src/contrib/sentry"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/events.test.ts b/sdks/node/test/worker/events.test.ts similarity index 98% rename from sdks/node/test/events.test.ts rename to sdks/node/test/worker/events.test.ts index 6f0a2799..3a7e52e9 100644 --- a/sdks/node/test/events.test.ts +++ b/sdks/node/test/worker/events.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { type Middleware, type OutcomeEvent, Queue, type Worker } from "../src/index"; +import { type Middleware, type OutcomeEvent, Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/mesh.test.ts b/sdks/node/test/worker/mesh.test.ts similarity index 94% rename from sdks/node/test/mesh.test.ts rename to sdks/node/test/worker/mesh.test.ts index 155fcf3d..06703aba 100644 --- a/sdks/node/test/mesh.test.ts +++ b/sdks/node/test/worker/mesh.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/periodic.test.ts b/sdks/node/test/worker/periodic.test.ts similarity index 96% rename from sdks/node/test/periodic.test.ts rename to sdks/node/test/worker/periodic.test.ts index abafe584..3ba0476f 100644 --- a/sdks/node/test/periodic.test.ts +++ b/sdks/node/test/worker/periodic.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows-conditions.test.ts b/sdks/node/test/workflows/conditions.test.ts similarity index 98% rename from sdks/node/test/workflows-conditions.test.ts rename to sdks/node/test/workflows/conditions.test.ts index 1c095456..66013ed3 100644 --- a/sdks/node/test/workflows-conditions.test.ts +++ b/sdks/node/test/workflows/conditions.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows.test.ts b/sdks/node/test/workflows/dag.test.ts similarity index 97% rename from sdks/node/test/workflows.test.ts rename to sdks/node/test/workflows/dag.test.ts index 13266b73..2fe4bd19 100644 --- a/sdks/node/test/workflows.test.ts +++ b/sdks/node/test/workflows/dag.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows-fanout.test.ts b/sdks/node/test/workflows/fanout.test.ts similarity index 98% rename from sdks/node/test/workflows-fanout.test.ts rename to sdks/node/test/workflows/fanout.test.ts index 7764dfc2..a7d27a11 100644 --- a/sdks/node/test/workflows-fanout.test.ts +++ b/sdks/node/test/workflows/fanout.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows-gates.test.ts b/sdks/node/test/workflows/gates.test.ts similarity index 98% rename from sdks/node/test/workflows-gates.test.ts rename to sdks/node/test/workflows/gates.test.ts index d7e552b8..972a70c1 100644 --- a/sdks/node/test/workflows-gates.test.ts +++ b/sdks/node/test/workflows/gates.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows-saga.test.ts b/sdks/node/test/workflows/saga.test.ts similarity index 98% rename from sdks/node/test/workflows-saga.test.ts rename to sdks/node/test/workflows/saga.test.ts index 8d1e382b..ea3157f6 100644 --- a/sdks/node/test/workflows-saga.test.ts +++ b/sdks/node/test/workflows/saga.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows-subworkflows.test.ts b/sdks/node/test/workflows/subworkflows.test.ts similarity index 98% rename from sdks/node/test/workflows-subworkflows.test.ts rename to sdks/node/test/workflows/subworkflows.test.ts index b2943051..93ca3f30 100644 --- a/sdks/node/test/workflows-subworkflows.test.ts +++ b/sdks/node/test/workflows/subworkflows.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; From 22a24819fa348fed2c248f68cd183d7a2dc049de Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 16/25] chore: add Node SDK lint + typecheck pre-commit hooks --- .pre-commit-config.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 447a9437..90423d22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,20 @@ repos: types: [python] pass_filenames: false + - id: node-lint + name: node lint (biome) + entry: bash -c 'cd sdks/node && pnpm exec biome ci src test' + language: system + files: ^sdks/node/(src/|test/|biome\.json) + pass_filenames: false + + - id: node-typecheck + name: node typecheck (tsc) + entry: bash -c 'cd sdks/node && pnpm exec tsc --noEmit -p tsconfig.json' + language: system + files: ^sdks/node/(src/|test/|tsconfig\.json|package\.json) + pass_filenames: false + - id: dashboard-lint name: dashboard lint (biome) entry: bash -c 'cd dashboard && pnpm exec biome ci src/' From 03d7b50e1f1b216de6e451a49c0febd09d0183f3 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:40 +0530 Subject: [PATCH 17/25] docs(node): document workflow conditions/gates/sub-workflows/saga --- NODE_SDK_TODO.md | 61 ++++--- docs/content/docs/node/index.mdx | 11 +- .../docs/node/workflows/conditions.mdx | 116 ++++++++++++++ docs/content/docs/node/workflows/fan-out.mdx | 7 - docs/content/docs/node/workflows/gates.mdx | 119 ++++++++++++++ docs/content/docs/node/workflows/index.mdx | 27 +++- docs/content/docs/node/workflows/meta.json | 2 +- docs/content/docs/node/workflows/saga.mdx | 151 ++++++++++++++++++ .../docs/node/workflows/sub-workflows.mdx | 131 +++++++++++++++ sdks/node/README.md | 74 ++++++++- sdks/node/src/workflows/manager.ts | 6 +- 11 files changed, 641 insertions(+), 64 deletions(-) create mode 100644 docs/content/docs/node/workflows/conditions.mdx create mode 100644 docs/content/docs/node/workflows/gates.mdx create mode 100644 docs/content/docs/node/workflows/saga.mdx create mode 100644 docs/content/docs/node/workflows/sub-workflows.mdx diff --git a/NODE_SDK_TODO.md b/NODE_SDK_TODO.md index 2dc7c970..3b03ffe9 100644 --- a/NODE_SDK_TODO.md +++ b/NODE_SDK_TODO.md @@ -35,6 +35,7 @@ SDK, zero Python dependency, over the shared Rust core. | **Workflows: fan-out / fan-in** | (this branch) | TS `WorkflowTracker` brain (`src/workflows/tracker.ts`) driven by the outcome callback; `.fanOut()` / `.fanIn()` builder steps; napi primitives `expandFanOut`/`checkFanOutCompletion`/`createDeferredJob`/`finalizeRunIfTerminal`/`getWorkflowRunPlan`/`workflowNodeForJob`/`cascadeSkipPending`; deferred-node submit. Storage-reconstructable (no submit-time registration) | | **Dashboard DAG completeness** | (this branch) | `JsWorkflowNode` carries `fanOutCount` + `compensation_*`; `/dag` handler enriches the raw graph with per-node `deps[]` + live `status` + job-id `id` so the SPA visualizer renders edges/colours/links | | **Contrib integrations** | (this branch) | `src/contrib/{otel,prometheus,express,fastify,nest,sentry}.ts` — each an optional subpath export (`taskito/contrib/*`) with an optional peer dep. OTel + Prometheus + Sentry = `Middleware` over the events layer; Express/Fastify = REST router (shared `rest.ts` table) + dashboard mount (via extracted `createDashboardHandler`); Nest = `TaskitoModule.forRoot` + injectable `TaskitoService`. 17 new vitest tests | +| **Workflows: conditions / gates / sub-workflows / saga** | (this branch) | `WorkflowTracker` drives all deferred kinds. Conditions (`on_success`/`on_failure`/`always`), approval gates (`.gate`, `resolveGate`/`approveGate`/`rejectGate`, timeouts), nested sub-workflows (`.subWorkflow`, parent linkage + storage-driven child→parent resolve), saga compensation (`.step({compensate})`, reverse-dependency storage-driven rollback). `StepMetadata` + napi (`skip`/gate/sub-workflow/saga) extended. 12 new tests | **Verify everything green:** ```bash @@ -82,33 +83,31 @@ relying on esbuild decorator-metadata emission (`experimentalDecorators` on; bio `unsafeParameterDecoratorsEnabled`). Sentry captures the real exception from `onError` and reports it on dead-letter (one event/dead job), optional per-retry warnings. -### 3. Advanced workflow features — LARGE (fan-out DONE; gates / sub-workflows / saga remain) - -DAG/linear + **fan-out / fan-in** work. The tracker-brain foundation is built: -a TS `WorkflowTracker` (`src/workflows/tracker.ts`) reacts to the worker outcome -callback, reconstructs the run plan from storage (DAG + step metadata — no -submit-time registration), and drives on-demand orchestration via napi -primitives (`expandFanOut`, `checkFanOutCompletion`, `createDeferredJob`, -`finalizeRunIfTerminal`, `getWorkflowRunPlan`, `workflowNodeForJob`, -`cascadeSkipPending`). `submit_workflow` takes `deferredNodeNames` (fan-out / -fan-in ∪ descendants get a node but no static job). Failures are fail-fast. - -**Still unbound: gates/conditions, sub-workflows, saga compensation.** They build -on this same foundation (the Python references are -`crates/taskito-python/src/py_queue/workflow_ops/{gates,saga}.rs` + the Python -`WorkflowTracker`): -- **gates/conditions** — a deferred node enters `waiting_approval`; resolve via a - new `resolveWorkflowGate` napi method + a JS-side timer (`setTimeout`) for - timeouts. Conditions = a `should_execute` check in `evaluateSuccessors`. -- **sub-workflows** — extend `submit_workflow` with `parent_run_id` / - `parent_node_name`; the tracker submits a child run for a sub-workflow node and - resolves the parent node when the child finalizes (populates `/children`). -- **saga** — reverse-topo compensation waves driven by the tracker; needs the - `setWorkflowNodeCompensation*` + run-state napi setters bound (storage methods - already exist on the `WorkflowStorage` trait). - -**Where:** `crates/taskito-node/src/queue/workflows.rs` + `src/workflows/tracker.ts`. -**Effort:** medium each now the brain exists. Each is a separate feature/commit. +### 3. Advanced workflow features — DONE + +The TS `WorkflowTracker` (`src/workflows/tracker.ts`) reconstructs the run plan from +storage and drives every deferred step kind off the worker outcome stream: +- **fan-out / fan-in** — `expandFanOut` / `checkFanOutCompletion` + collector. +- **conditions** — `.step({ condition })` (`on_success`/`on_failure`/`always`); + `shouldExecute` gates dispatch in `evaluateSuccessors`, skips propagate via + `skipWorkflowNode`. (String predicates only — JS callables can't cross the + storage-reconstructable boundary.) +- **gates** — `.gate({ timeoutMs, onTimeout, message })` parks at `waiting_approval`; + `setWorkflowNodeWaitingApproval` + `resolveWorkflowGate` napi, JS `setTimeout` + (unref'd) for timeouts; `queue.workflows.resolveGate`/`approveGate`/`rejectGate` + resolve from any process (idempotent via a terminal-status guard). +- **sub-workflows** — `.subWorkflow({ workflow })`; child spec persisted as base64 + `SubWorkflowTransport` in `sub_workflow` metadata; `submit_workflow` gained + `parent_run_id`/`parent_node_name`; child→parent resolution is storage-driven + (read parent linkage on child finalize → `resolveWorkflowGate`). Nesting supported. +- **saga** — `.step({ compensate })`; on a failed run the tracker re-derives the next + compensable nodes from node status each rollback outcome (reverse-dependency, + storage-driven, no in-memory waves), enqueues idempotent rollback jobs + (`enqueueCompensation`, dedup `unique_key`), routes their outcomes via + `compensationNodeForJob`, and finalizes `compensated` / `compensation_failed`. + +`StepMetadata` (taskito-workflows crate) gained `gate` / `sub_workflow` / `compensate` +fields (JSON blob, no schema migration; `#[derive(Default)]` + spread literals). ### 4. Dashboard workflows DAG panel completeness — DONE @@ -238,7 +237,7 @@ pnpm run build:native # napi build, all features pnpm run build:ts # tsup dual ESM/CJS + .d.ts pnpm typecheck # tsc --noEmit (includes test/) pnpm lint # biome -pnpm test # vitest (69 tests) +pnpm test # vitest (81 tests) pnpm run build:dashboard # build the React SPA into static/dashboard ``` @@ -262,11 +261,7 @@ sdks/node/src/ dashboard/{server,routes,handlers,contract,static,metrics,api,index}.ts contrib/{otel,prometheus,sentry,express,fastify,nest,rest}.ts # optional subpath exports cli/{index,connect,output,commands/*}.ts -<<<<<<< HEAD -sdks/node/test/*.test.ts # grouped by feature area -======= -sdks/node/test/*.test.ts # 22 files, 69 tests ->>>>>>> 9394bcb (docs(node): document Sentry contrib middleware) +sdks/node/test/{core,worker,observability,integrations,workflows,dashboard}/*.test.ts # grouped by feature area ``` Memory: see `.claude/memory/session-history.md` (Node SDK section) for the running diff --git a/docs/content/docs/node/index.mdx b/docs/content/docs/node/index.mdx index 6114b85f..64bb0317 100644 --- a/docs/content/docs/node/index.mdx +++ b/docs/content/docs/node/index.mdx @@ -60,9 +60,9 @@ new Queue({ backend: "redis", dsn: "redis://localhost" }); // Redis - Gates, sub-workflows, and saga compensation are not yet available in the Node - SDK. Use the [Python SDK](/guides/workflows/gates) in the meantime. - diff --git a/docs/content/docs/node/workflows/conditions.mdx b/docs/content/docs/node/workflows/conditions.mdx new file mode 100644 index 00000000..ccb698d2 --- /dev/null +++ b/docs/content/docs/node/workflows/conditions.mdx @@ -0,0 +1,116 @@ +--- +title: Conditions +description: "Run a step only when its predecessors succeeded, failed, or either." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Every `.step()` runs by default only when all of its predecessors completed +successfully. Pass `condition` to change that: + +```ts +const handle = queue.workflows + .define("resilient-pipeline") + .step("riskyStep", "riskyTask") + .step("celebrate", "celebrateTask", { + after: "riskyStep", + condition: "on_success", // default — only runs if riskyStep completed + }) + .step("recover", "recoverTask", { + after: "riskyStep", + condition: "on_failure", // only runs if riskyStep dead-lettered + }) + .submit(); + +queue.runWorker(); + +const run = await handle.wait(); +console.log(run.state); // "failed" — even if recover ran (see below) +``` + +## Condition values + +| Value | When the step runs | +|---|---| +| `"on_success"` | All predecessors completed successfully (default) | +| `"on_failure"` | At least one predecessor dead-lettered | +| `"always"` | Regardless of predecessor outcome | + +A step whose condition is not met transitions to `skipped`. Skipped steps +propagate: all of their descendants are skipped too, unless those descendants +have a separate predecessor that did complete. + +## How it works + +The `WorkflowTracker` evaluates conditions when a predecessor node settles. It +reads the run plan from storage, checks each dependent node's condition against +the settled outcome, and either creates the deferred job or marks the node +`skipped`. Because the tracker reconstructs run state from storage on every +event, submit and execute may be different processes. + + celebrate["celebrate\n(on_success)"] + riskyStep --> recover["recover\n(on_failure)"] + + classDef skip fill:#fef9c3,color:#713f12,stroke:#ca8a04 + classDef run fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef fail fill:#fee2e2,color:#7f1d1d,stroke:#ef4444`} +/> + +When `riskyStep` fails: `celebrate` → `skipped`, `recover` → enqueued and runs. +When `riskyStep` succeeds: `recover` → `skipped`, `celebrate` → enqueued and runs. + +## Conditions are string predicates only + +`condition` accepts only the three string literals above — you cannot pass a +JavaScript function. Conditions are evaluated by the `WorkflowTracker` at +runtime, which runs inside the worker process and may differ from the process +that called `.submit()`. Because the workflow definition is serialised to and +reconstructed from storage, a JS closure cannot cross that boundary. If you need +dynamic branching, encode the decision in the task's return value and gate +downstream work on that (for example, use a fan-out with a zero-item array to +short-circuit a branch). + +## A failed run stays failed + +When `riskyStep` fails and `recover` runs, the workflow run still ends in state +`"failed"`. The `on_failure` handler executes but does not "recover" the run — +it is an error-handling side effect, not a circuit-breaker. To treat a failure +as a non-fatal branch, model it differently (for example, wrap the risky logic +in a task that catches internally and returns a status sentinel). + + + To roll back already-completed steps when a run fails, use + [Saga compensation](/node/workflows/saga) instead of `on_failure` conditions. + + +## `always` steps + +Use `"always"` for teardown or notification steps that should run regardless of +the upstream outcome: + +```ts +queue.workflows + .define("with-cleanup") + .step("provision", "provisionTask") + .step("work", "workTask", { after: "provision" }) + .step("cleanup", "cleanupTask", { + after: "work", + condition: "always", + }) + .submit(); +``` + +`cleanup` runs whether `work` succeeded or failed. + +## Step options + +| Option | Type | Description | +|---|---|---| +| `after` | `string \| string[]` | Predecessor node name(s) | +| `condition` | `"on_success" \| "on_failure" \| "always"` | Condition under which this step runs (default `"on_success"`) | +| `maxRetries` | `number` | Retry limit | +| `timeoutMs` | `number` | Per-attempt timeout | +| `priority` | `number` | Queue priority | +| `queue` | `string` | Queue name | diff --git a/docs/content/docs/node/workflows/fan-out.mdx b/docs/content/docs/node/workflows/fan-out.mdx index 40e44e80..471334a9 100644 --- a/docs/content/docs/node/workflows/fan-out.mdx +++ b/docs/content/docs/node/workflows/fan-out.mdx @@ -162,10 +162,3 @@ if (run.state === "failed") { console.error("failed nodes:", failed.map(n => n.nodeName)); } ``` - - - Gates, sub-workflows, and saga compensation are not yet available in the Node - SDK. For those, use the Python SDK — see [gates](/guides/workflows/gates), - [composition](/guides/workflows/composition), and - [sagas](/guides/workflows/sagas). - diff --git a/docs/content/docs/node/workflows/gates.mdx b/docs/content/docs/node/workflows/gates.mdx new file mode 100644 index 00000000..2dca6e20 --- /dev/null +++ b/docs/content/docs/node/workflows/gates.mdx @@ -0,0 +1,119 @@ +--- +title: Approval Gates +description: "Pause a workflow run until a human or external system approves or rejects." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +An approval gate pauses a workflow run until something external resolves it. +No task runs at the gate — the run simply waits. + +```ts +const handle = queue.workflows + .define("release-pipeline") + .step("build", "buildTask") + .gate("approve-deploy", { + after: "build", + message: "Review build artifacts before deploying to production.", + timeoutMs: 24 * 60 * 60 * 1000, // 24 h + onTimeout: "reject", + }) + .step("deploy", "deployTask", { after: "approve-deploy" }) + .submit(); + +queue.runWorker(); + +// Later — from any process that has access to the same storage: +queue.workflows.approveGate(handle.runId, "approve-deploy"); +``` + + gate["approve-deploy\n(waiting_approval)"]:::waiting + gate -- approved --> deploy:::run + gate -- rejected --> deploy_skip["deploy\n(skipped)"]:::skip + + classDef done fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef waiting fill:#fef9c3,color:#713f12,stroke:#ca8a04 + classDef run fill:#bfdbfe,color:#1e3a8a,stroke:#3b82f6 + classDef skip fill:#f3f4f6,color:#6b7280,stroke:#9ca3af`} +/> + +## Gate node status + +While a gate is pending, its node status is `waiting_approval`. The run itself +stays in `running` state. Downstream steps do not start until the gate resolves. + +```ts +const nodes = handle.nodes(); +const gate = nodes.find(n => n.nodeName === "approve-deploy"); +console.log(gate?.status); // "waiting_approval" +``` + +## Resolving a gate + +Get the `runId` from the submit handle and call one of the three resolution +methods. Resolution works from any process that opens the same storage — the +resolver does not need to be the worker process. + +```ts +// Approve — downstream steps are enqueued. +queue.workflows.approveGate(runId, "approve-deploy"); + +// Reject — downstream steps are skipped, run transitions to "failed". +queue.workflows.rejectGate(runId, "approve-deploy"); + +// Reject with a reason recorded in storage. +queue.workflows.rejectGate(runId, "approve-deploy", "Artifacts failed QA review."); + +// Unified helper — pass a boolean. +queue.workflows.resolveGate(runId, "approve-deploy", true); // approve +queue.workflows.resolveGate(runId, "approve-deploy", false, "Reason."); // reject +``` + +## Timeout behaviour + +When `timeoutMs` elapses the gate auto-resolves according to `onTimeout`: + +| `onTimeout` | Effect | +|---|---| +| `"reject"` (default) | Same as calling `rejectGate` — downstream skipped, run `failed` | +| `"approve"` | Same as calling `approveGate` — downstream proceeds | + +Setting `timeoutMs` without `onTimeout` defaults to `"reject"`. + +## Gate options + +| Option | Type | Default | Description | +|---|---|---|---| +| `after` | `string \| string[]` | — | Predecessor node name(s) | +| `message` | `string` | — | Human-readable description shown in the dashboard | +| `timeoutMs` | `number` | — | Auto-resolve after this many milliseconds | +| `onTimeout` | `"approve" \| "reject"` | `"reject"` | Resolution on timeout | + +## Rejection behaviour + +Rejecting a gate marks the gate node `failed` and all downstream nodes +`skipped`. The workflow run ends in state `"failed"`. There is no partial +recovery — if you need a rejection branch, use +[conditions](/node/workflows/conditions) (an `on_failure` step) downstream. + + + Calling `approveGate` or `rejectGate` on a gate that has already been + resolved (approved, rejected, or timed out) is a no-op — the tracker ignores + stale resolutions. + + +## Webhooks and external triggers + +Because resolution is just an API call over shared storage, you can drive gates +from any external system. A common pattern is an HTTP endpoint in your API +server that receives a webhook and calls `approveGate`: + +```ts +app.post("/hooks/deploy-approval", async (req, res) => { + const { runId, approved, reason } = req.body; + queue.workflows.resolveGate(runId, "approve-deploy", approved, reason); + res.sendStatus(204); +}); +``` diff --git a/docs/content/docs/node/workflows/index.mdx b/docs/content/docs/node/workflows/index.mdx index 857e4138..21b8d1f3 100644 --- a/docs/content/docs/node/workflows/index.mdx +++ b/docs/content/docs/node/workflows/index.mdx @@ -34,11 +34,24 @@ Requires the addon built with the `workflows` cargo feature (enabled by href="/node/workflows/fan-out" description="Split a step's result into parallel children and collect results." /> + + + + - - - Gates, sub-workflows, and saga compensation are not yet available in the Node - SDK. See the Python SDK for [gates](/guides/workflows/gates), - [composition](/guides/workflows/composition), and - [sagas](/guides/workflows/sagas). - diff --git a/docs/content/docs/node/workflows/meta.json b/docs/content/docs/node/workflows/meta.json index bf59b7cc..78b4fc06 100644 --- a/docs/content/docs/node/workflows/meta.json +++ b/docs/content/docs/node/workflows/meta.json @@ -1,4 +1,4 @@ { "title": "Workflows", - "pages": ["index", "fan-out"] + "pages": ["index", "fan-out", "conditions", "gates", "sub-workflows", "saga"] } diff --git a/docs/content/docs/node/workflows/saga.mdx b/docs/content/docs/node/workflows/saga.mdx new file mode 100644 index 00000000..6e09ad05 --- /dev/null +++ b/docs/content/docs/node/workflows/saga.mdx @@ -0,0 +1,151 @@ +--- +title: Saga Compensation +description: "Automatically roll back completed steps in reverse order when a workflow run fails." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Saga compensation runs rollback tasks in reverse-dependency order when a +workflow run fails. Each step that completed and has a `compensate` task gets +rolled back; steps that never ran are left alone. + +## Basic example + +```ts +queue.task("reserveInventory", (orderId: string) => ({ reserved: true, orderId })); +queue.task("unreserveInventory", (result: { reserved: boolean; orderId: string }) => { /* undo */ }); + +queue.task("chargePayment", (orderId: string) => ({ charged: true, orderId })); +queue.task("refundPayment", (result: { charged: boolean; orderId: string }) => { /* undo */ }); + +queue.task("shipOrder", (orderId: string) => { throw new Error("Warehouse offline"); }); +// No compensate for ship — if it never succeeded, there's nothing to undo. + +const handle = queue.workflows + .define("checkout") + .step("reserve", "reserveInventory", { compensate: "unreserveInventory" }) + .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" }) + .step("ship", "shipOrder", { after: "charge" }) + .submit(); + +queue.runWorker(); + +const run = await handle.wait(); +// ship fails → charge rolled back (refundPayment) → reserve rolled back (unreserveInventory) +// run.state === "compensated" +``` + + charge --> ship + ship -. "run fails" .-> refund + refund --> unreserve + + classDef done fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef fail fill:#fee2e2,color:#7f1d1d,stroke:#ef4444 + classDef comp fill:#ede9fe,color:#4c1d95,stroke:#7c3aed`} +/> + +## How it works + +1. `ship` dead-letters — the run's failure is detected by the tracker. +2. The tracker collects every completed node that has a `compensate` task, + ordered in reverse-dependency order (most downstream first). +3. It enqueues each compensate task, passing the forward step's result as the + single argument. Compensate tasks are ordinary registered tasks — they run in + the worker pool like any other job. +4. Rollbacks execute sequentially in that reverse order. Each inherits the + forward step's `queue`, `maxRetries`, and `timeoutMs`. +5. When all rollbacks complete the run transitions to `"compensated"`. +6. If a rollback itself dead-letters, the run ends in `"compensation_failed"` — + remaining un-rolled-back steps are left as-is (fail-stop; no partial retry). + +Compensation is storage-driven and idempotent: each rollback enqueue is +deduplicated, so a worker restart during compensation does not double-run a +rollback. + +## Run terminal states + +| State | Meaning | +|---|---| +| `"completed"` | All steps succeeded | +| `"failed"` | A step failed; no compensators were defined | +| `"compensated"` | A step failed; all compensators completed | +| `"compensation_failed"` | A step failed; at least one compensator also failed | + + + `"compensation_failed"` is a manual-intervention state. Inspect + `handle.nodes()` to identify which rollback failed and whether data is + partially consistent. + + +## Compensate task signature + +The compensate task receives the forward step's return value as its single +positional argument. Design forward tasks to return enough context for the +compensator to act: + +```ts +queue.task("chargePayment", async (orderId: string) => { + const charge = await stripe.charge(orderId); + return { chargeId: charge.id, orderId }; // compensator needs chargeId +}); + +queue.task("refundPayment", async (result: { chargeId: string; orderId: string }) => { + await stripe.refund(result.chargeId); +}); +``` + +## Steps without compensators + +Steps that do not declare `compensate` are skipped during rollback. A step that +never ran (because it was downstream of the failing step) is also skipped — only +**completed** steps with a compensate task are rolled back. + +```ts +queue.workflows + .define("partial-saga") + .step("a", "taskA", { compensate: "undoA" }) // rolled back if run fails after a completes + .step("b", "taskB", { after: "a" }) // no compensate — left as-is + .step("c", "taskC", { after: "b", compensate: "undoC" }) + .submit(); + +// If b fails: only a is rolled back (b never completed; c never ran). +// If c fails: c has no result yet, so only a is rolled back. +``` + +## Step options + +| Option | Type | Description | +|---|---|---| +| `after` | `string \| string[]` | Predecessor node name(s) | +| `compensate` | `string` | Registered task name to run as the rollback for this step | +| `maxRetries` | `number` | Retry limit for the forward step (inherited by compensate) | +| `timeoutMs` | `number` | Timeout for the forward step (inherited by compensate) | +| `priority` | `number` | Queue priority | +| `queue` | `string` | Queue name | + +## Combining with conditions + +Conditions and compensation compose. An `on_failure` handler runs first (as the +run settles toward failure); once the run is failed, the tracker then rolls back +the completed compensable steps: + +```ts +queue.workflows + .define("safe-checkout") + .step("reserve", "reserveInventory", { compensate: "unreserveInventory" }) + .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" }) + .step("ship", "shipOrder", { after: "charge" }) + .step("notifyFailure", "sendFailureEmail", { + after: "ship", + condition: "on_failure", + }) + .submit(); +``` diff --git a/docs/content/docs/node/workflows/sub-workflows.mdx b/docs/content/docs/node/workflows/sub-workflows.mdx new file mode 100644 index 00000000..d9d20766 --- /dev/null +++ b/docs/content/docs/node/workflows/sub-workflows.mdx @@ -0,0 +1,131 @@ +--- +title: Sub-Workflows +description: "Compose workflow runs hierarchically — a parent step delegates to a child workflow." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +A sub-workflow step delegates to a fully independent child workflow run. The +parent node goes `running` while the child executes and resolves when the child +reaches a terminal state. + +## Basic composition + +Build the child spec with `.build()` (not `.submit()`) and pass it to +`.subWorkflow()` on the parent: + +```ts +// Child workflow spec — .build() returns a WorkflowSpec, does NOT submit. +const processChunk = queue.workflows + .define("process-chunk") + .step("validate", "validateTask") + .step("transform", "transformTask", { after: "validate" }) + .step("store", "storeTask", { after: "transform" }) + .build(); + +// Parent workflow. +const handle = queue.workflows + .define("full-pipeline") + .step("prepare", "prepareTask") + .subWorkflow("process", { + after: "prepare", + workflow: processChunk, + }) + .step("finish", "finishTask", { after: "process" }) + .submit(); + +queue.runWorker(); + +const run = await handle.wait(); +console.log(run.state); // "completed" | "failed" +``` + + process["process\n(sub-workflow)"]:::sub + subgraph child ["child run: process-chunk"] + v[validate] --> t[transform] --> s[store] + end + process -. spawns .-> child + process --> finish:::run + + classDef done fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef sub fill:#ede9fe,color:#4c1d95,stroke:#7c3aed + classDef run fill:#bfdbfe,color:#1e3a8a,stroke:#3b82f6`} +/> + +## How it works + +1. When `prepare` completes, the tracker creates the `process` node with kind + `sub_workflow` and submits the child as a linked run — a separate entry in + storage with its own nodes and state. +2. The parent `process` node status is `running` while the child is in flight. +3. The worker tracks the child run's outcome events. +4. When the child run finalises: + - **Child completed** → parent node transitions to `completed`; `finish` is + enqueued normally. + - **Child failed** → parent node transitions to `failed`; `finish` and any + other downstream nodes are `skipped`; the parent run ends `failed`. + +The child is a first-class run. It appears in dashboard queries, can be +inspected independently, and is visible via `queue.workflows.children()`. + +## Listing child runs + +```ts +const children = queue.workflows.children(handle.runId); +// WorkflowRun[] — each with id, state, parentRunId, parentNodeName, ... +``` + +## Nesting + +Child workflows may themselves contain sub-workflow steps. There is no enforced +depth limit, but deeply nested hierarchies make observability harder — prefer +fan-out for homogeneous parallel work and sub-workflows for distinct named +pipelines. + +A workflow's entry step must be a plain `.step` (a deferred kind — sub-workflow, +gate, fan-out, or conditioned step — only runs once a predecessor settles), so +give each sub-workflow an `after`: + +```ts +const inner = queue.workflows + .define("inner") + .step("a", "taskA") + .build(); + +const middle = queue.workflows + .define("middle") + .step("x", "taskX") + .subWorkflow("inner", { after: "x", workflow: inner }) + .build(); + +queue.workflows + .define("outer") + .step("init", "initTask") + .subWorkflow("middle", { after: "init", workflow: middle }) + .submit(); +``` + +## Sub-workflow options + +| Option | Type | Description | +|---|---|---| +| `after` | `string \| string[]` | Predecessor node name(s) | +| `workflow` | `WorkflowSpec` | Child workflow built with `.build()` | + + + Pass the result of `.build()`, not `.submit()`. Calling `.submit()` would + immediately enqueue the child as a standalone run unlinked from the parent. + + +## Failure propagation + +If the child run fails, the parent node is marked `failed` and the parent run +ends `failed`. The child's own node statuses are preserved and visible through +`queue.workflows.children()`. This mirrors how a failed step propagates through +the DAG — downstream steps are skipped. + +To handle child failures without failing the parent, wrap the sub-workflow node +in a conditional branch: place an `on_failure` step after it and an `on_success` +step for the happy path. See [Conditions](/node/workflows/conditions). diff --git a/sdks/node/README.md b/sdks/node/README.md index 82ee0ce2..1d512112 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -273,8 +273,73 @@ and run fail and downstream steps are skipped. An empty item list completes the fan-out immediately and runs the fan-in with `[]`. Children and the combiner each run on the fan-out step's `queue` / `maxRetries` / `timeoutMs` / `priority`. -Gates, sub-workflows, and saga compensation are not yet bound — for those, use -the Python SDK. +### Conditions + +A step's `condition` gates it on its predecessors' outcomes: `on_success` +(default — all predecessors completed), `on_failure` (a predecessor failed — an +error handler), or `always`. A step whose condition isn't met is `skipped`, and +the skip propagates downstream. + +```ts +queue.workflows + .define("with-handler") + .step("risky", "risky") + .step("recover", "rollback", { after: "risky", condition: "on_failure" }) + .step("notify", "notifyOk", { after: "risky", condition: "on_success" }) + .submit(); +``` + +### Approval gates + +A `gate` step pauses the run (`waiting_approval`) until resolved out-of-band. +Resolve from any process (it reads the plan from storage); an optional timeout +auto-resolves per `onTimeout`. + +```ts +const handle = queue.workflows + .define("publish") + .step("build", "build") + .gate("review", { after: "build", timeoutMs: 86_400_000, onTimeout: "reject" }) + .step("ship", "ship", { after: "review" }) + .submit(); + +queue.workflows.approveGate(handle.runId, "review"); // or rejectGate(..., reason) +``` + +### Sub-workflows + +A `subWorkflow` step runs a child workflow as a node; the parent advances when +the child finalizes (child failure fails the parent node). Build the child with +`.build()` (don't submit it directly). + +```ts +const child = queue.workflows.define("child").step("a", "taskA").build(); + +queue.workflows + .define("parent") + .step("prep", "prep") + .subWorkflow("sub", { after: "prep", workflow: child }) + .step("finish", "finish", { after: "sub" }) + .submit(); + +queue.workflows.children(handle.runId); // the spawned child run(s) +``` + +### Saga compensation + +Give a step a `compensate` task and, if the run fails, the tracker rolls back +each completed compensable step in reverse-dependency order, passing the step's +result to its compensator. The run ends `compensated`, or `compensation_failed` +if a rollback itself fails. + +```ts +queue.workflows + .define("checkout") + .step("reserve", "reserve", { compensate: "unreserve" }) + .step("charge", "charge", { after: "reserve", compensate: "refund" }) + .step("ship", "ship", { after: "charge" }) // if this fails → refund, then unreserve + .submit(); +``` ## Dashboard @@ -405,6 +470,5 @@ compiled in via `--features postgres,redis`. ## Not yet covered -Advanced workflow features (gates, sub-workflows, saga compensation — fan-out is -covered above), resources/proxies/interception, prebuilt platform binaries + npm -publish (host-only build for now), and Python⇄Node cross-language interop. +Resources / dependency-injection, prebuilt platform binaries + npm publish +(host-only build for now), and Python⇄Node cross-language interop. diff --git a/sdks/node/src/workflows/manager.ts b/sdks/node/src/workflows/manager.ts index 6bf191b9..74b4f7ec 100644 --- a/sdks/node/src/workflows/manager.ts +++ b/sdks/node/src/workflows/manager.ts @@ -30,9 +30,9 @@ const DEFAULT_WAIT_POLL_MS = 100; * The `queue.workflows` facade: define and submit workflows, then query runs. * * Supports DAG / linear workflows (steps pre-enqueued with `depends_on` chains, - * sequenced by the core scheduler) plus fan-out / fan-in steps the worker-side - * tracker expands at runtime (see `tracker.ts`). Gates, sub-workflows, and saga - * compensation are not yet available in the Node SDK. + * sequenced by the core scheduler) plus the deferred step kinds the worker-side + * tracker drives at runtime (see `tracker.ts`): fan-out / fan-in, conditions, + * approval gates, sub-workflows, and saga compensation. */ export class WorkflowManager { constructor( From e079e29d48a11e612ecc005c63d367bbabf04b1d Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:43:40 +0530 Subject: [PATCH 18/25] fix(node): guard contrib query flattening against prototype pollution Shared flattenQueryParams uses a null-proto object and skips __proto__/constructor/prototype, so user-controlled query keys can't pollute. Resolves CodeQL remote-property-injection in express/fastify. --- sdks/node/src/contrib/express.ts | 17 ++--------------- sdks/node/src/contrib/fastify.ts | 17 ++--------------- sdks/node/src/contrib/rest.ts | 23 +++++++++++++++++++++++ sdks/node/test/integrations/rest.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 49 insertions(+), 30 deletions(-) create mode 100644 sdks/node/test/integrations/rest.test.ts diff --git a/sdks/node/src/contrib/express.ts b/sdks/node/src/contrib/express.ts index 018caabe..efcfd07d 100644 --- a/sdks/node/src/contrib/express.ts +++ b/sdks/node/src/contrib/express.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import express, { type RequestHandler, type Router } from "express"; import { createDashboardHandler } from "../dashboard"; import type { Queue } from "../queue"; -import { buildRestRoutes, type RestOptions, type RestRequest } from "./rest"; +import { buildRestRoutes, flattenQueryParams, type RestOptions, type RestRequest } from "./rest"; /** Options for {@link taskitoRouter}. */ export type TaskitoRouterOptions = RestOptions; @@ -19,19 +19,6 @@ export interface TaskitoDashboardOptions { staticDir?: string; } -/** Flatten Express's parsed query into plain `string | undefined` values. */ -function flattenQuery(query: unknown): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(query as Record)) { - if (typeof value === "string") { - out[key] = value; - } else if (Array.isArray(value) && typeof value[0] === "string") { - out[key] = value[0]; - } - } - return out; -} - /** * Build an Express {@link Router} exposing the Taskito REST API (enqueue, stats, job * lookup, cancel, dead-letters). JSON body parsing is mounted on the router itself. @@ -44,7 +31,7 @@ export function taskitoRouter(queue: Queue, options: TaskitoRouterOptions = {}): const handler: RequestHandler = async (req, res) => { const request: RestRequest = { params: req.params as Record, - query: flattenQuery(req.query), + query: flattenQueryParams(req.query), body: req.body, }; try { diff --git a/sdks/node/src/contrib/fastify.ts b/sdks/node/src/contrib/fastify.ts index 70a29964..574203b1 100644 --- a/sdks/node/src/contrib/fastify.ts +++ b/sdks/node/src/contrib/fastify.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import { createDashboardHandler } from "../dashboard"; import type { Queue } from "../queue"; -import { buildRestRoutes, type RestOptions, type RestRequest } from "./rest"; +import { buildRestRoutes, flattenQueryParams, type RestOptions, type RestRequest } from "./rest"; /** Options for {@link taskitoFastify}. */ export interface TaskitoFastifyOptions extends RestOptions { @@ -24,19 +24,6 @@ export interface TaskitoDashboardPluginOptions { staticDir?: string; } -/** Flatten Fastify's parsed query into plain `string | undefined` values. */ -function flattenQuery(query: unknown): Record { - const out: Record = {}; - for (const [key, value] of Object.entries((query as Record) ?? {})) { - if (typeof value === "string") { - out[key] = value; - } else if (Array.isArray(value) && typeof value[0] === "string") { - out[key] = value[0]; - } - } - return out; -} - /** * Fastify plugin exposing the Taskito REST API (enqueue, stats, job lookup, cancel, * dead-letters). Register with a `prefix` to mount the routes under a base path. @@ -53,7 +40,7 @@ export const taskitoFastify: FastifyPluginAsync = async ( handler: async (request: FastifyRequest, reply: FastifyReply) => { const request_: RestRequest = { params: request.params as Record, - query: flattenQuery(request.query), + query: flattenQueryParams(request.query), body: request.body, }; const result = await route.handle(queue, request_); diff --git a/sdks/node/src/contrib/rest.ts b/sdks/node/src/contrib/rest.ts index 1feb1463..1060d859 100644 --- a/sdks/node/src/contrib/rest.ts +++ b/sdks/node/src/contrib/rest.ts @@ -22,6 +22,29 @@ export interface RestRequest { body: unknown; } +// Keys that would pollute Object.prototype if assigned from user-controlled query. +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** + * Flatten a framework's parsed query (`string | string[]`) into plain + * `string | undefined` values. Uses a null-prototype object and skips + * prototype-polluting keys, so user-controlled query names are safe to assign. + */ +export function flattenQueryParams(query: unknown): Record { + const out: Record = Object.create(null); + for (const [key, value] of Object.entries((query as Record) ?? {})) { + if (FORBIDDEN_KEYS.has(key)) { + continue; + } + if (typeof value === "string") { + out[key] = value; + } else if (Array.isArray(value) && typeof value[0] === "string") { + out[key] = value[0]; + } + } + return out; +} + /** A framework-neutral response: HTTP status + a JSON-serializable body. */ export interface RestResponse { status: number; diff --git a/sdks/node/test/integrations/rest.test.ts b/sdks/node/test/integrations/rest.test.ts new file mode 100644 index 00000000..6e42c4d5 --- /dev/null +++ b/sdks/node/test/integrations/rest.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { flattenQueryParams } from "../../src/contrib/rest"; + +it("flattens string and first-of-array query values", () => { + const out = flattenQueryParams({ status: "failed", tag: ["a", "b"], n: 5 }); + expect(out.status).toBe("failed"); + expect(out.tag).toBe("a"); + expect(out.n).toBeUndefined(); +}); + +it("ignores prototype-polluting query keys", () => { + const out = flattenQueryParams({ __proto__: "x", constructor: "y", limit: "10" }); + expect(out.limit).toBe("10"); + // No pollution of Object.prototype, and the dangerous keys aren't carried over. + expect(({} as Record).x).toBeUndefined(); + expect(Object.hasOwn(out, "__proto__")).toBe(false); +}); + +it("handles a null/undefined query", () => { + expect(flattenQueryParams(undefined)).toEqual({}); + expect(flattenQueryParams(null)).toEqual({}); +}); From 33df47f707d923338b4becb38f270ec5490ddaf0 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:47:35 +0530 Subject: [PATCH 19/25] fix(node): build query map via Object.fromEntries, no dynamic write --- sdks/node/src/contrib/rest.ts | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/sdks/node/src/contrib/rest.ts b/sdks/node/src/contrib/rest.ts index 1060d859..d18dcac8 100644 --- a/sdks/node/src/contrib/rest.ts +++ b/sdks/node/src/contrib/rest.ts @@ -22,27 +22,32 @@ export interface RestRequest { body: unknown; } -// Keys that would pollute Object.prototype if assigned from user-controlled query. +// Keys that would pollute Object.prototype if carried over from user-controlled query. const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); +/** Pick a single string from a parsed query value (`string` or `string[]`). */ +function firstString(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value) && typeof value[0] === "string") { + return value[0]; + } + return undefined; +} + /** * Flatten a framework's parsed query (`string | string[]`) into plain - * `string | undefined` values. Uses a null-prototype object and skips - * prototype-polluting keys, so user-controlled query names are safe to assign. + * `string | undefined` values. Built via `Object.fromEntries` over filtered + * entries — never a dynamic `obj[userKey] =` write — and prototype-polluting + * keys are dropped, so user-controlled query names can't inject properties. */ export function flattenQueryParams(query: unknown): Record { - const out: Record = Object.create(null); - for (const [key, value] of Object.entries((query as Record) ?? {})) { - if (FORBIDDEN_KEYS.has(key)) { - continue; - } - if (typeof value === "string") { - out[key] = value; - } else if (Array.isArray(value) && typeof value[0] === "string") { - out[key] = value[0]; - } - } - return out; + const entries = Object.entries((query as Record) ?? {}) + .filter(([key]) => !FORBIDDEN_KEYS.has(key)) + .map(([key, value]) => [key, firstString(value)] as const) + .filter((entry): entry is readonly [string, string] => entry[1] !== undefined); + return Object.fromEntries(entries); } /** A framework-neutral response: HTTP status + a JSON-serializable body. */ From c2beced4eaead442fb4fcee778baaf4683e61928 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:53:54 +0530 Subject: [PATCH 20/25] fix(node): cancel compensation job if node binding fails --- crates/taskito-node/src/queue/workflows.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/taskito-node/src/queue/workflows.rs b/crates/taskito-node/src/queue/workflows.rs index 5721c7ff..1c15d8e1 100644 --- a/crates/taskito-node/src/queue/workflows.rs +++ b/crates/taskito-node/src/queue/workflows.rs @@ -715,8 +715,15 @@ impl JsQueue { namespace: self.namespace.clone(), }; let job = self.storage.enqueue(new_job).map_err(to_napi_err)?; - wf.set_workflow_node_compensation_job(&run_id, &node_name, &job.id, now) - .map_err(to_napi_err)?; + // Cancel the job if binding fails, so a compensation job can't run + // untracked by the saga state machine (mirrors create_deferred_job). + if let Err(err) = wf + .set_workflow_node_compensation_job(&run_id, &node_name, &job.id, now) + .map_err(to_napi_err) + { + let _ = self.storage.cancel_job(&job.id); + return Err(err); + } Ok(job.id) } From f486825e5b7a81627fdac3b1aaab77747a4f32d3 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:53:58 +0530 Subject: [PATCH 21/25] fix(node): don't leak internal errors from express router 500s --- sdks/node/src/contrib/express.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/contrib/express.ts b/sdks/node/src/contrib/express.ts index efcfd07d..c3bd416c 100644 --- a/sdks/node/src/contrib/express.ts +++ b/sdks/node/src/contrib/express.ts @@ -8,8 +8,11 @@ import { fileURLToPath } from "node:url"; import express, { type RequestHandler, type Router } from "express"; import { createDashboardHandler } from "../dashboard"; import type { Queue } from "../queue"; +import { createLogger } from "../utils"; import { buildRestRoutes, flattenQueryParams, type RestOptions, type RestRequest } from "./rest"; +const log = createLogger("contrib:express"); + /** Options for {@link taskitoRouter}. */ export type TaskitoRouterOptions = RestOptions; @@ -38,7 +41,9 @@ export function taskitoRouter(queue: Queue, options: TaskitoRouterOptions = {}): const result = await route.handle(queue, request); res.status(result.status).json(result.body); } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + // Log internally; don't leak internal error details to API callers. + log.error(() => `${route.method} ${route.path} failed`, err); + res.status(500).json({ error: "internal server error" }); } }; if (route.method === "GET") { From 0a39cbf55aef8ce736687a4726e6b7afd7aebd12 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:54:03 +0530 Subject: [PATCH 22/25] fix(node): validate prometheus collector interval --- sdks/node/src/contrib/prometheus.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdks/node/src/contrib/prometheus.ts b/sdks/node/src/contrib/prometheus.ts index ccad657e..370bc058 100644 --- a/sdks/node/src/contrib/prometheus.ts +++ b/sdks/node/src/contrib/prometheus.ts @@ -158,7 +158,11 @@ export class PrometheusStatsCollector { const namespace = options.namespace ?? "taskito"; const register = options.register ?? defaultRegister; this.metrics = getMetrics(register, namespace); - this.intervalMs = options.intervalMs ?? 10_000; + const intervalMs = options.intervalMs ?? 10_000; + if (!Number.isFinite(intervalMs) || intervalMs <= 0) { + throw new RangeError(`intervalMs must be a positive finite number, got ${intervalMs}`); + } + this.intervalMs = intervalMs; } /** Begin polling. Sampling runs immediately, then every `intervalMs`. */ From e2c9598b5ece20797a42f1188558f39ed3343d23 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:54:05 +0530 Subject: [PATCH 23/25] fix(node): reject mutually exclusive include/exclude routes --- sdks/node/src/contrib/rest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdks/node/src/contrib/rest.ts b/sdks/node/src/contrib/rest.ts index d18dcac8..bb366897 100644 --- a/sdks/node/src/contrib/rest.ts +++ b/sdks/node/src/contrib/rest.ts @@ -176,6 +176,9 @@ function defineRoutes(resultTimeoutMs: number): RestRoute[] { /** Build the route set for a helper, applying `includeRoutes`/`excludeRoutes`. */ export function buildRestRoutes(options: RestOptions = {}): RestRoute[] { + if (options.includeRoutes && options.excludeRoutes) { + throw new Error("includeRoutes and excludeRoutes are mutually exclusive"); + } const routes = defineRoutes(options.resultTimeoutMs ?? 5000); if (options.includeRoutes) { const allow = new Set(options.includeRoutes); From 017dc8d755a857ba6a2259ee835f9965d5583751 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:54:08 +0530 Subject: [PATCH 24/25] test(node): guaranteed teardown for fastify/nest, route-option test --- sdks/node/test/integrations/fastify.test.ts | 17 +++++++---------- sdks/node/test/integrations/nest.test.ts | 11 ++++++----- sdks/node/test/integrations/rest.test.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/sdks/node/test/integrations/fastify.test.ts b/sdks/node/test/integrations/fastify.test.ts index 8eff36df..b0fd0f24 100644 --- a/sdks/node/test/integrations/fastify.test.ts +++ b/sdks/node/test/integrations/fastify.test.ts @@ -8,10 +8,13 @@ import { taskitoDashboardPlugin, taskitoFastify } from "../../src/contrib/fastif import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; +let app: ReturnType | undefined; -afterEach(() => { +afterEach(async () => { worker?.stop(); worker = undefined; + await app?.close(); + app = undefined; }); function newQueue(): Queue { @@ -32,7 +35,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { const queue = newQueue(); queue.task("add", (a: number, b: number) => a + b); - const app = Fastify(); + app = Fastify(); await app.register(taskitoFastify, { queue, prefix: "/tasks" }); await app.ready(); @@ -52,25 +55,21 @@ it("serves the REST API through a prefixed plugin", async () => { const stats = await app.inject({ method: "GET", url: "/tasks/stats" }); expect((stats.json() as { completed: number }).completed).toBeGreaterThanOrEqual(1); - - await app.close(); }); it("applies excludeRoutes", async () => { const queue = newQueue(); - const app = Fastify(); + app = Fastify(); await app.register(taskitoFastify, { queue, prefix: "/tasks", excludeRoutes: ["stats"] }); await app.ready(); expect((await app.inject({ method: "GET", url: "/tasks/stats" })).statusCode).toBe(404); expect((await app.inject({ method: "GET", url: "/tasks/dead-letters" })).statusCode).toBe(200); - - await app.close(); }); it("mounts the dashboard JSON API under a prefix", async () => { const queue = newQueue(); - const app = Fastify(); + app = Fastify(); await app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); await app.listen({ port: 0, host: "127.0.0.1" }); const { port } = app.server.address() as AddressInfo; @@ -78,6 +77,4 @@ it("mounts the dashboard JSON API under a prefix", async () => { const res = await fetch(`http://127.0.0.1:${port}/admin/api/auth/status`); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ setup_required: false }); - - await app.close(); }); diff --git a/sdks/node/test/integrations/nest.test.ts b/sdks/node/test/integrations/nest.test.ts index 63ce878d..560545a2 100644 --- a/sdks/node/test/integrations/nest.test.ts +++ b/sdks/node/test/integrations/nest.test.ts @@ -1,16 +1,19 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Test } from "@nestjs/testing"; +import { Test, type TestingModule } from "@nestjs/testing"; import { afterEach, expect, it } from "vitest"; import { TaskitoModule, TaskitoService } from "../../src/contrib/nest"; import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; +let moduleRef: TestingModule | undefined; -afterEach(() => { +afterEach(async () => { worker?.stop(); worker = undefined; + await moduleRef?.close(); + moduleRef = undefined; }); function newQueue(): Queue { @@ -32,7 +35,7 @@ it("injects TaskitoService bound to the queue", async () => { const queue = newQueue(); queue.task("add", (a: number, b: number) => a + b); - const moduleRef = await Test.createTestingModule({ + moduleRef = await Test.createTestingModule({ imports: [TaskitoModule.forRoot(queue)], }).compile(); const service = moduleRef.get(TaskitoService); @@ -45,6 +48,4 @@ it("injects TaskitoService bound to the queue", async () => { expect(await service.result(id)).toBe(13); expect(service.stats().completed).toBeGreaterThanOrEqual(1); - - await moduleRef.close(); }); diff --git a/sdks/node/test/integrations/rest.test.ts b/sdks/node/test/integrations/rest.test.ts index 6e42c4d5..18415ea3 100644 --- a/sdks/node/test/integrations/rest.test.ts +++ b/sdks/node/test/integrations/rest.test.ts @@ -1,5 +1,12 @@ import { expect, it } from "vitest"; -import { flattenQueryParams } from "../../src/contrib/rest"; +import { buildRestRoutes, flattenQueryParams } from "../../src/contrib/rest"; + +it("rejects mutually exclusive include/exclude route options", () => { + expect(() => buildRestRoutes({ includeRoutes: ["stats"], excludeRoutes: ["jobs"] })).toThrow( + /mutually exclusive/, + ); + expect(buildRestRoutes({ includeRoutes: ["stats"] }).map((r) => r.name)).toEqual(["stats"]); +}); it("flattens string and first-of-array query values", () => { const out = flattenQueryParams({ status: "failed", tag: ["a", "b"], n: 5 }); From a5025088c56482e18c6977df881545128f4598b0 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:54:10 +0530 Subject: [PATCH 25/25] docs(node): clarify gate rejection branches, note webhook auth --- docs/content/docs/node/workflows/gates.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/node/workflows/gates.mdx b/docs/content/docs/node/workflows/gates.mdx index 2dca6e20..e3375418 100644 --- a/docs/content/docs/node/workflows/gates.mdx +++ b/docs/content/docs/node/workflows/gates.mdx @@ -93,10 +93,10 @@ Setting `timeoutMs` without `onTimeout` defaults to `"reject"`. ## Rejection behaviour -Rejecting a gate marks the gate node `failed` and all downstream nodes -`skipped`. The workflow run ends in state `"failed"`. There is no partial -recovery — if you need a rejection branch, use -[conditions](/node/workflows/conditions) (an `on_failure` step) downstream. +Rejecting a gate marks the gate node `failed` and skips its default +(`on_success`) successors; the workflow run ends in state `"failed"`. Any +`on_failure` / `always` successors still run — use those (see +[conditions](/node/workflows/conditions)) if you need a rejection branch. Calling `approveGate` or `rejectGate` on a gate that has already been @@ -112,6 +112,8 @@ server that receives a webhook and calls `approveGate`: ```ts app.post("/hooks/deploy-approval", async (req, res) => { + // Resolving a gate is a privileged state transition: authenticate the caller, + // verify the webhook signature, and authorize access to this run first. const { runId, approved, reason } = req.body; queue.workflows.resolveGate(runId, "approve-deploy", approved, reason); res.sendStatus(204);