@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.
- package/README.md +836 -5
- package/exec.queue.html +228 -38
- package/exec.queue.js +553 -537
- package/exec.service.html +342 -229
- package/exec.service.js +325 -487
- package/node.queue.html +359 -0
- package/node.queue.js +569 -0
- package/package.json +19 -19
- package/python.config.html +55 -0
- package/python.config.js +24 -0
- package/python.queue.html +360 -0
- package/python.queue.js +555 -0
- package/utils/context.js +54 -0
- package/async.gpt.html +0 -327
- package/async.gpt.js +0 -615
- package/async.latex.html +0 -319
- package/async.latex.js +0 -618
- package/module.njk.html +0 -45
- package/module.njk.js +0 -12
- package/template.njk.html +0 -201
- package/template.njk.js +0 -138
- package/thrd.function.html +0 -312
- package/thrd.function.js +0 -586
- package/thread.queue.html +0 -311
- package/thread.queue.js +0 -586
package/node.queue.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
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 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.
|
|
70
|
+
|
|
71
|
+
const NODE_BOOTSTRAP = `
|
|
72
|
+
'use strict';
|
|
73
|
+
const vm = require('vm');
|
|
74
|
+
const readline = require('readline');
|
|
75
|
+
|
|
76
|
+
const ctx = vm.createContext({
|
|
77
|
+
require,
|
|
78
|
+
global: {},
|
|
79
|
+
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
|
+
},
|
|
92
|
+
setTimeout,
|
|
93
|
+
clearTimeout,
|
|
94
|
+
setInterval,
|
|
95
|
+
clearInterval,
|
|
96
|
+
Buffer,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
100
|
+
|
|
101
|
+
rl.on('line', (line) => {
|
|
102
|
+
line = line.trim();
|
|
103
|
+
if (!line) return;
|
|
104
|
+
|
|
105
|
+
let d;
|
|
106
|
+
try {
|
|
107
|
+
d = JSON.parse(line);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
process.stdout.write(JSON.stringify({ t: 'e', v: 'JSON parse error: ' + e.message }) + '\\n');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
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');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
// ── SECTION 1: TEMPLATE RESOLUTION ───────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
// Returns the effective template: msg.template overrides only when
|
|
125
|
+
// node.template is empty or null.
|
|
126
|
+
function selectTemplate(msg, node) {
|
|
127
|
+
if (msg.hasOwnProperty('template') && (node.template === '' || node.template === null)) {
|
|
128
|
+
return msg.template;
|
|
129
|
+
}
|
|
130
|
+
return node.template;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
// ── SECTION 2: OUTPUT PARSING ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
// Pure: parses rawValue per outputFormat.
|
|
137
|
+
// Returns { value } on success, { error: string } on failure.
|
|
138
|
+
function parseOutputValue(rawValue, outputFormat) {
|
|
139
|
+
if (outputFormat === 'parsedJSON') {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(rawValue);
|
|
142
|
+
if (typeof parsed === 'number') {
|
|
143
|
+
return { error: 'Error parsing JSON: result is a plain number' };
|
|
144
|
+
}
|
|
145
|
+
return { value: parsed };
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return { error: 'Error parsing JSON:\n\n' + err };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (outputFormat === 'parsedXML') {
|
|
152
|
+
try {
|
|
153
|
+
return { value: convertXML.xml2js(rawValue, { compact: true, spaces: 4 }) };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return { error: 'Error parsing XML:\n\n' + err };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (outputFormat === 'parsedYAML') {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = yaml.load(rawValue);
|
|
162
|
+
if (typeof parsed === 'number') {
|
|
163
|
+
return { error: 'Error parsing YAML: result is a plain number' };
|
|
164
|
+
}
|
|
165
|
+
return { value: parsed };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return { error: 'Error parsing YAML:\n\n' + err };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { value: rawValue };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
// ── SECTION 3: NODE DEFINITION ────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
module.exports = function(RED) {
|
|
178
|
+
|
|
179
|
+
function NodeQueueNode(n) {
|
|
180
|
+
RED.nodes.createNode(this, n);
|
|
181
|
+
|
|
182
|
+
this.name = n.name;
|
|
183
|
+
this.template = n.template || '';
|
|
184
|
+
this.outputFormat = n.output || 'str';
|
|
185
|
+
this.field = n.field || 'payload';
|
|
186
|
+
this.fieldType = n.fieldType || 'msg';
|
|
187
|
+
this.queue = Math.max(1, Number(n.queue) || 1);
|
|
188
|
+
this.streamMode = n.streamMode || 'delimited';
|
|
189
|
+
|
|
190
|
+
// Interpret escape sequences in the stored delimiter string: \n → newline, \t → tab, \r → CR
|
|
191
|
+
const delimRaw = (n.delimiter !== undefined && n.delimiter !== '') ? n.delimiter : '\\n';
|
|
192
|
+
this.delimiter = delimRaw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
|
|
193
|
+
|
|
194
|
+
const node = this;
|
|
195
|
+
|
|
196
|
+
// ── worker pool ───────────────────────────────────────────────────────
|
|
197
|
+
// Each worker: { proc, busy, currentItem, lineBuffer }
|
|
198
|
+
let workers = [];
|
|
199
|
+
let pendingQueue = [];
|
|
200
|
+
let closing = false;
|
|
201
|
+
|
|
202
|
+
// ── status state ──────────────────────────────────────────────────────
|
|
203
|
+
let currentState = { waiting: 0, executing: 0, workers: 0 };
|
|
204
|
+
let lastEmittedState = null;
|
|
205
|
+
let statusTimer = null;
|
|
206
|
+
let lastEmitTime = 0;
|
|
207
|
+
|
|
208
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
209
|
+
|
|
210
|
+
// ── idle timeout ──────────────────────────────────────────────────────
|
|
211
|
+
const IDLE_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
|
|
212
|
+
let idleTimer = null;
|
|
213
|
+
|
|
214
|
+
// ── output helpers ────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
// Emit one segment: parse it and call send(). Never calls done().
|
|
217
|
+
function emitSegment(item, value) {
|
|
218
|
+
const result = parseOutputValue(value, node.outputFormat);
|
|
219
|
+
if (result.error) {
|
|
220
|
+
node.error(result.error);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (node.fieldType === 'msg') {
|
|
224
|
+
const outMsg = Object.assign({}, item.msg);
|
|
225
|
+
RED.util.setMessageProperty(outMsg, node.field, result.value);
|
|
226
|
+
item.send(outMsg);
|
|
227
|
+
} else {
|
|
228
|
+
const context = RED.util.parseContextStore(node.field);
|
|
229
|
+
const target = node.context()[node.fieldType];
|
|
230
|
+
const outMsg = Object.assign({}, item.msg);
|
|
231
|
+
target.set(context.key, result.value, context.store, (err) => {
|
|
232
|
+
if (err) node.error(err);
|
|
233
|
+
else item.send(outMsg);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Route an output chunk: raw → accumulate for single emit at done;
|
|
239
|
+
// delimited → buffer and split on delimiter.
|
|
240
|
+
function handleOutputChunk(item, chunk) {
|
|
241
|
+
if (node.streamMode === 'raw') {
|
|
242
|
+
item.rawBuffer += chunk;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
item.segmentBuffer += chunk;
|
|
246
|
+
const parts = item.segmentBuffer.split(node.delimiter);
|
|
247
|
+
item.segmentBuffer = parts.pop(); // keep incomplete remainder
|
|
248
|
+
for (const part of parts) {
|
|
249
|
+
if (part) emitSegment(item, part);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── stream frame handler ──────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
// Handles one JSON line from the worker's stdout.
|
|
256
|
+
// Frames: { t:'o', v:chunk } | { t:'d' } | { t:'e', v:stack }
|
|
257
|
+
function handleStreamFrame(worker, line) {
|
|
258
|
+
let frame;
|
|
259
|
+
try {
|
|
260
|
+
frame = JSON.parse(line);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
node.error(`JSON parse error from Node.js worker: ${e.message}\nRaw: ${line}`);
|
|
263
|
+
if (worker.currentItem) {
|
|
264
|
+
const item = worker.currentItem;
|
|
265
|
+
worker.currentItem = null;
|
|
266
|
+
worker.busy = false;
|
|
267
|
+
item.done(e);
|
|
268
|
+
resetIdleTimer();
|
|
269
|
+
processNext();
|
|
270
|
+
computeAndStoreState();
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const item = worker.currentItem;
|
|
276
|
+
if (!item) return; // killed — ignore late frames silently
|
|
277
|
+
|
|
278
|
+
if (frame.t === 'o') {
|
|
279
|
+
handleOutputChunk(item, frame.v);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 'd' or 'e' — job is finished
|
|
284
|
+
worker.currentItem = null;
|
|
285
|
+
worker.busy = false;
|
|
286
|
+
|
|
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
|
+
if (node.streamMode === 'delimited' && item.segmentBuffer) {
|
|
293
|
+
emitSegment(item, item.segmentBuffer);
|
|
294
|
+
item.segmentBuffer = '';
|
|
295
|
+
}
|
|
296
|
+
if (node.streamMode === 'raw' && item.rawBuffer) {
|
|
297
|
+
emitSegment(item, item.rawBuffer);
|
|
298
|
+
item.rawBuffer = '';
|
|
299
|
+
}
|
|
300
|
+
item.done();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
resetIdleTimer();
|
|
304
|
+
processNext();
|
|
305
|
+
computeAndStoreState();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── worker management ─────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
function createWorker() {
|
|
311
|
+
const proc = spawn('node', ['-e', NODE_BOOTSTRAP], {
|
|
312
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const worker = { proc, busy: false, currentItem: null, lineBuffer: '' };
|
|
316
|
+
|
|
317
|
+
proc.stdout.on('data', (data) => {
|
|
318
|
+
worker.lineBuffer += data.toString();
|
|
319
|
+
const lines = worker.lineBuffer.split('\n');
|
|
320
|
+
worker.lineBuffer = lines.pop();
|
|
321
|
+
for (const line of lines) {
|
|
322
|
+
if (line.trim()) handleStreamFrame(worker, line);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
proc.stderr.on('data', (data) => {
|
|
327
|
+
const text = Buffer.isBuffer(data) ? data.toString() : String(data);
|
|
328
|
+
if (text && text.trim().length > 0) node.warn(text.trim());
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
proc.on('error', (err) => {
|
|
332
|
+
if (closing) return;
|
|
333
|
+
const errMsg = (err && err.message) ? err.message : String(err);
|
|
334
|
+
node.error(`Node.js worker error: ${errMsg}`);
|
|
335
|
+
workers = workers.filter(w => w !== worker);
|
|
336
|
+
if (worker.currentItem) {
|
|
337
|
+
worker.currentItem.done(new Error(errMsg));
|
|
338
|
+
worker.currentItem = null;
|
|
339
|
+
}
|
|
340
|
+
worker.busy = false;
|
|
341
|
+
processNext();
|
|
342
|
+
computeAndStoreState();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
proc.on('close', (code) => {
|
|
346
|
+
if (closing) return;
|
|
347
|
+
workers = workers.filter(w => w !== worker);
|
|
348
|
+
if (worker.currentItem) {
|
|
349
|
+
node.warn(`Node.js worker exited (code ${code}) — failing current job`);
|
|
350
|
+
worker.currentItem.done(new Error(`Node.js worker exited with code ${code}`));
|
|
351
|
+
worker.currentItem = null;
|
|
352
|
+
}
|
|
353
|
+
worker.busy = false;
|
|
354
|
+
processNext();
|
|
355
|
+
computeAndStoreState();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return worker;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ensureWorkers() {
|
|
362
|
+
while (workers.length < node.queue) {
|
|
363
|
+
workers.push(createWorker());
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function stopAllWorkers() {
|
|
368
|
+
for (const w of workers) {
|
|
369
|
+
try { w.proc.kill('SIGTERM'); } catch (_) {}
|
|
370
|
+
}
|
|
371
|
+
workers = [];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── idle timer ────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
function resetIdleTimer() {
|
|
377
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
378
|
+
idleTimer = setTimeout(() => {
|
|
379
|
+
idleTimer = null;
|
|
380
|
+
const anyBusy = workers.some(w => w.busy);
|
|
381
|
+
if (!anyBusy && pendingQueue.length === 0 && workers.length > 0) {
|
|
382
|
+
node.log('Idle timeout — stopping all Node.js workers');
|
|
383
|
+
stopAllWorkers();
|
|
384
|
+
computeAndStoreState();
|
|
385
|
+
}
|
|
386
|
+
}, IDLE_TIMEOUT_MS);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── scheduler ─────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function processNext() {
|
|
392
|
+
if (pendingQueue.length === 0) return;
|
|
393
|
+
|
|
394
|
+
ensureWorkers();
|
|
395
|
+
|
|
396
|
+
const worker = workers.find(w => !w.busy);
|
|
397
|
+
if (!worker) return;
|
|
398
|
+
|
|
399
|
+
const item = pendingQueue.shift();
|
|
400
|
+
worker.busy = true;
|
|
401
|
+
worker.currentItem = item;
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
worker.proc.stdin.write(JSON.stringify({ code: item.rendered }) + '\n');
|
|
405
|
+
} catch (e) {
|
|
406
|
+
node.error(`Failed to write to Node.js worker stdin: ${e.message}`);
|
|
407
|
+
worker.busy = false;
|
|
408
|
+
worker.currentItem = null;
|
|
409
|
+
item.done(e);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
processNext(); // fill next free worker immediately
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── status ────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
function computeState() {
|
|
418
|
+
return {
|
|
419
|
+
waiting: pendingQueue.length,
|
|
420
|
+
executing: workers.filter(w => w.busy).length,
|
|
421
|
+
workers: workers.length
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function computeStatusFromState(state) {
|
|
426
|
+
return {
|
|
427
|
+
fill: state.workers > 0 ? 'blue' : 'grey',
|
|
428
|
+
shape: state.executing > 0 ? 'ring' : 'dot',
|
|
429
|
+
text: `${state.waiting} (${state.executing}/${node.queue})`
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function emitStatus() {
|
|
434
|
+
lastEmitTime = Date.now();
|
|
435
|
+
if (
|
|
436
|
+
lastEmittedState &&
|
|
437
|
+
currentState.waiting === lastEmittedState.waiting &&
|
|
438
|
+
currentState.executing === lastEmittedState.executing &&
|
|
439
|
+
currentState.workers === lastEmittedState.workers
|
|
440
|
+
) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
lastEmittedState = { ...currentState };
|
|
444
|
+
node.status(computeStatusFromState(currentState));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function scheduleStatusEmit() {
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
const idle = currentState.waiting === 0 && currentState.executing === 0;
|
|
450
|
+
if (idle) {
|
|
451
|
+
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
452
|
+
emitStatus();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (now - lastEmitTime >= STATUS_INTERVAL_MS) {
|
|
456
|
+
emitStatus();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (!statusTimer) {
|
|
460
|
+
const delay = STATUS_INTERVAL_MS - (now - lastEmitTime);
|
|
461
|
+
statusTimer = setTimeout(() => {
|
|
462
|
+
statusTimer = null;
|
|
463
|
+
emitStatus();
|
|
464
|
+
}, delay);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function computeAndStoreState() {
|
|
469
|
+
currentState = computeState();
|
|
470
|
+
node.currentState = currentState;
|
|
471
|
+
scheduleStatusEmit();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── killAll ───────────────────────────────────────────────────────────
|
|
475
|
+
// Used by: msg.stop, close handler, HTTP admin endpoint.
|
|
476
|
+
|
|
477
|
+
function killAll() {
|
|
478
|
+
for (const w of workers) {
|
|
479
|
+
if (w.currentItem) {
|
|
480
|
+
w.currentItem.done();
|
|
481
|
+
w.currentItem = null;
|
|
482
|
+
}
|
|
483
|
+
try { w.proc.kill('SIGTERM'); } catch (_) {}
|
|
484
|
+
}
|
|
485
|
+
workers = [];
|
|
486
|
+
for (const item of pendingQueue) {
|
|
487
|
+
item.done();
|
|
488
|
+
}
|
|
489
|
+
pendingQueue = [];
|
|
490
|
+
currentState = { waiting: 0, executing: 0, workers: 0 };
|
|
491
|
+
node.currentState = currentState;
|
|
492
|
+
node.status({ fill: 'grey', shape: 'dot', text: `0 (0/${node.queue})` });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
node.killAll = killAll;
|
|
496
|
+
|
|
497
|
+
// ── input handler ─────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
node.on('input', function(msg, send, done) {
|
|
500
|
+
if (msg.stop === true) {
|
|
501
|
+
killAll();
|
|
502
|
+
done();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const template = selectTemplate(msg, node);
|
|
507
|
+
const context = buildRawContext(msg, node);
|
|
508
|
+
|
|
509
|
+
let rendered;
|
|
510
|
+
try {
|
|
511
|
+
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);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
node.error(
|
|
518
|
+
'Template render error: ' + e.message +
|
|
519
|
+
'\nTip: Do not use {{ }} inside comments. Nunjucks parses the entire template.'
|
|
520
|
+
);
|
|
521
|
+
done(e);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
pendingQueue.push({ msg, send, done, rendered, segmentBuffer: '', rawBuffer: '' });
|
|
526
|
+
resetIdleTimer();
|
|
527
|
+
processNext();
|
|
528
|
+
computeAndStoreState();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ── close handler ─────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
node.on('close', function(done) {
|
|
534
|
+
closing = true;
|
|
535
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
536
|
+
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
537
|
+
killAll();
|
|
538
|
+
done();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ── start ─────────────────────────────────────────────────────────────
|
|
542
|
+
// Lazy: workers start on first message, not at deploy time.
|
|
543
|
+
computeAndStoreState();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
RED.nodes.registerType('node.queue', NodeQueueNode);
|
|
547
|
+
|
|
548
|
+
RED.httpAdmin.post('/node-queue/:id/kill', function(req, res) {
|
|
549
|
+
const n = RED.nodes.getNode(req.params.id);
|
|
550
|
+
if (n && typeof n.killAll === 'function') {
|
|
551
|
+
n.killAll();
|
|
552
|
+
res.sendStatus(200);
|
|
553
|
+
} else {
|
|
554
|
+
res.sendStatus(404);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
RED.httpAdmin.get('/node-queue/:id/status', function(req, res) {
|
|
559
|
+
const n = RED.nodes.getNode(req.params.id);
|
|
560
|
+
if (n) {
|
|
561
|
+
res.json({
|
|
562
|
+
executing: n.currentState ? n.currentState.executing : 0,
|
|
563
|
+
workers: n.currentState ? n.currentState.workers : 0
|
|
564
|
+
});
|
|
565
|
+
} else {
|
|
566
|
+
res.sendStatus(404);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
};
|
package/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name" : "@inteli.city/node-red-contrib-exec-collection",
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"fs": "*",
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"fs": "*",
|
|
7
6
|
"tmp-promise": "*",
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
"js-yaml": "*",
|
|
8
|
+
"xml-js": "*",
|
|
9
|
+
"child_process": "*",
|
|
10
|
+
"queue": "^6.0.2",
|
|
12
11
|
"nunjucks": "^3.2.4",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"pm2": "*",
|
|
12
|
+
"os": "*",
|
|
13
|
+
"terminate": "*",
|
|
16
14
|
"pg-promise": "*"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
},
|
|
16
|
+
"keywords": [ "node-red" ],
|
|
17
|
+
"license": "Apache-2.0",
|
|
20
18
|
"node-red" : {
|
|
21
|
-
|
|
19
|
+
"version": ">=2.0.0",
|
|
22
20
|
"nodes": {
|
|
23
|
-
"exec queue":
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
21
|
+
"exec queue": "exec.queue.js",
|
|
22
|
+
"exec service": "exec.service.js",
|
|
23
|
+
"python queue": "python.queue.js",
|
|
24
|
+
"python config": "python.config.js",
|
|
25
|
+
"node queue": "node.queue.js",
|
|
26
|
+
"async pg": "async.pg.js"
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script type="text/markdown" data-help-name="python.config">
|
|
2
|
+
## python.config
|
|
3
|
+
|
|
4
|
+
Defines the Python executable used by python.queue.
|
|
5
|
+
|
|
6
|
+
The path must point to a valid Python binary. This can be:
|
|
7
|
+
- a system Python (e.g. `/usr/bin/python3`)
|
|
8
|
+
- a virtual environment Python (e.g. `/path/to/venv/bin/python`)
|
|
9
|
+
|
|
10
|
+
This node does not create or manage environments.
|
|
11
|
+
The environment must already exist and be fully configured.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
[Full documentation](https://www.npmjs.com/package/@inteli.city/node-red-contrib-exec-collection)
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script type="text/html" data-template-name="python.config">
|
|
19
|
+
<div class="form-row">
|
|
20
|
+
<label for="node-config-input-name">
|
|
21
|
+
<i class="fa fa-tag"></i>
|
|
22
|
+
<span data-i18n="node-red:common.label.name"></span>
|
|
23
|
+
</label>
|
|
24
|
+
<input type="text" id="node-config-input-name" data-i18n="[placeholder]node-red:common.label.name">
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="node-config-input-pythonPath">
|
|
29
|
+
<i class="fa fa-terminal"></i>
|
|
30
|
+
<span>Python Path</span>
|
|
31
|
+
</label>
|
|
32
|
+
<input type="text" id="node-config-input-pythonPath" placeholder="/usr/bin/python3">
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="form-row" style="margin-top:-4px;">
|
|
36
|
+
<div style="margin-left:105px; font-size:11px; color:#888; line-height:1.5em;">
|
|
37
|
+
This can be a system Python or a virtual environment (venv) executable.<br>
|
|
38
|
+
<strong>Examples:</strong> <span style="color:#c0392b;">/usr/bin/python3</span> · <span style="color:#c0392b;">/home/user/myenv/bin/python</span><br>
|
|
39
|
+
<em>Note: The venv must already exist and have all dependencies installed.</em>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<script type="text/javascript">
|
|
45
|
+
RED.nodes.registerType('python.config', {
|
|
46
|
+
category: 'config',
|
|
47
|
+
defaults: {
|
|
48
|
+
name: { value: '' },
|
|
49
|
+
pythonPath: { value: '', required: true }
|
|
50
|
+
},
|
|
51
|
+
label: function() {
|
|
52
|
+
return this.name || this.pythonPath || 'python.config';
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
</script>
|