@rosepetal/node-red-contrib-async-function 1.0.2 → 1.0.3

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.
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  const fs = require('fs').promises;
10
- const fsSync = require('fs');
10
+ const { constants: fsConstants } = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
  const crypto = require('crypto');
@@ -22,7 +22,7 @@ class SharedMemoryManager {
22
22
  */
23
23
  constructor(options = {}) {
24
24
  this.threshold = options.threshold ?? 100 * 1024; // 100KB default
25
- this.shmPath = this.detectShmPath();
25
+ this.shmPath = options.shmPath || null;
26
26
  this.trackAttachments = options.trackAttachments !== false;
27
27
  this.taskAttachments = new Map(); // taskId → Set<filePath>
28
28
  this.globalAttachments = new Set(); // All active files
@@ -32,24 +32,26 @@ class SharedMemoryManager {
32
32
  filesCreated: 0,
33
33
  filesDeleted: 0
34
34
  };
35
+ this.initialized = false;
35
36
 
36
- // Cleanup orphaned files from previous crashes
37
- if (options.cleanupOrphanedFiles !== false) {
38
- this.cleanupOrphanedFiles();
39
- }
37
+ this.ready = this.initialize(options).catch((err) => {
38
+ this.shmPath = os.tmpdir();
39
+ this.initialized = true;
40
+ console.warn(`Failed to initialize shared memory manager: ${err.message}`);
41
+ });
40
42
  }
41
43
 
42
44
  /**
43
45
  * Detect platform-specific shared memory path
44
46
  * @returns {string} Path to shared memory directory
45
47
  */
46
- detectShmPath() {
48
+ async detectShmPath() {
47
49
  // Linux: /dev/shm (RAM-backed tmpfs)
48
50
  const shmPath = '/dev/shm';
49
51
 
50
52
  try {
51
53
  // Check if /dev/shm exists and is writable
52
- fsSync.accessSync(shmPath, fsSync.constants.W_OK);
54
+ await fs.access(shmPath, fsConstants.W_OK);
53
55
  return shmPath;
54
56
  } catch (err) {
55
57
  // Fall back to os.tmpdir() (macOS, Windows, or Linux without /dev/shm)
@@ -57,6 +59,35 @@ class SharedMemoryManager {
57
59
  }
58
60
  }
59
61
 
62
+ /**
63
+ * Initialize shared memory manager
64
+ * @param {object} options - Configuration options
65
+ * @returns {Promise<void>}
66
+ */
67
+ async initialize(options = {}) {
68
+ if (this.initialized) {
69
+ return;
70
+ }
71
+
72
+ if (this.shmPath) {
73
+ try {
74
+ await fs.access(this.shmPath, fsConstants.W_OK);
75
+ } catch (_err) {
76
+ this.shmPath = await this.detectShmPath();
77
+ }
78
+ } else {
79
+ this.shmPath = await this.detectShmPath();
80
+ }
81
+ this.initialized = true;
82
+
83
+ // Cleanup orphaned files from previous crashes (async, non-blocking)
84
+ if (options.cleanupOrphanedFiles !== false) {
85
+ this.cleanupOrphanedFiles().catch((err) => {
86
+ console.warn(`Failed to cleanup orphaned files: ${err.message}`);
87
+ });
88
+ }
89
+ }
90
+
60
91
  /**
61
92
  * Generate unique collision-resistant filename
62
93
  * @param {number|string} taskId - Task identifier
@@ -80,6 +111,7 @@ class SharedMemoryManager {
80
111
  * @returns {Promise<object>} Descriptor object
81
112
  */
82
113
  async writeBuffer(buffer, taskId, bufferIndex) {
114
+ await this.ready;
83
115
  // Check if buffer exceeds threshold
84
116
  if (this.threshold > 0 && buffer.length <= this.threshold) {
85
117
  // Return inline buffer (no shared memory needed)
@@ -127,6 +159,7 @@ class SharedMemoryManager {
127
159
  * @returns {Promise<Buffer>} Buffer contents
128
160
  */
129
161
  async readBuffer(descriptor, options = {}) {
162
+ await this.ready;
130
163
  // Validate descriptor
131
164
  if (!descriptor || typeof descriptor !== 'object') {
132
165
  throw new Error('Invalid descriptor: must be an object');
@@ -234,6 +267,7 @@ class SharedMemoryManager {
234
267
  * @returns {Promise<void>}
235
268
  */
236
269
  async cleanupAll() {
270
+ await this.ready;
237
271
  // Delete all tracked files
238
272
  const deletePromises = [];
239
273
  for (const filePath of this.globalAttachments) {
@@ -259,11 +293,11 @@ class SharedMemoryManager {
259
293
  /**
260
294
  * Cleanup orphaned files from previous crashes
261
295
  */
262
- cleanupOrphanedFiles() {
296
+ async cleanupOrphanedFiles() {
263
297
  const oneHourAgo = Date.now() - (60 * 60 * 1000);
264
298
 
265
299
  try {
266
- const files = fsSync.readdirSync(this.shmPath);
300
+ const files = await fs.readdir(this.shmPath);
267
301
 
268
302
  for (const file of files) {
269
303
  // Skip files that don't match our pattern
@@ -274,11 +308,15 @@ class SharedMemoryManager {
274
308
  const filePath = path.join(this.shmPath, file);
275
309
 
276
310
  try {
277
- const stats = fsSync.statSync(filePath);
311
+ const stats = await fs.stat(filePath);
278
312
 
279
313
  // Only cleanup files older than 1 hour
280
314
  if (stats.mtimeMs < oneHourAgo) {
281
- fsSync.unlinkSync(filePath);
315
+ await fs.unlink(filePath).catch((err) => {
316
+ if (err.code !== 'ENOENT') {
317
+ throw err;
318
+ }
319
+ });
282
320
  console.log(`Cleaned up orphaned file: ${file}`);
283
321
  }
284
322
  } catch (err) {
@@ -17,6 +17,7 @@ const DEFAULT_CONFIG = {
17
17
  taskTimeout: 30000, // Default task timeout: 30s
18
18
  maxQueueSize: 100, // Max queued messages
19
19
  shmThreshold: 0, // Always use shared memory for Buffers
20
+ transferMode: 'transfer', // transfer | shared | copy
20
21
  libs: [], // External modules to load in workers
21
22
  nodeRedUserDir: null, // Node-RED user directory for module resolution
22
23
  workerScript: path.join(__dirname, 'worker-script.js')
@@ -79,6 +80,7 @@ class WorkerPool {
79
80
  const worker = new Worker(this.config.workerScript, {
80
81
  workerData: {
81
82
  shmThreshold: this.config.shmThreshold,
83
+ transferMode: this.config.transferMode,
82
84
  libs: this.config.libs || [],
83
85
  nodeRedUserDir: this.config.nodeRedUserDir
84
86
  }
@@ -262,19 +264,31 @@ class WorkerPool {
262
264
  workerState.state = WorkerState.BUSY;
263
265
  workerState.taskId = taskId;
264
266
 
265
- this.serializer.sanitizeMessage(msg, null, taskId).then(sanitizedMsg => {
267
+ const transferList = this.config.transferMode === 'transfer' ? [] : null;
268
+ const transferSet = transferList ? new Set() : null;
269
+
270
+ this.serializer.sanitizeMessage(msg, null, taskId, {
271
+ transferMode: this.config.transferMode,
272
+ transferList,
273
+ transferSet
274
+ }).then(sanitizedMsg => {
266
275
  // Start timeout after message preparation (matches hot-mode behavior)
267
276
  this.timeoutManager.startTimeout(taskId, timeout, () => {
268
277
  this.handleTimeout(workerState, taskId);
269
278
  });
270
279
 
271
280
  // Send task to worker
272
- workerState.worker.postMessage({
281
+ const payload = {
273
282
  type: 'execute',
274
283
  taskId,
275
284
  code,
276
285
  msg: sanitizedMsg
277
- });
286
+ };
287
+ if (transferList && transferList.length > 0) {
288
+ workerState.worker.postMessage(payload, transferList);
289
+ } else {
290
+ workerState.worker.postMessage(payload);
291
+ }
278
292
  }).catch(err => {
279
293
  // Fail task if message prep fails
280
294
  const callback = this.callbacks.get(taskId);
@@ -292,7 +306,7 @@ class WorkerPool {
292
306
  * @param {object} message - Message from worker
293
307
  */
294
308
  handleWorkerMessage(workerState, message) {
295
- const { type, taskId, result, error, performance } = message;
309
+ const { type, taskId, result, error, performance, contextUpdates, logs } = message || {};
296
310
 
297
311
  if (type === 'result') {
298
312
  // Task completed successfully
@@ -304,8 +318,18 @@ class WorkerPool {
304
318
  // Recycle worker immediately; result restoration happens asynchronously
305
319
  this.recycleWorker(workerState);
306
320
 
307
- this.serializer.restoreBuffers(result).then(restoredResult => {
308
- callback(null, { result: restoredResult, performance: performance || null });
321
+ const restoreResult = this.serializer.restoreBuffers(result);
322
+ const restoreContext = contextUpdates
323
+ ? this.serializer.restoreBuffers(contextUpdates)
324
+ : Promise.resolve(contextUpdates);
325
+
326
+ Promise.all([restoreResult, restoreContext]).then(([restoredResult, restoredContext]) => {
327
+ callback(null, {
328
+ result: restoredResult,
329
+ performance: performance || null,
330
+ contextUpdates: restoredContext || null,
331
+ logs: Array.isArray(logs) ? logs : null
332
+ });
309
333
  }).catch(restoreErr => {
310
334
  callback(restoreErr instanceof Error ? restoreErr : new Error(String(restoreErr)), null);
311
335
  });
@@ -322,10 +346,23 @@ class WorkerPool {
322
346
  const callback = this.callbacks.get(taskId);
323
347
  if (callback) {
324
348
  this.callbacks.delete(taskId);
325
- const err = new Error(error.message);
326
- err.stack = error.stack;
327
- err.name = error.name;
328
- callback(err, null);
349
+ const err = new Error(error && error.message ? error.message : 'Worker error');
350
+ err.stack = error && error.stack ? error.stack : err.stack;
351
+ err.name = error && error.name ? error.name : err.name;
352
+
353
+ if (contextUpdates) {
354
+ this.serializer.restoreBuffers(contextUpdates).then(restoredContext => {
355
+ err.contextUpdates = restoredContext;
356
+ err.logs = Array.isArray(logs) ? logs : null;
357
+ callback(err, null);
358
+ }).catch(() => {
359
+ err.logs = Array.isArray(logs) ? logs : null;
360
+ callback(err, null);
361
+ });
362
+ } else {
363
+ err.logs = Array.isArray(logs) ? logs : null;
364
+ callback(err, null);
365
+ }
329
366
  }
330
367
 
331
368
  // Return worker to idle state
@@ -16,6 +16,9 @@ const { AsyncMessageSerializer } = require('./message-serializer');
16
16
 
17
17
  // Track worker state
18
18
  let isTerminating = false;
19
+ const transferMode = workerData && typeof workerData.transferMode === 'string' ? workerData.transferMode : 'transfer';
20
+ const MSG_WRAPPER_KEY = '__rosepetal_msg';
21
+ const CONTEXT_WRAPPER_KEY = '__rosepetal_context';
19
22
 
20
23
  // AsyncLocalStorage for tracking task context across async boundaries
21
24
  // This ensures unhandled rejections can be attributed to the correct task
@@ -77,6 +80,120 @@ const baseRequire = (() => {
77
80
 
78
81
  global.require = baseRequire;
79
82
 
83
+ function createContextProxy(initialData, updates) {
84
+ const data = (initialData && typeof initialData === 'object') ? initialData : {};
85
+ const updateMap = updates || {};
86
+
87
+ const getValue = (key, fallback) => {
88
+ if (Object.prototype.hasOwnProperty.call(updateMap, key)) {
89
+ return updateMap[key];
90
+ }
91
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
92
+ return data[key];
93
+ }
94
+ return fallback;
95
+ };
96
+
97
+ const setValue = (key, value) => {
98
+ data[key] = value;
99
+ updateMap[key] = value;
100
+ };
101
+
102
+ const api = {
103
+ get: (key, storeOrCb, cbMaybe) => {
104
+ let callback = null;
105
+ if (typeof storeOrCb === 'function') {
106
+ callback = storeOrCb;
107
+ } else if (typeof cbMaybe === 'function') {
108
+ callback = cbMaybe;
109
+ }
110
+
111
+ const value = Array.isArray(key)
112
+ ? key.map((entry) => getValue(entry, undefined))
113
+ : getValue(key, undefined);
114
+
115
+ if (typeof callback === 'function') {
116
+ callback(null, value);
117
+ return undefined;
118
+ }
119
+ return value;
120
+ },
121
+ set: (key, value, cbMaybe) => {
122
+ let callback = typeof cbMaybe === 'function' ? cbMaybe : null;
123
+ let entries = [];
124
+
125
+ if (Array.isArray(key)) {
126
+ if (Array.isArray(value)) {
127
+ entries = key.map((entry, index) => [entry, value[index]]);
128
+ } else if (value && typeof value === 'object') {
129
+ entries = key.map((entry) => [entry, value[entry]]);
130
+ }
131
+ } else if (key && typeof key === 'object' && value !== null) {
132
+ entries = Object.entries(key);
133
+ if (typeof value === 'function') {
134
+ callback = value;
135
+ }
136
+ } else {
137
+ entries = [[key, value]];
138
+ }
139
+
140
+ entries.forEach(([entryKey, entryValue]) => {
141
+ if (entryKey !== undefined) {
142
+ setValue(entryKey, entryValue);
143
+ }
144
+ });
145
+
146
+ if (typeof callback === 'function') {
147
+ callback(null);
148
+ }
149
+ return undefined;
150
+ }
151
+ };
152
+
153
+ return new Proxy(api, {
154
+ get(target, prop) {
155
+ if (typeof prop === 'symbol' || prop in target) {
156
+ return target[prop];
157
+ }
158
+ return getValue(prop, undefined);
159
+ },
160
+ set(target, prop, value) {
161
+ if (typeof prop === 'symbol' || prop in target) {
162
+ target[prop] = value;
163
+ return true;
164
+ }
165
+ setValue(prop, value);
166
+ return true;
167
+ }
168
+ });
169
+ }
170
+
171
+ function createNodeProxy(logs) {
172
+ const push = (level, args) => {
173
+ if (!Array.isArray(logs)) {
174
+ return;
175
+ }
176
+ if (!args || args.length === 0) {
177
+ logs.push({ level, message: '' });
178
+ return;
179
+ }
180
+ const message = args.length === 1 ? args[0] : args.map((arg) => arg);
181
+ logs.push({ level, message });
182
+ };
183
+
184
+ return {
185
+ warn: (...args) => {
186
+ push('warn', args);
187
+ },
188
+ error: (...args) => {
189
+ push('error', args);
190
+ },
191
+ log: (...args) => {
192
+ push('log', args);
193
+ }
194
+ };
195
+ }
196
+
80
197
  function parseModuleSpec(spec) {
81
198
  const match = /((?:@[^/]+\/)?[^/@]+)(\/[^/@]+)?(?:@([\s\S]+))?/.exec(spec);
82
199
  if (!match) {
@@ -146,54 +263,133 @@ async function initializeWorker() {
146
263
  // This ensures unhandled rejections from fire-and-forget promises
147
264
  // can be traced back to the correct task
148
265
  taskContext.run({ taskId }, async () => {
266
+ const contextUpdates = { flow: {}, global: {}, context: {} };
267
+ const logs = [];
268
+
149
269
  try {
150
270
  // Restore + execute user code
151
271
  const restoreStart = process.hrtime.bigint();
152
- const restoredMsg = await serializer.restoreBuffers(msg);
272
+ const restoredPayload = await serializer.restoreBuffers(msg);
153
273
  const transferToWorkerMs = hrtimeDiffToMs(restoreStart);
154
274
 
275
+ let contextPayload = {};
276
+ let restoredMsg = restoredPayload;
277
+ if (restoredPayload && typeof restoredPayload === 'object' && Object.prototype.hasOwnProperty.call(restoredPayload, MSG_WRAPPER_KEY)) {
278
+ contextPayload = restoredPayload[CONTEXT_WRAPPER_KEY] || {};
279
+ restoredMsg = restoredPayload[MSG_WRAPPER_KEY];
280
+ }
281
+
282
+ const nodeProxy = createNodeProxy(logs);
283
+ const flowProxy = createContextProxy(contextPayload.flow, contextUpdates.flow);
284
+ const globalProxy = createContextProxy(contextPayload.global, contextUpdates.global);
285
+ const contextProxy = createContextProxy(contextPayload.context, contextUpdates.context);
286
+
155
287
  // Cache key includes code + module vars to handle different module configs
156
288
  const cacheKey = code + '|' + moduleVars.join(',');
157
289
  let userFunction = getCachedFunction(cacheKey);
158
290
  if (!userFunction) {
159
291
  // Create function with msg + all module variables as parameters
160
- userFunction = new AsyncFunction('msg', ...moduleVars, code);
292
+ userFunction = new AsyncFunction('msg', 'node', 'flow', 'global', 'context', ...moduleVars, code);
161
293
  setCachedFunction(cacheKey, userFunction);
162
294
  }
163
295
 
164
296
  const execStart = process.hrtime.bigint();
165
297
  // Execute with msg and all loaded module values
166
- const rawResult = await userFunction(restoredMsg, ...moduleValues);
298
+ const rawResult = await userFunction(restoredMsg, nodeProxy, flowProxy, globalProxy, contextProxy, ...moduleValues);
167
299
  const executionMs = hrtimeDiffToMs(execStart);
168
300
 
169
- // Offload buffers in the result (large Buffers -> shared memory descriptors)
301
+ // Offload buffers in result + context updates
170
302
  const encodeStart = process.hrtime.bigint();
171
- const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId);
303
+ const transferList = transferMode === 'transfer' ? [] : null;
304
+ const transferSet = transferList ? new Set() : null;
305
+ const bufferCache = new WeakMap();
306
+
307
+ const encodedResult = await serializer.sanitizeMessage(rawResult, null, taskId, {
308
+ transferMode,
309
+ transferList,
310
+ transferSet,
311
+ bufferCache
312
+ });
313
+
314
+ const hasUpdates = (
315
+ Object.keys(contextUpdates.flow).length > 0 ||
316
+ Object.keys(contextUpdates.global).length > 0 ||
317
+ Object.keys(contextUpdates.context).length > 0
318
+ );
319
+
320
+ const encodedContextUpdates = hasUpdates
321
+ ? await serializer.sanitizeMessage(contextUpdates, null, taskId, {
322
+ transferMode,
323
+ transferList,
324
+ transferSet,
325
+ bufferCache
326
+ })
327
+ : null;
328
+
172
329
  const transferToMainMs = hrtimeDiffToMs(encodeStart);
173
330
 
174
331
  // Send result back to main thread
175
- parentPort.postMessage({
332
+ const payload = {
176
333
  type: 'result',
177
334
  taskId,
178
335
  result: encodedResult,
336
+ contextUpdates: encodedContextUpdates,
337
+ logs: logs.length > 0 ? logs : null,
179
338
  performance: {
180
339
  transferToWorkerMs,
181
340
  executionMs,
182
341
  transferToMainMs
183
342
  }
184
- });
343
+ };
344
+
345
+ if (transferList && transferList.length > 0) {
346
+ parentPort.postMessage(payload, transferList);
347
+ } else {
348
+ parentPort.postMessage(payload);
349
+ }
185
350
 
186
351
  } catch (err) {
187
352
  // Send error back to main thread
188
- parentPort.postMessage({
353
+ const transferList = transferMode === 'transfer' ? [] : null;
354
+ const transferSet = transferList ? new Set() : null;
355
+ const bufferCache = new WeakMap();
356
+ const hasUpdates = (
357
+ Object.keys(contextUpdates.flow).length > 0 ||
358
+ Object.keys(contextUpdates.global).length > 0 ||
359
+ Object.keys(contextUpdates.context).length > 0
360
+ );
361
+
362
+ let encodedContextUpdates = null;
363
+ if (hasUpdates) {
364
+ try {
365
+ encodedContextUpdates = await serializer.sanitizeMessage(contextUpdates, null, taskId, {
366
+ transferMode,
367
+ transferList,
368
+ transferSet,
369
+ bufferCache
370
+ });
371
+ } catch (_encodeErr) {
372
+ encodedContextUpdates = null;
373
+ }
374
+ }
375
+
376
+ const payload = {
189
377
  type: 'error',
190
378
  taskId,
191
379
  error: {
192
380
  message: err.message,
193
381
  stack: err.stack,
194
382
  name: err.name
195
- }
196
- });
383
+ },
384
+ contextUpdates: encodedContextUpdates,
385
+ logs: logs.length > 0 ? logs : null
386
+ };
387
+
388
+ if (transferList && transferList.length > 0) {
389
+ parentPort.postMessage(payload, transferList);
390
+ } else {
391
+ parentPort.postMessage(payload);
392
+ }
197
393
  }
198
394
  });
199
395
  } else if (type === 'terminate') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rosepetal/node-red-contrib-async-function",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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",