@nrutils/node-red-utils 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +6 -0
- package/node-red-popup/NodeRedPopupFrontend.js +190 -96
- package/node-red-popup/README.md +6 -3
- package/node-red-worker/NodeRedWorker.js +443 -438
- package/node-red-worker/NodeRedWorkerRuntime.js +658 -650
- package/node-red-worker/README.md +9 -1
- package/package.json +2 -2
|
@@ -1,438 +1,443 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Copyright 2024 Contributors
|
|
3
|
-
* Licensed under the Apache License, Version 2.0
|
|
4
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const { Worker } = require('worker_threads');
|
|
9
|
-
|
|
10
|
-
// Types that the library uses itself. 'default' handlers do not get them.
|
|
11
|
-
const isInternalType = (type) => type === 'ack' || type.startsWith('_');
|
|
12
|
-
let nextLinkId = 0;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Extended Worker class for Node-RED that handles worker thread communication and management
|
|
16
|
-
* @extends {Worker}
|
|
17
|
-
*/
|
|
18
|
-
class NodeRedWorker extends Worker {
|
|
19
|
-
static _REDListenerCreated = false;
|
|
20
|
-
static _flowsRunning = false; // true from flows:started to flows:stopping
|
|
21
|
-
static _nodeRedWorkers = [];
|
|
22
|
-
|
|
23
|
-
constructor( params ) {
|
|
24
|
-
super(params.workerRuntimeFile, { workerData: params.workerData } );
|
|
25
|
-
|
|
26
|
-
this.RED = params.RED;
|
|
27
|
-
this.thisNode = params.thisNode;
|
|
28
|
-
|
|
29
|
-
this._connectionsCompleteCallback = params.connectionsCompleteCallback;
|
|
30
|
-
|
|
31
|
-
this.dirname = path.dirname(params.workerRuntimeFile);
|
|
32
|
-
this._wired = false; // true after the first flows:started wires this worker
|
|
33
|
-
NodeRedWorker._nodeRedWorkers.push({ worker: this, node: this.thisNode });
|
|
34
|
-
this._subscribers = new Map();
|
|
35
|
-
this._namedPorts = {};
|
|
36
|
-
this._links = []; // { linkId, peer }: one entry for each wire to another worker
|
|
37
|
-
this._exited = false;
|
|
38
|
-
this._requests = new Map(); // requestId -> { resolve, reject, timer }: request() calls without an answer
|
|
39
|
-
this._nextRequestId = 0;
|
|
40
|
-
this._setupStandardSubscriptions();
|
|
41
|
-
|
|
42
|
-
// A throw in the worker that nothing catches. Without this listener, Node-RED stops.
|
|
43
|
-
this.on('error', (err) => {
|
|
44
|
-
const message = err && err.message ? err.message : String(err);
|
|
45
|
-
this._rejectRequests(`worker error: ${message}`);
|
|
46
|
-
this._reportError(message);
|
|
47
|
-
});
|
|
48
|
-
this.on('exit', (code) => {
|
|
49
|
-
this._exited = true;
|
|
50
|
-
this._rejectRequests(`worker stopped with exit code ${code}`);
|
|
51
|
-
this._unlink();
|
|
52
|
-
NodeRedWorker._nodeRedWorkers = NodeRedWorker._nodeRedWorkers.filter(entry => entry.worker !== this);
|
|
53
|
-
if (!this._terminatePromise && !this._failed) this._reportError(`worker stopped with exit code ${code}`);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
this.senderTrace = {
|
|
57
|
-
nodeId: this.thisNode.id,
|
|
58
|
-
hostType: 'parent',
|
|
59
|
-
basePortsGroup: 'parentPorts',
|
|
60
|
-
portIndex: 0,
|
|
61
|
-
connectionIndex: 0
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
NodeRedWorker._listen(this.RED);
|
|
65
|
-
// A worker made after the flows started (init() after an await, or in an
|
|
66
|
-
// input handler) gets no flows:started. Wire it now.
|
|
67
|
-
if (NodeRedWorker._flowsRunning) NodeRedWorker._wireFresh(this.RED).catch(err => this.RED.log.error('nodeRedWorker: wiring failed: ' + err.message));
|
|
68
|
-
|
|
69
|
-
// call handlers when message is received
|
|
70
|
-
this.on('message', async (message) => {
|
|
71
|
-
// Other users of the port (e.g. the pubsub proxy) send messages without a type
|
|
72
|
-
if (typeof message?.type !== 'string') return;
|
|
73
|
-
const { type, data, senderTrace } = message;
|
|
74
|
-
// 'default' handlers get every type except the internal ones.
|
|
75
|
-
// A handler that fails is reported on the node; the next handlers still run.
|
|
76
|
-
for (const handler of [
|
|
77
|
-
...(this._subscribers.get(type) || []),
|
|
78
|
-
...(isInternalType(type) ? [] : this._subscribers.get('default') || [])]) {
|
|
79
|
-
try {
|
|
80
|
-
await handler(data, senderTrace, this.senderTrace, type);
|
|
81
|
-
} catch (error) {
|
|
82
|
-
this.thisNode.error(`worker message handler for '${type}' failed: ${(error && error.message) || error}`, {});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Listens to the flow events one time. The mixin calls it when the node type is
|
|
90
|
-
* registered, so that it knows if the flows run when a worker starts.
|
|
91
|
-
* @private
|
|
92
|
-
*/
|
|
93
|
-
static _listen(RED) {
|
|
94
|
-
if (NodeRedWorker._REDListenerCreated) return;
|
|
95
|
-
NodeRedWorker._REDListenerCreated = true;
|
|
96
|
-
RED.events.on('flows:stopping', () => { NodeRedWorker._flowsRunning = false; });
|
|
97
|
-
RED.events.on('flows:started', () => {
|
|
98
|
-
NodeRedWorker._flowsRunning = true;
|
|
99
|
-
NodeRedWorker._wireFresh(RED).catch(err => RED.log.error('nodeRedWorker: wiring failed: ' + err.message));
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Connects each worker that is not wired yet to the worker nodes it is wired to.
|
|
105
|
-
* Workers that are wired already keep their live connections.
|
|
106
|
-
* It uses the wires of the runtime nodes, so it also works in a subflow instance.
|
|
107
|
-
* @private
|
|
108
|
-
*/
|
|
109
|
-
static async _wireFresh(RED) {
|
|
110
|
-
const fresh = NodeRedWorker._nodeRedWorkers.filter(entry => !entry.worker._wired);
|
|
111
|
-
if (fresh.length === 0) return;
|
|
112
|
-
fresh.forEach(entry => { entry.worker._wired = true; });
|
|
113
|
-
|
|
114
|
-
const byNodeId = new Map(NodeRedWorker._nodeRedWorkers.map(entry => [entry.node.id, entry]));
|
|
115
|
-
const ackPromises = [];
|
|
116
|
-
|
|
117
|
-
for (const upstreamWorkerEntry of NodeRedWorker._nodeRedWorkers) {
|
|
118
|
-
const upstreamNode = upstreamWorkerEntry.node;
|
|
119
|
-
const upstreamWorker = upstreamWorkerEntry.worker;
|
|
120
|
-
const nodeWires = upstreamNode.wires || [];
|
|
121
|
-
const wires = !Array.isArray(nodeWires[0]) ? [nodeWires] : nodeWires;
|
|
122
|
-
wires.forEach((connections, outputPortIndex) => {
|
|
123
|
-
connections.forEach((nodeId, outputConnectionIndex) => {
|
|
124
|
-
const downstreamWorkerEntry = byNodeId.get(nodeId);
|
|
125
|
-
// Make a new connection only if one end is a new worker
|
|
126
|
-
if (!downstreamWorkerEntry || !(fresh.includes(upstreamWorkerEntry) || fresh.includes(downstreamWorkerEntry))) return;
|
|
127
|
-
const downstreamWorker = downstreamWorkerEntry.worker;
|
|
128
|
-
const channel = new MessageChannel();
|
|
129
|
-
const linkId = nextLinkId++;
|
|
130
|
-
upstreamWorker._links.push({ linkId, peer: downstreamWorker });
|
|
131
|
-
downstreamWorker._links.push({ linkId, peer: upstreamWorker });
|
|
132
|
-
|
|
133
|
-
ackPromises.push(upstreamWorker._waitForAcknowledgment(upstreamWorker, '_outputPortReceived'));
|
|
134
|
-
ackPromises.push(downstreamWorker._waitForAcknowledgment(downstreamWorker, '_inputPortReceived'));
|
|
135
|
-
|
|
136
|
-
upstreamWorker.publish('_addOutputConnection', {
|
|
137
|
-
nodeId: upstreamNode.id,
|
|
138
|
-
portIndex: outputPortIndex,
|
|
139
|
-
connectionIndex: outputConnectionIndex,
|
|
140
|
-
linkId,
|
|
141
|
-
messagePort: channel.port1
|
|
142
|
-
}, [channel.port1]);
|
|
143
|
-
|
|
144
|
-
downstreamWorker.publish('_addInputConnection', {
|
|
145
|
-
nodeId: downstreamWorkerEntry.node.id,
|
|
146
|
-
linkId,
|
|
147
|
-
messagePort: channel.port2
|
|
148
|
-
}, [channel.port2]);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Wait for all acks before proceeding
|
|
154
|
-
await Promise.all(ackPromises);
|
|
155
|
-
|
|
156
|
-
// Tell the new workers that their connections are complete
|
|
157
|
-
fresh.forEach(({ worker }) => {
|
|
158
|
-
if (worker._exited) return;
|
|
159
|
-
try {
|
|
160
|
-
if (typeof worker._connectionsCompleteCallback === 'function') worker._connectionsCompleteCallback();
|
|
161
|
-
} catch (err) {
|
|
162
|
-
worker.thisNode.error(err);
|
|
163
|
-
}
|
|
164
|
-
worker.publish('_ConnectionsComplete', null);
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Set up default message handlers from this worker
|
|
170
|
-
* @private
|
|
171
|
-
*/
|
|
172
|
-
_setupStandardSubscriptions() {
|
|
173
|
-
|
|
174
|
-
this.subscribe('_createNamedPort', ({ portName, port }, senderTrace, receiverTrace ) => {
|
|
175
|
-
this._namedPorts[portName] = port;
|
|
176
|
-
this.publish('_namedPortReceived', null);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// The worker file threw while it loaded. The thread is of no use now: stop it,
|
|
180
|
-
// so that 'exit' fires as for any other worker failure.
|
|
181
|
-
this.subscribe('_workerError', (message) => {
|
|
182
|
-
this._rejectRequests(`worker error: ${message}`);
|
|
183
|
-
this._reportError(message);
|
|
184
|
-
this.terminate();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
this.subscribe('
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* @
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
super(RED
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2024 Contributors
|
|
3
|
+
* Licensed under the Apache License, Version 2.0
|
|
4
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { Worker } = require('worker_threads');
|
|
9
|
+
|
|
10
|
+
// Types that the library uses itself. 'default' handlers do not get them.
|
|
11
|
+
const isInternalType = (type) => type === 'ack' || type.startsWith('_');
|
|
12
|
+
let nextLinkId = 0;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extended Worker class for Node-RED that handles worker thread communication and management
|
|
16
|
+
* @extends {Worker}
|
|
17
|
+
*/
|
|
18
|
+
class NodeRedWorker extends Worker {
|
|
19
|
+
static _REDListenerCreated = false;
|
|
20
|
+
static _flowsRunning = false; // true from flows:started to flows:stopping
|
|
21
|
+
static _nodeRedWorkers = [];
|
|
22
|
+
|
|
23
|
+
constructor( params ) {
|
|
24
|
+
super(params.workerRuntimeFile, { workerData: params.workerData } );
|
|
25
|
+
|
|
26
|
+
this.RED = params.RED;
|
|
27
|
+
this.thisNode = params.thisNode;
|
|
28
|
+
|
|
29
|
+
this._connectionsCompleteCallback = params.connectionsCompleteCallback;
|
|
30
|
+
|
|
31
|
+
this.dirname = path.dirname(params.workerRuntimeFile);
|
|
32
|
+
this._wired = false; // true after the first flows:started wires this worker
|
|
33
|
+
NodeRedWorker._nodeRedWorkers.push({ worker: this, node: this.thisNode });
|
|
34
|
+
this._subscribers = new Map();
|
|
35
|
+
this._namedPorts = {};
|
|
36
|
+
this._links = []; // { linkId, peer }: one entry for each wire to another worker
|
|
37
|
+
this._exited = false;
|
|
38
|
+
this._requests = new Map(); // requestId -> { resolve, reject, timer }: request() calls without an answer
|
|
39
|
+
this._nextRequestId = 0;
|
|
40
|
+
this._setupStandardSubscriptions();
|
|
41
|
+
|
|
42
|
+
// A throw in the worker that nothing catches. Without this listener, Node-RED stops.
|
|
43
|
+
this.on('error', (err) => {
|
|
44
|
+
const message = err && err.message ? err.message : String(err);
|
|
45
|
+
this._rejectRequests(`worker error: ${message}`);
|
|
46
|
+
this._reportError(message);
|
|
47
|
+
});
|
|
48
|
+
this.on('exit', (code) => {
|
|
49
|
+
this._exited = true;
|
|
50
|
+
this._rejectRequests(`worker stopped with exit code ${code}`);
|
|
51
|
+
this._unlink();
|
|
52
|
+
NodeRedWorker._nodeRedWorkers = NodeRedWorker._nodeRedWorkers.filter(entry => entry.worker !== this);
|
|
53
|
+
if (!this._terminatePromise && !this._failed) this._reportError(`worker stopped with exit code ${code}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.senderTrace = {
|
|
57
|
+
nodeId: this.thisNode.id,
|
|
58
|
+
hostType: 'parent',
|
|
59
|
+
basePortsGroup: 'parentPorts',
|
|
60
|
+
portIndex: 0,
|
|
61
|
+
connectionIndex: 0
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
NodeRedWorker._listen(this.RED);
|
|
65
|
+
// A worker made after the flows started (init() after an await, or in an
|
|
66
|
+
// input handler) gets no flows:started. Wire it now.
|
|
67
|
+
if (NodeRedWorker._flowsRunning) NodeRedWorker._wireFresh(this.RED).catch(err => this.RED.log.error('nodeRedWorker: wiring failed: ' + err.message));
|
|
68
|
+
|
|
69
|
+
// call handlers when message is received
|
|
70
|
+
this.on('message', async (message) => {
|
|
71
|
+
// Other users of the port (e.g. the pubsub proxy) send messages without a type
|
|
72
|
+
if (typeof message?.type !== 'string') return;
|
|
73
|
+
const { type, data, senderTrace } = message;
|
|
74
|
+
// 'default' handlers get every type except the internal ones.
|
|
75
|
+
// A handler that fails is reported on the node; the next handlers still run.
|
|
76
|
+
for (const handler of [
|
|
77
|
+
...(this._subscribers.get(type) || []),
|
|
78
|
+
...(isInternalType(type) ? [] : this._subscribers.get('default') || [])]) {
|
|
79
|
+
try {
|
|
80
|
+
await handler(data, senderTrace, this.senderTrace, type);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.thisNode.error(`worker message handler for '${type}' failed: ${(error && error.message) || error}`, {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Listens to the flow events one time. The mixin calls it when the node type is
|
|
90
|
+
* registered, so that it knows if the flows run when a worker starts.
|
|
91
|
+
* @private
|
|
92
|
+
*/
|
|
93
|
+
static _listen(RED) {
|
|
94
|
+
if (NodeRedWorker._REDListenerCreated) return;
|
|
95
|
+
NodeRedWorker._REDListenerCreated = true;
|
|
96
|
+
RED.events.on('flows:stopping', () => { NodeRedWorker._flowsRunning = false; });
|
|
97
|
+
RED.events.on('flows:started', () => {
|
|
98
|
+
NodeRedWorker._flowsRunning = true;
|
|
99
|
+
NodeRedWorker._wireFresh(RED).catch(err => RED.log.error('nodeRedWorker: wiring failed: ' + err.message));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Connects each worker that is not wired yet to the worker nodes it is wired to.
|
|
105
|
+
* Workers that are wired already keep their live connections.
|
|
106
|
+
* It uses the wires of the runtime nodes, so it also works in a subflow instance.
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
static async _wireFresh(RED) {
|
|
110
|
+
const fresh = NodeRedWorker._nodeRedWorkers.filter(entry => !entry.worker._wired);
|
|
111
|
+
if (fresh.length === 0) return;
|
|
112
|
+
fresh.forEach(entry => { entry.worker._wired = true; });
|
|
113
|
+
|
|
114
|
+
const byNodeId = new Map(NodeRedWorker._nodeRedWorkers.map(entry => [entry.node.id, entry]));
|
|
115
|
+
const ackPromises = [];
|
|
116
|
+
|
|
117
|
+
for (const upstreamWorkerEntry of NodeRedWorker._nodeRedWorkers) {
|
|
118
|
+
const upstreamNode = upstreamWorkerEntry.node;
|
|
119
|
+
const upstreamWorker = upstreamWorkerEntry.worker;
|
|
120
|
+
const nodeWires = upstreamNode.wires || [];
|
|
121
|
+
const wires = !Array.isArray(nodeWires[0]) ? [nodeWires] : nodeWires;
|
|
122
|
+
wires.forEach((connections, outputPortIndex) => {
|
|
123
|
+
connections.forEach((nodeId, outputConnectionIndex) => {
|
|
124
|
+
const downstreamWorkerEntry = byNodeId.get(nodeId);
|
|
125
|
+
// Make a new connection only if one end is a new worker
|
|
126
|
+
if (!downstreamWorkerEntry || !(fresh.includes(upstreamWorkerEntry) || fresh.includes(downstreamWorkerEntry))) return;
|
|
127
|
+
const downstreamWorker = downstreamWorkerEntry.worker;
|
|
128
|
+
const channel = new MessageChannel();
|
|
129
|
+
const linkId = nextLinkId++;
|
|
130
|
+
upstreamWorker._links.push({ linkId, peer: downstreamWorker });
|
|
131
|
+
downstreamWorker._links.push({ linkId, peer: upstreamWorker });
|
|
132
|
+
|
|
133
|
+
ackPromises.push(upstreamWorker._waitForAcknowledgment(upstreamWorker, '_outputPortReceived'));
|
|
134
|
+
ackPromises.push(downstreamWorker._waitForAcknowledgment(downstreamWorker, '_inputPortReceived'));
|
|
135
|
+
|
|
136
|
+
upstreamWorker.publish('_addOutputConnection', {
|
|
137
|
+
nodeId: upstreamNode.id,
|
|
138
|
+
portIndex: outputPortIndex,
|
|
139
|
+
connectionIndex: outputConnectionIndex,
|
|
140
|
+
linkId,
|
|
141
|
+
messagePort: channel.port1
|
|
142
|
+
}, [channel.port1]);
|
|
143
|
+
|
|
144
|
+
downstreamWorker.publish('_addInputConnection', {
|
|
145
|
+
nodeId: downstreamWorkerEntry.node.id,
|
|
146
|
+
linkId,
|
|
147
|
+
messagePort: channel.port2
|
|
148
|
+
}, [channel.port2]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Wait for all acks before proceeding
|
|
154
|
+
await Promise.all(ackPromises);
|
|
155
|
+
|
|
156
|
+
// Tell the new workers that their connections are complete
|
|
157
|
+
fresh.forEach(({ worker }) => {
|
|
158
|
+
if (worker._exited) return;
|
|
159
|
+
try {
|
|
160
|
+
if (typeof worker._connectionsCompleteCallback === 'function') worker._connectionsCompleteCallback();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
worker.thisNode.error(err);
|
|
163
|
+
}
|
|
164
|
+
worker.publish('_ConnectionsComplete', null);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Set up default message handlers from this worker
|
|
170
|
+
* @private
|
|
171
|
+
*/
|
|
172
|
+
_setupStandardSubscriptions() {
|
|
173
|
+
|
|
174
|
+
this.subscribe('_createNamedPort', ({ portName, port }, senderTrace, receiverTrace ) => {
|
|
175
|
+
this._namedPorts[portName] = port;
|
|
176
|
+
this.publish('_namedPortReceived', null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The worker file threw while it loaded. The thread is of no use now: stop it,
|
|
180
|
+
// so that 'exit' fires as for any other worker failure.
|
|
181
|
+
this.subscribe('_workerError', (message) => {
|
|
182
|
+
this._rejectRequests(`worker error: ${message}`);
|
|
183
|
+
this._reportError(message);
|
|
184
|
+
this.terminate();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// A subscribe handler in the worker failed (see handlerError in NodeRedWorkerRuntime.js)
|
|
188
|
+
this.subscribe('_handlerError', ({ type, message }) => {
|
|
189
|
+
this.thisNode.error(`worker message handler for '${type}' failed: ${message}`, {});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// The answer to a request(): a result, or an error as { name, message }
|
|
193
|
+
this.subscribe('_reply', ({ requestId, result, error }) => {
|
|
194
|
+
const request = this._requests.get(requestId);
|
|
195
|
+
if (!request) return;
|
|
196
|
+
this._requests.delete(requestId);
|
|
197
|
+
clearTimeout(request.timer);
|
|
198
|
+
if (!error) return request.resolve(result);
|
|
199
|
+
const err = new Error(error.message);
|
|
200
|
+
err.name = error.name;
|
|
201
|
+
request.reject(err);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Reports a worker failure on the node: an error in the log (Catch nodes get it) and a red status.
|
|
208
|
+
* The worker is not started again. The node stays in error until a Full deploy or Restart Flows starts it again.
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
_reportError(message) {
|
|
212
|
+
this._failed = true;
|
|
213
|
+
// The empty msg lets Catch nodes get the error; without a msg, Node-RED only logs it
|
|
214
|
+
this.thisNode.error(`Worker error: ${message}`, {});
|
|
215
|
+
this.thisNode.status({ fill: 'red', shape: 'ring', text: 'worker error' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Rejects every request() that has no answer yet.
|
|
220
|
+
* @private
|
|
221
|
+
*/
|
|
222
|
+
_rejectRequests(message) {
|
|
223
|
+
for (const { reject, timer } of this._requests.values()) {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
reject(new Error(message));
|
|
226
|
+
}
|
|
227
|
+
this._requests.clear();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Closes the wires between this worker and other workers.
|
|
232
|
+
* Each peer closes its end and frees the wire slot, so a new wire does not add a slot.
|
|
233
|
+
* @private
|
|
234
|
+
*/
|
|
235
|
+
_unlink() {
|
|
236
|
+
for (const { linkId, peer } of this._links) {
|
|
237
|
+
peer._links = peer._links.filter(link => link.linkId !== linkId);
|
|
238
|
+
if (!peer._exited) peer.publish('_removeConnection', { linkId });
|
|
239
|
+
}
|
|
240
|
+
this._links = [];
|
|
241
|
+
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Gets a named port by its channel name
|
|
246
|
+
* @param {string} channelName - The name of the channel
|
|
247
|
+
* @returns {MessagePort|null} The message port if found, null otherwise
|
|
248
|
+
*/
|
|
249
|
+
namedPort(channelName) { return this._namedPorts[channelName] || null };
|
|
250
|
+
|
|
251
|
+
_waitForAcknowledgment(worker, expectedAck) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
if (worker._exited) return resolve();
|
|
254
|
+
const finish = () => {
|
|
255
|
+
worker.removeListener('message', onMessage);
|
|
256
|
+
worker.removeListener('exit', finish);
|
|
257
|
+
resolve();
|
|
258
|
+
};
|
|
259
|
+
const onMessage = (message) => {
|
|
260
|
+
if (message.type === 'ack' && message.data === expectedAck) finish();
|
|
261
|
+
};
|
|
262
|
+
worker.on('message', onMessage);
|
|
263
|
+
worker.on('exit', finish); // a stopped worker sends no ack
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Publishes a message to the worker thread
|
|
269
|
+
* @param {string} type - The message type
|
|
270
|
+
* @param {any} [data] - The data to send. It must be cloneable, or postMessage throws a DataCloneError.
|
|
271
|
+
* @param {Array} [transferList] - Optional array of transferable objects
|
|
272
|
+
*/
|
|
273
|
+
publish(type, data, transferList = []) {
|
|
274
|
+
this.postMessage({ type, data, senderTrace: this.senderTrace }, transferList);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Sends a request to the handler that the worker registered with worker.handle(type, fn),
|
|
279
|
+
* and returns a Promise for its answer.
|
|
280
|
+
* @param {string} type - The request type
|
|
281
|
+
* @param {any} [data] - The data to send. It must be cloneable.
|
|
282
|
+
* @param {Array} [transferList] - Optional array of transferable objects
|
|
283
|
+
* @param {object} [options]
|
|
284
|
+
* @param {number} [options.timeout] - Reject after this many milliseconds. Default: no timeout.
|
|
285
|
+
* @returns {Promise<any>} The value that the handler returned. Rejects with the error that the
|
|
286
|
+
* handler threw, or when the worker stops, the node closes or the timeout expires.
|
|
287
|
+
*/
|
|
288
|
+
request(type, data, transferList = [], { timeout } = {}) {
|
|
289
|
+
if (this._exited || this._terminatePromise) {
|
|
290
|
+
return Promise.reject(new Error(`worker request '${type}': the worker is stopped`));
|
|
291
|
+
}
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
const requestId = this._nextRequestId++;
|
|
294
|
+
const request = { resolve, reject, timer: null };
|
|
295
|
+
if (timeout > 0) {
|
|
296
|
+
request.timer = setTimeout(() => {
|
|
297
|
+
this._requests.delete(requestId);
|
|
298
|
+
reject(new Error(`worker request '${type}' timed out after ${timeout} ms`));
|
|
299
|
+
}, timeout);
|
|
300
|
+
}
|
|
301
|
+
this._requests.set(requestId, request);
|
|
302
|
+
try {
|
|
303
|
+
this.postMessage({ type, data, senderTrace: this.senderTrace, requestId }, transferList);
|
|
304
|
+
} catch (err) { // for example a DataCloneError
|
|
305
|
+
clearTimeout(request.timer);
|
|
306
|
+
this._requests.delete(requestId);
|
|
307
|
+
reject(err);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** init() runs one time for each node. After it, thisNode.nodeRedWorker is this object. */
|
|
313
|
+
init() {
|
|
314
|
+
throw new Error('nodeRedWorker.init() was already called for this node; it starts one worker per node');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Subscribes to a message type
|
|
319
|
+
* @param {string} type - The message type to subscribe to
|
|
320
|
+
* @param {Function} handler - The handler function to be called when the message type is received
|
|
321
|
+
* @throws {Error} If handler is not a function
|
|
322
|
+
*/
|
|
323
|
+
subscribe(type, handler) {
|
|
324
|
+
if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
|
|
325
|
+
if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
|
|
326
|
+
this._subscribers.get(type).push(handler);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Gracefully terminates the worker thread.
|
|
331
|
+
* Publishes a 'close' message to the worker and waits up to 2 seconds for it to
|
|
332
|
+
* clean up (e.g., drain GPU dispatches, destroy buffers) before force-killing.
|
|
333
|
+
* Idempotent — subsequent calls return the same promise.
|
|
334
|
+
* @returns {Promise<number>} Promise that resolves with the exit code when the worker thread exits
|
|
335
|
+
*/
|
|
336
|
+
terminate() {
|
|
337
|
+
if (this._terminatePromise) return this._terminatePromise;
|
|
338
|
+
|
|
339
|
+
this._terminatePromise = (async () => {
|
|
340
|
+
this._rejectRequests('worker request ended: the node closed');
|
|
341
|
+
this._unlink();
|
|
342
|
+
// Signal the worker to shut down gracefully. A stopped worker cannot answer.
|
|
343
|
+
if (!this._exited) {
|
|
344
|
+
try {
|
|
345
|
+
this.publish('close', null);
|
|
346
|
+
// Wait for ack or timeout — whichever comes first
|
|
347
|
+
await Promise.race([
|
|
348
|
+
this._waitForAcknowledgment(this, 'close'),
|
|
349
|
+
new Promise(resolve => setTimeout(resolve, 2000)),
|
|
350
|
+
]);
|
|
351
|
+
} catch (_) {}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const index = NodeRedWorker._nodeRedWorkers.findIndex(entry => entry.worker === this);
|
|
355
|
+
if (index > -1) {
|
|
356
|
+
NodeRedWorker._nodeRedWorkers.splice(index, 1);
|
|
357
|
+
}
|
|
358
|
+
return super.terminate();
|
|
359
|
+
})();
|
|
360
|
+
|
|
361
|
+
return this._terminatePromise;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Creates a mixin that adds worker thread capabilities to Node-RED nodes
|
|
368
|
+
*
|
|
369
|
+
* This function implements the "functional mixin" pattern where a function takes a class
|
|
370
|
+
* and returns an extended version of that class. When used with NodeRedNodeBase.with(),
|
|
371
|
+
* multiple mixins can be composed together to build nodes with combined capabilities.
|
|
372
|
+
*
|
|
373
|
+
* Each mixin follows the same pattern:
|
|
374
|
+
* 1. It's a function that takes a superclass and returns a derived class
|
|
375
|
+
* 2. It adds its functionality through a namespaced object (e.g., this.nodeRedWorker)
|
|
376
|
+
* 3. It provides standard initialization methods (e.g., init)
|
|
377
|
+
* 4. It keeps its functionality self-contained to avoid naming conflicts
|
|
378
|
+
*
|
|
379
|
+
* The "mix in" concept is literal - each mixin adds its own namespaced functionality
|
|
380
|
+
* to the base object without interfering with other mixins. This approach creates
|
|
381
|
+
* a cleaner composition model than deep inheritance chains.
|
|
382
|
+
*
|
|
383
|
+
* @param {class} superclass - The class to extend with worker thread capabilities
|
|
384
|
+
* @returns {class} A new class that extends the superclass with worker functionality
|
|
385
|
+
*/
|
|
386
|
+
const nodeRedWorker = (superclass) => class extends superclass {
|
|
387
|
+
// Listen to the flow events before the first deploy
|
|
388
|
+
static onRegisterType(RED) {
|
|
389
|
+
super.onRegisterType?.(RED);
|
|
390
|
+
NodeRedWorker._listen(RED);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
constructor(RED, thisNode, config) {
|
|
394
|
+
super(RED, thisNode, config);
|
|
395
|
+
|
|
396
|
+
const notStarted = () => { throw new Error('nodeRedWorker: call init() before publish(), request(), subscribe() or other worker calls'); };
|
|
397
|
+
this.nodeRedWorker = {
|
|
398
|
+
|
|
399
|
+
init: function(workerFile, myWorkerData, callback = null) {
|
|
400
|
+
if (thisNode.nodeRedWorker instanceof NodeRedWorker) {
|
|
401
|
+
throw new Error('nodeRedWorker.init() was already called for this node; it starts one worker per node');
|
|
402
|
+
}
|
|
403
|
+
if (typeof workerFile !== 'string' || !path.isAbsolute(workerFile)) {
|
|
404
|
+
throw new TypeError(`nodeRedWorker.init(workerFile): workerFile must be an absolute path, for example path.join(__dirname, 'my-worker.js'); got ${JSON.stringify(workerFile)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const instance = new NodeRedWorker({
|
|
408
|
+
RED: RED,
|
|
409
|
+
thisNode: thisNode,
|
|
410
|
+
workerRuntimeFile: path.join( __dirname, 'workerRuntimeLoader.js'),
|
|
411
|
+
workerData: {
|
|
412
|
+
workerFile: workerFile,
|
|
413
|
+
myWorkerData: myWorkerData
|
|
414
|
+
},
|
|
415
|
+
connectionsCompleteCallback: callback
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Handle node close event for cleanup
|
|
419
|
+
thisNode.on('close', (done) => { instance.terminate().then(() => done(), () => done()); });
|
|
420
|
+
|
|
421
|
+
// Store the worker instance on the parent class to replace the namespace object
|
|
422
|
+
// the original nodeRedWorker object with only 'init' and '_parent' gets replaced
|
|
423
|
+
// with the new class instance
|
|
424
|
+
this._parent.nodeRedWorker = instance;
|
|
425
|
+
thisNode.nodeRedWorker = instance;
|
|
426
|
+
|
|
427
|
+
return instance;
|
|
428
|
+
},
|
|
429
|
+
// Reference to the parent class to allow the namespace object to modify its container
|
|
430
|
+
_parent: this,
|
|
431
|
+
publish: notStarted,
|
|
432
|
+
request: notStarted,
|
|
433
|
+
subscribe: notStarted,
|
|
434
|
+
on: notStarted,
|
|
435
|
+
namedPort: notStarted,
|
|
436
|
+
terminate: notStarted
|
|
437
|
+
};
|
|
438
|
+
thisNode.nodeRedWorker = this.nodeRedWorker;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
module.exports.nodeRedWorker = nodeRedWorker;
|
|
443
|
+
module.exports.NodeRedWorker = NodeRedWorker;
|