@nrutils/node-red-utils 0.0.0-stage → 0.1.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 +15 -0
  2. package/LICENSE +190 -0
  3. package/README.md +181 -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 +414 -0
  29. package/node-red-popup/README.md +110 -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 +438 -0
  39. package/node-red-worker/NodeRedWorkerRuntime.js +651 -0
  40. package/node-red-worker/README.md +265 -0
  41. package/node-red-worker/workerRuntimeLoader.js +55 -0
  42. package/package.json +70 -5
@@ -0,0 +1,617 @@
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
+ 'use strict';
8
+
9
+ const { EventEmitter } = require('events');
10
+ const { Worker, MessagePort } = require('worker_threads');
11
+
12
+ const STANDARD_KEYS = ['producerId', 'desc', 'type', 'kind'];
13
+ const ALLOWED_KINDS = new Set(['connect', 'change', 'disconnect']);
14
+ const PRODUCER_REGISTERED_EVENT = '__pubsub:producerRegistered';
15
+ // A getMetadata request to a worker producer fails after this time without a reply
16
+ const PULL_TIMEOUT_MS = 5000;
17
+
18
+ // All copies of this library in one process share this state.
19
+ // Thus producers of one copy are visible to consumers of another copy.
20
+ // The state holds plain data only, so copies of different versions stay compatible.
21
+ // Shape: { emitter: EventEmitter, producers: Map, adminMounted: boolean }
22
+ const shared = globalThis[Symbol.for('node-red-utils.pubsub-bus')] ??= {};
23
+ if (!shared.emitter) {
24
+ shared.emitter = new EventEmitter();
25
+ shared.emitter.setMaxListeners(0);
26
+ }
27
+ shared.producers ??= new Map();
28
+
29
+ let _busInstance = null;
30
+
31
+ /**
32
+ * Returns the process-lifetime singleton instance of NodeRedPubsubBus.
33
+ * The bus uses the state that all copies of this library share.
34
+ * @returns {NodeRedPubsubBus}
35
+ */
36
+ function getBus() {
37
+ if (!_busInstance) _busInstance = new NodeRedPubsubBus(shared);
38
+ return _busInstance;
39
+ }
40
+
41
+ /**
42
+ * Mounts the discovery admin endpoint. Idempotent — the route is registered
43
+ * exactly once per process, when the first node type with the mixin is registered.
44
+ * @param {object} RED
45
+ */
46
+ function mountAdminRoute(RED) {
47
+ if (shared.adminMounted || !RED || !RED.httpAdmin) return;
48
+ shared.adminMounted = true;
49
+ RED.httpAdmin.get('/node-red-pubsub-bus/producers',
50
+ RED.auth.needsPermission('flows.read'),
51
+ (req, res) => res.json(getBus().listProducers())
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Validates the standard message shape required by the bus.
57
+ * Throws if any required key is missing.
58
+ * @param {object} msg - The message to validate
59
+ * @throws {Error} If msg is missing any standard key
60
+ */
61
+ function validateStandardMsg(msg) {
62
+ if (!msg || typeof msg !== 'object') {
63
+ throw new Error('NodeRedPubsubBus: msg must be an object');
64
+ }
65
+ for (const k of STANDARD_KEYS) {
66
+ if (!(k in msg)) {
67
+ throw new Error(`NodeRedPubsubBus: msg is missing required key '${k}'`);
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * In-process pub/sub bus with MQTT-shaped semantics. Process-lifetime singleton;
74
+ * no broker, no external connection. Topics are flat strings of the form
75
+ * `<producerId>/<entryName>`. Three trigger kinds: `change` (broadcast — fires
76
+ * on producer register and on metadata mutation), `connect` (retained-on-
77
+ * subscribe replay to a single new subscriber) and `disconnect` (broadcast —
78
+ * fires when the producer unregisters).
79
+ */
80
+ class NodeRedPubsubBus {
81
+
82
+ /**
83
+ * @param {{emitter: EventEmitter, producers: Map}} [state] - Shared state. Omit it for a private bus.
84
+ */
85
+ constructor(state) {
86
+ if (state) {
87
+ this._emitter = state.emitter;
88
+ this._producers = state.producers;
89
+ } else {
90
+ this._emitter = new EventEmitter();
91
+ this._emitter.setMaxListeners(0);
92
+ // producerId -> { getEntries, getMetadata }
93
+ this._producers = new Map();
94
+ }
95
+ // worker bookkeeping is per-attach, kept inside attachWorker closures.
96
+ }
97
+
98
+ /**
99
+ * Publishes a message to a topic. Validates the standard shape; does not mutate msg.
100
+ * @param {string} topic
101
+ * @param {object} msg - Must contain producerId, desc, type, kind
102
+ */
103
+ publish(topic, msg) {
104
+ validateStandardMsg(msg);
105
+ this._emitter.emit(topic, msg);
106
+ }
107
+
108
+ /**
109
+ * Subscribes to a topic. If a producer is already registered for the topic's
110
+ * producerId, fires a `connect` event on the new handler immediately
111
+ * (retained-on-subscribe replay).
112
+ * A handler that throws or returns a Promise that rejects does not stop the
113
+ * other subscribers. The error goes to onError.
114
+ * @param {string} topic
115
+ * @param {Function} handler
116
+ * @param {Function} [onError] - Called with the error of the handler. Default: console.error.
117
+ * @returns {Function} unsubscribe
118
+ */
119
+ subscribe(topic, handler, onError = (err) => console.error('NodeRedPubsubBus: subscriber of ' + topic + ' failed:', err)) {
120
+ if (typeof handler !== 'function') {
121
+ throw new Error('NodeRedPubsubBus: handler must be a function');
122
+ }
123
+ const report = (err) => { try { onError(err); } catch (_) {} };
124
+ const guarded = (msg) => {
125
+ try {
126
+ const result = handler(msg);
127
+ if (result && typeof result.then === 'function') result.then(undefined, report);
128
+ } catch (err) { report(err); }
129
+ };
130
+ this._emitter.on(topic, guarded);
131
+
132
+ // Retained-on-subscribe replay.
133
+ const slash = topic.indexOf('/');
134
+ if (slash > 0) {
135
+ const producerId = topic.slice(0, slash);
136
+ const name = topic.slice(slash + 1);
137
+ const type = this._typeOf(producerId, name);
138
+ if (type) {
139
+ Promise.resolve().then(() => guarded(this._buildMsg(producerId, name, type, 'connect')));
140
+ }
141
+ }
142
+
143
+ return () => { this._emitter.off(topic, guarded); };
144
+ }
145
+
146
+ /**
147
+ * Registers a producer. Emits `change` per topic returned by getEntries() so any
148
+ * already-subscribed consumers re-pull. Returns an unregister fn that drops the
149
+ * producer and emits `disconnect` per topic of its last entries.
150
+ * @param {string} producerId
151
+ * @param {{ getEntries: Function, getMetadata: Function }} opts
152
+ * @returns {Function} unregister
153
+ */
154
+ registerProducer(producerId, { getEntries, getMetadata }) {
155
+ if (typeof getEntries !== 'function' || typeof getMetadata !== 'function') {
156
+ throw new Error('NodeRedPubsubBus: registerProducer requires getEntries and getMetadata fns');
157
+ }
158
+ if (this._producers.has(producerId)) {
159
+ throw new Error(`NodeRedPubsubBus: producer '${producerId}' is already registered`);
160
+ }
161
+
162
+ const producer = { getEntries, getMetadata };
163
+ this._producers.set(producerId, producer);
164
+
165
+ // Notify any onProducerRegistered listeners so they can subscribe to
166
+ // this producer's topics. Fired BEFORE the per-entry change burst so
167
+ // a fresh subscriber's `connect` replay covers every entry.
168
+ this._emitter.emit(PRODUCER_REGISTERED_EVENT, producerId);
169
+
170
+ // Fire change per entry so any already-subscribed consumers re-pull.
171
+ let entries = [];
172
+ try { entries = getEntries() || []; } catch (err) {
173
+ console.error('NodeRedPubsubBus: getEntries threw at register', err);
174
+ }
175
+ for (const entry of entries) {
176
+ const topic = `${producerId}/${entry.name}`;
177
+ this._emitter.emit(topic, this._buildMsg(producerId, entry.name, entry.type, 'change'));
178
+ }
179
+
180
+ return () => {
181
+ // A later registration with the same id is not ours to remove
182
+ if (this._producers.get(producerId) !== producer) return;
183
+ let lastEntries = [];
184
+ try { lastEntries = getEntries() || []; } catch (_) {}
185
+ this._producers.delete(producerId);
186
+ for (const entry of lastEntries) {
187
+ this._emitter.emit(`${producerId}/${entry.name}`,
188
+ this._buildMsg(producerId, entry.name, entry.type, 'disconnect'));
189
+ }
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Fired by a producer after it has updated its own metadataVersion to signal
195
+ * a `change` on a single entry. Producer is responsible for incrementing version.
196
+ * @param {string} producerId
197
+ * @param {string} name
198
+ * @returns {boolean} false if the producer is not registered or has no entry `name` in getEntries(): nothing is sent
199
+ */
200
+ notifyChange(producerId, name) {
201
+ const producer = this._producers.get(producerId);
202
+ if (!producer) return false;
203
+ const type = this._typeOf(producerId, name);
204
+ if (!type) return false;
205
+ const topic = `${producerId}/${name}`;
206
+ this._emitter.emit(topic, this._buildMsg(producerId, name, type, 'change'));
207
+ return true;
208
+ }
209
+
210
+ /**
211
+ * Installs the worker proxy bridge protocol on a Worker or MessagePort.
212
+ * Tracks every shim subscription, remote-producer registration, and pending
213
+ * get-metadata reqId so detach (or worker exit) is a clean teardown.
214
+ * @param {Worker|MessagePort} worker
215
+ * @param {Function} [onError] - Called with an Error when a worker request fails,
216
+ * for example a duplicate producer id. Default: console.error.
217
+ * @returns {Function} detach
218
+ */
219
+ attachWorker(worker, onError = (err) => console.error(err.message)) {
220
+ if (!worker || typeof worker.on !== 'function' || typeof worker.postMessage !== 'function') {
221
+ throw new Error('NodeRedPubsubBus: attachWorker requires a Worker or MessagePort');
222
+ }
223
+
224
+ // subId -> { topic, unsubscribe }
225
+ const shimSubs = new Map();
226
+ // producerId -> unregister fn
227
+ const remoteProducers = new Map();
228
+ // reqId -> { resolve, reject }
229
+ const pendingMetadataReqs = new Map();
230
+
231
+ let nextReqId = 1;
232
+
233
+ // Never throw from a 'message' listener: Node-RED would stop with an uncaught exception
234
+ const onMessage = (message) => {
235
+ try { dispatch(message); } catch (err) {
236
+ try { onError(err); } catch (_) {}
237
+ }
238
+ };
239
+ const dispatch = (message) => {
240
+ if (!message || typeof message !== 'object') return;
241
+ switch (message.tag) {
242
+ case 'pubsub:subscribe':
243
+ this._handleWorkerSubscribe(message, worker, shimSubs);
244
+ break;
245
+ case 'pubsub:unsubscribe':
246
+ this._handleWorkerUnsubscribe(message, shimSubs);
247
+ break;
248
+ case 'pubsub:publish':
249
+ this._handleWorkerPublish(message);
250
+ break;
251
+ case 'pubsub:register':
252
+ this._handleWorkerRegister(message, worker, remoteProducers, pendingMetadataReqs, () => nextReqId++);
253
+ break;
254
+ case 'pubsub:notify-change':
255
+ this._handleWorkerNotifyChange(message, remoteProducers);
256
+ break;
257
+ case 'pubsub:pull-metadata':
258
+ this._handleWorkerPullMetadata(message, worker);
259
+ break;
260
+ case 'pubsub:get-metadata-reply':
261
+ this._handleWorkerMetadataReply(message, pendingMetadataReqs);
262
+ break;
263
+ case 'pubsub:ping':
264
+ // Tells the worker proxy that it is attached
265
+ worker.postMessage({ tag: 'pubsub:pong' });
266
+ break;
267
+ default:
268
+ // Unknown tag — ignore (other protocols may share parentPort).
269
+ break;
270
+ }
271
+ };
272
+
273
+ worker.on('message', onMessage);
274
+
275
+ const cleanup = () => {
276
+ worker.off ? worker.off('message', onMessage) : worker.removeListener('message', onMessage);
277
+ for (const sub of shimSubs.values()) {
278
+ try { sub.unsubscribe(); } catch (_) {}
279
+ }
280
+ shimSubs.clear();
281
+ for (const { unregister } of remoteProducers.values()) {
282
+ try { unregister(); } catch (_) {}
283
+ }
284
+ remoteProducers.clear();
285
+ for (const pending of pendingMetadataReqs.values()) {
286
+ clearTimeout(pending.timer);
287
+ try { pending.reject(new Error('NodeRedPubsubBus: worker detached')); } catch (_) {}
288
+ }
289
+ pendingMetadataReqs.clear();
290
+ };
291
+
292
+ // Auto-detach on worker exit.
293
+ if (typeof worker.once === 'function') {
294
+ worker.once('exit', cleanup);
295
+ }
296
+
297
+ return cleanup;
298
+ }
299
+
300
+ /**
301
+ * Pulls metadata for a registered producer entry. Always returns a Promise for
302
+ * the producer's getMetadata(name) result. It rejects if the producer throws.
303
+ * The Promise gives null if the producer is not registered.
304
+ * @param {string} producerId
305
+ * @param {string} name
306
+ * @returns {Promise<*>}
307
+ */
308
+ async getMetadata(producerId, name) {
309
+ const producer = this._producers.get(producerId);
310
+ if (!producer) return null;
311
+ return producer.getMetadata(name);
312
+ }
313
+
314
+ /**
315
+ * Returns a snapshot of currently-subscribed topic strings.
316
+ * @returns {string[]}
317
+ */
318
+ listTopics() {
319
+ return this._emitter.eventNames().filter((n) => typeof n === 'string');
320
+ }
321
+
322
+ /**
323
+ * Returns a snapshot of every registered producer and its current entries.
324
+ * Used by editor-side discovery dropdowns.
325
+ * @returns {Array<{producerId: string, entries: Array<{name: string, type: string}>}>}
326
+ */
327
+ listProducers() {
328
+ const out = [];
329
+ for (const [producerId, producer] of this._producers.entries()) {
330
+ let entries = [];
331
+ try { entries = producer.getEntries() || []; } catch (_) {}
332
+ out.push({
333
+ producerId,
334
+ entries: entries.map((e) => ({ name: e.name, type: e.type }))
335
+ });
336
+ }
337
+ return out;
338
+ }
339
+
340
+ /**
341
+ * Returns the count of subscribers for a topic.
342
+ * @param {string} topic
343
+ * @returns {number}
344
+ */
345
+ listSubscribers(topic) {
346
+ return this._emitter.listenerCount(topic);
347
+ }
348
+
349
+ /**
350
+ * Subscribes to a fire-on-every-producer-registered notification. Handler
351
+ * receives the new `producerId`. Used by consumers
352
+ * that want to track the producer set as workers come online after deploy.
353
+ * @param {Function} handler
354
+ * @returns {Function} unsubscribe
355
+ */
356
+ onProducerRegistered(handler) {
357
+ if (typeof handler !== 'function') {
358
+ throw new Error('NodeRedPubsubBus: handler must be a function');
359
+ }
360
+ this._emitter.on(PRODUCER_REGISTERED_EVENT, handler);
361
+ return () => { this._emitter.off(PRODUCER_REGISTERED_EVENT, handler); };
362
+ }
363
+
364
+ // ----- internal helpers -----
365
+
366
+ /**
367
+ * Builds the standard message envelope. Identity-only — no payload, no version, no timestamp.
368
+ * @private
369
+ */
370
+ _buildMsg(producerId, desc, type, kind) {
371
+ if (!ALLOWED_KINDS.has(kind)) {
372
+ throw new Error(`NodeRedPubsubBus: invalid kind '${kind}'`);
373
+ }
374
+ return { producerId, desc, type, kind };
375
+ }
376
+
377
+ /**
378
+ * Looks up the declared type for a producer's entry name.
379
+ * Returns null if the producer or entry is not registered.
380
+ * @private
381
+ */
382
+ _typeOf(producerId, name) {
383
+ const producer = this._producers.get(producerId);
384
+ if (!producer) return null;
385
+ let entries = [];
386
+ try { entries = producer.getEntries() || []; } catch (_) {}
387
+ const found = entries.find((e) => e.name === name);
388
+ return found ? found.type : null;
389
+ }
390
+
391
+ /** @private */
392
+ _handleWorkerSubscribe(message, worker, shimSubs) {
393
+ const { subId, topic } = message;
394
+ if (subId == null || typeof topic !== 'string') return;
395
+ if (shimSubs.has(subId)) return;
396
+ const handler = (msg) => {
397
+ try {
398
+ worker.postMessage({ tag: 'pubsub:event', subId, msg });
399
+ } catch (err) {
400
+ console.error('NodeRedPubsubBus: postMessage to worker failed', err);
401
+ }
402
+ };
403
+ const unsubscribe = this.subscribe(topic, handler);
404
+ shimSubs.set(subId, { topic, unsubscribe });
405
+ }
406
+
407
+ /** @private */
408
+ _handleWorkerUnsubscribe(message, shimSubs) {
409
+ const { subId } = message;
410
+ const entry = shimSubs.get(subId);
411
+ if (!entry) return;
412
+ try { entry.unsubscribe(); } catch (_) {}
413
+ shimSubs.delete(subId);
414
+ }
415
+
416
+ /** @private */
417
+ _handleWorkerPublish(message) {
418
+ const { topic, msg } = message;
419
+ if (typeof topic !== 'string') return;
420
+ try { this.publish(topic, msg); } catch (err) {
421
+ console.error('NodeRedPubsubBus: worker publish rejected', err);
422
+ }
423
+ }
424
+
425
+ /** @private */
426
+ _handleWorkerRegister(message, worker, remoteProducers, pendingMetadataReqs, nextReqId) {
427
+ const { producerId, entries } = message;
428
+ if (typeof producerId !== 'string' || !Array.isArray(entries)) return;
429
+ if (remoteProducers.has(producerId)) return;
430
+ if (this._producers.has(producerId)) {
431
+ throw new Error(`NodeRedPubsubBus: a worker tried to register producer '${producerId}', ` +
432
+ 'but that id is already registered. Use a unique id, such as the node id.');
433
+ }
434
+
435
+ const requestFromWorker = (name) => new Promise((resolve, reject) => {
436
+ const reqId = nextReqId();
437
+ const timer = setTimeout(() => {
438
+ if (!pendingMetadataReqs.delete(reqId)) return;
439
+ reject(new Error(`NodeRedPubsubBus: getMetadata('${producerId}', '${name}'): ` +
440
+ `the worker producer did not reply within ${PULL_TIMEOUT_MS} ms`));
441
+ }, PULL_TIMEOUT_MS);
442
+ if (typeof timer.unref === 'function') timer.unref();
443
+ pendingMetadataReqs.set(reqId, { resolve, reject, timer });
444
+ try {
445
+ worker.postMessage({ tag: 'pubsub:get-metadata', reqId, producerId, name });
446
+ } catch (err) {
447
+ clearTimeout(timer);
448
+ pendingMetadataReqs.delete(reqId);
449
+ reject(err);
450
+ }
451
+ });
452
+
453
+ // entriesRef is mutable so notifyChange can refresh it without re-registering.
454
+ const entriesRef = { current: entries };
455
+ const unregister = this.registerProducer(producerId, {
456
+ getEntries: () => entriesRef.current,
457
+ getMetadata: (name) => requestFromWorker(name),
458
+ });
459
+ remoteProducers.set(producerId, { unregister, entriesRef });
460
+ }
461
+
462
+ /** @private */
463
+ _handleWorkerNotifyChange(message, remoteProducers) {
464
+ const { producerId, name, entries } = message;
465
+ if (typeof producerId !== 'string' || typeof name !== 'string') return;
466
+ // Refresh the cached entries snapshot (worker entries can grow/shrink dynamically).
467
+ if (Array.isArray(entries) && remoteProducers && remoteProducers.has(producerId)) {
468
+ remoteProducers.get(producerId).entriesRef.current = entries;
469
+ }
470
+ // Re-fire the producer-registered notification so consumers tracking the
471
+ // bus producer set can pick up entries that
472
+ // appeared after the initial registerProducer call. Idempotent on the
473
+ // consumer side.
474
+ this._emitter.emit(PRODUCER_REGISTERED_EVENT, producerId);
475
+ try { this.notifyChange(producerId, name); } catch (err) {
476
+ console.error('NodeRedPubsubBus: worker notifyChange rejected', err);
477
+ }
478
+ }
479
+
480
+ /** @private */
481
+ _handleWorkerPullMetadata(message, worker) {
482
+ const { reqId, producerId, name } = message;
483
+ if (typeof reqId === 'undefined') return;
484
+ const reply = (payload) => {
485
+ try { worker.postMessage({ tag: 'pubsub:pull-metadata-reply', reqId, payload }); }
486
+ catch (err) { console.error('NodeRedPubsubBus: pull-metadata-reply postMessage failed', err); }
487
+ };
488
+ this.getMetadata(producerId, name).then(p => reply(p == null ? null : p)).catch(() => reply(null));
489
+ }
490
+
491
+ /** @private */
492
+ _handleWorkerMetadataReply(message, pendingMetadataReqs) {
493
+ const { reqId, payload } = message;
494
+ const pending = pendingMetadataReqs.get(reqId);
495
+ if (!pending) return;
496
+ pendingMetadataReqs.delete(reqId);
497
+ clearTimeout(pending.timer);
498
+ pending.resolve(payload);
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Mixin factory that adds the namespaced `nodeRedPubsubBus` API to a host node.
504
+ * Proxies to the process-lifetime singleton bus. Tracks every producer registration
505
+ * and worker attachment made through this mixin instance so that on `thisNode.on('close')`
506
+ * only this node's resources are cleaned up — not other nodes' registrations.
507
+ *
508
+ * @param {class} superclass
509
+ * @returns {class}
510
+ */
511
+ const nodeRedPubsubBus = (superclass) => class extends superclass {
512
+ // Serve the discovery route before the first deploy
513
+ static onRegisterType(RED) {
514
+ super.onRegisterType?.(RED);
515
+ mountAdminRoute(RED);
516
+ }
517
+
518
+ constructor(RED, thisNode, config) {
519
+ super(RED, thisNode, config);
520
+
521
+ const bus = getBus();
522
+ const ownedUnregisters = new Set();
523
+ const ownedDetachers = new Set();
524
+ const ownedProducerListeners = new Set();
525
+ const ownedSubscriptions = new Set();
526
+
527
+ const namespace = {
528
+ publish: (topic, msg) => bus.publish(topic, msg),
529
+
530
+ subscribe: (topic, handler) => {
531
+ const unsubscribe = bus.subscribe(topic, handler,
532
+ (err) => thisNode.error(`pubsub subscriber of ${topic} failed: ${(err && err.message) || err}`));
533
+ const wrapped = () => {
534
+ ownedSubscriptions.delete(wrapped);
535
+ try { unsubscribe(); } catch (_) {}
536
+ };
537
+ ownedSubscriptions.add(wrapped);
538
+ return wrapped;
539
+ },
540
+
541
+ registerProducer: (producerId, opts) => {
542
+ const unregister = bus.registerProducer(producerId, opts);
543
+ const wrapped = () => {
544
+ ownedUnregisters.delete(wrapped);
545
+ try { unregister(); } catch (_) {}
546
+ };
547
+ ownedUnregisters.add(wrapped);
548
+ return wrapped;
549
+ },
550
+
551
+ notifyChange: (producerId, name) => {
552
+ if (!bus.notifyChange(producerId, name)) {
553
+ thisNode.debug(`pubsub notifyChange('${producerId}', '${name}') ignored: no such producer, or getEntries() has no entry '${name}'`);
554
+ }
555
+ },
556
+
557
+ getMetadata: (producerId, name) => bus.getMetadata(producerId, name),
558
+
559
+ attachWorker: (worker) => {
560
+ const detach = bus.attachWorker(worker, (err) => {
561
+ thisNode.error(err.message);
562
+ thisNode.status({ fill: 'red', shape: 'ring', text: 'pubsub error, see log' });
563
+ });
564
+ const wrapped = () => {
565
+ ownedDetachers.delete(wrapped);
566
+ try { detach(); } catch (_) {}
567
+ };
568
+ ownedDetachers.add(wrapped);
569
+ return wrapped;
570
+ },
571
+
572
+ listTopics: () => bus.listTopics(),
573
+
574
+ listSubscribers: (topic) => bus.listSubscribers(topic),
575
+
576
+ listProducers: () => bus.listProducers(),
577
+
578
+ onProducerRegistered: (handler) => {
579
+ const off = bus.onProducerRegistered(handler);
580
+ const wrapped = () => {
581
+ ownedProducerListeners.delete(wrapped);
582
+ try { off(); } catch (_) {}
583
+ };
584
+ ownedProducerListeners.add(wrapped);
585
+ return wrapped;
586
+ },
587
+
588
+ _parent: this,
589
+ };
590
+
591
+ this.nodeRedPubsubBus = namespace;
592
+ thisNode.nodeRedPubsubBus = namespace;
593
+
594
+ thisNode.on('close', () => {
595
+ for (const fn of Array.from(ownedUnregisters)) {
596
+ try { fn(); } catch (_) {}
597
+ }
598
+ for (const fn of Array.from(ownedDetachers)) {
599
+ try { fn(); } catch (_) {}
600
+ }
601
+ for (const fn of Array.from(ownedProducerListeners)) {
602
+ try { fn(); } catch (_) {}
603
+ }
604
+ for (const fn of Array.from(ownedSubscriptions)) {
605
+ try { fn(); } catch (_) {}
606
+ }
607
+ ownedUnregisters.clear();
608
+ ownedSubscriptions.clear();
609
+ ownedDetachers.clear();
610
+ ownedProducerListeners.clear();
611
+ });
612
+ }
613
+ };
614
+
615
+ module.exports.nodeRedPubsubBus = nodeRedPubsubBus;
616
+ module.exports.NodeRedPubsubBus = NodeRedPubsubBus;
617
+ module.exports.getBus = getBus;