@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,407 @@
1
+ /**
2
+ * Async Message Serializer
3
+ *
4
+ * Fast message cloning for worker thread communication.
5
+ * Matches the shared-memory + msg-copy semantics used by the python executor "hot mode":
6
+ * - Buffers (and typed arrays) can be offloaded to shared memory with descriptors
7
+ * - Base64 fallback for shared-memory failures
8
+ * - Circular references preserved via WeakMap
9
+ */
10
+
11
+ const SHARED_SENTINEL_KEY = '__rosepetal_shm_path__';
12
+ const SHARED_BASE64_KEY = '__rosepetal_base64__';
13
+
14
+ /**
15
+ * Async Message Serializer Class
16
+ * Handles message cloning with shared memory offloading for large buffers
17
+ */
18
+ class AsyncMessageSerializer {
19
+ /**
20
+ * Create an async message serializer
21
+ * @param {SharedMemoryManager} sharedMemoryManager - Shared memory manager instance
22
+ * @param {object} options - Configuration options
23
+ */
24
+ constructor(sharedMemoryManager, options = {}) {
25
+ this.shmManager = sharedMemoryManager;
26
+ this.maxDepth = options.maxDepth || 0;
27
+ }
28
+
29
+ /**
30
+ * Main entry point - sanitize message for worker thread communication
31
+ * @param {object} msg - Message object to sanitize
32
+ * @param {object} node - Node-RED node instance (for warnings)
33
+ * @param {number|string} taskId - Task identifier for shared memory tracking
34
+ * @returns {Promise<object>} Sanitized message object
35
+ */
36
+ async sanitizeMessage(msg, node, taskId) {
37
+ if (!msg || typeof msg !== 'object') {
38
+ return msg;
39
+ }
40
+
41
+ const seen = new WeakMap();
42
+ const bufferIndex = { value: 0 }; // Mutable counter for buffer indexing
43
+
44
+ try {
45
+ return await this.cloneValue(msg, seen, bufferIndex, taskId, 0);
46
+ } catch (err) {
47
+ if (node) {
48
+ node.warn(`Message cloning failed: ${err.message}, creating minimal message`);
49
+ }
50
+ return this.createMinimalMessage(msg);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Recursively clone a value with async operation and shared memory offloading
56
+ * @param {*} value - Value to clone
57
+ * @param {WeakMap} seen - Circular reference tracker
58
+ * @param {number} depth - Current recursion depth
59
+ * @param {object} bufferIndex - Mutable buffer index counter
60
+ * @param {number|string} taskId - Task identifier
61
+ * @param {object} node - Node-RED node instance
62
+ * @param {string} path - Current property path (for warnings)
63
+ * @returns {Promise<*>} Cloned value
64
+ */
65
+ async cloneValue(value, seen, bufferIndex, taskId, depth) {
66
+ // Optional depth guard (disabled by default for speed)
67
+ if (this.maxDepth > 0 && depth > this.maxDepth) {
68
+ return null;
69
+ }
70
+
71
+ if (value === null || value === undefined) {
72
+ return value;
73
+ }
74
+
75
+ // Handle primitives
76
+ const type = typeof value;
77
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint') {
78
+ return value;
79
+ }
80
+
81
+ // Drop non-cloneable types
82
+ if (type === 'function' || type === 'symbol') {
83
+ return undefined;
84
+ }
85
+
86
+ // Buffers + typed arrays: offload to shared memory when above threshold
87
+ if (Buffer.isBuffer(value)) {
88
+ return await this.shmManager.writeBuffer(value, taskId, bufferIndex.value++);
89
+ }
90
+
91
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
92
+ const asBuffer = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
93
+ return await this.shmManager.writeBuffer(asBuffer, taskId, bufferIndex.value++);
94
+ }
95
+
96
+ if (type === 'object') {
97
+ // Preserve circular references
98
+ if (seen.has(value)) {
99
+ return seen.get(value);
100
+ }
101
+
102
+ const clone = Array.isArray(value) ? [] : {};
103
+ seen.set(value, clone);
104
+
105
+ if (Array.isArray(value)) {
106
+ for (let i = 0; i < value.length; i++) {
107
+ const clonedItem = await this.cloneValue(value[i], seen, bufferIndex, taskId, depth + 1);
108
+ clone[i] = clonedItem === undefined ? null : clonedItem;
109
+ }
110
+ return clone;
111
+ }
112
+
113
+ const keys = Object.keys(value);
114
+ for (let i = 0; i < keys.length; i++) {
115
+ const key = keys[i];
116
+ const clonedVal = await this.cloneValue(value[key], seen, bufferIndex, taskId, depth + 1);
117
+ if (clonedVal !== undefined) {
118
+ clone[key] = clonedVal;
119
+ }
120
+ }
121
+
122
+ return clone;
123
+ }
124
+
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * Restore buffers from shared memory descriptors (worker-side)
130
+ * @param {*} value - Value to restore (may contain descriptors)
131
+ * @returns {Promise<*>} Value with buffers restored
132
+ */
133
+ async restoreBuffers(value) {
134
+ const seen = new WeakMap();
135
+ return this.restoreValue(value, seen);
136
+ }
137
+
138
+ async restoreValue(value, seen) {
139
+ // Handle null/undefined
140
+ if (value === null || value === undefined) {
141
+ return value;
142
+ }
143
+
144
+ // Handle primitives
145
+ const type = typeof value;
146
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint') {
147
+ return value;
148
+ }
149
+
150
+ // Handle buffers (already restored)
151
+ if (Buffer.isBuffer(value)) {
152
+ return value;
153
+ }
154
+
155
+ // Handle arrays
156
+ if (Array.isArray(value)) {
157
+ if (seen.has(value)) {
158
+ return seen.get(value);
159
+ }
160
+
161
+ const result = new Array(value.length);
162
+ seen.set(value, result);
163
+
164
+ for (let i = 0; i < value.length; i++) {
165
+ result[i] = await this.restoreValue(value[i], seen);
166
+ }
167
+
168
+ return result;
169
+ }
170
+
171
+ // Handle objects
172
+ if (type === 'object') {
173
+ // Shared memory descriptor
174
+ if (Object.prototype.hasOwnProperty.call(value, SHARED_SENTINEL_KEY)) {
175
+ try {
176
+ return await this.shmManager.readBuffer(value, { deleteAfterRead: true });
177
+ } catch (_err) {
178
+ return Buffer.alloc(0);
179
+ }
180
+ }
181
+
182
+ // Base64 fallback
183
+ if (Object.prototype.hasOwnProperty.call(value, SHARED_BASE64_KEY)) {
184
+ try {
185
+ return Buffer.from(value[SHARED_BASE64_KEY] || '', 'base64');
186
+ } catch (_err) {
187
+ return Buffer.alloc(0);
188
+ }
189
+ }
190
+
191
+ if (seen.has(value)) {
192
+ return seen.get(value);
193
+ }
194
+
195
+ const result = {};
196
+ seen.set(value, result);
197
+
198
+ const entries = Object.entries(value);
199
+ for (let i = 0; i < entries.length; i++) {
200
+ const [key, val] = entries[i];
201
+ result[key] = await this.restoreValue(val, seen);
202
+ }
203
+
204
+ return result;
205
+ }
206
+
207
+ return value;
208
+ }
209
+
210
+ /**
211
+ * Create a minimal message for error cases
212
+ * Preserves only essential properties
213
+ * @param {object} msg - Original message
214
+ * @returns {object} Minimal message
215
+ */
216
+ createMinimalMessage(msg) {
217
+ return {
218
+ _msgid: msg._msgid || '',
219
+ topic: msg.topic || '',
220
+ payload: msg.payload !== undefined ? msg.payload : null
221
+ };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Legacy synchronous functions for backward compatibility
227
+ * (used in tests or cases where SharedMemoryManager is not available)
228
+ */
229
+
230
+ /**
231
+ * Check if a value is serializable for worker thread communication
232
+ * @param {*} value - Value to check
233
+ * @returns {boolean} True if serializable
234
+ */
235
+ function isSerializable(value) {
236
+ if (value === null || value === undefined) {
237
+ return true;
238
+ }
239
+
240
+ const type = typeof value;
241
+
242
+ // Primitive types are serializable
243
+ if (type === 'string' || type === 'number' || type === 'boolean') {
244
+ return true;
245
+ }
246
+
247
+ // Functions are not serializable
248
+ if (type === 'function') {
249
+ return false;
250
+ }
251
+
252
+ // Symbols are not serializable
253
+ if (type === 'symbol') {
254
+ return false;
255
+ }
256
+
257
+ // Try to clone the value
258
+ try {
259
+ // Use structuredClone if available (Node.js 17+)
260
+ if (typeof structuredClone !== 'undefined') {
261
+ structuredClone(value);
262
+ return true;
263
+ }
264
+
265
+ // Fallback: try JSON serialization
266
+ JSON.stringify(value);
267
+ return true;
268
+ } catch (err) {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Legacy synchronous sanitize function (for backward compatibility)
275
+ * NOTE: This is kept for tests but should not be used in production
276
+ * Use AsyncMessageSerializer instead
277
+ *
278
+ * @param {object} msg - Message object to sanitize
279
+ * @param {object} node - Node-RED node instance (for warnings)
280
+ * @returns {object} Sanitized message object
281
+ */
282
+ function sanitizeMessage(msg, node) {
283
+ if (!msg || typeof msg !== 'object') {
284
+ return msg;
285
+ }
286
+
287
+ try {
288
+ // Try structuredClone first (Node.js 17+)
289
+ if (typeof structuredClone !== 'undefined') {
290
+ return structuredClone(msg);
291
+ }
292
+ } catch (err) {
293
+ // If structuredClone fails, fall through to manual filtering
294
+ if (node) {
295
+ node.warn('Message contains non-serializable data, filtering...');
296
+ }
297
+ }
298
+
299
+ // Manual filtering for older Node.js versions or complex cases
300
+ const cloned = {};
301
+ const seen = new WeakSet();
302
+
303
+ function cloneValue(value, path) {
304
+ // Handle null/undefined
305
+ if (value === null || value === undefined) {
306
+ return value;
307
+ }
308
+
309
+ // Handle primitives
310
+ const type = typeof value;
311
+ if (type === 'string' || type === 'number' || type === 'boolean') {
312
+ return value;
313
+ }
314
+
315
+ // Skip functions
316
+ if (type === 'function') {
317
+ if (node) {
318
+ node.warn(`Property '${path}' is a function and will be omitted`);
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ // Skip symbols
324
+ if (type === 'symbol') {
325
+ if (node) {
326
+ node.warn(`Property '${path}' is a symbol and will be omitted`);
327
+ }
328
+ return undefined;
329
+ }
330
+
331
+ // Handle dates
332
+ if (value instanceof Date) {
333
+ return new Date(value);
334
+ }
335
+
336
+ // Handle buffers
337
+ if (Buffer.isBuffer(value)) {
338
+ return Buffer.from(value);
339
+ }
340
+
341
+ // Handle arrays
342
+ if (Array.isArray(value)) {
343
+ return value.map((item, index) => cloneValue(item, `${path}[${index}]`));
344
+ }
345
+
346
+ // Handle objects
347
+ if (type === 'object') {
348
+ // Check for circular reference
349
+ if (seen.has(value)) {
350
+ if (node) {
351
+ node.warn(`Circular reference detected at '${path}'`);
352
+ }
353
+ return '[Circular]';
354
+ }
355
+
356
+ seen.add(value);
357
+
358
+ const result = {};
359
+ for (const [key, val] of Object.entries(value)) {
360
+ const clonedVal = cloneValue(val, path ? `${path}.${key}` : key);
361
+ if (clonedVal !== undefined) {
362
+ result[key] = clonedVal;
363
+ }
364
+ }
365
+
366
+ return result;
367
+ }
368
+
369
+ // Unknown type
370
+ if (node) {
371
+ node.warn(`Property '${path}' has unknown type '${type}' and will be omitted`);
372
+ }
373
+ return undefined;
374
+ }
375
+
376
+ // Clone each property
377
+ for (const [key, value] of Object.entries(msg)) {
378
+ const clonedValue = cloneValue(value, key);
379
+ if (clonedValue !== undefined) {
380
+ cloned[key] = clonedValue;
381
+ }
382
+ }
383
+
384
+ return cloned;
385
+ }
386
+
387
+ /**
388
+ * Create a minimal message for error cases
389
+ * Preserves only essential properties
390
+ *
391
+ * @param {object} msg - Original message
392
+ * @returns {object} Minimal message
393
+ */
394
+ function createMinimalMessage(msg) {
395
+ return {
396
+ _msgid: msg._msgid || '',
397
+ topic: msg.topic || '',
398
+ payload: msg.payload !== undefined ? msg.payload : null
399
+ };
400
+ }
401
+
402
+ module.exports = {
403
+ AsyncMessageSerializer,
404
+ isSerializable,
405
+ sanitizeMessage,
406
+ createMinimalMessage
407
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Module Installer
3
+ *
4
+ * Handles automatic installation of npm modules for the async-function node.
5
+ * Modules are installed in the Node-RED user directory (~/.node-red).
6
+ */
7
+
8
+ const { spawnSync } = require('child_process');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const fs = require('fs');
12
+
13
+ /**
14
+ * Get the Node-RED user directory
15
+ * @returns {string} Path to Node-RED user directory
16
+ */
17
+ function getNodeRedUserDir() {
18
+ // Check for explicit NODE_RED_HOME environment variable
19
+ if (process.env.NODE_RED_HOME) {
20
+ return process.env.NODE_RED_HOME;
21
+ }
22
+
23
+ // Default to ~/.node-red
24
+ const defaultDir = path.join(os.homedir(), '.node-red');
25
+
26
+ // Verify it exists
27
+ if (fs.existsSync(defaultDir)) {
28
+ return defaultDir;
29
+ }
30
+
31
+ // Fallback to home directory if .node-red doesn't exist
32
+ return os.homedir();
33
+ }
34
+
35
+ /**
36
+ * Install an npm module in the Node-RED user directory
37
+ * @param {string} moduleName - Name of the module to install
38
+ * @returns {boolean} True if installation succeeded
39
+ */
40
+ function installModule(moduleName) {
41
+ if (!moduleName || typeof moduleName !== 'string') {
42
+ console.error('[async-function] Invalid module name');
43
+ return false;
44
+ }
45
+
46
+ // Sanitize module name to prevent command injection
47
+ const sanitizedName = moduleName.trim();
48
+ if (!/^(@[\w-]+\/)?[\w.-]+(@[\w.-]+)?$/.test(sanitizedName)) {
49
+ console.error(`[async-function] Invalid module name format: ${sanitizedName}`);
50
+ return false;
51
+ }
52
+
53
+ const userDir = getNodeRedUserDir();
54
+
55
+ try {
56
+ console.log(`[async-function] Installing module: ${sanitizedName} in ${userDir}`);
57
+
58
+ // Use spawnSync with array arguments to prevent command injection
59
+ const result = spawnSync('npm', ['install', sanitizedName], {
60
+ cwd: userDir,
61
+ stdio: 'pipe',
62
+ timeout: 120000, // 2 minute timeout
63
+ env: {
64
+ ...process.env,
65
+ npm_config_loglevel: 'error'
66
+ }
67
+ });
68
+
69
+ if (result.error) {
70
+ throw result.error;
71
+ }
72
+
73
+ if (result.status !== 0) {
74
+ const stderr = result.stderr ? result.stderr.toString() : 'Unknown error';
75
+ throw new Error(`npm install failed with code ${result.status}: ${stderr}`);
76
+ }
77
+
78
+ console.log(`[async-function] Successfully installed: ${sanitizedName}`);
79
+ return true;
80
+
81
+ } catch (err) {
82
+ console.error(`[async-function] Failed to install ${sanitizedName}: ${err.message}`);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Check if a module is available
89
+ * @param {string} moduleName - Name of the module to check
90
+ * @returns {boolean} True if module can be resolved
91
+ */
92
+ function isModuleAvailable(moduleName) {
93
+ try {
94
+ require.resolve(moduleName);
95
+ return true;
96
+ } catch (err) {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ module.exports = {
102
+ installModule,
103
+ isModuleAvailable,
104
+ getNodeRedUserDir
105
+ };