@inteli.city/node-red-contrib-exec-collection 2.0.1 → 2.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.
- package/README.md +51 -0
- package/exec.queue.html +7 -3
- package/node.queue.html +85 -7
- package/node.queue.js +230 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -595,6 +595,57 @@ Use `console.log()` to produce output.
|
|
|
595
595
|
|
|
596
596
|
---
|
|
597
597
|
|
|
598
|
+
### Execution Modes
|
|
599
|
+
|
|
600
|
+
#### Synchronous (default)
|
|
601
|
+
|
|
602
|
+
Code runs in a plain function and completes immediately. No async operations are allowed.
|
|
603
|
+
|
|
604
|
+
- Safe and deterministic
|
|
605
|
+
- Using `await` or returning a Promise causes an explicit error
|
|
606
|
+
- Use this mode for pure computation, state manipulation, and synchronous I/O
|
|
607
|
+
|
|
608
|
+
#### Asynchronous (Promise-based)
|
|
609
|
+
|
|
610
|
+
Select **Asynchronous (Promise-based)** from the Execution dropdown.
|
|
611
|
+
|
|
612
|
+
The node waits for async work to complete before processing the next message. Your code must ensure all async work completes before execution ends.
|
|
613
|
+
|
|
614
|
+
- Supports HTTP requests, database calls, file I/O, and any Promise-based API
|
|
615
|
+
- Use `return <Promise>` or `await` all async calls before execution ends
|
|
616
|
+
|
|
617
|
+
#### Async Usage Guidelines
|
|
618
|
+
|
|
619
|
+
Always ensure async work completes before execution ends. Prefer:
|
|
620
|
+
|
|
621
|
+
- `return Promise` — the node waits for the returned Promise to resolve
|
|
622
|
+
- `await` all async calls — the execution boundary is the end of the async function
|
|
623
|
+
|
|
624
|
+
✔ Correct — Promise returned:
|
|
625
|
+
|
|
626
|
+
```js
|
|
627
|
+
const axios = require('axios');
|
|
628
|
+
return axios.get(url).then(r => r.data);
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
✔ Correct — await:
|
|
632
|
+
|
|
633
|
+
```js
|
|
634
|
+
const axios = require('axios');
|
|
635
|
+
const r = await axios.get(url);
|
|
636
|
+
console.log(r.data);
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
✘ Incorrect — async work is not awaited:
|
|
640
|
+
|
|
641
|
+
```js
|
|
642
|
+
axios.get(url).then(r => console.log(r.data));
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
**If async work is not completed before execution ends, the message may be lost or execution may fail.**
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
598
649
|
### Worker Lifecycle
|
|
599
650
|
|
|
600
651
|
- Workers start lazily on first message
|
package/exec.queue.html
CHANGED
|
@@ -250,7 +250,7 @@ valgrind bash $file
|
|
|
250
250
|
field: {value:"payload", validate:RED.validators.typedInput("fieldType")},
|
|
251
251
|
fieldType: {value:"msg"},
|
|
252
252
|
format: {value:"javascript"},
|
|
253
|
-
template: {value:`//
|
|
253
|
+
template: {value:`// Available variables: payload, topic, and all msg properties.\n// WARNING: Do not put Nunjucks expressions inside JS comments.\n\nconsole.log(\`\n{\n\t"value": "exec.queue",\n\t"purpose":"putting the ideas from the exec, template and queue nodes together"\n}\n\`)`},
|
|
254
254
|
output: {value:"str"},
|
|
255
255
|
outputEmpty: {value:false},
|
|
256
256
|
/* vimMode: {value:false}, */
|
|
@@ -319,9 +319,11 @@ valgrind bash $file
|
|
|
319
319
|
$("#node-input-cmdTemplate").prop("checked",false);
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
var initFormat = this.format || "javascript";
|
|
323
|
+
if (initFormat === "nunjucks") initFormat = "twig";
|
|
322
324
|
this.editor = RED.editor.createEditor({
|
|
323
325
|
id: 'node-input-template-editor',
|
|
324
|
-
mode: 'ace/mode/
|
|
326
|
+
mode: 'ace/mode/' + initFormat,
|
|
325
327
|
value: $("#node-input-template").val()
|
|
326
328
|
});
|
|
327
329
|
|
|
@@ -335,7 +337,9 @@ valgrind bash $file
|
|
|
335
337
|
this.editor.focus();
|
|
336
338
|
|
|
337
339
|
$("#node-input-format").on("change", function() {
|
|
338
|
-
var
|
|
340
|
+
var format = $("#node-input-format").val();
|
|
341
|
+
if (format === "nunjucks") format = "twig";
|
|
342
|
+
var mod = "ace/mode/" + format;
|
|
339
343
|
that.editor.getSession().setMode({
|
|
340
344
|
path: mod,
|
|
341
345
|
v: Date.now()
|
package/node.queue.html
CHANGED
|
@@ -4,7 +4,55 @@
|
|
|
4
4
|
|
|
5
5
|
Sends code to a pool of persistent Node.js workers. Each message renders the Nunjucks template into JavaScript, dispatches it to a free worker, and returns whatever `console.log()` prints as `msg.payload`.
|
|
6
6
|
|
|
7
|
-
**Mental model:**
|
|
7
|
+
**Mental model:** one message → one execution → one result. You are not running a script — you are sending code to a running Node.js engine. State persists between messages on the same worker.
|
|
8
|
+
|
|
9
|
+
**Execution is mandatory:** every execution must call `console.log()` at least once. Zero-output executions are treated as errors.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Execution Modes
|
|
14
|
+
|
|
15
|
+
### Synchronous (default)
|
|
16
|
+
|
|
17
|
+
Code runs in a plain function and completes immediately. No async operations are allowed.
|
|
18
|
+
|
|
19
|
+
- Using `await` causes an error: *"Asynchronous operations are not allowed in synchronous mode."*
|
|
20
|
+
- Returning a Promise causes the same error.
|
|
21
|
+
- Execution is safe and deterministic.
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
const value = JSON.parse("{{ payload }}");
|
|
25
|
+
console.log(value + 1);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Asynchronous (Promise-based)
|
|
29
|
+
|
|
30
|
+
Select **Asynchronous (Promise-based)** from the Execution dropdown.
|
|
31
|
+
|
|
32
|
+
The node waits for async work to complete before processing the next message. Your code must ensure all async work completes before execution ends — either by returning a Promise or by using `await`.
|
|
33
|
+
|
|
34
|
+
✔ Correct — Promise returned:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
const axios = require('axios');
|
|
38
|
+
return axios.get(url).then(r => r.data);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
✔ Correct — await:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const axios = require('axios');
|
|
45
|
+
const r = await axios.get(url);
|
|
46
|
+
console.log(r.data);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
✘ Incorrect — async work is not awaited:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
axios.get(url).then(r => console.log(r.data));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**If async work is not completed before execution ends, the message may be lost or execution may fail.**
|
|
8
56
|
|
|
9
57
|
---
|
|
10
58
|
|
|
@@ -42,7 +90,7 @@ Numbers can be injected directly:
|
|
|
42
90
|
const x = {{ payload }}; // safe when payload is a number
|
|
43
91
|
```
|
|
44
92
|
|
|
45
|
-
**Warning:** Nunjucks evaluates `{{ }}` everywhere — including inside `//` and `/* */` comments. Do not put Nunjucks expressions inside comments.
|
|
93
|
+
**Warning:** Nunjucks evaluates `{{ }}` everywhere — including inside `//` and `/* */` comments. Do not put Nunjucks expressions inside comments.
|
|
46
94
|
|
|
47
95
|
---
|
|
48
96
|
|
|
@@ -57,6 +105,8 @@ console.log("world"); // → one message: "world"
|
|
|
57
105
|
|
|
58
106
|
`console.warn()` and `console.error()` go to stderr and become node warnings — they do not affect `msg.payload`.
|
|
59
107
|
|
|
108
|
+
**Output is required:** if your code does not call `console.log()`, the execution fails.
|
|
109
|
+
|
|
60
110
|
The bottom row controls how output is handled:
|
|
61
111
|
|
|
62
112
|
**Output format** — how each segment is parsed (plain text, JSON, YAML, XML).
|
|
@@ -145,6 +195,17 @@ console.log(JSON.stringify({ platform: os.platform(), home: os.homedir() }));
|
|
|
145
195
|
<span>Queue</span>
|
|
146
196
|
</label>
|
|
147
197
|
<input style="width:60px" type="number" id="node-input-queue" value="1">
|
|
198
|
+
<label for="node-input-executionMode" style="width:auto; margin-left:14px;">
|
|
199
|
+
<i class="fa fa-bolt"></i>
|
|
200
|
+
<span>Execution</span>
|
|
201
|
+
</label>
|
|
202
|
+
<select id="node-input-executionMode" style="width:240px; margin-left:4px;">
|
|
203
|
+
<option value="sync">Synchronous</option>
|
|
204
|
+
<option value="async">Asynchronous (Promise-based)</option>
|
|
205
|
+
</select>
|
|
206
|
+
</div>
|
|
207
|
+
<div id="node-nq-async-panel" style="display:none; margin: -4px 0 10px 248px; font-size:0.82em; color:#555;">
|
|
208
|
+
Your code must complete async work before execution ends. See the sidebar docs for details and examples.
|
|
148
209
|
</div>
|
|
149
210
|
|
|
150
211
|
<div class="form-row" style="position: relative;">
|
|
@@ -196,11 +257,13 @@ console.log(JSON.stringify({ platform: os.platform(), home: os.homedir() }));
|
|
|
196
257
|
color: "rgb(100, 180, 140)",
|
|
197
258
|
category: 'function',
|
|
198
259
|
defaults: {
|
|
199
|
-
name:
|
|
200
|
-
queue:
|
|
201
|
-
|
|
260
|
+
name: { value: "" },
|
|
261
|
+
queue: { value: 1 },
|
|
262
|
+
executionMode: { value: "sync" },
|
|
263
|
+
asyncMode: { value: false }, // legacy — read on load for backward compat
|
|
264
|
+
streamMode: { value: "delimited" },
|
|
202
265
|
delimiter: { value: "\\n", validate: function(v) { return typeof v === 'string' && v.length > 0; } },
|
|
203
|
-
template: { value: "//
|
|
266
|
+
template: { value: "// Available variables: payload, topic, and all msg properties.\n// WARNING: Do not put Nunjucks expressions inside JS comments.\n// In async mode, return a Promise: return axios.get(url).then(r => r.data);\n\nconsole.log(`{{ payload }}`);" },
|
|
204
267
|
output: { value: "str" },
|
|
205
268
|
field: { value: "payload", validate: RED.validators.typedInput("fieldType") },
|
|
206
269
|
fieldType: { value: "msg" },
|
|
@@ -227,6 +290,19 @@ console.log(JSON.stringify({ platform: os.platform(), home: os.homedir() }));
|
|
|
227
290
|
typeField: $("#node-input-fieldType")
|
|
228
291
|
});
|
|
229
292
|
|
|
293
|
+
// ── execution mode ────────────────────────────────────────────────
|
|
294
|
+
// Resolve mode with backward-compat fallback for nodes saved before
|
|
295
|
+
// executionMode existed (those had asyncMode: true/false).
|
|
296
|
+
var savedMode = this.executionMode || (this.asyncMode === true ? "async" : "sync");
|
|
297
|
+
$("#node-input-executionMode").val(savedMode);
|
|
298
|
+
|
|
299
|
+
function updateExecutionModeUI() {
|
|
300
|
+
var isAsync = $("#node-input-executionMode").val() === "async";
|
|
301
|
+
$("#node-nq-async-panel").toggle(isAsync);
|
|
302
|
+
}
|
|
303
|
+
$("#node-input-executionMode").on("change", updateExecutionModeUI);
|
|
304
|
+
updateExecutionModeUI();
|
|
305
|
+
|
|
230
306
|
function updateStreamUI() {
|
|
231
307
|
var mode = $("#node-input-streamMode").val();
|
|
232
308
|
var $sel = $("#node-input-streamMode");
|
|
@@ -333,7 +409,9 @@ console.log(JSON.stringify({ platform: os.platform(), home: os.homedir() }));
|
|
|
333
409
|
}, 0);
|
|
334
410
|
},
|
|
335
411
|
oneditsave: function() {
|
|
336
|
-
this.
|
|
412
|
+
this.executionMode = $("#node-input-executionMode").val();
|
|
413
|
+
this.asyncMode = (this.executionMode === "async");
|
|
414
|
+
this.currentLine = this.editor.getCursorPosition();
|
|
337
415
|
$("#node-input-template").val(this.editor.getValue());
|
|
338
416
|
this.editor.destroy();
|
|
339
417
|
delete this.editor;
|
package/node.queue.js
CHANGED
|
@@ -58,37 +58,47 @@ function buildRawContext(msg, node) {
|
|
|
58
58
|
return ctx;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// ── BOOTSTRAP
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
61
|
+
// ── WORKER BOOTSTRAP ──────────────────────────────────────────────────────────
|
|
62
|
+
// Execution model: two explicit modes, selected per-job via d.asyncMode.
|
|
63
|
+
//
|
|
64
|
+
// SYNC MODE (d.asyncMode === false, default):
|
|
65
|
+
// • Code runs in a plain function(){}. No async/await allowed.
|
|
66
|
+
// • await → SyntaxError → explicit error frame.
|
|
67
|
+
// • Promise return value → detected → explicit error frame.
|
|
68
|
+
// • Completion is immediate after function returns.
|
|
69
|
+
//
|
|
70
|
+
// ASYNC MODE (d.asyncMode === true):
|
|
71
|
+
// • Code runs in async function(){}. MUST return a Promise or use await.
|
|
72
|
+
// • Completion = Promise resolve/reject.
|
|
73
|
+
// • Fire-and-forget (console.log after done) → error reported via __NQ_ERROR__ stderr marker.
|
|
74
|
+
//
|
|
75
|
+
// Both modes:
|
|
76
|
+
// • Zero console.log output → treated as failure (explicit error frame).
|
|
77
|
+
// • console.log() produces output; console.warn/error go to stderr only.
|
|
78
|
+
// • global state persists across messages on the same worker.
|
|
79
|
+
//
|
|
80
|
+
// Output model: all console.log calls are buffered during execution and sent
|
|
81
|
+
// as a single atomic payload inside the done frame.
|
|
82
|
+
//
|
|
83
|
+
// Protocol (one JSON frame per stdout line):
|
|
84
|
+
// { t:'d', outputs:['line\n', ...] } — done with all buffered output
|
|
85
|
+
// { t:'e', v:'stack...' } — error, job failed
|
|
86
|
+
//
|
|
87
|
+
// Stderr fire-and-forget marker (async mode only):
|
|
88
|
+
// __NQ_ERROR__:<message> — parent escalates to node.error()
|
|
70
89
|
|
|
71
90
|
const NODE_BOOTSTRAP = `
|
|
72
91
|
'use strict';
|
|
73
92
|
const vm = require('vm');
|
|
74
93
|
const readline = require('readline');
|
|
75
94
|
|
|
95
|
+
// console is replaced per-execution; other globals are stable across jobs.
|
|
76
96
|
const ctx = vm.createContext({
|
|
77
97
|
require,
|
|
78
98
|
global: {},
|
|
79
99
|
process,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const v = a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ');
|
|
83
|
-
process.stdout.write(JSON.stringify({ t: 'o', v: v + '\\n' }) + '\\n');
|
|
84
|
-
},
|
|
85
|
-
info: (...a) => {
|
|
86
|
-
const v = a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ');
|
|
87
|
-
process.stdout.write(JSON.stringify({ t: 'o', v: v + '\\n' }) + '\\n');
|
|
88
|
-
},
|
|
89
|
-
warn: (...a) => process.stderr.write(a.join(' ') + '\\n'),
|
|
90
|
-
error: (...a) => process.stderr.write(a.join(' ') + '\\n'),
|
|
91
|
-
},
|
|
100
|
+
Promise,
|
|
101
|
+
console: null,
|
|
92
102
|
setTimeout,
|
|
93
103
|
clearTimeout,
|
|
94
104
|
setInterval,
|
|
@@ -103,18 +113,96 @@ rl.on('line', (line) => {
|
|
|
103
113
|
if (!line) return;
|
|
104
114
|
|
|
105
115
|
let d;
|
|
106
|
-
try {
|
|
107
|
-
d = JSON.parse(line);
|
|
108
|
-
} catch (e) {
|
|
116
|
+
try { d = JSON.parse(line); } catch (e) {
|
|
109
117
|
process.stdout.write(JSON.stringify({ t: 'e', v: 'JSON parse error: ' + e.message }) + '\\n');
|
|
110
118
|
return;
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
const asyncMode = d.asyncMode === true;
|
|
122
|
+
|
|
123
|
+
// Per-execution output buffer. Populated by console.log during execution.
|
|
124
|
+
// After _done is true, any further console.log calls indicate fire-and-
|
|
125
|
+
// forget code; escalated as explicit errors via the __NQ_ERROR__ marker.
|
|
126
|
+
const _outputs = [];
|
|
127
|
+
let _done = false;
|
|
128
|
+
|
|
129
|
+
function _fmt(args) {
|
|
130
|
+
return Array.prototype.map.call(args, function(x) {
|
|
131
|
+
return typeof x === 'string' ? x : JSON.stringify(x);
|
|
132
|
+
}).join(' ');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _log() {
|
|
136
|
+
var v = _fmt(arguments) + '\\n';
|
|
137
|
+
if (_done) {
|
|
138
|
+
var errMsg = asyncMode
|
|
139
|
+
? 'Async mode requires returning a Promise.\\n' +
|
|
140
|
+
'Fire-and-forget detected: console.log() was called after execution completed.\\n' +
|
|
141
|
+
'Fix: return axios.get(url).then(r => r.data)\\n' +
|
|
142
|
+
'Lost value: ' + v
|
|
143
|
+
: 'Asynchronous operations are not allowed in synchronous mode.\\n' +
|
|
144
|
+
'Switch to Asynchronous mode or remove async code.\\n' +
|
|
145
|
+
'Lost value: ' + v;
|
|
146
|
+
process.stderr.write('__NQ_ERROR__:' + errMsg + '\\n');
|
|
147
|
+
} else {
|
|
148
|
+
_outputs.push(v);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ctx.console = { log: _log, info: _log,
|
|
153
|
+
warn: function() { process.stderr.write(_fmt(arguments) + '\\n'); },
|
|
154
|
+
error: function() { process.stderr.write(_fmt(arguments) + '\\n'); },
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (asyncMode) {
|
|
158
|
+
// ── ASYNC MODE ────────────────────────────────────────────────────────
|
|
159
|
+
// Code runs in async function. Completion = Promise resolve/reject.
|
|
160
|
+
let _r;
|
|
161
|
+
try {
|
|
162
|
+
_r = vm.runInContext('(async function(){"use strict";\\n' + d.code + '\\n})()', ctx);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: e.stack || e.message }) + '\\n');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
_r.then(function() {
|
|
168
|
+
_done = true;
|
|
169
|
+
if (_outputs.length === 0) {
|
|
170
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: 'Execution produced no output. Your code must call console.log() to produce output.' }) + '\\n');
|
|
171
|
+
} else {
|
|
172
|
+
process.stdout.write(JSON.stringify({ t: 'd', outputs: _outputs }) + '\\n');
|
|
173
|
+
}
|
|
174
|
+
}).catch(function(e) {
|
|
175
|
+
_done = true;
|
|
176
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: e.stack || e.message }) + '\\n');
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
// ── SYNC MODE ─────────────────────────────────────────────────────────
|
|
180
|
+
// Code runs in a plain function. await → SyntaxError. Promise return → error.
|
|
181
|
+
let _r;
|
|
182
|
+
try {
|
|
183
|
+
_r = vm.runInContext('(function(){"use strict";\\n' + d.code + '\\n})()', ctx);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
var msg = e.stack || e.message;
|
|
186
|
+
if (e.name === 'SyntaxError' && /await/.test(msg)) {
|
|
187
|
+
msg = 'Asynchronous operations are not allowed in synchronous mode.\\n' +
|
|
188
|
+
'Switch to Asynchronous mode or remove async code.\\n\\n' + msg;
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: msg }) + '\\n');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Detect Promise return (thenable) — explicit contract violation.
|
|
195
|
+
if (_r !== null && _r !== undefined && typeof _r.then === 'function') {
|
|
196
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: 'Asynchronous operations are not allowed in synchronous mode.\\nSwitch to Asynchronous mode or remove async code.\\n\\n(Your code returned a Promise — this is not allowed in synchronous mode.)' }) + '\\n');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_done = true;
|
|
201
|
+
if (_outputs.length === 0) {
|
|
202
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: 'Execution produced no output. Your code must call console.log() to produce output.' }) + '\\n');
|
|
203
|
+
} else {
|
|
204
|
+
process.stdout.write(JSON.stringify({ t: 'd', outputs: _outputs }) + '\\n');
|
|
205
|
+
}
|
|
118
206
|
}
|
|
119
207
|
});
|
|
120
208
|
`;
|
|
@@ -186,6 +274,8 @@ module.exports = function(RED) {
|
|
|
186
274
|
this.fieldType = n.fieldType || 'msg';
|
|
187
275
|
this.queue = Math.max(1, Number(n.queue) || 1);
|
|
188
276
|
this.streamMode = n.streamMode || 'delimited';
|
|
277
|
+
// executionMode is canonical; asyncMode is the legacy boolean (backward compat).
|
|
278
|
+
this.asyncMode = (n.executionMode === "async") || (n.asyncMode === true);
|
|
189
279
|
|
|
190
280
|
// Interpret escape sequences in the stored delimiter string: \n → newline, \t → tab, \r → CR
|
|
191
281
|
const delimRaw = (n.delimiter !== undefined && n.delimiter !== '') ? n.delimiter : '\\n';
|
|
@@ -199,6 +289,16 @@ module.exports = function(RED) {
|
|
|
199
289
|
let pendingQueue = [];
|
|
200
290
|
let closing = false;
|
|
201
291
|
|
|
292
|
+
// ── observability counters (REQ 6) ────────────────────────────────────
|
|
293
|
+
// Invariant: received = sent + errored + killed + in-flight (always)
|
|
294
|
+
// sent tracks item.send() calls — the only valid flow-level output signal.
|
|
295
|
+
const counters = { received: 0, sent: 0, completed: 0, errored: 0, killed: 0 };
|
|
296
|
+
|
|
297
|
+
function logCounters(event) {
|
|
298
|
+
const inflight = counters.received - counters.completed - counters.errored - counters.killed;
|
|
299
|
+
node.log(`[node.queue] ${event} | recv=${counters.received} sent=${counters.sent} done=${counters.completed} err=${counters.errored} kill=${counters.killed} inflight=${inflight}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
202
302
|
// ── status state ──────────────────────────────────────────────────────
|
|
203
303
|
let currentState = { waiting: 0, executing: 0, workers: 0 };
|
|
204
304
|
let lastEmittedState = null;
|
|
@@ -223,14 +323,16 @@ module.exports = function(RED) {
|
|
|
223
323
|
if (node.fieldType === 'msg') {
|
|
224
324
|
const outMsg = Object.assign({}, item.msg);
|
|
225
325
|
RED.util.setMessageProperty(outMsg, node.field, result.value);
|
|
326
|
+
counters.sent++;
|
|
226
327
|
item.send(outMsg);
|
|
227
328
|
} else {
|
|
228
329
|
const context = RED.util.parseContextStore(node.field);
|
|
229
330
|
const target = node.context()[node.fieldType];
|
|
230
331
|
const outMsg = Object.assign({}, item.msg);
|
|
231
332
|
target.set(context.key, result.value, context.store, (err) => {
|
|
232
|
-
if (err) node.error(err);
|
|
233
|
-
|
|
333
|
+
if (err) { node.error(err); return; }
|
|
334
|
+
counters.sent++;
|
|
335
|
+
item.send(outMsg);
|
|
234
336
|
});
|
|
235
337
|
}
|
|
236
338
|
}
|
|
@@ -250,20 +352,34 @@ module.exports = function(RED) {
|
|
|
250
352
|
}
|
|
251
353
|
}
|
|
252
354
|
|
|
253
|
-
// ──
|
|
355
|
+
// ── frame handler ─────────────────────────────────────────────────────
|
|
356
|
+
//
|
|
357
|
+
// Protocol frames (one JSON object per stdout line):
|
|
358
|
+
// { t:'d', outputs:['line\n', ...] } — job done, all output included
|
|
359
|
+
// { t:'e', v:'stack...' } — job failed with error
|
|
360
|
+
//
|
|
361
|
+
// Outputs are buffered inside the worker and arrive atomically in the
|
|
362
|
+
// done frame — they are guaranteed to precede it in the stdout stream.
|
|
363
|
+
// All sends therefore happen before done(), satisfying the flow-level
|
|
364
|
+
// delivery guarantee (REQ 1, 4).
|
|
365
|
+
//
|
|
366
|
+
// REQ 7: any frame arriving when currentItem is null is a protocol
|
|
367
|
+
// violation. It means either a worker bug or fire-and-forget code that
|
|
368
|
+
// bypassed the buffer (impossible with the current bootstrap). Either
|
|
369
|
+
// way it must be reported as an explicit error, never silently ignored.
|
|
254
370
|
|
|
255
|
-
// Handles one JSON line from the worker's stdout.
|
|
256
|
-
// Frames: { t:'o', v:chunk } | { t:'d' } | { t:'e', v:stack }
|
|
257
371
|
function handleStreamFrame(worker, line) {
|
|
258
372
|
let frame;
|
|
259
373
|
try {
|
|
260
374
|
frame = JSON.parse(line);
|
|
261
375
|
} catch (e) {
|
|
262
|
-
node.error(`JSON parse error from
|
|
376
|
+
node.error(`[node.queue] JSON parse error from worker: ${e.message}\nRaw: ${line}`);
|
|
263
377
|
if (worker.currentItem) {
|
|
264
378
|
const item = worker.currentItem;
|
|
265
379
|
worker.currentItem = null;
|
|
266
380
|
worker.busy = false;
|
|
381
|
+
counters.errored++;
|
|
382
|
+
logCounters('parse-error');
|
|
267
383
|
item.done(e);
|
|
268
384
|
resetIdleTimer();
|
|
269
385
|
processNext();
|
|
@@ -273,22 +389,25 @@ module.exports = function(RED) {
|
|
|
273
389
|
}
|
|
274
390
|
|
|
275
391
|
const item = worker.currentItem;
|
|
276
|
-
if (!item) return; // killed — ignore late frames silently
|
|
277
392
|
|
|
278
|
-
|
|
279
|
-
|
|
393
|
+
// REQ 7 — frame with no active job is always an error.
|
|
394
|
+
if (!item) {
|
|
395
|
+
node.error(`[node.queue] Frame (t:${frame.t}) received with no active job — possible worker bug or protocol error. Raw: ${line}`);
|
|
280
396
|
return;
|
|
281
397
|
}
|
|
282
398
|
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
399
|
+
// ── done ──────────────────────────────────────────────────────────
|
|
400
|
+
if (frame.t === 'd') {
|
|
401
|
+
worker.currentItem = null;
|
|
402
|
+
worker.busy = false;
|
|
403
|
+
|
|
404
|
+
// Process all buffered output then call done.
|
|
405
|
+
// Outputs are emitted via item.send() before item.done() so the
|
|
406
|
+
// downstream node receives the message before the job closes.
|
|
407
|
+
for (const chunk of (frame.outputs || [])) {
|
|
408
|
+
handleOutputChunk(item, chunk);
|
|
409
|
+
}
|
|
286
410
|
|
|
287
|
-
if (frame.t === 'e') {
|
|
288
|
-
node.error(`Node.js worker error:\n${frame.v}`);
|
|
289
|
-
item.done(new Error(frame.v));
|
|
290
|
-
} else {
|
|
291
|
-
// Flush buffered content before signalling done
|
|
292
411
|
if (node.streamMode === 'delimited' && item.segmentBuffer) {
|
|
293
412
|
emitSegment(item, item.segmentBuffer);
|
|
294
413
|
item.segmentBuffer = '';
|
|
@@ -297,12 +416,32 @@ module.exports = function(RED) {
|
|
|
297
416
|
emitSegment(item, item.rawBuffer);
|
|
298
417
|
item.rawBuffer = '';
|
|
299
418
|
}
|
|
419
|
+
|
|
420
|
+
counters.completed++;
|
|
421
|
+
logCounters('done');
|
|
300
422
|
item.done();
|
|
423
|
+
resetIdleTimer();
|
|
424
|
+
processNext();
|
|
425
|
+
computeAndStoreState();
|
|
426
|
+
return;
|
|
301
427
|
}
|
|
302
428
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
429
|
+
// ── error ─────────────────────────────────────────────────────────
|
|
430
|
+
if (frame.t === 'e') {
|
|
431
|
+
worker.currentItem = null;
|
|
432
|
+
worker.busy = false;
|
|
433
|
+
counters.errored++;
|
|
434
|
+
logCounters('error');
|
|
435
|
+
node.error(`[node.queue] Worker error:\n${frame.v}`);
|
|
436
|
+
item.done(new Error(frame.v));
|
|
437
|
+
resetIdleTimer();
|
|
438
|
+
processNext();
|
|
439
|
+
computeAndStoreState();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Unknown frame type — warn but keep the job alive.
|
|
444
|
+
node.warn(`[node.queue] Unknown frame type "${frame.t}" from worker — ignored`);
|
|
306
445
|
}
|
|
307
446
|
|
|
308
447
|
// ── worker management ─────────────────────────────────────────────────
|
|
@@ -325,7 +464,17 @@ module.exports = function(RED) {
|
|
|
325
464
|
|
|
326
465
|
proc.stderr.on('data', (data) => {
|
|
327
466
|
const text = Buffer.isBuffer(data) ? data.toString() : String(data);
|
|
328
|
-
if (text
|
|
467
|
+
if (!text || !text.trim()) return;
|
|
468
|
+
// Lines prefixed with __NQ_ERROR__: are contract violations escalated to node.error().
|
|
469
|
+
for (const line of text.split('\n')) {
|
|
470
|
+
const t = line.trim();
|
|
471
|
+
if (!t) continue;
|
|
472
|
+
if (t.startsWith('__NQ_ERROR__:')) {
|
|
473
|
+
node.error(t.slice('__NQ_ERROR__:'.length));
|
|
474
|
+
} else {
|
|
475
|
+
node.warn(t);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
329
478
|
});
|
|
330
479
|
|
|
331
480
|
proc.on('error', (err) => {
|
|
@@ -334,10 +483,15 @@ module.exports = function(RED) {
|
|
|
334
483
|
node.error(`Node.js worker error: ${errMsg}`);
|
|
335
484
|
workers = workers.filter(w => w !== worker);
|
|
336
485
|
if (worker.currentItem) {
|
|
337
|
-
worker.currentItem
|
|
486
|
+
const item = worker.currentItem;
|
|
338
487
|
worker.currentItem = null;
|
|
488
|
+
worker.busy = false;
|
|
489
|
+
counters.errored++;
|
|
490
|
+
logCounters('worker-error');
|
|
491
|
+
item.done(new Error(errMsg));
|
|
492
|
+
} else {
|
|
493
|
+
worker.busy = false;
|
|
339
494
|
}
|
|
340
|
-
worker.busy = false;
|
|
341
495
|
processNext();
|
|
342
496
|
computeAndStoreState();
|
|
343
497
|
});
|
|
@@ -347,10 +501,15 @@ module.exports = function(RED) {
|
|
|
347
501
|
workers = workers.filter(w => w !== worker);
|
|
348
502
|
if (worker.currentItem) {
|
|
349
503
|
node.warn(`Node.js worker exited (code ${code}) — failing current job`);
|
|
350
|
-
worker.currentItem
|
|
504
|
+
const item = worker.currentItem;
|
|
351
505
|
worker.currentItem = null;
|
|
506
|
+
worker.busy = false;
|
|
507
|
+
counters.errored++;
|
|
508
|
+
logCounters('worker-close');
|
|
509
|
+
item.done(new Error(`Node.js worker exited with code ${code}`));
|
|
510
|
+
} else {
|
|
511
|
+
worker.busy = false;
|
|
352
512
|
}
|
|
353
|
-
worker.busy = false;
|
|
354
513
|
processNext();
|
|
355
514
|
computeAndStoreState();
|
|
356
515
|
});
|
|
@@ -401,11 +560,13 @@ module.exports = function(RED) {
|
|
|
401
560
|
worker.currentItem = item;
|
|
402
561
|
|
|
403
562
|
try {
|
|
404
|
-
worker.proc.stdin.write(JSON.stringify({ code: item.rendered }) + '\n');
|
|
563
|
+
worker.proc.stdin.write(JSON.stringify({ code: item.rendered, asyncMode: node.asyncMode }) + '\n');
|
|
405
564
|
} catch (e) {
|
|
406
565
|
node.error(`Failed to write to Node.js worker stdin: ${e.message}`);
|
|
407
566
|
worker.busy = false;
|
|
408
567
|
worker.currentItem = null;
|
|
568
|
+
counters.errored++;
|
|
569
|
+
logCounters('stdin-error');
|
|
409
570
|
item.done(e);
|
|
410
571
|
}
|
|
411
572
|
|
|
@@ -468,6 +629,7 @@ module.exports = function(RED) {
|
|
|
468
629
|
function computeAndStoreState() {
|
|
469
630
|
currentState = computeState();
|
|
470
631
|
node.currentState = currentState;
|
|
632
|
+
node.counters = counters;
|
|
471
633
|
scheduleStatusEmit();
|
|
472
634
|
}
|
|
473
635
|
|
|
@@ -477,13 +639,16 @@ module.exports = function(RED) {
|
|
|
477
639
|
function killAll() {
|
|
478
640
|
for (const w of workers) {
|
|
479
641
|
if (w.currentItem) {
|
|
480
|
-
w.currentItem
|
|
642
|
+
const item = w.currentItem;
|
|
481
643
|
w.currentItem = null;
|
|
644
|
+
counters.killed++;
|
|
645
|
+
item.done();
|
|
482
646
|
}
|
|
483
647
|
try { w.proc.kill('SIGTERM'); } catch (_) {}
|
|
484
648
|
}
|
|
485
649
|
workers = [];
|
|
486
650
|
for (const item of pendingQueue) {
|
|
651
|
+
counters.killed++;
|
|
487
652
|
item.done();
|
|
488
653
|
}
|
|
489
654
|
pendingQueue = [];
|
|
@@ -503,21 +668,23 @@ module.exports = function(RED) {
|
|
|
503
668
|
return;
|
|
504
669
|
}
|
|
505
670
|
|
|
671
|
+
counters.received++;
|
|
672
|
+
|
|
506
673
|
const template = selectTemplate(msg, node);
|
|
507
674
|
const context = buildRawContext(msg, node);
|
|
508
675
|
|
|
509
676
|
let rendered;
|
|
510
677
|
try {
|
|
511
678
|
const env = new nunjucks.Environment(null, { autoescape: false });
|
|
512
|
-
|
|
513
|
-
.replace(/\/\/.*$/gm, '')
|
|
514
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
515
|
-
rendered = env.renderString(stripped, context);
|
|
679
|
+
rendered = env.renderString(template, context);
|
|
516
680
|
} catch (e) {
|
|
517
681
|
node.error(
|
|
518
682
|
'Template render error: ' + e.message +
|
|
519
|
-
'\nTip: Do not
|
|
683
|
+
'\nTip: Do not put Nunjucks expressions inside JS comments.',
|
|
684
|
+
msg
|
|
520
685
|
);
|
|
686
|
+
counters.errored++;
|
|
687
|
+
logCounters('template-error');
|
|
521
688
|
done(e);
|
|
522
689
|
return;
|
|
523
690
|
}
|
|
@@ -560,7 +727,8 @@ module.exports = function(RED) {
|
|
|
560
727
|
if (n) {
|
|
561
728
|
res.json({
|
|
562
729
|
executing: n.currentState ? n.currentState.executing : 0,
|
|
563
|
-
workers: n.currentState ? n.currentState.workers : 0
|
|
730
|
+
workers: n.currentState ? n.currentState.workers : 0,
|
|
731
|
+
counters: n.counters || { received: 0, sent: 0, completed: 0, errored: 0, killed: 0 }
|
|
564
732
|
});
|
|
565
733
|
} else {
|
|
566
734
|
res.sendStatus(404);
|