@zintrust/core 2.4.3 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/core",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "description": "Production-grade TypeScript backend framework for JavaScript",
5
5
  "homepage": "https://zintrust.com",
6
6
  "repository": {
@@ -202,6 +202,10 @@
202
202
  "types": "./src/socket-index.d.ts",
203
203
  "import": "./src/socket-index.js"
204
204
  },
205
+ "./tasks": {
206
+ "types": "./src/tasks.d.ts",
207
+ "import": "./src/tasks.js"
208
+ },
205
209
  "./package.json": "./package.json"
206
210
  },
207
211
  "dependencies": {
package/src/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * @zintrust/core v2.4.3
2
+ * @zintrust/core v2.4.4
3
3
  *
4
4
  * ZinTrust Framework - Production-Grade TypeScript Backend
5
5
  * Built for performance, type safety, and exceptional developer experience
6
6
  *
7
7
  * Build Information:
8
- * Built: 2026-06-01T09:39:36.110Z
8
+ * Built: 2026-06-01T13:14:15.197Z
9
9
  * Node: >=20.0.0
10
10
  * License: MIT
11
11
  *
@@ -21,7 +21,7 @@
21
21
  * Available at runtime for debugging and health checks
22
22
  */
23
23
  export const ZINTRUST_VERSION = '0.1.41';
24
- export const ZINTRUST_BUILD_DATE = '2026-06-01T09:39:36.070Z'; // Replaced during build
24
+ export const ZINTRUST_BUILD_DATE = '2026-06-01T13:14:15.161Z'; // Replaced during build
25
25
  export { Application } from './boot/Application.js';
26
26
  export { AwsSigV4 } from './common/index.js';
27
27
  export { SignedRequest } from './security/SignedRequest.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * BackgroundTaskScheduler — runtime-agnostic background task scheduling.
3
+ * Maps to ctx.waitUntil() in Cloudflare Workers, normal promises in Node.
4
+ */
5
+ type ExecutionContext = {
6
+ waitUntil(promise: Promise<unknown>): void;
7
+ };
8
+ type BackgroundTaskScheduler = {
9
+ schedule(task: Promise<void>): void;
10
+ isAvailable(): boolean;
11
+ };
12
+ export declare const BackgroundTaskScheduler: Readonly<{
13
+ /**
14
+ * Set the Worker execution context for waitUntil support.
15
+ * Call this from your Cloudflare Worker fetch handler:
16
+ * BackgroundTaskScheduler.setExecutionContext(ctx);
17
+ */
18
+ setExecutionContext(ctx: ExecutionContext): void;
19
+ /**
20
+ * Get the current scheduler instance.
21
+ */
22
+ getScheduler(): BackgroundTaskScheduler;
23
+ /**
24
+ * Schedule a background task.
25
+ * In Workers: uses ctx.waitUntil() if context is set
26
+ * In Node: fire-and-forget with error suppression
27
+ */
28
+ schedule(task: Promise<void>): void;
29
+ /**
30
+ * Check if a proper scheduler is available (i.e., Workers context is set).
31
+ */
32
+ isAvailable(): boolean;
33
+ /**
34
+ * Reset the scheduler (useful for testing).
35
+ */
36
+ reset(): void;
37
+ }>;
38
+ export {};
39
+ //# sourceMappingURL=BackgroundTaskScheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BackgroundTaskScheduler.d.ts","sourceRoot":"","sources":["../../../src/runtime/BackgroundTaskScheduler.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,KAAK,gBAAgB,GAAG;IACtB,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5C,CAAC;AAEF,KAAK,uBAAuB,GAAG;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpC,WAAW,IAAI,OAAO,CAAC;CACxB,CAAC;AA4DF,eAAO,MAAM,uBAAuB;IAClC;;;;OAIG;6BACsB,gBAAgB,GAAG,IAAI;IAKhD;;OAEG;oBACa,uBAAuB;IAmBvC;;;;OAIG;mBACY,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAInC;;OAEG;mBACY,OAAO;IAItB;;OAEG;aACM,IAAI;EAIb,CAAC"}
@@ -0,0 +1,105 @@
1
+ /**
2
+ * BackgroundTaskScheduler — runtime-agnostic background task scheduling.
3
+ * Maps to ctx.waitUntil() in Cloudflare Workers, normal promises in Node.
4
+ */
5
+ let _executionContext;
6
+ let _scheduler;
7
+ const isCloudflareRuntime = () => {
8
+ const globalRef = typeof globalThis === 'undefined' ? undefined : globalThis;
9
+ if (!globalRef)
10
+ return false;
11
+ // @ts-ignore - navigator is available in workers
12
+ if (typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers') {
13
+ return true;
14
+ }
15
+ return (globalRef.caches !== undefined ||
16
+ typeof globalRef.WebSocketPair === 'function' ||
17
+ globalRef.CF !== undefined);
18
+ };
19
+ const createWorkerScheduler = (ctx) => {
20
+ return Object.freeze({
21
+ schedule(task) {
22
+ ctx.waitUntil(task);
23
+ },
24
+ isAvailable() {
25
+ return true;
26
+ },
27
+ });
28
+ };
29
+ const createNodeScheduler = () => {
30
+ return Object.freeze({
31
+ schedule(task) {
32
+ // In Node, fire-and-forget is acceptable since the process doesn't terminate
33
+ // like Workers do. We still catch errors to prevent unhandled rejections.
34
+ task.catch(() => {
35
+ // Silently ignore background task errors to avoid noise
36
+ // The individual watchers handle their own error logging
37
+ });
38
+ },
39
+ isAvailable() {
40
+ return true;
41
+ },
42
+ });
43
+ };
44
+ const createFallbackScheduler = () => {
45
+ return Object.freeze({
46
+ schedule(task) {
47
+ // Fallback: fire-and-forget with error suppression
48
+ task.catch(() => undefined);
49
+ },
50
+ isAvailable() {
51
+ return false;
52
+ },
53
+ });
54
+ };
55
+ export const BackgroundTaskScheduler = Object.freeze({
56
+ /**
57
+ * Set the Worker execution context for waitUntil support.
58
+ * Call this from your Cloudflare Worker fetch handler:
59
+ * BackgroundTaskScheduler.setExecutionContext(ctx);
60
+ */
61
+ setExecutionContext(ctx) {
62
+ _executionContext = ctx;
63
+ _scheduler = createWorkerScheduler(ctx);
64
+ },
65
+ /**
66
+ * Get the current scheduler instance.
67
+ */
68
+ getScheduler() {
69
+ if (_scheduler)
70
+ return _scheduler;
71
+ if (_executionContext) {
72
+ _scheduler = createWorkerScheduler(_executionContext);
73
+ return _scheduler;
74
+ }
75
+ if (isCloudflareRuntime()) {
76
+ // We're in Workers but no context was set - use fallback
77
+ _scheduler = createFallbackScheduler();
78
+ return _scheduler;
79
+ }
80
+ // Node.js or other runtime
81
+ _scheduler = createNodeScheduler();
82
+ return _scheduler;
83
+ },
84
+ /**
85
+ * Schedule a background task.
86
+ * In Workers: uses ctx.waitUntil() if context is set
87
+ * In Node: fire-and-forget with error suppression
88
+ */
89
+ schedule(task) {
90
+ this.getScheduler().schedule(task);
91
+ },
92
+ /**
93
+ * Check if a proper scheduler is available (i.e., Workers context is set).
94
+ */
95
+ isAvailable() {
96
+ return this.getScheduler().isAvailable();
97
+ },
98
+ /**
99
+ * Reset the scheduler (useful for testing).
100
+ */
101
+ reset() {
102
+ _executionContext = undefined;
103
+ _scheduler = undefined;
104
+ },
105
+ });
package/src/tasks.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { BackgroundTaskScheduler } from './runtime/BackgroundTaskScheduler';
2
+ //# sourceMappingURL=tasks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/tasks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kCAAkC,CAAC"}
package/src/tasks.js ADDED
@@ -0,0 +1 @@
1
+ export { BackgroundTaskScheduler } from './runtime/BackgroundTaskScheduler.js';
@@ -0,0 +1,18 @@
1
+ import type { WorkerModule } from './processor-registry';
2
+ export type AppWorkerDefinition = Readonly<{
3
+ name: string;
4
+ queueName: string;
5
+ version: string;
6
+ autoStart: boolean;
7
+ activeStatus: boolean;
8
+ concurrency: number;
9
+ processorSpec: string;
10
+ }>;
11
+ /**
12
+ * Ensure a drain loop is running. Returns immediately. If a loop is already active this is
13
+ * a no-op (concurrent-PING guard). The returned promise resolves when the active loop ends
14
+ * and is suitable for `ctx.waitUntil`.
15
+ */
16
+ export declare const ensureDraining: (appWorkerDefinitions: AppWorkerDefinition[], workerModules: ReadonlyArray<WorkerModule>) => Promise<void>;
17
+ export declare const isDraining: () => boolean;
18
+ //# sourceMappingURL=drain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drain.d.ts","sourceRoot":"","sources":["../../../src/worker-runtime/drain.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAgCvE,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC,CAAC;AAsKH;;;;GAIG;AACH,eAAO,MAAM,cAAc,GACzB,sBAAsB,mBAAmB,EAAE,EAC3C,eAAe,aAAa,CAAC,YAAY,CAAC,KACzC,OAAO,CAAC,IAAI,CAUd,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,OAA+B,CAAC"}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Worker drain loop.
3
+ *
4
+ * The Cloudflare Worker is not an always-on daemon, so it does not hold persistent BullMQ
5
+ * consumers. Instead it is woken by a PING (on enqueue, or by cron) and then drains
6
+ * available jobs: pull an atomically-claimed job over HTTP RPC, run the bundled processor,
7
+ * and ack/fail it back over RPC. The whole drain runs inside the Worker so it executes the
8
+ * app's real business logic with all bindings/code already bundled.
9
+ *
10
+ * A single drain loop runs at a time (guard). A PING that arrives while a loop is active is
11
+ * a no-op for the loop (the caller still gets an immediate ack).
12
+ */
13
+ import { Env } from '../config/env.js';
14
+ import { Logger } from '../config/logger.js';
15
+ import { isNullish } from '../helper/index.js';
16
+ import { resolveProcessor } from './processor-registry.js';
17
+ import { ackJob, failJob, isRedisRpcConfigured, listWorkers, pullJob, } from './rpc-client.js';
18
+ let activeDrain = null;
19
+ const sleep = async (ms) => new Promise((resolve) => globalThis.setTimeout(resolve, ms));
20
+ const intEnv = (key, fallback) => {
21
+ const parsed = Number.parseInt(Env.get(key, '').trim(), 10);
22
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
23
+ };
24
+ /**
25
+ * Resolve which queues this Worker should drain: app manifest workers that have a bundled
26
+ * processor and are not disabled. The backend lifecycle registry is authoritative for
27
+ * start/stop — a worker explicitly `stopped` there is skipped; otherwise the manifest's
28
+ * `autoStart` decides.
29
+ */
30
+ const resolveDrainTargets = async (appWorkerDefinitions, workerModules) => {
31
+ let statusByQueue = new Map();
32
+ try {
33
+ const registered = await listWorkers();
34
+ statusByQueue = new Map(registered
35
+ .filter((entry) => typeof entry.queueName === 'string' && entry.source === 'redis-rpc-registry')
36
+ .map((entry) => [entry.queueName, entry.status ?? 'stopped']));
37
+ }
38
+ catch (error) {
39
+ Logger.debug('[worker-runtime] worker registry unavailable; falling back to manifest autoStart', error);
40
+ }
41
+ const targets = [];
42
+ for (const definition of appWorkerDefinitions) {
43
+ if (definition.activeStatus === false)
44
+ continue;
45
+ if (!resolveProcessor(definition.processorSpec, workerModules))
46
+ continue;
47
+ const status = statusByQueue.get(definition.queueName);
48
+ const enabled = status === undefined ? definition.autoStart === true : status === 'running';
49
+ if (enabled) {
50
+ targets.push({ queueName: definition.queueName, processorSpec: definition.processorSpec });
51
+ }
52
+ }
53
+ return targets;
54
+ };
55
+ const toJob = (queueName, pulled) => {
56
+ return {
57
+ id: pulled.id,
58
+ name: pulled.name ?? 'default',
59
+ data: pulled.payload,
60
+ queueName,
61
+ attemptsMade: pulled.attempts,
62
+ };
63
+ };
64
+ const getDrainLoopConfig = () => ({
65
+ deadline: Date.now() + intEnv('WORKER_DRAIN_MAX_MS', 300_000),
66
+ idleSleepMs: intEnv('WORKER_DRAIN_IDLE_SLEEP_MS', 1_000),
67
+ maxIdleCycles: intEnv('WORKER_DRAIN_MAX_IDLE_CYCLES', 5),
68
+ visibilityTimeoutMs: intEnv('WORKER_DRAIN_VISIBILITY_MS', 30_000),
69
+ });
70
+ /** Pull and process a single job for a queue. Returns true if a job was handled. */
71
+ const drainOneJob = async (target, visibilityTimeoutMs, workerModules) => {
72
+ const pulled = await pullJob(target.queueName, visibilityTimeoutMs);
73
+ if (!pulled)
74
+ return false;
75
+ const processor = resolveProcessor(target.processorSpec, workerModules);
76
+ if (!processor) {
77
+ await failJob(target.queueName, pulled.id, `No bundled processor for ${target.processorSpec}`);
78
+ return true;
79
+ }
80
+ try {
81
+ const returnValue = await processor(toJob(target.queueName, pulled));
82
+ await ackJob(target.queueName, pulled.id, returnValue);
83
+ }
84
+ catch (error) {
85
+ const reason = error instanceof Error ? error.message : String(error);
86
+ Logger.error('[worker-runtime] job processing failed', {
87
+ queue: target.queueName,
88
+ jobId: pulled.id,
89
+ reason,
90
+ });
91
+ await failJob(target.queueName, pulled.id, reason);
92
+ }
93
+ return true;
94
+ };
95
+ const drainTargetQueue = async (target, visibilityTimeoutMs, workerModules, deadline) => {
96
+ let handledCount = 0;
97
+ while (Date.now() < deadline) {
98
+ // eslint-disable-next-line no-await-in-loop
99
+ const handled = await drainOneJob(target, visibilityTimeoutMs, workerModules).catch((error) => {
100
+ Logger.warn('[worker-runtime] drainOneJob error', { queue: target.queueName, error });
101
+ return false;
102
+ });
103
+ if (!handled)
104
+ break;
105
+ handledCount += 1;
106
+ }
107
+ return handledCount;
108
+ };
109
+ const drainTargets = async (targets, visibilityTimeoutMs, workerModules, deadline) => {
110
+ const handledCounts = await Promise.all(targets.map(async (target) => drainTargetQueue(target, visibilityTimeoutMs, workerModules, deadline)));
111
+ return handledCounts.reduce((sum, handledCount) => sum + handledCount, 0);
112
+ };
113
+ const runDrainLoop = async (appWorkerDefinitions, workerModules) => {
114
+ if (!Env.getBool('WORKER_ENABLED', false) || !isRedisRpcConfigured()) {
115
+ return;
116
+ }
117
+ const { deadline, idleSleepMs, maxIdleCycles, visibilityTimeoutMs } = getDrainLoopConfig();
118
+ let idleCycles = 0;
119
+ while (Date.now() < deadline && idleCycles < maxIdleCycles) {
120
+ // eslint-disable-next-line no-await-in-loop
121
+ const targets = await resolveDrainTargets(appWorkerDefinitions, workerModules);
122
+ if (targets.length === 0)
123
+ break;
124
+ // eslint-disable-next-line no-await-in-loop
125
+ const handledThisCycle = await drainTargets(targets, visibilityTimeoutMs, workerModules, deadline);
126
+ if (handledThisCycle === 0) {
127
+ idleCycles += 1;
128
+ // eslint-disable-next-line no-await-in-loop
129
+ await sleep(idleSleepMs);
130
+ continue;
131
+ }
132
+ idleCycles = 0;
133
+ }
134
+ };
135
+ /**
136
+ * Ensure a drain loop is running. Returns immediately. If a loop is already active this is
137
+ * a no-op (concurrent-PING guard). The returned promise resolves when the active loop ends
138
+ * and is suitable for `ctx.waitUntil`.
139
+ */
140
+ export const ensureDraining = async (appWorkerDefinitions, workerModules) => {
141
+ if (!isNullish(activeDrain))
142
+ return activeDrain;
143
+ activeDrain = runDrainLoop(appWorkerDefinitions, workerModules)
144
+ .catch((error) => {
145
+ Logger.error('[worker-runtime] drain loop crashed', error);
146
+ })
147
+ .finally(() => {
148
+ activeDrain = null;
149
+ });
150
+ return activeDrain;
151
+ };
152
+ export const isDraining = () => activeDrain !== null;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Registers the global post-enqueue hook that core `Queue.enqueue` invokes after a
3
+ * successful enqueue. Every enqueue then wakes the worker runtime (PING), so jobs are
4
+ * drained promptly instead of waiting for the next cron tick. `triggerWorkerPing` is itself
5
+ * gated (WORKER_ENABLED + Redis RPC configured) and honours `WAIT_FOR_PING`.
6
+ */
7
+ import type { AppWorkerDefinition } from './drain';
8
+ import type { WorkerModule } from './processor-registry';
9
+ declare global {
10
+ var __zintrustQueueEnqueueHook: ((queue: string, jobId: unknown, driver: string) => unknown) | undefined;
11
+ }
12
+ /**
13
+ * Wire the app's bundled worker manifest and processor modules into the post-enqueue PING so
14
+ * the same-isolate drain loop can resolve which queues to drain and which processor to run.
15
+ */
16
+ export declare const installQueueEnqueuePingHook: (appWorkerDefinitions: AppWorkerDefinition[], workerModules: ReadonlyArray<WorkerModule>) => void;
17
+ //# sourceMappingURL=install-enqueue-hook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install-enqueue-hook.d.ts","sourceRoot":"","sources":["../../../src/worker-runtime/install-enqueue-hook.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAEjE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAEvE,OAAO,CAAC,MAAM,CAAC;IACb,IAAI,0BAA0B,EAC1B,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,GAC5D,SAAS,CAAC;CACf;AAED;;;GAGG;AACH,eAAO,MAAM,2BAA2B,GACtC,sBAAsB,mBAAmB,EAAE,EAC3C,eAAe,aAAa,CAAC,YAAY,CAAC,KACzC,IAGF,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { triggerWorkerPing } from './ping.js';
2
+ /**
3
+ * Wire the app's bundled worker manifest and processor modules into the post-enqueue PING so
4
+ * the same-isolate drain loop can resolve which queues to drain and which processor to run.
5
+ */
6
+ export const installQueueEnqueuePingHook = (appWorkerDefinitions, workerModules) => {
7
+ globalThis.__zintrustQueueEnqueueHook = async () => triggerWorkerPing(appWorkerDefinitions, workerModules);
8
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Worker PING — wake + drain trigger.
3
+ *
4
+ * Producer side (`triggerWorkerPing`): called after a job is enqueued (and by cron). It
5
+ * wakes the worker runtime so jobs are drained promptly instead of waiting for the next
6
+ * cron tick.
7
+ *
8
+ * - Monolith / same isolate (no `WORKER_PING_URL`): start the local drain loop directly
9
+ * via the background scheduler (`ctx.waitUntil`).
10
+ * - Split service (`WORKER_PING_URL` set): POST to the worker service's PING endpoint.
11
+ *
12
+ * `WAIT_FOR_PING` gates the producer: when `true` the producer awaits the PING *ack only*
13
+ * (worker-awake confirmation), never the job's completion; when false it is fire-and-forget.
14
+ *
15
+ * Endpoint side (`handlePing`): authenticates, schedules the drain loop on the background
16
+ * scheduler, and responds immediately with an awake ack. The concurrent-PING guard lives in
17
+ * `ensureDraining`, so overlapping pings never start duplicate loops.
18
+ */
19
+ import { type AppWorkerDefinition } from './drain';
20
+ import type { WorkerModule } from './processor-registry';
21
+ type PingRequest = {
22
+ getHeader?: (name: string) => string | string[] | undefined;
23
+ };
24
+ type PingResponse = {
25
+ json: (body: unknown) => unknown;
26
+ setStatus: (status: number) => PingResponse;
27
+ };
28
+ /**
29
+ * Producer-side wake. Safe no-op when the worker runtime is disabled/unconfigured.
30
+ *
31
+ * `appWorkerDefinitions` / `workerModules` are the app's bundled worker manifest and
32
+ * statically-imported processor modules; they are only used in the same-isolate branch to
33
+ * start the local drain loop. In the split-service branch the wake is a remote PING and the
34
+ * worker service supplies its own definitions, so they are unused there.
35
+ */
36
+ export declare const triggerWorkerPing: (appWorkerDefinitions: AppWorkerDefinition[], workerModules: ReadonlyArray<WorkerModule>) => Promise<void>;
37
+ /** Endpoint-side handler: authenticate, start draining in the background, ack immediately. */
38
+ export declare const handlePing: (req: PingRequest, res: PingResponse, appWorkerDefinitions: AppWorkerDefinition[], workerModules: ReadonlyArray<WorkerModule>) => void;
39
+ export {};
40
+ //# sourceMappingURL=ping.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ping.d.ts","sourceRoot":"","sources":["../../../src/worker-runtime/ping.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,EAA8B,KAAK,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC7F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAGvE,KAAK,WAAW,GAAG;IACjB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC;CAC7D,CAAC;AAEF,KAAK,YAAY,GAAG;IAClB,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;IACjC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,YAAY,CAAC;CAC7C,CAAC;AAcF;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAC5B,sBAAsB,mBAAmB,EAAE,EAC3C,eAAe,aAAa,CAAC,YAAY,CAAC,KACzC,OAAO,CAAC,IAAI,CA+Bd,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,UAAU,GACrB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,sBAAsB,mBAAmB,EAAE,EAC3C,eAAe,aAAa,CAAC,YAAY,CAAC,KACzC,IAaF,CAAC"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Worker PING — wake + drain trigger.
3
+ *
4
+ * Producer side (`triggerWorkerPing`): called after a job is enqueued (and by cron). It
5
+ * wakes the worker runtime so jobs are drained promptly instead of waiting for the next
6
+ * cron tick.
7
+ *
8
+ * - Monolith / same isolate (no `WORKER_PING_URL`): start the local drain loop directly
9
+ * via the background scheduler (`ctx.waitUntil`).
10
+ * - Split service (`WORKER_PING_URL` set): POST to the worker service's PING endpoint.
11
+ *
12
+ * `WAIT_FOR_PING` gates the producer: when `true` the producer awaits the PING *ack only*
13
+ * (worker-awake confirmation), never the job's completion; when false it is fire-and-forget.
14
+ *
15
+ * Endpoint side (`handlePing`): authenticates, schedules the drain loop on the background
16
+ * scheduler, and responds immediately with an awake ack. The concurrent-PING guard lives in
17
+ * `ensureDraining`, so overlapping pings never start duplicate loops.
18
+ */
19
+ import { Env } from '../config/env.js';
20
+ import { Logger } from '../config/logger.js';
21
+ import { BackgroundTaskScheduler } from '../runtime/BackgroundTaskScheduler.js';
22
+ import { ensureDraining, isDraining } from './drain.js';
23
+ import { isRedisRpcConfigured } from './rpc-client.js';
24
+ const getPingSecret = () => Env.get('WORKER_PING_SECRET', Env.get('REDIS_RPC_SECRET', Env.get('REDIS_PROXY_SECRET', Env.get('APP_KEY', '')))).trim();
25
+ const headerValue = (req, name) => {
26
+ const raw = req.getHeader?.(name);
27
+ const value = Array.isArray(raw) ? raw[0] : raw;
28
+ return typeof value === 'string' ? value.trim() : '';
29
+ };
30
+ /**
31
+ * Producer-side wake. Safe no-op when the worker runtime is disabled/unconfigured.
32
+ *
33
+ * `appWorkerDefinitions` / `workerModules` are the app's bundled worker manifest and
34
+ * statically-imported processor modules; they are only used in the same-isolate branch to
35
+ * start the local drain loop. In the split-service branch the wake is a remote PING and the
36
+ * worker service supplies its own definitions, so they are unused there.
37
+ */
38
+ export const triggerWorkerPing = async (appWorkerDefinitions, workerModules) => {
39
+ if (!Env.getBool('WORKER_ENABLED', false) || !isRedisRpcConfigured()) {
40
+ return;
41
+ }
42
+ const url = Env.get('WORKER_PING_URL', '').trim();
43
+ if (url.length === 0) {
44
+ // Same isolate: wake the local drain loop in the background.
45
+ BackgroundTaskScheduler.schedule(ensureDraining(appWorkerDefinitions, workerModules));
46
+ return;
47
+ }
48
+ const secret = getPingSecret();
49
+ const ack = fetch(url, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'content-type': 'application/json',
53
+ ...(secret ? { 'x-worker-ping-secret': secret } : {}),
54
+ },
55
+ body: JSON.stringify({ at: new Date().toISOString() }),
56
+ })
57
+ .then(() => undefined)
58
+ .catch((error) => {
59
+ Logger.warn('[worker-runtime] worker ping failed', error);
60
+ });
61
+ if (Env.getBool('WAIT_FOR_PING', false)) {
62
+ await ack; // wait for the awake ack only — never the job's completion
63
+ }
64
+ else {
65
+ BackgroundTaskScheduler.schedule(ack);
66
+ }
67
+ };
68
+ /** Endpoint-side handler: authenticate, start draining in the background, ack immediately. */
69
+ export const handlePing = (req, res, appWorkerDefinitions, workerModules) => {
70
+ const expected = getPingSecret();
71
+ if (expected.length > 0 && headerValue(req, 'x-worker-ping-secret') !== expected) {
72
+ res.setStatus(401).json({ ok: false, error: 'Invalid worker ping secret' });
73
+ return;
74
+ }
75
+ if (!Env.getBool('WORKER_ENABLED', false) || !isRedisRpcConfigured()) {
76
+ res.json({ ok: true, status: 'disabled' });
77
+ return;
78
+ }
79
+ const alreadyDraining = isDraining();
80
+ BackgroundTaskScheduler.schedule(ensureDraining(appWorkerDefinitions, workerModules));
81
+ res.json({ ok: true, status: 'awake', alreadyDraining });
82
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Bundled processor registry.
3
+ *
4
+ * Cloudflare Workers cannot dynamically `import()` a file path at runtime — the module
5
+ * graph is frozen at build time. So the worker processor functions are statically
6
+ * imported here and exposed as a `processorSpec -> ZinTrustProcessor` map. The Redis RPC
7
+ * backend only stores the `processorSpec` string as metadata; this runtime resolves that
8
+ * string to the already-bundled function when it pulls a job.
9
+ */
10
+ import type { Job } from 'bullmq';
11
+ export type WorkerProcessor = (job: Job) => Promise<unknown>;
12
+ export type WorkerModule = {
13
+ workerDefinition: {
14
+ processorSpec: string;
15
+ };
16
+ ZinTrustProcessor?: WorkerProcessor;
17
+ default?: WorkerProcessor;
18
+ };
19
+ export declare const resolveProcessor: (processorSpec: string, workerModules: ReadonlyArray<WorkerModule>) => WorkerProcessor | undefined;
20
+ export declare const hasProcessor: (processorSpec: string, workerModules: ReadonlyArray<WorkerModule>) => boolean;
21
+ export declare const listProcessorSpecs: (workerModules: ReadonlyArray<WorkerModule>) => string[];
22
+ //# sourceMappingURL=processor-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processor-registry.d.ts","sourceRoot":"","sources":["../../../src/worker-runtime/processor-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAElC,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE7D,MAAM,MAAM,YAAY,GAAG;IACzB,gBAAgB,EAAE;QAAE,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,CAAC,EAAE,eAAe,CAAC;IACpC,OAAO,CAAC,EAAE,eAAe,CAAC;CAC3B,CAAC;AAoBF,eAAO,MAAM,gBAAgB,GAC3B,eAAe,MAAM,EACrB,eAAe,aAAa,CAAC,YAAY,CAAC,KACzC,eAAe,GAAG,SAAgE,CAAC;AAEtF,eAAO,MAAM,YAAY,GACvB,eAAe,MAAM,EACrB,eAAe,aAAa,CAAC,YAAY,CAAC,KACzC,OAA8D,CAAC;AAElE,eAAO,MAAM,kBAAkB,GAAI,eAAe,aAAa,CAAC,YAAY,CAAC,KAAG,MAAM,EACjC,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Bundled processor registry.
3
+ *
4
+ * Cloudflare Workers cannot dynamically `import()` a file path at runtime — the module
5
+ * graph is frozen at build time. So the worker processor functions are statically
6
+ * imported here and exposed as a `processorSpec -> ZinTrustProcessor` map. The Redis RPC
7
+ * backend only stores the `processorSpec` string as metadata; this runtime resolves that
8
+ * string to the already-bundled function when it pulls a job.
9
+ */
10
+ const buildRegistry = (workerModules) => {
11
+ const registry = new Map();
12
+ for (const mod of workerModules) {
13
+ const spec = mod.workerDefinition?.processorSpec;
14
+ const processor = mod.ZinTrustProcessor ?? mod.default;
15
+ if (typeof spec === 'string' && spec.trim().length > 0 && typeof processor === 'function') {
16
+ registry.set(spec, processor);
17
+ }
18
+ }
19
+ return registry;
20
+ };
21
+ const processorRegistry = (workerModules) => buildRegistry(workerModules);
22
+ export const resolveProcessor = (processorSpec, workerModules) => processorRegistry(workerModules).get(processorSpec);
23
+ export const hasProcessor = (processorSpec, workerModules) => processorRegistry(workerModules).has(processorSpec);
24
+ export const listProcessorSpecs = (workerModules) => Array.from(processorRegistry(workerModules).keys());
@@ -0,0 +1,23 @@
1
+ export type PulledJob = {
2
+ id: string;
3
+ name?: string;
4
+ payload: unknown;
5
+ attempts: number;
6
+ };
7
+ export declare const isRedisRpcConfigured: () => boolean;
8
+ /** Atomically claim and pull the next waiting job for a queue, or undefined if none. */
9
+ export declare const pullJob: (queueName: string, visibilityTimeoutMs: number) => Promise<PulledJob | undefined>;
10
+ /** Mark a pulled job completed. */
11
+ export declare const ackJob: (queueName: string, jobId: string, returnValue?: unknown) => Promise<void>;
12
+ /** Mark a pulled job failed. */
13
+ export declare const failJob: (queueName: string, jobId: string, reason: string) => Promise<void>;
14
+ export type RegisteredWorker = {
15
+ workerName: string;
16
+ queueName: string;
17
+ status?: string;
18
+ processorSpec?: string | null;
19
+ source?: string;
20
+ };
21
+ /** List worker lifecycle records the backend holds (status running/stopped). */
22
+ export declare const listWorkers: () => Promise<RegisteredWorker[]>;
23
+ //# sourceMappingURL=rpc-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc-client.d.ts","sourceRoot":"","sources":["../../../src/worker-runtime/rpc-client.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAOF,eAAO,MAAM,oBAAoB,QAAO,OAAkC,CAAC;AA6B3E,wFAAwF;AACxF,eAAO,MAAM,OAAO,GAClB,WAAW,MAAM,EACjB,qBAAqB,MAAM,KAC1B,OAAO,CAAC,SAAS,GAAG,SAAS,CAM/B,CAAC;AAEF,mCAAmC;AACnC,eAAO,MAAM,MAAM,GACjB,WAAW,MAAM,EACjB,OAAO,MAAM,EACb,cAAc,OAAO,KACpB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF,gCAAgC;AAChC,eAAO,MAAM,OAAO,GAAU,WAAW,MAAM,EAAE,OAAO,MAAM,EAAE,QAAQ,MAAM,KAAG,OAAO,CAAC,IAAI,CAM5F,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,WAAW,QAAa,OAAO,CAAC,gBAAgB,EAAE,CAG9D,CAAC"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Worker-native Redis RPC client.
3
+ *
4
+ * The Cloudflare Worker cannot open a raw TCP/ioredis connection reliably, so all Redis
5
+ * access goes over HTTP to the Redis RPC backend (`REDIS_RPC_URL`). This is the same
6
+ * `/rpc` envelope used by the worker dashboard.
7
+ */
8
+ import { Env } from '../config/env.js';
9
+ import { Logger } from '../config/logger.js';
10
+ import { ErrorFactory } from '../exceptions/ZintrustError.js';
11
+ const getBaseUrl = () => Env.get('REDIS_RPC_URL', '').trim();
12
+ const getSecret = () => Env.get('REDIS_RPC_SECRET', Env.get('REDIS_PROXY_SECRET', Env.get('APP_KEY', ''))).trim();
13
+ export const isRedisRpcConfigured = () => getBaseUrl().length > 0;
14
+ const call = async (service, method, payload = {}) => {
15
+ const baseUrl = getBaseUrl();
16
+ if (baseUrl.length === 0) {
17
+ throw ErrorFactory.createConfigError('REDIS_RPC_URL is not configured');
18
+ }
19
+ const secret = getSecret();
20
+ const response = await fetch(new URL('/rpc', baseUrl), {
21
+ method: 'POST',
22
+ headers: {
23
+ 'content-type': 'application/json',
24
+ ...(secret ? { 'x-redis-rpc-secret': secret } : {}),
25
+ },
26
+ body: JSON.stringify({ requestId: crypto.randomUUID(), service, method, payload }),
27
+ });
28
+ const parsed = (await response.json());
29
+ if (!response.ok || parsed.ok !== true) {
30
+ throw ErrorFactory.createConfigError(parsed.error?.message ?? `Redis RPC ${service}.${method} failed (${response.status})`);
31
+ }
32
+ return parsed.result;
33
+ };
34
+ /** Atomically claim and pull the next waiting job for a queue, or undefined if none. */
35
+ export const pullJob = async (queueName, visibilityTimeoutMs) => {
36
+ const result = await call('queue', 'dequeue', {
37
+ queueName,
38
+ visibilityTimeoutMs,
39
+ });
40
+ return result ?? undefined;
41
+ };
42
+ /** Mark a pulled job completed. */
43
+ export const ackJob = async (queueName, jobId, returnValue) => {
44
+ await call('queue', 'ack', { queueName, jobId, returnValue });
45
+ };
46
+ /** Mark a pulled job failed. */
47
+ export const failJob = async (queueName, jobId, reason) => {
48
+ try {
49
+ await call('queue', 'fail', { queueName, jobId, reason });
50
+ }
51
+ catch (error) {
52
+ Logger.warn('[worker-runtime] failJob RPC failed', { queueName, jobId, error });
53
+ }
54
+ };
55
+ /** List worker lifecycle records the backend holds (status running/stopped). */
56
+ export const listWorkers = async () => {
57
+ const result = await call('worker', 'list', {});
58
+ return Array.isArray(result) ? result : [];
59
+ };
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=worker-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-runtime.d.ts","sourceRoot":"","sources":["../../src/worker-runtime.ts"],"names":[],"mappings":""}
@@ -0,0 +1 @@
1
+ "use strict";