@rosepetal/node-red-contrib-async-function 1.0.1 → 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,16 +105,20 @@ class WorkerPool {
105
105
  worker.on('message', (msg) => {
106
106
  if (msg.type === 'ready') {
107
107
  clearTimeout(readyTimeout);
108
- workerState.state = WorkerState.IDLE;
109
- this.workers.push(workerState);
110
-
111
- // Log module loading failures so users can diagnose issues
112
108
  if (msg.failedModules && msg.failedModules.length > 0) {
113
- for (const failed of msg.failedModules) {
114
- console.warn(`[async-function] Worker failed to load module "${failed.module}" (${failed.var}): ${failed.error}`);
115
- }
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;
116
118
  }
117
119
 
120
+ workerState.state = WorkerState.IDLE;
121
+ this.workers.push(workerState);
118
122
  resolve(workerState);
119
123
  }
120
124
  });
@@ -8,6 +8,9 @@
8
8
 
9
9
  const { parentPort, workerData } = require('worker_threads');
10
10
  const { AsyncLocalStorage } = require('async_hooks');
11
+ const { createRequire, builtinModules } = require('module');
12
+ const path = require('path');
13
+ const url = require('url');
11
14
  const { SharedMemoryManager } = require('./shared-memory-manager');
12
15
  const { AsyncMessageSerializer } = require('./message-serializer');
13
16
 
@@ -65,26 +68,56 @@ const moduleVars = [];
65
68
  const moduleValues = [];
66
69
  const failedModules = []; // Track modules that failed to load
67
70
 
68
- // Add Node-RED user directory to module search path for external modules
69
- if (workerData && workerData.nodeRedUserDir) {
70
- const path = require('path');
71
- const nodeModulesPath = path.join(workerData.nodeRedUserDir, 'node_modules');
72
- if (!module.paths.includes(nodeModulesPath)) {
73
- module.paths.unshift(nodeModulesPath);
71
+ const baseRequire = (() => {
72
+ if (workerData && workerData.nodeRedUserDir) {
73
+ return createRequire(path.join(workerData.nodeRedUserDir, 'package.json'));
74
74
  }
75
+ return require;
76
+ })();
77
+
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);
90
+ }
91
+ const builtin = builtinModules.includes(builtinName);
92
+ return { spec, module: moduleName, subpath, builtin };
75
93
  }
76
94
 
77
- 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
+ }
78
110
  for (const lib of workerData.libs) {
79
- if (lib.module && lib.var) {
80
- try {
81
- loadedModules[lib.var] = require(lib.module);
82
- moduleVars.push(lib.var);
83
- moduleValues.push(loadedModules[lib.var]);
84
- } catch (err) {
85
- console.error(`[async-function] Failed to load module ${lib.module}: ${err.message}`);
86
- failedModules.push({ module: lib.module, var: lib.var, error: err.message });
87
- }
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 });
88
121
  }
89
122
  }
90
123
  }
@@ -92,7 +125,13 @@ if (workerData && workerData.libs && Array.isArray(workerData.libs)) {
92
125
  /**
93
126
  * Handle incoming messages from main thread
94
127
  */
95
- if (parentPort) {
128
+ async function initializeWorker() {
129
+ await loadConfiguredModules();
130
+
131
+ if (!parentPort) {
132
+ return;
133
+ }
134
+
96
135
  parentPort.on('message', async (data) => {
97
136
  // Ignore messages if terminating
98
137
  if (isTerminating) {
@@ -111,7 +150,7 @@ if (parentPort) {
111
150
  // Restore + execute user code
112
151
  const restoreStart = process.hrtime.bigint();
113
152
  const restoredMsg = await serializer.restoreBuffers(msg);
114
- const transferToPythonMs = hrtimeDiffToMs(restoreStart);
153
+ const transferToWorkerMs = hrtimeDiffToMs(restoreStart);
115
154
 
116
155
  // Cache key includes code + module vars to handle different module configs
117
156
  const cacheKey = code + '|' + moduleVars.join(',');
@@ -130,7 +169,7 @@ if (parentPort) {
130
169
  // Offload buffers in the result (large Buffers -> shared memory descriptors)
131
170
  const encodeStart = process.hrtime.bigint();
132
171
  const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId);
133
- const transferToJsMs = hrtimeDiffToMs(encodeStart);
172
+ const transferToMainMs = hrtimeDiffToMs(encodeStart);
134
173
 
135
174
  // Send result back to main thread
136
175
  parentPort.postMessage({
@@ -138,9 +177,9 @@ if (parentPort) {
138
177
  taskId,
139
178
  result: encodedResult,
140
179
  performance: {
141
- transfer_to_python_ms: transferToPythonMs,
142
- execution_ms: executionMs,
143
- transfer_to_js_ms: transferToJsMs
180
+ transferToWorkerMs,
181
+ executionMs,
182
+ transferToMainMs
144
183
  }
145
184
  });
146
185
 
@@ -205,3 +244,12 @@ if (parentPort) {
205
244
  failedModules: failedModules.length > 0 ? failedModules : undefined
206
245
  });
207
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.1",
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",