@pauldeng/node-red-contrib-bullmq 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Node-RED nodes for BullMQ-backed Redis job queues.
10
10
 
11
- This package targets BullMQ 5.80.2 and Node-RED 4.1 or 5.x. It preserves the legacy `bull-queue-server`, `bull cmd`, and `bull run` node types where BullMQ has compatible behavior, and adds `bull job`, `bull events`, and `bull flow`.
11
+ This package targets BullMQ 5.80.9 and Node-RED 4.1 or 5.x. It preserves the legacy `bull-queue-server`, `bull cmd`, and `bull run` node types where BullMQ has compatible behavior, and adds `bull job`, `bull events`, and `bull flow`.
12
12
 
13
13
  ## Installation
14
14
 
@@ -26,7 +26,7 @@ Repository: <https://github.com/pauldeng/node-red-contrib-bullmq>
26
26
  - Node-RED 4.1.x or 5.x
27
27
  - Node.js 18+ with Node-RED 4.1.x, or Node.js 22.9+ with Node-RED 5.x
28
28
  - Redis with `maxmemory-policy=noeviction`
29
- - BullMQ 5.80.2
29
+ - BullMQ 5.80.9
30
30
 
31
31
  Bull v4 Redis data is not automatically migrated. Drain, retire, or otherwise handle old Bull queues before upgrading the runtime dependency.
32
32
 
@@ -66,11 +66,11 @@ msg.jobopts = {
66
66
  return msg;
67
67
  ```
68
68
 
69
- The scheduler id is `msg.schedulerId` when present, otherwise `msg.jobopts.jobId`. `repeat.cron` is translated to `repeat.pattern`; conflicting `cron` and `pattern` values are rejected.
69
+ When adding a legacy repeat job, the scheduler id is `msg.schedulerId` when present, otherwise `msg.jobopts.jobId`. `repeat.cron` is translated to `repeat.pattern`; conflicting `cron` and `pattern` values are rejected. Lookup and removal commands require the exact scheduler id in `msg.schedulerId`, `msg.jobid`, or `msg.jobId`.
70
70
 
71
71
  ## Commands
72
72
 
73
- `bull cmd` reads `msg.cmd`. The default command is `add`.
73
+ `bull cmd` reads `msg.cmd`. The legacy `msg.command` alias is also accepted, but new flows should use `msg.cmd`. The default command is `add`.
74
74
 
75
75
  Core supported command families include:
76
76
 
@@ -98,14 +98,11 @@ See [docs/COMMANDS.md](docs/COMMANDS.md).
98
98
 
99
99
  ## Examples
100
100
 
101
- Import [examples/example_flow.json](examples/example_flow.json) into Node-RED. It includes:
101
+ Import any of these flows into Node-RED:
102
102
 
103
- - simple add and run
104
- - required `basecasts` scheduled job
105
- - delayed and prioritized jobs
106
- - manual acknowledgement
107
- - QueueEvents
108
- - parent/child flow producer
103
+ - [examples/example_flow.json](examples/example_flow.json): an end-to-end flow with add/run, a legacy scheduler compatibility case, delayed and prioritized jobs, manual acknowledgement, QueueEvents, and a parent/child flow.
104
+ - [examples/bullmq_features.json](examples/bullmq_features.json): focused examples of common BullMQ features.
105
+ - [examples/repeatable_jobs.json](examples/repeatable_jobs.json): legacy repeat-command and Job Scheduler compatibility examples.
109
106
 
110
107
  The examples do not contain secrets.
111
108
 
package/bull-queue.html CHANGED
@@ -1,7 +1,7 @@
1
1
  <script type="text/html" data-template-name="bull-queue-server">
2
2
  <div class="form-row">
3
3
  <label for="node-config-input-name"><i class="fa fa-tasks"></i> Queue</label>
4
- <input type="text" id="node-config-input-name" placeholder="basecasts">
4
+ <input type="text" id="node-config-input-name" placeholder="email-jobs">
5
5
  </div>
6
6
  <div class="form-row">
7
7
  <label for="node-config-input-deployment"><i class="fa fa-server"></i> Deployment</label>
@@ -31,7 +31,7 @@
31
31
  <label for="node-config-input-sentinelMasterName"><i class="fa fa-tag"></i> Master</label>
32
32
  <input type="text" id="node-config-input-sentinelMasterName" placeholder="mymaster">
33
33
  </div>
34
- <div class="form-row">
34
+ <div class="form-row bull-db-row">
35
35
  <label for="node-config-input-db"><i class="fa fa-database"></i> Database</label>
36
36
  <input type="number" id="node-config-input-db" placeholder="0">
37
37
  </div>
@@ -95,6 +95,7 @@
95
95
  $(".bull-single-row").toggle(deployment === "single");
96
96
  $(".bull-cluster-row").toggle(deployment === "cluster");
97
97
  $(".bull-sentinel-row").toggle(deployment === "sentinel");
98
+ $(".bull-db-row").toggle(deployment !== "cluster");
98
99
  $(".bull-tls-row").toggle($("#node-config-input-tls").is(":checked"));
99
100
  }
100
101
 
@@ -269,7 +270,7 @@ return msg;</pre>
269
270
  <option value="manual">Manual acknowledgement</option>
270
271
  </select>
271
272
  </div>
272
- <div class="form-row">
273
+ <div class="form-row bull-ack-timeout-row">
273
274
  <label for="node-input-ackTimeout"><i class="fa fa-clock-o"></i> Ack Timeout</label>
274
275
  <input type="number" id="node-input-ackTimeout" placeholder="300000">
275
276
  </div>
@@ -315,26 +316,62 @@ return msg;</pre>
315
316
  </script>
316
317
 
317
318
  <script type="text/javascript">
318
- RED.nodes.registerType("bull run", {
319
- color: "#ffffff",
320
- category: "function",
321
- defaults: {
322
- name: { value: "" },
323
- queue: { type: "bull-queue-server", required: true },
324
- completionMode: { value: "immediate" },
325
- ackTimeout: { value: 300000, validate: RED.validators.number() },
326
- concurrency: { value: 1, validate: RED.validators.number() },
327
- limiterMax: { value: "" },
328
- limiterDuration: { value: "" }
329
- },
330
- inputs: 0,
331
- outputs: 1,
332
- align: "left",
333
- icon: "bull_icon.png",
334
- label: function() {
335
- return this.name || "bull run";
319
+ (function() {
320
+ function updateBullRunRows() {
321
+ $(".bull-ack-timeout-row").toggle(
322
+ $("#node-input-completionMode").val() === "manual"
323
+ );
336
324
  }
337
- });
325
+
326
+ function positiveInteger(value) {
327
+ var parsed = Number(value);
328
+ return value !== "" && Number.isInteger(parsed) && parsed > 0;
329
+ }
330
+
331
+ function optionalPositivePair(value, otherProperty) {
332
+ var input = $("#node-input-" + otherProperty);
333
+ var other = input.length ? input.val() : this[otherProperty];
334
+ return (
335
+ ((value === "" || value == null) && (other === "" || other == null)) ||
336
+ (positiveInteger(value) && positiveInteger(other))
337
+ );
338
+ }
339
+
340
+ RED.nodes.registerType("bull run", {
341
+ color: "#ffffff",
342
+ category: "function",
343
+ defaults: {
344
+ name: { value: "" },
345
+ queue: { type: "bull-queue-server", required: true },
346
+ completionMode: { value: "immediate" },
347
+ ackTimeout: { value: 300000, validate: RED.validators.number() },
348
+ concurrency: { value: 1, validate: positiveInteger },
349
+ limiterMax: {
350
+ value: "",
351
+ validate: function(value) {
352
+ return optionalPositivePair.call(this, value, "limiterDuration");
353
+ }
354
+ },
355
+ limiterDuration: {
356
+ value: "",
357
+ validate: function(value) {
358
+ return optionalPositivePair.call(this, value, "limiterMax");
359
+ }
360
+ }
361
+ },
362
+ inputs: 0,
363
+ outputs: 1,
364
+ align: "left",
365
+ icon: "bull_icon.png",
366
+ label: function() {
367
+ return this.name || "bull run";
368
+ },
369
+ oneditprepare: function() {
370
+ $("#node-input-completionMode").on("change", updateBullRunRows);
371
+ updateBullRunRows();
372
+ }
373
+ });
374
+ })();
338
375
  </script>
339
376
 
340
377
  <script type="text/html" data-template-name="bull job">
@@ -386,7 +423,11 @@ return msg;</pre>
386
423
  <pre>msg.cmd = "progress";
387
424
  msg.progress = 50;
388
425
  return msg;</pre>
389
- <p>Wire that message into another <code>bull job</code> node with <b>Action</b> <code>complete</code>, or set:</p>
426
+ <p>To use another <code>bull job</code> node with configured <b>Action</b> <code>complete</code>, pass the progress output through a Function node that removes the per-message override:</p>
427
+ <pre>delete msg.cmd;
428
+ msg.payload = { ok: true };
429
+ return msg;</pre>
430
+ <p>Alternatively, override the downstream action explicitly:</p>
390
431
  <pre>msg.cmd = "complete";
391
432
  msg.payload = { ok: true };
392
433
  return msg;</pre>
package/bull-queue.js CHANGED
@@ -47,22 +47,22 @@ const DEFAULT_EVENTS = [
47
47
  // an unreachable Redis server.
48
48
  const CLOSE_GRACE_MS = 1000;
49
49
 
50
- async function settleWithin(promise, ms) {
51
- let settled = false;
52
- (async () => {
53
- try {
54
- await promise;
55
- } catch (err) {
56
- // a failed graceful close still counts as settled
57
- }
58
- settled = true;
59
- })();
60
-
61
- const deadline = Date.now() + ms;
62
- while (!settled && Date.now() < deadline) {
63
- await sleep(25);
50
+ async function settled(promise) {
51
+ try {
52
+ await promise;
53
+ } catch (err) {
54
+ // a failed graceful close still counts as settled
64
55
  }
65
- return settled ? "settled" : "timeout";
56
+ return "settled";
57
+ }
58
+
59
+ async function timedOut(ms) {
60
+ await sleep(ms);
61
+ return "timeout";
62
+ }
63
+
64
+ async function settleWithin(promise, ms) {
65
+ return await Promise.race([settled(promise), timedOut(ms)]);
66
66
  }
67
67
 
68
68
  function disconnectClient(client) {
@@ -86,7 +86,7 @@ function forceDisconnect(resource) {
86
86
  }
87
87
  // BullMQ resource. Its public disconnect() awaits a connection promise that
88
88
  // never settles while Redis is unreachable, so reach for the underlying
89
- // ioredis clients directly (BullMQ is pinned to exactly 5.80.2).
89
+ // ioredis clients directly (BullMQ is pinned to exactly 5.80.9).
90
90
  if (resource.connection) {
91
91
  disconnectClient(resource.connection._client);
92
92
  }
@@ -122,6 +122,23 @@ async function closeResource(resource) {
122
122
  }
123
123
  }
124
124
 
125
+ async function closeResourcePair(owner, connection) {
126
+ let firstError;
127
+ try {
128
+ await closeResource(owner);
129
+ } catch (err) {
130
+ firstError = err;
131
+ }
132
+ try {
133
+ await closeResource(connection);
134
+ } catch (err) {
135
+ firstError ||= err;
136
+ }
137
+ if (firstError) {
138
+ throw firstError;
139
+ }
140
+ }
141
+
125
142
  function nodeSend(node, send, msg) {
126
143
  (send || node.send).call(node, msg);
127
144
  }
@@ -134,13 +151,17 @@ function nodeDone(node, done, err, msg) {
134
151
  }
135
152
  }
136
153
 
137
- function parsePositiveInteger(value, defaultValue) {
138
- if (value === undefined || value === null || value === "") {
154
+ function isPresent(value) {
155
+ return value !== undefined && value !== null && value !== "";
156
+ }
157
+
158
+ function parsePositiveInteger(value, defaultValue, field = "Value") {
159
+ if (!isPresent(value)) {
139
160
  return defaultValue;
140
161
  }
141
162
  const parsed = Number(value);
142
163
  if (!Number.isInteger(parsed) || parsed < 1) {
143
- throw new Error(`Expected a positive integer, got ${value}`);
164
+ throw new Error(`${field} must be a positive integer`);
144
165
  }
145
166
  return parsed;
146
167
  }
@@ -203,7 +224,7 @@ module.exports = function registerBullMQNodes(RED) {
203
224
  const node = this;
204
225
 
205
226
  node.users = {};
206
- node.resources = new Set();
227
+ node.resources = new Map();
207
228
  node.config = normalizeQueueConfig(n, node.credentials || {});
208
229
  node.queue = null;
209
230
  node.producerConnection = null;
@@ -229,17 +250,27 @@ module.exports = function registerBullMQNodes(RED) {
229
250
  const descriptor = buildRedisDescriptor(node.config, role);
230
251
  const connection = createRedisConnection(descriptor, IORedis);
231
252
  attachErrorListener(connection, owner);
232
- node.resources.add(connection);
233
253
  return connection;
234
254
  };
235
255
 
256
+ node.releaseResource = async function releaseResource(owner) {
257
+ if (!node.resources.has(owner)) {
258
+ return;
259
+ }
260
+ const connection = node.resources.get(owner);
261
+ node.resources.delete(owner);
262
+ await closeResourcePair(owner, connection);
263
+ };
264
+
236
265
  node.getQueue = function getQueue() {
237
266
  if (!node.queue) {
238
267
  node.producerConnection = node.createConnection("producer");
268
+ node.producerConnection.setMaxListeners(0);
239
269
  node.queue = new Queue(
240
270
  node.config.queueName,
241
271
  buildBullMQOptions(node.config, node.producerConnection)
242
272
  );
273
+ node.resources.set(node.queue, node.producerConnection);
243
274
  attachErrorListener(node.queue, node);
244
275
  }
245
276
  return node.queue;
@@ -261,7 +292,7 @@ module.exports = function registerBullMQNodes(RED) {
261
292
  ...buildBullMQOptions(node.config, connection),
262
293
  ...options,
263
294
  });
264
- node.resources.add(worker);
295
+ node.resources.set(worker, connection);
265
296
  return worker;
266
297
  };
267
298
 
@@ -271,7 +302,7 @@ module.exports = function registerBullMQNodes(RED) {
271
302
  node.config.queueName,
272
303
  buildBullMQOptions(node.config, connection)
273
304
  );
274
- node.resources.add(queueEvents);
305
+ node.resources.set(queueEvents, connection);
275
306
  return queueEvents;
276
307
  };
277
308
 
@@ -280,19 +311,19 @@ module.exports = function registerBullMQNodes(RED) {
280
311
  const flowProducer = new FlowProducer(
281
312
  buildBullMQOptions(node.config, connection)
282
313
  );
283
- node.resources.add(flowProducer);
314
+ node.resources.set(flowProducer, connection);
284
315
  return flowProducer;
285
316
  };
286
317
 
287
318
  node.on("close", async function onClose(removed, done) {
288
319
  try {
289
- if (node.queue) {
290
- await closeResource(node.queue);
291
- }
292
- const resources = Array.from(node.resources).reverse();
293
- for (const resource of resources) {
294
- await closeResource(resource);
295
- }
320
+ const resources = Array.from(node.resources.entries()).reverse();
321
+ node.resources.clear();
322
+ await Promise.all(
323
+ resources.map(([owner, connection]) =>
324
+ closeResourcePair(owner, connection)
325
+ )
326
+ );
296
327
  node.status({});
297
328
  done();
298
329
  } catch (err) {
@@ -377,12 +408,21 @@ module.exports = function registerBullMQNodes(RED) {
377
408
  node.bullQueue.register(node);
378
409
 
379
410
  const workerOptions = {
380
- concurrency: parsePositiveInteger(n.concurrency, 1),
411
+ concurrency: parsePositiveInteger(n.concurrency, 1, "Concurrency"),
381
412
  };
382
- if (n.limiterMax && n.limiterDuration) {
413
+ const hasLimiterMax = isPresent(n.limiterMax);
414
+ const hasLimiterDuration = isPresent(n.limiterDuration);
415
+ if (hasLimiterMax !== hasLimiterDuration) {
416
+ throw new Error("Limiter Max and Limiter Duration must be set together");
417
+ }
418
+ if (hasLimiterMax) {
383
419
  workerOptions.limiter = {
384
- max: parsePositiveInteger(n.limiterMax),
385
- duration: parsePositiveInteger(n.limiterDuration),
420
+ max: parsePositiveInteger(n.limiterMax, undefined, "Limiter Max"),
421
+ duration: parsePositiveInteger(
422
+ n.limiterDuration,
423
+ undefined,
424
+ "Limiter Duration"
425
+ ),
386
426
  };
387
427
  }
388
428
 
@@ -424,7 +464,7 @@ module.exports = function registerBullMQNodes(RED) {
424
464
  new Error("BullMQ run node closed before acknowledgement")
425
465
  );
426
466
  try {
427
- await closeResource(node.worker);
467
+ await node.bullQueue.releaseResource(node.worker);
428
468
  node.bullQueue.deregister(node, () => {});
429
469
  done();
430
470
  } catch (err) {
@@ -555,7 +595,7 @@ module.exports = function registerBullMQNodes(RED) {
555
595
 
556
596
  node.on("close", async function onClose(removed, done) {
557
597
  try {
558
- await closeResource(node.queueEvents);
598
+ await node.bullConn.releaseResource(node.queueEvents);
559
599
  node.bullConn.deregister(node, () => {});
560
600
  done();
561
601
  } catch (err) {
@@ -608,7 +648,7 @@ module.exports = function registerBullMQNodes(RED) {
608
648
 
609
649
  node.on("close", async function onClose(removed, done) {
610
650
  try {
611
- await closeResource(node.flowProducer);
651
+ await node.bullConn.releaseResource(node.flowProducer);
612
652
  node.bullConn.deregister(node, () => {});
613
653
  done();
614
654
  } catch (err) {
@@ -24,7 +24,9 @@ The runtime does Node-RED lifecycle work only: creating nodes, wiring input hand
24
24
  - QueueEvents uses a dedicated connection;
25
25
  - Cluster and MemoryDB use `{bull}` by default as the BullMQ prefix.
26
26
 
27
- Connection and resource errors are reported on the consuming runtime node's status (`bull run`, `bull events`, `bull flow`). The shared producer connection and queue used by `bull cmd` report on the config node.
27
+ Each BullMQ owner (`Queue`, `Worker`, `QueueEvents`, or `FlowProducer`) is tracked with its owned ioredis connection. A runtime node releases its pair on redeploy; config-node shutdown closes independent pairs concurrently, always attempting the BullMQ owner before its raw connection.
28
+
29
+ Connection and resource errors are reported on the consuming runtime node's status (`bull run`, `bull events`, `bull flow`). The config node owns the shared queue and its producer connection. Each `bull cmd` mirrors that shared producer connection on its visible status, while Queue errors report on the config node.
28
30
 
29
31
  Secrets are read from Node-RED credentials first, with legacy plain fields accepted only for backward compatibility.
30
32
 
package/docs/COMMANDS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Command Reference
2
2
 
3
- `bull cmd` reads `msg.cmd`. It writes the command result to `msg.payload`.
3
+ `bull cmd` reads `msg.cmd`. The `msg.command` field is a legacy alias; use `msg.cmd` in new flows. The node writes the command result to `msg.payload`.
4
4
 
5
5
  ## Jobs
6
6
 
@@ -8,15 +8,15 @@ Producer commands use bounded retries so Node-RED input handlers fail instead of
8
8
 
9
9
  ## Redis Cluster
10
10
 
11
- Use deployment `cluster` and provide startup nodes as comma or newline separated `host:port` values.
11
+ Use deployment `cluster` and provide startup nodes as a comma- or newline-separated endpoint list.
12
12
 
13
- Cluster auth and TLS are applied through ioredis `redisOptions`. The runtime sets a DNS lookup passthrough for TLS-enabled cluster discovery.
13
+ Cluster auth and TLS are applied through ioredis `redisOptions`. The runtime always sets a DNS lookup passthrough for cluster discovery, including non-TLS deployments.
14
14
 
15
15
  The BullMQ prefix must contain a Redis hash tag, normally `{bull}`. Untagged Cluster prefixes are rejected to prevent `CROSSSLOT` failures.
16
16
 
17
17
  ## AWS MemoryDB
18
18
 
19
- Use deployment `cluster`.
19
+ Use deployment `cluster`. The legacy `memorydb` deployment value remains a compatibility alias for `cluster` in imported flows.
20
20
 
21
21
  Typical settings:
22
22
 
@@ -41,14 +41,24 @@ Use deployment `sentinel` and configure:
41
41
 
42
42
  Sentinel authentication is separate from Redis data-node authentication.
43
43
 
44
+ ## Cluster And Sentinel Endpoint Lists
45
+
46
+ Cluster startup nodes and Sentinel discovery lists accept host names, `host:port`, bare IPv6 with the default port, bracketed IPv6 such as `[2001:db8::1]:6379`, and `redis://` or `rediss://` URLs. Explicit URL schemes must agree with the applicable TLS setting: Cluster endpoints use `tls`, and Sentinel discovery endpoints use `sentinelTls`.
47
+
48
+ URL credentials are rejected. Store Redis and Sentinel usernames/passwords in the config node credential fields instead of embedding them in an endpoint.
49
+
44
50
  ## TLS
45
51
 
46
- TLS options:
52
+ TLS behavior and options:
47
53
 
48
- - verify unauthorized certificates by default;
54
+ - rejects unauthorized certificates by default;
49
55
  - optional CA;
50
56
  - optional client certificate;
51
57
  - optional client private key;
52
58
  - optional server name.
53
59
 
54
60
  Disable verification only when the Redis deployment cannot be configured with a trusted CA and the risk is understood.
61
+
62
+ ## Secrets And Imported Flows
63
+
64
+ Passwords, CA data, client certificates, and private keys are stored in Node-RED credentials. Plaintext config fields remain a migration-only fallback for older imported flows; edit and redeploy those config nodes so secrets move into the credential store.
package/docs/MIGRATION.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## What Changes
11
11
 
12
- - Runtime dependency is BullMQ 5.80.2.
12
+ - Runtime dependency is BullMQ 5.80.9.
13
13
  - `bull` and `sprintf-js` are removed.
14
14
  - Repeatable jobs use BullMQ Job Schedulers.
15
15
  - Scheduled jobs require a stable scheduler id.
@@ -19,6 +19,7 @@ Input node for producer and administration commands.
19
19
  Input:
20
20
 
21
21
  - `msg.cmd`: command name. Defaults to `add`.
22
+ - `msg.command`: legacy alias for `msg.cmd`; use `msg.cmd` in new flows.
22
23
  - `msg.payload`: compatibility payload.
23
24
  - `msg.jobData`: full BullMQ job data when supplied.
24
25
  - `msg.jobName`: BullMQ job name. Defaults to `default`.
@@ -44,6 +45,8 @@ Completion modes:
44
45
  - `immediate`: complete after sending the message.
45
46
  - `manual`: wait for downstream `bull job` acknowledgement. Fails the job after the ack timeout; set the timeout to `0` to wait indefinitely.
46
47
 
48
+ Concurrency must be a positive integer. The optional limiter maximum and duration must either both be blank or both be positive integers.
49
+
47
50
  ## `bull job`
48
51
 
49
52
  Acts on manual-mode active jobs. Actions can be configured or supplied in `msg.cmd`.
@@ -65,7 +68,7 @@ Non-terminal actions:
65
68
 
66
69
  ## `bull events`
67
70
 
68
- QueueEvents source node. Empty event filter subscribes to the default documented event list.
71
+ QueueEvents source node. An empty event filter subscribes to: `active`, `added`, `cleaned`, `completed`, `deduplicated`, `delayed`, `drained`, `duplicated`, `failed`, `paused`, `progress`, `removed`, `resumed`, `stalled`, `waiting`, and `waiting-children`.
69
72
 
70
73
  Output:
71
74
 
@@ -21,6 +21,8 @@
21
21
  - `test/scheduler.test.js`: repeat scheduler compatibility.
22
22
  - `test/commands.test.js`: command dispatch behavior.
23
23
  - `test/acknowledgements.test.js`: manual-acknowledgement registry lifecycle and leak prevention.
24
+ - `test/shutdown.test.js`: BullMQ/ioredis resource ownership, partial redeploy cleanup, and concurrent shutdown.
25
+ - `test/async-style.test.js`: bounded-close async implementation constraints.
24
26
  - `test/node-red-registration.test.js`: Node-RED node type registration.
25
27
  - `test/editor-contract.test.js`: static editor surface.
26
28
  - `test/docs-contract.test.js`: required docs and examples.
package/docs/RELEASE.md CHANGED
@@ -13,7 +13,7 @@ used here only for the one-time first publish.)
13
13
 
14
14
  1. Confirm `package.json` has the intended `version` and the name `@pauldeng/node-red-contrib-bullmq`.
15
15
  2. Confirm the GitHub repository is `https://github.com/pauldeng/node-red-contrib-bullmq`.
16
- 3. Confirm BullMQ is pinned to exactly `5.80.2`.
16
+ 3. Confirm BullMQ is pinned to exactly `5.80.9`.
17
17
  4. Confirm no examples, docs, fixtures, or logs contain Redis, Sentinel, or MemoryDB secrets.
18
18
  5. Update `CHANGELOG.md` for the new version.
19
19
 
@@ -56,7 +56,7 @@ Do these once, before the first release.
56
56
  - [ ] **Branch ruleset** on `master` (Settings -> Rules -> Rulesets): require a pull request with at least one review, require the CI status checks to pass, block force pushes, restrict deletions. Optionally require linear history and signed commits.
57
57
  - [ ] **Code security and analysis** (Settings): enable Dependabot alerts, Dependabot security updates, secret scanning, push protection, and private vulnerability reporting.
58
58
  - [ ] **Workflow permissions** (Settings -> Actions -> General): set the default `GITHUB_TOKEN` to read-only.
59
- - [ ] **CodeQL**: either rely on `.github/workflows/codeql.yml`, or enable Code scanning "default setup" in the Security tab.
59
+ - [ ] **CodeQL**: enable Code scanning "default setup" in the Security tab.
60
60
  - [ ] **`release` environment** (Settings -> Environments -> New environment -> `release`): add yourself as a required reviewer so `publish.yml` waits for manual approval.
61
61
 
62
62
  ## 4. First publish (one time, manual)
package/docs/TESTING.md CHANGED
@@ -6,7 +6,7 @@
6
6
  npm test
7
7
  ```
8
8
 
9
- This runs built-in `node:test` suites for package metadata, connection normalization, scheduler compatibility, command dispatch, editor surface, registration, Docker fixture contracts, docs, and examples.
9
+ This runs built-in `node:test` suites for package metadata, connection normalization, scheduler compatibility, command dispatch, editor surface, registration, resource shutdown, async style, Docker fixture contracts, docs, and examples. The lifecycle checks live in `test/shutdown.test.js`; bounded-close implementation constraints live in `test/async-style.test.js`.
10
10
 
11
11
  ## Node-RED Runtime Tests
12
12
 
@@ -66,7 +66,7 @@ Current executable fixtures:
66
66
  - `sentinel-auth`: Redis master, two replicas, and three Sentinels with data-node ACL auth
67
67
  - `sentinel-tls`: Redis master, two replicas, and three TLS-enabled Sentinels
68
68
 
69
- The shared deployment test proves Node-RED load, connection, add/run delivery, required `basecasts` scheduler creation/removal, and absolute scheduler minute/second metadata. TLS fixtures use local self-signed test certificates and disable certificate verification for those Docker-only deployments. MemoryDB remains the certificate-verified TLS deployment path.
69
+ The shared deployment test proves Node-RED load, connection, add/run delivery, legacy scheduler compatibility through `basecasts` creation/removal, and absolute scheduler minute/second metadata. TLS fixtures use local self-signed test certificates and disable certificate verification for those Docker-only deployments. MemoryDB remains the certificate-verified TLS deployment path.
70
70
 
71
71
  ## AWS MemoryDB
72
72
 
@@ -21,7 +21,7 @@ Keep TLS verification enabled when possible. Provide the CA certificate or serve
21
21
 
22
22
  ## Repeat Job Is Not Found
23
23
 
24
- Legacy repeat lookup uses exact scheduler ids. Use `msg.schedulerId`, `msg.jobopts.jobId`, `msg.jobid`, or `msg.jobId` consistently.
24
+ Legacy repeat lookup uses exact scheduler ids. Pass the id returned during creation in `msg.schedulerId`, `msg.jobid`, or `msg.jobId`. A job option id can help derive the scheduler id during creation, but it is not a separate lookup field.
25
25
 
26
26
  ## Bull v4 Queue Data Missing After Upgrade
27
27
 
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ const { isIP } = require("node:net");
4
+
3
5
  const DEFAULT_REDIS_HOST = "localhost";
4
6
  const DEFAULT_REDIS_PORT = 6379;
5
7
  const CLUSTER_PREFIX = "{bull}";
@@ -32,7 +34,12 @@ function toPort(value, defaultValue = DEFAULT_REDIS_PORT) {
32
34
  return port;
33
35
  }
34
36
 
35
- function parseEndpoint(endpoint, defaultPort = DEFAULT_REDIS_PORT) {
37
+ function parseEndpoint(
38
+ endpoint,
39
+ defaultPort = DEFAULT_REDIS_PORT,
40
+ tls,
41
+ tlsName = "TLS",
42
+ ) {
36
43
  if (typeof endpoint === "object" && endpoint !== null) {
37
44
  const host = endpoint.host || endpoint.address;
38
45
  if (!isPresent(host)) {
@@ -48,36 +55,54 @@ function parseEndpoint(endpoint, defaultPort = DEFAULT_REDIS_PORT) {
48
55
  if (!text) {
49
56
  throw new Error("Redis endpoint cannot be empty");
50
57
  }
58
+ if (isIP(text)) {
59
+ return { host: text, port: defaultPort };
60
+ }
51
61
 
52
- let host = text;
53
- let port = defaultPort;
54
- const urlMatch = text.match(
55
- /^(?:redis|rediss):\/\/(?:[^@]+@)?([^/:]+)(?::(\d+))?/i,
56
- );
57
- if (urlMatch) {
58
- host = urlMatch[1];
59
- port = toPort(urlMatch[2], defaultPort);
60
- } else if (text.includes(":")) {
61
- const parts = text.split(":");
62
- port = toPort(parts.pop(), defaultPort);
63
- host = parts.join(":");
62
+ const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text);
63
+ let url;
64
+ try {
65
+ url = new URL(hasScheme ? text : `redis://${text}`);
66
+ } catch {
67
+ throw new Error(`Invalid Redis endpoint: ${text}`);
68
+ }
69
+ if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
70
+ throw new Error(`Unsupported Redis endpoint protocol: ${url.protocol}`);
71
+ }
72
+ if (url.username || url.password) {
73
+ throw new Error(
74
+ "Redis endpoint URLs cannot include credentials; use the config node credential fields",
75
+ );
76
+ }
77
+ if (hasScheme && tls !== undefined) {
78
+ const secure = url.protocol === "rediss:";
79
+ if (secure !== tls) {
80
+ throw new Error(
81
+ `${url.protocol}// endpoint requires ${tlsName} to be ${secure ? "enabled" : "disabled"}`,
82
+ );
83
+ }
64
84
  }
65
85
 
66
- if (!host.trim()) {
86
+ const host = url.hostname.replace(/^\[|\]$/g, "");
87
+ if (!host) {
67
88
  throw new Error(`Redis endpoint requires a host: ${text}`);
68
89
  }
69
-
70
- return { host: host.trim(), port };
90
+ return { host, port: toPort(url.port, defaultPort) };
71
91
  }
72
92
 
73
- function parseEndpointList(value, defaultPort = DEFAULT_REDIS_PORT) {
93
+ function parseEndpointList(
94
+ value,
95
+ defaultPort = DEFAULT_REDIS_PORT,
96
+ tls,
97
+ tlsName = "TLS",
98
+ ) {
74
99
  if (Array.isArray(value)) {
75
100
  return value
76
101
  .flatMap((item) =>
77
102
  typeof item === "string" ? item.split(/[\n,]+/) : [item],
78
103
  )
79
104
  .filter((item) => isPresent(item))
80
- .map((item) => parseEndpoint(item, defaultPort));
105
+ .map((item) => parseEndpoint(item, defaultPort, tls, tlsName));
81
106
  }
82
107
 
83
108
  if (!isPresent(value)) {
@@ -88,7 +113,7 @@ function parseEndpointList(value, defaultPort = DEFAULT_REDIS_PORT) {
88
113
  .split(/[\n,]+/)
89
114
  .map((item) => item.trim())
90
115
  .filter(Boolean)
91
- .map((item) => parseEndpoint(item, defaultPort));
116
+ .map((item) => parseEndpoint(item, defaultPort, tls, tlsName));
92
117
  }
93
118
 
94
119
  function readSecret(config, credentials, name) {
@@ -115,6 +140,8 @@ function normalizeQueueConfig(config = {}, credentials = {}) {
115
140
  if (!queueName) {
116
141
  throw new Error("BullMQ queue name is required");
117
142
  }
143
+ const tls = toBoolean(config.tls, false);
144
+ const sentinelTls = toBoolean(config.sentinelTls, false);
118
145
 
119
146
  const normalized = {
120
147
  queueName,
@@ -124,21 +151,31 @@ function normalizeQueueConfig(config = {}, credentials = {}) {
124
151
  db: isPresent(config.db) ? Number(config.db) : undefined,
125
152
  username: config.username || undefined,
126
153
  password: readSecret(config, credentials, "password") || undefined,
127
- tls: toBoolean(config.tls, false),
154
+ tls,
128
155
  tlsRejectUnauthorized: toBoolean(config.tlsRejectUnauthorized, true),
129
156
  tlsCa: readSecret(config, credentials, "tlsCa") || undefined,
130
157
  tlsCert: readSecret(config, credentials, "tlsCert") || undefined,
131
158
  tlsKey: readSecret(config, credentials, "tlsKey") || undefined,
132
159
  tlsServerName: config.tlsServerName || undefined,
133
160
  prefix: config.prefix || undefined,
134
- clusterNodes: parseEndpointList(config.clusterNodes || config.startupNodes),
161
+ clusterNodes: parseEndpointList(
162
+ config.clusterNodes || config.startupNodes,
163
+ DEFAULT_REDIS_PORT,
164
+ tls,
165
+ "TLS",
166
+ ),
135
167
  sentinelMasterName:
136
168
  config.sentinelMasterName || config.masterName || config.nameOfMaster,
137
- sentinels: parseEndpointList(config.sentinels, 26379),
169
+ sentinels: parseEndpointList(
170
+ config.sentinels,
171
+ 26379,
172
+ sentinelTls,
173
+ "Sentinel TLS",
174
+ ),
138
175
  sentinelUsername: config.sentinelUsername || undefined,
139
176
  sentinelPassword:
140
177
  readSecret(config, credentials, "sentinelPassword") || undefined,
141
- sentinelTls: toBoolean(config.sentinelTls, false),
178
+ sentinelTls,
142
179
  };
143
180
 
144
181
  if (deployment === "cluster") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pauldeng/node-red-contrib-bullmq",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "BullMQ-backed Redis job queue nodes for Node-RED",
5
5
  "main": "bull-queue.js",
6
6
  "files": [
@@ -25,8 +25,8 @@
25
25
  "validate": "npx --yes node-red-dev validate"
26
26
  },
27
27
  "dependencies": {
28
- "bullmq": "5.80.2",
29
- "ioredis": "5.10.1"
28
+ "bullmq": "5.80.9",
29
+ "ioredis": "5.11.1"
30
30
  },
31
31
  "license": "MIT",
32
32
  "keywords": [