@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,239 @@
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
+ //
10
+ // Worker-side counterpart to NodeRedPubsubBus. Same surface as the main-thread
11
+ // bus, but every call serializes over `parentPort`. Expects the host main-thread
12
+ // to have called `pubsubBus.attachWorker(worker)`. If that didn't happen, calls
13
+ // fall on the floor. The first call also sends a ping; attachWorker answers it.
14
+ // If no answer comes within 5s, a one-time warning is logged.
15
+ //
16
+
17
+ const { parentPort } = require('worker_threads');
18
+
19
+ // getMetadata fails after this time without a reply from the main thread
20
+ const PULL_TIMEOUT_MS = 5000;
21
+
22
+ /**
23
+ * Worker-side proxy for the main-thread NodeRedPubsubBus. Singleton per worker
24
+ * isolate; the worker only ever talks to one parentPort.
25
+ */
26
+ class NodeRedPubsubBusWorkerProxy {
27
+
28
+ constructor() {
29
+ // subId -> handler
30
+ this._subscribers = new Map();
31
+ // producerId -> getMetadata
32
+ this._producerMetadataFns = new Map();
33
+ // producerId -> getEntries (so notifyChange can refresh main-side entry cache)
34
+ this._producerEntriesFns = new Map();
35
+ // reqId -> { resolve, reject } for pulls we issued to main
36
+ this._pendingPulls = new Map();
37
+
38
+ this._nextSubId = 1;
39
+ this._nextLocalId = 1;
40
+ this._nextPullId = 1;
41
+
42
+ this._anyMessageReceived = false;
43
+ this._firstSendAt = null;
44
+ this._warnedNoBridge = false;
45
+ this._warnTimer = null;
46
+
47
+ if (parentPort) {
48
+ parentPort.on('message', (message) => this._onMessage(message));
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Publishes a message via the main-thread bus.
54
+ * @param {string} topic
55
+ * @param {object} msg
56
+ */
57
+ publish(topic, msg) {
58
+ this._send({ tag: 'pubsub:publish', topic, msg });
59
+ }
60
+
61
+ /**
62
+ * Subscribes to a topic via the main-thread bus.
63
+ * @param {string} topic
64
+ * @param {Function} handler
65
+ * @returns {Function} unsubscribe
66
+ */
67
+ subscribe(topic, handler) {
68
+ if (typeof handler !== 'function') {
69
+ throw new Error('NodeRedPubsubBusWorkerProxy: handler must be a function');
70
+ }
71
+ const subId = this._nextSubId++;
72
+ this._subscribers.set(subId, handler);
73
+ this._send({ tag: 'pubsub:subscribe', subId, topic });
74
+ return () => {
75
+ if (!this._subscribers.has(subId)) return;
76
+ this._subscribers.delete(subId);
77
+ this._send({ tag: 'pubsub:unsubscribe', subId });
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Registers a producer with the main-thread bus. Local getMetadata is retained
83
+ * to satisfy main → worker `pubsub:get-metadata` requests.
84
+ * @param {string} producerId
85
+ * @param {{ getEntries: Function, getMetadata: Function }} opts
86
+ * @returns {Function} unregister
87
+ */
88
+ registerProducer(producerId, { getEntries, getMetadata }) {
89
+ if (typeof getEntries !== 'function' || typeof getMetadata !== 'function') {
90
+ throw new Error('NodeRedPubsubBusWorkerProxy: registerProducer requires getEntries and getMetadata fns');
91
+ }
92
+ if (this._producerMetadataFns.has(producerId)) {
93
+ throw new Error(`NodeRedPubsubBusWorkerProxy: producer '${producerId}' already registered`);
94
+ }
95
+ this._producerMetadataFns.set(producerId, getMetadata);
96
+ this._producerEntriesFns.set(producerId, getEntries);
97
+ this._send({ tag: 'pubsub:register', producerId, entries: getEntries() });
98
+
99
+ return () => {
100
+ if (!this._producerMetadataFns.has(producerId)) return;
101
+ this._producerMetadataFns.delete(producerId);
102
+ this._producerEntriesFns.delete(producerId);
103
+ // Note: spec defines no explicit `pubsub:unregister` tag. Producer
104
+ // teardown is normally driven by worker exit, which auto-detaches
105
+ // on the main side. If a host wants to revoke a producer without
106
+ // exiting, that's an out-of-band concern beyond this spec.
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Asks the main-thread bus to fire a `change` event for a single entry of
112
+ * a worker-side producer. Use after mutating the cached metadata payload
113
+ * so subscribers re-pull via getMetadata.
114
+ * @param {string} producerId
115
+ * @param {string} name
116
+ */
117
+ notifyChange(producerId, name) {
118
+ // Re-send the current entries snapshot so the main-side cache stays in
119
+ // sync — entries can change dynamically as the worker discovers/loses
120
+ // upstream channels.
121
+ const getEntries = this._producerEntriesFns.get(producerId);
122
+ const entries = getEntries ? getEntries() : null;
123
+ this._send({ tag: 'pubsub:notify-change', producerId, name, entries });
124
+ }
125
+
126
+ /**
127
+ * Pulls metadata for any registered producer/entry through the main-thread bus.
128
+ * Returns a Promise that resolves to the payload (or null if unregistered).
129
+ * It rejects if no reply comes within PULL_TIMEOUT_MS, for example without attachWorker().
130
+ * @param {string} producerId
131
+ * @param {string} name
132
+ * @returns {Promise<*>}
133
+ */
134
+ getMetadata(producerId, name) {
135
+ return new Promise((resolve, reject) => {
136
+ const reqId = this._nextPullId++;
137
+ const timer = setTimeout(() => {
138
+ if (!this._pendingPulls.delete(reqId)) return;
139
+ reject(new Error(`NodeRedPubsubBusWorkerProxy: getMetadata('${producerId}', '${name}'): ` +
140
+ `no reply from the main thread within ${PULL_TIMEOUT_MS} ms. Did the host call attachWorker(worker)?`));
141
+ }, PULL_TIMEOUT_MS);
142
+ if (typeof timer.unref === 'function') timer.unref();
143
+ this._pendingPulls.set(reqId, { resolve, reject, timer });
144
+ this._send({ tag: 'pubsub:pull-metadata', reqId, producerId, name });
145
+ });
146
+ }
147
+
148
+ /** @private */
149
+ _onMessage(message) {
150
+ if (!message || typeof message !== 'object') return;
151
+ this._anyMessageReceived = true;
152
+ if (this._warnTimer) {
153
+ clearTimeout(this._warnTimer);
154
+ this._warnTimer = null;
155
+ }
156
+ switch (message.tag) {
157
+ case 'pubsub:event':
158
+ this._handleEvent(message);
159
+ break;
160
+ case 'pubsub:get-metadata':
161
+ this._handleGetMetadata(message);
162
+ break;
163
+ case 'pubsub:pull-metadata-reply':
164
+ this._handlePullMetadataReply(message);
165
+ break;
166
+ default:
167
+ break;
168
+ }
169
+ }
170
+
171
+ /** @private */
172
+ _handlePullMetadataReply(message) {
173
+ const { reqId, payload } = message;
174
+ const pending = this._pendingPulls.get(reqId);
175
+ if (!pending) return;
176
+ this._pendingPulls.delete(reqId);
177
+ clearTimeout(pending.timer);
178
+ pending.resolve(payload);
179
+ }
180
+
181
+ /** @private */
182
+ _handleEvent(message) {
183
+ const { subId, msg } = message;
184
+ const handler = this._subscribers.get(subId);
185
+ if (!handler) return;
186
+ const report = (err) => console.error('NodeRedPubsubBusWorkerProxy: subscriber handler failed', err);
187
+ try {
188
+ const result = handler(msg);
189
+ if (result && typeof result.then === 'function') result.then(undefined, report);
190
+ } catch (err) { report(err); }
191
+ }
192
+
193
+ /** @private */
194
+ _handleGetMetadata(message) {
195
+ const { reqId, producerId, name } = message;
196
+ const getMetadata = this._producerMetadataFns.get(producerId);
197
+ if (!getMetadata) {
198
+ this._send({ tag: 'pubsub:get-metadata-reply', reqId, payload: null });
199
+ return;
200
+ }
201
+ Promise.resolve()
202
+ .then(() => getMetadata(name))
203
+ .then((payload) => {
204
+ this._send({ tag: 'pubsub:get-metadata-reply', reqId, payload: payload || null });
205
+ })
206
+ .catch((err) => {
207
+ console.error('NodeRedPubsubBusWorkerProxy: getMetadata threw', err);
208
+ this._send({ tag: 'pubsub:get-metadata-reply', reqId, payload: null });
209
+ });
210
+ }
211
+
212
+ /** @private */
213
+ _send(message) {
214
+ if (!parentPort) return;
215
+ if (this._firstSendAt === null) {
216
+ this._firstSendAt = Date.now();
217
+ this._warnTimer = setTimeout(() => {
218
+ if (!this._anyMessageReceived && !this._warnedNoBridge) {
219
+ this._warnedNoBridge = true;
220
+ console.warn('NodeRedPubsubBusWorkerProxy: no main-side bridge detected after 5s. Did the host call pubsubBus.attachWorker(worker)?');
221
+ }
222
+ }, 5000);
223
+ if (typeof this._warnTimer.unref === 'function') this._warnTimer.unref();
224
+ // attachWorker answers this, so a producer-only worker also gets a reply
225
+ parentPort.postMessage({ tag: 'pubsub:ping' });
226
+ }
227
+ try {
228
+ parentPort.postMessage(message);
229
+ } catch (err) {
230
+ console.error('NodeRedPubsubBusWorkerProxy: postMessage failed', err);
231
+ }
232
+ }
233
+ }
234
+
235
+ const pubsubWorkerProxy = new NodeRedPubsubBusWorkerProxy();
236
+
237
+ module.exports = pubsubWorkerProxy;
238
+ module.exports.NodeRedPubsubBusWorkerProxy = NodeRedPubsubBusWorkerProxy;
239
+ module.exports.pubsubWorkerProxy = pubsubWorkerProxy;
@@ -0,0 +1,201 @@
1
+ # node-red-pubsub-bus
2
+
3
+ An in-process pub/sub bus for data that nodes share without a wire: readings, settings, calibration, schemas.
4
+
5
+ A producer node offers named entries. A consumer subscribes to an entry and gets an event when the entry changes. The event does not carry the value; the consumer asks the producer for the current value with `getMetadata`. A consumer that subscribes after the producer has registered the entry gets one `connect` event, so it can ask for the value at once. If the producer has no such entry yet, the consumer gets nothing until the producer adds the entry and calls `notifyChange`.
6
+
7
+ ## Usage
8
+
9
+ A producer node reads the CPU temperature every 2 seconds and offers it as the entry `latest`. It has no input and no wire:
10
+
11
+ ```js
12
+ const fs = require('fs');
13
+ const { NodeRedNodeBase, nodeRedPubsubBus } = require('@nrutils/node-red-utils');
14
+
15
+ // Reads the CPU temperature every 2 s and offers it on the bus as the entry 'latest'
16
+ module.exports = NodeRedNodeBase.createNode('cpu-temp', [nodeRedPubsubBus], (RED, thisNode, config) => {
17
+ const bus = thisNode.nodeRedPubsubBus;
18
+ let latest = null;
19
+
20
+ bus.registerProducer(thisNode.id, {
21
+ getEntries: () => (latest === null ? [] : [{ name: 'latest', type: 'acme/reading_v1' }]),
22
+ getMetadata: (name) => (name === 'latest' ? latest : null)
23
+ });
24
+
25
+ const timer = setInterval(() => {
26
+ fs.readFile('/sys/class/thermal/thermal_zone0/temp', 'utf8', (err, text) => {
27
+ if (err) return thisNode.status({ fill: 'red', shape: 'ring', text: err.code });
28
+ const celsius = Number(text) / 1000;
29
+ if (latest && latest.celsius === celsius) return; // no change, no event
30
+ latest = { celsius, time: Date.now() };
31
+ bus.notifyChange(thisNode.id, 'latest');
32
+ });
33
+ }, 2000);
34
+ thisNode.on('close', () => clearInterval(timer));
35
+ });
36
+ ```
37
+
38
+ A consumer node subscribes to `<producerId>/<entry>` and pulls the value on each event:
39
+
40
+ ```js
41
+ const { NodeRedNodeBase, nodeRedPubsubBus } = require('@nrutils/node-red-utils');
42
+
43
+ module.exports = NodeRedNodeBase.createNode('bus-consumer', [nodeRedPubsubBus], (RED, thisNode, config) => {
44
+ const bus = thisNode.nodeRedPubsubBus;
45
+
46
+ if (!config.producer) { // no producer selected in the edit dialog
47
+ thisNode.status({ fill: 'grey', shape: 'ring', text: 'no producer selected' });
48
+ return;
49
+ }
50
+
51
+ bus.subscribe(`${config.producer}/latest`, async (event) => {
52
+ if (event.kind === 'disconnect') { // the producer is gone
53
+ thisNode.status({ text: 'no producer' });
54
+ return;
55
+ }
56
+ try {
57
+ const value = await bus.getMetadata(event.producerId, event.desc);
58
+ if (value === null) return; // no value yet, or the producer is gone
59
+ thisNode.send({ payload: value, kind: event.kind });
60
+ } catch (err) {
61
+ thisNode.error(err); // for example a worker producer that did not reply
62
+ }
63
+ });
64
+ });
65
+ ```
66
+
67
+ Use as many consumer nodes as you need, on any flow tab. Each one reads the same producer. A consumer that is deployed later gets `connect` and pulls the current reading at once.
68
+
69
+ `config.producer` is the producer's node id. In the editor, fill a dropdown from the [discovery endpoint](#discovery-endpoint); see [Producer dropdown in the edit dialog](#producer-dropdown-in-the-edit-dialog).
70
+
71
+ ## How it works
72
+
73
+ - Events carry no payload. Every event is `{ producerId, desc, type, kind }`, where `desc` is the entry name. The consumer pulls the value with `getMetadata`, so it never acts on a stale copy.
74
+ - `kind` is one of:
75
+ - `'change'`: goes to all subscribers of the entry when the producer registers or calls `notifyChange`.
76
+ - `'connect'`: goes to one new subscriber if the entry already exists.
77
+ - `'disconnect'`: goes to all subscribers of the entry when the producer unregisters: its node closes (redeploy, delete), its worker exits, or it calls `unregister()`. The subscription stays. If the producer comes back with the same id, the subscriber gets a `change`.
78
+
79
+ Handle `change` and `connect` the same way: pull now. On `disconnect`, show that the producer is gone.
80
+ - `getEntries()` decides what exists. An entry that `getEntries()` does not return gets no events, does not show in `listProducers()`, and does not get a `disconnect`. Return `[]` to hide the entries until there is a value.
81
+ - `type` is a free-form string that the producer picks, such as `acme/reading_v1`. The bus does not check it.
82
+ - The method names call the value "metadata" (`getMetadata`). It can be any value.
83
+ - When a node closes, the mixin removes its subscriptions, its producers, its `onProducerRegistered` handlers and its attached workers. When a worker exits, the bus removes everything that the worker registered. Subscribers of the removed producers get `disconnect`.
84
+ - There is one bus per Node-RED process. Producer ids must be unique; `thisNode.id` is a good choice.
85
+
86
+ ## API
87
+
88
+ All methods are on `thisNode.nodeRedPubsubBus`.
89
+
90
+ - `registerProducer(producerId, { getEntries, getMetadata })` returns `unregister()`. `getEntries()` returns `[{ name, type }]`. `getMetadata(name)` returns the value or a Promise. Throws if `producerId` is already registered. `producerId` must not contain `/`, because the topic `<producerId>/<entry>` uses `/` to separate the two parts.
91
+ - `notifyChange(producerId, name)`: send a `change` for one entry. Call it after the value changes. If `getEntries()` has no entry `name` (or the producer is not registered), nothing is sent; the node logs a line at `debug` level. Add the entry to `getEntries()` first.
92
+ - `subscribe(topic, handler)` returns `unsubscribe()`. `topic` is `<producerId>/<entry>`. `handler(event)` can be `async`. If it throws or its Promise rejects, the node logs `pubsub subscriber of <topic> failed: <message>` with `node.error`, and the other subscribers and the producer are not affected.
93
+ - `getMetadata(producerId, name)`: always a Promise for the value. It gives `null` if there is no such producer. A producer also returns `null` when it has no value yet, so treat `null` as "no value now". The Promise rejects if the producer's `getMetadata` throws, or if a worker producer does not reply within 5 seconds, so use `await` in a `try`/`catch`.
94
+ - `publish(topic, event)`: emit your own event. It must have `producerId`, `desc`, `type` and `kind`.
95
+ - `onProducerRegistered(handler)` returns `unsubscribe()`. Calls `handler(producerId)` for each new producer, for example one that starts after deploy.
96
+ - `listProducers()`: `[{ producerId, entries: [{ name, type }] }]`. It holds only producers that are registered now, which means deployed and running nodes, from all node packages in this Node-RED. A node that is in the editor but not deployed is not in the list.
97
+ - `listTopics()`, `listSubscribers(topic)`: topic names and the subscriber count, for debugging.
98
+ - `attachWorker(worker)` returns `detach()`. Connects a worker thread to the bus; see below.
99
+
100
+ ## Discovery endpoint
101
+
102
+ The mixin adds `GET /node-red-pubsub-bus/producers` to the admin API when Node-RED loads a node type that uses it, so the route exists before the first deploy. It returns `listProducers()` as JSON (`[]` if no producer runs) and needs the `flows.read` permission. In the editor, use a relative URL, so that the editor sends its login token:
103
+
104
+ ```js
105
+ $.getJSON('node-red-pubsub-bus/producers', (producers) => { /* fill a <select> */ });
106
+ ```
107
+
108
+ ## Producer dropdown in the edit dialog
109
+
110
+ A consumer node stores the producer's node id in a `producer` property. This edit dialog lists the running producers that have an entry `latest` of type `acme/reading_v1`. Each option shows the producer node's name from the editor (`RED.nodes.node(id)`), so the producer needs no label option. If the saved producer does not run now (not deployed, or its worker stopped), the dialog keeps it as a "not running" option, so opening and closing the dialog does not clear it.
111
+
112
+ ```html
113
+ <script type="text/javascript">
114
+ RED.nodes.registerType('bus-consumer', {
115
+ category: 'function',
116
+ defaults: { name: { value: '' }, producer: { value: '' } },
117
+ inputs: 1,
118
+ outputs: 1,
119
+ label: function () { return this.name || 'bus-consumer'; },
120
+ oneditprepare: function () {
121
+ const node = this;
122
+ const select = $('#node-input-producer');
123
+ const label = (id) => { const n = RED.nodes.node(id); return (n && n.name) || id; };
124
+ $.getJSON('node-red-pubsub-bus/producers', function (producers) {
125
+ const running = producers.filter((p) =>
126
+ p.entries.some((e) => e.name === 'latest' && e.type === 'acme/reading_v1'));
127
+ select.empty().append($('<option>').val('').text('- select a producer -'));
128
+ for (const p of running) {
129
+ select.append($('<option>').val(p.producerId).text(label(p.producerId)));
130
+ }
131
+ if (node.producer && !running.some((p) => p.producerId === node.producer)) {
132
+ select.append($('<option>').val(node.producer).text(label(node.producer) + ' (not running)'));
133
+ }
134
+ select.val(node.producer);
135
+ });
136
+ }
137
+ });
138
+ </script>
139
+
140
+ <script type="text/html" data-template-name="bus-consumer">
141
+ <div class="form-row">
142
+ <label for="node-input-name">Name</label>
143
+ <input type="text" id="node-input-name">
144
+ </div>
145
+ <div class="form-row">
146
+ <label for="node-input-producer">Producer</label>
147
+ <select id="node-input-producer"></select>
148
+ </div>
149
+ </script>
150
+ ```
151
+
152
+ Points to know:
153
+
154
+ - Only deployed, running producers are in the list. Add a producer node, then deploy, then open the consumer's dialog.
155
+ - A producer whose `getEntries()` returns `[]` (no value yet) does not pass the filter.
156
+ - The list holds producers of all node packages. Filter by entry name and `type` to keep only the ones your node can read.
157
+
158
+ ## Producers in a worker thread
159
+
160
+ If the data lives in a [worker](../node-red-worker/README.md), attach the worker on the main thread:
161
+
162
+ ```js
163
+ const path = require('path');
164
+ const { NodeRedNodeBase, nodeRedWorker, nodeRedPubsubBus } = require('@nrutils/node-red-utils');
165
+
166
+ module.exports = NodeRedNodeBase.createNode('bus-worker-producer', [nodeRedWorker, nodeRedPubsubBus], (RED, thisNode, config) => {
167
+ const worker = thisNode.nodeRedWorker.init(path.join(__dirname, 'worker.js'), { id: thisNode.id });
168
+ thisNode.nodeRedPubsubBus.attachWorker(worker);
169
+ });
170
+ ```
171
+
172
+ Then use the worker proxy in the worker file:
173
+
174
+ ```js
175
+ const pubsub = require('@nrutils/node-red-utils/node-red-pubsub-bus-workerproxy');
176
+
177
+ module.exports = function (worker, data) {
178
+ let latest = null;
179
+ pubsub.registerProducer(data.id, {
180
+ getEntries: () => (latest === null ? [] : [{ name: 'latest', type: 'acme/reading_v1' }]),
181
+ getMetadata: (name) => (name === 'latest' ? latest : null)
182
+ });
183
+ worker.parentPorts.subscribe('reading', (value) => {
184
+ latest = value;
185
+ pubsub.notifyChange(data.id, 'latest');
186
+ });
187
+ };
188
+ ```
189
+
190
+ Life of a worker producer:
191
+
192
+ - Use the node id as the producer id: pass `thisNode.id` to the worker and register with it. Producer ids are unique in the process, and consumers store this id.
193
+ - On a redeploy the old node closes first. The mixin detaches its worker, the bus removes the producer, and subscribers get `disconnect`. Then the new node starts a new worker, which registers the same id again, and subscribers get `change`.
194
+ - If a worker registers an id that is already registered, the bus does not register it. The node logs the error and shows a red status. Node-RED keeps running.
195
+ - With `nodeRedWorker`, the close handler needs no pubsub code: the mixin detaches the worker and `nodeRedWorker` stops it.
196
+ - With a raw `new Worker(...)`, the mixin also detaches it on close, but the thread keeps running. Call `worker.terminate()` in your close handler.
197
+ - `unregister()` from the proxy only stops the worker's own answers. The main thread removes a worker producer when the node closes or the worker exits.
198
+
199
+ The proxy has `publish`, `subscribe`, `registerProducer`, `notifyChange` and `getMetadata`. Each call goes to the main-thread bus over `parentPort`; `getMetadata` always returns a Promise. That Promise rejects after 5 seconds without a reply, for example when the host did not call `attachWorker`. On its first call the proxy sends a ping that `attachWorker` answers. If no answer comes within 5 seconds, the proxy logs one warning. The usual cause is a missing `attachWorker`.
200
+
201
+ [Back to node-red-utils](../README.md)
@@ -0,0 +1,205 @@
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 fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // All copies of this library in one process share this state.
13
+ // Thus one route serves the files of every copy.
14
+ // Shape: { registry: Map<nodeType, Set<path>>, routeRegistered: boolean }
15
+ const shared = globalThis[Symbol.for('node-red-utils.resourceendpoint')] ??= {};
16
+ shared.registry ??= new Map();
17
+ shared.owners ??= new Map(); // nodeType -> name of the first package that registered it
18
+
19
+ /** Name in the nearest package.json above a path, or null */
20
+ function packageNameOf(resource) {
21
+ let dir = path.dirname(path.resolve(resource));
22
+ for (;;) {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name || null;
25
+ } catch (_) { /* no package.json here */ }
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir) return null;
28
+ dir = parent;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Find the whitelisted file for a request, or return null.
34
+ * A registered file matches its base name only.
35
+ * A registered directory matches a file inside that directory only.
36
+ * @param {Set<string>} resources - Registered file and directory paths
37
+ * @param {string} requestPath - The decoded path after /<nodeType>/
38
+ * @returns {string|null}
39
+ */
40
+ function findResource(resources, requestPath) {
41
+ for (const resource of resources) {
42
+ const root = path.resolve(resource);
43
+ let stat;
44
+ try { stat = fs.statSync(root); } catch (_) { continue; }
45
+
46
+ if (!stat.isDirectory()) {
47
+ if (path.basename(root) === requestPath) return root;
48
+ continue;
49
+ }
50
+
51
+ // Reject a path that resolves outside the registered directory
52
+ const candidate = path.resolve(root, requestPath);
53
+ if (!candidate.startsWith(root + path.sep)) continue;
54
+ try {
55
+ if (fs.statSync(candidate).isFile()) return candidate;
56
+ } catch (_) { /* not in this directory */ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * NodeRedResourceEndpoint - Class for registering and serving static resources
63
+ * Allows Node-RED nodes to serve static assets from a custom endpoint
64
+ */
65
+ class NodeRedResourceEndpoint {
66
+
67
+ /**
68
+ * Create a new resource endpoint handler
69
+ * @param {object} RED - The Node-RED runtime instance
70
+ */
71
+ constructor(RED) {
72
+ this.RED = RED;
73
+
74
+ // Register the route only once per process
75
+ if (!shared.routeRegistered) {
76
+ shared.routeRegistered = true;
77
+ this._setupEndpoint();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Register resources for a node type
83
+ * @param {string} nodeType - The type identifier for the node
84
+ * @param {Array<string>|string} resources - File paths or directory paths to whitelist
85
+ * @returns {NodeRedResourceEndpoint} this object
86
+ */
87
+ registerResources(nodeType, resources) {
88
+ if (!Array.isArray(resources)) resources = [resources];
89
+ if (!shared.registry.has(nodeType)) shared.registry.set(nodeType, new Set());
90
+ const resourceSet = shared.registry.get(nodeType);
91
+ for (const resource of resources) {
92
+ if (resourceSet.has(resource)) continue; // each node instance registers again
93
+ resourceSet.add(resource);
94
+ if (!fs.existsSync(resource)) {
95
+ this.RED.log.warn(`[NodeRedResourceEndpoint] Registered path does not exist: ${resource} (type ${nodeType})`);
96
+ }
97
+ const owner = packageNameOf(resource);
98
+ if (!shared.owners.has(nodeType)) shared.owners.set(nodeType, owner);
99
+ else if (owner && shared.owners.get(nodeType) && owner !== shared.owners.get(nodeType)) {
100
+ this.RED.log.warn(`[NodeRedResourceEndpoint] The packages "${shared.owners.get(nodeType)}" and "${owner}" ` +
101
+ `both register files for "${nodeType}". Both are served under node-red-resourceendpoint/${nodeType}/; ` +
102
+ `for the same file name, the first registered file wins.`);
103
+ }
104
+ }
105
+ return this;
106
+ }
107
+
108
+ /**
109
+ * Setup the HTTP endpoint to serve resources.
110
+ * Like the core /resources/ route of Node-RED, this route has no auth check:
111
+ * the editor loads these files with <script src>, which sends no token.
112
+ * @private
113
+ */
114
+ _setupEndpoint() {
115
+ this.RED.httpAdmin.get('/node-red-resourceendpoint/:nodeType/*', (req, res) => {
116
+ const nodeType = req.params.nodeType;
117
+ const requestPath = req.params[0];
118
+ const resources = shared.registry.get(nodeType);
119
+ const resourcePath = resources ? findResource(resources, requestPath) : null;
120
+
121
+ if (resourcePath) {
122
+ res.sendFile(resourcePath);
123
+ } else {
124
+ this.RED.log.warn(`[NodeRedResourceEndpoint] Resource not found: ${requestPath} for node type: ${nodeType}`);
125
+ res.sendStatus(404);
126
+ }
127
+ });
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Mixin that adds resource endpoint capabilities to Node-RED nodes
133
+ *
134
+ * Allows nodes to serve static assets from a custom endpoint
135
+ * Following the same functional mixin pattern used by NodeRedWorker
136
+ *
137
+ * @param {class} superclass - The class to extend with resource endpoint capabilities
138
+ * @returns {class} A new class that extends the superclass with resource endpoint functionality
139
+ */
140
+ const nodeRedResourceEndpoint = (superclass) => class extends superclass {
141
+ // Serve the `resources` of the createNode options before the first deploy
142
+ static onRegisterType(RED) {
143
+ super.onRegisterType?.(RED);
144
+ const { nodeType, resources } = this._registration || {};
145
+ if (resources !== undefined) new NodeRedResourceEndpoint(RED).registerResources(nodeType, resources);
146
+ }
147
+
148
+ constructor(RED, thisNode, config) {
149
+ super(RED, thisNode, config);
150
+
151
+ // Create a namespaced object for resource endpoint functionality
152
+ this.nodeRedResourceEndpoint = {
153
+ /**
154
+ * Initialize the resource endpoint for this node
155
+ * @param {Object} [options] - Configuration options
156
+ * @param {string} [options.nodeType] - The type identifier for the node. If not provided, thisNode.type will be used
157
+ * @param {Array<string>|string} [options.resources] - File paths or directory paths to whitelist
158
+ * @returns {object} The resource endpoint interface
159
+ */
160
+ init: function(options = {}) {
161
+ // Use provided nodeType or fallback to thisNode.type
162
+ this._nodeType = options.nodeType || thisNode.type;
163
+
164
+ // Get or create the static resource endpoint handler
165
+ if (!NodeRedResourceEndpoint._instance) {
166
+ NodeRedResourceEndpoint._instance = new NodeRedResourceEndpoint(RED);
167
+ }
168
+
169
+ this._parent.nodeRedResourceEndpoint = this;
170
+
171
+ // Register resources if provided
172
+ if (options.resources !== undefined) {
173
+ this.registerResources(options.resources);
174
+ }
175
+
176
+ return this;
177
+ },
178
+
179
+ /**
180
+ * Register resources for a node type
181
+ * @param {Array<string>|string} resources - Array of file paths or directory paths to whitelist
182
+ */
183
+ registerResources: function(resources) {
184
+ if (!this._nodeType) {
185
+ throw new Error('Resource endpoint must be initialized with a node type before registering resources');
186
+ }
187
+
188
+ // Use the class instance to register resources
189
+ NodeRedResourceEndpoint._instance.registerResources(this._nodeType, resources);
190
+ return this;
191
+ },
192
+
193
+ _nodeType: null,
194
+ _parent: this
195
+ };
196
+ thisNode.nodeRedResourceEndpoint = this.nodeRedResourceEndpoint;
197
+ }
198
+
199
+ // Static property to hold the single instance
200
+ static _instance = null;
201
+ };
202
+
203
+ // Export both the class and the mixin
204
+ module.exports.nodeRedResourceEndpoint = nodeRedResourceEndpoint;
205
+ module.exports.NodeRedResourceEndpoint = NodeRedResourceEndpoint;