@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,600 @@
1
+ <!-- Async Function Node - Node-RED Editor Configuration -->
2
+
3
+ <script type="text/javascript">
4
+ RED.nodes.registerType('async-function', {
5
+ category: 'function',
6
+ color: '#d7d9dc',
7
+ defaults: {
8
+ name: { value: '' },
9
+ func: {
10
+ value: '// Write your async code here\n// The code runs in a worker thread\n// Available: msg, return, async/await, require()\n// Not available: context, flow, global, node\n\nreturn msg;'
11
+ },
12
+ outputs: { value: 1, validate: RED.validators.number() },
13
+ timeout: { value: 30000, validate: RED.validators.number() },
14
+ numWorkers: { value: 3, validate: RED.validators.number() },
15
+ maxQueueSize: { value: 100, validate: RED.validators.number() },
16
+ libs: { value: [] },
17
+ noerr: { value: 0 }
18
+ },
19
+ inputs: 1,
20
+ outputs: 1,
21
+ icon: 'function.svg',
22
+ label: function() {
23
+ return this.name || 'async function';
24
+ },
25
+ labelStyle: function() {
26
+ return this.name ? 'node_label_italic' : '';
27
+ },
28
+ oneditprepare: function() {
29
+ const node = this;
30
+
31
+ // Create tabs system
32
+ const tabs = RED.tabs.create({
33
+ id: "async-func-tabs",
34
+ onchange: function(tab) {
35
+ // Hide all tabs
36
+ $("#async-func-tabs-content").children().hide();
37
+ // Show selected tab
38
+ $("#" + tab.id).show();
39
+
40
+ // Handle editor focus for Function tab
41
+ if (tab.id === "async-func-tab-function") {
42
+ const editor = $("#" + tab.id).find('.node-text-editor').first();
43
+ if (editor.length && node.editor) {
44
+ RED.tray.resize();
45
+ node.editor.focus();
46
+ }
47
+ }
48
+ }
49
+ });
50
+
51
+ // Add tabs
52
+ tabs.addTab({
53
+ id: "async-func-tab-setup",
54
+ iconClass: "fa fa-cog",
55
+ label: "Setup"
56
+ });
57
+
58
+ tabs.addTab({
59
+ id: "async-func-tab-function",
60
+ label: "Function"
61
+ });
62
+
63
+ // Activate Function tab by default
64
+ tabs.activateTab("async-func-tab-function");
65
+
66
+ // Build extraLibs for Monaco editor (format: [{var, module}])
67
+ var extraLibs = (this.libs || []).map(function(lib) {
68
+ return { var: lib.var, module: lib.module };
69
+ });
70
+
71
+ // Setup editor with extraLibs for Monaco IntelliSense
72
+ this.editor = RED.editor.createEditor({
73
+ id: 'node-input-func-editor',
74
+ mode: 'ace/mode/nrjavascript',
75
+ value: $('#node-input-func').val(),
76
+ globals: {
77
+ msg: true,
78
+ require: true,
79
+ console: true,
80
+ setTimeout: true,
81
+ clearTimeout: true,
82
+ setInterval: true,
83
+ clearInterval: true,
84
+ Buffer: true,
85
+ process: true
86
+ },
87
+ extraLibs: extraLibs
88
+ });
89
+
90
+ // Refresh Monaco module libs after editor is ready
91
+ var that = this;
92
+ if (extraLibs.length > 0) {
93
+ setTimeout(function() {
94
+ if (that.editor && that.editor.nodered && that.editor.nodered.refreshModuleLibs) {
95
+ that.editor.nodered.refreshModuleLibs(extraLibs);
96
+ }
97
+ }, 200);
98
+ }
99
+
100
+ // Setup outputs spinner
101
+ $('#node-input-outputs').spinner({
102
+ min: 0,
103
+ max: 10
104
+ });
105
+
106
+ // Setup timeout input
107
+ $('#node-input-timeout').spinner({
108
+ min: 1000,
109
+ max: 300000,
110
+ step: 1000
111
+ });
112
+
113
+ // Setup worker pool spinners
114
+ $('#node-input-numWorkers').spinner({ min: 1, max: 16 });
115
+ $('#node-input-maxQueueSize').spinner({ min: 10, max: 1000, step: 10 });
116
+
117
+ // Setup modules editable list
118
+ $('#node-input-libs-container').css('min-height','120px').editableList({
119
+ addButton: true,
120
+ removable: true,
121
+ sortable: false,
122
+ header: $('<div class="async-func-libs-header"><div>Module</div><div>Import as</div></div>'),
123
+ addItem: function(container, i, opt) {
124
+ var row = $('<div class="async-func-libs-entry"/>').appendTo(container);
125
+ $('<input/>', {
126
+ class: 'node-input-libs-module',
127
+ type: 'text',
128
+ placeholder: 'e.g. lodash'
129
+ }).val(opt.module || '').appendTo(row);
130
+ $('<input/>', {
131
+ class: 'node-input-libs-var',
132
+ type: 'text',
133
+ placeholder: 'e.g. _'
134
+ }).val(opt.var || '').appendTo(row);
135
+
136
+ // Auto-generate variable name from module name
137
+ row.find('.node-input-libs-module').on('change keyup', function() {
138
+ var varInput = row.find('.node-input-libs-var');
139
+ if (!varInput.val()) {
140
+ var moduleName = $(this).val().trim();
141
+ // Generate variable name: strip scope, replace dashes/dots with camelCase
142
+ var varName = moduleName
143
+ .replace(/^@[^/]+\//, '') // Remove npm scope
144
+ .replace(/[-_.]/g, ' ')
145
+ .replace(/\s+(.)/g, function(m, c) { return c.toUpperCase(); })
146
+ .replace(/\s/g, '');
147
+ varInput.val(varName);
148
+ varInput.trigger('change');
149
+ }
150
+ }).on('change', function() {
151
+ // Update editor modules when module name changes
152
+ if (typeof updateEditorModules === 'function') {
153
+ updateEditorModules();
154
+ }
155
+ });
156
+
157
+ // Validate variable name is a valid JS identifier and update editor
158
+ row.find('.node-input-libs-var').on('change keyup', function() {
159
+ var val = $(this).val().trim();
160
+ var isValid = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(val);
161
+ var reserved = ['msg', 'require', 'console', 'setTimeout', 'setInterval',
162
+ 'clearTimeout', 'clearInterval', 'Buffer', 'process'];
163
+ if (reserved.indexOf(val) !== -1) {
164
+ isValid = false;
165
+ }
166
+ if (val && !isValid) {
167
+ $(this).addClass('input-error');
168
+ } else {
169
+ $(this).removeClass('input-error');
170
+ }
171
+ }).on('change', function() {
172
+ // Update editor modules when variable name changes
173
+ if (typeof updateEditorModules === 'function') {
174
+ updateEditorModules();
175
+ }
176
+ });
177
+ }
178
+ });
179
+
180
+ // Populate existing libs
181
+ if (this.libs && this.libs.length > 0) {
182
+ var libsList = this.libs;
183
+ for (var i = 0; i < libsList.length; i++) {
184
+ $('#node-input-libs-container').editableList('addItem', libsList[i]);
185
+ }
186
+ }
187
+
188
+ // Get current libs list from editableList
189
+ function getLibsList() {
190
+ var libs = [];
191
+ $('#node-input-libs-container').editableList('items').each(function() {
192
+ var moduleName = $(this).find('.node-input-libs-module').val().trim();
193
+ var varName = $(this).find('.node-input-libs-var').val().trim();
194
+ if (moduleName && varName) {
195
+ libs.push({ var: varName, module: moduleName });
196
+ }
197
+ });
198
+ return libs;
199
+ }
200
+
201
+ // Update editor when modules change (supports both Monaco and ACE)
202
+ function updateEditorModules() {
203
+ if (node.editor) {
204
+ var libs = getLibsList();
205
+
206
+ // For Monaco editor: use refreshModuleLibs
207
+ if (node.editor.nodered && node.editor.nodered.refreshModuleLibs) {
208
+ node.editor.nodered.refreshModuleLibs(libs);
209
+ }
210
+
211
+ // For ACE editor: update globals
212
+ if (node.editor.session && node.editor.session.$worker) {
213
+ var globals = {
214
+ msg: true,
215
+ require: true,
216
+ console: true,
217
+ setTimeout: true,
218
+ clearTimeout: true,
219
+ setInterval: true,
220
+ clearInterval: true,
221
+ Buffer: true,
222
+ process: true
223
+ };
224
+ libs.forEach(function(lib) {
225
+ if (lib.var) {
226
+ globals[lib.var] = true;
227
+ }
228
+ });
229
+ node.editor.session.$worker.send("setOptions", [{
230
+ globals: globals,
231
+ maxerr: 1000
232
+ }]);
233
+ }
234
+ }
235
+ }
236
+
237
+ // Hook into editableList add/remove events and variable name changes
238
+ $('#node-input-libs-container').on('editablelistitemadd editablelistitemremove', updateEditorModules);
239
+ // Ensure configured module vars are registered in the editor on load
240
+ updateEditorModules();
241
+
242
+ // Resize editor on dialog resize
243
+ $('#dialog-form').on('dialogresize', function() {
244
+ const rows = $('#dialog-form>div:not(#async-func-tabs-content)');
245
+ let height = $('#dialog-form').height();
246
+ for (let i = 0; i < rows.length; i++) {
247
+ height -= $(rows[i]).outerHeight(true);
248
+ }
249
+ height -= 20; // Tab container margin adjustment
250
+
251
+ // Calculate editor height (only in Function tab)
252
+ const editorRow = $('#async-func-tab-function .node-text-editor-row');
253
+ const editorMargins = parseInt(editorRow.css('marginTop')) +
254
+ parseInt(editorRow.css('marginBottom'));
255
+
256
+ // Subtract Function tab's non-editor rows
257
+ const functionTabRows = $('#async-func-tab-function>div:not(.node-text-editor-row)');
258
+ let editorHeight = height;
259
+ for (let i = 0; i < functionTabRows.length; i++) {
260
+ editorHeight -= $(functionTabRows[i]).outerHeight(true);
261
+ }
262
+ editorHeight -= editorMargins;
263
+
264
+ $('.node-text-editor').css('height', editorHeight + 'px');
265
+ node.editor.resize();
266
+ });
267
+ },
268
+ oneditsave: function() {
269
+ // Save editor content
270
+ $('#node-input-func').val(this.editor.getValue());
271
+ this.editor.destroy();
272
+ delete this.editor;
273
+
274
+ // Save libs
275
+ var libs = [];
276
+ $('#node-input-libs-container').editableList('items').each(function() {
277
+ var module = $(this).find('.node-input-libs-module').val().trim();
278
+ var varName = $(this).find('.node-input-libs-var').val().trim();
279
+ if (module && varName) {
280
+ libs.push({ module: module, var: varName });
281
+ }
282
+ });
283
+ this.libs = libs;
284
+ },
285
+ oneditcancel: function() {
286
+ this.editor.destroy();
287
+ delete this.editor;
288
+ },
289
+ oneditresize: function(size) {
290
+ // Calculate available height excluding non-tab rows
291
+ const rows = $('#dialog-form>div:not(#async-func-tabs-content)');
292
+ let height = size.height;
293
+
294
+ for (let i = 0; i < rows.length; i++) {
295
+ height -= $(rows[i]).outerHeight(true);
296
+ }
297
+
298
+ height -= 20; // Tab container margin adjustment
299
+
300
+ // Calculate editor height (only in Function tab)
301
+ const editorRow = $('#async-func-tab-function .node-text-editor-row');
302
+ const editorMargins = parseInt(editorRow.css('marginTop')) +
303
+ parseInt(editorRow.css('marginBottom'));
304
+
305
+ // Subtract Function tab's non-editor rows
306
+ const functionTabRows = $('#async-func-tab-function>div:not(.node-text-editor-row)');
307
+ let editorHeight = height;
308
+ for (let i = 0; i < functionTabRows.length; i++) {
309
+ editorHeight -= $(functionTabRows[i]).outerHeight(true);
310
+ }
311
+ editorHeight -= editorMargins;
312
+
313
+ $('.node-text-editor').css('height', editorHeight + 'px');
314
+
315
+ if (this.editor) {
316
+ this.editor.resize();
317
+ }
318
+ }
319
+ });
320
+ </script>
321
+
322
+ <script type="text/html" data-template-name="async-function">
323
+ <style>
324
+ .async-func-tabs-row {
325
+ margin-bottom: 0;
326
+ }
327
+ .async-func-section {
328
+ border: 1px solid #ddd;
329
+ border-radius: 4px;
330
+ padding: 10px 15px;
331
+ margin-bottom: 15px;
332
+ background: #fafafa;
333
+ }
334
+ .async-func-section-header {
335
+ font-weight: 500;
336
+ margin-bottom: 10px;
337
+ color: #333;
338
+ border-bottom: 1px solid #eee;
339
+ padding-bottom: 5px;
340
+ }
341
+ .async-func-section .form-row {
342
+ margin-bottom: 8px;
343
+ }
344
+ .async-func-section .form-row:last-child {
345
+ margin-bottom: 0;
346
+ }
347
+ /* Modules editableList styles */
348
+ #node-input-libs-container {
349
+ min-height: 120px;
350
+ }
351
+ #node-input-libs-container .red-ui-editableList-header,
352
+ .async-func-libs-header {
353
+ display: flex;
354
+ gap: 10px;
355
+ padding: 6px 8px;
356
+ font-weight: 500;
357
+ color: #666;
358
+ background: var(--red-ui-tertiary-background, #f7f7f7);
359
+ border-bottom: 1px solid #ddd;
360
+ padding-right: 35px; /* Space for delete button column */
361
+ }
362
+ #node-input-libs-container .red-ui-editableList-header > div,
363
+ .async-func-libs-header > div {
364
+ flex: 1;
365
+ }
366
+ #node-input-libs-container .red-ui-editableList-container {
367
+ padding: 0;
368
+ }
369
+ #node-input-libs-container .red-ui-editableList-item-content {
370
+ padding: 4px 0;
371
+ }
372
+ .async-func-libs-entry {
373
+ display: flex;
374
+ gap: 10px;
375
+ align-items: center;
376
+ }
377
+ .async-func-libs-entry input {
378
+ flex: 1;
379
+ }
380
+ .async-func-libs-entry input.input-error {
381
+ border-color: #d9534f;
382
+ background-color: #fff5f5;
383
+ }
384
+ </style>
385
+
386
+ <div class="form-row">
387
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
388
+ <input type="text" id="node-input-name" placeholder="Name">
389
+ </div>
390
+
391
+ <!-- Tab Navigation Row -->
392
+ <div class="form-row async-func-tabs-row">
393
+ <ul style="min-width: 600px; margin-bottom: 20px;" id="async-func-tabs"></ul>
394
+ </div>
395
+
396
+ <!-- Tab Content Container -->
397
+ <div id="async-func-tabs-content" style="min-height: calc(100% - 95px);">
398
+
399
+ <!-- Setup Tab -->
400
+ <div id="async-func-tab-setup" style="display:none">
401
+ <!-- Behavior Section -->
402
+ <div class="async-func-section">
403
+ <div class="async-func-section-header">
404
+ <i class="fa fa-sliders"></i> Behavior
405
+ </div>
406
+ <div class="form-row">
407
+ <label for="node-input-outputs"><i class="fa fa-random"></i> Outputs</label>
408
+ <input id="node-input-outputs" style="width:60px;" value="1">
409
+ </div>
410
+ <div class="form-row">
411
+ <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout</label>
412
+ <input id="node-input-timeout" style="width:120px;" value="30000">
413
+ <span style="margin-left:5px;">ms</span>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Worker Pool Section -->
418
+ <div class="async-func-section">
419
+ <div class="async-func-section-header">
420
+ <i class="fa fa-cogs"></i> Worker Pool
421
+ </div>
422
+ <div class="form-row">
423
+ <label for="node-input-numWorkers">Workers</label>
424
+ <input type="number" id="node-input-numWorkers" style="width: 80px;" min="1" max="16" value="3">
425
+ <span style="margin-left: 10px; color: #999;">Fixed worker count (1-16)</span>
426
+ </div>
427
+ <div class="form-row">
428
+ <label for="node-input-maxQueueSize">Queue Size</label>
429
+ <input type="number" id="node-input-maxQueueSize" style="width: 80px;" min="10" max="1000" step="10" value="100">
430
+ <span style="margin-left: 10px; color: #999;">Max queued messages</span>
431
+ </div>
432
+ </div>
433
+
434
+ <!-- Modules Section -->
435
+ <div class="async-func-section">
436
+ <div class="async-func-section-header">
437
+ <i class="fa fa-cubes"></i> Modules
438
+ </div>
439
+ <div class="form-row" style="margin-bottom: 0;">
440
+ <ol id="node-input-libs-container"></ol>
441
+ </div>
442
+ </div>
443
+ </div>
444
+
445
+ <!-- Function Tab -->
446
+ <div id="async-func-tab-function" style="display:none">
447
+ <div class="form-row" style="margin-bottom:0;">
448
+ <label for="node-input-func"><i class="fa fa-wrench"></i> Function</label>
449
+ <input type="hidden" id="node-input-func">
450
+ </div>
451
+
452
+ <div class="form-row node-text-editor-row">
453
+ <div style="height:250px; min-height:150px;" class="node-text-editor" id="node-input-func-editor"></div>
454
+ </div>
455
+
456
+ <div class="form-tips">
457
+ <b>Tips:</b>
458
+ <ul style="margin-top:5px; margin-bottom:0;">
459
+ <li>Code runs in a worker thread to prevent blocking</li>
460
+ <li>Available: <code>msg</code>, <code>return</code>, <code>async/await</code>, <code>require()</code></li>
461
+ <li>Not available: <code>context</code>, <code>flow</code>, <code>global</code>, <code>node</code></li>
462
+ <li>Return <code>msg</code> for single output, or <code>[msg1, msg2]</code> for multiple outputs</li>
463
+ <li>Return <code>null</code> to stop the flow</li>
464
+ </ul>
465
+ </div>
466
+ </div>
467
+
468
+ </div>
469
+ </script>
470
+
471
+ <script type="text/html" data-help-name="async-function">
472
+ <p>Execute custom JavaScript code in a worker thread to prevent event loop blocking.</p>
473
+
474
+ <h3>Inputs</h3>
475
+ <dl class="message-properties">
476
+ <dt>payload <span class="property-type">any</span></dt>
477
+ <dd>The message payload to process</dd>
478
+ <dt class="optional">topic <span class="property-type">string</span></dt>
479
+ <dd>The message topic</dd>
480
+ </dl>
481
+
482
+ <h3>Outputs</h3>
483
+ <dl class="message-properties">
484
+ <dt>payload <span class="property-type">any</span></dt>
485
+ <dd>The processed message payload</dd>
486
+ </dl>
487
+
488
+ <h3>Details</h3>
489
+ <p>This node executes JavaScript code in a worker thread, preventing CPU-intensive operations
490
+ from blocking the Node-RED event loop.</p>
491
+
492
+ <h4>Available in Code</h4>
493
+ <ul>
494
+ <li><code>msg</code> - The incoming message object</li>
495
+ <li><code>return</code> - Return modified message or array of messages</li>
496
+ <li><code>async/await</code> - Use async operations</li>
497
+ <li><code>require()</code> - Import Node.js modules</li>
498
+ <li><code>console</code> - Logging functions</li>
499
+ <li><code>setTimeout</code>, <code>setInterval</code> - Timers</li>
500
+ </ul>
501
+
502
+ <h4>Not Available</h4>
503
+ <ul>
504
+ <li><code>context</code>, <code>flow</code>, <code>global</code> - Context storage (v2.0 feature)</li>
505
+ <li><code>node</code> - Node instance methods</li>
506
+ <li>Non-serializable objects in <code>msg</code></li>
507
+ </ul>
508
+
509
+ <h4>Example: Simple Transformation</h4>
510
+ <pre>msg.payload = msg.payload * 2;
511
+ return msg;</pre>
512
+
513
+ <h4>Example: Async Operation</h4>
514
+ <pre>const crypto = require('crypto');
515
+
516
+ msg.hash = crypto.createHash('sha256')
517
+ .update(msg.payload)
518
+ .digest('hex');
519
+
520
+ return msg;</pre>
521
+
522
+ <h4>Example: Multiple Outputs</h4>
523
+ <pre>if (msg.payload > 100) {
524
+ return [msg, null]; // Send to first output
525
+ } else {
526
+ return [null, msg]; // Send to second output
527
+ }</pre>
528
+
529
+ <h4>Example: CPU-Intensive Task</h4>
530
+ <pre>// Calculate prime numbers (won't block Node-RED!)
531
+ function isPrime(n) {
532
+ if (n <= 1) return false;
533
+ for (let i = 2; i * i <= n; i++) {
534
+ if (n % i === 0) return false;
535
+ }
536
+ return true;
537
+ }
538
+
539
+ const limit = msg.payload;
540
+ const primes = [];
541
+
542
+ for (let i = 2; i <= limit; i++) {
543
+ if (isPrime(i)) {
544
+ primes.push(i);
545
+ }
546
+ }
547
+
548
+ msg.payload = primes;
549
+ return msg;</pre>
550
+
551
+ <h3>Configuration</h3>
552
+ <dl class="message-properties">
553
+ <dt>Outputs</dt>
554
+ <dd>Number of output ports (0-10). Use array return for multiple outputs.</dd>
555
+ <dt>Timeout</dt>
556
+ <dd>Maximum execution time in milliseconds (1000-300000). Worker is terminated if exceeded. Default: 30000 (30s).</dd>
557
+ <dt>Workers</dt>
558
+ <dd>Fixed number of worker threads (1-16). Each node maintains exactly this many workers. Default: 3.</dd>
559
+ <dt>Queue Size</dt>
560
+ <dd>Maximum number of messages queued when all workers are busy (10-1000). Default: 100.</dd>
561
+ <dt>Buffers</dt>
562
+ <dd>All Buffers are transferred via shared memory when possible (falls back to base64 if needed).</dd>
563
+ </dl>
564
+
565
+ <h3>Status Display</h3>
566
+ <p>The node status shows real-time worker pool statistics:</p>
567
+ <ul>
568
+ <li><b>Active: X/Y</b> - X workers currently processing out of Y total workers</li>
569
+ <li><b>Queue: Z</b> - Z messages waiting in queue</li>
570
+ <li><b>SHM: N</b> - N shared memory files active (shown only when >0, indicates large buffers being processed)</li>
571
+ <li><b>Green dot</b> - Normal operation</li>
572
+ <li><b>Yellow dot</b> - Queue getting full (>50 messages)</li>
573
+ <li><b>Red dot</b> - Queue almost full (>90%) or error</li>
574
+ <li><b>Ring</b> - All workers busy with queued messages</li>
575
+ </ul>
576
+
577
+ <h3>Error Handling</h3>
578
+ <p>Errors in your code will be caught and sent to a Catch node if configured:</p>
579
+ <pre>if (!msg.payload) {
580
+ throw new Error('Payload is required');
581
+ }</pre>
582
+
583
+ <h3>Performance Notes</h3>
584
+ <ul>
585
+ <li>Worker threads add ~5-10ms overhead per message</li>
586
+ <li>Best for CPU-intensive operations (>10ms execution time)</li>
587
+ <li>For simple operations, use the standard function node</li>
588
+ <li>Workers are pooled and reused for efficiency</li>
589
+ <li><b>Buffer Transfer:</b> Buffers are streamed through shared memory (base64 fallback)</li>
590
+ <li>Event loop never blocks, even when processing multi-MB binary data (images, files, etc.)</li>
591
+ <li>Message transfer is optimized by copying only referenced <code>msg.*</code> keys when possible</li>
592
+ <li>Timing is recorded on <code>msg.performance</code> under the node label</li>
593
+ </ul>
594
+
595
+ <h3>References</h3>
596
+ <ul>
597
+ <li><a href="https://nodejs.org/api/worker_threads.html" target="_blank">Node.js Worker Threads</a></li>
598
+ <li><a href="https://nodered.org/docs/user-guide/writing-functions" target="_blank">Node-RED Function Nodes</a></li>
599
+ </ul>
600
+ </script>