@rosepetal/node-red-contrib-async-function 1.0.2 → 1.1.0

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.
@@ -1,13 +1,13 @@
1
- <!-- Async Function Node - Node-RED Editor Configuration -->
1
+ <!-- Worker Function Node - Node-RED Editor Configuration -->
2
2
 
3
3
  <script type="text/javascript">
4
- RED.nodes.registerType('async-function', {
4
+ RED.nodes.registerType('worker-function', {
5
5
  category: 'function',
6
6
  color: '#d7d9dc',
7
7
  defaults: {
8
8
  name: { value: '' },
9
9
  func: {
10
- value: '// Write your async code here\n// The code runs in a worker thread or child process\n// Available: msg, return, async/await, require()\n// Not available: context, flow, global, node\n\nreturn msg;'
10
+ value: '// Write your code here\n// The code runs in a worker thread or child process\n// Available: msg, return, async/await, require(), node.warn/error/log,\n// flow/global/context (snapshot-based)\n\nreturn msg;'
11
11
  },
12
12
  outputs: { value: 1, validate: RED.validators.number() },
13
13
  timeout: { value: 30000, validate: RED.validators.number() },
@@ -21,7 +21,7 @@
21
21
  outputs: 1,
22
22
  icon: 'function.svg',
23
23
  label: function() {
24
- return this.name || 'async function';
24
+ return this.name || 'worker function';
25
25
  },
26
26
  labelStyle: function() {
27
27
  return this.name ? 'node_label_italic' : '';
@@ -250,7 +250,7 @@
250
250
  status.text('Restarting...').css('color', '#666');
251
251
 
252
252
  $.ajax({
253
- url: 'async-function/' + nodeId + '/restart',
253
+ url: 'worker-function/' + nodeId + '/restart',
254
254
  type: 'POST',
255
255
  success: function(data) {
256
256
  status.text('✓ Workers restarted').css('color', 'green');
@@ -346,7 +346,7 @@
346
346
  });
347
347
  </script>
348
348
 
349
- <script type="text/html" data-template-name="async-function">
349
+ <script type="text/html" data-template-name="worker-function">
350
350
  <style>
351
351
  .async-func-tabs-row {
352
352
  margin-bottom: 0;
@@ -507,8 +507,8 @@
507
507
  <b>Tips:</b>
508
508
  <ul style="margin-top:5px; margin-bottom:0;">
509
509
  <li>Code runs in a worker thread or child process to prevent blocking</li>
510
- <li>Available: <code>msg</code>, <code>return</code>, <code>async/await</code>, <code>require()</code></li>
511
- <li>Not available: <code>context</code>, <code>flow</code>, <code>global</code>, <code>node</code></li>
510
+ <li>Available: <code>msg</code>, <code>return</code>, <code>async/await</code>, <code>require()</code>, <code>node</code>, <code>flow</code>, <code>global</code>, <code>context</code></li>
511
+ <li>Context access is snapshot-based (writes apply after execution)</li>
512
512
  <li>Use "Child Process" runtime for native modules that require main thread (e.g., <code>gl</code>)</li>
513
513
  <li>Return <code>msg</code> for single output, or <code>[msg1, msg2]</code> for multiple outputs</li>
514
514
  <li>Return <code>null</code> to stop the flow</li>
@@ -519,7 +519,7 @@
519
519
  </div>
520
520
  </script>
521
521
 
522
- <script type="text/html" data-help-name="async-function">
522
+ <script type="text/html" data-help-name="worker-function">
523
523
  <p>Execute custom JavaScript code in a worker thread or child process to prevent event loop blocking.</p>
524
524
 
525
525
  <h3>Inputs</h3>
@@ -546,14 +546,14 @@
546
546
  <li><code>return</code> - Return modified message or array of messages</li>
547
547
  <li><code>async/await</code> - Use async operations</li>
548
548
  <li><code>require()</code> - Import Node.js modules</li>
549
+ <li><code>node</code> - Node helpers (<code>warn</code>, <code>error</code>, <code>log</code>)</li>
550
+ <li><code>flow</code>, <code>global</code>, <code>context</code> - Snapshot-based context access (writes apply after execution)</li>
549
551
  <li><code>console</code> - Logging functions</li>
550
552
  <li><code>setTimeout</code>, <code>setInterval</code> - Timers</li>
551
553
  </ul>
552
554
 
553
555
  <h4>Not Available</h4>
554
556
  <ul>
555
- <li><code>context</code>, <code>flow</code>, <code>global</code> - Context storage (v2.0 feature)</li>
556
- <li><code>node</code> - Node instance methods</li>
557
557
  <li>Non-serializable objects in <code>msg</code></li>
558
558
  </ul>
559
559
 
@@ -612,7 +612,7 @@ return msg;</pre>
612
612
  <dt>Queue Size</dt>
613
613
  <dd>Maximum number of messages queued when all workers are busy (10-1000). Default: 100.</dd>
614
614
  <dt>Buffers</dt>
615
- <dd>All Buffers are transferred via shared memory when possible (falls back to base64 if needed).</dd>
615
+ <dd>Worker threads use zero-copy transfer when possible; shared memory is used as a fallback.</dd>
616
616
  </dl>
617
617
 
618
618
  <h3>Status Display</h3>
@@ -639,9 +639,11 @@ return msg;</pre>
639
639
  <li>Best for CPU-intensive operations (>10ms execution time)</li>
640
640
  <li>For simple operations, use the standard function node</li>
641
641
  <li>Workers are pooled and reused for efficiency</li>
642
- <li><b>Buffer Transfer:</b> Buffers are streamed through shared memory (base64 fallback)</li>
642
+ <li><b>Buffer Transfer:</b> Worker threads use zero-copy transfer when possible; shared memory is the fallback</li>
643
643
  <li>Event loop never blocks, even when processing multi-MB binary data (images, files, etc.)</li>
644
644
  <li>Message transfer is optimized by copying only referenced <code>msg.*</code> keys when possible</li>
645
+ <li>Context snapshots include only literal keys found in <code>flow.get("key")</code> etc.</li>
646
+ <li>Context snapshots use the default context store only</li>
645
647
  <li>Timing is recorded on <code>msg.performance</code> under the node label</li>
646
648
  </ul>
647
649
 
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Async Function Node
2
+ * Worker Function Node
3
3
  *
4
4
  * A Node-RED function node that executes user code in worker threads
5
- * to prevent event loop blocking.
5
+ * or child processes to prevent event loop blocking.
6
6
  */
7
7
 
8
8
  const path = require('path');
@@ -44,7 +44,7 @@ function extractMsgKeysFromCode(code) {
44
44
  return keys;
45
45
  }
46
46
 
47
- function buildWorkerInputMsg(originalMsg, code) {
47
+ function buildWorkerInputMsgCore(originalMsg, code) {
48
48
  if (!originalMsg || typeof originalMsg !== 'object') {
49
49
  return originalMsg;
50
50
  }
@@ -84,12 +84,310 @@ function hrtimeDiffToMs(start) {
84
84
  return Number(diff) / 1e6;
85
85
  }
86
86
 
87
+ function normalizeTransferMode(mode, executionMode) {
88
+ const normalized = typeof mode === 'string' ? mode.trim().toLowerCase() : '';
89
+ if (normalized === 'transfer' || normalized === 'shared' || normalized === 'copy') {
90
+ return normalized;
91
+ }
92
+ return executionMode === 'worker_threads' ? 'transfer' : 'shared';
93
+ }
94
+
95
+ const MSG_WRAPPER_KEY = '__rosepetal_msg';
96
+ const CONTEXT_WRAPPER_KEY = '__rosepetal_context';
97
+
98
+ function extractContextKeysFromCode(code) {
99
+ const flowKeys = new Set();
100
+ const globalKeys = new Set();
101
+ const contextKeys = new Set();
102
+
103
+ if (typeof code !== 'string' || !code.trim()) {
104
+ return { flow: flowKeys, global: globalKeys, context: contextKeys, usesContext: false };
105
+ }
106
+
107
+ const flowRegex = /flow\.(?:get|set)\(\s*['"]([^'"]+)['"]/g;
108
+ const flowBracket = /flow\[['"]([^'"]+)['"]\]/g;
109
+ const globalRegex = /global\.(?:get|set)\(\s*['"]([^'"]+)['"]/g;
110
+ const globalBracket = /global\[['"]([^'"]+)['"]\]/g;
111
+ const contextRegex = /context\.(?:get|set)\(\s*['"]([^'"]+)['"]/g;
112
+ const contextBracket = /context\[['"]([^'"]+)['"]\]/g;
113
+
114
+ let match = flowRegex.exec(code);
115
+ while (match) {
116
+ flowKeys.add(match[1]);
117
+ match = flowRegex.exec(code);
118
+ }
119
+
120
+ match = flowBracket.exec(code);
121
+ while (match) {
122
+ flowKeys.add(match[1]);
123
+ match = flowBracket.exec(code);
124
+ }
125
+
126
+ match = globalRegex.exec(code);
127
+ while (match) {
128
+ globalKeys.add(match[1]);
129
+ match = globalRegex.exec(code);
130
+ }
131
+
132
+ match = globalBracket.exec(code);
133
+ while (match) {
134
+ globalKeys.add(match[1]);
135
+ match = globalBracket.exec(code);
136
+ }
137
+
138
+ match = contextRegex.exec(code);
139
+ while (match) {
140
+ contextKeys.add(match[1]);
141
+ match = contextRegex.exec(code);
142
+ }
143
+
144
+ match = contextBracket.exec(code);
145
+ while (match) {
146
+ contextKeys.add(match[1]);
147
+ match = contextBracket.exec(code);
148
+ }
149
+
150
+ const usesContext = /\b(flow|global|context)\s*\./.test(code);
151
+ return { flow: flowKeys, global: globalKeys, context: contextKeys, usesContext };
152
+ }
153
+
154
+ async function buildContextSnapshot(node, code) {
155
+ const empty = { flow: {}, global: {}, context: {} };
156
+ if (!node || typeof node.context !== 'function') {
157
+ return { snapshot: empty, usesContext: false };
158
+ }
159
+
160
+ const keys = extractContextKeysFromCode(code);
161
+ if (!keys.usesContext) {
162
+ return { snapshot: empty, usesContext: false };
163
+ }
164
+
165
+ const ctx = node.context();
166
+ if (!ctx) {
167
+ return { snapshot: empty, usesContext: true };
168
+ }
169
+
170
+ const snapshot = { flow: {}, global: {}, context: {} };
171
+ const coerceBufferLike = (value) => {
172
+ if (!value || typeof value !== 'object') {
173
+ return value;
174
+ }
175
+ if (Buffer.isBuffer(value)) {
176
+ return value;
177
+ }
178
+ if (value.type === 'Buffer' && Array.isArray(value.data)) {
179
+ return Buffer.from(value.data);
180
+ }
181
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
182
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
183
+ }
184
+ return value;
185
+ };
186
+
187
+ const getValueAsync = (scope, key) => {
188
+ if (!scope || typeof scope.get !== 'function') {
189
+ return Promise.resolve(undefined);
190
+ }
191
+ if (scope.get.length >= 2) {
192
+ return new Promise((resolve) => {
193
+ try {
194
+ scope.get(key, (err, value) => {
195
+ resolve(err ? undefined : value);
196
+ });
197
+ } catch (_err) {
198
+ resolve(undefined);
199
+ }
200
+ });
201
+ }
202
+ try {
203
+ const value = scope.get(key);
204
+ if (value && typeof value.then === 'function') {
205
+ return value.then((resolved) => resolved).catch(() => undefined);
206
+ }
207
+ return Promise.resolve(value);
208
+ } catch (_err) {
209
+ return Promise.resolve(undefined);
210
+ }
211
+ };
212
+
213
+ const readKeys = async (scope, keySet, target) => {
214
+ if (!scope || typeof scope.get !== 'function') {
215
+ return;
216
+ }
217
+ const entries = Array.from(keySet);
218
+ await Promise.all(entries.map(async (key) => {
219
+ try {
220
+ const value = await getValueAsync(scope, key);
221
+ target[key] = coerceBufferLike(value);
222
+ } catch (_err) {
223
+ // Ignore context read errors to avoid blocking execution
224
+ }
225
+ }));
226
+ };
227
+
228
+ await Promise.all([
229
+ readKeys(ctx.flow, keys.flow, snapshot.flow),
230
+ readKeys(ctx.global, keys.global, snapshot.global),
231
+ readKeys(ctx, keys.context, snapshot.context)
232
+ ]);
233
+
234
+ return { snapshot, usesContext: true };
235
+ }
236
+
237
+ function buildWorkerInputMsg(originalMsg, code, contextSnapshot, useContextWrapper) {
238
+ if (!useContextWrapper) {
239
+ return buildWorkerInputMsgCore(originalMsg, code);
240
+ }
241
+
242
+ const msgPayload = buildWorkerInputMsgCore(originalMsg, code);
243
+ return {
244
+ [MSG_WRAPPER_KEY]: msgPayload,
245
+ [CONTEXT_WRAPPER_KEY]: contextSnapshot || { flow: {}, global: {}, context: {} }
246
+ };
247
+ }
248
+
249
+ function rehydrateContextValue(value) {
250
+ if (Buffer.isBuffer(value)) {
251
+ return value;
252
+ }
253
+
254
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
255
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
256
+ }
257
+
258
+ if (Array.isArray(value)) {
259
+ return value.map((item) => rehydrateContextValue(item));
260
+ }
261
+
262
+ if (value && typeof value === 'object') {
263
+ if (value.type === 'Buffer' && Array.isArray(value.data)) {
264
+ return Buffer.from(value.data);
265
+ }
266
+
267
+ const obj = Array.isArray(value) ? [] : {};
268
+ Object.keys(value).forEach((key) => {
269
+ obj[key] = rehydrateContextValue(value[key]);
270
+ });
271
+ return obj;
272
+ }
273
+
274
+ return value;
275
+ }
276
+
277
+ async function applyContextUpdates(node, updates, msg) {
278
+ if (!node || !updates || typeof updates !== 'object' || typeof node.context !== 'function') {
279
+ return;
280
+ }
281
+
282
+ const context = node.context();
283
+ if (!context) {
284
+ return;
285
+ }
286
+
287
+ const flowUpdates = updates.flow && typeof updates.flow === 'object' ? updates.flow : {};
288
+ const globalUpdates = updates.global && typeof updates.global === 'object' ? updates.global : {};
289
+ const contextUpdates = updates.context && typeof updates.context === 'object' ? updates.context : {};
290
+
291
+ const applyUpdates = async (scope, scopeLabel, scopeUpdates) => {
292
+ if (!scope || typeof scope.set !== 'function') {
293
+ return;
294
+ }
295
+ const setValueAsync = (key, value) => {
296
+ if (scope.set.length >= 3) {
297
+ return new Promise((resolve) => {
298
+ try {
299
+ scope.set(key, value, (err) => {
300
+ if (err && typeof node.warn === 'function') {
301
+ node.warn(`Failed to set ${scopeLabel} context "${key}": ${err.message || err}`, msg);
302
+ }
303
+ resolve();
304
+ });
305
+ } catch (err) {
306
+ if (typeof node.warn === 'function') {
307
+ node.warn(`Failed to set ${scopeLabel} context "${key}": ${err.message || err}`, msg);
308
+ }
309
+ resolve();
310
+ }
311
+ });
312
+ }
313
+ try {
314
+ const result = scope.set(key, value);
315
+ if (result && typeof result.then === 'function') {
316
+ return result.catch((err) => {
317
+ if (typeof node.warn === 'function') {
318
+ node.warn(`Failed to set ${scopeLabel} context "${key}": ${err.message || err}`, msg);
319
+ }
320
+ });
321
+ }
322
+ } catch (err) {
323
+ if (typeof node.warn === 'function') {
324
+ node.warn(`Failed to set ${scopeLabel} context "${key}": ${err.message || err}`, msg);
325
+ }
326
+ }
327
+ return Promise.resolve();
328
+ };
329
+ const entries = Object.keys(scopeUpdates);
330
+ await Promise.all(entries.map(async (key) => {
331
+ const value = rehydrateContextValue(scopeUpdates[key]);
332
+ await setValueAsync(key, value);
333
+ }));
334
+ };
335
+
336
+ await Promise.all([
337
+ applyUpdates(context.flow, 'flow', flowUpdates),
338
+ applyUpdates(context.global, 'global', globalUpdates),
339
+ applyUpdates(context, 'context', contextUpdates)
340
+ ]);
341
+ }
342
+
343
+ function applyWorkerLogs(node, logs, msg) {
344
+ if (!node || !Array.isArray(logs)) {
345
+ return;
346
+ }
347
+
348
+ logs.forEach((entry) => {
349
+ if (!entry) {
350
+ return;
351
+ }
352
+
353
+ const level = typeof entry === 'object' && entry.level ? String(entry.level) : 'warn';
354
+ const message = typeof entry === 'object' && entry.message !== undefined
355
+ ? entry.message
356
+ : entry;
357
+ let text = '';
358
+ if (typeof message === 'string') {
359
+ text = message;
360
+ } else {
361
+ try {
362
+ text = JSON.stringify(message);
363
+ } catch (_err) {
364
+ text = String(message);
365
+ }
366
+ }
367
+
368
+ if (level === 'error' && typeof node.error === 'function') {
369
+ node.error(text, msg);
370
+ return;
371
+ }
372
+
373
+ if (level === 'log' && typeof node.log === 'function') {
374
+ node.log(text);
375
+ return;
376
+ }
377
+
378
+ if (typeof node.warn === 'function') {
379
+ node.warn(text, msg);
380
+ }
381
+ });
382
+ }
383
+
384
+
87
385
  function applyPerformanceMetrics(node, originalMsg, targetMsg, performance) {
88
386
  if (!performance || typeof performance !== 'object') {
89
387
  return;
90
388
  }
91
389
 
92
- const label = (typeof node.name === 'string' && node.name.trim()) ? node.name.trim() : 'async function';
390
+ const label = (typeof node.name === 'string' && node.name.trim()) ? node.name.trim() : 'worker function';
93
391
  if (!label) {
94
392
  return;
95
393
  }
@@ -241,32 +539,9 @@ module.exports = function(RED) {
241
539
  }
242
540
 
243
541
  const nodeRedUserDir = resolveNodeRedUserDir(RED);
542
+ const transferMode = normalizeTransferMode(config.transferMode, node.executionMode);
244
543
 
245
- // Verify modules are resolvable WITHOUT loading them in the main thread.
246
- // Loading native modules (like 'gl') in main thread prevents them from
247
- // working in worker threads due to native module registration conflicts.
248
- if (node.libs.length > 0) {
249
- const { createRequire } = require('module');
250
- const nodeRedRequire = createRequire(path.join(nodeRedUserDir, 'package.json'));
251
-
252
- for (const lib of node.libs) {
253
- if (!lib || !lib.module || !lib.var) {
254
- continue;
255
- }
256
- try {
257
- // Only resolve the path - don't actually load the module
258
- nodeRedRequire.resolve(lib.module);
259
- } catch (err) {
260
- node.error(`Module "${lib.module}" not found. Install it with: cd ${nodeRedUserDir} && npm install ${lib.module}`);
261
- node.status({
262
- fill: 'red',
263
- shape: 'dot',
264
- text: `Module not found: ${lib.module}`
265
- });
266
- return;
267
- }
268
- }
269
- }
544
+ // Module resolution happens inside workers to avoid blocking the main thread.
270
545
 
271
546
  const startPool = () => {
272
547
  try {
@@ -276,6 +551,7 @@ module.exports = function(RED) {
276
551
  maxQueueSize: config.maxQueueSize || 100,
277
552
  taskTimeout: node.timeout,
278
553
  shmThreshold: 0,
554
+ transferMode,
279
555
  libs: node.libs,
280
556
  nodeRedUserDir
281
557
  });
@@ -324,22 +600,29 @@ module.exports = function(RED) {
324
600
  }
325
601
 
326
602
  const timing = { start: process.hrtime.bigint() };
327
- const workerMsg = buildWorkerInputMsg(msg, node.func);
603
+ const contextInfo = await buildContextSnapshot(node, node.func);
604
+ const workerMsg = buildWorkerInputMsg(msg, node.func, contextInfo.snapshot, contextInfo.usesContext);
328
605
 
329
606
  try {
330
607
  const payload = await node.pool.executeTask(node.func, workerMsg, node.timeout);
331
608
 
332
609
  let resultData;
333
610
  let performanceData = null;
611
+ let contextUpdates = null;
612
+ let logs = null;
334
613
 
335
614
  if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'result')) {
336
615
  resultData = payload.result;
337
616
  performanceData = payload.performance || null;
617
+ contextUpdates = payload.contextUpdates || null;
618
+ logs = payload.logs || null;
338
619
  } else {
339
620
  resultData = payload;
340
621
  }
341
622
 
342
623
  if (resultData === null || resultData === undefined) {
624
+ await applyContextUpdates(node, contextUpdates, msg);
625
+ applyWorkerLogs(node, logs, msg);
343
626
  done();
344
627
  return;
345
628
  }
@@ -348,6 +631,9 @@ module.exports = function(RED) {
348
631
  const mergedPerformance = Object.assign({}, performanceData || {});
349
632
  mergedPerformance.totalMs = totalMs;
350
633
 
634
+ await applyContextUpdates(node, contextUpdates, msg);
635
+ applyWorkerLogs(node, logs, msg);
636
+
351
637
  const output = mergeResult(msg, resultData);
352
638
  applyPerformanceToResult(node, msg, output, mergedPerformance);
353
639
 
@@ -362,8 +648,15 @@ module.exports = function(RED) {
362
648
  text: `Error: ${err.message.substring(0, 20)}`
363
649
  });
364
650
 
651
+ if (err && err.contextUpdates) {
652
+ await applyContextUpdates(node, err.contextUpdates, msg);
653
+ }
654
+ if (err && err.logs) {
655
+ applyWorkerLogs(node, err.logs, msg);
656
+ }
657
+
365
658
  // Log error
366
- node.error(`Async function error: ${err.message}`, msg);
659
+ node.error(`Worker function error: ${err.message}`, msg);
367
660
 
368
661
  // Propagate error to Catch node
369
662
  done(err);
@@ -410,12 +703,12 @@ module.exports = function(RED) {
410
703
  }
411
704
 
412
705
  // Register the node type
413
- RED.nodes.registerType('async-function', AsyncFunctionNode, {
706
+ RED.nodes.registerType('worker-function', AsyncFunctionNode, {
414
707
  dynamicModuleList: 'libs'
415
708
  });
416
709
 
417
710
  // HTTP endpoint to restart workers for a specific node
418
- RED.httpAdmin.post('/async-function/:id/restart', async function(req, res) {
711
+ RED.httpAdmin.post('/worker-function/:id/restart', async function(req, res) {
419
712
  const node = RED.nodes.getNode(req.params.id);
420
713
  if (!node || !node.pool) {
421
714
  return res.status(404).json({ error: 'Node not found or pool not initialized' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rosepetal/node-red-contrib-async-function",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "A Node-RED function node that runs code in worker threads to keep your flows responsive",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  "keywords": [
10
10
  "node-red",
11
11
  "function",
12
- "async",
12
+ "worker-function",
13
13
  "worker-threads",
14
14
  "non-blocking",
15
15
  "performance",
@@ -34,7 +34,7 @@
34
34
  "node-red": {
35
35
  "version": ">=2.0.0",
36
36
  "nodes": {
37
- "async-function": "nodes/async-function.js"
37
+ "worker-function": "nodes/worker-function.js"
38
38
  }
39
39
  },
40
40
  "dependencies": {}