node-red-contrib-redis-variable 1.4.1 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-redis-variable",
3
- "version": "1.4.1",
3
+ "version": "1.5.2",
4
4
  "description": "A comprehensive Node-RED node for Redis operations with universal payload-based configuration, automatic JSON handling, SSL/TLS support, and advanced pattern matching with pagination",
5
5
  "keywords": [
6
6
  "node-red",
@@ -43,11 +43,11 @@ module.exports = function (RED) {
43
43
  // Helper function to get nested values like "redis_config.host"
44
44
  function getNestedValue(context, path) {
45
45
  if (!context) return undefined;
46
-
46
+
47
47
  if (path.includes('.')) {
48
48
  const parts = path.split('.');
49
49
  let result = context.get(parts[0]);
50
-
50
+
51
51
  // If the first part returns an object, traverse it
52
52
  if (result && typeof result === 'object') {
53
53
  for (let i = 1; i < parts.length; i++) {
@@ -71,7 +71,7 @@ module.exports = function (RED) {
71
71
  RED.nodes.createNode(this, config);
72
72
  this.name = config.name || "Redis Config";
73
73
  this.cluster = config.cluster || false;
74
-
74
+
75
75
  // Connection configuration
76
76
  this.hostType = config.hostType || 'str';
77
77
  this.host = config.host || 'localhost';
@@ -79,7 +79,7 @@ module.exports = function (RED) {
79
79
  this.port = config.port || 6379;
80
80
  this.portType = config.portType || 'str';
81
81
  this.portContext = config.portContext;
82
-
82
+
83
83
  // Authentication
84
84
  this.passwordType = config.passwordType || 'str';
85
85
  this.password = config.password;
@@ -87,7 +87,7 @@ module.exports = function (RED) {
87
87
  this.username = config.username;
88
88
  this.usernameType = config.usernameType || 'str';
89
89
  this.usernameContext = config.usernameContext;
90
-
90
+
91
91
  // SSL/TLS Configuration
92
92
  this.enableTLS = config.enableTLS || false;
93
93
  this.tlsRejectUnauthorized = config.tlsRejectUnauthorized !== false; // Default to true
@@ -97,12 +97,12 @@ module.exports = function (RED) {
97
97
  this.tlsKeyContext = config.tlsKeyContext;
98
98
  this.tlsCaType = config.tlsCaType || 'str';
99
99
  this.tlsCaContext = config.tlsCaContext;
100
-
100
+
101
101
  // Database and other options
102
102
  this.database = config.database || 0;
103
103
  this.databaseType = config.databaseType || 'str';
104
104
  this.databaseContext = config.databaseContext;
105
-
105
+
106
106
  // Advanced options
107
107
  this.optionsType = config.optionsType || 'json';
108
108
  this.options = config.options || '{}';
@@ -116,7 +116,7 @@ module.exports = function (RED) {
116
116
  if (!value && value !== 0) {
117
117
  return null;
118
118
  }
119
-
119
+
120
120
  try {
121
121
  let result;
122
122
  switch (type) {
@@ -164,11 +164,11 @@ module.exports = function (RED) {
164
164
  if (this.hostType === 'str' && this.portType === 'str') {
165
165
  return true;
166
166
  }
167
-
167
+
168
168
  // For context-based configuration, check if the required values exist
169
169
  let hasHost = false;
170
170
  let hasPort = false;
171
-
171
+
172
172
  // Check host configuration
173
173
  if (this.hostType === 'str') {
174
174
  hasHost = !!(this.host);
@@ -176,7 +176,7 @@ module.exports = function (RED) {
176
176
  const contextHost = this.parseCredentialValue(this.hostContext, this.hostType, msg, executingNode);
177
177
  hasHost = !!(contextHost);
178
178
  }
179
-
179
+
180
180
  // Check port configuration
181
181
  if (this.portType === 'str') {
182
182
  hasPort = !!(this.port);
@@ -184,16 +184,16 @@ module.exports = function (RED) {
184
184
  const contextPort = this.parseCredentialValue(this.portContext, this.portType, msg, executingNode);
185
185
  hasPort = !!(contextPort);
186
186
  }
187
-
187
+
188
188
  // We need at least host and port to have a valid configuration
189
189
  const result = hasHost && hasPort;
190
-
190
+
191
191
  if (executingNode && process.env.NODE_RED_DEBUG) {
192
192
  executingNode.log(`Configuration check - hasHost: ${hasHost}, hasPort: ${hasPort}, result: ${result}`);
193
193
  }
194
-
194
+
195
195
  return result;
196
-
196
+
197
197
  } catch (error) {
198
198
  if (executingNode) {
199
199
  executingNode.error(`Error checking configuration: ${error.message}`);
@@ -339,7 +339,7 @@ module.exports = function (RED) {
339
339
  this.getClient = function(msg, executingNode, nodeId) {
340
340
  try {
341
341
  const id = nodeId || this.id;
342
-
342
+
343
343
  // Return existing connection if available
344
344
  if (connections[id]) {
345
345
  usedConn[id]++;
@@ -353,7 +353,7 @@ module.exports = function (RED) {
353
353
  if (!executingNode._configWarningShown) {
354
354
  executingNode.warn("Redis configuration not available in context. Skipping connection attempt.");
355
355
  executingNode._configWarningShown = true;
356
-
356
+
357
357
  // Reset warning flag after 30 seconds
358
358
  setTimeout(() => {
359
359
  if (executingNode) {
@@ -382,58 +382,26 @@ module.exports = function (RED) {
382
382
  maxRetriesPerRequest: 0
383
383
  };
384
384
 
385
- // Create Redis client
386
385
  // Create Redis client
387
386
  let client;
388
387
  if (this.cluster) {
389
- // Prepare cluster node(s)
390
- const host = options.host;
391
- const port = parseInt(options.port);
392
-
393
- // AWS ElastiCache cluster connection (TLS-safe)
394
- const clusterNodes = [{ host, port }];
395
-
388
+ // ioredis Cluster: first arg = startup nodes [{ host, port }], second = options with redisOptions
389
+ const clusterNodes = [{ host: options.host, port: parseInt(options.port, 10) }];
396
390
  const clusterOptions = {
397
- scaleReads: "slave",
398
391
  enableReadyCheck: false,
399
- slotsRefreshTimeout: 5000,
392
+ lazyConnect: true,
393
+ maxRetriesPerRequest: 3,
400
394
  redisOptions: {
401
395
  username: options.username || undefined,
402
396
  password: options.password || undefined,
403
- db: options.db || 0,
404
- connectTimeout: 5000,
405
- maxRetriesPerRequest: 3,
406
- retryStrategy: (times) => Math.min(times * 200, 2000),
407
- tls: this.enableTLS
408
- ? {
409
- servername: host,
410
- rejectUnauthorized: this.tlsRejectUnauthorized,
411
- ca: this.credentials?.tlsCa || undefined,
412
- cert: this.credentials?.tlsCert || undefined,
413
- key: this.credentials?.tlsKey || undefined,
414
- }
415
- : undefined,
397
+ db: 0,
398
+ connectTimeout: connectionOptions.connectTimeout || 5000,
399
+ tls: connectionOptions.tls || undefined,
416
400
  },
417
401
  };
418
-
419
402
  client = new Redis.Cluster(clusterNodes, clusterOptions);
420
403
  } else {
421
- // Non-cluster (single node)
422
- client = new Redis({
423
- ...options,
424
- enableReadyCheck: false,
425
- connectTimeout: 5000,
426
- maxRetriesPerRequest: 3,
427
- tls: this.enableTLS
428
- ? {
429
- servername: options.host,
430
- rejectUnauthorized: this.tlsRejectUnauthorized,
431
- ca: this.credentials?.tlsCa || undefined,
432
- cert: this.credentials?.tlsCert || undefined,
433
- key: this.credentials?.tlsKey || undefined,
434
- }
435
- : undefined,
436
- });
404
+ client = new Redis(connectionOptions);
437
405
  }
438
406
 
439
407
  // Track error state to prevent spam
@@ -444,11 +412,11 @@ module.exports = function (RED) {
444
412
  // Handle connection errors
445
413
  client.on("error", (e) => {
446
414
  const now = Date.now();
447
-
415
+
448
416
  // Only report errors once per interval to prevent spam
449
417
  if (!errorReported || (now - lastErrorTime) > ERROR_REPORT_INTERVAL) {
450
418
  let errorMsg = `Redis connection error: ${e.message}`;
451
-
419
+
452
420
  // Add specific diagnostics for common SSL issues
453
421
  if (e.message.includes("Protocol error") || e.message.includes("\\u0015")) {
454
422
  errorMsg += "\nThis usually indicates an SSL/TLS configuration issue. Try:";
@@ -457,13 +425,13 @@ module.exports = function (RED) {
457
425
  errorMsg += "\n3. Check if your Redis server is configured for SSL";
458
426
  errorMsg += "\n4. Enable 'Debug: Force No SSL' option for testing";
459
427
  }
460
-
428
+
461
429
  if (executingNode) {
462
430
  executingNode.error(errorMsg, {});
463
431
  } else {
464
432
  this.error(errorMsg, {});
465
433
  }
466
-
434
+
467
435
  errorReported = true;
468
436
  lastErrorTime = now;
469
437
  }
package/redis-variable.js CHANGED
@@ -4,7 +4,7 @@ module.exports = function (RED) {
4
4
  function RedisVariableNode(config) {
5
5
  RED.nodes.createNode(this, config);
6
6
  const node = this;
7
-
7
+
8
8
  // Node configuration
9
9
  this.operation = config.operation || "get";
10
10
  this.timeout = config.timeout || 0;
@@ -52,6 +52,19 @@ module.exports = function (RED) {
52
52
  return str;
53
53
  }
54
54
 
55
+ function stableStringify(value) {
56
+ if (value === null || value === undefined) return String(value);
57
+ if (Array.isArray(value)) {
58
+ return `[${value.map(stableStringify).join(',')}]`;
59
+ }
60
+ if (typeof value === 'object') {
61
+ const keys = Object.keys(value).sort();
62
+ const entries = keys.map((key) => `"${key}":${stableStringify(value[key])}`);
63
+ return `{${entries.join(',')}}`;
64
+ }
65
+ return JSON.stringify(value);
66
+ }
67
+
55
68
  // Handle different operations
56
69
  switch (this.operation) {
57
70
  case "subscribe":
@@ -296,6 +309,21 @@ module.exports = function (RED) {
296
309
  send = send || function() { node.send.apply(node, arguments) };
297
310
  done = done || function(err) { if(err) node.error(err, msg); };
298
311
 
312
+ // Ensure we only send and call done once per message
313
+ let completed = false;
314
+ const finish = (err) => {
315
+ if (completed) return;
316
+ completed = true;
317
+ if (err) node.error(err.message || err, msg);
318
+ done(err);
319
+ };
320
+ const sendAndDone = (m) => {
321
+ if (completed) return;
322
+ completed = true;
323
+ send(m);
324
+ done();
325
+ };
326
+
299
327
  if (!running) {
300
328
  running = true;
301
329
  }
@@ -304,8 +332,26 @@ module.exports = function (RED) {
304
332
  if (!redisConfig) {
305
333
  node.error("Redis configuration not found", msg);
306
334
  msg.payload = { error: "Redis configuration not found" };
307
- send(msg);
308
- done();
335
+ sendAndDone(msg);
336
+ return;
337
+ }
338
+
339
+ // Recreate client when connection options change (e.g., context-based config)
340
+ try {
341
+ const options = redisConfig.getConnectionOptions(msg, node);
342
+ const configKey = stableStringify({
343
+ cluster: redisConfig.cluster === true,
344
+ options
345
+ });
346
+ if (node._lastConnKey && node._lastConnKey !== configKey) {
347
+ redisConfig.forceDisconnect(node.id);
348
+ client = null;
349
+ }
350
+ node._lastConnKey = configKey;
351
+ } catch (configError) {
352
+ node.error(configError.message, msg);
353
+ msg.payload = { error: configError.message };
354
+ sendAndDone(msg);
309
355
  return;
310
356
  }
311
357
 
@@ -317,41 +363,50 @@ module.exports = function (RED) {
317
363
  node.warn("Redis configuration not available. Operation skipped.");
318
364
  node.status({ fill: "yellow", shape: "ring", text: "no config" });
319
365
  msg.payload = { error: "Redis configuration not available" };
320
- send(msg);
321
- done();
366
+ sendAndDone(msg);
322
367
  return;
323
368
  }
324
369
  }
325
-
326
- // Check if client is connected before proceeding
370
+
371
+ // Ensure client is connected before running commands
327
372
  if (client.status !== 'ready' && client.status !== 'connect') {
328
- // Try to connect if not connected
329
- if (client.status === 'disconnect' || client.status === 'end') {
330
- await client.connect();
331
- } else {
332
- // Force disconnect and recreate client for other error states
333
- try {
373
+ const CONNECT_WAIT_MS = 15000;
374
+ const waitForReady = () => new Promise((resolve, reject) => {
375
+ const timeout = setTimeout(() => reject(new Error('Connection timeout')), CONNECT_WAIT_MS);
376
+ const onReady = () => { clearTimeout(timeout); cleanup(); resolve(); };
377
+ const onError = (e) => { clearTimeout(timeout); cleanup(); reject(e); };
378
+ const cleanup = () => {
379
+ client.removeListener('ready', onReady);
380
+ client.removeListener('connect', onReady);
381
+ client.removeListener('error', onError);
382
+ };
383
+ client.once('ready', onReady);
384
+ client.once('connect', onReady);
385
+ client.once('error', onError);
386
+ });
387
+ try {
388
+ if (client.status === 'connecting') {
389
+ // Already connecting: wait for ready, do not call connect() again
390
+ await waitForReady();
391
+ } else if (typeof client.connect === 'function') {
392
+ await client.connect();
393
+ }
394
+ } catch (connectErr) {
395
+ const msg = connectErr.message || String(connectErr);
396
+ if (msg.includes('already connecting') || msg.includes('already connected')) {
397
+ // Race: another message triggered connect; wait for ready
398
+ await waitForReady();
399
+ } else {
334
400
  redisConfig.forceDisconnect(node.id);
335
401
  client = null;
336
- client = redisConfig.getClient(msg, node, node.id);
337
- if (!client) {
338
- node.warn("Redis configuration not available during reconnection. Operation skipped.");
339
- node.status({ fill: "yellow", shape: "ring", text: "no config" });
340
- msg.payload = { error: "Redis configuration not available" };
341
- send(msg);
342
- done();
343
- return;
344
- }
345
- } catch (reconnectError) {
346
- throw new Error(`Failed to reconnect: ${reconnectError.message}`);
402
+ throw new Error(`Failed to connect: ${msg}`);
347
403
  }
348
404
  }
349
405
  }
350
406
  } catch (err) {
351
407
  node.error(err.message, msg);
352
408
  msg.payload = { error: err.message };
353
- send(msg);
354
- done();
409
+ sendAndDone(msg);
355
410
  return;
356
411
  }
357
412
 
@@ -380,12 +435,25 @@ module.exports = function (RED) {
380
435
  if (!payload.key) {
381
436
  throw new Error("Missing key for SET operation. Use payload.key");
382
437
  }
383
- let setValue = payload.value !== undefined ? payload.value : payload.data;
438
+ const payloadIsObject = payload && typeof payload === 'object' && !Array.isArray(payload);
439
+ const payloadHasKey = payloadIsObject && Object.prototype.hasOwnProperty.call(payload, 'key');
440
+ const payloadHasValue = payloadIsObject && (
441
+ Object.prototype.hasOwnProperty.call(payload, 'value') ||
442
+ Object.prototype.hasOwnProperty.call(payload, 'data')
443
+ );
444
+ let setValue;
445
+ if (payloadHasValue) {
446
+ setValue = payload.value !== undefined ? payload.value : payload.data;
447
+ } else if (payloadHasKey) {
448
+ setValue = undefined;
449
+ } else {
450
+ setValue = payload;
451
+ }
384
452
  if (setValue === undefined) {
385
- throw new Error("Missing value for SET operation. Use payload.value or payload.data");
453
+ throw new Error("Missing value for SET operation. Use payload.value, payload.data, or payload as value");
386
454
  }
387
455
  setValue = smartSerialize(setValue);
388
-
456
+
389
457
  // Support TTL
390
458
  if (payload.ttl && payload.ttl > 0) {
391
459
  response = await client.setex(payload.key, payload.ttl, setValue);
@@ -420,14 +488,14 @@ module.exports = function (RED) {
420
488
  if (!pattern || typeof pattern !== 'string') {
421
489
  throw new Error("Missing pattern for MATCH operation. Use payload.pattern or payload as string");
422
490
  }
423
-
491
+
424
492
  let count = payload.count || 100; // Default scan count
425
493
  let startCursor = payload.cursor || payload.skip || 0; // Support cursor/skip for pagination
426
494
  let allKeys = [];
427
495
  let cursor = startCursor;
428
496
  let maxIterations = 1000; // Prevent infinite loops
429
497
  let iterations = 0;
430
-
498
+
431
499
  // If skip is specified, we need to scan through keys without collecting them
432
500
  if (payload.skip && payload.skip > 0) {
433
501
  let skipped = 0;
@@ -438,7 +506,7 @@ module.exports = function (RED) {
438
506
  const scanResult = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count);
439
507
  cursor = scanResult[0];
440
508
  skipped += scanResult[1].length;
441
-
509
+
442
510
  if (skipped >= payload.skip) {
443
511
  // We've skipped enough, start collecting from this point
444
512
  const excessSkipped = skipped - payload.skip;
@@ -450,7 +518,7 @@ module.exports = function (RED) {
450
518
  }
451
519
  } while (cursor !== 0 && cursor !== '0');
452
520
  }
453
-
521
+
454
522
  // Continue scanning to collect keys up to the limit
455
523
  do {
456
524
  if (iterations++ > maxIterations) {
@@ -459,15 +527,15 @@ module.exports = function (RED) {
459
527
  const scanResult = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count);
460
528
  cursor = scanResult[0];
461
529
  allKeys = allKeys.concat(scanResult[1]);
462
-
530
+
463
531
  // Break early if we've found enough keys
464
532
  if (allKeys.length >= count) {
465
533
  allKeys = allKeys.slice(0, count); // Trim to exact count
466
534
  break;
467
535
  }
468
536
  } while (cursor !== 0 && cursor !== '0');
469
-
470
- msg.payload = {
537
+
538
+ msg.payload = {
471
539
  pattern: pattern,
472
540
  keys: allKeys,
473
541
  count: allKeys.length,
@@ -487,7 +555,7 @@ module.exports = function (RED) {
487
555
  throw new Error("Missing key for TTL operation. Use payload.key or payload as string");
488
556
  }
489
557
  response = await client.ttl(ttlKey);
490
- msg.payload = {
558
+ msg.payload = {
491
559
  key: ttlKey,
492
560
  ttl: response,
493
561
  status: response === -1 ? "no expiration" : response === -2 ? "key not found" : "expires in " + response + " seconds"
@@ -500,7 +568,7 @@ module.exports = function (RED) {
500
568
  }
501
569
  let expireSeconds = payload.ttl || payload.seconds || payload.value || 3600;
502
570
  response = await client.expire(payload.key, expireSeconds);
503
- msg.payload = {
571
+ msg.payload = {
504
572
  success: response === 1,
505
573
  key: payload.key,
506
574
  ttl: expireSeconds,
@@ -514,7 +582,7 @@ module.exports = function (RED) {
514
582
  throw new Error("Missing key for PERSIST operation. Use payload.key or payload as string");
515
583
  }
516
584
  response = await client.persist(persistKey);
517
- msg.payload = {
585
+ msg.payload = {
518
586
  success: response === 1,
519
587
  key: persistKey,
520
588
  message: response === 1 ? "Expiration removed" : "Key not found or no expiration"
@@ -695,7 +763,7 @@ module.exports = function (RED) {
695
763
  }
696
764
  } catch (redisError) {
697
765
  // Handle Redis-specific errors (connection, command errors, etc.)
698
- if (redisError.message.includes('ECONNREFUSED') ||
766
+ if (redisError.message.includes('ECONNREFUSED') ||
699
767
  redisError.message.includes('ENOTFOUND') ||
700
768
  redisError.message.includes('ETIMEDOUT')) {
701
769
  // Connection-related errors - force disconnect to prevent dead connections
@@ -707,8 +775,8 @@ module.exports = function (RED) {
707
775
  // Ignore disconnect errors
708
776
  }
709
777
  }
710
-
711
- msg.payload = {
778
+
779
+ msg.payload = {
712
780
  error: `Redis connection failed: ${redisError.message}`,
713
781
  operation: node.operation,
714
782
  retryable: true
@@ -716,28 +784,25 @@ module.exports = function (RED) {
716
784
  node.status({ fill: "red", shape: "ring", text: "connection failed" });
717
785
  } else {
718
786
  // Other Redis errors (command errors, etc.)
719
- msg.payload = {
787
+ msg.payload = {
720
788
  error: `Redis operation failed: ${redisError.message}`,
721
789
  operation: node.operation,
722
790
  retryable: false
723
791
  };
724
792
  }
725
- send(msg);
726
- done();
793
+ sendAndDone(msg);
727
794
  return;
728
795
  }
729
796
 
730
797
  // Update node status on success
731
798
  node.status({ fill: "green", shape: "dot", text: node.operation });
732
- send(msg);
733
- done();
799
+ sendAndDone(msg);
734
800
 
735
801
  } catch (error) {
736
802
  // Handle general errors (validation, etc.)
737
803
  node.error(error.message, msg);
738
804
  msg.payload = { error: error.message };
739
- send(msg);
740
- done();
805
+ sendAndDone(msg);
741
806
  }
742
807
  });
743
808
  }
@@ -763,7 +828,7 @@ module.exports = function (RED) {
763
828
  node.on("close", async (undeploy, done) => {
764
829
  node.status({});
765
830
  running = false;
766
-
831
+
767
832
  if (node.operation === "instance" && node.location && node.topic) {
768
833
  try {
769
834
  node.context()[node.location].set(node.topic, null);
@@ -771,7 +836,7 @@ module.exports = function (RED) {
771
836
  // Ignore errors when cleaning up context
772
837
  }
773
838
  }
774
-
839
+
775
840
  if (client) {
776
841
  try {
777
842
  if (node.operation === 'subscribe' && node.topic) {
@@ -788,7 +853,7 @@ module.exports = function (RED) {
788
853
  const nodeId = node.block ? node.id : redisConfig.id;
789
854
  redisConfig.disconnect(nodeId);
790
855
  }
791
-
856
+
792
857
  client = null;
793
858
  done();
794
859
  });