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