@aaqu/fromcubes-portal-react 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -121,18 +121,6 @@ function removeRoute(router, path) {
121
121
  );
122
122
  }
123
123
 
124
- function esc(s) {
125
- return String(s)
126
- .replace(/&/g, "&")
127
- .replace(/</g, "&lt;")
128
- .replace(/>/g, "&gt;")
129
- .replace(/"/g, "&quot;");
130
- }
131
-
132
- function escScript(s) {
133
- return String(s).replace(/<\/(script)/gi, "<\\/$1");
134
- }
135
-
136
124
  module.exports = function (RED) {
137
125
  return createHelpers(RED);
138
126
  };
@@ -307,8 +295,6 @@ function createHelpers(RED) {
307
295
  removeRoute,
308
296
  isSafeName,
309
297
  validateSubPath,
310
- esc,
311
- escScript,
312
298
  pkgRoot,
313
299
  userDir,
314
300
  cacheDir,
@@ -45,6 +45,20 @@ module.exports = function (RED) {
45
45
  }
46
46
  const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
47
47
 
48
+ // Per-portal set of component names the portal depends on (needed set from last rebuild,
49
+ // including transitive deps). Lets component changes target only portals that use them.
50
+ if (!RED.settings.fcPortalNeeded) {
51
+ RED.settings.fcPortalNeeded = {};
52
+ }
53
+ const portalNeeded = RED.settings.fcPortalNeeded;
54
+
55
+ // Per-portal raw user JSX code string. Used as fallback to detect references to
56
+ // newly-added components that haven't been in a `needed` set yet.
57
+ if (!RED.settings.fcPortalCode) {
58
+ RED.settings.fcPortalCode = {};
59
+ }
60
+ const portalCode = RED.settings.fcPortalCode;
61
+
48
62
  // Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
49
63
  if (!RED.settings.fcEndpointOwners) {
50
64
  RED.settings.fcEndpointOwners = {};
@@ -57,23 +71,77 @@ module.exports = function (RED) {
57
71
  }
58
72
  const compNameOwners = RED.settings.fcCompNameOwners;
59
73
 
60
- // Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
61
- // Yields event loop between builds so HTTP server stays responsive
74
+ // Debounced selective rebuild: coalesces multiple component changes into one pass.
75
+ // Yields event loop between builds so HTTP server stays responsive.
62
76
  let _rebuildTimer = null;
63
- function scheduleRebuildAll() {
77
+ const _dirtyComps = new Set();
78
+ let _rebuildAllPending = false;
79
+
80
+ // Startup gate: on first process start, Node-RED constructs portal/component nodes
81
+ // sequentially over a window longer than the 50ms debounce. Without gating, an early
82
+ // flush rebuilds a portal, then a late component registration triggers a second
83
+ // rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
84
+ // collapses to exactly one rebuild pass.
85
+ let _startupPhase = true;
86
+ function _endStartupPhase() {
87
+ if (!_startupPhase) return;
88
+ _startupPhase = false;
89
+ if (_rebuildAllPending || _dirtyComps.size > 0) {
90
+ if (_rebuildTimer) { clearTimeout(_rebuildTimer); _rebuildTimer = null; }
91
+ _flushRebuild();
92
+ }
93
+ }
94
+ try {
95
+ if (RED.events && typeof RED.events.once === "function") {
96
+ RED.events.once("flows:started", _endStartupPhase);
97
+ }
98
+ } catch (e) { RED.log.trace("[portal-react] events.once: " + e.message); }
99
+ // Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
100
+ setTimeout(_endStartupPhase, 2000).unref?.();
101
+
102
+ function _armRebuild() {
103
+ if (_startupPhase) return; // gated — _endStartupPhase will flush
64
104
  if (_rebuildTimer) clearTimeout(_rebuildTimer);
65
- _rebuildTimer = setTimeout(() => {
66
- _rebuildTimer = null;
67
- const fns = Object.values(rebuildCallbacks);
68
- let i = 0;
69
- function next() {
70
- if (i >= fns.length) return;
71
- try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
72
- i++;
73
- if (i < fns.length) setImmediate(next);
105
+ _rebuildTimer = setTimeout(_flushRebuild, 50);
106
+ }
107
+ function scheduleRebuildAll() {
108
+ _rebuildAllPending = true;
109
+ _armRebuild();
110
+ }
111
+ function scheduleRebuildUsing(compName) {
112
+ if (!compName) return;
113
+ _dirtyComps.add(compName);
114
+ _armRebuild();
115
+ }
116
+ function _flushRebuild() {
117
+ _rebuildTimer = null;
118
+ const all = _rebuildAllPending;
119
+ const dirty = new Set(_dirtyComps);
120
+ _rebuildAllPending = false;
121
+ _dirtyComps.clear();
122
+
123
+ const targetIds = new Set();
124
+ for (const nodeId of Object.keys(rebuildCallbacks)) {
125
+ if (all) { targetIds.add(nodeId); continue; }
126
+ const used = portalNeeded[nodeId];
127
+ const raw = portalCode[nodeId] || "";
128
+ for (const name of dirty) {
129
+ if ((used && used.has(name)) || raw.includes(name)) {
130
+ targetIds.add(nodeId);
131
+ break;
132
+ }
74
133
  }
75
- next();
76
- }, 50);
134
+ }
135
+
136
+ const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
137
+ let i = 0;
138
+ function next() {
139
+ if (i >= fns.length) return;
140
+ try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
141
+ i++;
142
+ if (i < fns.length) setImmediate(next);
143
+ }
144
+ next();
77
145
  }
78
146
 
79
147
  // ── Load modules ─────────────────────────────────────────────
@@ -135,20 +203,24 @@ module.exports = function (RED) {
135
203
  }
136
204
  compNameOwners[compName] = node.id;
137
205
 
138
- registry[compName] = {
139
- code: config.compCode || "",
140
- };
206
+ const newCode = config.compCode || "";
207
+ const prevCode = registry[compName]?.code;
208
+ registry[compName] = { code: newCode };
141
209
 
142
210
  node.status({ fill: "green", shape: "dot", text: compName });
143
211
 
144
- // Trigger re-transpile on all portal-react nodes (debounced across all component registrations)
145
- scheduleRebuildAll();
212
+ // Only rebuild portals that reference this component, and only if the code actually changed.
213
+ if (prevCode !== newCode) {
214
+ scheduleRebuildUsing(compName);
215
+ }
146
216
 
147
217
  node.on("close", function (removed, done) {
148
218
  if (compNameOwners[compName] === node.id) {
149
219
  delete compNameOwners[compName];
150
220
  }
151
221
  delete registry[compName];
222
+ // Portals depending on this component must rebuild (topology changed or name resolution breaks).
223
+ scheduleRebuildUsing(compName);
152
224
  if (done) done();
153
225
  });
154
226
  }
@@ -243,7 +315,7 @@ module.exports = function (RED) {
243
315
  }
244
316
  pageState[endpoint] = { building: true, wsPath, pageTitle };
245
317
  clients.forEach((ws) => {
246
- try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (_) {}
318
+ try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
247
319
  });
248
320
 
249
321
  // Selective injection: only include components referenced in user code (+ transitive deps)
@@ -268,6 +340,10 @@ module.exports = function (RED) {
268
340
  }
269
341
  }
270
342
 
343
+ // Remember which components this portal depends on, so component changes
344
+ // can target only affected portals.
345
+ portalNeeded[nodeId] = new Set(needed);
346
+
271
347
  // Topological sort only needed components
272
348
  const entries = allEntries.filter(([n]) => needed.has(n));
273
349
  entries.sort((a, b) => {
@@ -388,7 +464,7 @@ module.exports = function (RED) {
388
464
  showWsStatus,
389
465
  };
390
466
  clients.forEach((ws) => {
391
- try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (_) {}
467
+ 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); }
392
468
  });
393
469
  return;
394
470
  }
@@ -413,28 +489,29 @@ module.exports = function (RED) {
413
489
  );
414
490
  node.status({ fill: "red", shape: "dot", text: "transpile error" });
415
491
  clients.forEach((ws) => {
416
- try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (_) {}
492
+ 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); }
417
493
  });
418
494
  } else {
419
- node.status({
420
- fill: "green",
421
- shape: "dot",
422
- text: `built • ${endpoint}`,
423
- });
495
+ updateStatus();
424
496
  if (compiled.metafile) {
425
497
  const output = Object.values(compiled.metafile.outputs)[0];
426
498
  const sizes = output
427
499
  ? Object.entries(output.inputs)
428
500
  .map(([name, info]) => ({
429
- name: name.replace(/^.*node_modules\//, ""),
501
+ name: name
502
+ .replace(/^.*node_modules\//, "")
503
+ .replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
430
504
  bytes: info.bytesInOutput,
431
505
  }))
432
506
  .sort((a, b) => b.bytes - a.bytes)
433
507
  .slice(0, 5)
434
508
  : [];
435
- const totalKB = (compiled.js.length / 1024).toFixed(1);
509
+ const totalKB = Math.round(compiled.js.length / 1024);
510
+ const top = sizes
511
+ .map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
512
+ .join(" · ");
436
513
  node.log(
437
- `Bundle${cacheHit ? " (cached)" : ""}: ${totalKB}KB top: ${sizes.map((s) => `${s.name} (${(s.bytes / 1024).toFixed(1)}KB)`).join(", ")}`,
514
+ `[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
438
515
  );
439
516
  }
440
517
  }
@@ -482,7 +559,7 @@ module.exports = function (RED) {
482
559
  if (!compiled.error && contentHash) {
483
560
  const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
484
561
  clients.forEach((ws) => {
485
- try { if (ws.readyState === 1) ws.send(versionFrame); } catch (_) {}
562
+ try { if (ws.readyState === 1) ws.send(versionFrame); } catch (e) { RED.log.trace("[portal-react] ws send version: " + e.message); }
486
563
  });
487
564
  }
488
565
 
@@ -491,11 +568,7 @@ module.exports = function (RED) {
491
568
  if (state && state.jsxHash === jsxHash) {
492
569
  state.css = css;
493
570
  state.cssHash = cssHash;
494
- node.status({
495
- fill: "green",
496
- shape: "dot",
497
- text: `built • ${endpoint}`,
498
- });
571
+ updateStatus();
499
572
  }
500
573
  });
501
574
  } catch (e) {
@@ -506,6 +579,8 @@ module.exports = function (RED) {
506
579
 
507
580
  // Register rebuild callback so library components can trigger re-transpile
508
581
  rebuildCallbacks[nodeId] = rebuild;
582
+ // Remember raw user JSX so selective rebuild can detect references to new components
583
+ portalCode[nodeId] = componentCode;
509
584
 
510
585
  // Initial build: debounced so all fc-portal-component nodes register first
511
586
  scheduleRebuildAll();
@@ -607,7 +682,7 @@ module.exports = function (RED) {
607
682
  // Plugin hook: plugins may reject the connection before upgrade.
608
683
  // Default (no plugins) = allowed, matches dashboard behavior.
609
684
  if (!hooks.allow("onIsValidConnection", request)) {
610
- try { socket.destroy(); } catch (_) {}
685
+ try { socket.destroy(); } catch (e) { RED.log.trace("[portal-react] socket destroy: " + e.message); }
611
686
  return;
612
687
  }
613
688
  wsServer.handleUpgrade(request, socket, head, (ws) => {
@@ -716,7 +791,8 @@ module.exports = function (RED) {
716
791
  try {
717
792
  ws.send(frame);
718
793
  return true;
719
- } catch (_) {
794
+ } catch (e) {
795
+ RED.log.trace("[portal-react] ws send frame: " + e.message);
720
796
  return false;
721
797
  }
722
798
  }
@@ -747,7 +823,7 @@ module.exports = function (RED) {
747
823
  clients.forEach((ws) => {
748
824
  try {
749
825
  ws.close(1001, "node redeployed");
750
- } catch (_) {}
826
+ } catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
751
827
  });
752
828
  clients.clear();
753
829
 
@@ -755,12 +831,14 @@ module.exports = function (RED) {
755
831
  if (wsServer) {
756
832
  try {
757
833
  wsServer.close();
758
- } catch (_) {}
834
+ } catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
759
835
  wsServer = null;
760
836
  }
761
837
 
762
- // Unregister rebuild callback
838
+ // Unregister rebuild callback + selective-rebuild metadata
763
839
  delete rebuildCallbacks[nodeId];
840
+ delete portalNeeded[nodeId];
841
+ delete portalCode[nodeId];
764
842
 
765
843
  // Release endpoint ownership
766
844
  if (endpointOwners[endpoint] === nodeId) {
@@ -796,7 +874,7 @@ module.exports = function (RED) {
796
874
  function wsSend(ws, obj) {
797
875
  try {
798
876
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
799
- } catch (_) {}
877
+ } catch (e) { RED.log.trace("[portal-react] wsSend: " + e.message); }
800
878
  }
801
879
 
802
880
  function updateStatus() {
@@ -870,7 +948,12 @@ module.exports = function (RED) {
870
948
  const { name, code } = req.body || {};
871
949
  if (!isSafeName(name))
872
950
  return res.status(400).json({ error: "invalid name" });
873
- registry[name] = { code: code || "" };
951
+ const newCode = code || "";
952
+ const prevCode = registry[name]?.code;
953
+ registry[name] = { code: newCode };
954
+ if (prevCode !== newCode) {
955
+ scheduleRebuildUsing(name);
956
+ }
874
957
  res.json({ ok: true });
875
958
  });
876
959
 
@@ -878,7 +961,11 @@ module.exports = function (RED) {
878
961
  const name = req.params.name;
879
962
  if (!isSafeName(name))
880
963
  return res.status(400).json({ error: "invalid name" });
964
+ const existed = Object.prototype.hasOwnProperty.call(registry, name);
881
965
  delete registry[name];
966
+ if (existed) {
967
+ scheduleRebuildUsing(name);
968
+ }
882
969
  res.json({ ok: true });
883
970
  });
884
971
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-portal-react",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "keywords": [
6
6
  "node-red",