@aaqu/fromcubes-portal-react 0.1.0-alpha.14 → 0.1.0-alpha.16

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