@rosepetal/node-red-contrib-async-function 1.0.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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Async Function Node
3
+ *
4
+ * A Node-RED function node that executes user code in worker threads
5
+ * to prevent event loop blocking.
6
+ */
7
+
8
+ const { WorkerPool } = require('./lib/worker-pool');
9
+ const { installModule, getNodeRedUserDir } = require('./lib/module-installer');
10
+
11
+ function extractMsgKeysFromCode(code) {
12
+ const keys = new Set();
13
+ if (typeof code !== 'string' || !code.trim()) {
14
+ return keys;
15
+ }
16
+
17
+ // msg['payload'] or msg["payload"]
18
+ const bracketRegex = /msg\[['"]([A-Za-z0-9_.$:-]+)['"]\]/g;
19
+ let match = bracketRegex.exec(code);
20
+ while (match) {
21
+ keys.add(match[1]);
22
+ match = bracketRegex.exec(code);
23
+ }
24
+
25
+ // msg.payload style access
26
+ const dotRegex = /msg\.([A-Za-z_][A-Za-z0-9_]*)/g;
27
+ match = dotRegex.exec(code);
28
+ while (match) {
29
+ keys.add(match[1]);
30
+ match = dotRegex.exec(code);
31
+ }
32
+
33
+ return keys;
34
+ }
35
+
36
+ function buildWorkerInputMsg(originalMsg, code) {
37
+ if (!originalMsg || typeof originalMsg !== 'object') {
38
+ return originalMsg;
39
+ }
40
+
41
+ const keys = extractMsgKeysFromCode(code);
42
+
43
+ // If we cannot confidently determine keys, fall back to full message
44
+ if (!keys || keys.size === 0) {
45
+ return originalMsg;
46
+ }
47
+
48
+ const subset = {};
49
+ keys.forEach((key) => {
50
+ if (Object.prototype.hasOwnProperty.call(originalMsg, key)) {
51
+ subset[key] = originalMsg[key];
52
+ }
53
+ });
54
+
55
+ // Preserve _msgid for traceability
56
+ if (Object.prototype.hasOwnProperty.call(originalMsg, '_msgid') && !Object.prototype.hasOwnProperty.call(subset, '_msgid')) {
57
+ subset._msgid = originalMsg._msgid;
58
+ }
59
+
60
+ return subset;
61
+ }
62
+
63
+ function normalizePerformanceValue(value) {
64
+ const numeric = Number(value);
65
+ return Number.isFinite(numeric) ? numeric : 0;
66
+ }
67
+
68
+ function hrtimeDiffToMs(start) {
69
+ if (typeof start !== 'bigint') {
70
+ return 0;
71
+ }
72
+ const diff = process.hrtime.bigint() - start;
73
+ return Number(diff) / 1e6;
74
+ }
75
+
76
+ function applyPerformanceMetrics(node, originalMsg, targetMsg, performance) {
77
+ if (!performance || typeof performance !== 'object') {
78
+ return;
79
+ }
80
+
81
+ const label = (typeof node.name === 'string' && node.name.trim()) ? node.name.trim() : 'async function';
82
+ if (!label) {
83
+ return;
84
+ }
85
+
86
+ const copyPerformance = (source, destination) => {
87
+ if (source && typeof source === 'object' && !Array.isArray(source)) {
88
+ Object.keys(source).forEach((key) => {
89
+ destination[key] = source[key];
90
+ });
91
+ }
92
+ };
93
+
94
+ const collected = {};
95
+ if (originalMsg && originalMsg !== targetMsg) {
96
+ copyPerformance(originalMsg.performance, collected);
97
+ }
98
+ copyPerformance(targetMsg.performance, collected);
99
+
100
+ collected[label] = {
101
+ transferToPythonMs: normalizePerformanceValue(performance.transfer_to_python_ms ?? performance.transferToPythonMs),
102
+ executionMs: normalizePerformanceValue(performance.execution_ms ?? performance.executionMs),
103
+ transferToJsMs: normalizePerformanceValue(performance.transfer_to_js_ms ?? performance.transferToJsMs),
104
+ totalMs: normalizePerformanceValue(performance.totalMs ?? performance.total_ms ?? performance.total)
105
+ };
106
+
107
+ targetMsg.performance = collected;
108
+ }
109
+
110
+ function mergeResult(originalMsg, resultData) {
111
+ if (resultData === null || resultData === undefined) {
112
+ return resultData;
113
+ }
114
+
115
+ if (Array.isArray(resultData)) {
116
+ return resultData.map((entry) => mergeResult(originalMsg, entry));
117
+ }
118
+
119
+ if (typeof resultData === 'object') {
120
+ return Object.assign({}, originalMsg, resultData);
121
+ }
122
+
123
+ return resultData;
124
+ }
125
+
126
+ function applyPerformanceToResult(node, originalMsg, resultData, performance) {
127
+ if (resultData === null || resultData === undefined) {
128
+ return;
129
+ }
130
+
131
+ if (Array.isArray(resultData)) {
132
+ resultData.forEach((entry) => applyPerformanceToResult(node, originalMsg, entry, performance));
133
+ return;
134
+ }
135
+
136
+ if (typeof resultData === 'object') {
137
+ applyPerformanceMetrics(node, originalMsg, resultData, performance);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Update node status with pool statistics
143
+ */
144
+ function updateStatus(node) {
145
+ if (!node.pool) return;
146
+
147
+ const stats = node.pool.getStats();
148
+
149
+ // Format: "Active: 2/4 | Queue: 5 | SHM: 3" (SHM only shown if >0 files)
150
+ let statusText = `Active: ${stats.busyWorkers}/${stats.totalWorkers} | Queue: ${stats.queuedTasks}`;
151
+ if (stats.sharedMemory && stats.sharedMemory.activeFiles > 0) {
152
+ statusText += ` | SHM: ${stats.sharedMemory.activeFiles}`;
153
+ }
154
+
155
+ // Color logic
156
+ let fill = 'green';
157
+ let shape = 'dot';
158
+
159
+ if (stats.queuedTasks > 50) {
160
+ fill = 'yellow'; // Queue getting full
161
+ }
162
+ if (stats.queuedTasks >= stats.config.maxQueueSize * 0.9) {
163
+ fill = 'red'; // Queue almost full
164
+ }
165
+ if (stats.busyWorkers === stats.totalWorkers && stats.queuedTasks > 0) {
166
+ shape = 'ring'; // All workers busy + queue
167
+ }
168
+
169
+ node.status({
170
+ fill: fill,
171
+ shape: shape,
172
+ text: statusText
173
+ });
174
+ }
175
+
176
+ module.exports = function(RED) {
177
+ /**
178
+ * Async Function Node Constructor
179
+ * @param {object} config - Node configuration
180
+ */
181
+ function AsyncFunctionNode(config) {
182
+ RED.nodes.createNode(this, config);
183
+ const node = this;
184
+
185
+ // Store configuration
186
+ node.func = config.func || 'return msg;';
187
+ node.outputs = config.outputs || 1;
188
+ node.timeout = config.timeout || 30000;
189
+ node.name = config.name || '';
190
+ node.errorRecoveryTimer = null; // Track error recovery timer
191
+
192
+ // Migrate old config to new format (backwards compatibility)
193
+ if (config.minWorkers !== undefined || config.maxWorkers !== undefined) {
194
+ config.numWorkers = config.maxWorkers || config.minWorkers || 3;
195
+ node.warn('Configuration migrated: minWorkers/maxWorkers → numWorkers=' + config.numWorkers);
196
+ }
197
+
198
+ // Store libs configuration
199
+ node.libs = config.libs || [];
200
+
201
+ // Pre-check and install missing modules
202
+ if (node.libs && node.libs.length > 0) {
203
+ for (const lib of node.libs) {
204
+ try {
205
+ require.resolve(lib.module);
206
+ } catch (err) {
207
+ if (err.code === 'MODULE_NOT_FOUND') {
208
+ node.warn(`Installing missing module: ${lib.module}`);
209
+ if (!installModule(lib.module)) {
210
+ node.error(`Failed to install module: ${lib.module}. Please install it manually in ~/.node-red`);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // Create per-node worker pool
218
+ try {
219
+ node.pool = new WorkerPool({
220
+ numWorkers: config.numWorkers || 3,
221
+ maxQueueSize: config.maxQueueSize || 100,
222
+ taskTimeout: node.timeout,
223
+ shmThreshold: 0,
224
+ libs: node.libs,
225
+ nodeRedUserDir: getNodeRedUserDir()
226
+ });
227
+ } catch (err) {
228
+ node.error('Failed to create worker pool: ' + err.message);
229
+ node.status({
230
+ fill: 'red',
231
+ shape: 'dot',
232
+ text: 'Pool creation failed'
233
+ });
234
+ return;
235
+ }
236
+
237
+ // Initialize pool
238
+ node.pool.initialize().then(() => {
239
+ updateStatus(node);
240
+ // Start periodic status updates
241
+ node.statusInterval = setInterval(() => {
242
+ updateStatus(node);
243
+ }, 2000); // Update every 2 seconds
244
+ }).catch(err => {
245
+ node.error('Failed to initialize worker pool: ' + err.message);
246
+ node.status({
247
+ fill: 'red',
248
+ shape: 'dot',
249
+ text: 'Init failed'
250
+ });
251
+ });
252
+
253
+ // Handle incoming messages
254
+ node.on('input', async function(msg, send, done) {
255
+ // Backwards compatibility with older Node-RED versions
256
+ send = send || function() { node.send.apply(node, arguments); };
257
+ done = done || function(err) {
258
+ if (err) {
259
+ node.error(err, msg);
260
+ }
261
+ };
262
+
263
+ const timing = { start: process.hrtime.bigint() };
264
+ const workerMsg = buildWorkerInputMsg(msg, node.func);
265
+
266
+ try {
267
+ const payload = await node.pool.executeTask(node.func, workerMsg, node.timeout);
268
+
269
+ let resultData;
270
+ let performanceData = null;
271
+
272
+ if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'result')) {
273
+ resultData = payload.result;
274
+ performanceData = payload.performance || null;
275
+ } else {
276
+ resultData = payload;
277
+ }
278
+
279
+ if (resultData === null || resultData === undefined) {
280
+ done();
281
+ return;
282
+ }
283
+
284
+ const totalMs = hrtimeDiffToMs(timing.start);
285
+ const mergedPerformance = Object.assign({}, performanceData || {});
286
+ mergedPerformance.totalMs = totalMs;
287
+
288
+ const output = mergeResult(msg, resultData);
289
+ applyPerformanceToResult(node, msg, output, mergedPerformance);
290
+
291
+ send(output);
292
+ done();
293
+ // Status updated by periodic interval (every 2s) - no per-message update needed
294
+ } catch (err) {
295
+ // Handle errors
296
+ node.status({
297
+ fill: 'red',
298
+ shape: 'dot',
299
+ text: `Error: ${err.message.substring(0, 20)}`
300
+ });
301
+
302
+ // Log error
303
+ node.error(`Async function error: ${err.message}`, msg);
304
+
305
+ // Propagate error to Catch node
306
+ done(err);
307
+
308
+ // Restore normal status after 3 seconds (clear any existing timer first)
309
+ if (node.errorRecoveryTimer) {
310
+ clearTimeout(node.errorRecoveryTimer);
311
+ }
312
+ node.errorRecoveryTimer = setTimeout(() => {
313
+ node.errorRecoveryTimer = null;
314
+ updateStatus(node);
315
+ }, 3000);
316
+ }
317
+ });
318
+
319
+ // Handle node close
320
+ node.on('close', async function(removed, done) {
321
+ // Clear status interval
322
+ if (node.statusInterval) {
323
+ clearInterval(node.statusInterval);
324
+ }
325
+
326
+ // Clear error recovery timer
327
+ if (node.errorRecoveryTimer) {
328
+ clearTimeout(node.errorRecoveryTimer);
329
+ node.errorRecoveryTimer = null;
330
+ }
331
+
332
+ node.status({});
333
+
334
+ // Shutdown per-node pool
335
+ if (node.pool) {
336
+ try {
337
+ await node.pool.shutdown();
338
+ } catch (err) {
339
+ node.error('Error shutting down worker pool: ' + err.message);
340
+ }
341
+ }
342
+
343
+ if (done) {
344
+ done();
345
+ }
346
+ });
347
+ }
348
+
349
+ // Register the node type
350
+ RED.nodes.registerType('async-function', AsyncFunctionNode);
351
+ };