@inteli.city/node-red-contrib-exec-collection 2.0.2 → 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 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:`// All msg values render as strings. Wrap in quotes for Python string literals.\n// WARNING: Nunjucks evaluates {{ }} everywhere, including inside // 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\`)`},
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}, */
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:** you are not running a script — you are sending code to a running Node.js engine. State persists between messages on the same worker.
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. Comments are stripped before rendering, so they have no effect on output anyway.
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: { value: "" },
200
- queue: { value: 1 },
201
- streamMode: { value: "delimited" },
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: "// All msg values render as strings. Wrap in quotes for JS string literals.\n// WARNING: Nunjucks evaluates {{ }} everywhere, including inside // comments.\n\nconst data = \"{{ payload }}\"\nconsole.log(data)" },
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.currentLine = this.editor.getCursorPosition();
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
- // Injected into each Node.js worker via node -e.
63
- // Reads one JSON line per execution: { "code": "...js..." }
64
- // Runs the code in a persistent vm context. Each console.log() immediately emits:
65
- // { "t": "o", "v": "...line\n" } output chunk
66
- // After execution:
67
- // { "t": "d" } — done (success)
68
- // { "t": "e", "v": "...stack..." } — error
69
- // Each worker has its own isolated context — no shared state between workers.
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
- console: {
81
- log: (...a) => {
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
- try {
114
- vm.runInContext('(function(){"use strict";' + d.code + '\\n})()', ctx);
115
- process.stdout.write(JSON.stringify({ t: 'd' }) + '\\n');
116
- } catch (e) {
117
- process.stdout.write(JSON.stringify({ t: 'e', v: e.stack || e.message }) + '\\n');
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
- else item.send(outMsg);
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
- // ── stream frame handler ──────────────────────────────────────────────
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 Node.js worker: ${e.message}\nRaw: ${line}`);
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
- if (frame.t === 'o') {
279
- handleOutputChunk(item, frame.v);
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
- // 'd' or 'e' — job is finished
284
- worker.currentItem = null;
285
- worker.busy = false;
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
- resetIdleTimer();
304
- processNext();
305
- computeAndStoreState();
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 && text.trim().length > 0) node.warn(text.trim());
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.done(new Error(errMsg));
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.done(new Error(`Node.js worker exited with code ${code}`));
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.done();
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
- const stripped = template
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 use {{ }} inside comments. Nunjucks parses the entire template.'
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name" : "@inteli.city/node-red-contrib-exec-collection",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "dependencies": {
5
5
  "fs": "*",
6
6
  "tmp-promise": "*",