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

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.
@@ -23,12 +23,6 @@ module.exports = function (RED) {
23
23
  }
24
24
  const registry = RED.settings.fcPortalRegistry;
25
25
 
26
- // CSS cache: hash → css string
27
- if (!RED.settings.fcCssCache) {
28
- RED.settings.fcCssCache = {};
29
- }
30
- const cssCache = RED.settings.fcCssCache;
31
-
32
26
  // Active upgrade handlers per node id (for cleanup on redeploy)
33
27
  if (!RED.settings.fcUpgradeHandlers) {
34
28
  RED.settings.fcUpgradeHandlers = {};
@@ -53,11 +47,15 @@ module.exports = function (RED) {
53
47
  }
54
48
  const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
55
49
 
56
- // Vendor bundle cache: cacheKey { js, hash }
57
- if (!RED.settings.fcVendorCache) {
58
- RED.settings.fcVendorCache = {};
50
+ // Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
51
+ let _rebuildTimer = null;
52
+ function scheduleRebuildAll() {
53
+ if (_rebuildTimer) clearTimeout(_rebuildTimer);
54
+ _rebuildTimer = setTimeout(() => {
55
+ _rebuildTimer = null;
56
+ Object.values(rebuildCallbacks).forEach((fn) => fn());
57
+ }, 50);
59
58
  }
60
- const vendorCache = RED.settings.fcVendorCache;
61
59
 
62
60
  // ── Helpers ───────────────────────────────────────────────────
63
61
 
@@ -92,136 +90,56 @@ module.exports = function (RED) {
92
90
  const pkgRoot = path.join(__dirname, "..");
93
91
  // userDir — where dynamicModuleList installs user packages
94
92
  const userDir = RED.settings.userDir || path.join(__dirname, "../../..");
95
- // esbuild resolveDir: package root (react is here); nodePaths adds userDir for user libs
96
- const resolveDir = pkgRoot;
97
93
 
98
- function getPackageName(moduleSpec) {
99
- const m = moduleSpec.match(/^((?:@[^/]+\/)?[^/]+)/);
100
- return m ? m[1] : moduleSpec;
101
- }
102
-
103
- function getInstalledVersion(pkgName) {
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) => {
104
97
  try {
105
- const pkgJson = require(
106
- require.resolve(pkgName + "/package.json", { paths: [pkgRoot, userDir] }),
107
- );
108
- return pkgJson.version;
109
- } catch {
110
- return null;
111
- }
112
- }
113
-
114
- function buildVendorBundle(libs) {
115
- const lines = [
116
- 'import React from "react";',
117
- 'import ReactDOM from "react-dom";',
118
- 'import { createRoot } from "react-dom/client";',
119
- "window.React = React;",
120
- "window.ReactDOM = ReactDOM;",
121
- "window.ReactDOM.createRoot = createRoot;",
122
- ];
123
- if (libs.length > 0) {
124
- lines.push("if(!window.__pkg) window.__pkg = {};");
125
- libs.forEach((lib, i) => {
126
- lines.push(`import * as __p${i} from ${JSON.stringify(lib.module)};`);
127
- lines.push(`window.__pkg[${JSON.stringify(lib.module)}] = __p${i}.default || __p${i};`);
128
- });
129
- }
130
- const contents = lines.join("\n");
131
- const buildResult = esbuild.buildSync({
132
- stdin: { contents, resolveDir, loader: "js" },
133
- bundle: true,
134
- format: "iife",
135
- minify: true,
136
- write: false,
137
- target: ["es2020"],
138
- define: { "process.env.NODE_ENV": '"production"' },
139
- logOverride: { "import-is-undefined": "silent" },
140
- nodePaths: [path.join(userDir, "node_modules")],
141
- });
142
- const js = buildResult.outputFiles[0].text;
143
- const h = hash(js);
144
- return { js, hash: h };
145
- }
146
-
147
- function getVendorBundle(libs) {
148
- // Build cache key from actual installed versions
149
- const keyParts = ["react@" + getInstalledVersion("react")];
150
- for (const lib of libs) {
151
- keyParts.push(lib.module + "@" + getInstalledVersion(getPackageName(lib.module)));
152
- }
153
- keyParts.sort();
154
- const cacheKey = hash(JSON.stringify(keyParts));
155
-
156
- if (vendorCache[cacheKey]) return vendorCache[cacheKey];
157
-
158
- const bundle = buildVendorBundle(libs);
159
- vendorCache[cacheKey] = bundle;
160
- return bundle;
161
- }
162
-
163
- function transpile(jsx, libs) {
164
- const externalList = ["react", "react-dom", "react-dom/client"];
165
- if (libs) {
166
- libs.forEach((lib) => {
167
- if (!externalList.includes(lib.module)) {
168
- externalList.push(lib.module);
169
- }
170
- });
171
- }
172
-
173
- // Auto-detect require() calls in JSX and add them as externals
174
- const requireRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
175
- let m;
176
- while ((m = requireRe.exec(jsx)) !== null) {
177
- if (!externalList.includes(m[1])) {
178
- externalList.push(m[1]);
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;
179
102
  }
180
- }
181
-
182
- const requireShim = [
183
- "var require = (function() {",
184
- ' var _r = {"react":window.React, "react-dom":window.ReactDOM, "react-dom/client":window.ReactDOM};',
185
- " return function(m) {",
186
- " if (_r[m]) return _r[m];",
187
- " if (window.__pkg && window.__pkg[m]) return window.__pkg[m];",
188
- ' throw new Error("Module not found: " + m);',
189
- " };",
190
- "})();",
191
- ].join("\n");
103
+ } catch (_) {}
104
+ });
192
105
 
106
+ function transpile(jsx) {
193
107
  try {
194
108
  const buildResult = esbuild.buildSync({
195
109
  stdin: {
196
110
  contents: jsx,
197
- resolveDir,
111
+ resolveDir: pkgRoot,
198
112
  loader: "jsx",
199
113
  },
200
114
  bundle: true,
201
115
  format: "iife",
116
+ minify: true,
202
117
  write: false,
203
118
  target: ["es2020"],
204
119
  jsx: "transform",
205
120
  jsxFactory: "React.createElement",
206
121
  jsxFragment: "React.Fragment",
207
- external: externalList,
208
122
  define: { "process.env.NODE_ENV": '"production"' },
209
- banner: { js: requireShim },
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
+ },
210
130
  });
211
- return { js: buildResult.outputFiles[0].text, error: null };
131
+ return { js: buildResult.outputFiles[0].text, metafile: buildResult.metafile, error: null };
212
132
  } catch (e) {
213
133
  return { js: null, error: e.message };
214
134
  }
215
135
  }
216
136
 
217
137
  async function generateCSS(source) {
218
- const key = hash(source);
219
- if (cssCache[key]) return cssCache[key];
138
+ const cssHash = hash(source);
220
139
  const compiled = await getTwCompiled();
221
140
  const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
222
141
  const css = compiled.build(candidates);
223
- cssCache[key] = css;
224
- return css;
142
+ return { css, cssHash };
225
143
  }
226
144
 
227
145
  const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
@@ -291,10 +209,8 @@ module.exports = function (RED) {
291
209
 
292
210
  node.status({ fill: "green", shape: "dot", text: compName });
293
211
 
294
- // Trigger re-transpile on all portal-react nodes (after all nodes init)
295
- setImmediate(() => {
296
- Object.values(rebuildCallbacks).forEach((fn) => fn());
297
- });
212
+ // Trigger re-transpile on all portal-react nodes (debounced across all component registrations)
213
+ scheduleRebuildAll();
298
214
 
299
215
  node.on("close", function (removed, done) {
300
216
  delete registry[compName];
@@ -320,25 +236,23 @@ module.exports = function (RED) {
320
236
  const libs = config.libs || [];
321
237
 
322
238
  // State
323
- const clients = new Set();
239
+ const clients = new Map(); // portalId → ws
324
240
  let lastPayload = null;
325
241
  let wsServer = null;
326
242
  let isClosing = false;
327
243
 
244
+ if (libs.length > 0) {
245
+ node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
246
+ } else {
247
+ node.status({ fill: "yellow", shape: "ring", text: "starting..." });
248
+ }
249
+
328
250
  const wsPath = nodeRoot + endpoint + "/_ws";
329
251
 
330
252
  // ── Rebuild: transpile JSX + update page state ────────────
331
253
 
332
254
  function rebuild() {
333
- // Build or get cached vendor bundle
334
- let vendorBundle;
335
- try {
336
- vendorBundle = getVendorBundle(libs);
337
- } catch (e) {
338
- node.error("Vendor bundle failed: " + e.message);
339
- node.status({ fill: "red", shape: "dot", text: "vendor build error" });
340
- return;
341
- }
255
+ node.status({ fill: "yellow", shape: "dot", text: "building..." });
342
256
 
343
257
  // Selective injection: only include components referenced in user code (+ transitive deps)
344
258
  const allEntries = Object.entries(registry);
@@ -378,7 +292,41 @@ module.exports = function (RED) {
378
292
  )
379
293
  .join("\n\n");
380
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}'`,
318
+ );
319
+ }
320
+ }
321
+
381
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
+ "",
382
330
  "// ── React shorthand ──",
383
331
  "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
384
332
  "const { createContext, memo, forwardRef, Fragment } = React;",
@@ -394,62 +342,85 @@ module.exports = function (RED) {
394
342
  " window.__NR.send(payload, topic);",
395
343
  " }, []);",
396
344
  " const user = window.__NR._user || null;",
397
- " return { data, send, user };",
345
+ " const portalClient = window.__NR._portalClient;",
346
+ " return { data, send, user, portalClient };",
398
347
  "}",
399
348
  ].join("\n"),
400
349
  "",
401
350
  "// ── Library components ──",
402
- libraryJsx,
351
+ cleanLibJsx,
403
352
  "",
404
353
  "// ── View component ──",
405
- componentCode,
354
+ cleanCompCode,
406
355
  "",
407
356
  "// ── Mount ──",
408
- "ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
357
+ "createRoot(document.getElementById('root')).render(React.createElement(App));",
409
358
  ].join("\n");
410
359
 
411
- const compiled = transpile(fullJsx, libs);
360
+ const compiled = transpile(fullJsx);
412
361
 
413
362
  if (compiled.error) {
414
363
  node.error("JSX transpile error: " + compiled.error);
415
364
  node.status({ fill: "red", shape: "dot", text: "transpile error" });
416
365
  } else {
417
- node.status({ fill: "grey", shape: "ring", text: endpoint });
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(", ")}`);
377
+ }
418
378
  }
419
379
 
420
- const cssHashReady = !compiled.error
421
- ? generateCSS(fullJsx)
422
- .then((css) => {
423
- node.status({ fill: "grey", shape: "ring", text: endpoint });
424
- return css ? hash(fullJsx) : "";
425
- })
426
- .catch((err) => {
427
- node.warn("Tailwind CSS generation failed: " + err.message);
428
- return "";
429
- })
430
- : Promise.resolve("");
431
-
432
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: "" });
433
393
 
434
394
  pageState[endpoint] = {
435
395
  compiled,
436
396
  contentHash,
437
- cssHashReady,
397
+ cssReady,
398
+ jsxHash,
399
+ css: null,
400
+ cssHash: "",
438
401
  pageTitle,
439
402
  wsPath,
440
403
  customHead,
441
404
  portalAuth,
442
405
  showWsStatus,
443
- vendorHash: vendorBundle.hash,
444
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}` });
414
+ }
415
+ });
445
416
  }
446
417
 
447
418
  // Register rebuild callback so library components can trigger re-transpile
448
419
  rebuildCallbacks[nodeId] = rebuild;
449
420
 
450
- // Delay initial build so all fc-portal-component nodes register first
421
+ // Initial build: debounced so all fc-portal-component nodes register first
422
+ scheduleRebuildAll();
451
423
  setImmediate(() => {
452
- rebuild();
453
424
 
454
425
  // Register route only once per endpoint (persists across deploys)
455
426
  if (!registeredRoutes[endpoint]) {
@@ -467,7 +438,7 @@ module.exports = function (RED) {
467
438
  .send(buildErrorPage(state.pageTitle, state.compiled.error));
468
439
  return;
469
440
  }
470
- const cssHash = await state.cssHashReady;
441
+ const { cssHash } = await state.cssReady;
471
442
  const user = state.portalAuth
472
443
  ? extractPortalUser(_req.headers)
473
444
  : null;
@@ -482,7 +453,6 @@ module.exports = function (RED) {
482
453
  cssHash,
483
454
  user,
484
455
  state.showWsStatus,
485
- state.vendorHash,
486
456
  ),
487
457
  );
488
458
  });
@@ -525,10 +495,12 @@ module.exports = function (RED) {
525
495
  ws.close();
526
496
  return;
527
497
  }
498
+ const portalClient = crypto.randomUUID();
499
+ ws._portalClient = portalClient;
528
500
  if (portalAuth) {
529
501
  ws._portalUser = extractPortalUser(request.headers);
530
502
  }
531
- clients.add(ws);
503
+ clients.set(portalClient, ws);
532
504
  updateStatus();
533
505
 
534
506
  // Push current state to new client
@@ -540,6 +512,9 @@ module.exports = function (RED) {
540
512
  const contentHash = pageState[endpoint]?.contentHash || "";
541
513
  wsSend(ws, { type: "version", hash: contentHash });
542
514
 
515
+ // Send assigned portalClient to browser
516
+ wsSend(ws, { type: "hello", portalClient });
517
+
543
518
  ws.on("message", (raw) => {
544
519
  try {
545
520
  const msg = JSON.parse(raw.toString());
@@ -548,9 +523,11 @@ module.exports = function (RED) {
548
523
  payload: msg.payload,
549
524
  topic: msg.topic || "",
550
525
  };
526
+ const client = { portalClient: ws._portalClient };
551
527
  if (portalAuth && ws._portalUser) {
552
- out._client = ws._portalUser;
528
+ Object.assign(client, ws._portalUser);
553
529
  }
530
+ out._client = client;
554
531
  node.send(out);
555
532
  }
556
533
  } catch (e) {
@@ -559,12 +536,12 @@ module.exports = function (RED) {
559
536
  });
560
537
 
561
538
  ws.on("close", () => {
562
- clients.delete(ws);
539
+ clients.delete(portalClient);
563
540
  updateStatus();
564
541
  });
565
542
 
566
543
  ws.on("error", () => {
567
- clients.delete(ws);
544
+ clients.delete(portalClient);
568
545
  updateStatus();
569
546
  });
570
547
  });
@@ -575,11 +552,33 @@ module.exports = function (RED) {
575
552
  // ── Input handler ─────────────────────────────────────────
576
553
 
577
554
  node.on("input", (msg, send, done) => {
578
- lastPayload = msg.payload;
555
+ const target = msg._client;
579
556
  const frame = JSON.stringify({ type: "data", payload: msg.payload });
580
- clients.forEach((ws) => {
581
- if (ws.readyState === 1) ws.send(frame);
582
- });
557
+
558
+ if (target && target.portalClient) {
559
+ // Target specific client by portalClient
560
+ const ws = clients.get(target.portalClient);
561
+ if (ws && ws.readyState === 1) ws.send(frame);
562
+ } else if (target && (target.userId || target.username)) {
563
+ // Target all sessions of a specific user
564
+ const matchId = target.userId;
565
+ const matchName = target.username;
566
+ clients.forEach((ws) => {
567
+ if (ws.readyState !== 1) return;
568
+ const u = ws._portalUser;
569
+ if (!u) return;
570
+ if ((matchId && u.userId === matchId) || (matchName && u.username === matchName)) {
571
+ ws.send(frame);
572
+ }
573
+ });
574
+ } else {
575
+ // Broadcast to all (default)
576
+ lastPayload = msg.payload;
577
+ clients.forEach((ws) => {
578
+ if (ws.readyState === 1) ws.send(frame);
579
+ });
580
+ }
581
+
583
582
  updateStatus();
584
583
  if (done) done();
585
584
  });
@@ -668,9 +667,16 @@ module.exports = function (RED) {
668
667
  res.json(twClassesCache);
669
668
  });
670
669
 
671
- // ── Vendor CSS endpoint (per content hash) ─────────────────
670
+ // ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
672
671
  RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
673
- const css = cssCache[req.params.hash];
672
+ const reqHash = req.params.hash;
673
+ let css = null;
674
+ for (const ep in pageState) {
675
+ if (pageState[ep]?.cssHash === reqHash) {
676
+ css = pageState[ep].css;
677
+ break;
678
+ }
679
+ }
674
680
  if (!css) {
675
681
  res.status(404).send("Not found");
676
682
  return;
@@ -682,21 +688,178 @@ module.exports = function (RED) {
682
688
  res.send(css);
683
689
  });
684
690
 
685
- // ── Vendor bundle endpoint (dynamic, hash-based) ───────────
686
- RED.httpAdmin.get("/portal-react/vendor/:hash.js", (req, res) => {
687
- const entry = Object.values(vendorCache).find(
688
- (v) => v.hash === req.params.hash,
691
+ // ── 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)
689
721
  );
690
- if (!entry) {
691
- res.status(404).send("Not found");
692
- return;
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" });
693
862
  }
694
- res.set({
695
- "Content-Type": "application/javascript",
696
- "Cache-Control": "public, max-age=31536000, immutable",
697
- ETag: `"${req.params.hash}"`,
698
- });
699
- res.send(entry.js);
700
863
  });
701
864
 
702
865
  // ── Admin API for component registry ──────────────────────────
@@ -723,14 +886,13 @@ module.exports = function (RED) {
723
886
 
724
887
  // ── Page builders ─────────────────────────────────────────────
725
888
 
726
- function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, vendorHash) {
889
+ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
727
890
  return `<!DOCTYPE html>
728
891
  <html lang="en">
729
892
  <head>
730
893
  <meta charset="UTF-8">
731
894
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
732
895
  <title>${esc(title)}</title>
733
- <script src="${adminRoot}/portal-react/vendor/${vendorHash}.js"><\/script>
734
896
  ${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
735
897
  ${escScript(customHead)}
736
898
  ${showWsStatus ? `<style>
@@ -756,6 +918,7 @@ module.exports = function (RED) {
756
918
  _retries: 0,
757
919
  _wasConnected: false,
758
920
  _version: null,
921
+ _portalClient: null,
759
922
  _user: ${user ? escScript(JSON.stringify(user)) : "null"},
760
923
 
761
924
  connect() {
@@ -773,6 +936,9 @@ module.exports = function (RED) {
773
936
  ws.onmessage = (e) => {
774
937
  try {
775
938
  const m = JSON.parse(e.data);
939
+ if (m.type === 'hello') {
940
+ this._portalClient = m.portalClient;
941
+ }
776
942
  if (m.type === 'version') {
777
943
  if (this._version && this._version !== m.hash) { location.reload(); return; }
778
944
  this._version = m.hash;