@aaqu/fromcubes-portal-react 0.1.0-alpha.13 → 0.1.0-alpha.15

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.
@@ -7,9 +7,7 @@
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
11
 
14
12
  module.exports = function (RED) {
15
13
  // ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
@@ -47,114 +45,55 @@ module.exports = function (RED) {
47
45
  }
48
46
  const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
49
47
 
50
- // ── Helpers ───────────────────────────────────────────────────
51
-
52
- function hash(str) {
53
- return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
54
- }
55
-
56
- const twCompile = require("tailwindcss").compile;
57
- const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
58
-
59
- let twCompiled = null;
60
- async function getTwCompiled() {
61
- if (twCompiled) return twCompiled;
62
- twCompiled = await twCompile(`@import 'tailwindcss';`, {
63
- loadStylesheet: async (id, base) => {
64
- let resolved;
65
- if (id === "tailwindcss") {
66
- resolved = require.resolve("tailwindcss/index.css");
67
- } else {
68
- resolved = require.resolve(id, { paths: [base || __dirname] });
69
- }
70
- return {
71
- content: fs.readFileSync(resolved, "utf8"),
72
- base: path.dirname(resolved),
73
- };
74
- },
75
- });
76
- return twCompiled;
77
- }
78
-
79
- // Package root — where react/react-dom live (this package's own node_modules)
80
- const pkgRoot = path.join(__dirname, "..");
81
- // userDir — where dynamicModuleList installs user packages
82
- const userDir = RED.settings.userDir || path.join(__dirname, "../../..");
83
-
84
-
85
- function transpile(jsx) {
86
- try {
87
- const buildResult = esbuild.buildSync({
88
- stdin: {
89
- contents: jsx,
90
- resolveDir: pkgRoot,
91
- loader: "jsx",
92
- },
93
- bundle: true,
94
- format: "iife",
95
- minify: true,
96
- write: false,
97
- target: ["es2020"],
98
- jsx: "transform",
99
- jsxFactory: "React.createElement",
100
- jsxFragment: "React.Fragment",
101
- define: { "process.env.NODE_ENV": '"production"' },
102
- logOverride: { "import-is-undefined": "silent" },
103
- nodePaths: [path.join(userDir, "node_modules")],
104
- alias: {
105
- "react": path.dirname(require.resolve("react/package.json", { paths: [pkgRoot] })),
106
- "react-dom": path.dirname(require.resolve("react-dom/package.json", { paths: [pkgRoot] })),
107
- },
108
- });
109
- return { js: buildResult.outputFiles[0].text, error: null };
110
- } catch (e) {
111
- return { js: null, error: e.message };
112
- }
48
+ // Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
49
+ if (!RED.settings.fcEndpointOwners) {
50
+ RED.settings.fcEndpointOwners = {};
113
51
  }
52
+ const endpointOwners = RED.settings.fcEndpointOwners;
114
53
 
115
- async function generateCSS(source) {
116
- const cssHash = hash(source);
117
- const compiled = await getTwCompiled();
118
- const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
119
- const css = compiled.build(candidates);
120
- return { css, cssHash };
54
+ // Track component name ownership: { compName: nodeId } — prevents duplicate component names
55
+ if (!RED.settings.fcCompNameOwners) {
56
+ RED.settings.fcCompNameOwners = {};
121
57
  }
122
-
123
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
124
-
125
- function isSafeName(name) {
126
- return (
127
- typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
128
- );
129
- }
130
-
131
- function extractPortalUser(headers) {
132
- const user = {};
133
- if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
134
- if (headers["x-portal-user-name"])
135
- user.userName = headers["x-portal-user-name"];
136
- if (headers["x-portal-user-username"])
137
- user.username = headers["x-portal-user-username"];
138
- if (headers["x-portal-user-email"])
139
- user.email = headers["x-portal-user-email"];
140
- if (headers["x-portal-user-role"])
141
- user.role = headers["x-portal-user-role"];
142
- if (headers["x-portal-user-groups"]) {
143
- try {
144
- user.groups = JSON.parse(headers["x-portal-user-groups"]);
145
- } catch (_) {
146
- user.groups = headers["x-portal-user-groups"];
58
+ const compNameOwners = RED.settings.fcCompNameOwners;
59
+
60
+ // Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
61
+ // Yields event loop between builds so HTTP server stays responsive
62
+ let _rebuildTimer = null;
63
+ function scheduleRebuildAll() {
64
+ if (_rebuildTimer) clearTimeout(_rebuildTimer);
65
+ _rebuildTimer = setTimeout(() => {
66
+ _rebuildTimer = null;
67
+ const fns = Object.values(rebuildCallbacks);
68
+ let i = 0;
69
+ function next() {
70
+ if (i >= fns.length) return;
71
+ try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
72
+ i++;
73
+ if (i < fns.length) setImmediate(next);
147
74
  }
148
- }
149
- return Object.keys(user).length > 0 ? user : null;
75
+ next();
76
+ }, 50);
150
77
  }
151
78
 
152
- function removeRoute(router, path) {
153
- if (!router || !router.stack) return;
154
- router.stack = router.stack.filter(
155
- (layer) => !(layer.route && layer.route.path === path),
156
- );
157
- }
79
+ // ── Load modules ─────────────────────────────────────────────
80
+ const helpers = require("./lib/helpers")(RED);
81
+ const {
82
+ hash,
83
+ transpile,
84
+ generateCSS,
85
+ extractPortalUser,
86
+ removeRoute,
87
+ isSafeName,
88
+ userDir,
89
+ readCachedJS,
90
+ writeCachedJS,
91
+ readCachedCSS,
92
+ writeCachedCSS,
93
+ deleteCacheFiles,
94
+ isHashInUse,
95
+ } = helpers;
96
+ const { buildPage, buildErrorPage } = require("./lib/page-builder");
158
97
 
159
98
  // ── Canvas node: shared component ─────────────────────────────
160
99
 
@@ -169,30 +108,37 @@ module.exports = function (RED) {
169
108
  return;
170
109
  }
171
110
 
111
+ // Duplicate component name check
112
+ const existingOwner = compNameOwners[compName];
113
+ if (existingOwner && existingOwner !== node.id) {
114
+ node.error(
115
+ `Component name "${compName}" is already used by another node`,
116
+ );
117
+ node.status({
118
+ fill: "red",
119
+ shape: "ring",
120
+ text: "duplicate: " + compName,
121
+ });
122
+ node.on("close", function (_removed, done) {
123
+ if (done) done();
124
+ });
125
+ return;
126
+ }
127
+ compNameOwners[compName] = node.id;
128
+
172
129
  registry[compName] = {
173
130
  code: config.compCode || "",
174
- inputs: config.compInputs
175
- ? config.compInputs
176
- .split(",")
177
- .map((s) => s.trim())
178
- .filter(Boolean)
179
- : [],
180
- outputs: config.compOutputs
181
- ? config.compOutputs
182
- .split(",")
183
- .map((s) => s.trim())
184
- .filter(Boolean)
185
- : [],
186
131
  };
187
132
 
188
133
  node.status({ fill: "green", shape: "dot", text: compName });
189
134
 
190
- // Trigger re-transpile on all portal-react nodes (after all nodes init)
191
- setImmediate(() => {
192
- Object.values(rebuildCallbacks).forEach((fn) => fn());
193
- });
135
+ // Trigger re-transpile on all portal-react nodes (debounced across all component registrations)
136
+ scheduleRebuildAll();
194
137
 
195
138
  node.on("close", function (removed, done) {
139
+ if (compNameOwners[compName] === node.id) {
140
+ delete compNameOwners[compName];
141
+ }
196
142
  delete registry[compName];
197
143
  if (done) done();
198
144
  });
@@ -215,15 +161,33 @@ module.exports = function (RED) {
215
161
  const showWsStatus = config.showWsStatus === true;
216
162
  const libs = config.libs || [];
217
163
 
164
+ // ── Duplicate endpoint check ──
165
+ const existingOwner = endpointOwners[endpoint];
166
+ if (existingOwner && existingOwner !== nodeId) {
167
+ node.error(
168
+ `Endpoint "${endpoint}" is already used by another portal node`,
169
+ );
170
+ node.status({
171
+ fill: "red",
172
+ shape: "ring",
173
+ text: "duplicate: " + endpoint,
174
+ });
175
+ node.on("close", function (_removed, done) {
176
+ if (done) done();
177
+ });
178
+ return;
179
+ }
180
+ endpointOwners[endpoint] = nodeId;
181
+
218
182
  // State
219
183
  const clients = new Map(); // portalId → ws
220
184
  let lastPayload = null;
221
185
  let wsServer = null;
222
186
  let isClosing = false;
187
+ let lastJsxHash = null;
223
188
 
224
189
  if (libs.length > 0) {
225
- const names = libs.map((l) => l.module).join(", ");
226
- node.status({ fill: "blue", shape: "ring", text: `installing ${names}...` });
190
+ node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
227
191
  } else {
228
192
  node.status({ fill: "yellow", shape: "ring", text: "starting..." });
229
193
  }
@@ -233,178 +197,349 @@ module.exports = function (RED) {
233
197
  // ── Rebuild: transpile JSX + update page state ────────────
234
198
 
235
199
  function rebuild() {
236
- node.status({ fill: "yellow", shape: "dot", text: "building..." });
237
-
238
- // Selective injection: only include components referenced in user code (+ transitive deps)
239
- const allEntries = Object.entries(registry);
240
- const needed = new Set();
241
-
242
- function addWithDeps(name) {
243
- if (needed.has(name)) return;
244
- const entry = registry[name];
245
- if (!entry) return;
246
- needed.add(name);
247
- for (const [other] of allEntries) {
248
- if (other !== name && entry.code.includes(other)) {
249
- addWithDeps(other);
200
+ try {
201
+ node.status({ fill: "yellow", shape: "dot", text: "building..." });
202
+
203
+ // ── Pre-build: clear cache, set building state, notify browsers ──
204
+ const prevState = pageState[endpoint];
205
+ const prevHash = prevState?.jsxHash;
206
+ if (prevHash && !isHashInUse(prevHash, pageState, endpoint)) {
207
+ deleteCacheFiles(prevHash);
208
+ }
209
+ pageState[endpoint] = { building: true, wsPath, pageTitle };
210
+ clients.forEach((ws) => {
211
+ try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (_) {}
212
+ });
213
+
214
+ // Selective injection: only include components referenced in user code (+ transitive deps)
215
+ const allEntries = Object.entries(registry);
216
+ const needed = new Set();
217
+
218
+ function addWithDeps(name) {
219
+ if (needed.has(name)) return;
220
+ const entry = registry[name];
221
+ if (!entry) return;
222
+ needed.add(name);
223
+ for (const [other] of allEntries) {
224
+ if (other !== name && entry.code.includes(other)) {
225
+ addWithDeps(other);
226
+ }
250
227
  }
251
228
  }
252
- }
253
229
 
254
- for (const [name] of allEntries) {
255
- if (componentCode.includes(name)) {
256
- addWithDeps(name);
230
+ for (const [name] of allEntries) {
231
+ if (componentCode.includes(name)) {
232
+ addWithDeps(name);
233
+ }
257
234
  }
258
- }
259
235
 
260
- // Topological sort only needed components
261
- const entries = allEntries.filter(([n]) => needed.has(n));
262
- entries.sort((a, b) => {
263
- const aUsesB = a[1].code.includes(b[0]);
264
- const bUsesA = b[1].code.includes(a[0]);
265
- if (aUsesB && !bUsesA) return 1; // a depends on b → b first
266
- if (bUsesA && !aUsesB) return -1; // b depends on a → a first
267
- return 0;
268
- });
269
- const libraryJsx = entries
270
- .map(
271
- ([name, c]) =>
272
- `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
273
- )
274
- .join("\n\n");
275
-
276
- // Extract import statements from library/user code so they appear at top level
277
- const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
278
- const libImports = libraryJsx.match(importRe) || [];
279
- const userImports = componentCode.match(importRe) || [];
280
- const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
281
- const cleanCompCode = componentCode.replace(importRe, "").trim();
282
-
283
- const fullJsx = [
284
- "// ── Imports ──",
285
- 'import React from "react";',
286
- 'import ReactDOM from "react-dom";',
287
- 'import { createRoot } from "react-dom/client";',
288
- ...libImports,
289
- ...userImports,
290
- "",
291
- "// ── React shorthand ──",
292
- "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
293
- "const { createContext, memo, forwardRef, Fragment } = React;",
294
- "",
295
- "// ── useNodeRed hook ──",
296
- [
297
- "function useNodeRed() {",
298
- " const [data, setData] = React.useState(window.__NR._lastData);",
299
- " React.useEffect(() => {",
300
- " return window.__NR.subscribe(setData);",
301
- " }, []);",
302
- " const send = React.useCallback((payload, topic) => {",
303
- " window.__NR.send(payload, topic);",
304
- " }, []);",
305
- " const user = window.__NR._user || null;",
306
- " const portalClient = window.__NR._portalClient;",
307
- " return { data, send, user, portalClient };",
308
- "}",
309
- ].join("\n"),
310
- "",
311
- "// ── Library components ──",
312
- cleanLibJsx,
313
- "",
314
- "// ── View component ──",
315
- cleanCompCode,
316
- "",
317
- "// ── Mount ──",
318
- "createRoot(document.getElementById('root')).render(React.createElement(App));",
319
- ].join("\n");
320
-
321
- const compiled = transpile(fullJsx);
322
-
323
- if (compiled.error) {
324
- node.error("JSX transpile error: " + compiled.error);
325
- node.status({ fill: "red", shape: "dot", text: "transpile error" });
326
- } else {
327
- node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
328
- }
236
+ // Topological sort only needed components
237
+ const entries = allEntries.filter(([n]) => needed.has(n));
238
+ entries.sort((a, b) => {
239
+ const aUsesB = a[1].code.includes(b[0]);
240
+ const bUsesA = b[1].code.includes(a[0]);
241
+ if (aUsesB && !bUsesA) return 1; // a depends on b → b first
242
+ if (bUsesA && !aUsesB) return -1; // b depends on a → a first
243
+ return 0;
244
+ });
245
+ const libraryJsx = entries
246
+ .map(
247
+ ([name, c]) =>
248
+ `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
249
+ )
250
+ .join("\n\n");
251
+
252
+ // Extract import statements from library/user code so they appear at top level
253
+ const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
254
+ const libImports = libraryJsx.match(importRe) || [];
255
+ const userImports = componentCode.match(importRe) || [];
256
+ const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
257
+ const cleanCompCode = componentCode.replace(importRe, "").trim();
258
+
259
+ // Warn about import * (prevents tree-shaking)
260
+ const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
261
+ const allCode = cleanLibJsx + "\n" + cleanCompCode;
262
+ for (const imp of [...libImports, ...userImports]) {
263
+ const m = imp.match(starRe);
264
+ if (!m) continue;
265
+ const [, localName, modulePath] = m;
266
+ const propRe = new RegExp(
267
+ `\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`,
268
+ "g",
269
+ );
270
+ const props = new Set();
271
+ let pm;
272
+ while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
273
+ if (props.size > 0) {
274
+ const named = [...props].sort().join(", ");
275
+ node.warn(
276
+ `"import * as ${localName}" bundles entire ${modulePath} library. ` +
277
+ `For smaller builds use: import { ${named} } from '${modulePath}'`,
278
+ );
279
+ }
280
+ }
329
281
 
330
- const contentHash = compiled.js ? hash(compiled.js) : "";
331
- const prevState = pageState[endpoint];
332
- const jsxHash = hash(fullJsx);
333
-
334
- const cssReady = !compiled.error
335
- ? (prevState?.jsxHash === jsxHash && prevState?.css
336
- ? Promise.resolve({ css: prevState.css, cssHash: prevState.cssHash })
337
- : generateCSS(fullJsx))
338
- .catch((err) => {
339
- node.warn("Tailwind CSS generation failed: " + err.message);
340
- return { css: "", cssHash: "" };
341
- })
342
- : Promise.resolve({ css: "", cssHash: "" });
343
-
344
- pageState[endpoint] = {
345
- compiled,
346
- contentHash,
347
- cssReady,
348
- jsxHash,
349
- css: null,
350
- cssHash: "",
351
- pageTitle,
352
- wsPath,
353
- customHead,
354
- portalAuth,
355
- showWsStatus,
356
- };
357
-
358
- cssReady.then(({ css, cssHash }) => {
359
- const state = pageState[endpoint];
360
- if (state && state.jsxHash === jsxHash) {
361
- state.css = css;
362
- state.cssHash = cssHash;
363
- node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
282
+ const fullJsx = [
283
+ "// ── Imports ──",
284
+ 'import React from "react";',
285
+ 'import ReactDOM from "react-dom";',
286
+ 'import { createRoot } from "react-dom/client";',
287
+ ...libImports,
288
+ ...userImports,
289
+ "",
290
+ "// ── React shorthand ──",
291
+ "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
292
+ "const { createContext, memo, forwardRef, Fragment } = React;",
293
+ "",
294
+ "// ── useNodeRed hook ──",
295
+ [
296
+ "function useNodeRed() {",
297
+ " const [data, setData] = React.useState(window.__NR._lastData);",
298
+ " React.useEffect(() => {",
299
+ " return window.__NR.subscribe(setData);",
300
+ " }, []);",
301
+ " const send = React.useCallback((payload, topic) => {",
302
+ " window.__NR.send(payload, topic);",
303
+ " }, []);",
304
+ " const user = window.__NR._user || null;",
305
+ " const portalClient = window.__NR._portalClient;",
306
+ " return { data, send, user, portalClient };",
307
+ "}",
308
+ ].join("\n"),
309
+ "",
310
+ "// ── Library components ──",
311
+ cleanLibJsx,
312
+ "",
313
+ "// ── View component ──",
314
+ cleanCompCode,
315
+ "",
316
+ "// ── Mount ──",
317
+ "createRoot(document.getElementById('root')).render(React.createElement(App));",
318
+ ].join("\n");
319
+
320
+ // ── Check: missing return in App ──
321
+ const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
322
+ if (appFnMatch) {
323
+ let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
324
+ let hasReturn = false;
325
+ while (i < cleanCompCode.length && depth > 0) {
326
+ const ch = cleanCompCode[i];
327
+ if (ch === "{") depth++;
328
+ else if (ch === "}") depth--;
329
+ else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
330
+ i++;
331
+ }
332
+ if (!hasReturn) {
333
+ node.error("App component has no return statement");
334
+ node.status({ fill: "red", shape: "dot", text: "missing return" });
335
+ const missingReturnError = {
336
+ js: null,
337
+ error: "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
338
+ };
339
+ pageState[endpoint] = {
340
+ compiled: missingReturnError,
341
+ contentHash: "",
342
+ cssReady: Promise.resolve({ css: "", cssHash: "" }),
343
+ jsxHash: "",
344
+ css: null,
345
+ cssHash: "",
346
+ pageTitle,
347
+ wsPath,
348
+ customHead,
349
+ portalAuth,
350
+ showWsStatus,
351
+ };
352
+ clients.forEach((ws) => {
353
+ try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (_) {}
354
+ });
355
+ return;
356
+ }
364
357
  }
365
- });
358
+
359
+ const jsxHash = hash(fullJsx);
360
+
361
+ // ── JS: disk cache → transpile ──
362
+ let compiled = readCachedJS(jsxHash);
363
+ let cacheHit = !!compiled;
364
+ if (!compiled) {
365
+ compiled = transpile(fullJsx);
366
+ if (!compiled.error) {
367
+ writeCachedJS(jsxHash, compiled.js, compiled.metafile);
368
+ }
369
+ }
370
+
371
+ if (compiled.error) {
372
+ node.error("JSX transpile error: " + compiled.error);
373
+ RED.log.warn(
374
+ `[portal-react] ${endpoint} — JSX transpile error: ${compiled.error}`,
375
+ );
376
+ node.status({ fill: "red", shape: "dot", text: "transpile error" });
377
+ clients.forEach((ws) => {
378
+ try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (_) {}
379
+ });
380
+ } else {
381
+ node.status({
382
+ fill: "green",
383
+ shape: "dot",
384
+ text: `built • ${endpoint}`,
385
+ });
386
+ if (compiled.metafile) {
387
+ const output = Object.values(compiled.metafile.outputs)[0];
388
+ const sizes = output
389
+ ? Object.entries(output.inputs)
390
+ .map(([name, info]) => ({
391
+ name: name.replace(/^.*node_modules\//, ""),
392
+ bytes: info.bytesInOutput,
393
+ }))
394
+ .sort((a, b) => b.bytes - a.bytes)
395
+ .slice(0, 5)
396
+ : [];
397
+ const totalKB = (compiled.js.length / 1024).toFixed(1);
398
+ node.log(
399
+ `Bundle${cacheHit ? " (cached)" : ""}: ${totalKB}KB — top: ${sizes.map((s) => `${s.name} (${(s.bytes / 1024).toFixed(1)}KB)`).join(", ")}`,
400
+ );
401
+ }
402
+ }
403
+
404
+ const contentHash = compiled.js ? hash(compiled.js) : "";
405
+
406
+ // ── CSS: disk cache → in-memory → generate ──
407
+ const cssReady = !compiled.error
408
+ ? (() => {
409
+ const cachedCSS = readCachedCSS(jsxHash);
410
+ if (cachedCSS) return Promise.resolve(cachedCSS);
411
+ if (prevState?.jsxHash === jsxHash && prevState?.css) {
412
+ return Promise.resolve({
413
+ css: prevState.css,
414
+ cssHash: prevState.cssHash,
415
+ });
416
+ }
417
+ return generateCSS(fullJsx).then(({ css, cssHash }) => {
418
+ writeCachedCSS(jsxHash, css);
419
+ return { css, cssHash };
420
+ });
421
+ })().catch((err) => {
422
+ node.warn("Tailwind CSS generation failed: " + err.message);
423
+ return { css: "", cssHash: "" };
424
+ })
425
+ : Promise.resolve({ css: "", cssHash: "" });
426
+
427
+ lastJsxHash = jsxHash;
428
+
429
+ pageState[endpoint] = {
430
+ compiled,
431
+ contentHash,
432
+ cssReady,
433
+ jsxHash,
434
+ css: null,
435
+ cssHash: "",
436
+ pageTitle,
437
+ wsPath,
438
+ customHead,
439
+ portalAuth,
440
+ showWsStatus,
441
+ };
442
+
443
+ // Notify all connected browsers that build finished — triggers reload or overlay cleanup
444
+ if (!compiled.error && contentHash) {
445
+ const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
446
+ clients.forEach((ws) => {
447
+ try { if (ws.readyState === 1) ws.send(versionFrame); } catch (_) {}
448
+ });
449
+ }
450
+
451
+ cssReady.then(({ css, cssHash }) => {
452
+ const state = pageState[endpoint];
453
+ if (state && state.jsxHash === jsxHash) {
454
+ state.css = css;
455
+ state.cssHash = cssHash;
456
+ node.status({
457
+ fill: "green",
458
+ shape: "dot",
459
+ text: `built • ${endpoint}`,
460
+ });
461
+ }
462
+ });
463
+ } catch (e) {
464
+ node.error("Rebuild failed: " + e.message);
465
+ node.status({ fill: "red", shape: "dot", text: "rebuild error" });
466
+ }
366
467
  }
367
468
 
368
469
  // Register rebuild callback so library components can trigger re-transpile
369
470
  rebuildCallbacks[nodeId] = rebuild;
370
471
 
371
- // Delay initial build so all fc-portal-component nodes register first
472
+ // Initial build: debounced so all fc-portal-component nodes register first
473
+ scheduleRebuildAll();
372
474
  setImmediate(() => {
373
- rebuild();
374
-
375
475
  // Register route only once per endpoint (persists across deploys)
376
476
  if (!registeredRoutes[endpoint]) {
377
477
  RED.httpNode.get(endpoint, async function (_req, res) {
378
- const state = pageState[endpoint];
379
- if (!state) {
380
- res.status(404).send("Not found");
381
- return;
382
- }
383
- res.set("Cache-Control", "no-store");
384
- if (state.compiled.error) {
478
+ try {
479
+ const state = pageState[endpoint];
480
+ if (!state || state.building) {
481
+ const bWsPath = state?.wsPath || wsPath;
482
+ res
483
+ .set("Cache-Control", "no-store")
484
+ .set("Refresh", "3")
485
+ .type("text/html")
486
+ .send(
487
+ `<!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>`,
488
+ );
489
+ return;
490
+ }
491
+ res.set("Cache-Control", "no-store");
492
+ if (state.compiled.error) {
493
+ res
494
+ .status(500)
495
+ .type("text/html")
496
+ .send(
497
+ buildErrorPage(
498
+ state.pageTitle,
499
+ state.compiled.error,
500
+ state.wsPath,
501
+ ),
502
+ );
503
+ return;
504
+ }
505
+ const { cssHash } = await Promise.race([
506
+ state.cssReady,
507
+ new Promise((_, reject) =>
508
+ setTimeout(
509
+ () => reject(new Error("CSS generation timeout")),
510
+ 15000,
511
+ ),
512
+ ),
513
+ ]);
514
+ const user = state.portalAuth
515
+ ? extractPortalUser(_req.headers)
516
+ : null;
517
+ res
518
+ .type("text/html")
519
+ .send(
520
+ buildPage(
521
+ state.pageTitle,
522
+ state.compiled.js,
523
+ state.wsPath,
524
+ state.customHead,
525
+ cssHash,
526
+ user,
527
+ state.showWsStatus,
528
+ adminRoot,
529
+ ),
530
+ );
531
+ } catch (e) {
385
532
  res
386
533
  .status(500)
387
534
  .type("text/html")
388
- .send(buildErrorPage(state.pageTitle, state.compiled.error));
389
- return;
535
+ .send(
536
+ buildErrorPage(
537
+ pageTitle,
538
+ "Page build failed: " + e.message,
539
+ wsPath,
540
+ ),
541
+ );
390
542
  }
391
- const { cssHash } = await state.cssReady;
392
- const user = state.portalAuth
393
- ? extractPortalUser(_req.headers)
394
- : null;
395
- res
396
- .type("text/html")
397
- .send(
398
- buildPage(
399
- state.pageTitle,
400
- state.compiled.js,
401
- state.wsPath,
402
- state.customHead,
403
- cssHash,
404
- user,
405
- state.showWsStatus,
406
- ),
407
- );
408
543
  });
409
544
  registeredRoutes[endpoint] = true;
410
545
  }
@@ -517,7 +652,10 @@ module.exports = function (RED) {
517
652
  if (ws.readyState !== 1) return;
518
653
  const u = ws._portalUser;
519
654
  if (!u) return;
520
- if ((matchId && u.userId === matchId) || (matchName && u.username === matchName)) {
655
+ if (
656
+ (matchId && u.userId === matchId) ||
657
+ (matchName && u.username === matchName)
658
+ ) {
521
659
  ws.send(frame);
522
660
  }
523
661
  });
@@ -563,8 +701,17 @@ module.exports = function (RED) {
563
701
  // Unregister rebuild callback
564
702
  delete rebuildCallbacks[nodeId];
565
703
 
704
+ // Release endpoint ownership
705
+ if (endpointOwners[endpoint] === nodeId) {
706
+ delete endpointOwners[endpoint];
707
+ }
708
+
566
709
  // Clean up route only when node is fully removed (not redeployed)
567
710
  if (removed) {
711
+ // Delete disk cache if no other endpoint uses this hash
712
+ if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
713
+ deleteCacheFiles(lastJsxHash);
714
+ }
568
715
  delete pageState[endpoint];
569
716
  removeRoute(RED.httpNode._router, endpoint);
570
717
  delete registeredRoutes[endpoint];
@@ -639,178 +786,8 @@ module.exports = function (RED) {
639
786
  });
640
787
 
641
788
  // ── Public assets folder ─────────────────────────────────────
642
- const assetsDir = path.join(userDir, "fromcubes-public");
643
- fs.mkdirSync(assetsDir, { recursive: true });
644
- const UNSAFE_EXTS = new Set([".html", ".htm", ".svg", ".js", ".mjs", ".xml", ".xhtml"]);
645
- RED.httpNode.use(
646
- "/fromcubes/public",
647
- (req, res, next) => {
648
- res.set("X-Content-Type-Options", "nosniff");
649
- res.set("Content-Security-Policy", "default-src 'none'");
650
- const ext = path.extname(req.path).toLowerCase();
651
- if (UNSAFE_EXTS.has(ext)) {
652
- res.set("Content-Disposition", "attachment");
653
- }
654
- next();
655
- },
656
- express.static(assetsDir, { maxAge: "1d" }),
657
- );
658
-
659
- const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)(\.|$)/i;
660
- function isSafePathSegment(s) {
661
- return (
662
- typeof s === "string" &&
663
- s.length > 0 &&
664
- s.length <= 255 &&
665
- !/[\\:*?"<>|\0]/.test(s) &&
666
- !s.startsWith(".") &&
667
- !s.endsWith(".") && // Windows strips trailing dots
668
- !s.endsWith(" ") && // Windows strips trailing spaces
669
- s !== ".." &&
670
- !RESERVED_NAMES.test(s)
671
- );
672
- }
673
-
674
- const MAX_PATH_DEPTH = 10;
675
- function safePath(rel) {
676
- if (!rel || typeof rel !== "string") return null;
677
- const segments = rel.split("/").filter(Boolean);
678
- if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
679
- if (!segments.every(isSafePathSegment)) return null;
680
- const resolved = path.resolve(assetsDir, ...segments);
681
- if (!resolved.startsWith(assetsDir + path.sep) && resolved !== assetsDir)
682
- return null;
683
- // Symlink escape check: verify realpath stays inside assetsDir
684
- try {
685
- const real = fs.realpathSync(resolved);
686
- if (!real.startsWith(assetsDir + path.sep) && real !== assetsDir)
687
- return null;
688
- } catch (_e) { /* path doesn't exist yet — OK for mkdir/upload */ }
689
- return resolved;
690
- }
691
-
692
- function scanDir(dir, prefix) {
693
- const results = [];
694
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
695
- if (entry.isSymbolicLink()) continue; // skip symlinks for safety
696
- const rel = prefix ? prefix + "/" + entry.name : entry.name;
697
- if (entry.isDirectory()) {
698
- results.push({ name: rel, type: "dir" });
699
- results.push(...scanDir(path.join(dir, entry.name), rel));
700
- } else if (entry.isFile()) {
701
- const stat = fs.statSync(path.join(dir, entry.name));
702
- results.push({ name: rel, type: "file", size: stat.size, mtime: stat.mtimeMs });
703
- }
704
- }
705
- return results;
706
- }
707
-
708
- RED.httpAdmin.get("/portal-react/assets", (_req, res) => {
709
- try {
710
- res.json(scanDir(assetsDir, ""));
711
- } catch (e) {
712
- res.json([]);
713
- }
714
- });
715
-
716
- RED.httpAdmin.post("/portal-react/assets/mkdir", express.json(), (req, res) => {
717
- const target = safePath(req.body && req.body.path);
718
- if (!target) return res.status(400).json({ error: "invalid path" });
719
- try {
720
- fs.mkdirSync(target, { recursive: true });
721
- res.json({ ok: true });
722
- } catch (e) {
723
- RED.log.error("portal-react assets mkdir: " + e.message);
724
- res.status(500).json({ error: "internal error" });
725
- }
726
- });
727
-
728
- RED.httpAdmin.post("/portal-react/assets/move", express.json(), (req, res) => {
729
- const from = safePath(req.body && req.body.from);
730
- const to = safePath(req.body && req.body.to);
731
- if (!from || !to) return res.status(400).json({ error: "invalid path" });
732
- const toName = path.basename(to);
733
- if (!toName || !toName.trim()) return res.status(400).json({ error: "name cannot be empty" });
734
- try {
735
- const toDir = path.dirname(to);
736
- fs.mkdirSync(toDir, { recursive: true });
737
- fs.renameSync(from, to);
738
- res.json({ ok: true });
739
- } catch (e) {
740
- RED.log.error("portal-react assets move: " + e.message);
741
- res.status(500).json({ error: "internal error" });
742
- }
743
- });
744
-
745
- const MAX_ASSETS_BYTES = 500 * 1024 * 1024; // 500 MB total
746
- const MAX_ASSETS_FILES = 1000;
747
- function getAssetsStats() {
748
- let size = 0, count = 0;
749
- function walk(dir) {
750
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
751
- if (e.isSymbolicLink()) continue;
752
- const p = path.join(dir, e.name);
753
- if (e.isDirectory()) walk(p);
754
- else if (e.isFile()) { size += fs.statSync(p).size; count++; }
755
- }
756
- }
757
- try { walk(assetsDir); } catch (_e) { /* ignore */ }
758
- return { size, count };
759
- }
760
-
761
- RED.httpAdmin.post(
762
- "/portal-react/assets/upload/*",
763
- express.raw({ type: "*/*", limit: "100mb" }),
764
- (req, res) => {
765
- const rel = req.params[0];
766
- const target = safePath(rel);
767
- if (!target) return res.status(400).json({ error: "invalid path" });
768
- const stats = getAssetsStats();
769
- if (stats.size + req.body.length > MAX_ASSETS_BYTES)
770
- return res.status(413).json({ error: "storage limit exceeded (500MB)" });
771
- if (stats.count >= MAX_ASSETS_FILES)
772
- return res.status(413).json({ error: "file count limit exceeded (1000)" });
773
- try {
774
- fs.mkdirSync(path.dirname(target), { recursive: true });
775
- fs.writeFileSync(target, req.body);
776
- res.json({ ok: true });
777
- } catch (e) {
778
- RED.log.error("portal-react assets upload: " + e.message);
779
- res.status(500).json({ error: "internal error" });
780
- }
781
- },
782
- );
783
-
784
- RED.httpAdmin.delete("/portal-react/assets/*", (req, res) => {
785
- const rel = req.params[0];
786
- const target = safePath(rel);
787
- if (!target) return res.status(400).json({ error: "invalid path" });
788
- try {
789
- fs.rmSync(target, { recursive: true, force: true });
790
- res.json({ ok: true });
791
- } catch (e) {
792
- RED.log.error("portal-react assets delete: " + e.message);
793
- res.status(404).json({ error: "not found" });
794
- }
795
- });
796
-
797
- RED.httpAdmin.get("/portal-react/assets/download/*", (req, res) => {
798
- const rel = req.params[0];
799
- const target = safePath(rel);
800
- if (!target) return res.status(400).json({ error: "invalid path" });
801
- try {
802
- const stat = fs.statSync(target);
803
- if (stat.isDirectory()) return res.status(400).json({ error: "is a directory" });
804
- const filename = path.basename(target);
805
- res.set({
806
- "Content-Disposition": 'attachment; filename="' + filename.replace(/"/g, '\\"') + '"',
807
- "Content-Length": stat.size,
808
- });
809
- fs.createReadStream(target).pipe(res);
810
- } catch (e) {
811
- res.status(404).json({ error: "not found" });
812
- }
813
- });
789
+ const { registerAssets } = require("./lib/assets");
790
+ registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
814
791
 
815
792
  // ── Admin API for component registry ──────────────────────────
816
793
 
@@ -819,10 +796,10 @@ module.exports = function (RED) {
819
796
  });
820
797
 
821
798
  RED.httpAdmin.post("/portal-react/registry", (req, res) => {
822
- const { name, code, inputs, outputs } = req.body || {};
799
+ const { name, code } = req.body || {};
823
800
  if (!isSafeName(name))
824
801
  return res.status(400).json({ error: "invalid name" });
825
- registry[name] = { code, inputs: inputs || [], outputs: outputs || [] };
802
+ registry[name] = { code: code || "" };
826
803
  res.json({ ok: true });
827
804
  });
828
805
 
@@ -833,133 +810,4 @@ module.exports = function (RED) {
833
810
  delete registry[name];
834
811
  res.json({ ok: true });
835
812
  });
836
-
837
- // ── Page builders ─────────────────────────────────────────────
838
-
839
- function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
840
- return `<!DOCTYPE html>
841
- <html lang="en">
842
- <head>
843
- <meta charset="UTF-8">
844
- <meta name="viewport" content="width=device-width,initial-scale=1.0">
845
- <title>${esc(title)}</title>
846
- ${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
847
- ${escScript(customHead)}
848
- ${showWsStatus ? `<style>
849
- #__cs {
850
- position: fixed; bottom: 6px; right: 6px;
851
- padding: 3px 8px; font-size: 10px; border-radius: 3px;
852
- z-index: 99999; background: #111; border: 1px solid #333;
853
- opacity: .7; transition: opacity .2s;
854
- }
855
- #__cs:hover { opacity: 1 }
856
- #__cs.ok { color: #4ade80 }
857
- #__cs.err { color: #f87171 }
858
- </style>` : ""}
859
- </head>
860
- <body>
861
- <div id="root"></div>
862
- ${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
863
- <script>
864
- window.__NR = {
865
- _ws: null,
866
- _listeners: new Set(),
867
- _lastData: null,
868
- _retries: 0,
869
- _wasConnected: false,
870
- _version: null,
871
- _portalClient: null,
872
- _user: ${user ? escScript(JSON.stringify(user)) : "null"},
873
-
874
- connect() {
875
- const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
876
- const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
877
- this._ws = ws;
878
- const s = document.getElementById('__cs');
879
-
880
- ws.onopen = () => {
881
- if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
882
- this._retries = 0;
883
- this._wasConnected = true;
884
- };
885
-
886
- ws.onmessage = (e) => {
887
- try {
888
- const m = JSON.parse(e.data);
889
- if (m.type === 'hello') {
890
- this._portalClient = m.portalClient;
891
- }
892
- if (m.type === 'version') {
893
- if (this._version && this._version !== m.hash) { location.reload(); return; }
894
- this._version = m.hash;
895
- }
896
- if (m.type === 'data') {
897
- this._lastData = m.payload;
898
- this._listeners.forEach(fn => fn(m.payload));
899
- }
900
- } catch (err) { console.error('WS parse', err); }
901
- };
902
-
903
- ws.onclose = () => {
904
- if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
905
- this._ws = null;
906
- const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
907
- this._retries++;
908
- setTimeout(() => this.connect(), delay);
909
- };
910
-
911
- ws.onerror = () => ws.close();
912
- },
913
-
914
- subscribe(fn) {
915
- this._listeners.add(fn);
916
- if (this._lastData !== null) fn(this._lastData);
917
- return () => this._listeners.delete(fn);
918
- },
919
-
920
- send(payload, topic) {
921
- if (this._ws && this._ws.readyState === 1)
922
- this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
923
- }
924
- };
925
- window.__NR.connect();
926
- <\/script>
927
- <script>
928
- ${escScript(transpiledJs)}
929
- <\/script>
930
- </body>
931
- </html>`;
932
- }
933
-
934
- function buildErrorPage(title, error) {
935
- return `<!DOCTYPE html>
936
- <html lang="en">
937
- <head>
938
- <meta charset="UTF-8">
939
- <title>${esc(title)} — Error</title>
940
- <style>
941
- body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
942
- h1 { color: #ff4444; margin-bottom: 16px }
943
- pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
944
- </style>
945
- </head>
946
- <body>
947
- <h1>JSX Transpile Error</h1>
948
- <p>Fix the component code in Node-RED and deploy again.</p>
949
- <pre>${esc(error)}</pre>
950
- </body>
951
- </html>`;
952
- }
953
-
954
- function esc(s) {
955
- return String(s)
956
- .replace(/&/g, "&amp;")
957
- .replace(/</g, "&lt;")
958
- .replace(/>/g, "&gt;")
959
- .replace(/"/g, "&quot;");
960
- }
961
-
962
- function escScript(s) {
963
- return String(s).replace(/<\/(script)/gi, "<\\/$1");
964
- }
965
813
  };