@aaqu/fromcubes-portal-react 0.1.0-alpha.25 → 0.1.0-alpha.27

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