@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,192 @@
1
+ /**
2
+ * Worker Thread Script
3
+ *
4
+ * Executes user-provided JavaScript code in an isolated worker thread.
5
+ * Handles message communication with the main thread.
6
+ * Restores buffers from shared memory before execution.
7
+ */
8
+
9
+ const { parentPort, workerData } = require('worker_threads');
10
+ const { SharedMemoryManager } = require('./shared-memory-manager');
11
+ const { AsyncMessageSerializer } = require('./message-serializer');
12
+
13
+ // Track worker state
14
+ let isTerminating = false;
15
+
16
+ function hrtimeDiffToMs(start) {
17
+ if (typeof start !== 'bigint') {
18
+ return 0;
19
+ }
20
+ const diff = process.hrtime.bigint() - start;
21
+ return Number(diff) / 1e6;
22
+ }
23
+
24
+ // Shared memory management (for buffer restoration + result encoding)
25
+ const shmManager = new SharedMemoryManager({
26
+ threshold: workerData && typeof workerData.shmThreshold === 'number' ? workerData.shmThreshold : undefined,
27
+ trackAttachments: false,
28
+ cleanupOrphanedFiles: false
29
+ });
30
+ const serializer = new AsyncMessageSerializer(shmManager);
31
+
32
+ // Cache compiled user code per worker for hot-path performance
33
+ // Bounded LRU-style cache to prevent memory leaks with varied code inputs
34
+ const AsyncFunction = (async function() {}).constructor;
35
+ const MAX_CACHE_SIZE = 100;
36
+ const compiledCodeCache = new Map(); // code string -> AsyncFunction(msg, ...modules) { ... }
37
+
38
+ function getCachedFunction(cacheKey) {
39
+ const fn = compiledCodeCache.get(cacheKey);
40
+ if (fn) {
41
+ // Move to end for LRU behavior (delete + re-add)
42
+ compiledCodeCache.delete(cacheKey);
43
+ compiledCodeCache.set(cacheKey, fn);
44
+ }
45
+ return fn;
46
+ }
47
+
48
+ function setCachedFunction(cacheKey, fn) {
49
+ // Evict oldest entries if at capacity
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
+ // Load configured external modules
58
+ const loadedModules = {};
59
+ const moduleVars = [];
60
+ const moduleValues = [];
61
+
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);
68
+ }
69
+ }
70
+
71
+ if (workerData && workerData.libs && Array.isArray(workerData.libs)) {
72
+ 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
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Handle incoming messages from main thread
87
+ */
88
+ if (parentPort) {
89
+ parentPort.on('message', async (data) => {
90
+ // Ignore messages if terminating
91
+ if (isTerminating) {
92
+ return;
93
+ }
94
+
95
+ const { type, taskId, code, msg } = data;
96
+
97
+ // Handle different message types
98
+ 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
+ }
146
+ });
147
+ }
148
+ } else if (type === 'terminate') {
149
+ // Graceful termination requested
150
+ isTerminating = true;
151
+ parentPort.postMessage({
152
+ type: 'terminated',
153
+ taskId
154
+ });
155
+ process.exit(0);
156
+ }
157
+ });
158
+
159
+ // Handle errors
160
+ process.on('uncaughtException', (err) => {
161
+ if (!isTerminating) {
162
+ parentPort.postMessage({
163
+ type: 'error',
164
+ taskId: null,
165
+ error: {
166
+ message: `Uncaught exception: ${err.message}`,
167
+ stack: err.stack,
168
+ name: err.name
169
+ }
170
+ });
171
+ }
172
+ });
173
+
174
+ process.on('unhandledRejection', (reason, _promise) => {
175
+ if (!isTerminating) {
176
+ parentPort.postMessage({
177
+ type: 'error',
178
+ taskId: null,
179
+ error: {
180
+ message: `Unhandled rejection: ${reason}`,
181
+ stack: reason?.stack || '',
182
+ name: 'UnhandledRejection'
183
+ }
184
+ });
185
+ }
186
+ });
187
+
188
+ // Signal ready
189
+ parentPort.postMessage({
190
+ type: 'ready'
191
+ });
192
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@rosepetal/node-red-contrib-async-function",
3
+ "version": "1.0.0",
4
+ "description": "A Node-RED function node that runs code in worker threads to keep your flows responsive",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/rosepetal-ai/node-red-contrib-async-function.git"
8
+ },
9
+ "keywords": [
10
+ "node-red",
11
+ "function",
12
+ "async",
13
+ "worker-threads",
14
+ "non-blocking",
15
+ "performance",
16
+ "rosepetal"
17
+ ],
18
+ "author": "Rosepetal SL (https://www.rosepetal.ai)",
19
+ "contributors": [
20
+ {
21
+ "name": "Nil Allue",
22
+ "email": "nil.allue@rosepetal.ai"
23
+ }
24
+ ],
25
+ "license": "Apache-2.0",
26
+
27
+ "bugs": {
28
+ "url": "https://github.com/rosepetal-ai/node-red-contrib-async-function/issues"
29
+ },
30
+ "homepage": "https://github.com/rosepetal-ai/node-red-contrib-async-function#readme",
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "node-red": {
35
+ "version": ">=2.0.0",
36
+ "nodes": {
37
+ "async-function": "nodes/async-function.js"
38
+ }
39
+ },
40
+ "dependencies": {}
41
+ }