@rosepetal/node-red-contrib-async-function 1.0.0 → 1.0.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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Child Process Script
3
+ *
4
+ * Executes user-provided JavaScript code in a child process.
5
+ * Handles message communication with the parent process.
6
+ * Restores buffers from shared memory before execution.
7
+ */
8
+
9
+ const { AsyncLocalStorage } = require('async_hooks');
10
+ const { createRequire, builtinModules } = require('module');
11
+ const path = require('path');
12
+ const url = require('url');
13
+ const { SharedMemoryManager } = require('./shared-memory-manager');
14
+ const { AsyncMessageSerializer } = require('./message-serializer');
15
+
16
+ // Track worker state
17
+ let isTerminating = false;
18
+ let isInitialized = false;
19
+
20
+ // AsyncLocalStorage for tracking task context across async boundaries
21
+ const taskContext = new AsyncLocalStorage();
22
+
23
+ function hrtimeDiffToMs(start) {
24
+ if (typeof start !== 'bigint') {
25
+ return 0;
26
+ }
27
+ const diff = process.hrtime.bigint() - start;
28
+ return Number(diff) / 1e6;
29
+ }
30
+
31
+ // Shared memory manager and serializer are initialized after init message
32
+ let shmManager = null;
33
+ let serializer = null;
34
+
35
+ // Cache compiled user code per worker for hot-path performance
36
+ const AsyncFunction = (async function() {}).constructor;
37
+ const MAX_CACHE_SIZE = 100;
38
+ const compiledCodeCache = new Map();
39
+
40
+ function getCachedFunction(cacheKey) {
41
+ const fn = compiledCodeCache.get(cacheKey);
42
+ if (fn) {
43
+ compiledCodeCache.delete(cacheKey);
44
+ compiledCodeCache.set(cacheKey, fn);
45
+ }
46
+ return fn;
47
+ }
48
+
49
+ function setCachedFunction(cacheKey, fn) {
50
+ if (compiledCodeCache.size >= MAX_CACHE_SIZE) {
51
+ const oldestKey = compiledCodeCache.keys().next().value;
52
+ compiledCodeCache.delete(oldestKey);
53
+ }
54
+ compiledCodeCache.set(cacheKey, fn);
55
+ }
56
+
57
+ // Module loading setup
58
+ let baseRequire = require;
59
+ const loadedModules = {};
60
+ const moduleVars = [];
61
+ const moduleValues = [];
62
+ const failedModules = [];
63
+
64
+ function configureBaseRequire(nodeRedUserDir) {
65
+ if (nodeRedUserDir) {
66
+ baseRequire = createRequire(path.join(nodeRedUserDir, 'package.json'));
67
+ } else {
68
+ baseRequire = require;
69
+ }
70
+ global.require = baseRequire;
71
+ }
72
+
73
+ function parseModuleSpec(spec) {
74
+ const match = /((?:@[^/]+\/)?[^/@]+)(\/[^/@]+)?(?:@([\s\S]+))?/.exec(spec);
75
+ if (!match) {
76
+ return { spec, module: spec, subpath: '', builtin: false };
77
+ }
78
+ const moduleName = match[1];
79
+ const subpath = match[2] || '';
80
+ let builtinName = moduleName;
81
+ if (builtinName.startsWith('node:')) {
82
+ builtinName = builtinName.slice(5);
83
+ }
84
+ const builtin = builtinModules.includes(builtinName);
85
+ return { spec, module: moduleName, subpath, builtin };
86
+ }
87
+
88
+ async function loadModule(spec) {
89
+ const parsed = parseModuleSpec(spec);
90
+ if (parsed.builtin) {
91
+ return baseRequire(parsed.module + parsed.subpath);
92
+ }
93
+ const resolvedPath = baseRequire.resolve(spec);
94
+ const moduleUrl = url.pathToFileURL(resolvedPath);
95
+ const imported = await import(moduleUrl);
96
+ return imported.default || imported;
97
+ }
98
+
99
+ async function loadConfiguredModules(libs) {
100
+ if (!Array.isArray(libs)) {
101
+ return;
102
+ }
103
+ for (const lib of libs) {
104
+ if (!lib || !lib.module || !lib.var) {
105
+ continue;
106
+ }
107
+ try {
108
+ loadedModules[lib.var] = await loadModule(lib.module);
109
+ moduleVars.push(lib.var);
110
+ moduleValues.push(loadedModules[lib.var]);
111
+ } catch (err) {
112
+ console.error(`[async-function] Failed to load module ${lib.module}: ${err.message}`);
113
+ failedModules.push({ module: lib.module, var: lib.var, error: err.message });
114
+ }
115
+ }
116
+ }
117
+
118
+ function sendMessage(payload) {
119
+ if (typeof process.send === 'function') {
120
+ process.send(payload);
121
+ }
122
+ }
123
+
124
+ function sendError(taskId, err) {
125
+ const error = err instanceof Error ? err : new Error(String(err));
126
+ sendMessage({
127
+ type: 'error',
128
+ taskId,
129
+ error: {
130
+ message: error.message,
131
+ stack: error.stack || '',
132
+ name: error.name || 'Error'
133
+ }
134
+ });
135
+ }
136
+
137
+ async function initializeWorker(initData) {
138
+ const nodeRedUserDir = initData && initData.nodeRedUserDir ? initData.nodeRedUserDir : null;
139
+ configureBaseRequire(nodeRedUserDir);
140
+
141
+ const threshold = initData && typeof initData.shmThreshold === 'number' ? initData.shmThreshold : undefined;
142
+ shmManager = new SharedMemoryManager({
143
+ threshold,
144
+ trackAttachments: false,
145
+ cleanupOrphanedFiles: false
146
+ });
147
+ serializer = new AsyncMessageSerializer(shmManager);
148
+
149
+ await loadConfiguredModules(initData ? initData.libs : []);
150
+ isInitialized = true;
151
+
152
+ sendMessage({
153
+ type: 'ready',
154
+ failedModules: failedModules.length > 0 ? failedModules : undefined
155
+ });
156
+ }
157
+
158
+ process.on('message', async (data) => {
159
+ if (!data || typeof data !== 'object') {
160
+ return;
161
+ }
162
+
163
+ if (data.type === 'init') {
164
+ try {
165
+ await initializeWorker(data);
166
+ } catch (err) {
167
+ failedModules.push({ module: '(init)', var: null, error: err.message });
168
+ sendMessage({
169
+ type: 'ready',
170
+ failedModules
171
+ });
172
+ }
173
+ return;
174
+ }
175
+
176
+ if (isTerminating) {
177
+ return;
178
+ }
179
+
180
+ if (!isInitialized) {
181
+ if (data.type === 'execute') {
182
+ sendError(data.taskId, new Error('Worker not initialized'));
183
+ }
184
+ return;
185
+ }
186
+
187
+ const { type, taskId, code, msg } = data;
188
+
189
+ if (type === 'execute') {
190
+ taskContext.run({ taskId }, async () => {
191
+ try {
192
+ const restoreStart = process.hrtime.bigint();
193
+ const restoredMsg = await serializer.restoreBuffers(msg);
194
+ const transferToWorkerMs = hrtimeDiffToMs(restoreStart);
195
+
196
+ const cacheKey = code + '|' + moduleVars.join(',');
197
+ let userFunction = getCachedFunction(cacheKey);
198
+ if (!userFunction) {
199
+ userFunction = new AsyncFunction('msg', ...moduleVars, code);
200
+ setCachedFunction(cacheKey, userFunction);
201
+ }
202
+
203
+ const execStart = process.hrtime.bigint();
204
+ const rawResult = await userFunction(restoredMsg, ...moduleValues);
205
+ const executionMs = hrtimeDiffToMs(execStart);
206
+
207
+ const encodeStart = process.hrtime.bigint();
208
+ const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId);
209
+ const transferToMainMs = hrtimeDiffToMs(encodeStart);
210
+
211
+ sendMessage({
212
+ type: 'result',
213
+ taskId,
214
+ result: encodedResult,
215
+ performance: {
216
+ transferToWorkerMs,
217
+ executionMs,
218
+ transferToMainMs
219
+ }
220
+ });
221
+ } catch (err) {
222
+ sendError(taskId, err);
223
+ }
224
+ });
225
+ } else if (type === 'terminate') {
226
+ isTerminating = true;
227
+ sendMessage({
228
+ type: 'terminated',
229
+ taskId
230
+ });
231
+ process.exit(0);
232
+ }
233
+ });
234
+
235
+ process.on('uncaughtException', (err) => {
236
+ if (!isTerminating) {
237
+ const store = taskContext.getStore();
238
+ sendMessage({
239
+ type: 'error',
240
+ taskId: store?.taskId ?? null,
241
+ error: {
242
+ message: `Uncaught exception: ${err.message}`,
243
+ stack: err.stack || '',
244
+ name: err.name || 'Error'
245
+ }
246
+ });
247
+ }
248
+ });
249
+
250
+ process.on('unhandledRejection', (reason) => {
251
+ if (!isTerminating) {
252
+ const store = taskContext.getStore();
253
+ const error = reason instanceof Error ? reason : new Error(String(reason));
254
+ sendMessage({
255
+ type: 'error',
256
+ taskId: store?.taskId ?? null,
257
+ error: {
258
+ message: `Unhandled rejection: ${error.message}`,
259
+ stack: error.stack || '',
260
+ name: 'UnhandledRejection'
261
+ }
262
+ });
263
+ }
264
+ });
@@ -2,7 +2,7 @@
2
2
  * Async Message Serializer
3
3
  *
4
4
  * Fast message cloning for worker thread communication.
5
- * Matches the shared-memory + msg-copy semantics used by the python executor "hot mode":
5
+ * Matches the shared-memory + msg-copy semantics used by the legacy executor "hot mode":
6
6
  * - Buffers (and typed arrays) can be offloaded to shared memory with descriptors
7
7
  * - Base64 fallback for shared-memory failures
8
8
  * - Circular references preserved via WeakMap
@@ -105,6 +105,18 @@ class WorkerPool {
105
105
  worker.on('message', (msg) => {
106
106
  if (msg.type === 'ready') {
107
107
  clearTimeout(readyTimeout);
108
+ if (msg.failedModules && msg.failedModules.length > 0) {
109
+ const detail = msg.failedModules
110
+ .map((failed) => `${failed.module} (${failed.var}): ${failed.error}`)
111
+ .join('; ');
112
+ const err = new Error(`Worker failed to load module(s): ${detail}`);
113
+ err.failedModules = msg.failedModules;
114
+ worker.terminate().finally(() => {
115
+ reject(err);
116
+ });
117
+ return;
118
+ }
119
+
108
120
  workerState.state = WorkerState.IDLE;
109
121
  this.workers.push(workerState);
110
122
  resolve(workerState);
@@ -7,12 +7,20 @@
7
7
  */
8
8
 
9
9
  const { parentPort, workerData } = require('worker_threads');
10
+ const { AsyncLocalStorage } = require('async_hooks');
11
+ const { createRequire, builtinModules } = require('module');
12
+ const path = require('path');
13
+ const url = require('url');
10
14
  const { SharedMemoryManager } = require('./shared-memory-manager');
11
15
  const { AsyncMessageSerializer } = require('./message-serializer');
12
16
 
13
17
  // Track worker state
14
18
  let isTerminating = false;
15
19
 
20
+ // AsyncLocalStorage for tracking task context across async boundaries
21
+ // This ensures unhandled rejections can be attributed to the correct task
22
+ const taskContext = new AsyncLocalStorage();
23
+
16
24
  function hrtimeDiffToMs(start) {
17
25
  if (typeof start !== 'bigint') {
18
26
  return 0;
@@ -58,26 +66,58 @@ function setCachedFunction(cacheKey, fn) {
58
66
  const loadedModules = {};
59
67
  const moduleVars = [];
60
68
  const moduleValues = [];
69
+ const failedModules = []; // Track modules that failed to load
70
+
71
+ const baseRequire = (() => {
72
+ if (workerData && workerData.nodeRedUserDir) {
73
+ return createRequire(path.join(workerData.nodeRedUserDir, 'package.json'));
74
+ }
75
+ return require;
76
+ })();
61
77
 
62
- // Add Node-RED user directory to module search path for external modules
63
- if (workerData && workerData.nodeRedUserDir) {
64
- const path = require('path');
65
- const nodeModulesPath = path.join(workerData.nodeRedUserDir, 'node_modules');
66
- if (!module.paths.includes(nodeModulesPath)) {
67
- module.paths.unshift(nodeModulesPath);
78
+ global.require = baseRequire;
79
+
80
+ function parseModuleSpec(spec) {
81
+ const match = /((?:@[^/]+\/)?[^/@]+)(\/[^/@]+)?(?:@([\s\S]+))?/.exec(spec);
82
+ if (!match) {
83
+ return { spec, module: spec, subpath: '', builtin: false };
84
+ }
85
+ const moduleName = match[1];
86
+ const subpath = match[2] || '';
87
+ let builtinName = moduleName;
88
+ if (builtinName.startsWith('node:')) {
89
+ builtinName = builtinName.slice(5);
68
90
  }
91
+ const builtin = builtinModules.includes(builtinName);
92
+ return { spec, module: moduleName, subpath, builtin };
69
93
  }
70
94
 
71
- if (workerData && workerData.libs && Array.isArray(workerData.libs)) {
95
+ async function loadModule(spec) {
96
+ const parsed = parseModuleSpec(spec);
97
+ if (parsed.builtin) {
98
+ return baseRequire(parsed.module + parsed.subpath);
99
+ }
100
+ const resolvedPath = baseRequire.resolve(spec);
101
+ const moduleUrl = url.pathToFileURL(resolvedPath);
102
+ const imported = await import(moduleUrl);
103
+ return imported.default || imported;
104
+ }
105
+
106
+ async function loadConfiguredModules() {
107
+ if (!workerData || !Array.isArray(workerData.libs)) {
108
+ return;
109
+ }
72
110
  for (const lib of workerData.libs) {
73
- if (lib.module && lib.var) {
74
- try {
75
- loadedModules[lib.var] = require(lib.module);
76
- moduleVars.push(lib.var);
77
- moduleValues.push(loadedModules[lib.var]);
78
- } catch (err) {
79
- console.error(`[async-function] Failed to load module ${lib.module}: ${err.message}`);
80
- }
111
+ if (!lib || !lib.module || !lib.var) {
112
+ continue;
113
+ }
114
+ try {
115
+ loadedModules[lib.var] = await loadModule(lib.module);
116
+ moduleVars.push(lib.var);
117
+ moduleValues.push(loadedModules[lib.var]);
118
+ } catch (err) {
119
+ console.error(`[async-function] Failed to load module ${lib.module}: ${err.message}`);
120
+ failedModules.push({ module: lib.module, var: lib.var, error: err.message });
81
121
  }
82
122
  }
83
123
  }
@@ -85,7 +125,13 @@ if (workerData && workerData.libs && Array.isArray(workerData.libs)) {
85
125
  /**
86
126
  * Handle incoming messages from main thread
87
127
  */
88
- if (parentPort) {
128
+ async function initializeWorker() {
129
+ await loadConfiguredModules();
130
+
131
+ if (!parentPort) {
132
+ return;
133
+ }
134
+
89
135
  parentPort.on('message', async (data) => {
90
136
  // Ignore messages if terminating
91
137
  if (isTerminating) {
@@ -96,55 +142,60 @@ if (parentPort) {
96
142
 
97
143
  // Handle different message types
98
144
  if (type === 'execute') {
99
- try {
100
- // Restore + execute user code
101
- const restoreStart = process.hrtime.bigint();
102
- const restoredMsg = await serializer.restoreBuffers(msg);
103
- const transferToPythonMs = hrtimeDiffToMs(restoreStart);
104
-
105
- // Cache key includes code + module vars to handle different module configs
106
- const cacheKey = code + '|' + moduleVars.join(',');
107
- let userFunction = getCachedFunction(cacheKey);
108
- if (!userFunction) {
109
- // Create function with msg + all module variables as parameters
110
- userFunction = new AsyncFunction('msg', ...moduleVars, code);
111
- setCachedFunction(cacheKey, userFunction);
112
- }
113
-
114
- const execStart = process.hrtime.bigint();
115
- // Execute with msg and all loaded module values
116
- const rawResult = await userFunction(restoredMsg, ...moduleValues);
117
- const executionMs = hrtimeDiffToMs(execStart);
118
-
119
- // Offload buffers in the result (large Buffers -> shared memory descriptors)
120
- const encodeStart = process.hrtime.bigint();
121
- const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId);
122
- const transferToJsMs = hrtimeDiffToMs(encodeStart);
123
-
124
- // Send result back to main thread
125
- parentPort.postMessage({
126
- type: 'result',
127
- taskId,
128
- result: encodedResult,
129
- performance: {
130
- transfer_to_python_ms: transferToPythonMs,
131
- execution_ms: executionMs,
132
- transfer_to_js_ms: transferToJsMs
133
- }
134
- });
135
-
136
- } catch (err) {
137
- // Send error back to main thread
138
- parentPort.postMessage({
139
- type: 'error',
140
- taskId,
141
- error: {
142
- message: err.message,
143
- stack: err.stack,
144
- name: err.name
145
+ // Run task within AsyncLocalStorage context for proper error attribution
146
+ // This ensures unhandled rejections from fire-and-forget promises
147
+ // can be traced back to the correct task
148
+ taskContext.run({ taskId }, async () => {
149
+ try {
150
+ // Restore + execute user code
151
+ const restoreStart = process.hrtime.bigint();
152
+ const restoredMsg = await serializer.restoreBuffers(msg);
153
+ const transferToWorkerMs = hrtimeDiffToMs(restoreStart);
154
+
155
+ // Cache key includes code + module vars to handle different module configs
156
+ const cacheKey = code + '|' + moduleVars.join(',');
157
+ let userFunction = getCachedFunction(cacheKey);
158
+ if (!userFunction) {
159
+ // Create function with msg + all module variables as parameters
160
+ userFunction = new AsyncFunction('msg', ...moduleVars, code);
161
+ setCachedFunction(cacheKey, userFunction);
145
162
  }
146
- });
147
- }
163
+
164
+ const execStart = process.hrtime.bigint();
165
+ // Execute with msg and all loaded module values
166
+ const rawResult = await userFunction(restoredMsg, ...moduleValues);
167
+ const executionMs = hrtimeDiffToMs(execStart);
168
+
169
+ // Offload buffers in the result (large Buffers -> shared memory descriptors)
170
+ const encodeStart = process.hrtime.bigint();
171
+ const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId);
172
+ const transferToMainMs = hrtimeDiffToMs(encodeStart);
173
+
174
+ // Send result back to main thread
175
+ parentPort.postMessage({
176
+ type: 'result',
177
+ taskId,
178
+ result: encodedResult,
179
+ performance: {
180
+ transferToWorkerMs,
181
+ executionMs,
182
+ transferToMainMs
183
+ }
184
+ });
185
+
186
+ } catch (err) {
187
+ // Send error back to main thread
188
+ parentPort.postMessage({
189
+ type: 'error',
190
+ taskId,
191
+ error: {
192
+ message: err.message,
193
+ stack: err.stack,
194
+ name: err.name
195
+ }
196
+ });
197
+ }
198
+ });
148
199
  } else if (type === 'terminate') {
149
200
  // Graceful termination requested
150
201
  isTerminating = true;
@@ -156,12 +207,13 @@ if (parentPort) {
156
207
  }
157
208
  });
158
209
 
159
- // Handle errors
210
+ // Handle errors - use AsyncLocalStorage to get taskId for proper error attribution
160
211
  process.on('uncaughtException', (err) => {
161
212
  if (!isTerminating) {
213
+ const store = taskContext.getStore();
162
214
  parentPort.postMessage({
163
215
  type: 'error',
164
- taskId: null,
216
+ taskId: store?.taskId ?? null,
165
217
  error: {
166
218
  message: `Uncaught exception: ${err.message}`,
167
219
  stack: err.stack,
@@ -173,9 +225,10 @@ if (parentPort) {
173
225
 
174
226
  process.on('unhandledRejection', (reason, _promise) => {
175
227
  if (!isTerminating) {
228
+ const store = taskContext.getStore();
176
229
  parentPort.postMessage({
177
230
  type: 'error',
178
- taskId: null,
231
+ taskId: store?.taskId ?? null,
179
232
  error: {
180
233
  message: `Unhandled rejection: ${reason}`,
181
234
  stack: reason?.stack || '',
@@ -185,8 +238,18 @@ if (parentPort) {
185
238
  }
186
239
  });
187
240
 
188
- // Signal ready
241
+ // Signal ready (include any module loading failures)
189
242
  parentPort.postMessage({
190
- type: 'ready'
243
+ type: 'ready',
244
+ failedModules: failedModules.length > 0 ? failedModules : undefined
191
245
  });
192
246
  }
247
+
248
+ initializeWorker().catch((err) => {
249
+ if (parentPort) {
250
+ parentPort.postMessage({
251
+ type: 'ready',
252
+ failedModules: [{ module: '(init)', var: null, error: err.message }]
253
+ });
254
+ }
255
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rosepetal/node-red-contrib-async-function",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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",