@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.
- package/README.md +1 -1
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/portal-page-route.js +128 -0
- package/nodes/lib/registry-nodes.js +222 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +35 -15
- package/nodes/portal-react.js +72 -629
- package/package.json +9 -5
package/nodes/portal-react.js
CHANGED
|
@@ -95,7 +95,9 @@
|
|
|
95
95
|
*/
|
|
96
96
|
|
|
97
97
|
const crypto = require("crypto");
|
|
98
|
-
const
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
};
|