@aaqu/fromcubes-portal-react 0.1.0-alpha.25 → 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.
@@ -559,6 +486,11 @@ module.exports = function (RED) {
559
486
  isHashInUse,
560
487
  } = helpers;
561
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");
562
494
  const hooks = require("./lib/hooks")(RED);
563
495
  const router = require("./lib/router");
564
496
 
@@ -568,270 +500,16 @@ module.exports = function (RED) {
568
500
  // `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
569
501
  const lastBroadcastCache = new Map();
570
502
 
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);
503
+ registerRegistryNodes(RED, {
504
+ registry,
505
+ utilities,
506
+ compNameOwners,
507
+ utilSymbolOwners,
508
+ isSafeName,
509
+ quickCheckSyntax,
510
+ shortStatus,
511
+ scheduleRebuildUsing,
512
+ });
835
513
 
836
514
  // ── Main node: portal-react ───────────────────────────────────
837
515
 
@@ -952,6 +630,7 @@ module.exports = function (RED) {
952
630
  let base;
953
631
  if (st.errorKind === "missing-component") base = "missing: " + st.errorSource;
954
632
  else if (st.errorSource) base = "broken: " + st.errorSource;
633
+ else if (st.errorKind === "missing-app") base = "no App";
955
634
  else if (st.errorKind === "missing-return") base = "no return";
956
635
  else if (st.errorKind === "rebuild") base = "rebuild err";
957
636
  else base = "transpile err";
@@ -1286,9 +965,16 @@ module.exports = function (RED) {
1286
965
  }
1287
966
  }
1288
967
 
1289
- // ── 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
+
1290
974
  let missingReturn = false;
1291
- 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
+ );
1292
978
  if (appFnMatch) {
1293
979
  let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
1294
980
  let hasReturn = false;
@@ -1305,7 +991,7 @@ module.exports = function (RED) {
1305
991
  // ── Resolve compiled (success or unified error) ──
1306
992
  let compiled;
1307
993
  let cacheHit = false;
1308
- let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-return' | 'transpile'
994
+ let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-app' | 'missing-return' | 'transpile'
1309
995
  if (missingComps) {
1310
996
  const list = missingComps.join(", ");
1311
997
  const plural = missingComps.length > 1;
@@ -1332,6 +1018,13 @@ module.exports = function (RED) {
1332
1018
  error: `${label} "${errorSource}" has a syntax error:\n\n${srcErr}`,
1333
1019
  };
1334
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";
1335
1028
  } else if (missingReturn) {
1336
1029
  compiled = {
1337
1030
  js: null,
@@ -1359,6 +1052,8 @@ module.exports = function (RED) {
1359
1052
  ? `Component "${errorSource}" syntax error: `
1360
1053
  : errorKind === "utility"
1361
1054
  ? `Utility "${errorSource}" syntax error: `
1055
+ : errorKind === "missing-app"
1056
+ ? "App component is required: "
1362
1057
  : errorKind === "missing-return"
1363
1058
  ? "App component has no return statement: "
1364
1059
  : "JSX transpile error: ") + compiled.error,
@@ -1566,97 +1261,19 @@ module.exports = function (RED) {
1566
1261
  setImmediate(() => {
1567
1262
  // Register route only once per endpoint (persists across deploys)
1568
1263
  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
- });
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
+ );
1660
1277
  registeredRoutes[endpoint] = true;
1661
1278
  }
1662
1279
 
@@ -2025,198 +1642,19 @@ module.exports = function (RED) {
2025
1642
  dynamicModuleList: "libs",
2026
1643
  });
2027
1644
 
2028
- // ── Serve Monaco editor files locally ────────────────────────
2029
1645
  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"), {
1646
+ const { registerAdminApi } = require("./lib/admin-api");
1647
+ registerAdminApi(RED, {
1648
+ express,
1649
+ permRead: PERM_READ,
1650
+ permWrite: PERM_WRITE,
2106
1651
  csrfGuard,
2107
1652
  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 });
1653
+ jsonBodyLimit: JSON_BODY_LIMIT,
1654
+ userDir,
1655
+ pageState,
1656
+ registry,
1657
+ utilities,
1658
+ extractUtilitySymbols,
2221
1659
  });
2222
1660
  };