node-red-contrib-influxdb3 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/influxdb3.html CHANGED
@@ -7,7 +7,9 @@
7
7
  defaults: {
8
8
  name: { value: '' },
9
9
  host: { value: '', required: true },
10
- database: { value: '', required: true }
10
+ database: { value: '', required: true },
11
+ tlsRejectUnauthorized: { value: true },
12
+ caCertPath: { value: '' }
11
13
  },
12
14
  credentials: {
13
15
  token: { type: 'password' }
@@ -35,6 +37,15 @@
35
37
  <label for="node-config-input-database"><i class="fa fa-database"></i> Database</label>
36
38
  <input type="text" id="node-config-input-database" placeholder="my-database">
37
39
  </div>
40
+ <div class="form-row">
41
+ <label for="node-config-input-tlsRejectUnauthorized"><i class="fa fa-lock"></i> Verify TLS</label>
42
+ <input type="checkbox" id="node-config-input-tlsRejectUnauthorized" style="width:auto;">
43
+ <span>Reject unauthorized certificates</span>
44
+ </div>
45
+ <div class="form-row">
46
+ <label for="node-config-input-caCertPath"><i class="fa fa-certificate"></i> CA Cert Path</label>
47
+ <input type="text" id="node-config-input-caCertPath" placeholder="C:\\path\\to\\root-ca.pem">
48
+ </div>
38
49
  </script>
39
50
 
40
51
  <script type="text/html" data-help-name="influxdb3-config">
@@ -50,6 +61,10 @@
50
61
  <dd>The authentication token for accessing InfluxDB v3</dd>
51
62
  <dt>Database <span class="property-type">string</span></dt>
52
63
  <dd>The default database (bucket) name to write to</dd>
64
+ <dt>Verify TLS <span class="property-type">boolean</span></dt>
65
+ <dd>When unchecked, sets <code>NODE_TLS_REJECT_UNAUTHORIZED=0</code> for the Node-RED process</dd>
66
+ <dt>CA Cert Path <span class="property-type">string</span></dt>
67
+ <dd>Optional filesystem path used to set <code>NODE_EXTRA_CA_CERTS</code> for custom root CAs</dd>
53
68
  </dl>
54
69
  </script>
55
70
 
package/influxdb3.js CHANGED
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * InfluxDB v3 nodes for Node-RED
3
+ * @module node-red-contrib-influxdb3
3
4
  */
4
5
 
5
6
  module.exports = function(RED) {
7
+ // @influxdata/influxdb3-client Point API:
8
+ // Point.setIntegerField(name, value)
9
+ // Point.setFloatField(name, value)
10
+ // Point.setStringField(name, value)
11
+ // Point.setBooleanField(name, value)
12
+ // Point.setTag(name, value)
13
+ // Point.setTimestamp(date)
14
+ // Point.toLineProtocol()
6
15
  const { InfluxDBClient, Point } = require('@influxdata/influxdb3-client');
7
16
 
8
17
  /**
9
18
  * Normalize host URL to ensure it has trailing slash
19
+ * @param {string} host
20
+ * @returns {string}
10
21
  */
11
22
  function normalizeHost(host) {
12
23
  if (!host || typeof host !== 'string') {
@@ -15,120 +26,91 @@ module.exports = function(RED) {
15
26
  return host.endsWith('/') ? host : host + '/';
16
27
  }
17
28
 
18
- /**
19
- * Process a field value and add it to a Point
20
- */
21
- function addFieldToPoint(point, key, value, integerFields) {
22
- if (value === null || value === undefined) {
23
- return false;
24
- }
25
-
26
- // Handle string with 'i' suffix for integers (e.g., "42i")
27
- if (typeof value === 'string' && /^-?\d+i$/.test(value)) {
28
- const intValue = parseInt(value.slice(0, -1), 10);
29
- // Validate parsed value
30
- if (!isNaN(intValue) && isFinite(intValue)) {
31
- point.setIntegerField(key, intValue);
32
- return true;
33
- }
34
- } else if (typeof value === 'number') {
35
- // Validate number
36
- if (!isFinite(value)) {
37
- RED.log.warn(`Skipping field '${key}': value is not finite (${value})`);
38
- return false;
39
- }
40
-
41
- // Default to float for all numbers (safe default)
42
- // Use integer only if explicitly marked in 'integers' array
43
- if (integerFields.has(key)) {
44
- const intValue = Math.floor(value);
45
- if (intValue !== value) {
46
- RED.log.warn(`Field '${key}': truncating ${value} to ${intValue} for integer field`);
47
- }
48
- point.setIntegerField(key, intValue);
49
- } else {
50
- point.setFloatField(key, value);
51
- }
52
- return true;
53
- } else if (typeof value === 'boolean') {
54
- point.setBooleanField(key, value);
55
- return true;
56
- } else if (typeof value === 'string') {
57
- point.setStringField(key, value);
58
- return true;
59
- } else {
60
- // Complex types (arrays, objects) are not supported
61
- RED.log.warn(`Skipping field '${key}': unsupported type ${typeof value}`);
62
- return false;
63
- }
64
-
65
- return false;
66
- }
67
-
68
29
  /**
69
30
  * Configuration node to hold InfluxDB v3 connection details
31
+ * @param {object} config
70
32
  */
71
33
  function InfluxDB3ConfigNode(config) {
72
34
  RED.nodes.createNode(this, config);
73
-
35
+
36
+ /** @type {string} */
74
37
  this.host = config.host;
38
+ /** @type {string} */
75
39
  this.database = config.database;
40
+ /** @type {string} */
76
41
  this.name = config.name;
77
-
78
- // Store token as a credential
79
- this.token = this.credentials.token;
80
-
42
+ /** @type {boolean} */
43
+ this.tlsRejectUnauthorized = config.tlsRejectUnauthorized !== false;
44
+ /** @type {string} */
45
+ this.caCertPath = config.caCertPath;
46
+
47
+ // Store token as a credential (populated by Node-RED runtime)
48
+ /** @type {string} */
49
+ if (!this.credentials) {
50
+ RED.log.warn('InfluxDB v3 config: credentials object is undefined');
51
+ }
52
+ this.token = this.credentials ? this.credentials.token : undefined;
53
+
81
54
  // Client instance (will be created on demand)
55
+ /** @type {InfluxDBClient|null} */
82
56
  this.client = null;
83
-
84
- // Get or create a client instance
85
- this.getClient = function() {
86
- if (!this.client) {
87
- // Validate configuration
88
- if (!this.host) {
57
+
58
+ const configNode = this;
59
+
60
+ /**
61
+ * Get or create a client instance
62
+ * @returns {InfluxDBClient}
63
+ */
64
+ configNode.getClient = function() {
65
+ if (!configNode.client) {
66
+ if (!configNode.host) {
89
67
  throw new Error('InfluxDB host is not configured');
90
68
  }
91
- if (!this.token) {
69
+ if (!configNode.token) {
92
70
  throw new Error('InfluxDB token is not configured');
93
71
  }
94
- if (!this.database) {
72
+ if (!configNode.database) {
95
73
  throw new Error('InfluxDB database is not configured');
96
74
  }
97
-
98
- try {
99
- const normalizedHost = normalizeHost(this.host);
100
-
101
- RED.log.info(`InfluxDB v3: Connecting to ${normalizedHost} with database ${this.database}`);
102
-
103
- this.client = new InfluxDBClient({
104
- host: normalizedHost,
105
- token: this.token,
106
- database: this.database
107
- });
108
-
109
- RED.log.info(`InfluxDB v3: Client created successfully`);
110
- } catch (error) {
111
- RED.log.error(`InfluxDB v3: Failed to create client - ${error.message}`);
112
- throw new Error(`Failed to create InfluxDB client: ${error.message}`);
75
+
76
+ const normalizedHost = normalizeHost(configNode.host);
77
+
78
+ if (!configNode.tlsRejectUnauthorized) {
79
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
80
+ RED.log.warn('InfluxDB v3: TLS certificate verification is disabled for this process.');
81
+ }
82
+
83
+ if (configNode.caCertPath) {
84
+ process.env.NODE_EXTRA_CA_CERTS = configNode.caCertPath;
85
+ RED.log.info(`InfluxDB v3: Using extra CA certificates from ${configNode.caCertPath}`);
113
86
  }
87
+
88
+ RED.log.info(`InfluxDB v3: Connecting to ${normalizedHost} with database ${configNode.database}`);
89
+
90
+ configNode.client = new InfluxDBClient({
91
+ host: normalizedHost,
92
+ token: configNode.token,
93
+ database: configNode.database
94
+ });
95
+
96
+ RED.log.info('InfluxDB v3: Client created successfully');
114
97
  }
115
- return this.client;
98
+ return configNode.client;
116
99
  };
117
-
118
- // Clean up on close
119
- this.on('close', function() {
120
- if (this.client) {
100
+
101
+ configNode.on('close', function() {
102
+ if (configNode.client) {
121
103
  try {
122
104
  RED.log.info('InfluxDB v3: Closing client connection');
123
- this.client.close();
124
- this.client = null;
105
+ configNode.client.close();
125
106
  } catch (error) {
126
107
  RED.log.warn(`InfluxDB v3: Error closing client - ${error.message}`);
127
108
  }
109
+ configNode.client = null;
128
110
  }
129
111
  });
130
112
  }
131
-
113
+
132
114
  RED.nodes.registerType('influxdb3-config', InfluxDB3ConfigNode, {
133
115
  credentials: {
134
116
  token: { type: 'password' }
@@ -137,62 +119,244 @@ module.exports = function(RED) {
137
119
 
138
120
  /**
139
121
  * InfluxDB v3 Write Node
122
+ * @param {object} config
140
123
  */
141
124
  function InfluxDB3WriteNode(config) {
142
125
  RED.nodes.createNode(this, config);
143
-
126
+
144
127
  this.influxdb = RED.nodes.getNode(config.influxdb);
145
128
  this.measurement = config.measurement;
146
129
  this.database = config.database;
147
-
130
+
148
131
  const node = this;
149
132
  let statusTimeout = null;
150
-
151
- if (!this.influxdb) {
152
- this.error('InfluxDB v3 config not set');
153
- this.status({ fill: 'red', shape: 'dot', text: 'no config' });
133
+
134
+ if (!node.influxdb) {
135
+ node.error('InfluxDB v3 config not set');
136
+ node.status({ fill: 'red', shape: 'dot', text: 'no config' });
154
137
  return;
155
138
  }
156
-
157
- // Helper to set status with auto-clear
158
- function setStatus(status, clearAfterMs = 0) {
139
+
140
+ /**
141
+ * Safely serialize a value for diagnostic logging.
142
+ * Handles circular references and large objects.
143
+ * @param {*} value
144
+ * @returns {string}
145
+ */
146
+ function safeStringify(value) {
147
+ try {
148
+ return JSON.stringify(value);
149
+ } catch (e) {
150
+ return `[unserializable: ${e.message}]`;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Process a field value and add it to a Point.
156
+ * Returns true if the field was added, false if it was skipped.
157
+ *
158
+ * Uses the type-specific Point methods:
159
+ * - point.setFloatField(name, value) for numbers (default)
160
+ * - point.setIntegerField(name, value) for integers
161
+ * - point.setStringField(name, value) for strings
162
+ * - point.setBooleanField(name, value) for booleans
163
+ *
164
+ * @param {Point} point - The InfluxDB Point to add the field to
165
+ * @param {string} key - The field name
166
+ * @param {*} value - The field value
167
+ * @param {Set<string>} integerFields - Set of field names to treat as integers
168
+ * @param {string} measurement - Measurement name for diagnostic context
169
+ * @returns {boolean} true if the field was added successfully
170
+ */
171
+ function addFieldToPoint(point, key, value, integerFields, measurement) {
172
+ const context = measurement ? ` (measurement: '${measurement}')` : '';
173
+
174
+ if (value === null || value === undefined) {
175
+ node.warn(`Skipping field '${key}': value is ${value}${context}`);
176
+ return false;
177
+ }
178
+
179
+ if (typeof value === 'object') {
180
+ const typeName = Array.isArray(value)
181
+ ? 'Array'
182
+ : (value.constructor ? value.constructor.name : 'object');
183
+ node.warn(
184
+ `Skipping field '${key}': unsupported type 'object' (${typeName})${context}. ` +
185
+ `Actual value: ${safeStringify(value)}. ` +
186
+ `The value for field '${key}' must be a number, string, or boolean.`
187
+ );
188
+ return false;
189
+ }
190
+
191
+ if (typeof value === 'string') {
192
+ // Check for integer suffix e.g. "42i"
193
+ if (/^-?\d+i$/.test(value)) {
194
+ point.setIntegerField(key, parseInt(value.slice(0, -1), 10));
195
+ return true;
196
+ }
197
+ point.setStringField(key, value);
198
+ return true;
199
+ }
200
+
201
+ if (typeof value === 'boolean') {
202
+ point.setBooleanField(key, value);
203
+ return true;
204
+ }
205
+
206
+ if (typeof value === 'number') {
207
+ if (!isFinite(value)) {
208
+ node.warn(
209
+ `Skipping field '${key}': numeric value is ${value} (not finite)${context}. ` +
210
+ `Check the source data for NaN or Infinity.`
211
+ );
212
+ return false;
213
+ }
214
+ if (integerFields && integerFields.has(key)) {
215
+ if (!Number.isInteger(value)) {
216
+ node.warn(
217
+ `Field '${key}' is marked as integer but value is ${value}${context}. ` +
218
+ `Value will be truncated to ${Math.floor(value)} using Math.floor.`
219
+ );
220
+ }
221
+ point.setIntegerField(key, Math.floor(value));
222
+ } else {
223
+ point.setFloatField(key, value);
224
+ }
225
+ return true;
226
+ }
227
+
228
+ node.warn(
229
+ `Skipping field '${key}': unsupported type '${typeof value}'${context}. ` +
230
+ `Value: ${safeStringify(value)}`
231
+ );
232
+ return false;
233
+ }
234
+
235
+ /**
236
+ * Set node status with optional auto-clear
237
+ * @param {object} status
238
+ * @param {number} [clearAfterMs=0]
239
+ */
240
+ function setStatus(status, clearAfterMs) {
159
241
  if (statusTimeout) {
160
242
  clearTimeout(statusTimeout);
161
243
  statusTimeout = null;
162
244
  }
163
-
245
+
164
246
  node.status(status);
165
-
166
- if (clearAfterMs > 0) {
167
- statusTimeout = setTimeout(() => {
247
+
248
+ if (clearAfterMs && clearAfterMs > 0) {
249
+ statusTimeout = setTimeout(function() {
168
250
  node.status({});
169
251
  statusTimeout = null;
170
252
  }, clearAfterMs);
171
253
  }
172
254
  }
173
-
255
+
256
+ /**
257
+ * Build line protocol from an object payload.
258
+ * @param {object} msg - The incoming Node-RED message
259
+ * @returns {{lineProtocol: string}|{error: string}} result or error
260
+ */
261
+ function buildLineProtocol(msg) {
262
+ const measurement = msg.measurement || node.measurement;
263
+
264
+ if (!measurement) {
265
+ return { error: 'Measurement not specified' };
266
+ }
267
+
268
+ const point = new Point(measurement);
269
+
270
+ // Add tags
271
+ if (msg.payload.tags && typeof msg.payload.tags === 'object' && !Array.isArray(msg.payload.tags)) {
272
+ for (const [key, value] of Object.entries(msg.payload.tags)) {
273
+ if (value !== null && value !== undefined) {
274
+ point.setTag(key, String(value));
275
+ }
276
+ }
277
+ }
278
+
279
+ // Get list of fields that should be treated as integers
280
+ const integerFields = new Set(msg.payload.integers || []);
281
+ let fieldCount = 0;
282
+
283
+ // Add fields
284
+ if (msg.payload.fields && typeof msg.payload.fields === 'object' && !Array.isArray(msg.payload.fields)) {
285
+ // Explicit fields object
286
+ for (const [key, value] of Object.entries(msg.payload.fields)) {
287
+ if (addFieldToPoint(point, key, value, integerFields, measurement)) {
288
+ fieldCount++;
289
+ }
290
+ }
291
+ } else {
292
+ // Simplified format: treat all non-reserved properties as fields
293
+ const reservedKeys = new Set(['tags', 'timestamp', 'integers', 'fields']);
294
+ for (const [key, value] of Object.entries(msg.payload)) {
295
+ if (!reservedKeys.has(key)) {
296
+ if (addFieldToPoint(point, key, value, integerFields, measurement)) {
297
+ fieldCount++;
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ if (fieldCount === 0) {
304
+ return {
305
+ error: 'No valid fields to write - all fields were skipped or payload had no fields. ' +
306
+ 'Payload was: ' + safeStringify(msg.payload)
307
+ };
308
+ }
309
+
310
+ // Handle timestamp — use nullish coalescing to preserve falsy-but-valid values like 0
311
+ const ts = (msg.payload.timestamp !== null && msg.payload.timestamp !== undefined)
312
+ ? msg.payload.timestamp
313
+ : msg.timestamp;
314
+ if (ts !== null && ts !== undefined) {
315
+ if (ts instanceof Date && !isNaN(ts.getTime())) {
316
+ point.setTimestamp(ts);
317
+ } else if (typeof ts === 'number' && isFinite(ts) && ts >= 0) {
318
+ point.setTimestamp(new Date(ts));
319
+ } else if (typeof ts === 'string' && ts.trim() !== '') {
320
+ const parsed = new Date(ts);
321
+ if (!isNaN(parsed.getTime())) {
322
+ point.setTimestamp(parsed);
323
+ } else {
324
+ node.warn(`Invalid timestamp string: '${ts}'`);
325
+ }
326
+ } else {
327
+ node.warn(`Invalid timestamp: ${safeStringify(ts)} (type: ${typeof ts})`);
328
+ }
329
+ }
330
+
331
+ const lp = point.toLineProtocol();
332
+
333
+ if (!lp || lp.trim() === '') {
334
+ return { error: 'Generated line protocol is empty' };
335
+ }
336
+
337
+ return { lineProtocol: lp };
338
+ }
339
+
174
340
  // Process incoming messages
175
341
  node.on('input', async function(msg, send, done) {
176
342
  // For Node-RED 0.x compatibility
177
- send = send || function() { node.send.apply(node, arguments); };
178
- done = done || function(err) {
179
- if (err) {
180
- node.error(err, msg);
181
- }
343
+ send = send || function(m) { node.send(m); };
344
+ done = done || function(err) {
345
+ if (err) { node.error(err, msg); }
182
346
  };
183
-
347
+
184
348
  try {
185
349
  const client = node.influxdb.getClient();
186
-
350
+
187
351
  // Determine the database to use
188
352
  const targetDatabase = msg.database || node.database || node.influxdb.database;
189
-
353
+
190
354
  if (!targetDatabase) {
191
355
  throw new Error('Database not specified');
192
356
  }
193
-
357
+
194
358
  let lineProtocol;
195
-
359
+
196
360
  // Check if msg.payload is already in line protocol format
197
361
  if (typeof msg.payload === 'string') {
198
362
  lineProtocol = msg.payload.trim();
@@ -200,96 +364,29 @@ module.exports = function(RED) {
200
364
  throw new Error('Line protocol string is empty');
201
365
  }
202
366
  } else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
203
- // Build line protocol from payload object
204
- const measurement = msg.measurement || node.measurement;
205
-
206
- if (!measurement) {
207
- throw new Error('Measurement not specified');
208
- }
209
-
210
- const point = new Point(measurement);
211
-
212
- // Add tags
213
- if (msg.payload.tags && typeof msg.payload.tags === 'object' && !Array.isArray(msg.payload.tags)) {
214
- for (const [key, value] of Object.entries(msg.payload.tags)) {
215
- if (value !== null && value !== undefined) {
216
- point.setTag(key, String(value));
217
- }
218
- }
219
- }
220
-
221
- // Get list of fields that should be treated as integers
222
- const integerFields = new Set(msg.payload.integers || []);
223
- let fieldCount = 0;
224
-
225
- // Add fields
226
- if (msg.payload.fields && typeof msg.payload.fields === 'object' && !Array.isArray(msg.payload.fields)) {
227
- // Explicit fields object
228
- for (const [key, value] of Object.entries(msg.payload.fields)) {
229
- if (addFieldToPoint(point, key, value, integerFields)) {
230
- fieldCount++;
231
- }
232
- }
233
- } else {
234
- // Simplified format: treat all non-reserved properties as fields
235
- const reservedKeys = new Set(['tags', 'timestamp', 'integers', 'fields']);
236
- for (const [key, value] of Object.entries(msg.payload)) {
237
- if (!reservedKeys.has(key)) {
238
- if (addFieldToPoint(point, key, value, integerFields)) {
239
- fieldCount++;
240
- }
241
- }
242
- }
243
- }
244
-
245
- if (fieldCount === 0) {
246
- throw new Error('No valid fields to write - at least one field is required');
247
- }
248
-
249
- // Add timestamp if provided
250
- if (msg.payload.timestamp) {
251
- const ts = msg.payload.timestamp;
252
- if (ts instanceof Date && !isNaN(ts.getTime())) {
253
- point.setTimestamp(ts);
254
- } else if (typeof ts === 'number' && isFinite(ts) && ts > 0) {
255
- point.setTimestamp(new Date(ts));
256
- } else {
257
- node.warn(`Invalid timestamp in payload: ${ts}`);
258
- }
259
- } else if (msg.timestamp) {
260
- const ts = msg.timestamp;
261
- if (ts instanceof Date && !isNaN(ts.getTime())) {
262
- point.setTimestamp(ts);
263
- } else if (typeof ts === 'number' && isFinite(ts) && ts > 0) {
264
- point.setTimestamp(new Date(ts));
265
- } else {
266
- node.warn(`Invalid timestamp in msg: ${ts}`);
267
- }
268
- }
269
-
270
- lineProtocol = point.toLineProtocol();
271
-
272
- if (!lineProtocol || lineProtocol.trim() === '') {
273
- throw new Error('Generated line protocol is empty');
367
+ const result = buildLineProtocol(msg);
368
+ if (result.error) {
369
+ throw new Error(result.error);
274
370
  }
371
+ lineProtocol = result.lineProtocol;
275
372
  } else {
276
373
  throw new Error('Invalid payload format. Expected string (line protocol) or object with fields');
277
374
  }
278
-
375
+
279
376
  // Write to InfluxDB
280
377
  await client.write(lineProtocol, targetDatabase);
281
-
378
+
282
379
  setStatus({ fill: 'green', shape: 'dot', text: 'written' }, 3000);
283
-
380
+
284
381
  send(msg);
285
382
  done();
286
-
383
+
287
384
  } catch (error) {
288
385
  setStatus({ fill: 'red', shape: 'dot', text: 'error' });
289
386
  done(error);
290
387
  }
291
388
  });
292
-
389
+
293
390
  node.on('close', function() {
294
391
  if (statusTimeout) {
295
392
  clearTimeout(statusTimeout);
@@ -298,6 +395,6 @@ module.exports = function(RED) {
298
395
  node.status({});
299
396
  });
300
397
  }
301
-
398
+
302
399
  RED.nodes.registerType('influxdb3-write', InfluxDB3WriteNode);
303
400
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "node-red-contrib-influxdb3",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Node-RED nodes for InfluxDB v3 integration",
5
5
  "main": "influxdb3.js",
6
6
  "scripts": {
7
- "test": "echo \"No tests specified\""
7
+ "test": "jest"
8
8
  },
9
9
  "keywords": [
10
10
  "node-red",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "https://github.com/stuartb55/nodered-influxdb3.git"
23
+ "url": "git+https://github.com/stuartb55/nodered-influxdb3.git"
24
24
  },
25
25
  "homepage": "https://github.com/stuartb55/nodered-influxdb3#readme",
26
26
  "bugs": {
@@ -33,10 +33,9 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@influxdata/influxdb3-client": "^1.4.0"
36
+ "@influxdata/influxdb3-client": "^2.0.0"
37
37
  },
38
- "peerDependencies": {
39
- "node-red": ">=3.0.0"
38
+ "devDependencies": {
39
+ "jest": "^30.2.0"
40
40
  }
41
41
  }
42
-