@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +190 -0
- package/README.md +181 -2
- package/index.js +50 -0
- package/node-red-base/NodeRedNodeBase.js +143 -0
- package/node-red-base/README.md +215 -0
- package/node-red-editorcomms/EditorCommsBackend.js +155 -0
- package/node-red-editorcomms/EditorCommsFrontend.js +105 -0
- package/node-red-editorcomms/README.md +164 -0
- package/node-red-gpu/GpuBuffer.js +29 -0
- package/node-red-gpu/Kernel.js +35 -0
- package/node-red-gpu/README.md +220 -0
- package/node-red-gpu/backends/CpuBackend.js +109 -0
- package/node-red-gpu/backends/VulkanBackend.js +232 -0
- package/node-red-gpu/errors.js +48 -0
- package/node-red-gpu/index.js +6 -0
- package/node-red-gpu/log.js +18 -0
- package/node-red-gpu/native/binding.gyp +14 -0
- package/node-red-gpu/native/src/gpu_addon.cpp +1066 -0
- package/node-red-gpu/nodeRedGpuMain.js +39 -0
- package/node-red-gpu/nodeRedGpuWorker.js +242 -0
- package/node-red-gpu/transpiler/emitGlsl.js +497 -0
- package/node-red-gpu/transpiler/glslNames.js +60 -0
- package/node-red-gpu/transpiler/index.js +81 -0
- package/node-red-gpu/transpiler/inferTypes.js +302 -0
- package/node-red-gpu/transpiler/validate.js +461 -0
- package/node-red-popup/NodeRedPopupBackend.js +69 -0
- package/node-red-popup/NodeRedPopupFrontend.js +414 -0
- package/node-red-popup/README.md +110 -0
- package/node-red-pubsub-bus/NodeRedPubsubBus.js +617 -0
- package/node-red-pubsub-bus/NodeRedPubsubBusWorkerProxy.js +239 -0
- package/node-red-pubsub-bus/README.md +201 -0
- package/node-red-resourceendpoint/NodeRedResourceEndpoint.js +205 -0
- package/node-red-resourceendpoint/README.md +119 -0
- package/node-red-singleton/NodeRedSingletonBackend.js +30 -0
- package/node-red-singleton/NodeRedSingletonFrontend.js +54 -0
- package/node-red-singleton/README.md +72 -0
- package/node-red-worker/NodeRedWorker.js +438 -0
- package/node-red-worker/NodeRedWorkerRuntime.js +651 -0
- package/node-red-worker/README.md +265 -0
- package/node-red-worker/workerRuntimeLoader.js +55 -0
- 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;
|