@zintrust/queue-redis 2.1.4 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,9 +16,16 @@ npm i @zintrust/queue-redis
16
16
  import '@zintrust/queue-redis/register';
17
17
  ```
18
18
 
19
- Then set `QUEUE_DRIVER=redis` and configure `REDIS_URL`.
19
+ Then set `QUEUE_DRIVER=redis` and configure your Redis connection.
20
20
 
21
- For Cloudflare Workers, set `ENABLE_CLOUDFLARE_SOCKETS=true` and use a TCP-accessible Redis endpoint.
21
+ For Cloudflare Workers or any runtime without Redis TCP access, run Redis RPC from a Node.js backend and configure the Worker with both flags:
22
+
23
+ ```bash
24
+ USE_REDIS_PROXY=true
25
+ REDIS_RPC_URL=https://queues.example.com
26
+ ```
27
+
28
+ Start the backend with `zin redis-rpc` or `zin s redis-rpc`. See [`@zintrust/redis-rpc`](https://www.npmjs.com/package/@zintrust/redis-rpc).
22
29
 
23
30
  ## When to use
24
31
 
@@ -1,5 +1,5 @@
1
1
  import type { BullMQPayload, QueueMessage } from '@zintrust/core/queue';
2
- import { Queue } from 'bullmq';
2
+ import type { Queue } from 'bullmq';
3
3
  interface IQueueDriver {
4
4
  enqueue(queue: string, payload: BullMQPayload): Promise<string>;
5
5
  dequeue<T = unknown>(queue: string): Promise<QueueMessage<T> | undefined>;
@@ -8,13 +8,11 @@ interface IQueueDriver {
8
8
  drain(queue: string): Promise<void>;
9
9
  }
10
10
  interface IBullMQRedisQueue extends IQueueDriver {
11
- getQueue(queueName: string): Queue;
11
+ getQueue(queueName: string): Promise<Queue>;
12
12
  shutdown(): Promise<void>;
13
13
  closeQueue(queueName: string): Promise<void>;
14
14
  getQueueNames(): string[];
15
15
  }
16
- export declare const shouldUseHttpProxyDriver: () => boolean;
17
- export declare const runWithDirectQueueDriver: <T>(fn: () => Promise<T>) => Promise<T>;
18
16
  /**
19
17
  * BullMQ Redis Queue Driver
20
18
  *
@@ -6,24 +6,19 @@ import { Logger } from '@zintrust/core/logger';
6
6
  import { createLockProvider, getLockProvider, registerLockProvider, resolveDeduplicationLockKey, resolveLockPrefix, } from '@zintrust/core/queue';
7
7
  import { createRedisConnection, getBullMQSafeQueueName } from '@zintrust/core/redis';
8
8
  import { generateUuid, ZintrustLang } from '@zintrust/core/utils';
9
- import { Queue } from 'bullmq';
10
- import { HttpQueueDriver } from './HttpQueueDriver.js';
11
- export const shouldUseHttpProxyDriver = () => {
12
- if (directModeDepth > 0)
13
- return false;
14
- const isCloudFlareWorkers = Cloudflare.getWorkersEnv() !== null;
15
- const isProxy = isCloudFlareWorkers || Env.getBool('QUEUE_HTTP_PROXY_ENABLED', false);
16
- return isProxy;
17
- };
18
- let directModeDepth = 0;
19
- export const runWithDirectQueueDriver = async (fn) => {
20
- directModeDepth += 1;
21
- try {
22
- return await fn();
23
- }
24
- finally {
25
- directModeDepth = Math.max(0, directModeDepth - 1);
26
- }
9
+ import { RedisRpcQueueDriver, shouldUseRedisRpcQueueDriver } from './RedisRpcQueueDriver.js';
10
+ // Lazy BullMQ loader keyed on a variable specifier so bundlers (esbuild/wrangler)
11
+ // do not inline bullmq/ioredis into the Workers bundle. Every public method routes
12
+ // through RedisRpcQueueDriver first when shouldUseRedisRpcQueueDriver() is true
13
+ // (always, on Workers), so this loader never runs there.
14
+ let QueueCtor;
15
+ const ensureBullmqLoaded = async () => {
16
+ if (QueueCtor !== undefined)
17
+ return QueueCtor;
18
+ const bullmqPkg = 'bullmq';
19
+ const loaded = (await import(bullmqPkg)).Queue;
20
+ QueueCtor = loaded;
21
+ return loaded;
27
22
  };
28
23
  /**
29
24
  * BullMQ Redis Queue Driver
@@ -37,17 +32,6 @@ export const BullMQRedisQueue = (() => {
37
32
  let lockProviderCache = null;
38
33
  const PULL_WORKER_TOKEN = 'pull-worker';
39
34
  const SHARED_CONNECTION_SHUTDOWN_TIMEOUT_MS = 100;
40
- const isRedisProxyEnabled = () => {
41
- return Env.USE_REDIS_PROXY === true || Env.get('REDIS_PROXY_URL', '').trim() !== '';
42
- };
43
- const assertProxyAndWorkersCompatibility = (isWorkersRuntime) => {
44
- if (isRedisProxyEnabled() && shouldUseHttpProxyDriver() === false) {
45
- throw ErrorFactory.createConfigError('BullMQ Redis driver does not support REDIS proxy transport directly. Enable QUEUE_HTTP_PROXY_ENABLED=true for queue proxy mode, or disable REDIS proxy mode for direct BullMQ access.');
46
- }
47
- if (isWorkersRuntime && Cloudflare.isCloudflareSocketsEnabled() === false) {
48
- throw ErrorFactory.createConfigError('BullMQ Redis driver requires ENABLE_CLOUDFLARE_SOCKETS=true in Cloudflare Workers. To use HTTP queue proxy mode, set QUEUE_HTTP_PROXY_ENABLED=true and QUEUE_HTTP_PROXY_URL.');
49
- }
50
- };
51
35
  const resolveQueueRedisConfig = () => {
52
36
  let workersHost = Cloudflare.getWorkersVar('WORKERS_REDIS_HOST');
53
37
  let workersPortRaw = Cloudflare.getWorkersVar('WORKERS_REDIS_PORT');
@@ -75,12 +59,11 @@ export const BullMQRedisQueue = (() => {
75
59
  const assertWorkersHostIsReachable = (isWorkersRuntime, redisConfig) => {
76
60
  if (isWorkersRuntime &&
77
61
  (redisConfig.host === 'localhost' || redisConfig.host === '127.0.0.1')) {
78
- throw ErrorFactory.createConfigError('Redis host cannot be localhost in Cloudflare Workers. Use a public Redis host, or enable queue HTTP proxy mode with QUEUE_HTTP_PROXY_ENABLED=true and QUEUE_HTTP_PROXY_URL.');
62
+ throw ErrorFactory.createConfigError('Redis host cannot be localhost in Cloudflare Workers. Use a public Redis host.');
79
63
  }
80
64
  };
81
65
  const createSharedBullMqConnection = () => {
82
66
  const isWorkersRuntime = Cloudflare.getWorkersEnv() !== null;
83
- assertProxyAndWorkersCompatibility(isWorkersRuntime);
84
67
  const redisConfig = resolveQueueRedisConfig();
85
68
  assertWorkersHostIsReachable(isWorkersRuntime, redisConfig);
86
69
  return createRedisConnection({
@@ -207,10 +190,8 @@ export const BullMQRedisQueue = (() => {
207
190
  await closeSharedConnection(sharedConnection);
208
191
  }
209
192
  };
210
- const getQueue = (queueName) => {
211
- if (shouldUseHttpProxyDriver()) {
212
- throw ErrorFactory.createConfigError('BullMQ queue instance is not available when QUEUE_HTTP_PROXY mode is active.');
213
- }
193
+ const getQueue = async (queueName) => {
194
+ const QueueCtor = await ensureBullmqLoaded();
214
195
  // Check if queue exists in cache
215
196
  if (queues.has(queueName)) {
216
197
  const existingQueue = queues.get(queueName);
@@ -242,7 +223,7 @@ export const BullMQRedisQueue = (() => {
242
223
  const backoffDelay = Env.getInt('BULLMQ_BACKOFF_DELAY', 2000);
243
224
  const backoffType = Env.get('BULLMQ_BACKOFF_TYPE', 'exponential');
244
225
  const prefix = getBullMQSafeQueueName();
245
- const queue = new Queue(queueName, {
226
+ const queue = new QueueCtor(queueName, {
246
227
  connection: connection,
247
228
  prefix,
248
229
  defaultJobOptions: {
@@ -412,12 +393,12 @@ export const BullMQRedisQueue = (() => {
412
393
  closeQueue,
413
394
  getQueueNames,
414
395
  async enqueue(queue, payload) {
415
- if (shouldUseHttpProxyDriver()) {
416
- return HttpQueueDriver.enqueue(queue, payload);
396
+ if (shouldUseRedisRpcQueueDriver()) {
397
+ return RedisRpcQueueDriver.enqueue(queue, payload);
417
398
  }
418
399
  let requestedJobId;
419
400
  try {
420
- const q = getQueue(queue);
401
+ const q = await getQueue(queue);
421
402
  // Extract BullMQ options from payload with proper typing
422
403
  const payloadData = payload;
423
404
  const jobOptions = createJobOptions(payloadData);
@@ -450,11 +431,11 @@ export const BullMQRedisQueue = (() => {
450
431
  }
451
432
  },
452
433
  async dequeue(queue) {
453
- if (shouldUseHttpProxyDriver()) {
454
- return HttpQueueDriver.dequeue(queue);
434
+ if (shouldUseRedisRpcQueueDriver()) {
435
+ return RedisRpcQueueDriver.dequeue(queue);
455
436
  }
456
437
  try {
457
- const q = getQueue(queue);
438
+ const q = await getQueue(queue);
458
439
  const jobs = await q.getJobs(['waiting'], 0, 1);
459
440
  if (jobs.length === 0)
460
441
  return undefined;
@@ -481,12 +462,12 @@ export const BullMQRedisQueue = (() => {
481
462
  }
482
463
  },
483
464
  async ack(queue, id) {
484
- if (shouldUseHttpProxyDriver()) {
485
- await HttpQueueDriver.ack(queue, id);
465
+ if (shouldUseRedisRpcQueueDriver()) {
466
+ await RedisRpcQueueDriver.ack(queue, id);
486
467
  return;
487
468
  }
488
469
  try {
489
- const q = getQueue(queue);
470
+ const q = await getQueue(queue);
490
471
  const job = await q.getJob(id);
491
472
  if (job) {
492
473
  await job.moveToCompleted('acknowledged', PULL_WORKER_TOKEN, false);
@@ -501,11 +482,11 @@ export const BullMQRedisQueue = (() => {
501
482
  }
502
483
  },
503
484
  async length(queue) {
504
- if (shouldUseHttpProxyDriver()) {
505
- return HttpQueueDriver.length(queue);
485
+ if (shouldUseRedisRpcQueueDriver()) {
486
+ return RedisRpcQueueDriver.length(queue);
506
487
  }
507
488
  try {
508
- const q = getQueue(queue);
489
+ const q = await getQueue(queue);
509
490
  const counts = await q.getJobCounts();
510
491
  return counts['waiting'] || 0;
511
492
  }
@@ -515,12 +496,12 @@ export const BullMQRedisQueue = (() => {
515
496
  }
516
497
  },
517
498
  async drain(queue) {
518
- if (shouldUseHttpProxyDriver()) {
519
- await HttpQueueDriver.drain(queue);
499
+ if (shouldUseRedisRpcQueueDriver()) {
500
+ await RedisRpcQueueDriver.drain(queue);
520
501
  return;
521
502
  }
522
503
  try {
523
- const q = getQueue(queue);
504
+ const q = await getQueue(queue);
524
505
  await q.drain();
525
506
  Logger.debug(`BullMQ: Queue ${queue} drained`);
526
507
  }
@@ -1,4 +1,4 @@
1
- import type { IRouter } from '@zintrust/core/http';
1
+ import { type IRouter } from '@zintrust/core/http';
2
2
  type QueueGatewaySettings = {
3
3
  basePath: string;
4
4
  keyId: string;
@@ -3,7 +3,7 @@ import { ErrorFactory } from '@zintrust/core/errors';
3
3
  import { Router } from '@zintrust/core/http';
4
4
  import { Logger } from '@zintrust/core/logger';
5
5
  import { SignedRequest } from '@zintrust/core/security';
6
- import BullMQRedisQueue, { runWithDirectQueueDriver } from './BullMQRedisQueue.js';
6
+ import BullMQRedisQueue from './BullMQRedisQueue.js';
7
7
  const nonces = new Map();
8
8
  const nowMs = () => Date.now();
9
9
  const normalizePath = (value) => {
@@ -31,9 +31,8 @@ const readSettings = () => {
31
31
  const cleanupExpiredNonces = () => {
32
32
  const current = nowMs();
33
33
  for (const [nonceKey, expiresAt] of nonces.entries()) {
34
- if (expiresAt <= current) {
34
+ if (expiresAt <= current)
35
35
  nonces.delete(nonceKey);
36
- }
37
36
  }
38
37
  };
39
38
  const storeNonce = async (keyId, nonce, ttlMs) => {
@@ -52,18 +51,14 @@ const getBodyRecord = (req) => {
52
51
  return {};
53
52
  };
54
53
  const getRawBody = (req) => {
55
- const rawText = req.context['rawBodyText'];
54
+ const rawText = req.context?.['rawBodyText'];
56
55
  if (typeof rawText === 'string')
57
56
  return rawText;
58
57
  return JSON.stringify(getBodyRecord(req));
59
58
  };
60
59
  const toIncomingHeaders = (req) => {
61
60
  const headers = req.getHeaders();
62
- const normalize = (value) => {
63
- if (Array.isArray(value))
64
- return value.join(',');
65
- return value;
66
- };
61
+ const normalize = (value) => Array.isArray(value) ? value.join(',') : value;
67
62
  return {
68
63
  'x-zt-key-id': normalize(headers['x-zt-key-id']),
69
64
  'x-zt-timestamp': normalize(headers['x-zt-timestamp']),
@@ -73,63 +68,51 @@ const toIncomingHeaders = (req) => {
73
68
  };
74
69
  };
75
70
  const sendFailure = (res, requestId, status, code, message, details) => {
76
- const payload = {
71
+ res.status(status).json({
77
72
  ok: false,
78
73
  requestId,
79
74
  result: null,
80
75
  error: { code, message, details },
81
- };
82
- res.status(status).json(payload);
76
+ });
83
77
  };
84
78
  const sendSuccess = (res, requestId, result) => {
85
- const payload = {
86
- ok: true,
87
- requestId,
88
- result,
89
- error: null,
90
- };
91
- res.status(200).json(payload);
79
+ res.status(200).json({ ok: true, requestId, result, error: null });
92
80
  };
93
81
  const readQueueName = (payload) => {
94
- const value = payload.queue;
95
- if (typeof value !== 'string')
96
- return null;
97
- const normalized = value.trim();
98
- return normalized === '' ? null : normalized;
82
+ const value = payload['queue'];
83
+ if (typeof value !== 'string' || value.trim() === '') {
84
+ throw ErrorFactory.createValidationError('payload.queue is required');
85
+ }
86
+ return value.trim();
99
87
  };
100
88
  const executeAction = async (request) => {
101
- return runWithDirectQueueDriver(async () => {
102
- const queueName = readQueueName(request.payload);
103
- if (!queueName) {
104
- throw ErrorFactory.createValidationError('payload.queue is required');
105
- }
106
- switch (request.action) {
107
- case 'enqueue': {
108
- const payload = request.payload.payload;
109
- if (!payload || typeof payload !== 'object') {
110
- throw ErrorFactory.createValidationError('payload.payload is required for enqueue');
111
- }
112
- return BullMQRedisQueue.enqueue(queueName, payload);
89
+ const queueName = readQueueName(request.payload);
90
+ switch (request.action) {
91
+ case 'enqueue': {
92
+ const payload = request.payload['payload'];
93
+ if (!payload || typeof payload !== 'object') {
94
+ throw ErrorFactory.createValidationError('payload.payload is required for enqueue');
113
95
  }
114
- case 'dequeue':
115
- return BullMQRedisQueue.dequeue(queueName);
116
- case 'ack': {
117
- const id = request.payload.id;
118
- if (typeof id !== 'string' || id.trim() === '') {
119
- throw ErrorFactory.createValidationError('payload.id is required for ack');
120
- }
121
- await BullMQRedisQueue.ack(queueName, id);
122
- return null;
96
+ return BullMQRedisQueue.enqueue(queueName, payload);
97
+ }
98
+ case 'dequeue':
99
+ return BullMQRedisQueue.dequeue(queueName);
100
+ case 'ack': {
101
+ const id = request.payload['id'];
102
+ if (typeof id !== 'string' || id.trim() === '') {
103
+ throw ErrorFactory.createValidationError('payload.id is required for ack');
123
104
  }
124
- case 'length':
125
- return BullMQRedisQueue.length(queueName);
126
- case 'drain':
127
- await BullMQRedisQueue.drain(queueName);
128
- return null;
129
- default:
130
- throw ErrorFactory.createValidationError(`Unsupported action: ${String(request.action)}`);
105
+ await BullMQRedisQueue.ack(queueName, id);
106
+ return null;
131
107
  }
132
- });
108
+ case 'length':
109
+ return BullMQRedisQueue.length(queueName);
110
+ case 'drain':
111
+ await BullMQRedisQueue.drain(queueName);
112
+ return null;
113
+ default:
114
+ throw ErrorFactory.createValidationError(`Unsupported action: ${String(request.action)}`);
115
+ }
133
116
  };
134
117
  const verifyRequest = async (req, bodyText, settings) => {
135
118
  if (settings.keyId.trim() === '' || settings.secret.trim() === '') {
@@ -140,30 +123,25 @@ const verifyRequest = async (req, bodyText, settings) => {
140
123
  message: 'Queue HTTP gateway signing credentials are not configured',
141
124
  };
142
125
  }
143
- const url = new URL(req.getPath(), 'http://localhost');
144
126
  const verifyResult = await SignedRequest.verify({
145
127
  method: req.getMethod(),
146
- url,
128
+ url: new URL(req.getPath(), 'http://localhost'),
147
129
  body: bodyText,
148
130
  headers: toIncomingHeaders(req),
149
131
  nowMs: nowMs(),
150
132
  windowMs: settings.signingWindowMs,
151
133
  verifyNonce: async (keyId, nonce) => storeNonce(keyId, nonce, settings.nonceTtlMs),
152
- getSecretForKeyId: async (keyId) => {
153
- if (keyId === settings.keyId)
154
- return settings.secret;
155
- return undefined;
156
- },
134
+ getSecretForKeyId: async (keyId) => (keyId === settings.keyId ? settings.secret : undefined),
157
135
  });
158
136
  if (verifyResult.ok === true)
159
137
  return { ok: true };
160
- const errorCode = 'code' in verifyResult ? verifyResult.code : 'INVALID_SIGNATURE';
161
- const errorMessage = 'message' in verifyResult ? verifyResult.message : 'Invalid signature';
138
+ const code = 'code' in verifyResult ? verifyResult.code : 'INVALID_SIGNATURE';
139
+ const message = 'message' in verifyResult ? verifyResult.message : 'Invalid signature';
162
140
  return {
163
141
  ok: false,
164
- code: errorCode,
165
- status: errorCode === 'EXPIRED' || errorCode === 'REPLAYED' ? 401 : 403,
166
- message: errorMessage,
142
+ code,
143
+ status: code === 'EXPIRED' || code === 'REPLAYED' ? 401 : 403,
144
+ message,
167
145
  };
168
146
  };
169
147
  const createHandler = (settings) => {
@@ -178,37 +156,37 @@ const createHandler = (settings) => {
178
156
  sendFailure(res, requestId, auth.status, auth.code, auth.message);
179
157
  return;
180
158
  }
181
- const action = body['action'];
182
- const payload = body['payload'];
183
- if (typeof action !== 'string') {
159
+ if (typeof body['action'] !== 'string') {
184
160
  sendFailure(res, requestId, 400, 'VALIDATION_ERROR', 'action is required');
185
161
  return;
186
162
  }
187
- if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
163
+ if (!body['payload'] || typeof body['payload'] !== 'object' || Array.isArray(body['payload'])) {
188
164
  sendFailure(res, requestId, 400, 'VALIDATION_ERROR', 'payload must be an object');
189
165
  return;
190
166
  }
191
- const normalizedRequest = {
192
- action: action,
193
- requestId,
194
- payload: payload,
195
- };
196
167
  try {
197
- const result = await executeAction(normalizedRequest);
168
+ const result = await executeAction({
169
+ action: body['action'],
170
+ requestId,
171
+ payload: body['payload'],
172
+ });
198
173
  sendSuccess(res, requestId, result);
199
174
  }
200
175
  catch (error) {
201
176
  Logger.error('Queue HTTP gateway action failed', error);
202
- sendFailure(res, requestId, 500, 'QUEUE_ERROR', 'Queue operation failed', error instanceof Error ? { message: error.message } : error);
177
+ sendFailure(res, requestId, 500, 'QUEUE_ERROR', 'Queue operation failed', {
178
+ message: error instanceof Error ? error.message : String(error),
179
+ });
203
180
  }
204
181
  };
205
182
  };
206
183
  export const QueueHttpGateway = Object.freeze({
207
184
  create(config) {
185
+ const defaults = readSettings();
208
186
  const settings = {
209
- ...readSettings(),
187
+ ...defaults,
210
188
  ...config,
211
- basePath: normalizePath(config?.basePath ?? readSettings().basePath),
189
+ basePath: normalizePath(config?.basePath ?? defaults.basePath),
212
190
  };
213
191
  const routeOptions = settings.middleware.length > 0 ? { middleware: settings.middleware } : undefined;
214
192
  return {
@@ -83,7 +83,10 @@ export const createRedisPublishClient = async () => {
83
83
  */
84
84
  const tryCreateRedisClient = async (url) => {
85
85
  try {
86
- const mod = (await import('redis'));
86
+ // Variable specifier so bundlers (esbuild/wrangler) do not inline node-redis
87
+ // into the Workers bundle — this path never runs on Workers (RPC proxy is used).
88
+ const redisPkg = 'redis';
89
+ const mod = (await import(redisPkg));
87
90
  const client = mod.createClient({ url });
88
91
  if (typeof client.connect === 'function') {
89
92
  await connectClient(client, 'Redis publish client failed to connect');
@@ -99,7 +102,9 @@ const tryCreateRedisClient = async (url) => {
99
102
  */
100
103
  const tryCreateIoRedisClient = async (url) => {
101
104
  try {
102
- const mod = (await import('ioredis'));
105
+ // Variable specifier so bundlers do not inline ioredis into the Workers bundle.
106
+ const ioredisPkg = 'ioredis';
107
+ const mod = (await import(ioredisPkg));
103
108
  const redis = mod.default(url);
104
109
  const client = {
105
110
  publish: (channel, message) => redis.publish(channel, message),
@@ -0,0 +1,11 @@
1
+ import type { BullMQPayload, QueueMessage } from '@zintrust/core/queue';
2
+ export interface IQueueDriver {
3
+ enqueue(queue: string, payload: BullMQPayload): Promise<string>;
4
+ dequeue<T = unknown>(queue: string): Promise<QueueMessage<T> | undefined>;
5
+ ack(queue: string, id: string): Promise<void>;
6
+ length(queue: string): Promise<number>;
7
+ drain(queue: string): Promise<void>;
8
+ }
9
+ export declare const shouldUseRedisRpcQueueDriver: () => boolean;
10
+ export declare const RedisRpcQueueDriver: IQueueDriver;
11
+ export default RedisRpcQueueDriver;
@@ -0,0 +1,131 @@
1
+ import { Env } from '@zintrust/core/config';
2
+ import { ErrorFactory } from '@zintrust/core/errors';
3
+ import { JobStateTracker, TimeoutManager } from '@zintrust/core/queue';
4
+ import { generateUuid } from '@zintrust/core/utils';
5
+ const REDIS_RPC_PACKAGE = '@zintrust/redis-rpc';
6
+ export const shouldUseRedisRpcQueueDriver = () => {
7
+ return Env.USE_REDIS_PROXY === true && Env.get('REDIS_RPC_URL', '').trim() !== '';
8
+ };
9
+ const resolveRpcBaseUrl = () => {
10
+ const configured = Env.get('REDIS_RPC_URL', '').trim();
11
+ if (configured.length > 0)
12
+ return configured;
13
+ const host = Env.get('REDIS_RPC_HOST', '127.0.0.1').trim() || '127.0.0.1';
14
+ const port = Env.getInt('REDIS_RPC_PORT', 8794);
15
+ return `http://${host}:${port}`;
16
+ };
17
+ const createRpcClient = async () => {
18
+ try {
19
+ const mod = (await import(REDIS_RPC_PACKAGE));
20
+ return mod.createRedisRpcClient({
21
+ baseUrl: resolveRpcBaseUrl(),
22
+ secret: Env.get('REDIS_RPC_SECRET', Env.get('REDIS_PROXY_SECRET', Env.APP_KEY)),
23
+ });
24
+ }
25
+ catch (error) {
26
+ throw ErrorFactory.createConfigError('@zintrust/redis-rpc is required when USE_REDIS_PROXY=true and REDIS_RPC_URL is configured', error);
27
+ }
28
+ };
29
+ const resolveRequestedJobId = (payloadData) => {
30
+ if (typeof payloadData?.jobId === 'string' && payloadData.jobId.trim().length > 0) {
31
+ return payloadData.jobId.trim();
32
+ }
33
+ return generateUuid();
34
+ };
35
+ const createJobOptions = (payloadData) => ({
36
+ jobId: resolveRequestedJobId(payloadData),
37
+ delay: payloadData.delay,
38
+ attempts: payloadData.attempts,
39
+ priority: payloadData.priority,
40
+ removeOnComplete: payloadData.removeOnComplete || 100,
41
+ removeOnFail: payloadData.removeOnFail || 50,
42
+ backoff: payloadData.backoff || {
43
+ type: 'exponential',
44
+ delay: 2000,
45
+ },
46
+ repeat: payloadData.repeat,
47
+ lifo: payloadData.lifo ?? false,
48
+ });
49
+ const resolveJobId = (result, fallback) => {
50
+ if (typeof result === 'object' && result !== null) {
51
+ const id = result.id;
52
+ if (typeof id === 'string' && id.trim().length > 0)
53
+ return id;
54
+ if (typeof id === 'number' && Number.isFinite(id))
55
+ return String(id);
56
+ }
57
+ return fallback;
58
+ };
59
+ const markPendingRecoveryFallback = async (input) => {
60
+ const payload = input.payload;
61
+ const currentAttempts = typeof payload['_currentAttempts'] === 'number' && Number.isFinite(payload['_currentAttempts'])
62
+ ? Math.max(0, Math.floor(payload['_currentAttempts']))
63
+ : 0;
64
+ const maxAttempts = typeof payload['attempts'] === 'number' && Number.isFinite(payload['attempts'])
65
+ ? Math.max(1, Math.floor(payload['attempts']))
66
+ : undefined;
67
+ const idempotencyKey = typeof payload['uniqueId'] === 'string' && payload['uniqueId'].trim().length > 0
68
+ ? payload['uniqueId'].trim()
69
+ : undefined;
70
+ await JobStateTracker.enqueued({
71
+ queueName: input.queue,
72
+ jobId: input.fallbackJobId,
73
+ payload: input.payload,
74
+ attempts: currentAttempts,
75
+ maxAttempts,
76
+ idempotencyKey,
77
+ });
78
+ const pendingRecoveryApi = JobStateTracker;
79
+ await pendingRecoveryApi.pendingRecovery?.({
80
+ queueName: input.queue,
81
+ jobId: input.fallbackJobId,
82
+ reason: 'Redis RPC enqueue failed; marked pending recovery',
83
+ error: input.error,
84
+ });
85
+ };
86
+ export const RedisRpcQueueDriver = Object.freeze({
87
+ async enqueue(queue, payload) {
88
+ const fallbackJobId = resolveRequestedJobId(payload);
89
+ const timeoutMs = Env.getInt('REDIS_RPC_TIMEOUT_MS', Env.getInt('QUEUE_HTTP_PROXY_TIMEOUT_MS', 10000));
90
+ const options = createJobOptions({ ...payload, jobId: fallbackJobId });
91
+ try {
92
+ return await TimeoutManager.withTimeoutRetry(async () => {
93
+ const client = await createRpcClient();
94
+ const result = await client.queue('add', {
95
+ target: queue,
96
+ args: [`${queue}-job`, payload, options],
97
+ });
98
+ return resolveJobId(result, fallbackJobId);
99
+ }, {
100
+ timeoutMs,
101
+ maxRetries: Math.max(0, Env.getInt('REDIS_RPC_RETRY_MAX', Env.getInt('QUEUE_HTTP_PROXY_RETRY_MAX', 2))),
102
+ retryDelayMs: Math.max(0, Env.getInt('REDIS_RPC_RETRY_DELAY_MS', Env.getInt('QUEUE_HTTP_PROXY_RETRY_DELAY_MS', 500))),
103
+ operationName: `redis-rpc-queue-enqueue:${queue}`,
104
+ });
105
+ }
106
+ catch (error) {
107
+ await markPendingRecoveryFallback({ queue, fallbackJobId, payload, error });
108
+ return fallbackJobId;
109
+ }
110
+ },
111
+ async dequeue(queue) {
112
+ const client = await createRpcClient();
113
+ return client.queue('dequeue', {
114
+ target: queue,
115
+ visibilityTimeoutMs: Env.getInt('QUEUE_REDIS_VISIBILITY_TIMEOUT_MS', 30_000),
116
+ });
117
+ },
118
+ async ack(queue, id) {
119
+ const client = await createRpcClient();
120
+ await client.queue('ack', { target: queue, args: [id] });
121
+ },
122
+ async length(queue) {
123
+ const client = await createRpcClient();
124
+ return client.queue('length', { target: queue });
125
+ },
126
+ async drain(queue) {
127
+ const client = await createRpcClient();
128
+ await client.queue('drain', { target: queue });
129
+ },
130
+ });
131
+ export default RedisRpcQueueDriver;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BullMQRedisQueue } from './BullMQRedisQueue';
2
- export { HttpQueueDriver } from './HttpQueueDriver';
3
2
  export { QueueHttpGateway } from './QueueHttpGateway';
3
+ export { RedisRpcQueueDriver, shouldUseRedisRpcQueueDriver } from './RedisRpcQueueDriver';
4
4
  export { createRedisPublishClient, resetPublishClient, type RedisPublishClient, } from './RedisPublishClient';
5
5
  export type { QueueMessage } from '@zintrust/core/queue';
6
6
  /**
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BullMQRedisQueue } from './BullMQRedisQueue.js';
2
- export { HttpQueueDriver } from './HttpQueueDriver.js';
3
2
  export { QueueHttpGateway } from './QueueHttpGateway.js';
3
+ export { RedisRpcQueueDriver, shouldUseRedisRpcQueueDriver } from './RedisRpcQueueDriver.js';
4
4
  export { createRedisPublishClient, resetPublishClient, } from './RedisPublishClient.js';
5
5
  /**
6
6
  * Package version and build metadata
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/queue-redis",
3
- "version": "2.1.4",
3
+ "version": "2.4.2",
4
4
  "description": "Redis queue driver for ZinTrust.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -23,7 +23,13 @@
23
23
  "node": ">=20.0.0"
24
24
  },
25
25
  "peerDependencies": {
26
- "@zintrust/core": "*"
26
+ "@zintrust/core": "*",
27
+ "@zintrust/redis-rpc": "*"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@zintrust/redis-rpc": {
31
+ "optional": true
32
+ }
27
33
  },
28
34
  "publishConfig": {
29
35
  "access": "public"
@@ -40,7 +46,7 @@
40
46
  "prepublishOnly": "npm run build"
41
47
  },
42
48
  "dependencies": {
43
- "ioredis": "^5.10.1",
49
+ "ioredis": "^5.11.0",
44
50
  "redis": "^5.12.1"
45
51
  }
46
52
  }
@@ -1,17 +0,0 @@
1
- import type { BullMQPayload, QueueMessage } from '@zintrust/core/queue';
2
- export type QueueRpcAction = 'enqueue' | 'dequeue' | 'ack' | 'length' | 'drain';
3
- export interface IQueueDriver {
4
- enqueue(queue: string, payload: BullMQPayload): Promise<string>;
5
- dequeue<T = unknown>(queue: string): Promise<QueueMessage<T> | undefined>;
6
- ack(queue: string, id: string): Promise<void>;
7
- length(queue: string): Promise<number>;
8
- drain(queue: string): Promise<void>;
9
- }
10
- export declare const HttpQueueDriver: Readonly<{
11
- enqueue(queue: string, payload: BullMQPayload): Promise<string>;
12
- dequeue<T = unknown>(queue: string): Promise<QueueMessage<T> | undefined>;
13
- ack(queue: string, id: string): Promise<void>;
14
- length(queue: string): Promise<number>;
15
- drain(queue: string): Promise<void>;
16
- }>;
17
- export default HttpQueueDriver;
@@ -1,258 +0,0 @@
1
- import { Env } from '@zintrust/core/config';
2
- import { ErrorFactory } from '@zintrust/core/errors';
3
- import { Logger } from '@zintrust/core/logger';
4
- import { JobStateTracker, TimeoutManager } from '@zintrust/core/queue';
5
- import { SignedRequest } from '@zintrust/core/security';
6
- import { generateUuid } from '@zintrust/core/utils';
7
- const DEFAULT_PROXY_URL = 'http://127.0.0.1:7772';
8
- const DEFAULT_ROUTE_PATH = '/api/_sys/queue/rpc';
9
- const createTimeoutSignal = (timeoutMs) => {
10
- if (timeoutMs <= 0)
11
- return undefined;
12
- const timeout = AbortSignal.timeout;
13
- return typeof timeout === 'function' ? timeout(timeoutMs) : undefined;
14
- };
15
- const toBodyText = (payload) => JSON.stringify(payload);
16
- const normalizeBaseUrl = (value) => {
17
- const raw = value.trim();
18
- return raw === '' ? DEFAULT_PROXY_URL : raw;
19
- };
20
- const normalizeRoutePath = (value) => {
21
- const trimmed = value.trim();
22
- if (trimmed === '')
23
- return DEFAULT_ROUTE_PATH;
24
- if (trimmed.startsWith('/'))
25
- return trimmed;
26
- return `/${trimmed}`;
27
- };
28
- const resolveSigningPrefix = (baseUrl) => {
29
- try {
30
- const parsed = new URL(baseUrl);
31
- const path = parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname;
32
- if (path === '' || path === '/')
33
- return undefined;
34
- return path;
35
- }
36
- catch {
37
- return undefined;
38
- }
39
- };
40
- const buildSigningUrl = (requestUrl, baseUrl) => {
41
- const prefix = resolveSigningPrefix(baseUrl);
42
- if (!prefix)
43
- return requestUrl;
44
- if (requestUrl.pathname === prefix || requestUrl.pathname.startsWith(`${prefix}/`)) {
45
- const signingUrl = new URL(requestUrl.toString());
46
- const stripped = requestUrl.pathname.slice(prefix.length);
47
- signingUrl.pathname = stripped.startsWith('/') ? stripped : `/${stripped}`;
48
- return signingUrl;
49
- }
50
- return requestUrl;
51
- };
52
- const resolveSettings = () => {
53
- const baseUrl = normalizeBaseUrl(Env.get('QUEUE_HTTP_PROXY_URL', DEFAULT_PROXY_URL));
54
- const routePath = normalizeRoutePath(Env.get('QUEUE_HTTP_PROXY_PATH', DEFAULT_ROUTE_PATH));
55
- const keyId = Env.get('QUEUE_HTTP_PROXY_KEY_ID', Env.APP_NAME || 'zintrust').trim();
56
- const configuredSecret = Env.get('QUEUE_HTTP_PROXY_KEY', '').trim();
57
- const secret = configuredSecret === '' ? Env.APP_KEY : configuredSecret;
58
- const timeoutMs = Env.getInt('QUEUE_HTTP_PROXY_TIMEOUT_MS', 10000);
59
- if (secret.trim() === '') {
60
- throw ErrorFactory.createConfigError('QUEUE_HTTP_PROXY_KEY or APP_KEY is required');
61
- }
62
- return {
63
- baseUrl,
64
- routePath,
65
- keyId,
66
- secret,
67
- timeoutMs,
68
- };
69
- };
70
- const buildRpcUrl = (settings) => {
71
- const url = new URL(settings.baseUrl);
72
- const basePath = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname;
73
- const routePath = settings.routePath.startsWith('/')
74
- ? settings.routePath
75
- : `/${settings.routePath}`;
76
- url.pathname = `${basePath}${routePath}`;
77
- url.search = '';
78
- return url;
79
- };
80
- const parseJsonResponse = async (response) => {
81
- const text = await response.text();
82
- if (text.trim() === '') {
83
- return {
84
- ok: false,
85
- error: { code: 'EMPTY_RESPONSE', message: 'Empty response from queue gateway' },
86
- };
87
- }
88
- try {
89
- return JSON.parse(text);
90
- }
91
- catch {
92
- return {
93
- ok: false,
94
- error: { code: 'INVALID_JSON', message: text },
95
- };
96
- }
97
- };
98
- const ensureSuccessfulResponse = (response, requestId) => {
99
- if (!response.ok) {
100
- const code = response.error?.code || 'QUEUE_HTTP_PROXY_ERROR';
101
- const message = response.error?.message || 'Queue gateway returned an error';
102
- const details = {
103
- code,
104
- requestId,
105
- gatewayRequestId: response.requestId,
106
- details: response.error?.details,
107
- };
108
- throw ErrorFactory.createTryCatchError(message, details);
109
- }
110
- return response.result;
111
- };
112
- const callGateway = async (action, payload) => {
113
- const settings = resolveSettings();
114
- const url = buildRpcUrl(settings);
115
- const requestId = generateUuid();
116
- const requestBody = {
117
- action,
118
- requestId,
119
- payload,
120
- };
121
- const bodyText = toBodyText(requestBody);
122
- const signingUrl = buildSigningUrl(url, settings.baseUrl);
123
- const params = {
124
- method: 'POST',
125
- url: signingUrl,
126
- body: bodyText,
127
- keyId: settings.keyId,
128
- secret: settings.secret,
129
- };
130
- const signedHeaders = await SignedRequest.createHeaders(params);
131
- try {
132
- const response = await fetch(url, {
133
- method: 'POST',
134
- headers: {
135
- 'Content-Type': 'application/json',
136
- ...signedHeaders,
137
- },
138
- body: bodyText,
139
- signal: createTimeoutSignal(settings.timeoutMs),
140
- });
141
- const parsed = await parseJsonResponse(response);
142
- if (!response.ok && parsed.ok === false) {
143
- throw ErrorFactory.createConnectionError(parsed.error?.message || `Queue gateway HTTP error (${response.status})`, {
144
- status: response.status,
145
- requestId,
146
- error: parsed.error,
147
- });
148
- }
149
- return ensureSuccessfulResponse(parsed, requestId);
150
- }
151
- catch (error) {
152
- if (error instanceof Error && error.name === 'AbortError') {
153
- throw ErrorFactory.createConnectionError('Queue gateway request timed out', {
154
- timeoutMs: settings.timeoutMs,
155
- requestId,
156
- });
157
- }
158
- throw error;
159
- }
160
- };
161
- const resolveFallbackJobId = (payload) => {
162
- if (typeof payload.jobId === 'string' && payload.jobId.trim().length > 0) {
163
- return payload.jobId.trim();
164
- }
165
- return generateUuid();
166
- };
167
- const resolveCurrentAttempts = (payload) => {
168
- const raw = payload['_currentAttempts'];
169
- if (typeof raw !== 'number' || Number.isFinite(raw) === false)
170
- return 0;
171
- return Math.max(0, Math.floor(raw));
172
- };
173
- const resolveMaxAttempts = (payload) => {
174
- if (typeof payload.attempts === 'number' && Number.isFinite(payload.attempts)) {
175
- return Math.max(1, Math.floor(payload.attempts));
176
- }
177
- return undefined;
178
- };
179
- const resolveIdempotencyKey = (payload) => {
180
- if (typeof payload.uniqueId !== 'string')
181
- return undefined;
182
- const trimmed = payload.uniqueId.trim();
183
- return trimmed.length > 0 ? trimmed : undefined;
184
- };
185
- const markPendingRecoveryFallback = async (input) => {
186
- const currentAttempts = resolveCurrentAttempts(input.payload);
187
- const maxAttempts = resolveMaxAttempts(input.payload);
188
- const idempotencyKey = resolveIdempotencyKey(input.payload);
189
- // Compatibility shim: consumers of `@zintrust/core` may have a narrower
190
- // `JobStateTracker.enqueued` type than the runtime implementation.
191
- const enqueuedApi = JobStateTracker;
192
- await enqueuedApi.enqueued({
193
- queueName: input.queue,
194
- jobId: input.fallbackJobId,
195
- payload: input.payload,
196
- attempts: currentAttempts,
197
- maxAttempts,
198
- idempotencyKey,
199
- });
200
- const pendingRecoveryApi = JobStateTracker;
201
- if (typeof pendingRecoveryApi.pendingRecovery === 'function') {
202
- await pendingRecoveryApi.pendingRecovery({
203
- queueName: input.queue,
204
- jobId: input.fallbackJobId,
205
- reason: 'HTTP queue proxy enqueue failed; marked pending recovery',
206
- error: input.error,
207
- });
208
- }
209
- };
210
- export const HttpQueueDriver = Object.freeze({
211
- async enqueue(queue, payload) {
212
- const fallbackJobId = resolveFallbackJobId(payload);
213
- const timeoutMs = Env.getInt('QUEUE_HTTP_PROXY_TIMEOUT_MS', 10000);
214
- try {
215
- return await TimeoutManager.withTimeoutRetry(async () => callGateway('enqueue', { queue, payload }), {
216
- timeoutMs,
217
- maxRetries: Math.max(0, Env.getInt('QUEUE_HTTP_PROXY_RETRY_MAX', 2)),
218
- retryDelayMs: Math.max(0, Env.getInt('QUEUE_HTTP_PROXY_RETRY_DELAY_MS', 500)),
219
- operationName: `http-queue-enqueue:${queue}`,
220
- });
221
- }
222
- catch (error) {
223
- Logger.warn('HTTP queue enqueue failed; storing tracker fallback in memory', {
224
- ...Logger.withTraceSkipContext({
225
- queue,
226
- fallbackJobId,
227
- error: error instanceof Error ? error.message : String(error),
228
- }),
229
- });
230
- await markPendingRecoveryFallback({
231
- queue,
232
- fallbackJobId,
233
- payload,
234
- error,
235
- });
236
- Logger.warn('Job marked pending recovery in tracker', {
237
- ...Logger.withTraceSkipContext({
238
- queue,
239
- jobId: fallbackJobId,
240
- }),
241
- });
242
- return fallbackJobId;
243
- }
244
- },
245
- async dequeue(queue) {
246
- return callGateway('dequeue', { queue });
247
- },
248
- async ack(queue, id) {
249
- await callGateway('ack', { queue, id });
250
- },
251
- async length(queue) {
252
- return callGateway('length', { queue });
253
- },
254
- async drain(queue) {
255
- await callGateway('drain', { queue });
256
- },
257
- });
258
- export default HttpQueueDriver;