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

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,12 +47,6 @@ 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 = {};
59
- }
60
- const vendorCache = RED.settings.fcVendorCache;
61
-
62
50
  // ── Helpers ───────────────────────────────────────────────────
63
51
 
64
52
  function hash(str) {
@@ -92,121 +80,31 @@ module.exports = function (RED) {
92
80
  const pkgRoot = path.join(__dirname, "..");
93
81
  // userDir — where dynamicModuleList installs user packages
94
82
  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
83
 
98
- function getPackageName(moduleSpec) {
99
- const m = moduleSpec.match(/^((?:@[^/]+\/)?[^/]+)/);
100
- return m ? m[1] : moduleSpec;
101
- }
102
-
103
- function getInstalledVersion(pkgName) {
104
- 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]);
179
- }
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");
192
84
 
85
+ function transpile(jsx) {
193
86
  try {
194
87
  const buildResult = esbuild.buildSync({
195
88
  stdin: {
196
89
  contents: jsx,
197
- resolveDir,
90
+ resolveDir: pkgRoot,
198
91
  loader: "jsx",
199
92
  },
200
93
  bundle: true,
201
94
  format: "iife",
95
+ minify: true,
202
96
  write: false,
203
97
  target: ["es2020"],
204
98
  jsx: "transform",
205
99
  jsxFactory: "React.createElement",
206
100
  jsxFragment: "React.Fragment",
207
- external: externalList,
208
101
  define: { "process.env.NODE_ENV": '"production"' },
209
- banner: { js: requireShim },
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
+ },
210
108
  });
211
109
  return { js: buildResult.outputFiles[0].text, error: null };
212
110
  } catch (e) {
@@ -215,13 +113,11 @@ module.exports = function (RED) {
215
113
  }
216
114
 
217
115
  async function generateCSS(source) {
218
- const key = hash(source);
219
- if (cssCache[key]) return cssCache[key];
116
+ const cssHash = hash(source);
220
117
  const compiled = await getTwCompiled();
221
118
  const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
222
119
  const css = compiled.build(candidates);
223
- cssCache[key] = css;
224
- return css;
120
+ return { css, cssHash };
225
121
  }
226
122
 
227
123
  const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
@@ -320,25 +216,24 @@ module.exports = function (RED) {
320
216
  const libs = config.libs || [];
321
217
 
322
218
  // State
323
- const clients = new Set();
219
+ const clients = new Map(); // portalId → ws
324
220
  let lastPayload = null;
325
221
  let wsServer = null;
326
222
  let isClosing = false;
327
223
 
224
+ if (libs.length > 0) {
225
+ const names = libs.map((l) => l.module).join(", ");
226
+ node.status({ fill: "blue", shape: "ring", text: `installing ${names}...` });
227
+ } else {
228
+ node.status({ fill: "yellow", shape: "ring", text: "starting..." });
229
+ }
230
+
328
231
  const wsPath = nodeRoot + endpoint + "/_ws";
329
232
 
330
233
  // ── Rebuild: transpile JSX + update page state ────────────
331
234
 
332
235
  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
- }
236
+ node.status({ fill: "yellow", shape: "dot", text: "building..." });
342
237
 
343
238
  // Selective injection: only include components referenced in user code (+ transitive deps)
344
239
  const allEntries = Object.entries(registry);
@@ -378,7 +273,21 @@ module.exports = function (RED) {
378
273
  )
379
274
  .join("\n\n");
380
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
+
381
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
+ "",
382
291
  "// ── React shorthand ──",
383
292
  "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
384
293
  "const { createContext, memo, forwardRef, Fragment } = React;",
@@ -394,54 +303,66 @@ module.exports = function (RED) {
394
303
  " window.__NR.send(payload, topic);",
395
304
  " }, []);",
396
305
  " const user = window.__NR._user || null;",
397
- " return { data, send, user };",
306
+ " const portalClient = window.__NR._portalClient;",
307
+ " return { data, send, user, portalClient };",
398
308
  "}",
399
309
  ].join("\n"),
400
310
  "",
401
311
  "// ── Library components ──",
402
- libraryJsx,
312
+ cleanLibJsx,
403
313
  "",
404
314
  "// ── View component ──",
405
- componentCode,
315
+ cleanCompCode,
406
316
  "",
407
317
  "// ── Mount ──",
408
- "ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
318
+ "createRoot(document.getElementById('root')).render(React.createElement(App));",
409
319
  ].join("\n");
410
320
 
411
- const compiled = transpile(fullJsx, libs);
321
+ const compiled = transpile(fullJsx);
412
322
 
413
323
  if (compiled.error) {
414
324
  node.error("JSX transpile error: " + compiled.error);
415
325
  node.status({ fill: "red", shape: "dot", text: "transpile error" });
416
326
  } else {
417
- node.status({ fill: "grey", shape: "ring", text: endpoint });
327
+ node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
418
328
  }
419
329
 
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
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: "" });
433
343
 
434
344
  pageState[endpoint] = {
435
345
  compiled,
436
346
  contentHash,
437
- cssHashReady,
347
+ cssReady,
348
+ jsxHash,
349
+ css: null,
350
+ cssHash: "",
438
351
  pageTitle,
439
352
  wsPath,
440
353
  customHead,
441
354
  portalAuth,
442
355
  showWsStatus,
443
- vendorHash: vendorBundle.hash,
444
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}` });
364
+ }
365
+ });
445
366
  }
446
367
 
447
368
  // Register rebuild callback so library components can trigger re-transpile
@@ -467,7 +388,7 @@ module.exports = function (RED) {
467
388
  .send(buildErrorPage(state.pageTitle, state.compiled.error));
468
389
  return;
469
390
  }
470
- const cssHash = await state.cssHashReady;
391
+ const { cssHash } = await state.cssReady;
471
392
  const user = state.portalAuth
472
393
  ? extractPortalUser(_req.headers)
473
394
  : null;
@@ -482,7 +403,6 @@ module.exports = function (RED) {
482
403
  cssHash,
483
404
  user,
484
405
  state.showWsStatus,
485
- state.vendorHash,
486
406
  ),
487
407
  );
488
408
  });
@@ -525,10 +445,12 @@ module.exports = function (RED) {
525
445
  ws.close();
526
446
  return;
527
447
  }
448
+ const portalClient = crypto.randomUUID();
449
+ ws._portalClient = portalClient;
528
450
  if (portalAuth) {
529
451
  ws._portalUser = extractPortalUser(request.headers);
530
452
  }
531
- clients.add(ws);
453
+ clients.set(portalClient, ws);
532
454
  updateStatus();
533
455
 
534
456
  // Push current state to new client
@@ -540,6 +462,9 @@ module.exports = function (RED) {
540
462
  const contentHash = pageState[endpoint]?.contentHash || "";
541
463
  wsSend(ws, { type: "version", hash: contentHash });
542
464
 
465
+ // Send assigned portalClient to browser
466
+ wsSend(ws, { type: "hello", portalClient });
467
+
543
468
  ws.on("message", (raw) => {
544
469
  try {
545
470
  const msg = JSON.parse(raw.toString());
@@ -548,9 +473,11 @@ module.exports = function (RED) {
548
473
  payload: msg.payload,
549
474
  topic: msg.topic || "",
550
475
  };
476
+ const client = { portalClient: ws._portalClient };
551
477
  if (portalAuth && ws._portalUser) {
552
- out._client = ws._portalUser;
478
+ Object.assign(client, ws._portalUser);
553
479
  }
480
+ out._client = client;
554
481
  node.send(out);
555
482
  }
556
483
  } catch (e) {
@@ -559,12 +486,12 @@ module.exports = function (RED) {
559
486
  });
560
487
 
561
488
  ws.on("close", () => {
562
- clients.delete(ws);
489
+ clients.delete(portalClient);
563
490
  updateStatus();
564
491
  });
565
492
 
566
493
  ws.on("error", () => {
567
- clients.delete(ws);
494
+ clients.delete(portalClient);
568
495
  updateStatus();
569
496
  });
570
497
  });
@@ -575,11 +502,33 @@ module.exports = function (RED) {
575
502
  // ── Input handler ─────────────────────────────────────────
576
503
 
577
504
  node.on("input", (msg, send, done) => {
578
- lastPayload = msg.payload;
505
+ const target = msg._client;
579
506
  const frame = JSON.stringify({ type: "data", payload: msg.payload });
580
- clients.forEach((ws) => {
581
- if (ws.readyState === 1) ws.send(frame);
582
- });
507
+
508
+ if (target && target.portalClient) {
509
+ // Target specific client by portalClient
510
+ const ws = clients.get(target.portalClient);
511
+ if (ws && ws.readyState === 1) ws.send(frame);
512
+ } else if (target && (target.userId || target.username)) {
513
+ // Target all sessions of a specific user
514
+ const matchId = target.userId;
515
+ const matchName = target.username;
516
+ clients.forEach((ws) => {
517
+ if (ws.readyState !== 1) return;
518
+ const u = ws._portalUser;
519
+ if (!u) return;
520
+ if ((matchId && u.userId === matchId) || (matchName && u.username === matchName)) {
521
+ ws.send(frame);
522
+ }
523
+ });
524
+ } else {
525
+ // Broadcast to all (default)
526
+ lastPayload = msg.payload;
527
+ clients.forEach((ws) => {
528
+ if (ws.readyState === 1) ws.send(frame);
529
+ });
530
+ }
531
+
583
532
  updateStatus();
584
533
  if (done) done();
585
534
  });
@@ -668,9 +617,16 @@ module.exports = function (RED) {
668
617
  res.json(twClassesCache);
669
618
  });
670
619
 
671
- // ── Vendor CSS endpoint (per content hash) ─────────────────
620
+ // ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
672
621
  RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
673
- const css = cssCache[req.params.hash];
622
+ const reqHash = req.params.hash;
623
+ let css = null;
624
+ for (const ep in pageState) {
625
+ if (pageState[ep]?.cssHash === reqHash) {
626
+ css = pageState[ep].css;
627
+ break;
628
+ }
629
+ }
674
630
  if (!css) {
675
631
  res.status(404).send("Not found");
676
632
  return;
@@ -682,21 +638,178 @@ module.exports = function (RED) {
682
638
  res.send(css);
683
639
  });
684
640
 
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,
641
+ // ── 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)
689
671
  );
690
- if (!entry) {
691
- res.status(404).send("Not found");
692
- return;
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" });
693
812
  }
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
813
  });
701
814
 
702
815
  // ── Admin API for component registry ──────────────────────────
@@ -723,14 +836,13 @@ module.exports = function (RED) {
723
836
 
724
837
  // ── Page builders ─────────────────────────────────────────────
725
838
 
726
- function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, vendorHash) {
839
+ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
727
840
  return `<!DOCTYPE html>
728
841
  <html lang="en">
729
842
  <head>
730
843
  <meta charset="UTF-8">
731
844
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
732
845
  <title>${esc(title)}</title>
733
- <script src="${adminRoot}/portal-react/vendor/${vendorHash}.js"><\/script>
734
846
  ${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
735
847
  ${escScript(customHead)}
736
848
  ${showWsStatus ? `<style>
@@ -756,6 +868,7 @@ module.exports = function (RED) {
756
868
  _retries: 0,
757
869
  _wasConnected: false,
758
870
  _version: null,
871
+ _portalClient: null,
759
872
  _user: ${user ? escScript(JSON.stringify(user)) : "null"},
760
873
 
761
874
  connect() {
@@ -773,6 +886,9 @@ module.exports = function (RED) {
773
886
  ws.onmessage = (e) => {
774
887
  try {
775
888
  const m = JSON.parse(e.data);
889
+ if (m.type === 'hello') {
890
+ this._portalClient = m.portalClient;
891
+ }
776
892
  if (m.type === 'version') {
777
893
  if (this._version && this._version !== m.hash) { location.reload(); return; }
778
894
  this._version = m.hash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-portal-react",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "keywords": [
6
6
  "node-red",