@aaqu/fromcubes-portal-react 0.1.0-alpha.21 → 0.1.0-alpha.22
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 +26 -0
- package/examples/008-utility-debounce-flow.json +72 -0
- package/nodes/lib/page-builder.js +24 -24
- package/nodes/portal-react.html +578 -209
- package/nodes/portal-react.js +367 -11
- package/package.json +1 -1
package/nodes/portal-react.js
CHANGED
|
@@ -66,11 +66,39 @@ module.exports = function (RED) {
|
|
|
66
66
|
const endpointOwners = RED.settings.fcEndpointOwners;
|
|
67
67
|
|
|
68
68
|
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
69
|
+
// Shared namespace with fc-portal-utility nodes so a component and a utility
|
|
70
|
+
// can never share the same name.
|
|
69
71
|
if (!RED.settings.fcCompNameOwners) {
|
|
70
72
|
RED.settings.fcCompNameOwners = {};
|
|
71
73
|
}
|
|
72
74
|
const compNameOwners = RED.settings.fcCompNameOwners;
|
|
73
75
|
|
|
76
|
+
// Utility registry: populated by fc-portal-utility canvas nodes at deploy time.
|
|
77
|
+
// Keyed by node-level utilName (e.g. "mathHelpers"), value { code, error }.
|
|
78
|
+
// Each utility node may declare multiple top-level symbols inside its code block.
|
|
79
|
+
if (!RED.settings.fcPortalUtilities) {
|
|
80
|
+
RED.settings.fcPortalUtilities = {};
|
|
81
|
+
}
|
|
82
|
+
const utilities = RED.settings.fcPortalUtilities;
|
|
83
|
+
|
|
84
|
+
// Per-portal set of utility node names this portal depends on (transitively).
|
|
85
|
+
// Lets utility code changes target only portals that reference at least one
|
|
86
|
+
// symbol declared by the changed utility node.
|
|
87
|
+
if (!RED.settings.fcPortalNeededUtilities) {
|
|
88
|
+
RED.settings.fcPortalNeededUtilities = {};
|
|
89
|
+
}
|
|
90
|
+
const portalNeededUtilities = RED.settings.fcPortalNeededUtilities;
|
|
91
|
+
|
|
92
|
+
// Track owner of each top-level symbol declared inside fc-portal-utility nodes:
|
|
93
|
+
// { symbol: utilName }. Lets us catch collisions upfront (component-name vs
|
|
94
|
+
// utility-symbol, utility-symbol vs utility-symbol from another node) before
|
|
95
|
+
// they reach esbuild, where the diagnostic would surface on the portal node
|
|
96
|
+
// as a confusing transpile error rather than on the offending utility node.
|
|
97
|
+
if (!RED.settings.fcUtilSymbolOwners) {
|
|
98
|
+
RED.settings.fcUtilSymbolOwners = {};
|
|
99
|
+
}
|
|
100
|
+
const utilSymbolOwners = RED.settings.fcUtilSymbolOwners;
|
|
101
|
+
|
|
74
102
|
// Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
|
|
75
103
|
// portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
|
|
76
104
|
if (!RED.settings.fcPortalSig) {
|
|
@@ -133,10 +161,15 @@ module.exports = function (RED) {
|
|
|
133
161
|
if (dirty.size > 0) {
|
|
134
162
|
for (const nodeId of Object.keys(rebuildCallbacks)) {
|
|
135
163
|
if (targetIds.has(nodeId)) continue;
|
|
136
|
-
const
|
|
164
|
+
const usedComps = portalNeeded[nodeId];
|
|
165
|
+
const usedUtils = portalNeededUtilities[nodeId];
|
|
137
166
|
const raw = portalCode[nodeId] || "";
|
|
138
167
|
for (const name of dirty) {
|
|
139
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
(usedComps && usedComps.has(name)) ||
|
|
170
|
+
(usedUtils && usedUtils.has(name)) ||
|
|
171
|
+
raw.includes(name)
|
|
172
|
+
) {
|
|
140
173
|
targetIds.add(nodeId);
|
|
141
174
|
break;
|
|
142
175
|
}
|
|
@@ -213,6 +246,25 @@ module.exports = function (RED) {
|
|
|
213
246
|
});
|
|
214
247
|
return;
|
|
215
248
|
}
|
|
249
|
+
|
|
250
|
+
// Cross-namespace collision: compName matches an existing utility's
|
|
251
|
+
// internal top-level symbol → bundle would have two declarations of the
|
|
252
|
+
// same identifier (component IIFE + utility raw decl).
|
|
253
|
+
const utilSymOwner = utilSymbolOwners[compName];
|
|
254
|
+
if (utilSymOwner) {
|
|
255
|
+
node.error(
|
|
256
|
+
`Component name "${compName}" conflicts with a top-level symbol declared in utility "${utilSymOwner}"`,
|
|
257
|
+
);
|
|
258
|
+
node.status({
|
|
259
|
+
fill: "red",
|
|
260
|
+
shape: "ring",
|
|
261
|
+
text: "duplicate symbol: " + compName,
|
|
262
|
+
});
|
|
263
|
+
node.on("close", function (_removed, done) {
|
|
264
|
+
if (done) done();
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
216
268
|
compNameOwners[compName] = node.id;
|
|
217
269
|
|
|
218
270
|
const newCode = config.compCode || "";
|
|
@@ -245,6 +297,145 @@ module.exports = function (RED) {
|
|
|
245
297
|
}
|
|
246
298
|
RED.nodes.registerType("fc-portal-component", PortalComponentNode);
|
|
247
299
|
|
|
300
|
+
// ── Canvas node: shared utility (helpers / hooks / constants) ─
|
|
301
|
+
|
|
302
|
+
// Parse top-level symbols from utility code: function/const/let/class declarations.
|
|
303
|
+
// Used for selective inclusion (does user JSX reference any of these symbols?)
|
|
304
|
+
// and for the editor "Utilities" dialog (lists exported identifiers per node).
|
|
305
|
+
function extractUtilitySymbols(code) {
|
|
306
|
+
const names = new Set();
|
|
307
|
+
const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
308
|
+
let m;
|
|
309
|
+
while ((m = re.exec(code || ""))) names.add(m[1]);
|
|
310
|
+
return names;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function PortalUtilityNode(config) {
|
|
314
|
+
RED.nodes.createNode(this, config);
|
|
315
|
+
const node = this;
|
|
316
|
+
const utilName = (config.utilName || "").trim();
|
|
317
|
+
|
|
318
|
+
if (!isSafeName(utilName)) {
|
|
319
|
+
node.error("Invalid utility name: " + utilName);
|
|
320
|
+
node.status({ fill: "red", shape: "dot", text: "invalid name" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Duplicate name check across components AND utilities (shared namespace)
|
|
325
|
+
const existingOwner = compNameOwners[utilName];
|
|
326
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
327
|
+
node.error(
|
|
328
|
+
`Name "${utilName}" is already used by another component or utility`,
|
|
329
|
+
);
|
|
330
|
+
node.status({
|
|
331
|
+
fill: "red",
|
|
332
|
+
shape: "ring",
|
|
333
|
+
text: "duplicate: " + utilName,
|
|
334
|
+
});
|
|
335
|
+
node.on("close", function (_removed, done) {
|
|
336
|
+
if (done) done();
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
compNameOwners[utilName] = node.id;
|
|
341
|
+
|
|
342
|
+
const newCode = config.utilCode || "";
|
|
343
|
+
const prevCode = utilities[utilName]?.code;
|
|
344
|
+
const prevSyms = extractUtilitySymbols(prevCode || "");
|
|
345
|
+
const newSyms = extractUtilitySymbols(newCode);
|
|
346
|
+
|
|
347
|
+
// Free this node's previously-registered symbols before checking new ones,
|
|
348
|
+
// so a redeploy that simply renames an internal symbol doesn't see itself
|
|
349
|
+
// as a collision.
|
|
350
|
+
for (const s of prevSyms) {
|
|
351
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Cross-namespace symbol collision check:
|
|
355
|
+
// - vs component names (a component declares `const Name = (() => ...)();`
|
|
356
|
+
// at top level — a utility-internal `function Name` would clash)
|
|
357
|
+
// - vs other utility nodes' internal symbols (raw top-level decls clash)
|
|
358
|
+
const conflicts = [];
|
|
359
|
+
for (const s of newSyms) {
|
|
360
|
+
if (Object.prototype.hasOwnProperty.call(registry, s)) {
|
|
361
|
+
conflicts.push(`${s} (component)`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const symOwner = utilSymbolOwners[s];
|
|
365
|
+
if (symOwner && symOwner !== utilName) {
|
|
366
|
+
conflicts.push(`${s} (utility ${symOwner})`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
371
|
+
const dupErr =
|
|
372
|
+
conflicts.length > 0
|
|
373
|
+
? "duplicate symbols: " + conflicts.join(", ")
|
|
374
|
+
: null;
|
|
375
|
+
// Syntax error takes precedence (most actionable). If both, both are
|
|
376
|
+
// surfaced in the node.error message but status text shows syntax.
|
|
377
|
+
const combinedErr = syntaxErr || dupErr;
|
|
378
|
+
|
|
379
|
+
utilities[utilName] = { code: newCode, error: combinedErr };
|
|
380
|
+
|
|
381
|
+
if (combinedErr) {
|
|
382
|
+
const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
|
|
383
|
+
node.error(`Utility "${utilName}": ${msgs}`);
|
|
384
|
+
if (syntaxErr) {
|
|
385
|
+
const short = syntaxErr.split("\n")[0].slice(0, 40);
|
|
386
|
+
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
387
|
+
} else {
|
|
388
|
+
const firstSym = conflicts[0].split(" ")[0];
|
|
389
|
+
node.status({
|
|
390
|
+
fill: "red",
|
|
391
|
+
shape: "ring",
|
|
392
|
+
text: "duplicate symbol: " + firstSym,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// Don't register symbols on conflict — leave the namespace clean for
|
|
396
|
+
// whichever node actually owns them. Dependent portals will surface
|
|
397
|
+
// "broken: <utilName>" via the standard utility-error path.
|
|
398
|
+
} else {
|
|
399
|
+
// Register all new symbols as owned by this utility node
|
|
400
|
+
for (const s of newSyms) utilSymbolOwners[s] = utilName;
|
|
401
|
+
node.status({
|
|
402
|
+
fill: "green",
|
|
403
|
+
shape: "dot",
|
|
404
|
+
text: "utility: " + utilName,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Trigger rebuild of portals using this utility (any of its inner symbols).
|
|
409
|
+
// Push BOTH the node-level utilName AND each top-level symbol into the
|
|
410
|
+
// dirty set so portals matching by either are caught by selective rebuild.
|
|
411
|
+
if (prevCode !== newCode) {
|
|
412
|
+
scheduleRebuildUsing(utilName);
|
|
413
|
+
for (const s of newSyms) scheduleRebuildUsing(s);
|
|
414
|
+
// Symbols removed in this edit must also force rebuild for portals that
|
|
415
|
+
// referenced them — those portals will fail to find the symbol and need
|
|
416
|
+
// to surface an error or recompile against the new utility code.
|
|
417
|
+
for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
node.on("close", function (removed, done) {
|
|
421
|
+
if (compNameOwners[utilName] === node.id) {
|
|
422
|
+
delete compNameOwners[utilName];
|
|
423
|
+
}
|
|
424
|
+
// Remove all symbols this node currently owns (none if it errored out
|
|
425
|
+
// on dup-check, all of newSyms otherwise — driven by the registration
|
|
426
|
+
// table, not by re-parsing the code).
|
|
427
|
+
for (const s of Object.keys(utilSymbolOwners)) {
|
|
428
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
429
|
+
}
|
|
430
|
+
const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
|
|
431
|
+
delete utilities[utilName];
|
|
432
|
+
scheduleRebuildUsing(utilName);
|
|
433
|
+
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
434
|
+
if (done) done();
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
|
|
438
|
+
|
|
248
439
|
// ── Main node: portal-react ───────────────────────────────────
|
|
249
440
|
|
|
250
441
|
function PortalReactNode(config) {
|
|
@@ -410,6 +601,45 @@ module.exports = function (RED) {
|
|
|
410
601
|
// can target only affected portals.
|
|
411
602
|
portalNeeded[nodeId] = new Set(needed);
|
|
412
603
|
|
|
604
|
+
// ── Selective utility injection ──
|
|
605
|
+
// Each utility node contributes raw top-level code that may declare
|
|
606
|
+
// multiple symbols. Pull the whole node in if the user JSX OR any
|
|
607
|
+
// included library component references at least one of its symbols.
|
|
608
|
+
const allUtilEntries = Object.entries(utilities);
|
|
609
|
+
const utilSymbols = new Map(); // utilName -> Set of symbol names
|
|
610
|
+
for (const [n, u] of allUtilEntries) {
|
|
611
|
+
utilSymbols.set(n, extractUtilitySymbols(u.code || ""));
|
|
612
|
+
}
|
|
613
|
+
const includedLibraryCode = [...needed]
|
|
614
|
+
.map((n) => registry[n]?.code || "")
|
|
615
|
+
.join("\n");
|
|
616
|
+
const referencesAnySymbol = (text, syms) => {
|
|
617
|
+
for (const s of syms) {
|
|
618
|
+
const re = new RegExp(`\\b${s}\\b`);
|
|
619
|
+
if (re.test(text)) return true;
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
};
|
|
623
|
+
const neededUtils = new Set();
|
|
624
|
+
function addUtilWithDeps(name) {
|
|
625
|
+
if (neededUtils.has(name)) return;
|
|
626
|
+
const u = utilities[name];
|
|
627
|
+
if (!u) return;
|
|
628
|
+
neededUtils.add(name);
|
|
629
|
+
// Transitively include other utilities referenced by this utility's code
|
|
630
|
+
for (const [other, otherSyms] of utilSymbols) {
|
|
631
|
+
if (other === name) continue;
|
|
632
|
+
if (referencesAnySymbol(u.code || "", otherSyms)) {
|
|
633
|
+
addUtilWithDeps(other);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const userScanText = componentCode + "\n" + includedLibraryCode;
|
|
638
|
+
for (const [n, syms] of utilSymbols) {
|
|
639
|
+
if (referencesAnySymbol(userScanText, syms)) addUtilWithDeps(n);
|
|
640
|
+
}
|
|
641
|
+
portalNeededUtilities[nodeId] = new Set(neededUtils);
|
|
642
|
+
|
|
413
643
|
// Topological sort only needed components
|
|
414
644
|
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
415
645
|
entries.sort((a, b) => {
|
|
@@ -426,17 +656,39 @@ module.exports = function (RED) {
|
|
|
426
656
|
)
|
|
427
657
|
.join("\n\n");
|
|
428
658
|
|
|
429
|
-
//
|
|
659
|
+
// Build utility block — raw top-level concat of needed utility codes.
|
|
660
|
+
// No IIFE wrapper: a single utility node may declare many symbols.
|
|
661
|
+
const utilityJsx = [...neededUtils]
|
|
662
|
+
.map((n) => `// Utility: ${n}\n${utilities[n].code}`)
|
|
663
|
+
.join("\n\n");
|
|
664
|
+
|
|
665
|
+
// Extract import statements from library/utility/user code so they appear at top level
|
|
430
666
|
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
431
667
|
const libImports = libraryJsx.match(importRe) || [];
|
|
432
668
|
const userImports = componentCode.match(importRe) || [];
|
|
669
|
+
const utilImports = utilityJsx.match(importRe) || [];
|
|
433
670
|
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
434
671
|
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
672
|
+
const cleanUtilJsx = utilityJsx.replace(importRe, "").trim();
|
|
673
|
+
|
|
674
|
+
// Dedupe imports across all sources (libs may already pull React; user
|
|
675
|
+
// and utility may import the same package).
|
|
676
|
+
const seenImports = new Set();
|
|
677
|
+
const dedupImports = (arr) =>
|
|
678
|
+
arr.filter((s) => {
|
|
679
|
+
const k = s.trim();
|
|
680
|
+
if (seenImports.has(k)) return false;
|
|
681
|
+
seenImports.add(k);
|
|
682
|
+
return true;
|
|
683
|
+
});
|
|
684
|
+
const allLibImports = dedupImports(libImports);
|
|
685
|
+
const allUserImports = dedupImports(userImports);
|
|
686
|
+
const allUtilImports = dedupImports(utilImports);
|
|
435
687
|
|
|
436
688
|
// Warn about import * (prevents tree-shaking)
|
|
437
689
|
const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
|
|
438
|
-
const allCode = cleanLibJsx + "\n" + cleanCompCode;
|
|
439
|
-
for (const imp of [...
|
|
690
|
+
const allCode = cleanLibJsx + "\n" + cleanUtilJsx + "\n" + cleanCompCode;
|
|
691
|
+
for (const imp of [...allLibImports, ...allUserImports, ...allUtilImports]) {
|
|
440
692
|
const m = imp.match(starRe);
|
|
441
693
|
if (!m) continue;
|
|
442
694
|
const [, localName, modulePath] = m;
|
|
@@ -461,8 +713,9 @@ module.exports = function (RED) {
|
|
|
461
713
|
'import React from "react";',
|
|
462
714
|
'import ReactDOM from "react-dom";',
|
|
463
715
|
'import { createRoot } from "react-dom/client";',
|
|
464
|
-
...
|
|
465
|
-
...
|
|
716
|
+
...allLibImports,
|
|
717
|
+
...allUtilImports,
|
|
718
|
+
...allUserImports,
|
|
466
719
|
"",
|
|
467
720
|
"// ── React shorthand ──",
|
|
468
721
|
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
@@ -487,6 +740,9 @@ module.exports = function (RED) {
|
|
|
487
740
|
"}",
|
|
488
741
|
].join("\n"),
|
|
489
742
|
"",
|
|
743
|
+
"// ── Utilities (helpers / hooks / constants) ──",
|
|
744
|
+
cleanUtilJsx,
|
|
745
|
+
"",
|
|
490
746
|
"// ── Library components ──",
|
|
491
747
|
cleanLibJsx,
|
|
492
748
|
"",
|
|
@@ -499,14 +755,25 @@ module.exports = function (RED) {
|
|
|
499
755
|
|
|
500
756
|
const jsxHash = hash(fullJsx);
|
|
501
757
|
|
|
502
|
-
// ── Check: any used component has its own syntax error ──
|
|
758
|
+
// ── Check: any used component or utility has its own syntax error ──
|
|
503
759
|
let errorSource = null;
|
|
760
|
+
let errorSourceKind = null; // 'component' | 'utility'
|
|
504
761
|
for (const name of needed) {
|
|
505
762
|
if (registry[name]?.error) {
|
|
506
763
|
errorSource = name;
|
|
764
|
+
errorSourceKind = "component";
|
|
507
765
|
break;
|
|
508
766
|
}
|
|
509
767
|
}
|
|
768
|
+
if (!errorSource) {
|
|
769
|
+
for (const name of neededUtils) {
|
|
770
|
+
if (utilities[name]?.error) {
|
|
771
|
+
errorSource = name;
|
|
772
|
+
errorSourceKind = "utility";
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
510
777
|
|
|
511
778
|
// ── Check: missing return in App ──
|
|
512
779
|
let missingReturn = false;
|
|
@@ -529,11 +796,16 @@ module.exports = function (RED) {
|
|
|
529
796
|
let cacheHit = false;
|
|
530
797
|
let errorKind = null; // 'component' | 'missing-return' | 'transpile'
|
|
531
798
|
if (errorSource) {
|
|
799
|
+
const srcErr =
|
|
800
|
+
errorSourceKind === "utility"
|
|
801
|
+
? utilities[errorSource].error
|
|
802
|
+
: registry[errorSource].error;
|
|
803
|
+
const label = errorSourceKind === "utility" ? "Utility" : "Component";
|
|
532
804
|
compiled = {
|
|
533
805
|
js: null,
|
|
534
|
-
error:
|
|
806
|
+
error: `${label} "${errorSource}" has a syntax error:\n\n${srcErr}`,
|
|
535
807
|
};
|
|
536
|
-
errorKind =
|
|
808
|
+
errorKind = errorSourceKind;
|
|
537
809
|
} else if (missingReturn) {
|
|
538
810
|
compiled = {
|
|
539
811
|
js: null,
|
|
@@ -557,6 +829,8 @@ module.exports = function (RED) {
|
|
|
557
829
|
node.error(
|
|
558
830
|
(errorKind === "component"
|
|
559
831
|
? `Component "${errorSource}" syntax error: `
|
|
832
|
+
: errorKind === "utility"
|
|
833
|
+
? `Utility "${errorSource}" syntax error: `
|
|
560
834
|
: errorKind === "missing-return"
|
|
561
835
|
? "App component has no return statement: "
|
|
562
836
|
: "JSX transpile error: ") + compiled.error,
|
|
@@ -750,7 +1024,7 @@ module.exports = function (RED) {
|
|
|
750
1024
|
.set("Cache-Control", "no-store")
|
|
751
1025
|
.type("text/html")
|
|
752
1026
|
.send(
|
|
753
|
-
`<!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(){
|
|
1027
|
+
`<!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>`,
|
|
754
1028
|
);
|
|
755
1029
|
return;
|
|
756
1030
|
}
|
|
@@ -1058,6 +1332,7 @@ module.exports = function (RED) {
|
|
|
1058
1332
|
// Unregister rebuild callback + selective-rebuild metadata
|
|
1059
1333
|
delete rebuildCallbacks[nodeId];
|
|
1060
1334
|
delete portalNeeded[nodeId];
|
|
1335
|
+
delete portalNeededUtilities[nodeId];
|
|
1061
1336
|
delete portalCode[nodeId];
|
|
1062
1337
|
|
|
1063
1338
|
// Release endpoint ownership
|
|
@@ -1191,4 +1466,85 @@ module.exports = function (RED) {
|
|
|
1191
1466
|
}
|
|
1192
1467
|
res.json({ ok: true });
|
|
1193
1468
|
});
|
|
1469
|
+
|
|
1470
|
+
// ── Admin API for utility registry ────────────────────────────
|
|
1471
|
+
|
|
1472
|
+
RED.httpAdmin.get("/portal-react/utilities", (_req, res) => {
|
|
1473
|
+
// Include parsed top-level symbols so the editor "Utilities" dialog can
|
|
1474
|
+
// list which identifiers each node exports.
|
|
1475
|
+
const out = {};
|
|
1476
|
+
for (const [name, u] of Object.entries(utilities)) {
|
|
1477
|
+
out[name] = {
|
|
1478
|
+
code: u.code,
|
|
1479
|
+
error: u.error || null,
|
|
1480
|
+
symbols: [...extractUtilitySymbols(u.code || "")],
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
res.json(out);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
RED.httpAdmin.post("/portal-react/utilities", (req, res) => {
|
|
1487
|
+
const { name, code } = req.body || {};
|
|
1488
|
+
if (!isSafeName(name))
|
|
1489
|
+
return res.status(400).json({ error: "invalid name" });
|
|
1490
|
+
const newCode = code || "";
|
|
1491
|
+
const prevCode = utilities[name]?.code;
|
|
1492
|
+
const prevSyms = extractUtilitySymbols(prevCode || "");
|
|
1493
|
+
const newSyms = extractUtilitySymbols(newCode);
|
|
1494
|
+
|
|
1495
|
+
// Free previously-owned symbols before conflict check (mirror node ctor)
|
|
1496
|
+
for (const s of prevSyms) {
|
|
1497
|
+
if (utilSymbolOwners[s] === name) delete utilSymbolOwners[s];
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const conflicts = [];
|
|
1501
|
+
for (const s of newSyms) {
|
|
1502
|
+
if (Object.prototype.hasOwnProperty.call(registry, s)) {
|
|
1503
|
+
conflicts.push(`${s} (component)`);
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const symOwner = utilSymbolOwners[s];
|
|
1507
|
+
if (symOwner && symOwner !== name) {
|
|
1508
|
+
conflicts.push(`${s} (utility ${symOwner})`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
1513
|
+
const dupErr =
|
|
1514
|
+
conflicts.length > 0
|
|
1515
|
+
? "duplicate symbols: " + conflicts.join(", ")
|
|
1516
|
+
: null;
|
|
1517
|
+
const combinedErr = syntaxErr || dupErr;
|
|
1518
|
+
|
|
1519
|
+
utilities[name] = { code: newCode, error: combinedErr };
|
|
1520
|
+
|
|
1521
|
+
if (!combinedErr) {
|
|
1522
|
+
for (const s of newSyms) utilSymbolOwners[s] = name;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
if (prevCode !== newCode) {
|
|
1526
|
+
scheduleRebuildUsing(name);
|
|
1527
|
+
for (const s of newSyms) scheduleRebuildUsing(s);
|
|
1528
|
+
for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
|
|
1529
|
+
}
|
|
1530
|
+
res.json({ ok: true, error: combinedErr || null });
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
RED.httpAdmin.delete("/portal-react/utilities/:name", (req, res) => {
|
|
1534
|
+
const name = req.params.name;
|
|
1535
|
+
if (!isSafeName(name))
|
|
1536
|
+
return res.status(400).json({ error: "invalid name" });
|
|
1537
|
+
const prev = utilities[name];
|
|
1538
|
+
delete utilities[name];
|
|
1539
|
+
// Release all symbols owned by this utility
|
|
1540
|
+
for (const s of Object.keys(utilSymbolOwners)) {
|
|
1541
|
+
if (utilSymbolOwners[s] === name) delete utilSymbolOwners[s];
|
|
1542
|
+
}
|
|
1543
|
+
if (prev) {
|
|
1544
|
+
const removedSyms = extractUtilitySymbols(prev.code || "");
|
|
1545
|
+
scheduleRebuildUsing(name);
|
|
1546
|
+
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
1547
|
+
}
|
|
1548
|
+
res.json({ ok: true });
|
|
1549
|
+
});
|
|
1194
1550
|
};
|