node-red-contrib-redis-variable 1.3.1 → 1.5.1

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
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-redis-variable",
3
- "version": "1.3.1",
3
+ "version": "1.5.1",
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",
File without changes
@@ -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) {
@@ -400,11 +400,11 @@ module.exports = function (RED) {
400
400
  // Handle connection errors
401
401
  client.on("error", (e) => {
402
402
  const now = Date.now();
403
-
403
+
404
404
  // Only report errors once per interval to prevent spam
405
405
  if (!errorReported || (now - lastErrorTime) > ERROR_REPORT_INTERVAL) {
406
406
  let errorMsg = `Redis connection error: ${e.message}`;
407
-
407
+
408
408
  // Add specific diagnostics for common SSL issues
409
409
  if (e.message.includes("Protocol error") || e.message.includes("\\u0015")) {
410
410
  errorMsg += "\nThis usually indicates an SSL/TLS configuration issue. Try:";
@@ -413,13 +413,13 @@ module.exports = function (RED) {
413
413
  errorMsg += "\n3. Check if your Redis server is configured for SSL";
414
414
  errorMsg += "\n4. Enable 'Debug: Force No SSL' option for testing";
415
415
  }
416
-
416
+
417
417
  if (executingNode) {
418
418
  executingNode.error(errorMsg, {});
419
419
  } else {
420
420
  this.error(errorMsg, {});
421
421
  }
422
-
422
+
423
423
  errorReported = true;
424
424
  lastErrorTime = now;
425
425
  }
@@ -5,7 +5,8 @@
5
5
  defaults: {
6
6
  name: { value: "" },
7
7
  redisConfig: { value: "", type: "redis-variable-config" },
8
- operation: { value: "get" }
8
+ operation: { value: "get" },
9
+ topic: { value: "" }
9
10
  },
10
11
  inputs: 1,
11
12
  outputs: 1,
@@ -62,9 +63,15 @@
62
63
  </optgroup>
63
64
  <optgroup label="Pub/Sub Operations">
64
65
  <option value="publish">PUBLISH - Publish message</option>
66
+ <option value="subscribe">SUBSCRIBE - Subscribe to channel</option>
67
+ <option value="psubscribe">PSUBSCRIBE - Pattern subscribe</option>
65
68
  </optgroup>
66
69
  </select>
67
70
  </div>
71
+ <div class="form-row">
72
+ <label for="node-input-topic"><i class="fa fa-hashtag"></i> Channel/Pattern</label>
73
+ <input type="text" id="node-input-topic" placeholder="notifications or news.*">
74
+ </div>
68
75
  </script>
69
76
 
70
77
  <script type="text/x-red" data-help-name="redis-variable">
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;
@@ -14,6 +14,7 @@ module.exports = function (RED) {
14
14
  this.stored = config.stored || false;
15
15
  this.params = config.params;
16
16
  this.location = config.location || 'flow';
17
+ this.topic = config.topic || "";
17
18
  this.sha1 = "";
18
19
 
19
20
  // Save redisConfig once in the constructor
@@ -51,6 +52,19 @@ module.exports = function (RED) {
51
52
  return str;
52
53
  }
53
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
+
54
68
  // Handle different operations
55
69
  switch (this.operation) {
56
70
  case "subscribe":
@@ -75,56 +89,89 @@ module.exports = function (RED) {
75
89
  // Subscription operations (subscribe, psubscribe)
76
90
  function handleSubscription() {
77
91
  try {
78
- if (node.operation === "psubscribe") {
79
- client.on("pmessage", function (pattern, channel, message) {
80
- var payload = smartParse(message);
81
- node.send({
82
- pattern: pattern,
83
- topic: channel,
84
- payload: payload,
85
- });
86
- });
87
- client[node.operation](node.topic, (err, count) => {
88
- if (err) {
89
- node.error(err.message);
90
- node.status({
91
- fill: "red",
92
- shape: "dot",
93
- text: "error",
92
+ if (!redisConfig) {
93
+ node.status({ fill: "yellow", shape: "ring", text: "no config" });
94
+ node.warn("Redis configuration not found for subscription.");
95
+ return;
96
+ }
97
+
98
+ if (!node.topic) {
99
+ node.status({ fill: "red", shape: "dot", text: "missing channel" });
100
+ node.error("Missing channel/pattern for subscription (topic)");
101
+ return;
102
+ }
103
+
104
+ client = redisConfig.getClient(null, node, node.id);
105
+ if (!client) {
106
+ node.status({ fill: "yellow", shape: "ring", text: "no config" });
107
+ node.warn("Redis configuration not available. Subscription skipped.");
108
+ return;
109
+ }
110
+
111
+ const setup = async () => {
112
+ try {
113
+ if (client.status !== 'ready' && client.status !== 'connect') {
114
+ await client.connect();
115
+ }
116
+
117
+ const topics = (node.topic || "").split(/[\s,]+/).filter(Boolean);
118
+
119
+ if (node.operation === "psubscribe") {
120
+ client.on("pmessage", function (pattern, channel, message) {
121
+ var payload = smartParse(message);
122
+ node.send({
123
+ pattern: pattern,
124
+ topic: channel,
125
+ payload: payload,
126
+ });
94
127
  });
95
- } else {
96
- node.status({
97
- fill: "green",
98
- shape: "dot",
99
- text: "connected",
128
+ client.psubscribe(...topics, (err) => {
129
+ if (err) {
130
+ // Fallback: if server doesn't support PSUBSCRIBE and no wildcard is used, try SUBSCRIBE
131
+ if (err.message && err.message.toLowerCase().includes('unknown command')) {
132
+ const hasWildcard = topics.some(t => t.includes('*'));
133
+ if (!hasWildcard) {
134
+ client.subscribe(...topics, (subErr) => {
135
+ if (subErr) {
136
+ node.error(subErr.message);
137
+ node.status({ fill: "red", shape: "dot", text: "error" });
138
+ } else {
139
+ node.status({ fill: "green", shape: "dot", text: "connected" });
140
+ }
141
+ });
142
+ return;
143
+ }
144
+ }
145
+ node.error(err.message);
146
+ node.status({ fill: "red", shape: "dot", text: "error" });
147
+ } else {
148
+ node.status({ fill: "green", shape: "dot", text: "connected" });
149
+ }
100
150
  });
101
- }
102
- });
103
- } else if (node.operation === "subscribe") {
104
- client.on("message", function (channel, message) {
105
- var payload = smartParse(message);
106
- node.send({
107
- topic: channel,
108
- payload: payload,
109
- });
110
- });
111
- client[node.operation](node.topic, (err, count) => {
112
- if (err) {
113
- node.error(err.message);
114
- node.status({
115
- fill: "red",
116
- shape: "dot",
117
- text: "error",
151
+ } else if (node.operation === "subscribe") {
152
+ client.on("message", function (channel, message) {
153
+ var payload = smartParse(message);
154
+ node.send({
155
+ topic: channel,
156
+ payload: payload,
157
+ });
118
158
  });
119
- } else {
120
- node.status({
121
- fill: "green",
122
- shape: "dot",
123
- text: "connected",
159
+ client.subscribe(...topics, (err) => {
160
+ if (err) {
161
+ node.error(err.message);
162
+ node.status({ fill: "red", shape: "dot", text: "error" });
163
+ } else {
164
+ node.status({ fill: "green", shape: "dot", text: "connected" });
165
+ }
124
166
  });
125
167
  }
126
- });
127
- }
168
+ } catch (e) {
169
+ node.error(`Subscription setup failed: ${e.message}`);
170
+ node.status({ fill: "red", shape: "dot", text: "error" });
171
+ }
172
+ };
173
+
174
+ setup();
128
175
  } catch (error) {
129
176
  node.error(`Subscription error: ${error.message}`);
130
177
  node.status({
@@ -275,6 +322,26 @@ module.exports = function (RED) {
275
322
  return;
276
323
  }
277
324
 
325
+ // Recreate client when connection options change (e.g., context-based config)
326
+ try {
327
+ const options = redisConfig.getConnectionOptions(msg, node);
328
+ const configKey = stableStringify({
329
+ cluster: redisConfig.cluster === true,
330
+ options
331
+ });
332
+ if (node._lastConnKey && node._lastConnKey !== configKey) {
333
+ redisConfig.forceDisconnect(node.id);
334
+ client = null;
335
+ }
336
+ node._lastConnKey = configKey;
337
+ } catch (configError) {
338
+ node.error(configError.message, msg);
339
+ msg.payload = { error: configError.message };
340
+ send(msg);
341
+ done();
342
+ return;
343
+ }
344
+
278
345
  // Only create client if not already created
279
346
  try {
280
347
  if (!client) {
@@ -288,11 +355,13 @@ module.exports = function (RED) {
288
355
  return;
289
356
  }
290
357
  }
291
-
358
+
292
359
  // Check if client is connected before proceeding
293
360
  if (client.status !== 'ready' && client.status !== 'connect') {
294
361
  // Try to connect if not connected
295
- if (client.status === 'disconnect' || client.status === 'end') {
362
+ if (client.status === 'wait') {
363
+ await client.connect();
364
+ } else if (client.status === 'disconnect' || client.status === 'end') {
296
365
  await client.connect();
297
366
  } else {
298
367
  // Force disconnect and recreate client for other error states
@@ -346,12 +415,25 @@ module.exports = function (RED) {
346
415
  if (!payload.key) {
347
416
  throw new Error("Missing key for SET operation. Use payload.key");
348
417
  }
349
- let setValue = payload.value !== undefined ? payload.value : payload.data;
418
+ const payloadIsObject = payload && typeof payload === 'object' && !Array.isArray(payload);
419
+ const payloadHasKey = payloadIsObject && Object.prototype.hasOwnProperty.call(payload, 'key');
420
+ const payloadHasValue = payloadIsObject && (
421
+ Object.prototype.hasOwnProperty.call(payload, 'value') ||
422
+ Object.prototype.hasOwnProperty.call(payload, 'data')
423
+ );
424
+ let setValue;
425
+ if (payloadHasValue) {
426
+ setValue = payload.value !== undefined ? payload.value : payload.data;
427
+ } else if (payloadHasKey) {
428
+ setValue = undefined;
429
+ } else {
430
+ setValue = payload;
431
+ }
350
432
  if (setValue === undefined) {
351
- throw new Error("Missing value for SET operation. Use payload.value or payload.data");
433
+ throw new Error("Missing value for SET operation. Use payload.value, payload.data, or payload as value");
352
434
  }
353
435
  setValue = smartSerialize(setValue);
354
-
436
+
355
437
  // Support TTL
356
438
  if (payload.ttl && payload.ttl > 0) {
357
439
  response = await client.setex(payload.key, payload.ttl, setValue);
@@ -386,14 +468,14 @@ module.exports = function (RED) {
386
468
  if (!pattern || typeof pattern !== 'string') {
387
469
  throw new Error("Missing pattern for MATCH operation. Use payload.pattern or payload as string");
388
470
  }
389
-
471
+
390
472
  let count = payload.count || 100; // Default scan count
391
473
  let startCursor = payload.cursor || payload.skip || 0; // Support cursor/skip for pagination
392
474
  let allKeys = [];
393
475
  let cursor = startCursor;
394
476
  let maxIterations = 1000; // Prevent infinite loops
395
477
  let iterations = 0;
396
-
478
+
397
479
  // If skip is specified, we need to scan through keys without collecting them
398
480
  if (payload.skip && payload.skip > 0) {
399
481
  let skipped = 0;
@@ -404,7 +486,7 @@ module.exports = function (RED) {
404
486
  const scanResult = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count);
405
487
  cursor = scanResult[0];
406
488
  skipped += scanResult[1].length;
407
-
489
+
408
490
  if (skipped >= payload.skip) {
409
491
  // We've skipped enough, start collecting from this point
410
492
  const excessSkipped = skipped - payload.skip;
@@ -416,7 +498,7 @@ module.exports = function (RED) {
416
498
  }
417
499
  } while (cursor !== 0 && cursor !== '0');
418
500
  }
419
-
501
+
420
502
  // Continue scanning to collect keys up to the limit
421
503
  do {
422
504
  if (iterations++ > maxIterations) {
@@ -425,15 +507,15 @@ module.exports = function (RED) {
425
507
  const scanResult = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count);
426
508
  cursor = scanResult[0];
427
509
  allKeys = allKeys.concat(scanResult[1]);
428
-
510
+
429
511
  // Break early if we've found enough keys
430
512
  if (allKeys.length >= count) {
431
513
  allKeys = allKeys.slice(0, count); // Trim to exact count
432
514
  break;
433
515
  }
434
516
  } while (cursor !== 0 && cursor !== '0');
435
-
436
- msg.payload = {
517
+
518
+ msg.payload = {
437
519
  pattern: pattern,
438
520
  keys: allKeys,
439
521
  count: allKeys.length,
@@ -453,7 +535,7 @@ module.exports = function (RED) {
453
535
  throw new Error("Missing key for TTL operation. Use payload.key or payload as string");
454
536
  }
455
537
  response = await client.ttl(ttlKey);
456
- msg.payload = {
538
+ msg.payload = {
457
539
  key: ttlKey,
458
540
  ttl: response,
459
541
  status: response === -1 ? "no expiration" : response === -2 ? "key not found" : "expires in " + response + " seconds"
@@ -466,7 +548,7 @@ module.exports = function (RED) {
466
548
  }
467
549
  let expireSeconds = payload.ttl || payload.seconds || payload.value || 3600;
468
550
  response = await client.expire(payload.key, expireSeconds);
469
- msg.payload = {
551
+ msg.payload = {
470
552
  success: response === 1,
471
553
  key: payload.key,
472
554
  ttl: expireSeconds,
@@ -480,7 +562,7 @@ module.exports = function (RED) {
480
562
  throw new Error("Missing key for PERSIST operation. Use payload.key or payload as string");
481
563
  }
482
564
  response = await client.persist(persistKey);
483
- msg.payload = {
565
+ msg.payload = {
484
566
  success: response === 1,
485
567
  key: persistKey,
486
568
  message: response === 1 ? "Expiration removed" : "Key not found or no expiration"
@@ -661,7 +743,7 @@ module.exports = function (RED) {
661
743
  }
662
744
  } catch (redisError) {
663
745
  // Handle Redis-specific errors (connection, command errors, etc.)
664
- if (redisError.message.includes('ECONNREFUSED') ||
746
+ if (redisError.message.includes('ECONNREFUSED') ||
665
747
  redisError.message.includes('ENOTFOUND') ||
666
748
  redisError.message.includes('ETIMEDOUT')) {
667
749
  // Connection-related errors - force disconnect to prevent dead connections
@@ -673,8 +755,8 @@ module.exports = function (RED) {
673
755
  // Ignore disconnect errors
674
756
  }
675
757
  }
676
-
677
- msg.payload = {
758
+
759
+ msg.payload = {
678
760
  error: `Redis connection failed: ${redisError.message}`,
679
761
  operation: node.operation,
680
762
  retryable: true
@@ -682,7 +764,7 @@ module.exports = function (RED) {
682
764
  node.status({ fill: "red", shape: "ring", text: "connection failed" });
683
765
  } else {
684
766
  // Other Redis errors (command errors, etc.)
685
- msg.payload = {
767
+ msg.payload = {
686
768
  error: `Redis operation failed: ${redisError.message}`,
687
769
  operation: node.operation,
688
770
  retryable: false
@@ -729,7 +811,7 @@ module.exports = function (RED) {
729
811
  node.on("close", async (undeploy, done) => {
730
812
  node.status({});
731
813
  running = false;
732
-
814
+
733
815
  if (node.operation === "instance" && node.location && node.topic) {
734
816
  try {
735
817
  node.context()[node.location].set(node.topic, null);
@@ -737,12 +819,24 @@ module.exports = function (RED) {
737
819
  // Ignore errors when cleaning up context
738
820
  }
739
821
  }
740
-
822
+
823
+ if (client) {
824
+ try {
825
+ if (node.operation === 'subscribe' && node.topic) {
826
+ await client.unsubscribe(node.topic);
827
+ } else if (node.operation === 'psubscribe' && node.topic) {
828
+ await client.punsubscribe(node.topic);
829
+ }
830
+ } catch (e) {
831
+ // ignore
832
+ }
833
+ }
834
+
741
835
  if (redisConfig) {
742
836
  const nodeId = node.block ? node.id : redisConfig.id;
743
837
  redisConfig.disconnect(nodeId);
744
838
  }
745
-
839
+
746
840
  client = null;
747
841
  done();
748
842
  });