@nrutils/node-red-utils 0.0.0-stage → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +190 -0
  3. package/README.md +187 -2
  4. package/index.js +50 -0
  5. package/node-red-base/NodeRedNodeBase.js +143 -0
  6. package/node-red-base/README.md +215 -0
  7. package/node-red-editorcomms/EditorCommsBackend.js +155 -0
  8. package/node-red-editorcomms/EditorCommsFrontend.js +105 -0
  9. package/node-red-editorcomms/README.md +164 -0
  10. package/node-red-gpu/GpuBuffer.js +29 -0
  11. package/node-red-gpu/Kernel.js +35 -0
  12. package/node-red-gpu/README.md +220 -0
  13. package/node-red-gpu/backends/CpuBackend.js +109 -0
  14. package/node-red-gpu/backends/VulkanBackend.js +232 -0
  15. package/node-red-gpu/errors.js +48 -0
  16. package/node-red-gpu/index.js +6 -0
  17. package/node-red-gpu/log.js +18 -0
  18. package/node-red-gpu/native/binding.gyp +14 -0
  19. package/node-red-gpu/native/src/gpu_addon.cpp +1066 -0
  20. package/node-red-gpu/nodeRedGpuMain.js +39 -0
  21. package/node-red-gpu/nodeRedGpuWorker.js +242 -0
  22. package/node-red-gpu/transpiler/emitGlsl.js +497 -0
  23. package/node-red-gpu/transpiler/glslNames.js +60 -0
  24. package/node-red-gpu/transpiler/index.js +81 -0
  25. package/node-red-gpu/transpiler/inferTypes.js +302 -0
  26. package/node-red-gpu/transpiler/validate.js +461 -0
  27. package/node-red-popup/NodeRedPopupBackend.js +69 -0
  28. package/node-red-popup/NodeRedPopupFrontend.js +508 -0
  29. package/node-red-popup/README.md +113 -0
  30. package/node-red-pubsub-bus/NodeRedPubsubBus.js +617 -0
  31. package/node-red-pubsub-bus/NodeRedPubsubBusWorkerProxy.js +239 -0
  32. package/node-red-pubsub-bus/README.md +201 -0
  33. package/node-red-resourceendpoint/NodeRedResourceEndpoint.js +205 -0
  34. package/node-red-resourceendpoint/README.md +119 -0
  35. package/node-red-singleton/NodeRedSingletonBackend.js +30 -0
  36. package/node-red-singleton/NodeRedSingletonFrontend.js +54 -0
  37. package/node-red-singleton/README.md +72 -0
  38. package/node-red-worker/NodeRedWorker.js +443 -0
  39. package/node-red-worker/NodeRedWorkerRuntime.js +659 -0
  40. package/node-red-worker/README.md +273 -0
  41. package/node-red-worker/workerRuntimeLoader.js +55 -0
  42. package/package.json +70 -5
@@ -0,0 +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
+ // 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;