@aaqu/fromcubes-portal-react 0.1.0-alpha.24 → 0.1.0-alpha.26

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.
@@ -95,7 +95,6 @@
95
95
  */
96
96
 
97
97
  const crypto = require("crypto");
98
- const path = require("path");
99
98
 
100
99
  module.exports = function (RED) {
101
100
  // ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
@@ -329,85 +328,13 @@ module.exports = function (RED) {
329
328
  }
330
329
  const portalSig = RED.settings.portalReactSig;
331
330
 
332
- /**
333
- * Shared WebSocket heartbeat tick.
334
- *
335
- * Replaces the previous per-client `setInterval(ping, 30 s)` pattern: with
336
- * N connected browsers the old code held N intervals; this module-level
337
- * tick walks every registered `WebSocket.Server` once every 30 s and pings
338
- * every alive client. Result: 1 interval regardless of fan-out.
339
- *
340
- * The tick auto-starts when the first portal node registers its
341
- * `WebSocket.Server` via `registerPingedServer()` and clears itself when
342
- * the last server unregisters. `unref()` ensures it never blocks process
343
- * exit.
344
- *
345
- * @description N→1 interval count; bounded liveness latency 30 s.
346
- * @private
347
- */
348
- const PING_INTERVAL_MS = 30_000;
349
- if (!RED.settings.portalReactPingedServers) {
350
- RED.settings.portalReactPingedServers = new Set();
351
- }
352
- const pingedServers = RED.settings.portalReactPingedServers;
353
-
354
- if (!RED.settings.portalReactPingTick) {
355
- RED.settings.portalReactPingTick = { iv: null };
356
- }
357
- const pingTick = RED.settings.portalReactPingTick;
358
-
359
- /**
360
- * Single tick of the WS keep-alive sweep. For each registered ws.Server, drop
361
- * sockets whose previous ping went unanswered (they are dead) and ping the
362
- * survivors. The pong handler resets `_isAlive` back to true. Runs on a
363
- * shared interval so multiple portals share one timer.
364
- *
365
- * @returns {void}
366
- * @private
367
- */
368
- function _pingSweep() {
369
- for (const srv of pingedServers) {
370
- try {
371
- srv.clients.forEach((ws) => {
372
- if (ws._isAlive === false) {
373
- try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
374
- return;
375
- }
376
- ws._isAlive = false;
377
- try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
378
- });
379
- } catch (e) {
380
- RED.log.trace("[portal-react] ping sweep: " + e.message);
381
- }
382
- }
383
- }
384
-
385
- /**
386
- * Add a `WebSocket.Server` to the shared ping-tick rotation. Idempotent.
387
- * Starts the global interval the first time the set is non-empty.
388
- * @param {WebSocket.Server} wsServer
389
- */
390
- function registerPingedServer(wsServer) {
391
- if (pingedServers.has(wsServer)) return;
392
- pingedServers.add(wsServer);
393
- if (!pingTick.iv) {
394
- pingTick.iv = setInterval(_pingSweep, PING_INTERVAL_MS);
395
- pingTick.iv.unref?.();
396
- }
397
- }
398
-
399
- /**
400
- * Remove a `WebSocket.Server` from the shared rotation. Clears the
401
- * interval automatically when the last server unregisters.
402
- * @param {WebSocket.Server} wsServer
403
- */
404
- function unregisterPingedServer(wsServer) {
405
- if (!pingedServers.delete(wsServer)) return;
406
- if (pingedServers.size === 0 && pingTick.iv) {
407
- clearInterval(pingTick.iv);
408
- pingTick.iv = null;
409
- }
410
- }
331
+ const {
332
+ createWsHeartbeat,
333
+ } = require("./lib/ws-heartbeat");
334
+ const {
335
+ registerPingedServer,
336
+ unregisterPingedServer,
337
+ } = createWsHeartbeat(RED);
411
338
 
412
339
  // Debounced selective rebuild: coalesces multiple component changes into one pass.
413
340
  // Yields event loop between builds so HTTP server stays responsive.
@@ -545,6 +472,7 @@ module.exports = function (RED) {
545
472
  generateCSS,
546
473
  extractPortalUser,
547
474
  serveableHash,
475
+ hasFreshBuild,
548
476
  removeRoute,
549
477
  isSafeName,
550
478
  validateSubPath,
@@ -558,6 +486,11 @@ module.exports = function (RED) {
558
486
  isHashInUse,
559
487
  } = helpers;
560
488
  const { buildPage, buildErrorPage } = require("./lib/page-builder");
489
+ const { createPortalPageHandler } = require("./lib/portal-page-route");
490
+ const {
491
+ extractUtilitySymbols,
492
+ registerRegistryNodes,
493
+ } = require("./lib/registry-nodes");
561
494
  const hooks = require("./lib/hooks")(RED);
562
495
  const router = require("./lib/router");
563
496
 
@@ -567,270 +500,16 @@ module.exports = function (RED) {
567
500
  // `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
568
501
  const lastBroadcastCache = new Map();
569
502
 
570
- // ── Canvas node: shared component ─────────────────────────────
571
-
572
- /**
573
- * Canvas node that registers a named React component into the shared
574
- * component registry. One node = one named identifier; multiple
575
- * components require multiple `fc-portal-component` nodes.
576
- *
577
- * Constructor side-effects:
578
- * - validates `compName` (JS identifier + length + prototype blacklist)
579
- * - checks duplicates against the cross-namespace owner table
580
- * - syntax-checks the JSX via `quickCheckSyntax`
581
- * - schedules a selective rebuild of every portal that references this name
582
- *
583
- * @param {ComponentNodeConfig} config
584
- * @returns {void}
585
- * @fires Node-RED#close via node.on("close", …) — removes registration on disable / delete.
586
- * @private
587
- */
588
- function PortalComponentNode(config) {
589
- RED.nodes.createNode(this, config);
590
- const node = this;
591
- const compName = (config.compName || "").trim();
592
-
593
- if (!isSafeName(compName)) {
594
- node.error("Invalid component name: " + compName);
595
- node.status({ fill: "red", shape: "dot", text: "invalid name" });
596
- return;
597
- }
598
-
599
- // Duplicate component name check
600
- const existingOwner = compNameOwners[compName];
601
- if (existingOwner && existingOwner !== node.id) {
602
- node.error(
603
- `Component name "${compName}" is already used by another node`,
604
- );
605
- node.status({
606
- fill: "red",
607
- shape: "ring",
608
- text: shortStatus("dup: " + compName),
609
- });
610
- node.on("close", function (_removed, done) {
611
- done();
612
- });
613
- return;
614
- }
615
-
616
- // Cross-namespace collision: compName matches an existing utility's
617
- // internal top-level symbol → bundle would have two declarations of the
618
- // same identifier (component IIFE + utility raw decl).
619
- const utilSymOwner = utilSymbolOwners[compName];
620
- if (utilSymOwner) {
621
- node.error(
622
- `Component name "${compName}" conflicts with a top-level symbol declared in utility "${utilSymOwner}"`,
623
- );
624
- node.status({
625
- fill: "red",
626
- shape: "ring",
627
- text: shortStatus("dup sym: " + compName),
628
- });
629
- node.on("close", function (_removed, done) {
630
- done();
631
- });
632
- return;
633
- }
634
- compNameOwners[compName] = node.id;
635
-
636
- const newCode = config.compCode || "";
637
- const prevCode = registry[compName]?.code;
638
- const syntaxErr = quickCheckSyntax(newCode);
639
- registry[compName] = { code: newCode, error: syntaxErr };
640
-
641
- if (syntaxErr) {
642
- node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
643
- const short = syntaxErr.split("\n")[0];
644
- node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
645
- } else {
646
- node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
647
- }
648
-
649
- // Only rebuild portals that reference this component, and only if the code actually changed.
650
- if (prevCode !== newCode) {
651
- scheduleRebuildUsing(compName);
652
- }
653
-
654
- node.on("close", function (removed, done) {
655
- if (compNameOwners[compName] === node.id) {
656
- delete compNameOwners[compName];
657
- }
658
- delete registry[compName];
659
- // Portals depending on this component must rebuild (topology changed or name resolution breaks).
660
- scheduleRebuildUsing(compName);
661
- done();
662
- });
663
- }
664
- RED.nodes.registerType("fc-portal-component", PortalComponentNode);
665
-
666
- // ── Canvas node: shared utility (helpers / hooks / constants) ─
667
-
668
- // Parse top-level symbols from utility code: function/const/let/class declarations.
669
- // Used for selective inclusion (does user JSX reference any of these symbols?)
670
- // and for the editor "Utilities" dialog (lists exported identifiers per node).
671
- // Code is rejected silently when oversize so the regex cannot be weaponized
672
- // (ReDoS) by feeding multi-MB code into a sticky regex with `\s+` runs.
673
- const MAX_UTIL_CODE_BYTES = 1_000_000;
674
- /**
675
- * Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
676
- * names. Used to populate the symbol-ownership table (collision checks)
677
- * and the editor's Utilities dialog. Oversize input is rejected silently
678
- * (ReDoS guard — the regex has multiple `\s+` runs that could regress
679
- * to near-linear on multi-MB payloads).
680
- *
681
- * @param {string} code
682
- * @returns {Set<string>} set of declared top-level identifiers
683
- * @example
684
- * extractUtilitySymbols("const PI = 3.14;\nfunction add(a,b) { return a+b }");
685
- * // → Set(2) { "PI", "add" }
686
- */
687
- function extractUtilitySymbols(code) {
688
- const names = new Set();
689
- if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
690
- const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
691
- let m;
692
- while ((m = re.exec(code))) names.add(m[1]);
693
- return names;
694
- }
695
-
696
- /**
697
- * Canvas node that contributes raw top-level JavaScript (helpers / custom
698
- * hooks / constants) to the bundle. Unlike `fc-portal-component`, the code
699
- * is injected *without* an IIFE wrapper — one node can declare many
700
- * top-level symbols. Each symbol is registered with the cross-namespace
701
- * owner table so identifiers don't collide with component names or other
702
- * utility nodes.
703
- *
704
- * @param {UtilityNodeConfig} config
705
- * @returns {void}
706
- * @fires Node-RED#close on disable / delete — frees registry + owned symbols.
707
- * @private
708
- */
709
- function PortalUtilityNode(config) {
710
- RED.nodes.createNode(this, config);
711
- const node = this;
712
- const utilName = (config.utilName || "").trim();
713
-
714
- if (!isSafeName(utilName)) {
715
- node.error("Invalid utility name: " + utilName);
716
- node.status({ fill: "red", shape: "dot", text: "invalid name" });
717
- return;
718
- }
719
-
720
- // Duplicate name check across components AND utilities (shared namespace)
721
- const existingOwner = compNameOwners[utilName];
722
- if (existingOwner && existingOwner !== node.id) {
723
- node.error(
724
- `Name "${utilName}" is already used by another component or utility`,
725
- );
726
- node.status({
727
- fill: "red",
728
- shape: "ring",
729
- text: shortStatus("dup: " + utilName),
730
- });
731
- node.on("close", function (_removed, done) {
732
- done();
733
- });
734
- return;
735
- }
736
- compNameOwners[utilName] = node.id;
737
-
738
- const newCode = config.utilCode || "";
739
- const prevCode = utilities[utilName]?.code;
740
- const prevSyms = extractUtilitySymbols(prevCode || "");
741
- const newSyms = extractUtilitySymbols(newCode);
742
-
743
- // Free this node's previously-registered symbols before checking new ones,
744
- // so a redeploy that simply renames an internal symbol doesn't see itself
745
- // as a collision.
746
- for (const s of prevSyms) {
747
- if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
748
- }
749
-
750
- // Cross-namespace symbol collision check:
751
- // - vs component names (a component declares `const Name = (() => ...)();`
752
- // at top level — a utility-internal `function Name` would clash)
753
- // - vs other utility nodes' internal symbols (raw top-level decls clash)
754
- const conflicts = [];
755
- for (const s of newSyms) {
756
- if (Object.prototype.hasOwnProperty.call(registry, s)) {
757
- conflicts.push(`${s} (component)`);
758
- continue;
759
- }
760
- const symOwner = utilSymbolOwners[s];
761
- if (symOwner && symOwner !== utilName) {
762
- conflicts.push(`${s} (utility ${symOwner})`);
763
- }
764
- }
765
-
766
- const syntaxErr = quickCheckSyntax(newCode);
767
- const dupErr =
768
- conflicts.length > 0
769
- ? "duplicate symbols: " + conflicts.join(", ")
770
- : null;
771
- // Syntax error takes precedence (most actionable). If both, both are
772
- // surfaced in the node.error message but status text shows syntax.
773
- const combinedErr = syntaxErr || dupErr;
774
-
775
- utilities[utilName] = { code: newCode, error: combinedErr };
776
-
777
- if (combinedErr) {
778
- const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
779
- node.error(`Utility "${utilName}": ${msgs}`);
780
- if (syntaxErr) {
781
- const short = syntaxErr.split("\n")[0];
782
- node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
783
- } else {
784
- const firstSym = conflicts[0].split(" ")[0];
785
- node.status({
786
- fill: "red",
787
- shape: "ring",
788
- text: shortStatus("dup sym: " + firstSym),
789
- });
790
- }
791
- // Don't register symbols on conflict — leave the namespace clean for
792
- // whichever node actually owns them. Dependent portals will surface
793
- // "broken: <utilName>" via the standard utility-error path.
794
- } else {
795
- // Register all new symbols as owned by this utility node
796
- for (const s of newSyms) utilSymbolOwners[s] = utilName;
797
- node.status({
798
- fill: "green",
799
- shape: "dot",
800
- text: shortStatus(utilName),
801
- });
802
- }
803
-
804
- // Trigger rebuild of portals using this utility (any of its inner symbols).
805
- // Push BOTH the node-level utilName AND each top-level symbol into the
806
- // dirty set so portals matching by either are caught by selective rebuild.
807
- if (prevCode !== newCode) {
808
- scheduleRebuildUsing(utilName);
809
- for (const s of newSyms) scheduleRebuildUsing(s);
810
- // Symbols removed in this edit must also force rebuild for portals that
811
- // referenced them — those portals will fail to find the symbol and need
812
- // to surface an error or recompile against the new utility code.
813
- for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
814
- }
815
-
816
- node.on("close", function (removed, done) {
817
- if (compNameOwners[utilName] === node.id) {
818
- delete compNameOwners[utilName];
819
- }
820
- // Remove all symbols this node currently owns (none if it errored out
821
- // on dup-check, all of newSyms otherwise — driven by the registration
822
- // table, not by re-parsing the code).
823
- for (const s of Object.keys(utilSymbolOwners)) {
824
- if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
825
- }
826
- const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
827
- delete utilities[utilName];
828
- scheduleRebuildUsing(utilName);
829
- for (const s of removedSyms) scheduleRebuildUsing(s);
830
- done();
831
- });
832
- }
833
- RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
503
+ registerRegistryNodes(RED, {
504
+ registry,
505
+ utilities,
506
+ compNameOwners,
507
+ utilSymbolOwners,
508
+ isSafeName,
509
+ quickCheckSyntax,
510
+ shortStatus,
511
+ scheduleRebuildUsing,
512
+ });
834
513
 
835
514
  // ── Main node: portal-react ───────────────────────────────────
836
515
 
@@ -951,6 +630,7 @@ module.exports = function (RED) {
951
630
  let base;
952
631
  if (st.errorKind === "missing-component") base = "missing: " + st.errorSource;
953
632
  else if (st.errorSource) base = "broken: " + st.errorSource;
633
+ else if (st.errorKind === "missing-app") base = "no App";
954
634
  else if (st.errorKind === "missing-return") base = "no return";
955
635
  else if (st.errorKind === "rebuild") base = "rebuild err";
956
636
  else base = "transpile err";
@@ -1285,9 +965,16 @@ module.exports = function (RED) {
1285
965
  }
1286
966
  }
1287
967
 
1288
- // ── Check: missing return in App ──
968
+ // ── Check: App definition + missing return ──
969
+ const hasAppDefinition =
970
+ /\b(?:export\s+default\s+)?function\s+App\s*\(/.test(cleanCompCode) ||
971
+ /\bclass\s+App\b/.test(cleanCompCode) ||
972
+ /\b(?:const|let|var)\s+App\s*=/.test(cleanCompCode);
973
+
1289
974
  let missingReturn = false;
1290
- const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
975
+ const appFnMatch = cleanCompCode.match(
976
+ /(?:export\s+default\s+)?function\s+App\s*\([^)]*\)\s*\{/,
977
+ );
1291
978
  if (appFnMatch) {
1292
979
  let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
1293
980
  let hasReturn = false;
@@ -1304,7 +991,7 @@ module.exports = function (RED) {
1304
991
  // ── Resolve compiled (success or unified error) ──
1305
992
  let compiled;
1306
993
  let cacheHit = false;
1307
- let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-return' | 'transpile'
994
+ let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-app' | 'missing-return' | 'transpile'
1308
995
  if (missingComps) {
1309
996
  const list = missingComps.join(", ");
1310
997
  const plural = missingComps.length > 1;
@@ -1331,6 +1018,13 @@ module.exports = function (RED) {
1331
1018
  error: `${label} "${errorSource}" has a syntax error:\n\n${srcErr}`,
1332
1019
  };
1333
1020
  errorKind = errorSourceKind;
1021
+ } else if (!hasAppDefinition) {
1022
+ compiled = {
1023
+ js: null,
1024
+ error:
1025
+ "App component is required.\n\nAdd a top-level App component, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
1026
+ };
1027
+ errorKind = "missing-app";
1334
1028
  } else if (missingReturn) {
1335
1029
  compiled = {
1336
1030
  js: null,
@@ -1358,6 +1052,8 @@ module.exports = function (RED) {
1358
1052
  ? `Component "${errorSource}" syntax error: `
1359
1053
  : errorKind === "utility"
1360
1054
  ? `Utility "${errorSource}" syntax error: `
1055
+ : errorKind === "missing-app"
1056
+ ? "App component is required: "
1361
1057
  : errorKind === "missing-return"
1362
1058
  ? "App component has no return statement: "
1363
1059
  : "JSX transpile error: ") + compiled.error,
@@ -1549,8 +1245,11 @@ module.exports = function (RED) {
1549
1245
  );
1550
1246
  const prevSig = portalSig[nodeId];
1551
1247
  const existing = pageState[endpoint];
1552
- const hasValidBuild =
1553
- !!existing && !existing.building && !existing.compiled?.error;
1248
+ // hasFreshBuild also requires `compiled` to be present — close() nulls it on
1249
+ // redeploy, and a guard that ignored that (checking only building/error)
1250
+ // would treat the destroyed build as valid and skip the rebuild, leaving the
1251
+ // GET route serving the holding page forever. See helpers.hasFreshBuild.
1252
+ const hasValidBuild = hasFreshBuild(existing);
1554
1253
  portalSig[nodeId] = sig;
1555
1254
 
1556
1255
  if (prevSig !== sig || !hasValidBuild) {
@@ -1562,97 +1261,19 @@ module.exports = function (RED) {
1562
1261
  setImmediate(() => {
1563
1262
  // Register route only once per endpoint (persists across deploys)
1564
1263
  if (!registeredRoutes[endpoint]) {
1565
- RED.httpNode.get(endpoint, async function (_req, res) {
1566
- try {
1567
- const state = pageState[endpoint];
1568
- // `!state.compiled` is the teardown window between node close
1569
- // (which nulls compiled) and the next rebuild. Treat it like
1570
- // "building" — serve the holding page (200), never fall through to
1571
- // `state.compiled.error` which would throw and 500 via the catch.
1572
- if (!state || state.building || !state.compiled) {
1573
- const bWsPath = state?.wsPath || wsPath;
1574
- res
1575
- .set("Cache-Control", "no-store")
1576
- .type("text/html")
1577
- .send(
1578
- `<!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(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
1579
- );
1580
- return;
1581
- }
1582
- res.set("Cache-Control", "no-store");
1583
- if (state.compiled.error) {
1584
- if (state.lastGood) {
1585
- // Degraded: serve previous good build, banner-only error UI.
1586
- const user = state.portalAuth
1587
- ? extractPortalUser(_req.headers)
1588
- : null;
1589
- res
1590
- .type("text/html")
1591
- .send(
1592
- buildPage(
1593
- state.lastGood.pageTitle,
1594
- state.lastGood.compiledJs,
1595
- state.wsPath,
1596
- state.lastGood.customHead,
1597
- state.lastGood.cssHash,
1598
- user,
1599
- state.showWsStatus,
1600
- adminRoot,
1601
- ),
1602
- );
1603
- return;
1604
- }
1605
- res
1606
- .status(500)
1607
- .type("text/html")
1608
- .send(
1609
- buildErrorPage(
1610
- state.pageTitle,
1611
- state.compiled.error,
1612
- state.wsPath,
1613
- ),
1614
- );
1615
- return;
1616
- }
1617
- const { cssHash } = await Promise.race([
1618
- state.cssReady,
1619
- new Promise((_, reject) =>
1620
- setTimeout(
1621
- () => reject(new Error("CSS generation timeout")),
1622
- 15000,
1623
- ),
1624
- ),
1625
- ]);
1626
- const user = state.portalAuth
1627
- ? extractPortalUser(_req.headers)
1628
- : null;
1629
- res
1630
- .type("text/html")
1631
- .send(
1632
- buildPage(
1633
- state.pageTitle,
1634
- state.compiled.js,
1635
- state.wsPath,
1636
- state.customHead,
1637
- cssHash,
1638
- user,
1639
- state.showWsStatus,
1640
- adminRoot,
1641
- ),
1642
- );
1643
- } catch (e) {
1644
- res
1645
- .status(500)
1646
- .type("text/html")
1647
- .send(
1648
- buildErrorPage(
1649
- pageTitle,
1650
- "Page build failed: " + e.message,
1651
- wsPath,
1652
- ),
1653
- );
1654
- }
1655
- });
1264
+ RED.httpNode.get(
1265
+ endpoint,
1266
+ createPortalPageHandler({
1267
+ endpoint,
1268
+ pageState,
1269
+ wsPath,
1270
+ pageTitle,
1271
+ adminRoot,
1272
+ buildPage,
1273
+ buildErrorPage,
1274
+ extractPortalUser,
1275
+ }),
1276
+ );
1656
1277
  registeredRoutes[endpoint] = true;
1657
1278
  }
1658
1279
 
@@ -2021,198 +1642,19 @@ module.exports = function (RED) {
2021
1642
  dynamicModuleList: "libs",
2022
1643
  });
2023
1644
 
2024
- // ── Serve Monaco editor files locally ────────────────────────
2025
1645
  const express = require("express");
2026
- const monacoPath = path.dirname(
2027
- require.resolve("monaco-editor/package.json"),
2028
- );
2029
- RED.httpAdmin.use(
2030
- "/portal-react/vs",
2031
- PERM_READ,
2032
- express.static(path.join(monacoPath, "min", "vs")),
2033
- );
2034
-
2035
- // ── Tailwind class list endpoint ────────────────────────────
2036
- const { generateCandidates } = require("./tw-candidates");
2037
- let twClassesCache = null;
2038
- RED.httpAdmin.get("/portal-react/tw-classes", PERM_READ, (_req, res) => {
2039
- if (!twClassesCache) {
2040
- twClassesCache = generateCandidates();
2041
- }
2042
- res.json(twClassesCache);
2043
- });
2044
-
2045
- // ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
2046
- // Public (httpNode) — served to browsers loading the portal page, not the
2047
- // editor. Hash is constrained to short hex so a hostile client cannot probe
2048
- // for arbitrary pageState keys.
2049
- const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
2050
- RED.httpNode.get("/fromcubes/css/:hash.css", (req, res) => {
2051
- const reqHash = req.params.hash;
2052
- if (!CSS_HASH_RE.test(reqHash)) {
2053
- res.status(400).send("Bad request");
2054
- return;
2055
- }
2056
- let css = null;
2057
- for (const ep in pageState) {
2058
- if (pageState[ep]?.cssHash === reqHash) {
2059
- css = pageState[ep].css;
2060
- break;
2061
- }
2062
- }
2063
- if (!css) {
2064
- res.status(404).send("Not found");
2065
- return;
2066
- }
2067
- res.set({
2068
- "Content-Type": "text/css",
2069
- "Cache-Control": "public, max-age=31536000, immutable",
2070
- });
2071
- res.send(css);
2072
- });
2073
- // Back-compat: legacy admin URL still works (page-builder may emit it for
2074
- // existing builds). Apply the same hash whitelist.
2075
- RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
2076
- const reqHash = req.params.hash;
2077
- if (!CSS_HASH_RE.test(reqHash)) {
2078
- res.status(400).send("Bad request");
2079
- return;
2080
- }
2081
- let css = null;
2082
- for (const ep in pageState) {
2083
- if (pageState[ep]?.cssHash === reqHash) {
2084
- css = pageState[ep].css;
2085
- break;
2086
- }
2087
- }
2088
- if (!css) {
2089
- res.status(404).send("Not found");
2090
- return;
2091
- }
2092
- res.set({
2093
- "Content-Type": "text/css",
2094
- "Cache-Control": "public, max-age=31536000, immutable",
2095
- });
2096
- res.send(css);
2097
- });
2098
-
2099
- // ── Public assets folder ─────────────────────────────────────
2100
- const { registerAssets } = require("./lib/assets");
2101
- registerAssets(RED, express, path.join(userDir, "fromcubes", "public"), {
1646
+ const { registerAdminApi } = require("./lib/admin-api");
1647
+ registerAdminApi(RED, {
1648
+ express,
1649
+ permRead: PERM_READ,
1650
+ permWrite: PERM_WRITE,
2102
1651
  csrfGuard,
2103
1652
  rateLimit,
2104
- jsonLimit: JSON_BODY_LIMIT,
2105
- });
2106
-
2107
- // ── Admin API for component registry ──────────────────────────
2108
-
2109
- RED.httpAdmin.get("/portal-react/registry", PERM_READ, (_req, res) => {
2110
- res.json(registry);
2111
- });
2112
-
2113
- RED.httpAdmin.post("/portal-react/registry", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
2114
- const { name, code } = req.body || {};
2115
- if (!isSafeName(name))
2116
- return res.status(400).json({ error: "invalid name" });
2117
- const newCode = code || "";
2118
- const prevCode = registry[name]?.code;
2119
- registry[name] = { code: newCode };
2120
- if (prevCode !== newCode) {
2121
- scheduleRebuildUsing(name);
2122
- }
2123
- res.json({ ok: true });
2124
- });
2125
-
2126
- RED.httpAdmin.delete("/portal-react/registry/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
2127
- const name = req.params.name;
2128
- if (!isSafeName(name))
2129
- return res.status(400).json({ error: "invalid name" });
2130
- const existed = Object.prototype.hasOwnProperty.call(registry, name);
2131
- delete registry[name];
2132
- if (existed) {
2133
- scheduleRebuildUsing(name);
2134
- }
2135
- res.json({ ok: true });
2136
- });
2137
-
2138
- // ── Admin API for utility registry ────────────────────────────
2139
-
2140
- RED.httpAdmin.get("/portal-react/utilities", PERM_READ, (_req, res) => {
2141
- // Include parsed top-level symbols so the editor "Utilities" dialog can
2142
- // list which identifiers each node exports.
2143
- const out = {};
2144
- for (const [name, u] of Object.entries(utilities)) {
2145
- out[name] = {
2146
- code: u.code,
2147
- error: u.error || null,
2148
- symbols: [...extractUtilitySymbols(u.code || "")],
2149
- };
2150
- }
2151
- res.json(out);
2152
- });
2153
-
2154
- RED.httpAdmin.post("/portal-react/utilities", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
2155
- const { name, code } = req.body || {};
2156
- if (!isSafeName(name))
2157
- return res.status(400).json({ error: "invalid name" });
2158
- const newCode = code || "";
2159
- const prevCode = utilities[name]?.code;
2160
- const prevSyms = extractUtilitySymbols(prevCode || "");
2161
- const newSyms = extractUtilitySymbols(newCode);
2162
-
2163
- // Free previously-owned symbols before conflict check (mirror node ctor)
2164
- for (const s of prevSyms) {
2165
- if (utilSymbolOwners[s] === name) delete utilSymbolOwners[s];
2166
- }
2167
-
2168
- const conflicts = [];
2169
- for (const s of newSyms) {
2170
- if (Object.prototype.hasOwnProperty.call(registry, s)) {
2171
- conflicts.push(`${s} (component)`);
2172
- continue;
2173
- }
2174
- const symOwner = utilSymbolOwners[s];
2175
- if (symOwner && symOwner !== name) {
2176
- conflicts.push(`${s} (utility ${symOwner})`);
2177
- }
2178
- }
2179
-
2180
- const syntaxErr = quickCheckSyntax(newCode);
2181
- const dupErr =
2182
- conflicts.length > 0
2183
- ? "duplicate symbols: " + conflicts.join(", ")
2184
- : null;
2185
- const combinedErr = syntaxErr || dupErr;
2186
-
2187
- utilities[name] = { code: newCode, error: combinedErr };
2188
-
2189
- if (!combinedErr) {
2190
- for (const s of newSyms) utilSymbolOwners[s] = name;
2191
- }
2192
-
2193
- if (prevCode !== newCode) {
2194
- scheduleRebuildUsing(name);
2195
- for (const s of newSyms) scheduleRebuildUsing(s);
2196
- for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
2197
- }
2198
- res.json({ ok: true, error: combinedErr || null });
2199
- });
2200
-
2201
- RED.httpAdmin.delete("/portal-react/utilities/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
2202
- const name = req.params.name;
2203
- if (!isSafeName(name))
2204
- return res.status(400).json({ error: "invalid name" });
2205
- const prev = utilities[name];
2206
- delete utilities[name];
2207
- // Release all symbols owned by this utility
2208
- for (const s of Object.keys(utilSymbolOwners)) {
2209
- if (utilSymbolOwners[s] === name) delete utilSymbolOwners[s];
2210
- }
2211
- if (prev) {
2212
- const removedSyms = extractUtilitySymbols(prev.code || "");
2213
- scheduleRebuildUsing(name);
2214
- for (const s of removedSyms) scheduleRebuildUsing(s);
2215
- }
2216
- res.json({ ok: true });
1653
+ jsonBodyLimit: JSON_BODY_LIMIT,
1654
+ userDir,
1655
+ pageState,
1656
+ registry,
1657
+ utilities,
1658
+ extractUtilitySymbols,
2217
1659
  });
2218
1660
  };