@inteli.city/node-red-contrib-exec-collection 1.0.4 → 1.0.5

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,555 @@
1
+ 'use strict';
2
+
3
+ // ── requires ──────────────────────────────────────────────────────────────────
4
+
5
+ const nunjucks = require('nunjucks');
6
+ const { spawn } = require('child_process');
7
+ const yaml = require('js-yaml');
8
+ const convertXML = require('xml-js');
9
+
10
+ // ── LOCAL TEMPLATE CONTEXT ────────────────────────────────────────────────────
11
+ // Raw context: objects are NOT pre-stringified. Instead, each value is wrapped
12
+ // in a Proxy that serialises itself only when Nunjucks coerces it to a string
13
+ // (i.e. at output time). This means:
14
+ // {{ payload }} → JSON.stringify(payload) (calls toString / toPrimitive)
15
+ // {{ payload.a }} → the value of a (string/number as-is, object → JSON)
16
+ // {{ payload.a.b }} → the value of b
17
+ // No "[object Object]" is ever produced.
18
+
19
+ const _SKIP_KEYS = ['req', 'res', '_req', '_res'];
20
+
21
+ function _safeStringify(obj) {
22
+ const seen = new WeakSet();
23
+ try {
24
+ return JSON.stringify(obj, (key, value) => {
25
+ if (typeof value === 'object' && value !== null) {
26
+ if (seen.has(value)) return '[Circular]';
27
+ seen.add(value);
28
+ }
29
+ return value;
30
+ });
31
+ } catch (e) {
32
+ return '[Unserializable]';
33
+ }
34
+ }
35
+
36
+ function _wrapValue(value) {
37
+ if (typeof value !== 'object' || value === null) return value;
38
+ return new Proxy(value, {
39
+ get(target, prop) {
40
+ if (prop === Symbol.toPrimitive) return () => _safeStringify(target);
41
+ if (prop === 'toString') return () => _safeStringify(target);
42
+ const result = Reflect.get(target, prop);
43
+ if (typeof result === 'object' && result !== null) return _wrapValue(result);
44
+ return result;
45
+ }
46
+ });
47
+ }
48
+
49
+ function buildRawContext(msg, node) {
50
+ const ctx = {};
51
+ for (const k of Object.keys(msg || {})) {
52
+ if (_SKIP_KEYS.includes(k)) continue;
53
+ ctx[k] = _wrapValue(msg[k]);
54
+ }
55
+ ctx.flow = { get: (key) => node.context().flow.get(key) };
56
+ ctx.global = { get: (key) => node.context().global.get(key) };
57
+ ctx.env = process.env;
58
+ return ctx;
59
+ }
60
+
61
+ // ── BOOTSTRAP ─────────────────────────────────────────────────────────────────
62
+ // Injected into each Python worker via -c.
63
+ // Reads one JSON line per execution: { "code": "...python..." }
64
+ // During execution stdout is intercepted; each write() emits a frame:
65
+ // { "t": "o", "v": "...chunk..." } — output chunk
66
+ // After execution:
67
+ // { "t": "d" } — done (success)
68
+ // { "t": "e", "v": "...traceback..." } — error
69
+ // Each worker has its own isolated namespace — no shared state between workers.
70
+
71
+ const PYTHON_BOOTSTRAP = `\
72
+ import sys as _sys, json as _json, traceback as _tb
73
+
74
+ class _Out(object):
75
+ def __init__(self, r): self._r = r; self._buf = ''
76
+ def write(self, s):
77
+ if not s: return
78
+ self._buf += s
79
+ while '\\n' in self._buf:
80
+ nl = self._buf.index('\\n')
81
+ chunk = self._buf[:nl+1]
82
+ self._buf = self._buf[nl+1:]
83
+ self._r.write(_json.dumps({'t':'o','v':chunk}) + '\\n')
84
+ self._r.flush()
85
+ def flush(self):
86
+ if self._buf:
87
+ self._r.write(_json.dumps({'t':'o','v':self._buf}) + '\\n')
88
+ self._r.flush()
89
+ self._buf = ''
90
+
91
+ _real = _sys.stdout
92
+ _ns = {}
93
+ while True:
94
+ _line = _sys.stdin.readline()
95
+ if not _line: break
96
+ _line = _line.rstrip('\\n')
97
+ if not _line: continue
98
+ _out = _Out(_real)
99
+ _sys.stdout = _out
100
+ try:
101
+ _d = _json.loads(_line)
102
+ exec(_d['code'], _ns)
103
+ _out.flush()
104
+ _real.write(_json.dumps({'t':'d'}) + '\\n')
105
+ _real.flush()
106
+ except Exception:
107
+ _out.flush()
108
+ _real.write(_json.dumps({'t':'e','v':_tb.format_exc()}) + '\\n')
109
+ _real.flush()
110
+ finally:
111
+ _sys.stdout = _real
112
+ `;
113
+
114
+ // ── SECTION 1: TEMPLATE RESOLUTION ───────────────────────────────────────────
115
+
116
+ function selectTemplate(msg, node) {
117
+ if (msg.hasOwnProperty('template') && (node.template === '' || node.template === null)) {
118
+ return msg.template;
119
+ }
120
+ return node.template;
121
+ }
122
+
123
+
124
+ // ── SECTION 2: OUTPUT PARSING ─────────────────────────────────────────────────
125
+
126
+ // Pure: parses rawValue per outputFormat.
127
+ // Returns { value } on success, { error: string } on failure.
128
+ function parseOutputValue(rawValue, outputFormat) {
129
+ if (outputFormat === 'parsedJSON') {
130
+ try {
131
+ const parsed = JSON.parse(rawValue);
132
+ if (typeof parsed === 'number') {
133
+ return { error: 'Error parsing JSON: result is a plain number' };
134
+ }
135
+ return { value: parsed };
136
+ } catch (err) {
137
+ return { error: 'Error parsing JSON:\n\n' + err };
138
+ }
139
+ }
140
+
141
+ if (outputFormat === 'parsedXML') {
142
+ try {
143
+ return { value: convertXML.xml2js(rawValue, { compact: true, spaces: 4 }) };
144
+ } catch (err) {
145
+ return { error: 'Error parsing XML:\n\n' + err };
146
+ }
147
+ }
148
+
149
+ if (outputFormat === 'parsedYAML') {
150
+ try {
151
+ const parsed = yaml.load(rawValue);
152
+ if (typeof parsed === 'number') {
153
+ return { error: 'Error parsing YAML: result is a plain number' };
154
+ }
155
+ return { value: parsed };
156
+ } catch (err) {
157
+ return { error: 'Error parsing YAML:\n\n' + err };
158
+ }
159
+ }
160
+
161
+ return { value: rawValue };
162
+ }
163
+
164
+
165
+ // ── SECTION 3: NODE DEFINITION ────────────────────────────────────────────────
166
+
167
+ module.exports = function(RED) {
168
+
169
+ function PythonQueueNode(n) {
170
+ RED.nodes.createNode(this, n);
171
+
172
+ this.name = n.name;
173
+ this.template = n.template || '';
174
+ this.outputFormat = n.output || 'str';
175
+ this.field = n.field || 'payload';
176
+ this.fieldType = n.fieldType || 'msg';
177
+ this.queue = Math.max(1, Number(n.queue) || 1);
178
+ this.pythonConfig = RED.nodes.getNode(n.pythonConfig);
179
+ this.streamMode = n.streamMode || 'delimited';
180
+
181
+ // Interpret escape sequences in the stored delimiter string: \n → newline, \t → tab, \r → CR
182
+ const delimRaw = (n.delimiter !== undefined && n.delimiter !== '') ? n.delimiter : '\\n';
183
+ this.delimiter = delimRaw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
184
+
185
+ const node = this;
186
+ const pythonPath = (node.pythonConfig && node.pythonConfig.pythonPath) || 'python3';
187
+
188
+ // ── worker pool ───────────────────────────────────────────────────────
189
+ // Each worker: { proc, busy, currentItem, lineBuffer }
190
+ let workers = [];
191
+ let pendingQueue = [];
192
+ let closing = false;
193
+
194
+ // ── status state ──────────────────────────────────────────────────────
195
+ let currentState = { waiting: 0, executing: 0, workers: 0 };
196
+ let lastEmittedState = null;
197
+ let statusTimer = null;
198
+ let lastEmitTime = 0;
199
+
200
+ const STATUS_INTERVAL_MS = 1000;
201
+
202
+ // ── idle timeout ──────────────────────────────────────────────────────
203
+ const IDLE_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
204
+ let idleTimer = null;
205
+
206
+ // ── output helpers ────────────────────────────────────────────────────
207
+
208
+ // Emit one segment: parse it and call send(). Never calls done().
209
+ function emitSegment(item, value) {
210
+ const result = parseOutputValue(value, node.outputFormat);
211
+ if (result.error) {
212
+ node.error(result.error);
213
+ return;
214
+ }
215
+ if (node.fieldType === 'msg') {
216
+ const outMsg = Object.assign({}, item.msg);
217
+ RED.util.setMessageProperty(outMsg, node.field, result.value);
218
+ item.send(outMsg);
219
+ } else {
220
+ const context = RED.util.parseContextStore(node.field);
221
+ const target = node.context()[node.fieldType];
222
+ const outMsg = Object.assign({}, item.msg);
223
+ target.set(context.key, result.value, context.store, (err) => {
224
+ if (err) node.error(err);
225
+ else item.send(outMsg);
226
+ });
227
+ }
228
+ }
229
+
230
+ // Route an output chunk: raw → accumulate for single emit at done;
231
+ // delimited → buffer and split on delimiter.
232
+ function handleOutputChunk(item, chunk) {
233
+ if (node.streamMode === 'raw') {
234
+ item.rawBuffer += chunk;
235
+ return;
236
+ }
237
+ item.segmentBuffer += chunk;
238
+ const parts = item.segmentBuffer.split(node.delimiter);
239
+ item.segmentBuffer = parts.pop(); // keep incomplete remainder
240
+ for (const part of parts) {
241
+ if (part) emitSegment(item, part);
242
+ }
243
+ }
244
+
245
+ // ── stream frame handler ──────────────────────────────────────────────
246
+
247
+ // Handles one JSON line from the worker's stdout.
248
+ // Frames: { t:'o', v:chunk } | { t:'d' } | { t:'e', v:traceback }
249
+ function handleStreamFrame(worker, line) {
250
+ let frame;
251
+ try {
252
+ frame = JSON.parse(line);
253
+ } catch (e) {
254
+ node.error(`JSON parse error from Python: ${e.message}\nRaw: ${line}`);
255
+ if (worker.currentItem) {
256
+ const item = worker.currentItem;
257
+ worker.currentItem = null;
258
+ worker.busy = false;
259
+ item.done(e);
260
+ resetIdleTimer();
261
+ processNext();
262
+ computeAndStoreState();
263
+ }
264
+ return;
265
+ }
266
+
267
+ const item = worker.currentItem;
268
+ if (!item) return; // killed — ignore late frames silently
269
+
270
+ if (frame.t === 'o') {
271
+ handleOutputChunk(item, frame.v);
272
+ return;
273
+ }
274
+
275
+ // 'd' or 'e' — job is finished
276
+ worker.currentItem = null;
277
+ worker.busy = false;
278
+
279
+ if (frame.t === 'e') {
280
+ node.error(`Python error:\n${frame.v}`);
281
+ item.done(new Error(frame.v));
282
+ } else {
283
+ // Flush buffered content before signalling done
284
+ if (node.streamMode === 'delimited' && item.segmentBuffer) {
285
+ emitSegment(item, item.segmentBuffer);
286
+ item.segmentBuffer = '';
287
+ }
288
+ if (node.streamMode === 'raw' && item.rawBuffer) {
289
+ emitSegment(item, item.rawBuffer);
290
+ item.rawBuffer = '';
291
+ }
292
+ item.done();
293
+ }
294
+
295
+ resetIdleTimer();
296
+ processNext();
297
+ computeAndStoreState();
298
+ }
299
+
300
+ // ── worker management ─────────────────────────────────────────────────
301
+
302
+ function createWorker() {
303
+ const proc = spawn(pythonPath, ['-u', '-c', PYTHON_BOOTSTRAP], {
304
+ stdio: ['pipe', 'pipe', 'pipe']
305
+ });
306
+
307
+ const worker = { proc, busy: false, currentItem: null, lineBuffer: '' };
308
+
309
+ proc.stdout.on('data', (data) => {
310
+ worker.lineBuffer += data.toString();
311
+ const lines = worker.lineBuffer.split('\n');
312
+ worker.lineBuffer = lines.pop();
313
+ for (const line of lines) {
314
+ if (line.trim()) handleStreamFrame(worker, line);
315
+ }
316
+ });
317
+
318
+ proc.stderr.on('data', (data) => {
319
+ const text = Buffer.isBuffer(data) ? data.toString() : String(data);
320
+ if (text && text.trim().length > 0) node.warn(text.trim());
321
+ });
322
+
323
+ proc.on('error', (err) => {
324
+ if (closing) return;
325
+ const errMsg = (err && err.message) ? err.message : String(err);
326
+ node.error(`Python worker error: ${errMsg}`);
327
+ workers = workers.filter(w => w !== worker);
328
+ if (worker.currentItem) {
329
+ worker.currentItem.done(new Error(errMsg));
330
+ worker.currentItem = null;
331
+ }
332
+ worker.busy = false;
333
+ processNext();
334
+ computeAndStoreState();
335
+ });
336
+
337
+ proc.on('close', (code) => {
338
+ if (closing) return;
339
+ workers = workers.filter(w => w !== worker);
340
+ if (worker.currentItem) {
341
+ node.warn(`Python worker exited (code ${code}) — failing current job`);
342
+ worker.currentItem.done(new Error(`Python worker exited with code ${code}`));
343
+ worker.currentItem = null;
344
+ }
345
+ worker.busy = false;
346
+ processNext();
347
+ computeAndStoreState();
348
+ });
349
+
350
+ return worker;
351
+ }
352
+
353
+ function ensureWorkers() {
354
+ while (workers.length < node.queue) {
355
+ workers.push(createWorker());
356
+ }
357
+ }
358
+
359
+ function stopAllWorkers() {
360
+ for (const w of workers) {
361
+ try { w.proc.kill('SIGTERM'); } catch (_) {}
362
+ }
363
+ workers = [];
364
+ }
365
+
366
+ // ── idle timer ────────────────────────────────────────────────────────
367
+
368
+ function resetIdleTimer() {
369
+ if (idleTimer) clearTimeout(idleTimer);
370
+ idleTimer = setTimeout(() => {
371
+ idleTimer = null;
372
+ const anyBusy = workers.some(w => w.busy);
373
+ if (!anyBusy && pendingQueue.length === 0 && workers.length > 0) {
374
+ node.log('Idle timeout — stopping all Python workers');
375
+ stopAllWorkers();
376
+ computeAndStoreState();
377
+ }
378
+ }, IDLE_TIMEOUT_MS);
379
+ }
380
+
381
+ // ── scheduler ─────────────────────────────────────────────────────────
382
+
383
+ function processNext() {
384
+ if (pendingQueue.length === 0) return;
385
+
386
+ ensureWorkers();
387
+
388
+ const worker = workers.find(w => !w.busy);
389
+ if (!worker) return;
390
+
391
+ const item = pendingQueue.shift();
392
+ worker.busy = true;
393
+ worker.currentItem = item;
394
+
395
+ try {
396
+ worker.proc.stdin.write(JSON.stringify({ code: item.rendered }) + '\n');
397
+ } catch (e) {
398
+ node.error(`Failed to write to Python stdin: ${e.message}`);
399
+ worker.busy = false;
400
+ worker.currentItem = null;
401
+ item.done(e);
402
+ }
403
+
404
+ processNext(); // fill next free worker immediately
405
+ }
406
+
407
+ // ── status ────────────────────────────────────────────────────────────
408
+
409
+ function computeState() {
410
+ return {
411
+ waiting: pendingQueue.length,
412
+ executing: workers.filter(w => w.busy).length,
413
+ workers: workers.length
414
+ };
415
+ }
416
+
417
+ function computeStatusFromState(state) {
418
+ return {
419
+ fill: state.workers > 0 ? 'blue' : 'grey',
420
+ shape: state.executing > 0 ? 'ring' : 'dot',
421
+ text: `${state.waiting} (${state.executing}/${node.queue})`
422
+ };
423
+ }
424
+
425
+ function emitStatus() {
426
+ lastEmitTime = Date.now();
427
+ if (
428
+ lastEmittedState &&
429
+ currentState.waiting === lastEmittedState.waiting &&
430
+ currentState.executing === lastEmittedState.executing &&
431
+ currentState.workers === lastEmittedState.workers
432
+ ) {
433
+ return;
434
+ }
435
+ lastEmittedState = { ...currentState };
436
+ node.status(computeStatusFromState(currentState));
437
+ }
438
+
439
+ function scheduleStatusEmit() {
440
+ const now = Date.now();
441
+ const idle = currentState.waiting === 0 && currentState.executing === 0;
442
+ if (idle) {
443
+ if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
444
+ emitStatus();
445
+ return;
446
+ }
447
+ if (now - lastEmitTime >= STATUS_INTERVAL_MS) {
448
+ emitStatus();
449
+ return;
450
+ }
451
+ if (!statusTimer) {
452
+ const delay = STATUS_INTERVAL_MS - (now - lastEmitTime);
453
+ statusTimer = setTimeout(() => {
454
+ statusTimer = null;
455
+ emitStatus();
456
+ }, delay);
457
+ }
458
+ }
459
+
460
+ function computeAndStoreState() {
461
+ currentState = computeState();
462
+ node.currentState = currentState;
463
+ scheduleStatusEmit();
464
+ }
465
+
466
+ // ── killAll ───────────────────────────────────────────────────────────
467
+ // Used by: msg.stop, close handler, HTTP admin endpoint.
468
+
469
+ function killAll() {
470
+ for (const w of workers) {
471
+ if (w.currentItem) {
472
+ w.currentItem.done();
473
+ w.currentItem = null;
474
+ }
475
+ try { w.proc.kill('SIGTERM'); } catch (_) {}
476
+ }
477
+ workers = [];
478
+ for (const item of pendingQueue) {
479
+ item.done();
480
+ }
481
+ pendingQueue = [];
482
+ currentState = { waiting: 0, executing: 0, workers: 0 };
483
+ node.currentState = currentState;
484
+ node.status({ fill: 'grey', shape: 'dot', text: `0 (0/${node.queue})` });
485
+ }
486
+
487
+ node.killAll = killAll;
488
+
489
+ // ── input handler ─────────────────────────────────────────────────────
490
+
491
+ node.on('input', function(msg, send, done) {
492
+ if (msg.stop === true) {
493
+ killAll();
494
+ done();
495
+ return;
496
+ }
497
+
498
+ const template = selectTemplate(msg, node);
499
+ const context = buildRawContext(msg, node);
500
+
501
+ let rendered;
502
+ try {
503
+ const env = new nunjucks.Environment(null, { autoescape: false });
504
+ rendered = env.renderString(template, context);
505
+ } catch (e) {
506
+ node.error('Template render error: ' + e.message + '\nTip: wrap variables in quotes — print("{{ payload }}")');
507
+ done(e);
508
+ return;
509
+ }
510
+
511
+ pendingQueue.push({ msg, send, done, rendered, segmentBuffer: '', rawBuffer: '' });
512
+ resetIdleTimer();
513
+ processNext();
514
+ computeAndStoreState();
515
+ });
516
+
517
+ // ── close handler ─────────────────────────────────────────────────────
518
+
519
+ node.on('close', function(done) {
520
+ closing = true;
521
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
522
+ if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
523
+ killAll();
524
+ done();
525
+ });
526
+
527
+ // ── start ─────────────────────────────────────────────────────────────
528
+ // Lazy: workers start on first message, not at deploy time.
529
+ computeAndStoreState();
530
+ }
531
+
532
+ RED.nodes.registerType('python.queue', PythonQueueNode);
533
+
534
+ RED.httpAdmin.post('/python-queue/:id/kill', function(req, res) {
535
+ const n = RED.nodes.getNode(req.params.id);
536
+ if (n && typeof n.killAll === 'function') {
537
+ n.killAll();
538
+ res.sendStatus(200);
539
+ } else {
540
+ res.sendStatus(404);
541
+ }
542
+ });
543
+
544
+ RED.httpAdmin.get('/python-queue/:id/status', function(req, res) {
545
+ const n = RED.nodes.getNode(req.params.id);
546
+ if (n) {
547
+ res.json({
548
+ executing: n.currentState ? n.currentState.executing : 0,
549
+ workers: n.currentState ? n.currentState.workers : 0
550
+ });
551
+ } else {
552
+ res.sendStatus(404);
553
+ }
554
+ });
555
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const SKIP_KEYS = ['req', 'res', '_req', '_res'];
4
+
5
+ function safeStringify(obj) {
6
+ const seen = new WeakSet();
7
+ try {
8
+ return JSON.stringify(obj, (key, value) => {
9
+ if (typeof value === 'object' && value !== null) {
10
+ if (seen.has(value)) return '[Circular]';
11
+ seen.add(value);
12
+ }
13
+ return value;
14
+ });
15
+ } catch (e) {
16
+ return '[Unserializable Object]';
17
+ }
18
+ }
19
+
20
+ // Builds the Nunjucks rendering context from msg and node.
21
+ // All msg values are coerced to strings so that {{ payload }} is always safe
22
+ // to embed anywhere in the template without quotes or filters:
23
+ // string → kept as-is
24
+ // number → String(v)
25
+ // object → safeStringify(v) (circular-safe, capped at 10 000 chars)
26
+ // null/undefined → ""
27
+ // Keys in SKIP_KEYS (req, res, etc.) are omitted to avoid serializing
28
+ // non-serializable Node-RED runtime objects.
29
+ function buildContext(msg, node) {
30
+ const ctx = {};
31
+
32
+ for (const k of Object.keys(msg || {})) {
33
+ if (SKIP_KEYS.includes(k)) continue;
34
+
35
+ const v = msg[k];
36
+
37
+ if (v === null || v === undefined) {
38
+ ctx[k] = '';
39
+ } else if (typeof v === 'object') {
40
+ const str = safeStringify(v);
41
+ ctx[k] = str.length > 10000 ? '[Object too large]' : str;
42
+ } else {
43
+ ctx[k] = String(v);
44
+ }
45
+ }
46
+
47
+ ctx.flow = { get: (key) => node.context().flow.get(key) };
48
+ ctx.global = { get: (key) => node.context().global.get(key) };
49
+ ctx.env = process.env;
50
+
51
+ return ctx;
52
+ }
53
+
54
+ module.exports = { buildContext };