@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.20
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/LICENSE +1 -1
- package/README.md +154 -79
- package/examples/001-shared-components-flow.json +68 -0
- package/examples/{sensor-portal-flow.json → 002-sensor-portal-flow.json} +3 -3
- package/examples/003-chart-portal-flow.json +93 -0
- package/examples/004-d3-poland-flow.json +80 -0
- package/examples/005-threejs-portal-flow.json +87 -0
- package/examples/006-pixi-portal-flow.json +86 -0
- package/examples/007-webgpu-tsl-flow.json +85 -0
- package/nodes/lib/assets.js +212 -0
- package/nodes/lib/helpers.js +308 -0
- package/nodes/lib/hooks.js +82 -0
- package/nodes/lib/page-builder.js +219 -0
- package/nodes/lib/router.js +56 -0
- package/nodes/portal-react.html +1139 -195
- package/nodes/portal-react.js +720 -349
- package/package.json +21 -11
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
package/nodes/portal-react.js
CHANGED
|
@@ -1,29 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @aaqu/fromcubes-portal-react
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Node-RED node that serves React apps from configurable HTTP endpoints
|
|
5
|
+
* with live WebSocket data binding. JSX is transpiled server-side via esbuild
|
|
6
|
+
* at deploy time — browsers receive pre-compiled JS.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
|
-
const fs = require("fs");
|
|
11
10
|
const path = require("path");
|
|
12
|
-
const esbuild = require("esbuild");
|
|
13
|
-
|
|
14
|
-
const reactBundle = fs.readFileSync(
|
|
15
|
-
path.join(__dirname, "vendor", "react-19.production.min.js"),
|
|
16
|
-
"utf8",
|
|
17
|
-
);
|
|
18
|
-
const reactHash = crypto
|
|
19
|
-
.createHash("sha256")
|
|
20
|
-
.update(reactBundle)
|
|
21
|
-
.digest("hex")
|
|
22
|
-
.slice(0, 10);
|
|
23
11
|
|
|
24
12
|
module.exports = function (RED) {
|
|
25
13
|
// ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
|
|
26
14
|
const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
15
|
+
const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
27
16
|
|
|
28
17
|
// ── Shared state ──────────────────────────────────────────────
|
|
29
18
|
// Component registry: populated by fc-portal-component canvas nodes at deploy time
|
|
@@ -32,12 +21,6 @@ module.exports = function (RED) {
|
|
|
32
21
|
}
|
|
33
22
|
const registry = RED.settings.fcPortalRegistry;
|
|
34
23
|
|
|
35
|
-
// CSS cache: hash → css string
|
|
36
|
-
if (!RED.settings.fcCssCache) {
|
|
37
|
-
RED.settings.fcCssCache = {};
|
|
38
|
-
}
|
|
39
|
-
const cssCache = RED.settings.fcCssCache;
|
|
40
|
-
|
|
41
24
|
// Active upgrade handlers per node id (for cleanup on redeploy)
|
|
42
25
|
if (!RED.settings.fcUpgradeHandlers) {
|
|
43
26
|
RED.settings.fcUpgradeHandlers = {};
|
|
@@ -62,84 +45,143 @@ module.exports = function (RED) {
|
|
|
62
45
|
}
|
|
63
46
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
64
47
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
48
|
+
// Per-portal set of component names the portal depends on (needed set from last rebuild,
|
|
49
|
+
// including transitive deps). Lets component changes target only portals that use them.
|
|
50
|
+
if (!RED.settings.fcPortalNeeded) {
|
|
51
|
+
RED.settings.fcPortalNeeded = {};
|
|
69
52
|
}
|
|
53
|
+
const portalNeeded = RED.settings.fcPortalNeeded;
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
async function getTwCompiled() {
|
|
76
|
-
if (twCompiled) return twCompiled;
|
|
77
|
-
twCompiled = await twCompile(`@import 'tailwindcss';`, {
|
|
78
|
-
loadStylesheet: async (id, base) => {
|
|
79
|
-
let resolved;
|
|
80
|
-
if (id === "tailwindcss") {
|
|
81
|
-
resolved = require.resolve("tailwindcss/index.css");
|
|
82
|
-
} else {
|
|
83
|
-
resolved = require.resolve(id, { paths: [base || __dirname] });
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
content: fs.readFileSync(resolved, "utf8"),
|
|
87
|
-
base: path.dirname(resolved),
|
|
88
|
-
};
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
return twCompiled;
|
|
55
|
+
// Per-portal raw user JSX code string. Used as fallback to detect references to
|
|
56
|
+
// newly-added components that haven't been in a `needed` set yet.
|
|
57
|
+
if (!RED.settings.fcPortalCode) {
|
|
58
|
+
RED.settings.fcPortalCode = {};
|
|
92
59
|
}
|
|
60
|
+
const portalCode = RED.settings.fcPortalCode;
|
|
93
61
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
stdin: {
|
|
98
|
-
contents: jsx,
|
|
99
|
-
resolveDir: path.join(__dirname, "../../.."),
|
|
100
|
-
loader: "jsx",
|
|
101
|
-
},
|
|
102
|
-
bundle: true,
|
|
103
|
-
format: "iife",
|
|
104
|
-
write: false,
|
|
105
|
-
target: ["es2020"],
|
|
106
|
-
jsx: "transform",
|
|
107
|
-
jsxFactory: "React.createElement",
|
|
108
|
-
jsxFragment: "React.Fragment",
|
|
109
|
-
external: ["react", "react-dom"],
|
|
110
|
-
define: { "process.env.NODE_ENV": '"production"' },
|
|
111
|
-
});
|
|
112
|
-
return { js: buildResult.outputFiles[0].text, error: null };
|
|
113
|
-
} catch (e) {
|
|
114
|
-
return { js: null, error: e.message };
|
|
115
|
-
}
|
|
62
|
+
// Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
|
|
63
|
+
if (!RED.settings.fcEndpointOwners) {
|
|
64
|
+
RED.settings.fcEndpointOwners = {};
|
|
116
65
|
}
|
|
66
|
+
const endpointOwners = RED.settings.fcEndpointOwners;
|
|
117
67
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const compiled = await getTwCompiled();
|
|
122
|
-
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
123
|
-
const css = compiled.build(candidates);
|
|
124
|
-
cssCache[key] = css;
|
|
125
|
-
return css;
|
|
68
|
+
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
69
|
+
if (!RED.settings.fcCompNameOwners) {
|
|
70
|
+
RED.settings.fcCompNameOwners = {};
|
|
126
71
|
}
|
|
72
|
+
const compNameOwners = RED.settings.fcCompNameOwners;
|
|
127
73
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
|
|
133
|
-
);
|
|
74
|
+
// Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
|
|
75
|
+
// portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
|
|
76
|
+
if (!RED.settings.fcPortalSig) {
|
|
77
|
+
RED.settings.fcPortalSig = {};
|
|
134
78
|
}
|
|
79
|
+
const portalSig = RED.settings.fcPortalSig;
|
|
80
|
+
|
|
81
|
+
// Debounced selective rebuild: coalesces multiple component changes into one pass.
|
|
82
|
+
// Yields event loop between builds so HTTP server stays responsive.
|
|
83
|
+
let _rebuildTimer = null;
|
|
84
|
+
const _dirtyComps = new Set();
|
|
85
|
+
const _dirtyPortals = new Set();
|
|
86
|
+
|
|
87
|
+
// Startup gate: on first process start, Node-RED constructs portal/component nodes
|
|
88
|
+
// sequentially over a window longer than the 50ms debounce. Without gating, an early
|
|
89
|
+
// flush rebuilds a portal, then a late component registration triggers a second
|
|
90
|
+
// rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
|
|
91
|
+
// collapses to exactly one rebuild pass.
|
|
92
|
+
let _startupPhase = true;
|
|
93
|
+
function _endStartupPhase() {
|
|
94
|
+
if (!_startupPhase) return;
|
|
95
|
+
_startupPhase = false;
|
|
96
|
+
if (_dirtyPortals.size > 0 || _dirtyComps.size > 0) {
|
|
97
|
+
if (_rebuildTimer) { clearTimeout(_rebuildTimer); _rebuildTimer = null; }
|
|
98
|
+
_flushRebuild();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
if (RED.events && typeof RED.events.once === "function") {
|
|
103
|
+
RED.events.once("flows:started", _endStartupPhase);
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { RED.log.trace("[portal-react] events.once: " + e.message); }
|
|
106
|
+
// Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
|
|
107
|
+
setTimeout(_endStartupPhase, 2000).unref?.();
|
|
108
|
+
|
|
109
|
+
function _armRebuild() {
|
|
110
|
+
if (_startupPhase) return; // gated — _endStartupPhase will flush
|
|
111
|
+
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
112
|
+
_rebuildTimer = setTimeout(_flushRebuild, 50);
|
|
113
|
+
}
|
|
114
|
+
function scheduleRebuildSelf(nodeId) {
|
|
115
|
+
if (!nodeId) return;
|
|
116
|
+
_dirtyPortals.add(nodeId);
|
|
117
|
+
_armRebuild();
|
|
118
|
+
}
|
|
119
|
+
function scheduleRebuildUsing(compName) {
|
|
120
|
+
if (!compName) return;
|
|
121
|
+
_dirtyComps.add(compName);
|
|
122
|
+
_armRebuild();
|
|
123
|
+
}
|
|
124
|
+
function _flushRebuild() {
|
|
125
|
+
_rebuildTimer = null;
|
|
126
|
+
const dirty = new Set(_dirtyComps);
|
|
127
|
+
const selfIds = new Set(_dirtyPortals);
|
|
128
|
+
_dirtyComps.clear();
|
|
129
|
+
_dirtyPortals.clear();
|
|
130
|
+
|
|
131
|
+
const targetIds = new Set(selfIds);
|
|
132
|
+
if (dirty.size > 0) {
|
|
133
|
+
for (const nodeId of Object.keys(rebuildCallbacks)) {
|
|
134
|
+
if (targetIds.has(nodeId)) continue;
|
|
135
|
+
const used = portalNeeded[nodeId];
|
|
136
|
+
const raw = portalCode[nodeId] || "";
|
|
137
|
+
for (const name of dirty) {
|
|
138
|
+
if ((used && used.has(name)) || raw.includes(name)) {
|
|
139
|
+
targetIds.add(nodeId);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
135
145
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
|
|
147
|
+
let i = 0;
|
|
148
|
+
function next() {
|
|
149
|
+
if (i >= fns.length) return;
|
|
150
|
+
try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
151
|
+
i++;
|
|
152
|
+
if (i < fns.length) setImmediate(next);
|
|
153
|
+
}
|
|
154
|
+
next();
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
// ── Load modules ─────────────────────────────────────────────
|
|
158
|
+
const helpers = require("./lib/helpers")(RED);
|
|
159
|
+
const {
|
|
160
|
+
hash,
|
|
161
|
+
transpile,
|
|
162
|
+
generateCSS,
|
|
163
|
+
extractPortalUser,
|
|
164
|
+
removeRoute,
|
|
165
|
+
isSafeName,
|
|
166
|
+
validateSubPath,
|
|
167
|
+
userDir,
|
|
168
|
+
readCachedJS,
|
|
169
|
+
writeCachedJS,
|
|
170
|
+
readCachedCSS,
|
|
171
|
+
writeCachedCSS,
|
|
172
|
+
deleteCacheFiles,
|
|
173
|
+
isHashInUse,
|
|
174
|
+
} = helpers;
|
|
175
|
+
const { buildPage, buildErrorPage } = require("./lib/page-builder");
|
|
176
|
+
const hooks = require("./lib/hooks")(RED);
|
|
177
|
+
const router = require("./lib/router");
|
|
178
|
+
|
|
179
|
+
// Per-process cache of the last broadcast payload per endpoint.
|
|
180
|
+
// Lets a freshly-connected client see the most recent broadcast value
|
|
181
|
+
// (similar to dashboard2's lastMsg recovery). Sent as a distinct
|
|
182
|
+
// `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
|
|
183
|
+
const lastBroadcastCache = new Map();
|
|
184
|
+
|
|
143
185
|
// ── Canvas node: shared component ─────────────────────────────
|
|
144
186
|
|
|
145
187
|
function PortalComponentNode(config) {
|
|
@@ -153,31 +195,42 @@ module.exports = function (RED) {
|
|
|
153
195
|
return;
|
|
154
196
|
}
|
|
155
197
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
// Duplicate component name check
|
|
199
|
+
const existingOwner = compNameOwners[compName];
|
|
200
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
201
|
+
node.error(
|
|
202
|
+
`Component name "${compName}" is already used by another node`,
|
|
203
|
+
);
|
|
204
|
+
node.status({
|
|
205
|
+
fill: "red",
|
|
206
|
+
shape: "ring",
|
|
207
|
+
text: "duplicate: " + compName,
|
|
208
|
+
});
|
|
209
|
+
node.on("close", function (_removed, done) {
|
|
210
|
+
if (done) done();
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
compNameOwners[compName] = node.id;
|
|
215
|
+
|
|
216
|
+
const newCode = config.compCode || "";
|
|
217
|
+
const prevCode = registry[compName]?.code;
|
|
218
|
+
registry[compName] = { code: newCode };
|
|
171
219
|
|
|
172
220
|
node.status({ fill: "green", shape: "dot", text: compName });
|
|
173
221
|
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
222
|
+
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
223
|
+
if (prevCode !== newCode) {
|
|
224
|
+
scheduleRebuildUsing(compName);
|
|
225
|
+
}
|
|
178
226
|
|
|
179
227
|
node.on("close", function (removed, done) {
|
|
228
|
+
if (compNameOwners[compName] === node.id) {
|
|
229
|
+
delete compNameOwners[compName];
|
|
230
|
+
}
|
|
180
231
|
delete registry[compName];
|
|
232
|
+
// Portals depending on this component must rebuild (topology changed or name resolution breaks).
|
|
233
|
+
scheduleRebuildUsing(compName);
|
|
181
234
|
if (done) done();
|
|
182
235
|
});
|
|
183
236
|
}
|
|
@@ -191,130 +244,459 @@ module.exports = function (RED) {
|
|
|
191
244
|
const nodeId = node.id;
|
|
192
245
|
|
|
193
246
|
// Config
|
|
194
|
-
const
|
|
247
|
+
const subPathResult = validateSubPath(config.subPath);
|
|
248
|
+
const legacyEndpoint =
|
|
249
|
+
typeof config.endpoint === "string" && config.endpoint.trim().length > 0
|
|
250
|
+
? config.endpoint.trim()
|
|
251
|
+
: null;
|
|
252
|
+
|
|
253
|
+
if (!subPathResult.ok) {
|
|
254
|
+
// No valid subPath. If there's a legacy endpoint, hard-fail with a
|
|
255
|
+
// migration message; otherwise fail on the sub-path error.
|
|
256
|
+
if (legacyEndpoint) {
|
|
257
|
+
node.error(
|
|
258
|
+
`Legacy 'endpoint' field detected ("${legacyEndpoint}"). ` +
|
|
259
|
+
"Open the node, set a Sub-path (served under /fromcubes/<sub-path>), and redeploy.",
|
|
260
|
+
);
|
|
261
|
+
node.status({ fill: "red", shape: "ring", text: "legacy endpoint" });
|
|
262
|
+
} else {
|
|
263
|
+
node.error("Invalid sub-path: " + subPathResult.error);
|
|
264
|
+
node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
|
|
265
|
+
}
|
|
266
|
+
node.on("close", function (_removed, done) {
|
|
267
|
+
if (done) done();
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const subPath = subPathResult.value;
|
|
272
|
+
const endpoint = "/fromcubes/" + subPath;
|
|
273
|
+
|
|
195
274
|
const componentCode = config.componentCode || "";
|
|
196
275
|
const pageTitle = config.pageTitle || "Portal";
|
|
197
276
|
const customHead = config.customHead || "";
|
|
277
|
+
const portalAuth = config.portalAuth === true;
|
|
278
|
+
const showWsStatus = config.showWsStatus === true;
|
|
279
|
+
const libs = config.libs || [];
|
|
280
|
+
|
|
281
|
+
// ── Duplicate endpoint check ──
|
|
282
|
+
const existingOwner = endpointOwners[endpoint];
|
|
283
|
+
if (existingOwner && existingOwner !== nodeId) {
|
|
284
|
+
node.error(
|
|
285
|
+
`Endpoint "${endpoint}" is already used by another portal node`,
|
|
286
|
+
);
|
|
287
|
+
node.status({
|
|
288
|
+
fill: "red",
|
|
289
|
+
shape: "ring",
|
|
290
|
+
text: "duplicate: " + endpoint,
|
|
291
|
+
});
|
|
292
|
+
node.on("close", function (_removed, done) {
|
|
293
|
+
if (done) done();
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
endpointOwners[endpoint] = nodeId;
|
|
198
298
|
|
|
199
299
|
// State
|
|
200
|
-
const clients = new
|
|
201
|
-
|
|
300
|
+
const clients = new Map(); // portalClient → ws
|
|
301
|
+
const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
|
|
202
302
|
let wsServer = null;
|
|
203
303
|
let isClosing = false;
|
|
304
|
+
let lastJsxHash = null;
|
|
204
305
|
|
|
205
|
-
|
|
306
|
+
if (libs.length > 0) {
|
|
307
|
+
node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
|
|
308
|
+
} else {
|
|
309
|
+
node.status({ fill: "yellow", shape: "ring", text: "starting..." });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
313
|
+
|
|
314
|
+
function updateStatus() {
|
|
315
|
+
if (isClosing) return;
|
|
316
|
+
const n = clients.size;
|
|
317
|
+
node.status({
|
|
318
|
+
fill: n > 0 ? "green" : "grey",
|
|
319
|
+
shape: n > 0 ? "dot" : "ring",
|
|
320
|
+
text: `${endpoint} [${n} client${n !== 1 ? "s" : ""}]`,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
206
323
|
|
|
207
324
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
208
325
|
|
|
209
326
|
function rebuild() {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const names = entries.map(([n]) => n);
|
|
213
|
-
entries.sort((a, b) => {
|
|
214
|
-
const aUsesB = a[1].code.includes(b[0]);
|
|
215
|
-
const bUsesA = b[1].code.includes(a[0]);
|
|
216
|
-
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
217
|
-
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
218
|
-
return 0;
|
|
219
|
-
});
|
|
220
|
-
const libraryJsx = entries
|
|
221
|
-
.map(([name, c]) =>
|
|
222
|
-
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`
|
|
223
|
-
)
|
|
224
|
-
.join("\n\n");
|
|
225
|
-
|
|
226
|
-
const fullJsx = [
|
|
227
|
-
"// ── React shorthand ──",
|
|
228
|
-
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
229
|
-
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
230
|
-
"",
|
|
231
|
-
"// ── useNodeRed hook ──",
|
|
232
|
-
`function useNodeRed() {
|
|
233
|
-
const [data, setData] = React.useState(window.__NR._lastData);
|
|
234
|
-
React.useEffect(() => {
|
|
235
|
-
return window.__NR.subscribe(setData);
|
|
236
|
-
}, []);
|
|
237
|
-
const send = React.useCallback((payload, topic) => {
|
|
238
|
-
window.__NR.send(payload, topic);
|
|
239
|
-
}, []);
|
|
240
|
-
return { data, send };
|
|
241
|
-
}`,
|
|
242
|
-
"",
|
|
243
|
-
"// ── Library components ──",
|
|
244
|
-
libraryJsx,
|
|
245
|
-
"",
|
|
246
|
-
"// ── View component ──",
|
|
247
|
-
componentCode,
|
|
248
|
-
"",
|
|
249
|
-
"// ── Mount ──",
|
|
250
|
-
"ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
251
|
-
].join("\n");
|
|
327
|
+
try {
|
|
328
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
252
329
|
|
|
253
|
-
|
|
330
|
+
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
331
|
+
const prevState = pageState[endpoint];
|
|
332
|
+
const prevHash = prevState?.jsxHash;
|
|
333
|
+
if (prevHash && !isHashInUse(prevHash, pageState, endpoint)) {
|
|
334
|
+
deleteCacheFiles(prevHash);
|
|
335
|
+
}
|
|
336
|
+
pageState[endpoint] = { building: true, wsPath, pageTitle };
|
|
337
|
+
clients.forEach((ws) => {
|
|
338
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
|
|
339
|
+
});
|
|
254
340
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
341
|
+
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
342
|
+
const allEntries = Object.entries(registry);
|
|
343
|
+
const needed = new Set();
|
|
344
|
+
|
|
345
|
+
function addWithDeps(name) {
|
|
346
|
+
if (needed.has(name)) return;
|
|
347
|
+
const entry = registry[name];
|
|
348
|
+
if (!entry) return;
|
|
349
|
+
needed.add(name);
|
|
350
|
+
for (const [other] of allEntries) {
|
|
351
|
+
if (other !== name && entry.code.includes(other)) {
|
|
352
|
+
addWithDeps(other);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
261
356
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
357
|
+
for (const [name] of allEntries) {
|
|
358
|
+
if (componentCode.includes(name)) {
|
|
359
|
+
addWithDeps(name);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Remember which components this portal depends on, so component changes
|
|
364
|
+
// can target only affected portals.
|
|
365
|
+
portalNeeded[nodeId] = new Set(needed);
|
|
366
|
+
|
|
367
|
+
// Topological sort only needed components
|
|
368
|
+
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
369
|
+
entries.sort((a, b) => {
|
|
370
|
+
const aUsesB = a[1].code.includes(b[0]);
|
|
371
|
+
const bUsesA = b[1].code.includes(a[0]);
|
|
372
|
+
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
373
|
+
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
374
|
+
return 0;
|
|
375
|
+
});
|
|
376
|
+
const libraryJsx = entries
|
|
377
|
+
.map(
|
|
378
|
+
([name, c]) =>
|
|
379
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
|
|
380
|
+
)
|
|
381
|
+
.join("\n\n");
|
|
382
|
+
|
|
383
|
+
// Extract import statements from library/user code so they appear at top level
|
|
384
|
+
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
385
|
+
const libImports = libraryJsx.match(importRe) || [];
|
|
386
|
+
const userImports = componentCode.match(importRe) || [];
|
|
387
|
+
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
388
|
+
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
389
|
+
|
|
390
|
+
// Warn about import * (prevents tree-shaking)
|
|
391
|
+
const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
|
|
392
|
+
const allCode = cleanLibJsx + "\n" + cleanCompCode;
|
|
393
|
+
for (const imp of [...libImports, ...userImports]) {
|
|
394
|
+
const m = imp.match(starRe);
|
|
395
|
+
if (!m) continue;
|
|
396
|
+
const [, localName, modulePath] = m;
|
|
397
|
+
const propRe = new RegExp(
|
|
398
|
+
`\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`,
|
|
399
|
+
"g",
|
|
400
|
+
);
|
|
401
|
+
const props = new Set();
|
|
402
|
+
let pm;
|
|
403
|
+
while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
|
|
404
|
+
if (props.size > 0) {
|
|
405
|
+
const named = [...props].sort().join(", ");
|
|
406
|
+
node.warn(
|
|
407
|
+
`"import * as ${localName}" bundles entire ${modulePath} library. ` +
|
|
408
|
+
`For smaller builds use: import { ${named} } from '${modulePath}'`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const fullJsx = [
|
|
414
|
+
"// ── Imports ──",
|
|
415
|
+
'import React from "react";',
|
|
416
|
+
'import ReactDOM from "react-dom";',
|
|
417
|
+
'import { createRoot } from "react-dom/client";',
|
|
418
|
+
...libImports,
|
|
419
|
+
...userImports,
|
|
420
|
+
"",
|
|
421
|
+
"// ── React shorthand ──",
|
|
422
|
+
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
423
|
+
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
424
|
+
"",
|
|
425
|
+
"// ── useNodeRed hook ──",
|
|
426
|
+
[
|
|
427
|
+
"function useNodeRed(opts) {",
|
|
428
|
+
" // opts.ignoreRecovery = true → ignore the cached last-broadcast",
|
|
429
|
+
" // frame the server sends on connect; data stays undefined until",
|
|
430
|
+
" // a fresh broadcast arrives. Latched once globally — strictest",
|
|
431
|
+
" // call wins (any caller asking to ignore disables recovery for all).",
|
|
432
|
+
" if (opts && opts.ignoreRecovery) window.__NR._ignoreRecovery = true;",
|
|
433
|
+
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
434
|
+
" React.useEffect(() => window.__NR.subscribe(setData), []);",
|
|
435
|
+
" const send = React.useCallback((payload, topic) => {",
|
|
436
|
+
" window.__NR.send(payload, topic);",
|
|
437
|
+
" }, []);",
|
|
438
|
+
" const user = window.__NR._user || null;",
|
|
439
|
+
" const portalClient = window.__NR._portalClient;",
|
|
440
|
+
" return { data, send, user, portalClient };",
|
|
441
|
+
"}",
|
|
442
|
+
].join("\n"),
|
|
443
|
+
"",
|
|
444
|
+
"// ── Library components ──",
|
|
445
|
+
cleanLibJsx,
|
|
446
|
+
"",
|
|
447
|
+
"// ── View component ──",
|
|
448
|
+
cleanCompCode,
|
|
449
|
+
"",
|
|
450
|
+
"// ── Mount ──",
|
|
451
|
+
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
452
|
+
].join("\n");
|
|
453
|
+
|
|
454
|
+
// ── Check: missing return in App ──
|
|
455
|
+
const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
|
|
456
|
+
if (appFnMatch) {
|
|
457
|
+
let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
|
|
458
|
+
let hasReturn = false;
|
|
459
|
+
while (i < cleanCompCode.length && depth > 0) {
|
|
460
|
+
const ch = cleanCompCode[i];
|
|
461
|
+
if (ch === "{") depth++;
|
|
462
|
+
else if (ch === "}") depth--;
|
|
463
|
+
else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
|
|
464
|
+
i++;
|
|
465
|
+
}
|
|
466
|
+
if (!hasReturn) {
|
|
467
|
+
node.error("App component has no return statement");
|
|
468
|
+
node.status({ fill: "red", shape: "dot", text: "missing return" });
|
|
469
|
+
const missingReturnError = {
|
|
470
|
+
js: null,
|
|
471
|
+
error: "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
472
|
+
};
|
|
473
|
+
pageState[endpoint] = {
|
|
474
|
+
compiled: missingReturnError,
|
|
475
|
+
contentHash: "",
|
|
476
|
+
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
477
|
+
jsxHash: "",
|
|
478
|
+
css: null,
|
|
479
|
+
cssHash: "",
|
|
480
|
+
pageTitle,
|
|
481
|
+
wsPath,
|
|
482
|
+
customHead,
|
|
483
|
+
portalAuth,
|
|
484
|
+
showWsStatus,
|
|
485
|
+
};
|
|
486
|
+
clients.forEach((ws) => {
|
|
487
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (e) { RED.log.trace("[portal-react] ws send error frame: " + e.message); }
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const jsxHash = hash(fullJsx);
|
|
494
|
+
|
|
495
|
+
// ── JS: disk cache → transpile ──
|
|
496
|
+
let compiled = readCachedJS(jsxHash);
|
|
497
|
+
let cacheHit = !!compiled;
|
|
498
|
+
if (!compiled) {
|
|
499
|
+
compiled = transpile(fullJsx);
|
|
500
|
+
if (!compiled.error) {
|
|
501
|
+
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (compiled.error) {
|
|
506
|
+
node.error("JSX transpile error: " + compiled.error);
|
|
507
|
+
RED.log.warn(
|
|
508
|
+
`[portal-react] ${endpoint} — JSX transpile error: ${compiled.error}`,
|
|
509
|
+
);
|
|
510
|
+
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
511
|
+
clients.forEach((ws) => {
|
|
512
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (e) { RED.log.trace("[portal-react] ws send transpile error: " + e.message); }
|
|
513
|
+
});
|
|
514
|
+
} else {
|
|
515
|
+
updateStatus();
|
|
516
|
+
if (compiled.metafile) {
|
|
517
|
+
const output = Object.values(compiled.metafile.outputs)[0];
|
|
518
|
+
const sizes = output
|
|
519
|
+
? Object.entries(output.inputs)
|
|
520
|
+
.map(([name, info]) => ({
|
|
521
|
+
name: name
|
|
522
|
+
.replace(/^.*node_modules\//, "")
|
|
523
|
+
.replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
|
|
524
|
+
bytes: info.bytesInOutput,
|
|
525
|
+
}))
|
|
526
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
527
|
+
.slice(0, 5)
|
|
528
|
+
: [];
|
|
529
|
+
const totalKB = Math.round(compiled.js.length / 1024);
|
|
530
|
+
const top = sizes
|
|
531
|
+
.map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
|
|
532
|
+
.join(" · ");
|
|
533
|
+
node.log(
|
|
534
|
+
`[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
540
|
+
|
|
541
|
+
// ── CSS: disk cache → in-memory → generate ──
|
|
542
|
+
const cssReady = !compiled.error
|
|
543
|
+
? (() => {
|
|
544
|
+
const cachedCSS = readCachedCSS(jsxHash);
|
|
545
|
+
if (cachedCSS) return Promise.resolve(cachedCSS);
|
|
546
|
+
if (prevState?.jsxHash === jsxHash && prevState?.css) {
|
|
547
|
+
return Promise.resolve({
|
|
548
|
+
css: prevState.css,
|
|
549
|
+
cssHash: prevState.cssHash,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return generateCSS(fullJsx).then(({ css, cssHash }) => {
|
|
553
|
+
writeCachedCSS(jsxHash, css);
|
|
554
|
+
return { css, cssHash };
|
|
555
|
+
});
|
|
556
|
+
})().catch((err) => {
|
|
269
557
|
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
270
|
-
return "";
|
|
558
|
+
return { css: "", cssHash: "" };
|
|
271
559
|
})
|
|
272
|
-
|
|
560
|
+
: Promise.resolve({ css: "", cssHash: "" });
|
|
561
|
+
|
|
562
|
+
lastJsxHash = jsxHash;
|
|
563
|
+
|
|
564
|
+
pageState[endpoint] = {
|
|
565
|
+
compiled,
|
|
566
|
+
contentHash,
|
|
567
|
+
cssReady,
|
|
568
|
+
jsxHash,
|
|
569
|
+
css: null,
|
|
570
|
+
cssHash: "",
|
|
571
|
+
pageTitle,
|
|
572
|
+
wsPath,
|
|
573
|
+
customHead,
|
|
574
|
+
portalAuth,
|
|
575
|
+
showWsStatus,
|
|
576
|
+
};
|
|
273
577
|
|
|
274
|
-
|
|
275
|
-
compiled
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
578
|
+
// Notify all connected browsers that build finished — triggers reload or overlay cleanup
|
|
579
|
+
if (!compiled.error && contentHash) {
|
|
580
|
+
const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
|
|
581
|
+
clients.forEach((ws) => {
|
|
582
|
+
try { if (ws.readyState === 1) ws.send(versionFrame); } catch (e) { RED.log.trace("[portal-react] ws send version: " + e.message); }
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
cssReady.then(({ css, cssHash }) => {
|
|
587
|
+
const state = pageState[endpoint];
|
|
588
|
+
if (state && state.jsxHash === jsxHash) {
|
|
589
|
+
state.css = css;
|
|
590
|
+
state.cssHash = cssHash;
|
|
591
|
+
updateStatus();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
} catch (e) {
|
|
595
|
+
node.error("Rebuild failed: " + e.message);
|
|
596
|
+
node.status({ fill: "red", shape: "dot", text: "rebuild error" });
|
|
597
|
+
}
|
|
281
598
|
}
|
|
282
599
|
|
|
283
600
|
// Register rebuild callback so library components can trigger re-transpile
|
|
284
601
|
rebuildCallbacks[nodeId] = rebuild;
|
|
285
|
-
|
|
286
|
-
|
|
602
|
+
// Remember raw user JSX so selective rebuild can detect references to new components
|
|
603
|
+
portalCode[nodeId] = componentCode;
|
|
604
|
+
|
|
605
|
+
// No-op redeploy detection: if nothing in the portal's config changed AND a valid
|
|
606
|
+
// build already exists for this endpoint, skip rebuild. Node-RED Full deploy
|
|
607
|
+
// reconstructs every node even when unchanged — without this check every portal
|
|
608
|
+
// would rebuild on every Full deploy.
|
|
609
|
+
const sig = hash(
|
|
610
|
+
[
|
|
611
|
+
componentCode,
|
|
612
|
+
JSON.stringify(libs),
|
|
613
|
+
pageTitle,
|
|
614
|
+
customHead,
|
|
615
|
+
String(portalAuth),
|
|
616
|
+
String(showWsStatus),
|
|
617
|
+
].join("\0"),
|
|
618
|
+
);
|
|
619
|
+
const prevSig = portalSig[nodeId];
|
|
620
|
+
const existing = pageState[endpoint];
|
|
621
|
+
const hasValidBuild =
|
|
622
|
+
!!existing && !existing.building && !existing.compiled?.error;
|
|
623
|
+
portalSig[nodeId] = sig;
|
|
624
|
+
|
|
625
|
+
if (prevSig !== sig || !hasValidBuild) {
|
|
626
|
+
scheduleRebuildSelf(nodeId);
|
|
627
|
+
} else {
|
|
628
|
+
node.log(`[${nodeId}] unchanged — skipping rebuild`);
|
|
629
|
+
node.status({ fill: "grey", shape: "ring", text: `${endpoint} [0 clients]` });
|
|
630
|
+
}
|
|
287
631
|
setImmediate(() => {
|
|
288
|
-
rebuild();
|
|
289
|
-
|
|
290
632
|
// Register route only once per endpoint (persists across deploys)
|
|
291
633
|
if (!registeredRoutes[endpoint]) {
|
|
292
634
|
RED.httpNode.get(endpoint, async function (_req, res) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
635
|
+
try {
|
|
636
|
+
const state = pageState[endpoint];
|
|
637
|
+
if (!state || state.building) {
|
|
638
|
+
const bWsPath = state?.wsPath || wsPath;
|
|
639
|
+
res
|
|
640
|
+
.set("Cache-Control", "no-store")
|
|
641
|
+
.set("Refresh", "3")
|
|
642
|
+
.type("text/html")
|
|
643
|
+
.send(
|
|
644
|
+
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){var r=0;function c(){var p=location.protocol==='https:'?'wss:':'ws:';var ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{var m=JSON.parse(e.data);if(m.type==='version'||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){var d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
|
|
645
|
+
);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
res.set("Cache-Control", "no-store");
|
|
649
|
+
if (state.compiled.error) {
|
|
650
|
+
res
|
|
651
|
+
.status(500)
|
|
652
|
+
.type("text/html")
|
|
653
|
+
.send(
|
|
654
|
+
buildErrorPage(
|
|
655
|
+
state.pageTitle,
|
|
656
|
+
state.compiled.error,
|
|
657
|
+
state.wsPath,
|
|
658
|
+
),
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const { cssHash } = await Promise.race([
|
|
663
|
+
state.cssReady,
|
|
664
|
+
new Promise((_, reject) =>
|
|
665
|
+
setTimeout(
|
|
666
|
+
() => reject(new Error("CSS generation timeout")),
|
|
667
|
+
15000,
|
|
668
|
+
),
|
|
669
|
+
),
|
|
670
|
+
]);
|
|
671
|
+
const user = state.portalAuth
|
|
672
|
+
? extractPortalUser(_req.headers)
|
|
673
|
+
: null;
|
|
674
|
+
res
|
|
675
|
+
.type("text/html")
|
|
676
|
+
.send(
|
|
677
|
+
buildPage(
|
|
678
|
+
state.pageTitle,
|
|
679
|
+
state.compiled.js,
|
|
680
|
+
state.wsPath,
|
|
681
|
+
state.customHead,
|
|
682
|
+
cssHash,
|
|
683
|
+
user,
|
|
684
|
+
state.showWsStatus,
|
|
685
|
+
adminRoot,
|
|
686
|
+
),
|
|
687
|
+
);
|
|
688
|
+
} catch (e) {
|
|
300
689
|
res
|
|
301
690
|
.status(500)
|
|
302
691
|
.type("text/html")
|
|
303
|
-
.send(
|
|
304
|
-
|
|
692
|
+
.send(
|
|
693
|
+
buildErrorPage(
|
|
694
|
+
pageTitle,
|
|
695
|
+
"Page build failed: " + e.message,
|
|
696
|
+
wsPath,
|
|
697
|
+
),
|
|
698
|
+
);
|
|
305
699
|
}
|
|
306
|
-
const cssHash = await state.cssHashReady;
|
|
307
|
-
res
|
|
308
|
-
.type("text/html")
|
|
309
|
-
.send(
|
|
310
|
-
buildPage(
|
|
311
|
-
state.pageTitle,
|
|
312
|
-
state.compiled.js,
|
|
313
|
-
state.wsPath,
|
|
314
|
-
state.customHead,
|
|
315
|
-
cssHash,
|
|
316
|
-
),
|
|
317
|
-
);
|
|
318
700
|
});
|
|
319
701
|
registeredRoutes[endpoint] = true;
|
|
320
702
|
}
|
|
@@ -341,6 +723,12 @@ module.exports = function (RED) {
|
|
|
341
723
|
pathname = request.url;
|
|
342
724
|
}
|
|
343
725
|
if (pathname === wsPath) {
|
|
726
|
+
// Plugin hook: plugins may reject the connection before upgrade.
|
|
727
|
+
// Default (no plugins) = allowed, matches dashboard behavior.
|
|
728
|
+
if (!hooks.allow("onIsValidConnection", request)) {
|
|
729
|
+
try { socket.destroy(); } catch (e) { RED.log.trace("[portal-react] socket destroy: " + e.message); }
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
344
732
|
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
345
733
|
wsServer.emit("connection", ws, request);
|
|
346
734
|
});
|
|
@@ -350,42 +738,87 @@ module.exports = function (RED) {
|
|
|
350
738
|
RED.server.on("upgrade", onUpgrade);
|
|
351
739
|
upgradeHandlers[nodeId] = onUpgrade;
|
|
352
740
|
|
|
353
|
-
wsServer.on("connection", (ws) => {
|
|
741
|
+
wsServer.on("connection", (ws, request) => {
|
|
354
742
|
if (isClosing) {
|
|
355
743
|
ws.close();
|
|
356
744
|
return;
|
|
357
745
|
}
|
|
358
|
-
|
|
746
|
+
const portalClient = crypto.randomUUID();
|
|
747
|
+
ws._portalClient = portalClient;
|
|
748
|
+
if (portalAuth) {
|
|
749
|
+
ws._portalUser = extractPortalUser(request.headers);
|
|
750
|
+
}
|
|
751
|
+
clients.set(portalClient, ws);
|
|
752
|
+
|
|
753
|
+
// Index by userId for O(1) user-cast routing
|
|
754
|
+
const userId = ws._portalUser && ws._portalUser.userId;
|
|
755
|
+
if (userId) {
|
|
756
|
+
let set = userIndex.get(userId);
|
|
757
|
+
if (!set) {
|
|
758
|
+
set = new Set();
|
|
759
|
+
userIndex.set(userId, set);
|
|
760
|
+
}
|
|
761
|
+
set.add(ws);
|
|
762
|
+
}
|
|
763
|
+
|
|
359
764
|
updateStatus();
|
|
360
765
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
766
|
+
// Send content version for deploy-reload detection
|
|
767
|
+
const contentHash = pageState[endpoint]?.contentHash || "";
|
|
768
|
+
wsSend(ws, { type: "version", hash: contentHash });
|
|
769
|
+
|
|
770
|
+
// Send assigned portalClient to browser
|
|
771
|
+
wsSend(ws, { type: "hello", portalClient });
|
|
772
|
+
|
|
773
|
+
// Send the cached last broadcast (if any) as a distinct
|
|
774
|
+
// `recovery` frame. The browser uses this to seed `data` on a
|
|
775
|
+
// fresh connection. React components can opt out via
|
|
776
|
+
// useNodeRed({ ignoreRecovery: true }).
|
|
777
|
+
if (lastBroadcastCache.has(endpoint)) {
|
|
778
|
+
wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
|
|
364
779
|
}
|
|
365
780
|
|
|
366
781
|
ws.on("message", (raw) => {
|
|
367
782
|
try {
|
|
368
783
|
const msg = JSON.parse(raw.toString());
|
|
369
784
|
if (msg.type === "output") {
|
|
370
|
-
|
|
785
|
+
let out = {
|
|
371
786
|
payload: msg.payload,
|
|
372
787
|
topic: msg.topic || "",
|
|
373
|
-
}
|
|
788
|
+
};
|
|
789
|
+
// Server-side identity injection — the client cannot forge
|
|
790
|
+
// _client because we build it from ws state, not from the
|
|
791
|
+
// inbound frame.
|
|
792
|
+
const client = { portalClient: ws._portalClient };
|
|
793
|
+
if (portalAuth && ws._portalUser) {
|
|
794
|
+
Object.assign(client, ws._portalUser);
|
|
795
|
+
}
|
|
796
|
+
out._client = client;
|
|
797
|
+
// Transform hook — plugins may mutate / drop the msg.
|
|
798
|
+
// A hook returning null signals "drop this message".
|
|
799
|
+
out = hooks.transform("onInbound", out, ws);
|
|
800
|
+
if (out) node.send(out);
|
|
801
|
+
return;
|
|
374
802
|
}
|
|
375
803
|
} catch (e) {
|
|
376
804
|
node.warn("Bad WS message: " + e.message);
|
|
377
805
|
}
|
|
378
806
|
});
|
|
379
807
|
|
|
380
|
-
|
|
381
|
-
clients.delete(
|
|
808
|
+
const detach = () => {
|
|
809
|
+
clients.delete(portalClient);
|
|
810
|
+
if (userId) {
|
|
811
|
+
const set = userIndex.get(userId);
|
|
812
|
+
if (set) {
|
|
813
|
+
set.delete(ws);
|
|
814
|
+
if (set.size === 0) userIndex.delete(userId);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
382
817
|
updateStatus();
|
|
383
|
-
}
|
|
818
|
+
};
|
|
384
819
|
|
|
385
|
-
ws.on("
|
|
386
|
-
|
|
387
|
-
updateStatus();
|
|
388
|
-
});
|
|
820
|
+
ws.on("close", detach);
|
|
821
|
+
ws.on("error", detach);
|
|
389
822
|
});
|
|
390
823
|
} catch (e) {
|
|
391
824
|
node.error("WebSocket setup failed: " + e.message);
|
|
@@ -393,12 +826,28 @@ module.exports = function (RED) {
|
|
|
393
826
|
|
|
394
827
|
// ── Input handler ─────────────────────────────────────────
|
|
395
828
|
|
|
829
|
+
// sendTo: single point where every outbound frame passes through
|
|
830
|
+
// the onCanSendTo hook. Strict-by-default — no opt-in per widget
|
|
831
|
+
// type like dashboard's acceptsClientConfig.
|
|
832
|
+
function sendTo(ws, frame, msg) {
|
|
833
|
+
if (!ws || ws.readyState !== 1) return false;
|
|
834
|
+
if (!hooks.allow("onCanSendTo", ws, msg)) return false;
|
|
835
|
+
try {
|
|
836
|
+
ws.send(frame);
|
|
837
|
+
return true;
|
|
838
|
+
} catch (e) {
|
|
839
|
+
RED.log.trace("[portal-react] ws send frame: " + e.message);
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
396
844
|
node.on("input", (msg, send, done) => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
845
|
+
const result = router.route(msg, { clients, userIndex, sendTo });
|
|
846
|
+
// Cache the latest broadcast payload so freshly-connected clients
|
|
847
|
+
// can recover it via the `recovery` frame on connect.
|
|
848
|
+
if (result.mode === "broadcast") {
|
|
849
|
+
lastBroadcastCache.set(endpoint, msg.payload);
|
|
850
|
+
}
|
|
402
851
|
updateStatus();
|
|
403
852
|
if (done) done();
|
|
404
853
|
});
|
|
@@ -418,7 +867,7 @@ module.exports = function (RED) {
|
|
|
418
867
|
clients.forEach((ws) => {
|
|
419
868
|
try {
|
|
420
869
|
ws.close(1001, "node redeployed");
|
|
421
|
-
} catch (
|
|
870
|
+
} catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
|
|
422
871
|
});
|
|
423
872
|
clients.clear();
|
|
424
873
|
|
|
@@ -426,15 +875,37 @@ module.exports = function (RED) {
|
|
|
426
875
|
if (wsServer) {
|
|
427
876
|
try {
|
|
428
877
|
wsServer.close();
|
|
429
|
-
} catch (
|
|
878
|
+
} catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
|
|
430
879
|
wsServer = null;
|
|
431
880
|
}
|
|
432
881
|
|
|
433
|
-
// Unregister rebuild callback
|
|
882
|
+
// Unregister rebuild callback + selective-rebuild metadata
|
|
434
883
|
delete rebuildCallbacks[nodeId];
|
|
884
|
+
delete portalNeeded[nodeId];
|
|
885
|
+
delete portalCode[nodeId];
|
|
886
|
+
|
|
887
|
+
// Release endpoint ownership
|
|
888
|
+
if (endpointOwners[endpoint] === nodeId) {
|
|
889
|
+
delete endpointOwners[endpoint];
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Drop the recovery cache only on full node removal — on a
|
|
893
|
+
// redeploy we keep it so reconnecting clients still recover.
|
|
894
|
+
if (removed) {
|
|
895
|
+
lastBroadcastCache.delete(endpoint);
|
|
896
|
+
delete portalSig[nodeId];
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Clear the userIndex — WS clients are already closed above, but
|
|
900
|
+
// the Map itself should not outlive the node instance.
|
|
901
|
+
userIndex.clear();
|
|
435
902
|
|
|
436
903
|
// Clean up route only when node is fully removed (not redeployed)
|
|
437
904
|
if (removed) {
|
|
905
|
+
// Delete disk cache if no other endpoint uses this hash
|
|
906
|
+
if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
|
|
907
|
+
deleteCacheFiles(lastJsxHash);
|
|
908
|
+
}
|
|
438
909
|
delete pageState[endpoint];
|
|
439
910
|
removeRoute(RED.httpNode._router, endpoint);
|
|
440
911
|
delete registeredRoutes[endpoint];
|
|
@@ -448,22 +919,15 @@ module.exports = function (RED) {
|
|
|
448
919
|
function wsSend(ws, obj) {
|
|
449
920
|
try {
|
|
450
921
|
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
451
|
-
} catch (
|
|
922
|
+
} catch (e) { RED.log.trace("[portal-react] wsSend: " + e.message); }
|
|
452
923
|
}
|
|
453
924
|
|
|
454
|
-
function updateStatus() {
|
|
455
|
-
if (isClosing) return;
|
|
456
|
-
const n = clients.size;
|
|
457
|
-
node.status({
|
|
458
|
-
fill: n > 0 ? "green" : "grey",
|
|
459
|
-
shape: n > 0 ? "dot" : "ring",
|
|
460
|
-
text: `${endpoint} [${n} client${n !== 1 ? "s" : ""}]`,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
925
|
}); // end setImmediate
|
|
464
926
|
}
|
|
465
927
|
|
|
466
|
-
RED.nodes.registerType("portal-react", PortalReactNode
|
|
928
|
+
RED.nodes.registerType("portal-react", PortalReactNode, {
|
|
929
|
+
dynamicModuleList: "libs",
|
|
930
|
+
});
|
|
467
931
|
|
|
468
932
|
// ── Serve Monaco editor files locally ────────────────────────
|
|
469
933
|
const express = require("express");
|
|
@@ -485,9 +949,16 @@ module.exports = function (RED) {
|
|
|
485
949
|
res.json(twClassesCache);
|
|
486
950
|
});
|
|
487
951
|
|
|
488
|
-
// ── Vendor CSS endpoint (per
|
|
952
|
+
// ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
|
|
489
953
|
RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
|
|
490
|
-
const
|
|
954
|
+
const reqHash = req.params.hash;
|
|
955
|
+
let css = null;
|
|
956
|
+
for (const ep in pageState) {
|
|
957
|
+
if (pageState[ep]?.cssHash === reqHash) {
|
|
958
|
+
css = pageState[ep].css;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
491
962
|
if (!css) {
|
|
492
963
|
res.status(404).send("Not found");
|
|
493
964
|
return;
|
|
@@ -499,15 +970,9 @@ module.exports = function (RED) {
|
|
|
499
970
|
res.send(css);
|
|
500
971
|
});
|
|
501
972
|
|
|
502
|
-
// ──
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
"Content-Type": "application/javascript",
|
|
506
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
507
|
-
ETag: `"${reactHash}"`,
|
|
508
|
-
});
|
|
509
|
-
res.send(reactBundle);
|
|
510
|
-
});
|
|
973
|
+
// ── Public assets folder ─────────────────────────────────────
|
|
974
|
+
const { registerAssets } = require("./lib/assets");
|
|
975
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
|
|
511
976
|
|
|
512
977
|
// ── Admin API for component registry ──────────────────────────
|
|
513
978
|
|
|
@@ -516,10 +981,15 @@ module.exports = function (RED) {
|
|
|
516
981
|
});
|
|
517
982
|
|
|
518
983
|
RED.httpAdmin.post("/portal-react/registry", (req, res) => {
|
|
519
|
-
const { name, code
|
|
984
|
+
const { name, code } = req.body || {};
|
|
520
985
|
if (!isSafeName(name))
|
|
521
986
|
return res.status(400).json({ error: "invalid name" });
|
|
522
|
-
|
|
987
|
+
const newCode = code || "";
|
|
988
|
+
const prevCode = registry[name]?.code;
|
|
989
|
+
registry[name] = { code: newCode };
|
|
990
|
+
if (prevCode !== newCode) {
|
|
991
|
+
scheduleRebuildUsing(name);
|
|
992
|
+
}
|
|
523
993
|
res.json({ ok: true });
|
|
524
994
|
});
|
|
525
995
|
|
|
@@ -527,110 +997,11 @@ module.exports = function (RED) {
|
|
|
527
997
|
const name = req.params.name;
|
|
528
998
|
if (!isSafeName(name))
|
|
529
999
|
return res.status(400).json({ error: "invalid name" });
|
|
1000
|
+
const existed = Object.prototype.hasOwnProperty.call(registry, name);
|
|
530
1001
|
delete registry[name];
|
|
1002
|
+
if (existed) {
|
|
1003
|
+
scheduleRebuildUsing(name);
|
|
1004
|
+
}
|
|
531
1005
|
res.json({ ok: true });
|
|
532
1006
|
});
|
|
533
|
-
|
|
534
|
-
// ── Page builders ─────────────────────────────────────────────
|
|
535
|
-
|
|
536
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash) {
|
|
537
|
-
return `<!DOCTYPE html>
|
|
538
|
-
<html lang="en">
|
|
539
|
-
<head>
|
|
540
|
-
<meta charset="UTF-8">
|
|
541
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
542
|
-
<title>${esc(title)}</title>
|
|
543
|
-
<script src="${adminRoot}/portal-react/vendor/react.min.js?v=${reactHash}"><\/script>
|
|
544
|
-
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
545
|
-
${escScript(customHead)}
|
|
546
|
-
<style>
|
|
547
|
-
@layer base{
|
|
548
|
-
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
549
|
-
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0}
|
|
550
|
-
#root{min-height:100vh}
|
|
551
|
-
}
|
|
552
|
-
#__cs{position:fixed;bottom:6px;right:6px;padding:3px 8px;font-size:10px;border-radius:3px;z-index:99999;background:#111;border:1px solid #333;opacity:.7;transition:opacity .2s}
|
|
553
|
-
#__cs:hover{opacity:1}
|
|
554
|
-
#__cs.ok{color:#4ade80}
|
|
555
|
-
#__cs.err{color:#f87171}
|
|
556
|
-
</style>
|
|
557
|
-
</head>
|
|
558
|
-
<body>
|
|
559
|
-
<div id="root"></div>
|
|
560
|
-
<div id="__cs" class="err">disconnected</div>
|
|
561
|
-
<script>
|
|
562
|
-
window.__NR={
|
|
563
|
-
_ws:null,_listeners:new Set(),_lastData:null,_retries:0,_wasConnected:false,
|
|
564
|
-
connect(){
|
|
565
|
-
const p=location.protocol==='https:'?'wss:':'ws:';
|
|
566
|
-
const ws=new WebSocket(p+'//'+location.host+'${wsPath}');
|
|
567
|
-
this._ws=ws;
|
|
568
|
-
const s=document.getElementById('__cs');
|
|
569
|
-
ws.onopen=()=>{
|
|
570
|
-
if(this._wasConnected){location.reload();return;}
|
|
571
|
-
s.textContent='connected';s.className='ok';this._retries=0;this._wasConnected=true;
|
|
572
|
-
};
|
|
573
|
-
ws.onmessage=(e)=>{
|
|
574
|
-
try{const m=JSON.parse(e.data);if(m.type==='data'){this._lastData=m.payload;this._listeners.forEach(fn=>fn(m.payload));}}
|
|
575
|
-
catch(err){console.error('WS parse',err);}
|
|
576
|
-
};
|
|
577
|
-
ws.onclose=(e)=>{
|
|
578
|
-
s.textContent='disconnected';s.className='err';
|
|
579
|
-
this._ws=null;
|
|
580
|
-
const delay=Math.min(500*Math.pow(2,this._retries),8000);
|
|
581
|
-
this._retries++;
|
|
582
|
-
setTimeout(()=>this.connect(),delay);
|
|
583
|
-
};
|
|
584
|
-
ws.onerror=()=>ws.close();
|
|
585
|
-
},
|
|
586
|
-
subscribe(fn){
|
|
587
|
-
this._listeners.add(fn);
|
|
588
|
-
if(this._lastData!==null)fn(this._lastData);
|
|
589
|
-
return()=>this._listeners.delete(fn);
|
|
590
|
-
},
|
|
591
|
-
send(payload,topic){
|
|
592
|
-
if(this._ws&&this._ws.readyState===1)
|
|
593
|
-
this._ws.send(JSON.stringify({type:'output',payload,topic:topic||''}));
|
|
594
|
-
}
|
|
595
|
-
};
|
|
596
|
-
window.__NR.connect();
|
|
597
|
-
<\/script>
|
|
598
|
-
<script>
|
|
599
|
-
${escScript(transpiledJs)}
|
|
600
|
-
<\/script>
|
|
601
|
-
</body>
|
|
602
|
-
</html>`;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function buildErrorPage(title, error) {
|
|
606
|
-
return `<!DOCTYPE html>
|
|
607
|
-
<html lang="en">
|
|
608
|
-
<head>
|
|
609
|
-
<meta charset="UTF-8">
|
|
610
|
-
<title>${esc(title)} — Error</title>
|
|
611
|
-
<style>
|
|
612
|
-
body{font-family:monospace;background:#1a0000;color:#f87171;padding:40px;line-height:1.6}
|
|
613
|
-
h1{color:#ff4444;margin-bottom:16px}
|
|
614
|
-
pre{background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5}
|
|
615
|
-
</style>
|
|
616
|
-
</head>
|
|
617
|
-
<body>
|
|
618
|
-
<h1>JSX Transpile Error</h1>
|
|
619
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
620
|
-
<pre>${esc(error)}</pre>
|
|
621
|
-
</body>
|
|
622
|
-
</html>`;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function esc(s) {
|
|
626
|
-
return String(s)
|
|
627
|
-
.replace(/&/g, "&")
|
|
628
|
-
.replace(/</g, "<")
|
|
629
|
-
.replace(/>/g, ">")
|
|
630
|
-
.replace(/"/g, """);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function escScript(s) {
|
|
634
|
-
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
635
|
-
}
|
|
636
1007
|
};
|