@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.
@@ -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,143 @@ 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 = {};
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
- 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
- );
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
- 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
- };
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
- // Trigger re-transpile on all portal-react nodes (after all nodes init)
175
- setImmediate(() => {
176
- Object.values(rebuildCallbacks).forEach((fn) => fn());
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 endpoint = (config.endpoint || "/portal").replace(/\/+$/, "");
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 Set();
201
- let lastPayload = null;
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
- const wsPath = endpoint + "/_ws";
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
- // 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");
327
+ try {
328
+ node.status({ fill: "yellow", shape: "dot", text: "building..." });
252
329
 
253
- const compiled = transpile(fullJsx);
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
- 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
- }
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
- 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) => {
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
- : Promise.resolve("");
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
- pageState[endpoint] = {
275
- compiled,
276
- cssHashReady,
277
- pageTitle,
278
- wsPath,
279
- customHead,
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
- // Delay initial build so all fc-portal-component nodes register first
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
- 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) {
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(buildErrorPage(state.pageTitle, state.compiled.error));
304
- return;
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
- clients.add(ws);
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
- // Push current state to new client
362
- if (lastPayload !== null) {
363
- wsSend(ws, { type: "data", payload: lastPayload });
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
- node.send({
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
- ws.on("close", () => {
381
- clients.delete(ws);
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("error", () => {
386
- clients.delete(ws);
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
- 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
- });
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 content hash) ─────────────────
952
+ // ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
489
953
  RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
490
- const css = cssCache[req.params.hash];
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
- // ── 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
- });
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, inputs, outputs } = req.body || {};
984
+ const { name, code } = req.body || {};
520
985
  if (!isSafeName(name))
521
986
  return res.status(400).json({ error: "invalid name" });
522
- registry[name] = { code, inputs: inputs || [], outputs: outputs || [] };
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, "&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
1007
  };