@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.
@@ -1,651 +1,659 @@
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
- //
8
- // This class is created in the worker thread
9
- //
10
-
11
- 'use strict';
12
- const { parentPort, MessagePort } = require('worker_threads');
13
-
14
- // Types that the library uses itself. 'default' handlers do not get them.
15
- const isInternalType = (type) => type === 'ack' || type.startsWith('_');
16
-
17
- /**
18
- * Walks a value and collects every unique ArrayBuffer / MessagePort found.
19
- * Used by Connection.pipe() so forwarded messages preserve zero-copy transfer
20
- * semantics across relay hops without the relay having to know the payload shape.
21
- */
22
- function collectTransferables(obj, acc = new Set(), seen = new WeakSet()) {
23
- if (!obj || typeof obj !== 'object') return acc;
24
- if (seen.has(obj)) return acc;
25
- seen.add(obj);
26
- if (obj instanceof ArrayBuffer) { acc.add(obj); return acc; }
27
- if (ArrayBuffer.isView(obj)) { acc.add(obj.buffer); return acc; }
28
- if (obj instanceof MessagePort) { acc.add(obj); return acc; }
29
- for (const k in obj) collectTransferables(obj[k], acc, seen);
30
- return acc;
31
- }
32
-
33
- /**
34
- * Represents a connection between nodes with pub/sub capabilities
35
- * @extends {Object}
36
- */
37
- class Connection extends Object {
38
- /**
39
- * Creates a new Connection
40
- * @param {MessagePort} messagePort - MessagePort for communication
41
- * @param {Connections} connections - Parent Connections instance
42
- */
43
- constructor(messagePort, trace) {
44
- super();
45
- this.trace = trace;
46
- this._intervalID = null; // used in publishUntilAcknowledged
47
- this._subscribers = new Map();
48
- this.messagePort = messagePort;
49
- this.setMessagePort(messagePort);
50
- this._currentPollingTopic = null;
51
- this._currentPollingAckTopic = null;
52
- this._currentPollingAckHandler = null;
53
- this._currentPollingCancel = null;
54
- Object.defineProperty(this, 'length', { value: 1, writable: false, configurable: false });
55
-
56
- // namedPorts setup
57
- this._namedPortsMap = new Map();
58
- this.namedPorts = new Proxy({}, {
59
- get: (target, prop) => {
60
- if (prop === 'forEach') {
61
- return (callback) => {
62
- if (typeof callback !== 'function') {
63
- throw new Error('Callback must be a function');
64
- }
65
- for (const [name, port] of this._namedPortsMap.entries()) {
66
- callback(port, name, this.namedPorts);
67
- }
68
- };
69
- } else if (prop === 'length') {
70
- return this._namedPortsMap.size;
71
- } else if (prop === Symbol.iterator) {
72
- return function* () {
73
- for (const [name, port] of this._namedPortsMap.entries()) {
74
- yield [name, port];
75
- }
76
- }.bind(this);
77
- } else if (prop === 'delete') {
78
- return (portName) => {
79
- if (this._namedPortsMap.has(portName)) {
80
- const port = this._namedPortsMap.get(portName);
81
- if (port && typeof port.close === 'function') {
82
- port.close();
83
- }
84
- return this._namedPortsMap.delete(portName);
85
- }
86
- return false;
87
- };
88
- } else if (prop === 'deleteAll') {
89
- return () => {
90
- for (const port of this._namedPortsMap.values()) {
91
- if (port && typeof port.close === 'function') {
92
- port.close();
93
- }
94
- }
95
- this._namedPortsMap.clear();
96
- return true;
97
- };
98
- } else if (typeof prop === 'string') {
99
- return this._namedPortsMap.get(prop);
100
- }
101
- return target[prop];
102
- }
103
- });
104
- }
105
-
106
- /**
107
- * Creates a named MessageChannel
108
- */
109
- createNamedPort(myPortName, theirPortName = myPortName) {
110
- const channel = new MessageChannel();
111
- this._namedPortsMap.set(myPortName, channel.port2);
112
- this.publish('_createNamedPort', { portName: theirPortName, port: channel.port1 }, [channel.port1]);
113
- return channel.port2;
114
- };
115
-
116
- /**
117
- * Forwards every raw message arriving on this connection's MessagePort to
118
- * the target's MessagePort, reconstructing transferList so ArrayBuffers
119
- * stay zero-copy across the hop. Used to build generic passthrough relay
120
- * workers (link in / link out / taps) without knowing the payload shape.
121
- *
122
- * @param {Connection|Connections|Ports} target - destination to pipe into.
123
- * Connection: forward to its single messagePort. Connections/Ports: fan
124
- * out to every contained Connection (only the first gets the transfer;
125
- * remaining recipients receive structured-clone copies since a transferable
126
- * can only be moved once).
127
- */
128
- pipe(target) {
129
- const resolveTargets = () => {
130
- if (target instanceof Connection) return [target];
131
- if (target && typeof target === 'object' && target._connections) {
132
- return Object.values(target._connections);
133
- }
134
- if (target && typeof target === 'object' && target._ports) {
135
- const out = [];
136
- for (const port of Object.values(target._ports)) {
137
- if (port._connections) out.push(...Object.values(port._connections));
138
- }
139
- return out;
140
- }
141
- return [];
142
- };
143
-
144
- this.messagePort.on('message', (message) => {
145
- const targets = resolveTargets();
146
- if (targets.length === 0) return;
147
- const transferables = [...collectTransferables(message)];
148
- for (let i = 0; i < targets.length; i++) {
149
- const t = targets[i];
150
- if (!t.messagePort) continue;
151
- t.messagePort.postMessage(message, i === 0 ? transferables : []);
152
- }
153
- });
154
- }
155
-
156
- /**
157
- * Sets or updates the message port for this connection and establishes message handling
158
- * @param {MessagePort} messagePort - The message port to use for communication
159
- * @returns {void}
160
- *
161
- * @description
162
- * This method assigns a MessagePort to the connection and sets up event listeners
163
- * to handle incoming messages. When a message arrives, it:
164
- * 1. Extracts trace information for the receiver
165
- * 2. Processes the message data and sender trace
166
- * 3. Calls appropriate subscribers at multiple levels (port group, port, and connection)
167
- * 4. Routes the message to the handlers of its type and to the 'default' handlers
168
- *
169
- * If the messagePort parameter is falsy, the method returns early without making changes.
170
- */
171
- setMessagePort(messagePort) {
172
- if (!messagePort) return;
173
- this.messagePort = messagePort;
174
- this.linkId = null;
175
- messagePort.on('message', (message) => {
176
- if (this.messagePort !== messagePort) return; // a port that was replaced or removed
177
- const { baseObject, basePortsGroup, portIndex } = this.trace;
178
- const levels = [baseObject[basePortsGroup], baseObject[basePortsGroup][portIndex], this];
179
-
180
- // Before the worker file runs, keep a message that has no handler yet.
181
- // The runtime sends it again after the worker file adds its subscriptions.
182
- if (!baseObject._ready && typeof message?.type === 'string' &&
183
- !levels.some(level => level._subscribers.has(message.type))) {
184
- baseObject._earlyMessages.push({ connection: this, message });
185
- return;
186
- }
187
- this._dispatch(message);
188
- });
189
- }
190
-
191
- /**
192
- * Calls the subscribers of a message at the port group, port, and connection levels
193
- * @param {object} message - The received message
194
- * @private
195
- */
196
- async _dispatch(message) {
197
- // Other users of the port (e.g. the pubsub proxy) send messages without a type
198
- if (typeof message?.type !== 'string') return;
199
- // A request() from the main thread goes to its worker.handle() handler only
200
- if (message.requestId !== undefined) return this.trace.baseObject._answer(this, message);
201
- try {
202
- const { baseObject, basePortsGroup, portIndex, connectionIndex } = this.trace;
203
- const nodeId = baseObject.nodeId;
204
- const receiverTrace = { nodeId, basePortsGroup, portIndex, connectionIndex };
205
-
206
- const { type, data, senderTrace } = message;
207
- const callHandlers = async (subscriberMap) => {
208
- const handlers = subscriberMap.get(type) || [];
209
- const defaultHandlers = isInternalType(type) ? [] : subscriberMap.get('default') || [];
210
-
211
- for (const handler of [...handlers, ...defaultHandlers]) {
212
- await Promise.resolve(handler(data, senderTrace, receiverTrace, type));
213
- }
214
- };
215
- // Call subscribers at different levels
216
- await callHandlers(baseObject[basePortsGroup]._subscribers);
217
- await callHandlers(baseObject[basePortsGroup][portIndex]._subscribers);
218
- await callHandlers(this._subscribers);
219
- } catch (error) {
220
- console.error('Error handling message:', error);
221
- }
222
- }
223
-
224
- /**
225
- * Subscribes to a message type
226
- * @param {string} type - The message type to subscribe to
227
- * @param {Function} handler - Handler function to call when messages of this type arrive
228
- * @throws {Error} If handler is not a function
229
- */
230
- subscribe(type, handler) {
231
- if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
232
- if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
233
- this._subscribers.get(type).push(handler);
234
- }
235
-
236
- /**
237
- * Unsubscribes a handler from a message type
238
- * @param {string} type - The message type to unsubscribe from
239
- * @param {Function} handler - The handler function to remove
240
- * @returns {boolean} - Whether the handler was successfully removed
241
- */
242
- unsubscribe(type, handler) {
243
- if (!this._subscribers.has(type)) return false;
244
- const handlers = this._subscribers.get(type);
245
- const index = handlers.indexOf(handler);
246
- if (index !== -1) {
247
- handlers.splice(index, 1);
248
- if (handlers.length === 0) {
249
- this._subscribers.delete(type);
250
- }
251
- return true;
252
- }
253
- return false;
254
- }
255
-
256
- /**
257
- * Publishes a message to this connection
258
- * @param {string} type - The message type
259
- * @param {any} data - Data to send
260
- * @param {Array} [transferList=[]] - Optional array of transferable objects
261
- */
262
- publish(type, data, transferList = []) {
263
- if (!this.messagePort) {
264
- // silently fail
265
- } else {
266
- const { basePortsGroup, portIndex, connectionIndex } = this.trace;
267
- const nodeId = this.trace.baseObject.nodeId;
268
- const senderTrace = { nodeId, hostType: 'worker', basePortsGroup, portIndex, connectionIndex };
269
- this.messagePort.postMessage({ type, data, senderTrace }, transferList);
270
- }
271
- }
272
-
273
- /**
274
- * Repeatedly sends a message until an acknowledgment is received
275
- * @param {string} messageTopic - The message type to send
276
- * @param {string} ackTopic - The message type to wait for as acknowledgment
277
- * @param {number} interval - Milliseconds between retries
278
- * @param {any} data - Data to send
279
- * @param {Array} [transferList=[]] - Optional array of transferable objects
280
- * @returns {Function} cancel - Function to call to stop sending. If this topic is already polling, nothing is sent again and the cancel function of that poll is returned.
281
- */
282
- publishUntilAcknowledged(messageTopic, ackTopic, interval, data, transferList = []) {
283
-
284
- if (this._currentPollingTopic === messageTopic) return this._currentPollingCancel; // already polling this topic
285
-
286
- // Cancel any existing message polling
287
- if (this._publishIntervalId) {
288
- clearInterval(this._publishIntervalId);
289
- this._publishIntervalId = null;
290
- if (this._currentPollingTopic && this._currentPollingAckHandler) {
291
- this.unsubscribe(this._currentPollingAckTopic, this._currentPollingAckHandler);
292
- this._currentPollingAckHandler = null;
293
- }
294
- }
295
-
296
- // Store the current polling topic and ack topic
297
- this._currentPollingTopic = messageTopic;
298
- this._currentPollingAckTopic = ackTopic;
299
-
300
- // Publish the message
301
- this.publish(messageTopic, data, transferList); // Send the first message immediately
302
- this._publishIntervalId = setInterval(() => {
303
- this.publish(messageTopic, data, transferList) }, interval);
304
-
305
- // Create and store the acknowledgment handler
306
- this._currentPollingAckHandler = (ackData, senderTrace, receiverTrace) => {
307
- clearInterval(this._publishIntervalId);
308
- this._publishIntervalId = null;
309
- this._currentPollingTopic = null;
310
- this._currentPollingAckTopic = null;
311
- this.unsubscribe(ackTopic, this._currentPollingAckHandler);
312
- this._currentPollingAckHandler = null;
313
- };
314
-
315
- // Subscribe to the acknowledgment topic
316
- this.subscribe(ackTopic, this._currentPollingAckHandler);
317
-
318
- // Return a cancel function
319
- this._currentPollingCancel = () => {
320
- if (this._publishIntervalId) {
321
- clearInterval(this._publishIntervalId);
322
- this._publishIntervalId = null;
323
- }
324
-
325
- if (this._currentPollingAckHandler) {
326
- this.unsubscribe(ackTopic, this._currentPollingAckHandler);
327
- this._currentPollingAckHandler = null;
328
- }
329
-
330
- this._currentPollingTopic = null;
331
- this._currentPollingAckTopic = null;
332
- };
333
- return this._currentPollingCancel;
334
- }
335
-
336
- }
337
-
338
- /**
339
- * Manages multiple connections for a port
340
- * @extends {Object}
341
- */
342
- class Connections extends Object {
343
- /**
344
- * Creates a new Connections instance
345
- * @param {Ports} ports - Parent Ports instance
346
- * @returns {Proxy} A proxy that auto-creates connections on property access
347
- */
348
- constructor(trace) {
349
- super();
350
- this.trace = trace;
351
- this._subscribers = new Map();
352
- this._connections = {};
353
-
354
- Object.defineProperty(this, 'length', {
355
- get: () =>
356
- Object.keys(this._connections).length
357
- });
358
-
359
- // Make the class iterable
360
- this[Symbol.iterator] = function* () {
361
- for (const key of Object.keys(this._connections).sort((a, b) => Number(a) - Number(b))) {
362
- yield this._connections[key];
363
- }
364
- };
365
-
366
- return new Proxy(this, {
367
- get(target, prop) {
368
- if (typeof prop === 'string' && !isNaN(prop) && !(prop in target)) {
369
- target.addConnection(Number(prop));
370
- }
371
- return target[prop];
372
- }
373
- });
374
- }
375
-
376
- forEach(callback) {
377
- if (typeof callback !== 'function') { throw new Error('Callback must be a function') };
378
-
379
- for (const [index, connection] of Object.entries(this._connections)) {
380
- callback(connection, Number(index), this);
381
- }
382
- }
383
-
384
- addConnection(connectionIndex, messagePort) {
385
- if (this._connections[connectionIndex]) { // If the connection already exists but has no messagePort, just set the port
386
- if (messagePort) { this._connections[connectionIndex].setMessagePort(messagePort) };
387
- } else { // Otherwise create a new connection
388
- this[connectionIndex] = new Connection(messagePort, { ...this.trace, connectionIndex });
389
- this._connections[connectionIndex] = this[connectionIndex];
390
- }
391
- }
392
-
393
- hasConnection(connectionIndex) {
394
- return connectionIndex in this._connections;
395
- }
396
-
397
- /**
398
- * Subscribes to a message type for all connections in this group
399
- * @param {string} type - The message type to subscribe to
400
- * @param {Function} handler - Handler function to call when messages of this type arrive
401
- * @throws {Error} If handler is not a function
402
- */
403
- subscribe(type, handler) {
404
- if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
405
- if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
406
- this._subscribers.get(type).push(handler);
407
- }
408
-
409
- /**
410
- * Publishes a message to all connections in this group
411
- * @param {string} type - The message type
412
- * @param {any} data - Data to send
413
- * @param {Array} [transferList=[]] - Optional array of transferable objects
414
- */
415
- publish(type, data, transferList = []) {
416
- Object.values(this._connections).forEach(connection => {
417
- connection.publish(type, data, transferList);
418
- });
419
- }
420
-
421
- /**
422
- * Closes the connection with this linkId, if this port has it, and frees its slot
423
- * @returns {boolean} true if the connection was found
424
- */
425
- removeLink(linkId) {
426
- for (const connection of Object.values(this._connections)) {
427
- if (connection.linkId !== linkId) continue;
428
- connection.messagePort.close();
429
- connection.messagePort = null;
430
- connection.linkId = null;
431
- return true;
432
- }
433
- return false;
434
- }
435
-
436
- getNextConnectionNumber() {
437
- // First look for existing connections without messagePorts
438
- for (const [key, connection] of Object.entries(this._connections)) {
439
- if (!connection.messagePort) {
440
- return Number(key);
441
- }
442
- }
443
- // If no pending connections found, get next available number
444
- const numericKeys = Object.keys(this._connections)
445
- .filter(key => !isNaN(key))
446
- .map(Number);
447
- return numericKeys.length ? Math.max(...numericKeys) + 1 : 0;
448
- }
449
-
450
- }
451
-
452
- /**
453
- * Manages multiple ports for a base ports group
454
- * @extends {Object}
455
- */
456
- class Ports extends Object {
457
- /**
458
- * Creates a new Ports instance
459
- * @param {NodeRedWorkerRuntime} basePortsGroup - Parent NodeRedWorkerRuntime instance
460
- * @returns {Proxy} A proxy that auto-creates ports on property access
461
- */
462
- constructor(trace) {
463
- super()
464
- this.trace = trace;
465
- this._subscribers = new Map();
466
- this._ports = {};
467
-
468
- Object.defineProperty(this, 'length', {
469
- get: () =>
470
- Object.keys(this._ports).length
471
- });
472
-
473
- this[Symbol.iterator] = function* () {
474
- for (const key of Object.keys(this._ports).sort((a, b) => Number(a) - Number(b))) {
475
- yield this._ports[key];
476
- }
477
- };
478
-
479
- return new Proxy(this, {
480
- get(target, prop) {
481
- if (typeof prop === 'string' && !isNaN(prop) && !(prop in target)) {
482
- target.addPort(Number(prop));
483
- }
484
- return target[prop];
485
- }
486
- });
487
- }
488
-
489
- forEach(callback) {
490
- if (typeof callback !== 'function') {
491
- throw new Error('Callback must be a function');
492
- }
493
-
494
- for (const [index, port] of Object.entries(this._ports)) {
495
- callback(port, Number(index), this);
496
- }
497
- }
498
-
499
- addPort(portIndex) {
500
- this[portIndex] = new Connections({ ...this.trace, portIndex });
501
- this._ports[portIndex] = this[portIndex];
502
- }
503
-
504
- hasPort(portIndex) {
505
- return portIndex in this._ports;
506
- }
507
-
508
- /**
509
- * Subscribes to a message type for all ports in this group
510
- * @param {string} type - The message type to subscribe to
511
- * @param {Function} handler - Handler function to call when messages of this type arrive
512
- * @throws {Error} If handler is not a function
513
- */
514
- subscribe(type, handler) {
515
- if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
516
- if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
517
- this._subscribers.get(type).push(handler);
518
- }
519
-
520
- /**
521
- * Publishes a message to all ports in this group
522
- * @param {string} type - The message type
523
- * @param {any} data - Data to send
524
- * @param {Array} [transferList=[]] - Optional array of transferable objects
525
- */
526
- publish(type, data, transferList = []) {
527
- Object.values(this._ports).forEach(port => {
528
- port.publish(type, data, transferList);
529
- });
530
- }
531
-
532
- }
533
-
534
- /**
535
- * Pub/Sub message communication between worker threads and the main thread in Node-RED
536
- */
537
- class NodeRedWorkerRuntime {
538
- /**
539
- * Creates a new NodeRedWorkerRuntime instance
540
- * @param {string} workerPath - Path to the worker directory
541
- * @param {Function} [ConnectionsCompleteCallback=null] - Callback function executed when all connections are established
542
- */
543
- constructor(params) {
544
- // list all params here
545
- this._connectionsCompleteCallback = params.ConnectionsCompleteCallback;
546
-
547
- this.nodeId = null;
548
- this._started = false; // true after the worker file was loaded
549
- this._ready = false; // true after the worker file added its subscriptions
550
- this._earlyMessages = []; // messages that arrived before _ready, in order
551
- this._handlers = new Map(); // request type -> handler of worker.handle()
552
- this.inputPorts = new Ports({ baseObject: this, basePortsGroup: 'inputPorts' });
553
- this.outputPorts = new Ports({ baseObject: this, basePortsGroup: 'outputPorts' });
554
- this.parentPorts = new Ports({ baseObject: this, basePortsGroup: 'parentPorts' });
555
- this.parentPorts[0][0].setMessagePort(parentPort); // attach the standard worker parentPort to our parentPorts object
556
- this._setupStandardSubscriptions();
557
- }
558
-
559
- /**
560
- * Answers the main-thread request() calls of one type.
561
- * The handler gets (data, transferList) and returns the answer or a Promise for it.
562
- * Push ArrayBuffers of the answer into transferList to move them instead of copying them.
563
- * If the handler throws, request() rejects with an Error that has the same name and message.
564
- * @param {string} type - The request type
565
- * @param {Function} handler - (data, transferList) => answer
566
- * @throws {Error} If handler is not a function, or the type has a handler already
567
- */
568
- handle(type, handler) {
569
- if (typeof handler !== 'function') throw new Error('Handler must be a function');
570
- if (this._handlers.has(type)) throw new Error(`worker.handle(): type '${type}' has a handler already`);
571
- this._handlers.set(type, handler);
572
- }
573
-
574
- /**
575
- * Calls the handler of a request and sends the answer back on the same connection.
576
- * @private
577
- */
578
- async _answer(connection, { type, data, requestId }) {
579
- const reply = (body, transferList = []) =>
580
- connection.messagePort.postMessage({ type: '_reply', data: { requestId, ...body } }, transferList);
581
- try {
582
- const handler = this._handlers.get(type);
583
- if (!handler) throw new Error(`the worker has no worker.handle('${type}', ...) handler`);
584
- const transferList = [];
585
- const result = await handler(data, transferList);
586
- reply({ result }, transferList);
587
- } catch (error) {
588
- // For example a throw in the handler, or an answer that cannot be cloned
589
- reply({ error: { name: (error && error.name) || 'Error', message: (error && error.message) || String(error) } });
590
- }
591
- }
592
-
593
- _setupStandardSubscriptions() {
594
-
595
- // Handle input port addition
596
- this.parentPorts.subscribe('_addInputConnection', ({ nodeId, linkId, messagePort }, senderTrace, receiverTrace) => {
597
- const myConnectionIndex = this.inputPorts[0].getNextConnectionNumber();
598
- this.nodeId = nodeId;
599
- this.inputPorts[0][myConnectionIndex].setMessagePort(messagePort);
600
- this.inputPorts[0][myConnectionIndex].linkId = linkId;
601
- this.parentPorts.publish('ack', '_inputPortReceived');
602
- });
603
-
604
- // Handle output port addition
605
- this.parentPorts.subscribe('_addOutputConnection', ({ nodeId, portIndex, connectionIndex, linkId, messagePort }, senderTrace, receiverTrace) => {
606
- this.nodeId = nodeId;
607
- this.outputPorts[portIndex][connectionIndex].setMessagePort(messagePort);
608
- this.outputPorts[portIndex][connectionIndex].linkId = linkId;
609
- this.parentPorts.publish('ack', '_outputPortReceived');
610
- });
611
-
612
- // The worker at the other end of a wire closed: close this end and free the slot
613
- this.parentPorts.subscribe('_removeConnection', ({ linkId }) => {
614
- for (const group of [this.inputPorts, this.outputPorts]) {
615
- for (const port of Object.values(group._ports)) {
616
- if (port.removeLink(linkId)) return;
617
- }
618
- }
619
- });
620
-
621
- // Handle _createNamedPorts from other workers
622
- this.inputPorts.subscribe('_createNamedPort', ({ portName, port }, senderTrace, receiverTrace) => {
623
- const connection = this.inputPorts[receiverTrace.portIndex][receiverTrace.connectionIndex];
624
- if (connection._namedPortsMap.has(portName)) throw new Error(`_createNamedPort error: Named port '${portName}' already exists.`);
625
- connection._namedPortsMap.set(portName, port);
626
- });
627
-
628
- // Handle connections complete notification
629
- // Load the worker file one time only, then send the early messages again
630
- this.parentPorts.subscribe('_ConnectionsComplete', async (data, senderTrace, receiverTrace) => {
631
- if (this._started) return;
632
- this._started = true;
633
- if (typeof this._connectionsCompleteCallback === 'function') {
634
- await this._connectionsCompleteCallback();
635
- }
636
- this._ready = true;
637
- const early = this._earlyMessages;
638
- this._earlyMessages = [];
639
- for (const { connection, message } of early) connection._dispatch(message);
640
- });
641
-
642
- // Built-in close ack — fires at connection level (after user-level 'close' handlers)
643
- // so gpu.close() and other cleanup runs first, then we ack the main thread
644
- this.parentPorts[0][0].subscribe('close', () => {
645
- this.parentPorts.publish('ack', 'close');
646
- });
647
-
648
- }
649
- }
650
-
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
+ //
8
+ // This class is created in the worker thread
9
+ //
10
+
11
+ 'use strict';
12
+ const { parentPort, MessagePort } = require('worker_threads');
13
+
14
+ // Types that the library uses itself. 'default' handlers do not get them.
15
+ const isInternalType = (type) => type === 'ack' || type.startsWith('_');
16
+
17
+ /**
18
+ * Walks a value and collects every unique ArrayBuffer / MessagePort found.
19
+ * Used by Connection.pipe() so forwarded messages preserve zero-copy transfer
20
+ * semantics across relay hops without the relay having to know the payload shape.
21
+ */
22
+ function collectTransferables(obj, acc = new Set(), seen = new WeakSet()) {
23
+ if (!obj || typeof obj !== 'object') return acc;
24
+ if (seen.has(obj)) return acc;
25
+ seen.add(obj);
26
+ if (obj instanceof ArrayBuffer) { acc.add(obj); return acc; }
27
+ if (ArrayBuffer.isView(obj)) { acc.add(obj.buffer); return acc; }
28
+ if (obj instanceof MessagePort) { acc.add(obj); return acc; }
29
+ for (const k in obj) collectTransferables(obj[k], acc, seen);
30
+ return acc;
31
+ }
32
+
33
+ /**
34
+ * Represents a connection between nodes with pub/sub capabilities
35
+ * @extends {Object}
36
+ */
37
+ class Connection extends Object {
38
+ /**
39
+ * Creates a new Connection
40
+ * @param {MessagePort} messagePort - MessagePort for communication
41
+ * @param {Connections} connections - Parent Connections instance
42
+ */
43
+ constructor(messagePort, trace) {
44
+ super();
45
+ this.trace = trace;
46
+ this._intervalID = null; // used in publishUntilAcknowledged
47
+ this._subscribers = new Map();
48
+ this.messagePort = messagePort;
49
+ this.setMessagePort(messagePort);
50
+ this._currentPollingTopic = null;
51
+ this._currentPollingAckTopic = null;
52
+ this._currentPollingAckHandler = null;
53
+ this._currentPollingCancel = null;
54
+ Object.defineProperty(this, 'length', { value: 1, writable: false, configurable: false });
55
+
56
+ // namedPorts setup
57
+ this._namedPortsMap = new Map();
58
+ this.namedPorts = new Proxy({}, {
59
+ get: (target, prop) => {
60
+ if (prop === 'forEach') {
61
+ return (callback) => {
62
+ if (typeof callback !== 'function') {
63
+ throw new Error('Callback must be a function');
64
+ }
65
+ for (const [name, port] of this._namedPortsMap.entries()) {
66
+ callback(port, name, this.namedPorts);
67
+ }
68
+ };
69
+ } else if (prop === 'length') {
70
+ return this._namedPortsMap.size;
71
+ } else if (prop === Symbol.iterator) {
72
+ return function* () {
73
+ for (const [name, port] of this._namedPortsMap.entries()) {
74
+ yield [name, port];
75
+ }
76
+ }.bind(this);
77
+ } else if (prop === 'delete') {
78
+ return (portName) => {
79
+ if (this._namedPortsMap.has(portName)) {
80
+ const port = this._namedPortsMap.get(portName);
81
+ if (port && typeof port.close === 'function') {
82
+ port.close();
83
+ }
84
+ return this._namedPortsMap.delete(portName);
85
+ }
86
+ return false;
87
+ };
88
+ } else if (prop === 'deleteAll') {
89
+ return () => {
90
+ for (const port of this._namedPortsMap.values()) {
91
+ if (port && typeof port.close === 'function') {
92
+ port.close();
93
+ }
94
+ }
95
+ this._namedPortsMap.clear();
96
+ return true;
97
+ };
98
+ } else if (typeof prop === 'string') {
99
+ return this._namedPortsMap.get(prop);
100
+ }
101
+ return target[prop];
102
+ }
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Creates a named MessageChannel
108
+ */
109
+ createNamedPort(myPortName, theirPortName = myPortName) {
110
+ const channel = new MessageChannel();
111
+ this._namedPortsMap.set(myPortName, channel.port2);
112
+ this.publish('_createNamedPort', { portName: theirPortName, port: channel.port1 }, [channel.port1]);
113
+ return channel.port2;
114
+ };
115
+
116
+ /**
117
+ * Forwards every raw message arriving on this connection's MessagePort to
118
+ * the target's MessagePort, reconstructing transferList so ArrayBuffers
119
+ * stay zero-copy across the hop. Used to build generic passthrough relay
120
+ * workers (link in / link out / taps) without knowing the payload shape.
121
+ *
122
+ * @param {Connection|Connections|Ports} target - destination to pipe into.
123
+ * Connection: forward to its single messagePort. Connections/Ports: fan
124
+ * out to every contained Connection (only the first gets the transfer;
125
+ * remaining recipients receive structured-clone copies since a transferable
126
+ * can only be moved once).
127
+ */
128
+ pipe(target) {
129
+ const resolveTargets = () => {
130
+ if (target instanceof Connection) return [target];
131
+ if (target && typeof target === 'object' && target._connections) {
132
+ return Object.values(target._connections);
133
+ }
134
+ if (target && typeof target === 'object' && target._ports) {
135
+ const out = [];
136
+ for (const port of Object.values(target._ports)) {
137
+ if (port._connections) out.push(...Object.values(port._connections));
138
+ }
139
+ return out;
140
+ }
141
+ return [];
142
+ };
143
+
144
+ this.messagePort.on('message', (message) => {
145
+ const targets = resolveTargets();
146
+ if (targets.length === 0) return;
147
+ const transferables = [...collectTransferables(message)];
148
+ for (let i = 0; i < targets.length; i++) {
149
+ const t = targets[i];
150
+ if (!t.messagePort) continue;
151
+ t.messagePort.postMessage(message, i === 0 ? transferables : []);
152
+ }
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Sets or updates the message port for this connection and establishes message handling
158
+ * @param {MessagePort} messagePort - The message port to use for communication
159
+ * @returns {void}
160
+ *
161
+ * @description
162
+ * This method assigns a MessagePort to the connection and sets up event listeners
163
+ * to handle incoming messages. When a message arrives, it:
164
+ * 1. Extracts trace information for the receiver
165
+ * 2. Processes the message data and sender trace
166
+ * 3. Calls appropriate subscribers at multiple levels (port group, port, and connection)
167
+ * 4. Routes the message to the handlers of its type and to the 'default' handlers
168
+ *
169
+ * If the messagePort parameter is falsy, the method returns early without making changes.
170
+ */
171
+ setMessagePort(messagePort) {
172
+ if (!messagePort) return;
173
+ this.messagePort = messagePort;
174
+ this.linkId = null;
175
+ messagePort.on('message', (message) => {
176
+ if (this.messagePort !== messagePort) return; // a port that was replaced or removed
177
+ const { baseObject, basePortsGroup, portIndex } = this.trace;
178
+ const levels = [baseObject[basePortsGroup], baseObject[basePortsGroup][portIndex], this];
179
+
180
+ // Before the worker file runs, keep a message that has no handler yet.
181
+ // The runtime sends it again after the worker file adds its subscriptions.
182
+ if (!baseObject._ready && typeof message?.type === 'string' &&
183
+ !levels.some(level => level._subscribers.has(message.type))) {
184
+ baseObject._earlyMessages.push({ connection: this, message });
185
+ return;
186
+ }
187
+ this._dispatch(message);
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Calls the subscribers of a message at the port group, port, and connection levels
193
+ * @param {object} message - The received message
194
+ * @private
195
+ */
196
+ async _dispatch(message) {
197
+ // Other users of the port (e.g. the pubsub proxy) send messages without a type
198
+ if (typeof message?.type !== 'string') return;
199
+ const { baseObject, basePortsGroup, portIndex, connectionIndex } = this.trace;
200
+ // A request() from the main thread goes to its worker.handle() handler only
201
+ if (message.requestId !== undefined) return baseObject._answer(this, message);
202
+ const receiverTrace = { nodeId: baseObject.nodeId, basePortsGroup, portIndex, connectionIndex };
203
+ const { type, data, senderTrace } = message;
204
+ // The handlers at the port group, port and connection levels, in this order.
205
+ // A handler that fails goes to worker.handlerError(); the next handlers still run.
206
+ for (const subscriberMap of [baseObject[basePortsGroup]._subscribers,
207
+ baseObject[basePortsGroup][portIndex]._subscribers, this._subscribers]) {
208
+ const handlers = subscriberMap.get(type) || [];
209
+ const defaultHandlers = isInternalType(type) ? [] : subscriberMap.get('default') || [];
210
+ for (const handler of [...handlers, ...defaultHandlers]) {
211
+ try {
212
+ await handler(data, senderTrace, receiverTrace, type);
213
+ } catch (error) {
214
+ baseObject.handlerError(error, type, data);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Subscribes to a message type
222
+ * @param {string} type - The message type to subscribe to
223
+ * @param {Function} handler - Handler function to call when messages of this type arrive
224
+ * @throws {Error} If handler is not a function
225
+ */
226
+ subscribe(type, handler) {
227
+ if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
228
+ if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
229
+ this._subscribers.get(type).push(handler);
230
+ }
231
+
232
+ /**
233
+ * Unsubscribes a handler from a message type
234
+ * @param {string} type - The message type to unsubscribe from
235
+ * @param {Function} handler - The handler function to remove
236
+ * @returns {boolean} - Whether the handler was successfully removed
237
+ */
238
+ unsubscribe(type, handler) {
239
+ if (!this._subscribers.has(type)) return false;
240
+ const handlers = this._subscribers.get(type);
241
+ const index = handlers.indexOf(handler);
242
+ if (index !== -1) {
243
+ handlers.splice(index, 1);
244
+ if (handlers.length === 0) {
245
+ this._subscribers.delete(type);
246
+ }
247
+ return true;
248
+ }
249
+ return false;
250
+ }
251
+
252
+ /**
253
+ * Publishes a message to this connection
254
+ * @param {string} type - The message type
255
+ * @param {any} data - Data to send
256
+ * @param {Array} [transferList=[]] - Optional array of transferable objects
257
+ */
258
+ publish(type, data, transferList = []) {
259
+ if (!this.messagePort) {
260
+ // silently fail
261
+ } else {
262
+ const { basePortsGroup, portIndex, connectionIndex } = this.trace;
263
+ const nodeId = this.trace.baseObject.nodeId;
264
+ const senderTrace = { nodeId, hostType: 'worker', basePortsGroup, portIndex, connectionIndex };
265
+ this.messagePort.postMessage({ type, data, senderTrace }, transferList);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Repeatedly sends a message until an acknowledgment is received
271
+ * @param {string} messageTopic - The message type to send
272
+ * @param {string} ackTopic - The message type to wait for as acknowledgment
273
+ * @param {number} interval - Milliseconds between retries
274
+ * @param {any} data - Data to send
275
+ * @param {Array} [transferList=[]] - Optional array of transferable objects
276
+ * @returns {Function} cancel - Function to call to stop sending. If this topic is already polling, nothing is sent again and the cancel function of that poll is returned.
277
+ */
278
+ publishUntilAcknowledged(messageTopic, ackTopic, interval, data, transferList = []) {
279
+
280
+ if (this._currentPollingTopic === messageTopic) return this._currentPollingCancel; // already polling this topic
281
+
282
+ // Cancel any existing message polling
283
+ if (this._publishIntervalId) {
284
+ clearInterval(this._publishIntervalId);
285
+ this._publishIntervalId = null;
286
+ if (this._currentPollingTopic && this._currentPollingAckHandler) {
287
+ this.unsubscribe(this._currentPollingAckTopic, this._currentPollingAckHandler);
288
+ this._currentPollingAckHandler = null;
289
+ }
290
+ }
291
+
292
+ // Store the current polling topic and ack topic
293
+ this._currentPollingTopic = messageTopic;
294
+ this._currentPollingAckTopic = ackTopic;
295
+
296
+ // Publish the message
297
+ this.publish(messageTopic, data, transferList); // Send the first message immediately
298
+ this._publishIntervalId = setInterval(() => {
299
+ this.publish(messageTopic, data, transferList) }, interval);
300
+
301
+ // Create and store the acknowledgment handler
302
+ this._currentPollingAckHandler = (ackData, senderTrace, receiverTrace) => {
303
+ clearInterval(this._publishIntervalId);
304
+ this._publishIntervalId = null;
305
+ this._currentPollingTopic = null;
306
+ this._currentPollingAckTopic = null;
307
+ this.unsubscribe(ackTopic, this._currentPollingAckHandler);
308
+ this._currentPollingAckHandler = null;
309
+ };
310
+
311
+ // Subscribe to the acknowledgment topic
312
+ this.subscribe(ackTopic, this._currentPollingAckHandler);
313
+
314
+ // Return a cancel function
315
+ this._currentPollingCancel = () => {
316
+ if (this._publishIntervalId) {
317
+ clearInterval(this._publishIntervalId);
318
+ this._publishIntervalId = null;
319
+ }
320
+
321
+ if (this._currentPollingAckHandler) {
322
+ this.unsubscribe(ackTopic, this._currentPollingAckHandler);
323
+ this._currentPollingAckHandler = null;
324
+ }
325
+
326
+ this._currentPollingTopic = null;
327
+ this._currentPollingAckTopic = null;
328
+ };
329
+ return this._currentPollingCancel;
330
+ }
331
+
332
+ }
333
+
334
+ /**
335
+ * Manages multiple connections for a port
336
+ * @extends {Object}
337
+ */
338
+ class Connections extends Object {
339
+ /**
340
+ * Creates a new Connections instance
341
+ * @param {Ports} ports - Parent Ports instance
342
+ * @returns {Proxy} A proxy that auto-creates connections on property access
343
+ */
344
+ constructor(trace) {
345
+ super();
346
+ this.trace = trace;
347
+ this._subscribers = new Map();
348
+ this._connections = {};
349
+
350
+ Object.defineProperty(this, 'length', {
351
+ get: () =>
352
+ Object.keys(this._connections).length
353
+ });
354
+
355
+ // Make the class iterable
356
+ this[Symbol.iterator] = function* () {
357
+ for (const key of Object.keys(this._connections).sort((a, b) => Number(a) - Number(b))) {
358
+ yield this._connections[key];
359
+ }
360
+ };
361
+
362
+ return new Proxy(this, {
363
+ get(target, prop) {
364
+ if (typeof prop === 'string' && !isNaN(prop) && !(prop in target)) {
365
+ target.addConnection(Number(prop));
366
+ }
367
+ return target[prop];
368
+ }
369
+ });
370
+ }
371
+
372
+ forEach(callback) {
373
+ if (typeof callback !== 'function') { throw new Error('Callback must be a function') };
374
+
375
+ for (const [index, connection] of Object.entries(this._connections)) {
376
+ callback(connection, Number(index), this);
377
+ }
378
+ }
379
+
380
+ addConnection(connectionIndex, messagePort) {
381
+ if (this._connections[connectionIndex]) { // If the connection already exists but has no messagePort, just set the port
382
+ if (messagePort) { this._connections[connectionIndex].setMessagePort(messagePort) };
383
+ } else { // Otherwise create a new connection
384
+ this[connectionIndex] = new Connection(messagePort, { ...this.trace, connectionIndex });
385
+ this._connections[connectionIndex] = this[connectionIndex];
386
+ }
387
+ }
388
+
389
+ hasConnection(connectionIndex) {
390
+ return connectionIndex in this._connections;
391
+ }
392
+
393
+ /**
394
+ * Subscribes to a message type for all connections in this group
395
+ * @param {string} type - The message type to subscribe to
396
+ * @param {Function} handler - Handler function to call when messages of this type arrive
397
+ * @throws {Error} If handler is not a function
398
+ */
399
+ subscribe(type, handler) {
400
+ if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
401
+ if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
402
+ this._subscribers.get(type).push(handler);
403
+ }
404
+
405
+ /**
406
+ * Publishes a message to all connections in this group
407
+ * @param {string} type - The message type
408
+ * @param {any} data - Data to send
409
+ * @param {Array} [transferList=[]] - Optional array of transferable objects
410
+ */
411
+ publish(type, data, transferList = []) {
412
+ Object.values(this._connections).forEach(connection => {
413
+ connection.publish(type, data, transferList);
414
+ });
415
+ }
416
+
417
+ /**
418
+ * Closes the connection with this linkId, if this port has it, and frees its slot
419
+ * @returns {boolean} true if the connection was found
420
+ */
421
+ removeLink(linkId) {
422
+ for (const connection of Object.values(this._connections)) {
423
+ if (connection.linkId !== linkId) continue;
424
+ connection.messagePort.close();
425
+ connection.messagePort = null;
426
+ connection.linkId = null;
427
+ return true;
428
+ }
429
+ return false;
430
+ }
431
+
432
+ getNextConnectionNumber() {
433
+ // First look for existing connections without messagePorts
434
+ for (const [key, connection] of Object.entries(this._connections)) {
435
+ if (!connection.messagePort) {
436
+ return Number(key);
437
+ }
438
+ }
439
+ // If no pending connections found, get next available number
440
+ const numericKeys = Object.keys(this._connections)
441
+ .filter(key => !isNaN(key))
442
+ .map(Number);
443
+ return numericKeys.length ? Math.max(...numericKeys) + 1 : 0;
444
+ }
445
+
446
+ }
447
+
448
+ /**
449
+ * Manages multiple ports for a base ports group
450
+ * @extends {Object}
451
+ */
452
+ class Ports extends Object {
453
+ /**
454
+ * Creates a new Ports instance
455
+ * @param {NodeRedWorkerRuntime} basePortsGroup - Parent NodeRedWorkerRuntime instance
456
+ * @returns {Proxy} A proxy that auto-creates ports on property access
457
+ */
458
+ constructor(trace) {
459
+ super()
460
+ this.trace = trace;
461
+ this._subscribers = new Map();
462
+ this._ports = {};
463
+
464
+ Object.defineProperty(this, 'length', {
465
+ get: () =>
466
+ Object.keys(this._ports).length
467
+ });
468
+
469
+ this[Symbol.iterator] = function* () {
470
+ for (const key of Object.keys(this._ports).sort((a, b) => Number(a) - Number(b))) {
471
+ yield this._ports[key];
472
+ }
473
+ };
474
+
475
+ return new Proxy(this, {
476
+ get(target, prop) {
477
+ if (typeof prop === 'string' && !isNaN(prop) && !(prop in target)) {
478
+ target.addPort(Number(prop));
479
+ }
480
+ return target[prop];
481
+ }
482
+ });
483
+ }
484
+
485
+ forEach(callback) {
486
+ if (typeof callback !== 'function') {
487
+ throw new Error('Callback must be a function');
488
+ }
489
+
490
+ for (const [index, port] of Object.entries(this._ports)) {
491
+ callback(port, Number(index), this);
492
+ }
493
+ }
494
+
495
+ addPort(portIndex) {
496
+ this[portIndex] = new Connections({ ...this.trace, portIndex });
497
+ this._ports[portIndex] = this[portIndex];
498
+ }
499
+
500
+ hasPort(portIndex) {
501
+ return portIndex in this._ports;
502
+ }
503
+
504
+ /**
505
+ * Subscribes to a message type for all ports in this group
506
+ * @param {string} type - The message type to subscribe to
507
+ * @param {Function} handler - Handler function to call when messages of this type arrive
508
+ * @throws {Error} If handler is not a function
509
+ */
510
+ subscribe(type, handler) {
511
+ if (typeof handler !== 'function') { throw new Error('Handler must be a function') };
512
+ if (!this._subscribers.has(type)) { this._subscribers.set(type, []) };
513
+ this._subscribers.get(type).push(handler);
514
+ }
515
+
516
+ /**
517
+ * Publishes a message to all ports in this group
518
+ * @param {string} type - The message type
519
+ * @param {any} data - Data to send
520
+ * @param {Array} [transferList=[]] - Optional array of transferable objects
521
+ */
522
+ publish(type, data, transferList = []) {
523
+ Object.values(this._ports).forEach(port => {
524
+ port.publish(type, data, transferList);
525
+ });
526
+ }
527
+
528
+ }
529
+
530
+ /**
531
+ * Pub/Sub message communication between worker threads and the main thread in Node-RED
532
+ */
533
+ class NodeRedWorkerRuntime {
534
+ /**
535
+ * Creates a new NodeRedWorkerRuntime instance
536
+ * @param {string} workerPath - Path to the worker directory
537
+ * @param {Function} [ConnectionsCompleteCallback=null] - Callback function executed when all connections are established
538
+ */
539
+ constructor(params) {
540
+ // list all params here
541
+ this._connectionsCompleteCallback = params.ConnectionsCompleteCallback;
542
+
543
+ this.nodeId = null;
544
+ this._started = false; // true after the worker file was loaded
545
+ this._ready = false; // true after the worker file added its subscriptions
546
+ this._earlyMessages = []; // messages that arrived before _ready, in order
547
+ this._handlers = new Map(); // request type -> handler of worker.handle()
548
+ this.inputPorts = new Ports({ baseObject: this, basePortsGroup: 'inputPorts' });
549
+ this.outputPorts = new Ports({ baseObject: this, basePortsGroup: 'outputPorts' });
550
+ this.parentPorts = new Ports({ baseObject: this, basePortsGroup: 'parentPorts' });
551
+ this.parentPorts[0][0].setMessagePort(parentPort); // attach the standard worker parentPort to our parentPorts object
552
+ this._setupStandardSubscriptions();
553
+ }
554
+
555
+ /**
556
+ * Answers the main-thread request() calls of one type.
557
+ * The handler gets (data, transferList) and returns the answer or a Promise for it.
558
+ * Push ArrayBuffers of the answer into transferList to move them instead of copying them.
559
+ * If the handler throws, request() rejects with an Error that has the same name and message.
560
+ * @param {string} type - The request type
561
+ * @param {Function} handler - (data, transferList) => answer
562
+ * @throws {Error} If handler is not a function, or the type has a handler already
563
+ */
564
+ handle(type, handler) {
565
+ if (typeof handler !== 'function') throw new Error('Handler must be a function');
566
+ if (this._handlers.has(type)) throw new Error(`worker.handle(): type '${type}' has a handler already`);
567
+ this._handlers.set(type, handler);
568
+ }
569
+
570
+ /**
571
+ * Calls the handler of a request and sends the answer back on the same connection.
572
+ * @private
573
+ */
574
+ async _answer(connection, { type, data, requestId }) {
575
+ const reply = (body, transferList = []) =>
576
+ connection.messagePort.postMessage({ type: '_reply', data: { requestId, ...body } }, transferList);
577
+ try {
578
+ const handler = this._handlers.get(type);
579
+ if (!handler) throw new Error(`the worker has no worker.handle('${type}', ...) handler`);
580
+ const transferList = [];
581
+ const result = await handler(data, transferList);
582
+ reply({ result }, transferList);
583
+ } catch (error) {
584
+ // For example a throw in the handler, or an answer that cannot be cloned
585
+ reply({ error: { name: (error && error.name) || 'Error', message: (error && error.message) || String(error) } });
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Reports a throw (or a rejected Promise) of a subscribe handler in the worker.
591
+ * The default sends it to the main thread, where the node logs it with node.error.
592
+ * A worker file can replace it: worker.handlerError = (error, type, data) => { ... }.
593
+ * @param {*} error - The value that the handler threw
594
+ * @param {string} type - The message type of the handler
595
+ * @param {*} data - The data of the message that the handler got
596
+ */
597
+ handlerError(error, type, data) {
598
+ this.parentPorts.publish('_handlerError', { type, message: (error && error.message) || String(error) });
599
+ }
600
+
601
+ _setupStandardSubscriptions() {
602
+
603
+ // Handle input port addition
604
+ this.parentPorts.subscribe('_addInputConnection', ({ nodeId, linkId, messagePort }, senderTrace, receiverTrace) => {
605
+ const myConnectionIndex = this.inputPorts[0].getNextConnectionNumber();
606
+ this.nodeId = nodeId;
607
+ this.inputPorts[0][myConnectionIndex].setMessagePort(messagePort);
608
+ this.inputPorts[0][myConnectionIndex].linkId = linkId;
609
+ this.parentPorts.publish('ack', '_inputPortReceived');
610
+ });
611
+
612
+ // Handle output port addition
613
+ this.parentPorts.subscribe('_addOutputConnection', ({ nodeId, portIndex, connectionIndex, linkId, messagePort }, senderTrace, receiverTrace) => {
614
+ this.nodeId = nodeId;
615
+ this.outputPorts[portIndex][connectionIndex].setMessagePort(messagePort);
616
+ this.outputPorts[portIndex][connectionIndex].linkId = linkId;
617
+ this.parentPorts.publish('ack', '_outputPortReceived');
618
+ });
619
+
620
+ // The worker at the other end of a wire closed: close this end and free the slot
621
+ this.parentPorts.subscribe('_removeConnection', ({ linkId }) => {
622
+ for (const group of [this.inputPorts, this.outputPorts]) {
623
+ for (const port of Object.values(group._ports)) {
624
+ if (port.removeLink(linkId)) return;
625
+ }
626
+ }
627
+ });
628
+
629
+ // Handle _createNamedPorts from other workers
630
+ this.inputPorts.subscribe('_createNamedPort', ({ portName, port }, senderTrace, receiverTrace) => {
631
+ const connection = this.inputPorts[receiverTrace.portIndex][receiverTrace.connectionIndex];
632
+ if (connection._namedPortsMap.has(portName)) throw new Error(`_createNamedPort error: Named port '${portName}' already exists.`);
633
+ connection._namedPortsMap.set(portName, port);
634
+ });
635
+
636
+ // Handle connections complete notification
637
+ // Load the worker file one time only, then send the early messages again
638
+ this.parentPorts.subscribe('_ConnectionsComplete', async (data, senderTrace, receiverTrace) => {
639
+ if (this._started) return;
640
+ this._started = true;
641
+ if (typeof this._connectionsCompleteCallback === 'function') {
642
+ await this._connectionsCompleteCallback();
643
+ }
644
+ this._ready = true;
645
+ const early = this._earlyMessages;
646
+ this._earlyMessages = [];
647
+ for (const { connection, message } of early) connection._dispatch(message);
648
+ });
649
+
650
+ // Built-in close ack — fires at connection level (after user-level 'close' handlers)
651
+ // so gpu.close() and other cleanup runs first, then we ack the main thread
652
+ this.parentPorts[0][0].subscribe('close', () => {
653
+ this.parentPorts.publish('ack', 'close');
654
+ });
655
+
656
+ }
657
+ }
658
+
651
659
  module.exports.NodeRedWorkerRuntime = NodeRedWorkerRuntime;