@aaqu/fromcubes-portal-react 0.1.0-alpha.20 → 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/helpers.js +34 -28
- package/nodes/lib/page-builder.js +175 -47
- package/nodes/portal-react.html +582 -210
- package/nodes/portal-react.js +603 -60
- package/package.json +5 -5
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) {
|
|
@@ -110,6 +138,7 @@ module.exports = function (RED) {
|
|
|
110
138
|
if (_startupPhase) return; // gated — _endStartupPhase will flush
|
|
111
139
|
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
112
140
|
_rebuildTimer = setTimeout(_flushRebuild, 50);
|
|
141
|
+
_rebuildTimer.unref?.();
|
|
113
142
|
}
|
|
114
143
|
function scheduleRebuildSelf(nodeId) {
|
|
115
144
|
if (!nodeId) return;
|
|
@@ -132,10 +161,15 @@ module.exports = function (RED) {
|
|
|
132
161
|
if (dirty.size > 0) {
|
|
133
162
|
for (const nodeId of Object.keys(rebuildCallbacks)) {
|
|
134
163
|
if (targetIds.has(nodeId)) continue;
|
|
135
|
-
const
|
|
164
|
+
const usedComps = portalNeeded[nodeId];
|
|
165
|
+
const usedUtils = portalNeededUtilities[nodeId];
|
|
136
166
|
const raw = portalCode[nodeId] || "";
|
|
137
167
|
for (const name of dirty) {
|
|
138
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
(usedComps && usedComps.has(name)) ||
|
|
170
|
+
(usedUtils && usedUtils.has(name)) ||
|
|
171
|
+
raw.includes(name)
|
|
172
|
+
) {
|
|
139
173
|
targetIds.add(nodeId);
|
|
140
174
|
break;
|
|
141
175
|
}
|
|
@@ -159,6 +193,7 @@ module.exports = function (RED) {
|
|
|
159
193
|
const {
|
|
160
194
|
hash,
|
|
161
195
|
transpile,
|
|
196
|
+
quickCheckSyntax,
|
|
162
197
|
generateCSS,
|
|
163
198
|
extractPortalUser,
|
|
164
199
|
removeRoute,
|
|
@@ -211,13 +246,39 @@ module.exports = function (RED) {
|
|
|
211
246
|
});
|
|
212
247
|
return;
|
|
213
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
|
+
}
|
|
214
268
|
compNameOwners[compName] = node.id;
|
|
215
269
|
|
|
216
270
|
const newCode = config.compCode || "";
|
|
217
271
|
const prevCode = registry[compName]?.code;
|
|
218
|
-
|
|
272
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
273
|
+
registry[compName] = { code: newCode, error: syntaxErr };
|
|
219
274
|
|
|
220
|
-
|
|
275
|
+
if (syntaxErr) {
|
|
276
|
+
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
277
|
+
const short = syntaxErr.split("\n")[0].slice(0, 40);
|
|
278
|
+
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
279
|
+
} else {
|
|
280
|
+
node.status({ fill: "green", shape: "dot", text: compName });
|
|
281
|
+
}
|
|
221
282
|
|
|
222
283
|
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
223
284
|
if (prevCode !== newCode) {
|
|
@@ -236,6 +297,145 @@ module.exports = function (RED) {
|
|
|
236
297
|
}
|
|
237
298
|
RED.nodes.registerType("fc-portal-component", PortalComponentNode);
|
|
238
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
|
+
|
|
239
439
|
// ── Main node: portal-react ───────────────────────────────────
|
|
240
440
|
|
|
241
441
|
function PortalReactNode(config) {
|
|
@@ -313,11 +513,49 @@ module.exports = function (RED) {
|
|
|
313
513
|
|
|
314
514
|
function updateStatus() {
|
|
315
515
|
if (isClosing) return;
|
|
516
|
+
const st = pageState[endpoint];
|
|
316
517
|
const n = clients.size;
|
|
518
|
+
const clientTail = n > 0 ? ` [${n} client${n !== 1 ? "s" : ""}]` : "";
|
|
519
|
+
|
|
520
|
+
// Preserve build-error state — don't clobber with client count until JSX is fixed.
|
|
521
|
+
// Show "(serving last good)" suffix in degraded mode (ring shape) so it is
|
|
522
|
+
// obvious the portal still works for connected clients despite the broken build.
|
|
523
|
+
if (st && st.compiled && st.compiled.error) {
|
|
524
|
+
let base;
|
|
525
|
+
if (st.errorSource) base = "broken: " + st.errorSource;
|
|
526
|
+
else if (st.errorKind === "missing-return") base = "missing return";
|
|
527
|
+
else if (st.errorKind === "rebuild") base = "rebuild error";
|
|
528
|
+
else base = "transpile error";
|
|
529
|
+
if (st.lastGood) {
|
|
530
|
+
node.status({
|
|
531
|
+
fill: "red",
|
|
532
|
+
shape: "ring",
|
|
533
|
+
text: base + " (serving last good)" + clientTail,
|
|
534
|
+
});
|
|
535
|
+
} else {
|
|
536
|
+
node.status({ fill: "red", shape: "dot", text: base + clientTail });
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Preserve building state — same reason.
|
|
541
|
+
if (st && st.building) {
|
|
542
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
// Build succeeded but a connected browser threw at runtime
|
|
546
|
+
// (e.g. ReferenceError to a missing component / undefined identifier).
|
|
547
|
+
if (st && st.runtimeError) {
|
|
548
|
+
node.status({
|
|
549
|
+
fill: "red",
|
|
550
|
+
shape: "ring",
|
|
551
|
+
text: "runtime error" + clientTail,
|
|
552
|
+
});
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
317
555
|
node.status({
|
|
318
556
|
fill: n > 0 ? "green" : "grey",
|
|
319
557
|
shape: n > 0 ? "dot" : "ring",
|
|
320
|
-
text: `${endpoint}
|
|
558
|
+
text: `${endpoint}${clientTail || " [0 clients]"}`,
|
|
321
559
|
});
|
|
322
560
|
}
|
|
323
561
|
|
|
@@ -325,8 +563,6 @@ module.exports = function (RED) {
|
|
|
325
563
|
|
|
326
564
|
function rebuild() {
|
|
327
565
|
try {
|
|
328
|
-
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
329
|
-
|
|
330
566
|
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
331
567
|
const prevState = pageState[endpoint];
|
|
332
568
|
const prevHash = prevState?.jsxHash;
|
|
@@ -334,6 +570,7 @@ module.exports = function (RED) {
|
|
|
334
570
|
deleteCacheFiles(prevHash);
|
|
335
571
|
}
|
|
336
572
|
pageState[endpoint] = { building: true, wsPath, pageTitle };
|
|
573
|
+
updateStatus();
|
|
337
574
|
clients.forEach((ws) => {
|
|
338
575
|
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
|
|
339
576
|
});
|
|
@@ -364,6 +601,45 @@ module.exports = function (RED) {
|
|
|
364
601
|
// can target only affected portals.
|
|
365
602
|
portalNeeded[nodeId] = new Set(needed);
|
|
366
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
|
+
|
|
367
643
|
// Topological sort only needed components
|
|
368
644
|
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
369
645
|
entries.sort((a, b) => {
|
|
@@ -380,17 +656,39 @@ module.exports = function (RED) {
|
|
|
380
656
|
)
|
|
381
657
|
.join("\n\n");
|
|
382
658
|
|
|
383
|
-
//
|
|
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
|
|
384
666
|
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
385
667
|
const libImports = libraryJsx.match(importRe) || [];
|
|
386
668
|
const userImports = componentCode.match(importRe) || [];
|
|
669
|
+
const utilImports = utilityJsx.match(importRe) || [];
|
|
387
670
|
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
388
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);
|
|
389
687
|
|
|
390
688
|
// Warn about import * (prevents tree-shaking)
|
|
391
689
|
const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
|
|
392
|
-
const allCode = cleanLibJsx + "\n" + cleanCompCode;
|
|
393
|
-
for (const imp of [...
|
|
690
|
+
const allCode = cleanLibJsx + "\n" + cleanUtilJsx + "\n" + cleanCompCode;
|
|
691
|
+
for (const imp of [...allLibImports, ...allUserImports, ...allUtilImports]) {
|
|
394
692
|
const m = imp.match(starRe);
|
|
395
693
|
if (!m) continue;
|
|
396
694
|
const [, localName, modulePath] = m;
|
|
@@ -415,8 +713,9 @@ module.exports = function (RED) {
|
|
|
415
713
|
'import React from "react";',
|
|
416
714
|
'import ReactDOM from "react-dom";',
|
|
417
715
|
'import { createRoot } from "react-dom/client";',
|
|
418
|
-
...
|
|
419
|
-
...
|
|
716
|
+
...allLibImports,
|
|
717
|
+
...allUtilImports,
|
|
718
|
+
...allUserImports,
|
|
420
719
|
"",
|
|
421
720
|
"// ── React shorthand ──",
|
|
422
721
|
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
@@ -441,6 +740,9 @@ module.exports = function (RED) {
|
|
|
441
740
|
"}",
|
|
442
741
|
].join("\n"),
|
|
443
742
|
"",
|
|
743
|
+
"// ── Utilities (helpers / hooks / constants) ──",
|
|
744
|
+
cleanUtilJsx,
|
|
745
|
+
"",
|
|
444
746
|
"// ── Library components ──",
|
|
445
747
|
cleanLibJsx,
|
|
446
748
|
"",
|
|
@@ -451,7 +753,30 @@ module.exports = function (RED) {
|
|
|
451
753
|
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
452
754
|
].join("\n");
|
|
453
755
|
|
|
756
|
+
const jsxHash = hash(fullJsx);
|
|
757
|
+
|
|
758
|
+
// ── Check: any used component or utility has its own syntax error ──
|
|
759
|
+
let errorSource = null;
|
|
760
|
+
let errorSourceKind = null; // 'component' | 'utility'
|
|
761
|
+
for (const name of needed) {
|
|
762
|
+
if (registry[name]?.error) {
|
|
763
|
+
errorSource = name;
|
|
764
|
+
errorSourceKind = "component";
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
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
|
+
}
|
|
777
|
+
|
|
454
778
|
// ── Check: missing return in App ──
|
|
779
|
+
let missingReturn = false;
|
|
455
780
|
const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
|
|
456
781
|
if (appFnMatch) {
|
|
457
782
|
let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
|
|
@@ -463,54 +788,54 @@ module.exports = function (RED) {
|
|
|
463
788
|
else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
|
|
464
789
|
i++;
|
|
465
790
|
}
|
|
466
|
-
|
|
467
|
-
node.error("App component has no return statement");
|
|
468
|
-
node.status({ fill: "red", shape: "dot", text: "missing return" });
|
|
469
|
-
const missingReturnError = {
|
|
470
|
-
js: null,
|
|
471
|
-
error: "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
472
|
-
};
|
|
473
|
-
pageState[endpoint] = {
|
|
474
|
-
compiled: missingReturnError,
|
|
475
|
-
contentHash: "",
|
|
476
|
-
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
477
|
-
jsxHash: "",
|
|
478
|
-
css: null,
|
|
479
|
-
cssHash: "",
|
|
480
|
-
pageTitle,
|
|
481
|
-
wsPath,
|
|
482
|
-
customHead,
|
|
483
|
-
portalAuth,
|
|
484
|
-
showWsStatus,
|
|
485
|
-
};
|
|
486
|
-
clients.forEach((ws) => {
|
|
487
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (e) { RED.log.trace("[portal-react] ws send error frame: " + e.message); }
|
|
488
|
-
});
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
791
|
+
missingReturn = !hasReturn;
|
|
491
792
|
}
|
|
492
793
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
let
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
794
|
+
// ── Resolve compiled (success or unified error) ──
|
|
795
|
+
let compiled;
|
|
796
|
+
let cacheHit = false;
|
|
797
|
+
let errorKind = null; // 'component' | 'missing-return' | 'transpile'
|
|
798
|
+
if (errorSource) {
|
|
799
|
+
const srcErr =
|
|
800
|
+
errorSourceKind === "utility"
|
|
801
|
+
? utilities[errorSource].error
|
|
802
|
+
: registry[errorSource].error;
|
|
803
|
+
const label = errorSourceKind === "utility" ? "Utility" : "Component";
|
|
804
|
+
compiled = {
|
|
805
|
+
js: null,
|
|
806
|
+
error: `${label} "${errorSource}" has a syntax error:\n\n${srcErr}`,
|
|
807
|
+
};
|
|
808
|
+
errorKind = errorSourceKind;
|
|
809
|
+
} else if (missingReturn) {
|
|
810
|
+
compiled = {
|
|
811
|
+
js: null,
|
|
812
|
+
error:
|
|
813
|
+
"App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
814
|
+
};
|
|
815
|
+
errorKind = "missing-return";
|
|
816
|
+
} else {
|
|
817
|
+
compiled = readCachedJS(jsxHash);
|
|
818
|
+
cacheHit = !!compiled;
|
|
819
|
+
if (!compiled) {
|
|
820
|
+
compiled = transpile(fullJsx);
|
|
821
|
+
if (!compiled.error) {
|
|
822
|
+
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
823
|
+
}
|
|
502
824
|
}
|
|
825
|
+
if (compiled.error) errorKind = "transpile";
|
|
503
826
|
}
|
|
504
827
|
|
|
505
828
|
if (compiled.error) {
|
|
506
|
-
node.error(
|
|
507
|
-
|
|
508
|
-
|
|
829
|
+
node.error(
|
|
830
|
+
(errorKind === "component"
|
|
831
|
+
? `Component "${errorSource}" syntax error: `
|
|
832
|
+
: errorKind === "utility"
|
|
833
|
+
? `Utility "${errorSource}" syntax error: `
|
|
834
|
+
: errorKind === "missing-return"
|
|
835
|
+
? "App component has no return statement: "
|
|
836
|
+
: "JSX transpile error: ") + compiled.error,
|
|
509
837
|
);
|
|
510
|
-
|
|
511
|
-
clients.forEach((ws) => {
|
|
512
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (e) { RED.log.trace("[portal-react] ws send transpile error: " + e.message); }
|
|
513
|
-
});
|
|
838
|
+
// Status + WS frames handled below (lastGood-aware).
|
|
514
839
|
} else {
|
|
515
840
|
updateStatus();
|
|
516
841
|
if (compiled.metafile) {
|
|
@@ -561,6 +886,12 @@ module.exports = function (RED) {
|
|
|
561
886
|
|
|
562
887
|
lastJsxHash = jsxHash;
|
|
563
888
|
|
|
889
|
+
// Preserve last successful build so that on transpile errors we keep
|
|
890
|
+
// serving the previous working JS instead of throwing clients to an error page.
|
|
891
|
+
const lastGood = compiled.error
|
|
892
|
+
? prevState?.lastGood || null
|
|
893
|
+
: null; // will be populated after cssReady resolves on success
|
|
894
|
+
|
|
564
895
|
pageState[endpoint] = {
|
|
565
896
|
compiled,
|
|
566
897
|
contentHash,
|
|
@@ -573,8 +904,24 @@ module.exports = function (RED) {
|
|
|
573
904
|
customHead,
|
|
574
905
|
portalAuth,
|
|
575
906
|
showWsStatus,
|
|
907
|
+
errorSource,
|
|
908
|
+
errorKind,
|
|
909
|
+
lastGood,
|
|
576
910
|
};
|
|
577
911
|
|
|
912
|
+
if (compiled.error) {
|
|
913
|
+
// Status text (red) handled centrally by updateStatus — it formats
|
|
914
|
+
// base + "(serving last good)" + client-count suffix consistently
|
|
915
|
+
// across build and connect/disconnect events.
|
|
916
|
+
updateStatus();
|
|
917
|
+
const frame = lastGood
|
|
918
|
+
? JSON.stringify({ type: "error", message: compiled.error, degraded: true })
|
|
919
|
+
: JSON.stringify({ type: "error", message: compiled.error });
|
|
920
|
+
clients.forEach((ws) => {
|
|
921
|
+
try { if (ws.readyState === 1) ws.send(frame); } catch (e) { RED.log.trace("[portal-react] ws send error: " + e.message); }
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
578
925
|
// Notify all connected browsers that build finished — triggers reload or overlay cleanup
|
|
579
926
|
if (!compiled.error && contentHash) {
|
|
580
927
|
const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
|
|
@@ -588,12 +935,49 @@ module.exports = function (RED) {
|
|
|
588
935
|
if (state && state.jsxHash === jsxHash) {
|
|
589
936
|
state.css = css;
|
|
590
937
|
state.cssHash = cssHash;
|
|
938
|
+
// Snapshot current good build so future failed builds can fall back.
|
|
939
|
+
if (!state.compiled.error && state.compiled.js) {
|
|
940
|
+
state.lastGood = {
|
|
941
|
+
compiledJs: state.compiled.js,
|
|
942
|
+
contentHash: state.contentHash,
|
|
943
|
+
cssHash,
|
|
944
|
+
pageTitle: state.pageTitle,
|
|
945
|
+
customHead: state.customHead,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
591
948
|
updateStatus();
|
|
592
949
|
}
|
|
593
950
|
});
|
|
594
951
|
} catch (e) {
|
|
595
952
|
node.error("Rebuild failed: " + e.message);
|
|
596
|
-
|
|
953
|
+
// Surface as a regular build error so the lastGood/degraded path,
|
|
954
|
+
// status formatting and FE error frame all run uniformly.
|
|
955
|
+
const prev = pageState[endpoint];
|
|
956
|
+
pageState[endpoint] = {
|
|
957
|
+
compiled: { js: null, error: "Internal rebuild error: " + e.message },
|
|
958
|
+
contentHash: "",
|
|
959
|
+
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
960
|
+
jsxHash: "",
|
|
961
|
+
css: null,
|
|
962
|
+
cssHash: "",
|
|
963
|
+
pageTitle,
|
|
964
|
+
wsPath,
|
|
965
|
+
customHead,
|
|
966
|
+
portalAuth,
|
|
967
|
+
showWsStatus,
|
|
968
|
+
errorSource: null,
|
|
969
|
+
errorKind: "rebuild",
|
|
970
|
+
lastGood: prev?.lastGood || null,
|
|
971
|
+
};
|
|
972
|
+
updateStatus();
|
|
973
|
+
const frame = JSON.stringify({
|
|
974
|
+
type: "error",
|
|
975
|
+
message: "Internal rebuild error: " + e.message,
|
|
976
|
+
degraded: !!prev?.lastGood,
|
|
977
|
+
});
|
|
978
|
+
clients.forEach((ws) => {
|
|
979
|
+
try { if (ws.readyState === 1) ws.send(frame); } catch (err) { RED.log.trace("[portal-react] ws send rebuild err: " + err.message); }
|
|
980
|
+
});
|
|
597
981
|
}
|
|
598
982
|
}
|
|
599
983
|
|
|
@@ -626,7 +1010,7 @@ module.exports = function (RED) {
|
|
|
626
1010
|
scheduleRebuildSelf(nodeId);
|
|
627
1011
|
} else {
|
|
628
1012
|
node.log(`[${nodeId}] unchanged — skipping rebuild`);
|
|
629
|
-
|
|
1013
|
+
updateStatus();
|
|
630
1014
|
}
|
|
631
1015
|
setImmediate(() => {
|
|
632
1016
|
// Register route only once per endpoint (persists across deploys)
|
|
@@ -638,15 +1022,35 @@ module.exports = function (RED) {
|
|
|
638
1022
|
const bWsPath = state?.wsPath || wsPath;
|
|
639
1023
|
res
|
|
640
1024
|
.set("Cache-Control", "no-store")
|
|
641
|
-
.set("Refresh", "3")
|
|
642
1025
|
.type("text/html")
|
|
643
1026
|
.send(
|
|
644
|
-
`<!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>`,
|
|
645
1028
|
);
|
|
646
1029
|
return;
|
|
647
1030
|
}
|
|
648
1031
|
res.set("Cache-Control", "no-store");
|
|
649
1032
|
if (state.compiled.error) {
|
|
1033
|
+
if (state.lastGood) {
|
|
1034
|
+
// Degraded: serve previous good build, banner-only error UI.
|
|
1035
|
+
const user = state.portalAuth
|
|
1036
|
+
? extractPortalUser(_req.headers)
|
|
1037
|
+
: null;
|
|
1038
|
+
res
|
|
1039
|
+
.type("text/html")
|
|
1040
|
+
.send(
|
|
1041
|
+
buildPage(
|
|
1042
|
+
state.lastGood.pageTitle,
|
|
1043
|
+
state.lastGood.compiledJs,
|
|
1044
|
+
state.wsPath,
|
|
1045
|
+
state.lastGood.customHead,
|
|
1046
|
+
state.lastGood.cssHash,
|
|
1047
|
+
user,
|
|
1048
|
+
state.showWsStatus,
|
|
1049
|
+
adminRoot,
|
|
1050
|
+
),
|
|
1051
|
+
);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
650
1054
|
res
|
|
651
1055
|
.status(500)
|
|
652
1056
|
.type("text/html")
|
|
@@ -763,13 +1167,28 @@ module.exports = function (RED) {
|
|
|
763
1167
|
|
|
764
1168
|
updateStatus();
|
|
765
1169
|
|
|
766
|
-
// Send content version for deploy-reload detection
|
|
767
|
-
|
|
1170
|
+
// Send content version for deploy-reload detection.
|
|
1171
|
+
// In degraded mode (current build failed but lastGood served), advertise
|
|
1172
|
+
// the lastGood hash so the freshly reloaded client matches the JS we sent.
|
|
1173
|
+
const cs = pageState[endpoint];
|
|
1174
|
+
const contentHash =
|
|
1175
|
+
cs?.compiled?.error && cs?.lastGood
|
|
1176
|
+
? cs.lastGood.contentHash
|
|
1177
|
+
: cs?.contentHash || "";
|
|
768
1178
|
wsSend(ws, { type: "version", hash: contentHash });
|
|
769
1179
|
|
|
770
1180
|
// Send assigned portalClient to browser
|
|
771
1181
|
wsSend(ws, { type: "hello", portalClient });
|
|
772
1182
|
|
|
1183
|
+
// Degraded warning — show banner, not full overlay.
|
|
1184
|
+
if (cs?.compiled?.error && cs?.lastGood) {
|
|
1185
|
+
wsSend(ws, {
|
|
1186
|
+
type: "error",
|
|
1187
|
+
message: cs.compiled.error,
|
|
1188
|
+
degraded: true,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
773
1192
|
// Send the cached last broadcast (if any) as a distinct
|
|
774
1193
|
// `recovery` frame. The browser uses this to seed `data` on a
|
|
775
1194
|
// fresh connection. React components can opt out via
|
|
@@ -778,9 +1197,37 @@ module.exports = function (RED) {
|
|
|
778
1197
|
wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
|
|
779
1198
|
}
|
|
780
1199
|
|
|
1200
|
+
// Heartbeat — detect dead sockets via WS ping/pong. Browser
|
|
1201
|
+
// auto-replies to ping frames, no client JS needed.
|
|
1202
|
+
ws._isAlive = true;
|
|
1203
|
+
ws.on("pong", () => { ws._isAlive = true; });
|
|
1204
|
+
ws._pingIv = setInterval(() => {
|
|
1205
|
+
if (ws._isAlive === false) {
|
|
1206
|
+
try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
ws._isAlive = false;
|
|
1210
|
+
try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
|
|
1211
|
+
}, 30000);
|
|
1212
|
+
|
|
781
1213
|
ws.on("message", (raw) => {
|
|
782
1214
|
try {
|
|
783
1215
|
const msg = JSON.parse(raw.toString());
|
|
1216
|
+
if (msg.type === "runtime_error") {
|
|
1217
|
+
// Browser caught an exception while running the bundle —
|
|
1218
|
+
// surface it on node status so the editor shows red even
|
|
1219
|
+
// when the build itself succeeded (e.g. ReferenceError to
|
|
1220
|
+
// an undefined identifier or missing component).
|
|
1221
|
+
const st = pageState[endpoint];
|
|
1222
|
+
if (st && !(st.compiled && st.compiled.error)) {
|
|
1223
|
+
st.runtimeError = String(msg.message || "")
|
|
1224
|
+
.split("\n")[0]
|
|
1225
|
+
.slice(0, 200);
|
|
1226
|
+
node.error("Runtime error in browser: " + st.runtimeError);
|
|
1227
|
+
updateStatus();
|
|
1228
|
+
}
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
784
1231
|
if (msg.type === "output") {
|
|
785
1232
|
let out = {
|
|
786
1233
|
payload: msg.payload,
|
|
@@ -806,6 +1253,7 @@ module.exports = function (RED) {
|
|
|
806
1253
|
});
|
|
807
1254
|
|
|
808
1255
|
const detach = () => {
|
|
1256
|
+
if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
|
|
809
1257
|
clients.delete(portalClient);
|
|
810
1258
|
if (userId) {
|
|
811
1259
|
const set = userIndex.get(userId);
|
|
@@ -863,8 +1311,10 @@ module.exports = function (RED) {
|
|
|
863
1311
|
delete upgradeHandlers[nodeId];
|
|
864
1312
|
}
|
|
865
1313
|
|
|
866
|
-
// Close all WS clients
|
|
1314
|
+
// Close all WS clients — clear heartbeat interval BEFORE ws.close()
|
|
1315
|
+
// so pending pings do not leak if the 'close' event is delayed.
|
|
867
1316
|
clients.forEach((ws) => {
|
|
1317
|
+
if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
|
|
868
1318
|
try {
|
|
869
1319
|
ws.close(1001, "node redeployed");
|
|
870
1320
|
} catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
|
|
@@ -882,6 +1332,7 @@ module.exports = function (RED) {
|
|
|
882
1332
|
// Unregister rebuild callback + selective-rebuild metadata
|
|
883
1333
|
delete rebuildCallbacks[nodeId];
|
|
884
1334
|
delete portalNeeded[nodeId];
|
|
1335
|
+
delete portalNeededUtilities[nodeId];
|
|
885
1336
|
delete portalCode[nodeId];
|
|
886
1337
|
|
|
887
1338
|
// Release endpoint ownership
|
|
@@ -900,6 +1351,17 @@ module.exports = function (RED) {
|
|
|
900
1351
|
// the Map itself should not outlive the node instance.
|
|
901
1352
|
userIndex.clear();
|
|
902
1353
|
|
|
1354
|
+
// Break references to large objects / Promises in pageState even on
|
|
1355
|
+
// redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
|
|
1356
|
+
// between close and the new build these would retain closures over
|
|
1357
|
+
// the old clients/userIndex/rebuild scope.
|
|
1358
|
+
const st = pageState[endpoint];
|
|
1359
|
+
if (st) {
|
|
1360
|
+
st.cssReady = null;
|
|
1361
|
+
st.compiled = null;
|
|
1362
|
+
st.css = null;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
903
1365
|
// Clean up route only when node is fully removed (not redeployed)
|
|
904
1366
|
if (removed) {
|
|
905
1367
|
// Delete disk cache if no other endpoint uses this hash
|
|
@@ -1004,4 +1466,85 @@ module.exports = function (RED) {
|
|
|
1004
1466
|
}
|
|
1005
1467
|
res.json({ ok: true });
|
|
1006
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
|
+
});
|
|
1007
1550
|
};
|