@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,18 @@
1
1
  /**
2
2
  * @aaqu/fromcubes-portal-react
3
3
  *
4
- * Server-side JSX transpilation & bundling via esbuild.
5
- * Deploy-safe: handles rapid redeploys without leaking WS connections or stale routes.
6
- * Transpiled JS is cached by content hash unchanged code skips recompilation on redeploy.
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 timebrowsers 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,145 @@ module.exports = function (RED) {
62
45
  }
63
46
  const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
64
47
 
65
- // ── Helpers ───────────────────────────────────────────────────
66
-
67
- function hash(str) {
68
- return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
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
- const twCompile = require("tailwindcss").compile;
72
- const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
73
-
74
- let twCompiled = null;
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
- function transpile(jsx) {
95
- try {
96
- const buildResult = esbuild.buildSync({
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
- async function generateCSS(source) {
119
- const key = hash(source);
120
- if (cssCache[key]) return cssCache[key];
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
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
129
-
130
- function isSafeName(name) {
131
- return (
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 = {};
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
+ }
134
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
+ _rebuildTimer.unref?.();
114
+ }
115
+ function scheduleRebuildSelf(nodeId) {
116
+ if (!nodeId) return;
117
+ _dirtyPortals.add(nodeId);
118
+ _armRebuild();
119
+ }
120
+ function scheduleRebuildUsing(compName) {
121
+ if (!compName) return;
122
+ _dirtyComps.add(compName);
123
+ _armRebuild();
124
+ }
125
+ function _flushRebuild() {
126
+ _rebuildTimer = null;
127
+ const dirty = new Set(_dirtyComps);
128
+ const selfIds = new Set(_dirtyPortals);
129
+ _dirtyComps.clear();
130
+ _dirtyPortals.clear();
131
+
132
+ const targetIds = new Set(selfIds);
133
+ if (dirty.size > 0) {
134
+ for (const nodeId of Object.keys(rebuildCallbacks)) {
135
+ if (targetIds.has(nodeId)) continue;
136
+ const used = portalNeeded[nodeId];
137
+ const raw = portalCode[nodeId] || "";
138
+ for (const name of dirty) {
139
+ if ((used && used.has(name)) || raw.includes(name)) {
140
+ targetIds.add(nodeId);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ }
135
146
 
136
- function removeRoute(router, path) {
137
- if (!router || !router.stack) return;
138
- router.stack = router.stack.filter(
139
- (layer) => !(layer.route && layer.route.path === path),
140
- );
147
+ const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
148
+ let i = 0;
149
+ function next() {
150
+ if (i >= fns.length) return;
151
+ try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
152
+ i++;
153
+ if (i < fns.length) setImmediate(next);
154
+ }
155
+ next();
141
156
  }
142
157
 
158
+ // ── Load modules ─────────────────────────────────────────────
159
+ const helpers = require("./lib/helpers")(RED);
160
+ const {
161
+ hash,
162
+ transpile,
163
+ quickCheckSyntax,
164
+ generateCSS,
165
+ extractPortalUser,
166
+ removeRoute,
167
+ isSafeName,
168
+ validateSubPath,
169
+ userDir,
170
+ readCachedJS,
171
+ writeCachedJS,
172
+ readCachedCSS,
173
+ writeCachedCSS,
174
+ deleteCacheFiles,
175
+ isHashInUse,
176
+ } = helpers;
177
+ const { buildPage, buildErrorPage } = require("./lib/page-builder");
178
+ const hooks = require("./lib/hooks")(RED);
179
+ const router = require("./lib/router");
180
+
181
+ // Per-process cache of the last broadcast payload per endpoint.
182
+ // Lets a freshly-connected client see the most recent broadcast value
183
+ // (similar to dashboard2's lastMsg recovery). Sent as a distinct
184
+ // `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
185
+ const lastBroadcastCache = new Map();
186
+
143
187
  // ── Canvas node: shared component ─────────────────────────────
144
188
 
145
189
  function PortalComponentNode(config) {
@@ -153,31 +197,49 @@ module.exports = function (RED) {
153
197
  return;
154
198
  }
155
199
 
156
- registry[compName] = {
157
- code: config.compCode || "",
158
- inputs: config.compInputs
159
- ? config.compInputs
160
- .split(",")
161
- .map((s) => s.trim())
162
- .filter(Boolean)
163
- : [],
164
- outputs: config.compOutputs
165
- ? config.compOutputs
166
- .split(",")
167
- .map((s) => s.trim())
168
- .filter(Boolean)
169
- : [],
170
- };
171
-
172
- node.status({ fill: "green", shape: "dot", text: compName });
173
-
174
- // Trigger re-transpile on all portal-react nodes (after all nodes init)
175
- setImmediate(() => {
176
- Object.values(rebuildCallbacks).forEach((fn) => fn());
177
- });
200
+ // Duplicate component name check
201
+ const existingOwner = compNameOwners[compName];
202
+ if (existingOwner && existingOwner !== node.id) {
203
+ node.error(
204
+ `Component name "${compName}" is already used by another node`,
205
+ );
206
+ node.status({
207
+ fill: "red",
208
+ shape: "ring",
209
+ text: "duplicate: " + compName,
210
+ });
211
+ node.on("close", function (_removed, done) {
212
+ if (done) done();
213
+ });
214
+ return;
215
+ }
216
+ compNameOwners[compName] = node.id;
217
+
218
+ const newCode = config.compCode || "";
219
+ const prevCode = registry[compName]?.code;
220
+ const syntaxErr = quickCheckSyntax(newCode);
221
+ registry[compName] = { code: newCode, error: syntaxErr };
222
+
223
+ if (syntaxErr) {
224
+ node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
225
+ const short = syntaxErr.split("\n")[0].slice(0, 40);
226
+ node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
227
+ } else {
228
+ node.status({ fill: "green", shape: "dot", text: compName });
229
+ }
230
+
231
+ // Only rebuild portals that reference this component, and only if the code actually changed.
232
+ if (prevCode !== newCode) {
233
+ scheduleRebuildUsing(compName);
234
+ }
178
235
 
179
236
  node.on("close", function (removed, done) {
237
+ if (compNameOwners[compName] === node.id) {
238
+ delete compNameOwners[compName];
239
+ }
180
240
  delete registry[compName];
241
+ // Portals depending on this component must rebuild (topology changed or name resolution breaks).
242
+ scheduleRebuildUsing(compName);
181
243
  if (done) done();
182
244
  });
183
245
  }
@@ -191,130 +253,580 @@ module.exports = function (RED) {
191
253
  const nodeId = node.id;
192
254
 
193
255
  // Config
194
- const endpoint = (config.endpoint || "/portal").replace(/\/+$/, "");
256
+ const subPathResult = validateSubPath(config.subPath);
257
+ const legacyEndpoint =
258
+ typeof config.endpoint === "string" && config.endpoint.trim().length > 0
259
+ ? config.endpoint.trim()
260
+ : null;
261
+
262
+ if (!subPathResult.ok) {
263
+ // No valid subPath. If there's a legacy endpoint, hard-fail with a
264
+ // migration message; otherwise fail on the sub-path error.
265
+ if (legacyEndpoint) {
266
+ node.error(
267
+ `Legacy 'endpoint' field detected ("${legacyEndpoint}"). ` +
268
+ "Open the node, set a Sub-path (served under /fromcubes/<sub-path>), and redeploy.",
269
+ );
270
+ node.status({ fill: "red", shape: "ring", text: "legacy endpoint" });
271
+ } else {
272
+ node.error("Invalid sub-path: " + subPathResult.error);
273
+ node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
274
+ }
275
+ node.on("close", function (_removed, done) {
276
+ if (done) done();
277
+ });
278
+ return;
279
+ }
280
+ const subPath = subPathResult.value;
281
+ const endpoint = "/fromcubes/" + subPath;
282
+
195
283
  const componentCode = config.componentCode || "";
196
284
  const pageTitle = config.pageTitle || "Portal";
197
285
  const customHead = config.customHead || "";
286
+ const portalAuth = config.portalAuth === true;
287
+ const showWsStatus = config.showWsStatus === true;
288
+ const libs = config.libs || [];
289
+
290
+ // ── Duplicate endpoint check ──
291
+ const existingOwner = endpointOwners[endpoint];
292
+ if (existingOwner && existingOwner !== nodeId) {
293
+ node.error(
294
+ `Endpoint "${endpoint}" is already used by another portal node`,
295
+ );
296
+ node.status({
297
+ fill: "red",
298
+ shape: "ring",
299
+ text: "duplicate: " + endpoint,
300
+ });
301
+ node.on("close", function (_removed, done) {
302
+ if (done) done();
303
+ });
304
+ return;
305
+ }
306
+ endpointOwners[endpoint] = nodeId;
198
307
 
199
308
  // State
200
- const clients = new Set();
201
- let lastPayload = null;
309
+ const clients = new Map(); // portalClient → ws
310
+ const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
202
311
  let wsServer = null;
203
312
  let isClosing = false;
313
+ let lastJsxHash = null;
314
+
315
+ if (libs.length > 0) {
316
+ node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
317
+ } else {
318
+ node.status({ fill: "yellow", shape: "ring", text: "starting..." });
319
+ }
204
320
 
205
- const wsPath = endpoint + "/_ws";
321
+ const wsPath = nodeRoot + endpoint + "/_ws";
322
+
323
+ function updateStatus() {
324
+ if (isClosing) return;
325
+ const st = pageState[endpoint];
326
+ const n = clients.size;
327
+ const clientTail = n > 0 ? ` [${n} client${n !== 1 ? "s" : ""}]` : "";
328
+
329
+ // Preserve build-error state — don't clobber with client count until JSX is fixed.
330
+ // Show "(serving last good)" suffix in degraded mode (ring shape) so it is
331
+ // obvious the portal still works for connected clients despite the broken build.
332
+ if (st && st.compiled && st.compiled.error) {
333
+ let base;
334
+ if (st.errorSource) base = "broken: " + st.errorSource;
335
+ else if (st.errorKind === "missing-return") base = "missing return";
336
+ else if (st.errorKind === "rebuild") base = "rebuild error";
337
+ else base = "transpile error";
338
+ if (st.lastGood) {
339
+ node.status({
340
+ fill: "red",
341
+ shape: "ring",
342
+ text: base + " (serving last good)" + clientTail,
343
+ });
344
+ } else {
345
+ node.status({ fill: "red", shape: "dot", text: base + clientTail });
346
+ }
347
+ return;
348
+ }
349
+ // Preserve building state — same reason.
350
+ if (st && st.building) {
351
+ node.status({ fill: "yellow", shape: "dot", text: "building..." });
352
+ return;
353
+ }
354
+ // Build succeeded but a connected browser threw at runtime
355
+ // (e.g. ReferenceError to a missing component / undefined identifier).
356
+ if (st && st.runtimeError) {
357
+ node.status({
358
+ fill: "red",
359
+ shape: "ring",
360
+ text: "runtime error" + clientTail,
361
+ });
362
+ return;
363
+ }
364
+ node.status({
365
+ fill: n > 0 ? "green" : "grey",
366
+ shape: n > 0 ? "dot" : "ring",
367
+ text: `${endpoint}${clientTail || " [0 clients]"}`,
368
+ });
369
+ }
206
370
 
207
371
  // ── Rebuild: transpile JSX + update page state ────────────
208
372
 
209
373
  function rebuild() {
210
- // Topological sort: components used by others come first
211
- const entries = Object.entries(registry);
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");
374
+ try {
375
+ // ── Pre-build: clear cache, set building state, notify browsers ──
376
+ const prevState = pageState[endpoint];
377
+ const prevHash = prevState?.jsxHash;
378
+ if (prevHash && !isHashInUse(prevHash, pageState, endpoint)) {
379
+ deleteCacheFiles(prevHash);
380
+ }
381
+ pageState[endpoint] = { building: true, wsPath, pageTitle };
382
+ updateStatus();
383
+ clients.forEach((ws) => {
384
+ try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
385
+ });
252
386
 
253
- const compiled = transpile(fullJsx);
387
+ // Selective injection: only include components referenced in user code (+ transitive deps)
388
+ const allEntries = Object.entries(registry);
389
+ const needed = new Set();
390
+
391
+ function addWithDeps(name) {
392
+ if (needed.has(name)) return;
393
+ const entry = registry[name];
394
+ if (!entry) return;
395
+ needed.add(name);
396
+ for (const [other] of allEntries) {
397
+ if (other !== name && entry.code.includes(other)) {
398
+ addWithDeps(other);
399
+ }
400
+ }
401
+ }
254
402
 
255
- if (compiled.error) {
256
- node.error("JSX transpile error: " + compiled.error);
257
- node.status({ fill: "red", shape: "dot", text: "transpile error" });
258
- } else {
259
- node.status({ fill: "grey", shape: "ring", text: endpoint });
260
- }
403
+ for (const [name] of allEntries) {
404
+ if (componentCode.includes(name)) {
405
+ addWithDeps(name);
406
+ }
407
+ }
261
408
 
262
- const cssHashReady = !compiled.error
263
- ? generateCSS(fullJsx)
264
- .then((css) => {
265
- node.status({ fill: "grey", shape: "ring", text: endpoint });
266
- return css ? hash(fullJsx) : "";
267
- })
268
- .catch((err) => {
409
+ // Remember which components this portal depends on, so component changes
410
+ // can target only affected portals.
411
+ portalNeeded[nodeId] = new Set(needed);
412
+
413
+ // Topological sort only needed components
414
+ const entries = allEntries.filter(([n]) => needed.has(n));
415
+ entries.sort((a, b) => {
416
+ const aUsesB = a[1].code.includes(b[0]);
417
+ const bUsesA = b[1].code.includes(a[0]);
418
+ if (aUsesB && !bUsesA) return 1; // a depends on b → b first
419
+ if (bUsesA && !aUsesB) return -1; // b depends on a → a first
420
+ return 0;
421
+ });
422
+ const libraryJsx = entries
423
+ .map(
424
+ ([name, c]) =>
425
+ `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
426
+ )
427
+ .join("\n\n");
428
+
429
+ // Extract import statements from library/user code so they appear at top level
430
+ const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
431
+ const libImports = libraryJsx.match(importRe) || [];
432
+ const userImports = componentCode.match(importRe) || [];
433
+ const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
434
+ const cleanCompCode = componentCode.replace(importRe, "").trim();
435
+
436
+ // Warn about import * (prevents tree-shaking)
437
+ const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
438
+ const allCode = cleanLibJsx + "\n" + cleanCompCode;
439
+ for (const imp of [...libImports, ...userImports]) {
440
+ const m = imp.match(starRe);
441
+ if (!m) continue;
442
+ const [, localName, modulePath] = m;
443
+ const propRe = new RegExp(
444
+ `\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`,
445
+ "g",
446
+ );
447
+ const props = new Set();
448
+ let pm;
449
+ while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
450
+ if (props.size > 0) {
451
+ const named = [...props].sort().join(", ");
452
+ node.warn(
453
+ `"import * as ${localName}" bundles entire ${modulePath} library. ` +
454
+ `For smaller builds use: import { ${named} } from '${modulePath}'`,
455
+ );
456
+ }
457
+ }
458
+
459
+ const fullJsx = [
460
+ "// ── Imports ──",
461
+ 'import React from "react";',
462
+ 'import ReactDOM from "react-dom";',
463
+ 'import { createRoot } from "react-dom/client";',
464
+ ...libImports,
465
+ ...userImports,
466
+ "",
467
+ "// ── React shorthand ──",
468
+ "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
469
+ "const { createContext, memo, forwardRef, Fragment } = React;",
470
+ "",
471
+ "// ── useNodeRed hook ──",
472
+ [
473
+ "function useNodeRed(opts) {",
474
+ " // opts.ignoreRecovery = true → ignore the cached last-broadcast",
475
+ " // frame the server sends on connect; data stays undefined until",
476
+ " // a fresh broadcast arrives. Latched once globally — strictest",
477
+ " // call wins (any caller asking to ignore disables recovery for all).",
478
+ " if (opts && opts.ignoreRecovery) window.__NR._ignoreRecovery = true;",
479
+ " const [data, setData] = React.useState(window.__NR._lastData);",
480
+ " React.useEffect(() => window.__NR.subscribe(setData), []);",
481
+ " const send = React.useCallback((payload, topic) => {",
482
+ " window.__NR.send(payload, topic);",
483
+ " }, []);",
484
+ " const user = window.__NR._user || null;",
485
+ " const portalClient = window.__NR._portalClient;",
486
+ " return { data, send, user, portalClient };",
487
+ "}",
488
+ ].join("\n"),
489
+ "",
490
+ "// ── Library components ──",
491
+ cleanLibJsx,
492
+ "",
493
+ "// ── View component ──",
494
+ cleanCompCode,
495
+ "",
496
+ "// ── Mount ──",
497
+ "createRoot(document.getElementById('root')).render(React.createElement(App));",
498
+ ].join("\n");
499
+
500
+ const jsxHash = hash(fullJsx);
501
+
502
+ // ── Check: any used component has its own syntax error ──
503
+ let errorSource = null;
504
+ for (const name of needed) {
505
+ if (registry[name]?.error) {
506
+ errorSource = name;
507
+ break;
508
+ }
509
+ }
510
+
511
+ // ── Check: missing return in App ──
512
+ let missingReturn = false;
513
+ const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
514
+ if (appFnMatch) {
515
+ let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
516
+ let hasReturn = false;
517
+ while (i < cleanCompCode.length && depth > 0) {
518
+ const ch = cleanCompCode[i];
519
+ if (ch === "{") depth++;
520
+ else if (ch === "}") depth--;
521
+ else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
522
+ i++;
523
+ }
524
+ missingReturn = !hasReturn;
525
+ }
526
+
527
+ // ── Resolve compiled (success or unified error) ──
528
+ let compiled;
529
+ let cacheHit = false;
530
+ let errorKind = null; // 'component' | 'missing-return' | 'transpile'
531
+ if (errorSource) {
532
+ compiled = {
533
+ js: null,
534
+ error: `Component "${errorSource}" has a syntax error:\n\n${registry[errorSource].error}`,
535
+ };
536
+ errorKind = "component";
537
+ } else if (missingReturn) {
538
+ compiled = {
539
+ js: null,
540
+ error:
541
+ "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
542
+ };
543
+ errorKind = "missing-return";
544
+ } else {
545
+ compiled = readCachedJS(jsxHash);
546
+ cacheHit = !!compiled;
547
+ if (!compiled) {
548
+ compiled = transpile(fullJsx);
549
+ if (!compiled.error) {
550
+ writeCachedJS(jsxHash, compiled.js, compiled.metafile);
551
+ }
552
+ }
553
+ if (compiled.error) errorKind = "transpile";
554
+ }
555
+
556
+ if (compiled.error) {
557
+ node.error(
558
+ (errorKind === "component"
559
+ ? `Component "${errorSource}" syntax error: `
560
+ : errorKind === "missing-return"
561
+ ? "App component has no return statement: "
562
+ : "JSX transpile error: ") + compiled.error,
563
+ );
564
+ // Status + WS frames handled below (lastGood-aware).
565
+ } else {
566
+ updateStatus();
567
+ if (compiled.metafile) {
568
+ const output = Object.values(compiled.metafile.outputs)[0];
569
+ const sizes = output
570
+ ? Object.entries(output.inputs)
571
+ .map(([name, info]) => ({
572
+ name: name
573
+ .replace(/^.*node_modules\//, "")
574
+ .replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
575
+ bytes: info.bytesInOutput,
576
+ }))
577
+ .sort((a, b) => b.bytes - a.bytes)
578
+ .slice(0, 5)
579
+ : [];
580
+ const totalKB = Math.round(compiled.js.length / 1024);
581
+ const top = sizes
582
+ .map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
583
+ .join(" · ");
584
+ node.log(
585
+ `[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
586
+ );
587
+ }
588
+ }
589
+
590
+ const contentHash = compiled.js ? hash(compiled.js) : "";
591
+
592
+ // ── CSS: disk cache → in-memory → generate ──
593
+ const cssReady = !compiled.error
594
+ ? (() => {
595
+ const cachedCSS = readCachedCSS(jsxHash);
596
+ if (cachedCSS) return Promise.resolve(cachedCSS);
597
+ if (prevState?.jsxHash === jsxHash && prevState?.css) {
598
+ return Promise.resolve({
599
+ css: prevState.css,
600
+ cssHash: prevState.cssHash,
601
+ });
602
+ }
603
+ return generateCSS(fullJsx).then(({ css, cssHash }) => {
604
+ writeCachedCSS(jsxHash, css);
605
+ return { css, cssHash };
606
+ });
607
+ })().catch((err) => {
269
608
  node.warn("Tailwind CSS generation failed: " + err.message);
270
- return "";
609
+ return { css: "", cssHash: "" };
271
610
  })
272
- : Promise.resolve("");
611
+ : Promise.resolve({ css: "", cssHash: "" });
612
+
613
+ lastJsxHash = jsxHash;
614
+
615
+ // Preserve last successful build so that on transpile errors we keep
616
+ // serving the previous working JS instead of throwing clients to an error page.
617
+ const lastGood = compiled.error
618
+ ? prevState?.lastGood || null
619
+ : null; // will be populated after cssReady resolves on success
620
+
621
+ pageState[endpoint] = {
622
+ compiled,
623
+ contentHash,
624
+ cssReady,
625
+ jsxHash,
626
+ css: null,
627
+ cssHash: "",
628
+ pageTitle,
629
+ wsPath,
630
+ customHead,
631
+ portalAuth,
632
+ showWsStatus,
633
+ errorSource,
634
+ errorKind,
635
+ lastGood,
636
+ };
273
637
 
274
- pageState[endpoint] = {
275
- compiled,
276
- cssHashReady,
277
- pageTitle,
278
- wsPath,
279
- customHead,
280
- };
638
+ if (compiled.error) {
639
+ // Status text (red) handled centrally by updateStatus — it formats
640
+ // base + "(serving last good)" + client-count suffix consistently
641
+ // across build and connect/disconnect events.
642
+ updateStatus();
643
+ const frame = lastGood
644
+ ? JSON.stringify({ type: "error", message: compiled.error, degraded: true })
645
+ : JSON.stringify({ type: "error", message: compiled.error });
646
+ clients.forEach((ws) => {
647
+ try { if (ws.readyState === 1) ws.send(frame); } catch (e) { RED.log.trace("[portal-react] ws send error: " + e.message); }
648
+ });
649
+ }
650
+
651
+ // Notify all connected browsers that build finished — triggers reload or overlay cleanup
652
+ if (!compiled.error && contentHash) {
653
+ const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
654
+ clients.forEach((ws) => {
655
+ try { if (ws.readyState === 1) ws.send(versionFrame); } catch (e) { RED.log.trace("[portal-react] ws send version: " + e.message); }
656
+ });
657
+ }
658
+
659
+ cssReady.then(({ css, cssHash }) => {
660
+ const state = pageState[endpoint];
661
+ if (state && state.jsxHash === jsxHash) {
662
+ state.css = css;
663
+ state.cssHash = cssHash;
664
+ // Snapshot current good build so future failed builds can fall back.
665
+ if (!state.compiled.error && state.compiled.js) {
666
+ state.lastGood = {
667
+ compiledJs: state.compiled.js,
668
+ contentHash: state.contentHash,
669
+ cssHash,
670
+ pageTitle: state.pageTitle,
671
+ customHead: state.customHead,
672
+ };
673
+ }
674
+ updateStatus();
675
+ }
676
+ });
677
+ } catch (e) {
678
+ node.error("Rebuild failed: " + e.message);
679
+ // Surface as a regular build error so the lastGood/degraded path,
680
+ // status formatting and FE error frame all run uniformly.
681
+ const prev = pageState[endpoint];
682
+ pageState[endpoint] = {
683
+ compiled: { js: null, error: "Internal rebuild error: " + e.message },
684
+ contentHash: "",
685
+ cssReady: Promise.resolve({ css: "", cssHash: "" }),
686
+ jsxHash: "",
687
+ css: null,
688
+ cssHash: "",
689
+ pageTitle,
690
+ wsPath,
691
+ customHead,
692
+ portalAuth,
693
+ showWsStatus,
694
+ errorSource: null,
695
+ errorKind: "rebuild",
696
+ lastGood: prev?.lastGood || null,
697
+ };
698
+ updateStatus();
699
+ const frame = JSON.stringify({
700
+ type: "error",
701
+ message: "Internal rebuild error: " + e.message,
702
+ degraded: !!prev?.lastGood,
703
+ });
704
+ clients.forEach((ws) => {
705
+ try { if (ws.readyState === 1) ws.send(frame); } catch (err) { RED.log.trace("[portal-react] ws send rebuild err: " + err.message); }
706
+ });
707
+ }
281
708
  }
282
709
 
283
710
  // Register rebuild callback so library components can trigger re-transpile
284
711
  rebuildCallbacks[nodeId] = rebuild;
285
-
286
- // Delay initial build so all fc-portal-component nodes register first
712
+ // Remember raw user JSX so selective rebuild can detect references to new components
713
+ portalCode[nodeId] = componentCode;
714
+
715
+ // No-op redeploy detection: if nothing in the portal's config changed AND a valid
716
+ // build already exists for this endpoint, skip rebuild. Node-RED Full deploy
717
+ // reconstructs every node even when unchanged — without this check every portal
718
+ // would rebuild on every Full deploy.
719
+ const sig = hash(
720
+ [
721
+ componentCode,
722
+ JSON.stringify(libs),
723
+ pageTitle,
724
+ customHead,
725
+ String(portalAuth),
726
+ String(showWsStatus),
727
+ ].join("\0"),
728
+ );
729
+ const prevSig = portalSig[nodeId];
730
+ const existing = pageState[endpoint];
731
+ const hasValidBuild =
732
+ !!existing && !existing.building && !existing.compiled?.error;
733
+ portalSig[nodeId] = sig;
734
+
735
+ if (prevSig !== sig || !hasValidBuild) {
736
+ scheduleRebuildSelf(nodeId);
737
+ } else {
738
+ node.log(`[${nodeId}] unchanged — skipping rebuild`);
739
+ updateStatus();
740
+ }
287
741
  setImmediate(() => {
288
- rebuild();
289
-
290
742
  // Register route only once per endpoint (persists across deploys)
291
743
  if (!registeredRoutes[endpoint]) {
292
744
  RED.httpNode.get(endpoint, async function (_req, res) {
293
- const state = pageState[endpoint];
294
- if (!state) {
295
- res.status(404).send("Not found");
296
- return;
297
- }
298
- res.set("Cache-Control", "no-store");
299
- if (state.compiled.error) {
745
+ try {
746
+ const state = pageState[endpoint];
747
+ if (!state || state.building) {
748
+ const bWsPath = state?.wsPath || wsPath;
749
+ res
750
+ .set("Cache-Control", "no-store")
751
+ .type("text/html")
752
+ .send(
753
+ `<!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.hash)||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>`,
754
+ );
755
+ return;
756
+ }
757
+ res.set("Cache-Control", "no-store");
758
+ if (state.compiled.error) {
759
+ if (state.lastGood) {
760
+ // Degraded: serve previous good build, banner-only error UI.
761
+ const user = state.portalAuth
762
+ ? extractPortalUser(_req.headers)
763
+ : null;
764
+ res
765
+ .type("text/html")
766
+ .send(
767
+ buildPage(
768
+ state.lastGood.pageTitle,
769
+ state.lastGood.compiledJs,
770
+ state.wsPath,
771
+ state.lastGood.customHead,
772
+ state.lastGood.cssHash,
773
+ user,
774
+ state.showWsStatus,
775
+ adminRoot,
776
+ ),
777
+ );
778
+ return;
779
+ }
780
+ res
781
+ .status(500)
782
+ .type("text/html")
783
+ .send(
784
+ buildErrorPage(
785
+ state.pageTitle,
786
+ state.compiled.error,
787
+ state.wsPath,
788
+ ),
789
+ );
790
+ return;
791
+ }
792
+ const { cssHash } = await Promise.race([
793
+ state.cssReady,
794
+ new Promise((_, reject) =>
795
+ setTimeout(
796
+ () => reject(new Error("CSS generation timeout")),
797
+ 15000,
798
+ ),
799
+ ),
800
+ ]);
801
+ const user = state.portalAuth
802
+ ? extractPortalUser(_req.headers)
803
+ : null;
804
+ res
805
+ .type("text/html")
806
+ .send(
807
+ buildPage(
808
+ state.pageTitle,
809
+ state.compiled.js,
810
+ state.wsPath,
811
+ state.customHead,
812
+ cssHash,
813
+ user,
814
+ state.showWsStatus,
815
+ adminRoot,
816
+ ),
817
+ );
818
+ } catch (e) {
300
819
  res
301
820
  .status(500)
302
821
  .type("text/html")
303
- .send(buildErrorPage(state.pageTitle, state.compiled.error));
304
- return;
822
+ .send(
823
+ buildErrorPage(
824
+ pageTitle,
825
+ "Page build failed: " + e.message,
826
+ wsPath,
827
+ ),
828
+ );
305
829
  }
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
830
  });
319
831
  registeredRoutes[endpoint] = true;
320
832
  }
@@ -341,6 +853,12 @@ module.exports = function (RED) {
341
853
  pathname = request.url;
342
854
  }
343
855
  if (pathname === wsPath) {
856
+ // Plugin hook: plugins may reject the connection before upgrade.
857
+ // Default (no plugins) = allowed, matches dashboard behavior.
858
+ if (!hooks.allow("onIsValidConnection", request)) {
859
+ try { socket.destroy(); } catch (e) { RED.log.trace("[portal-react] socket destroy: " + e.message); }
860
+ return;
861
+ }
344
862
  wsServer.handleUpgrade(request, socket, head, (ws) => {
345
863
  wsServer.emit("connection", ws, request);
346
864
  });
@@ -350,42 +868,131 @@ module.exports = function (RED) {
350
868
  RED.server.on("upgrade", onUpgrade);
351
869
  upgradeHandlers[nodeId] = onUpgrade;
352
870
 
353
- wsServer.on("connection", (ws) => {
871
+ wsServer.on("connection", (ws, request) => {
354
872
  if (isClosing) {
355
873
  ws.close();
356
874
  return;
357
875
  }
358
- clients.add(ws);
876
+ const portalClient = crypto.randomUUID();
877
+ ws._portalClient = portalClient;
878
+ if (portalAuth) {
879
+ ws._portalUser = extractPortalUser(request.headers);
880
+ }
881
+ clients.set(portalClient, ws);
882
+
883
+ // Index by userId for O(1) user-cast routing
884
+ const userId = ws._portalUser && ws._portalUser.userId;
885
+ if (userId) {
886
+ let set = userIndex.get(userId);
887
+ if (!set) {
888
+ set = new Set();
889
+ userIndex.set(userId, set);
890
+ }
891
+ set.add(ws);
892
+ }
893
+
359
894
  updateStatus();
360
895
 
361
- // Push current state to new client
362
- if (lastPayload !== null) {
363
- wsSend(ws, { type: "data", payload: lastPayload });
896
+ // Send content version for deploy-reload detection.
897
+ // In degraded mode (current build failed but lastGood served), advertise
898
+ // the lastGood hash so the freshly reloaded client matches the JS we sent.
899
+ const cs = pageState[endpoint];
900
+ const contentHash =
901
+ cs?.compiled?.error && cs?.lastGood
902
+ ? cs.lastGood.contentHash
903
+ : cs?.contentHash || "";
904
+ wsSend(ws, { type: "version", hash: contentHash });
905
+
906
+ // Send assigned portalClient to browser
907
+ wsSend(ws, { type: "hello", portalClient });
908
+
909
+ // Degraded warning — show banner, not full overlay.
910
+ if (cs?.compiled?.error && cs?.lastGood) {
911
+ wsSend(ws, {
912
+ type: "error",
913
+ message: cs.compiled.error,
914
+ degraded: true,
915
+ });
916
+ }
917
+
918
+ // Send the cached last broadcast (if any) as a distinct
919
+ // `recovery` frame. The browser uses this to seed `data` on a
920
+ // fresh connection. React components can opt out via
921
+ // useNodeRed({ ignoreRecovery: true }).
922
+ if (lastBroadcastCache.has(endpoint)) {
923
+ wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
364
924
  }
365
925
 
926
+ // Heartbeat — detect dead sockets via WS ping/pong. Browser
927
+ // auto-replies to ping frames, no client JS needed.
928
+ ws._isAlive = true;
929
+ ws.on("pong", () => { ws._isAlive = true; });
930
+ ws._pingIv = setInterval(() => {
931
+ if (ws._isAlive === false) {
932
+ try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
933
+ return;
934
+ }
935
+ ws._isAlive = false;
936
+ try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
937
+ }, 30000);
938
+
366
939
  ws.on("message", (raw) => {
367
940
  try {
368
941
  const msg = JSON.parse(raw.toString());
942
+ if (msg.type === "runtime_error") {
943
+ // Browser caught an exception while running the bundle —
944
+ // surface it on node status so the editor shows red even
945
+ // when the build itself succeeded (e.g. ReferenceError to
946
+ // an undefined identifier or missing component).
947
+ const st = pageState[endpoint];
948
+ if (st && !(st.compiled && st.compiled.error)) {
949
+ st.runtimeError = String(msg.message || "")
950
+ .split("\n")[0]
951
+ .slice(0, 200);
952
+ node.error("Runtime error in browser: " + st.runtimeError);
953
+ updateStatus();
954
+ }
955
+ return;
956
+ }
369
957
  if (msg.type === "output") {
370
- node.send({
958
+ let out = {
371
959
  payload: msg.payload,
372
960
  topic: msg.topic || "",
373
- });
961
+ };
962
+ // Server-side identity injection — the client cannot forge
963
+ // _client because we build it from ws state, not from the
964
+ // inbound frame.
965
+ const client = { portalClient: ws._portalClient };
966
+ if (portalAuth && ws._portalUser) {
967
+ Object.assign(client, ws._portalUser);
968
+ }
969
+ out._client = client;
970
+ // Transform hook — plugins may mutate / drop the msg.
971
+ // A hook returning null signals "drop this message".
972
+ out = hooks.transform("onInbound", out, ws);
973
+ if (out) node.send(out);
974
+ return;
374
975
  }
375
976
  } catch (e) {
376
977
  node.warn("Bad WS message: " + e.message);
377
978
  }
378
979
  });
379
980
 
380
- ws.on("close", () => {
381
- clients.delete(ws);
981
+ const detach = () => {
982
+ if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
983
+ clients.delete(portalClient);
984
+ if (userId) {
985
+ const set = userIndex.get(userId);
986
+ if (set) {
987
+ set.delete(ws);
988
+ if (set.size === 0) userIndex.delete(userId);
989
+ }
990
+ }
382
991
  updateStatus();
383
- });
992
+ };
384
993
 
385
- ws.on("error", () => {
386
- clients.delete(ws);
387
- updateStatus();
388
- });
994
+ ws.on("close", detach);
995
+ ws.on("error", detach);
389
996
  });
390
997
  } catch (e) {
391
998
  node.error("WebSocket setup failed: " + e.message);
@@ -393,12 +1000,28 @@ module.exports = function (RED) {
393
1000
 
394
1001
  // ── Input handler ─────────────────────────────────────────
395
1002
 
1003
+ // sendTo: single point where every outbound frame passes through
1004
+ // the onCanSendTo hook. Strict-by-default — no opt-in per widget
1005
+ // type like dashboard's acceptsClientConfig.
1006
+ function sendTo(ws, frame, msg) {
1007
+ if (!ws || ws.readyState !== 1) return false;
1008
+ if (!hooks.allow("onCanSendTo", ws, msg)) return false;
1009
+ try {
1010
+ ws.send(frame);
1011
+ return true;
1012
+ } catch (e) {
1013
+ RED.log.trace("[portal-react] ws send frame: " + e.message);
1014
+ return false;
1015
+ }
1016
+ }
1017
+
396
1018
  node.on("input", (msg, send, done) => {
397
- lastPayload = msg.payload;
398
- const frame = JSON.stringify({ type: "data", payload: msg.payload });
399
- clients.forEach((ws) => {
400
- if (ws.readyState === 1) ws.send(frame);
401
- });
1019
+ const result = router.route(msg, { clients, userIndex, sendTo });
1020
+ // Cache the latest broadcast payload so freshly-connected clients
1021
+ // can recover it via the `recovery` frame on connect.
1022
+ if (result.mode === "broadcast") {
1023
+ lastBroadcastCache.set(endpoint, msg.payload);
1024
+ }
402
1025
  updateStatus();
403
1026
  if (done) done();
404
1027
  });
@@ -414,11 +1037,13 @@ module.exports = function (RED) {
414
1037
  delete upgradeHandlers[nodeId];
415
1038
  }
416
1039
 
417
- // Close all WS clients
1040
+ // Close all WS clients — clear heartbeat interval BEFORE ws.close()
1041
+ // so pending pings do not leak if the 'close' event is delayed.
418
1042
  clients.forEach((ws) => {
1043
+ if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
419
1044
  try {
420
1045
  ws.close(1001, "node redeployed");
421
- } catch (_) {}
1046
+ } catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
422
1047
  });
423
1048
  clients.clear();
424
1049
 
@@ -426,15 +1051,48 @@ module.exports = function (RED) {
426
1051
  if (wsServer) {
427
1052
  try {
428
1053
  wsServer.close();
429
- } catch (_) {}
1054
+ } catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
430
1055
  wsServer = null;
431
1056
  }
432
1057
 
433
- // Unregister rebuild callback
1058
+ // Unregister rebuild callback + selective-rebuild metadata
434
1059
  delete rebuildCallbacks[nodeId];
1060
+ delete portalNeeded[nodeId];
1061
+ delete portalCode[nodeId];
1062
+
1063
+ // Release endpoint ownership
1064
+ if (endpointOwners[endpoint] === nodeId) {
1065
+ delete endpointOwners[endpoint];
1066
+ }
1067
+
1068
+ // Drop the recovery cache only on full node removal — on a
1069
+ // redeploy we keep it so reconnecting clients still recover.
1070
+ if (removed) {
1071
+ lastBroadcastCache.delete(endpoint);
1072
+ delete portalSig[nodeId];
1073
+ }
1074
+
1075
+ // Clear the userIndex — WS clients are already closed above, but
1076
+ // the Map itself should not outlive the node instance.
1077
+ userIndex.clear();
1078
+
1079
+ // Break references to large objects / Promises in pageState even on
1080
+ // redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
1081
+ // between close and the new build these would retain closures over
1082
+ // the old clients/userIndex/rebuild scope.
1083
+ const st = pageState[endpoint];
1084
+ if (st) {
1085
+ st.cssReady = null;
1086
+ st.compiled = null;
1087
+ st.css = null;
1088
+ }
435
1089
 
436
1090
  // Clean up route only when node is fully removed (not redeployed)
437
1091
  if (removed) {
1092
+ // Delete disk cache if no other endpoint uses this hash
1093
+ if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
1094
+ deleteCacheFiles(lastJsxHash);
1095
+ }
438
1096
  delete pageState[endpoint];
439
1097
  removeRoute(RED.httpNode._router, endpoint);
440
1098
  delete registeredRoutes[endpoint];
@@ -448,22 +1106,15 @@ module.exports = function (RED) {
448
1106
  function wsSend(ws, obj) {
449
1107
  try {
450
1108
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
451
- } catch (_) {}
1109
+ } catch (e) { RED.log.trace("[portal-react] wsSend: " + e.message); }
452
1110
  }
453
1111
 
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
1112
  }); // end setImmediate
464
1113
  }
465
1114
 
466
- RED.nodes.registerType("portal-react", PortalReactNode);
1115
+ RED.nodes.registerType("portal-react", PortalReactNode, {
1116
+ dynamicModuleList: "libs",
1117
+ });
467
1118
 
468
1119
  // ── Serve Monaco editor files locally ────────────────────────
469
1120
  const express = require("express");
@@ -485,9 +1136,16 @@ module.exports = function (RED) {
485
1136
  res.json(twClassesCache);
486
1137
  });
487
1138
 
488
- // ── Vendor CSS endpoint (per content hash) ─────────────────
1139
+ // ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
489
1140
  RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
490
- const css = cssCache[req.params.hash];
1141
+ const reqHash = req.params.hash;
1142
+ let css = null;
1143
+ for (const ep in pageState) {
1144
+ if (pageState[ep]?.cssHash === reqHash) {
1145
+ css = pageState[ep].css;
1146
+ break;
1147
+ }
1148
+ }
491
1149
  if (!css) {
492
1150
  res.status(404).send("Not found");
493
1151
  return;
@@ -499,15 +1157,9 @@ module.exports = function (RED) {
499
1157
  res.send(css);
500
1158
  });
501
1159
 
502
- // ── Vendor React bundle endpoint ────────────────────────────
503
- RED.httpAdmin.get("/portal-react/vendor/react.min.js", (_req, res) => {
504
- res.set({
505
- "Content-Type": "application/javascript",
506
- "Cache-Control": "public, max-age=31536000, immutable",
507
- ETag: `"${reactHash}"`,
508
- });
509
- res.send(reactBundle);
510
- });
1160
+ // ── Public assets folder ─────────────────────────────────────
1161
+ const { registerAssets } = require("./lib/assets");
1162
+ registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
511
1163
 
512
1164
  // ── Admin API for component registry ──────────────────────────
513
1165
 
@@ -516,10 +1168,15 @@ module.exports = function (RED) {
516
1168
  });
517
1169
 
518
1170
  RED.httpAdmin.post("/portal-react/registry", (req, res) => {
519
- const { name, code, inputs, outputs } = req.body || {};
1171
+ const { name, code } = req.body || {};
520
1172
  if (!isSafeName(name))
521
1173
  return res.status(400).json({ error: "invalid name" });
522
- registry[name] = { code, inputs: inputs || [], outputs: outputs || [] };
1174
+ const newCode = code || "";
1175
+ const prevCode = registry[name]?.code;
1176
+ registry[name] = { code: newCode };
1177
+ if (prevCode !== newCode) {
1178
+ scheduleRebuildUsing(name);
1179
+ }
523
1180
  res.json({ ok: true });
524
1181
  });
525
1182
 
@@ -527,110 +1184,11 @@ module.exports = function (RED) {
527
1184
  const name = req.params.name;
528
1185
  if (!isSafeName(name))
529
1186
  return res.status(400).json({ error: "invalid name" });
1187
+ const existed = Object.prototype.hasOwnProperty.call(registry, name);
530
1188
  delete registry[name];
1189
+ if (existed) {
1190
+ scheduleRebuildUsing(name);
1191
+ }
531
1192
  res.json({ ok: true });
532
1193
  });
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, "&amp;")
628
- .replace(/</g, "&lt;")
629
- .replace(/>/g, "&gt;")
630
- .replace(/"/g, "&quot;");
631
- }
632
-
633
- function escScript(s) {
634
- return String(s).replace(/<\/(script)/gi, "<\\/$1");
635
- }
636
1194
  };