@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.
@@ -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 used = portalNeeded[nodeId];
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 ((used && used.has(name)) || raw.includes(name)) {
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
- registry[compName] = { code: newCode };
272
+ const syntaxErr = quickCheckSyntax(newCode);
273
+ registry[compName] = { code: newCode, error: syntaxErr };
219
274
 
220
- node.status({ fill: "green", shape: "dot", text: compName });
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} [${n} client${n !== 1 ? "s" : ""}]`,
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
- // Extract import statements from library/user code so they appear at top level
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 [...libImports, ...userImports]) {
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
- ...libImports,
419
- ...userImports,
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
- if (!hasReturn) {
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
- const jsxHash = hash(fullJsx);
494
-
495
- // ── JS: disk cache → transpile ──
496
- let compiled = readCachedJS(jsxHash);
497
- let cacheHit = !!compiled;
498
- if (!compiled) {
499
- compiled = transpile(fullJsx);
500
- if (!compiled.error) {
501
- writeCachedJS(jsxHash, compiled.js, compiled.metafile);
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("JSX transpile error: " + compiled.error);
507
- RED.log.warn(
508
- `[portal-react] ${endpoint} JSX transpile error: ${compiled.error}`,
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
- node.status({ fill: "red", shape: "dot", text: "transpile error" });
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
- node.status({ fill: "red", shape: "dot", text: "rebuild error" });
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
- node.status({ fill: "grey", shape: "ring", text: `${endpoint} [0 clients]` });
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(){var r=0;function c(){var p=location.protocol==='https:'?'wss:':'ws:';var ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{var m=JSON.parse(e.data);if(m.type==='version'||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){var d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
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
- const contentHash = pageState[endpoint]?.contentHash || "";
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
  };