@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.
- package/README.md +4 -3
- package/nodes/async-function.html +65 -12
- package/nodes/async-function.js +132 -48
- package/nodes/lib/child-process-pool.js +610 -0
- package/nodes/lib/child-process-script.js +264 -0
- package/nodes/lib/message-serializer.js +1 -1
- package/nodes/lib/worker-pool.js +11 -7
- package/nodes/lib/worker-script.js +70 -22
- package/package.json +1 -1
|
@@ -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
|
|
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
|
package/nodes/lib/worker-pool.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
69
|
-
if (workerData && workerData.nodeRedUserDir) {
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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