@aaqu/fromcubes-portal-react 0.1.0-alpha.23 → 0.1.0-alpha.25

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 CHANGED
@@ -227,7 +227,8 @@ Import **Shared Components** first — it provides the UI building blocks (Page,
227
227
  | Red status "legacy endpoint" on deploy | Flow was saved before the `/fromcubes/` prefix became hardcoded. Open the node, set **Sub-path**, redeploy. Automatic migration is disabled to avoid silent URL changes. |
228
228
  | Red status "bad sub-path" | Sub-path is empty or violates the rules (no leading `/`, no whitespace, no `..`, segments must start alphanumerically, `public`/`_ws` reserved). |
229
229
  | Yellow status "css-fail" | Tailwind generation failed (usually an invalid class in JSX). Page still loads but unstyled. Fix the class, redeploy — the status clears on the next successful build. |
230
- | `npm install @aaqu/fromcubes-portal-react` ends with `EACCES` on `postinstall` | `scripts/sync-resources.js` copies Monaco into `resources/vs/` after install. The Node-RED `userDir/node_modules/...` install path must be writable by the user running Node-RED. Re-run `npm install` from a shell with the right ownership. |
230
+ | Code editor stays blank / `portal-react/vs/loader.js` 404s | Monaco is served live from the package's own `node_modules` at `/portal-react/vs` (no postinstall copy step). The 404 means `RED.httpAdmin` is mounted under a non-root `httpAdminRoot` the editor didn't account for, or `monaco-editor` failed to install. Reinstall the package and hard-refresh the editor (`Cmd/Ctrl-Shift-R`). |
231
+ | `npm install @aaqu/fromcubes-portal-react` ends with `EACCES` | The Node-RED `userDir/node_modules` install path is not writable by the user running Node-RED. Re-run `npm install` from a shell with the right ownership. |
231
232
  | Browser request `/portal-react/css/<hash>.css` returns 404 | The portal's deploy hasn't produced a CSS bundle yet — open the editor, redeploy. If the URL is bookmarked from before a deploy, the hash is stale; reload the portal page itself. |
232
233
  | WebSocket reconnects in an endless loop | Reverse-proxy is not forwarding `Upgrade: websocket` on `/fromcubes/<sub-path>/_ws`. Check the proxy config — nginx needs `proxy_set_header Upgrade $http_upgrade`, Traefik needs the `websocket` middleware. |
233
234
  | `libs` packages fail to install on deploy | The user-installed npm packages declared in **Libs** install via Node-RED's `dynamicModuleList` mechanism, which needs network access from `userDir` and a writable `node_modules`. Behind a corporate proxy set `npm config set proxy …` for the Node-RED user. |
@@ -363,6 +363,44 @@ function findMissingComponentRefs(userCode, knownNames) {
363
363
  return missing;
364
364
  }
365
365
 
366
+ /**
367
+ * Resolve the content hash the server may advertise to a browser over the WS
368
+ * `version` frame. The hash MUST only be non-empty when there is a page the
369
+ * GET route can actually serve — otherwise a stale hash makes the served error
370
+ * / building page reload in a tight loop (it reloads on any non-empty
371
+ * `version` hash).
372
+ *
373
+ * States, in order:
374
+ * - no state / nulled `compiled` (post-`close` teardown window) → ""
375
+ * - degraded (build error but a previous good build is kept) → lastGood hash
376
+ * - hard build error with no fallback → ""
377
+ * - real serveable build (`compiled.js` present) → contentHash
378
+ *
379
+ * @param {?PageState} state pageState[endpoint] (may be null/partially torn down).
380
+ * @returns {string} Hash to advertise, or "" when nothing is serveable.
381
+ */
382
+ function serveableHash(state) {
383
+ if (!state || !state.compiled) return "";
384
+ if (state.compiled.error) {
385
+ return state.lastGood ? state.lastGood.contentHash || "" : "";
386
+ }
387
+ return state.compiled.js ? state.contentHash || "" : "";
388
+ }
389
+
390
+ /**
391
+ * True only when `state` holds a real, serveable build. Used by the no-op
392
+ * redeploy guard to decide whether a rebuild can be skipped. MUST be false
393
+ * after close() nulls `compiled` (`{ ...compiled: null }` teardown window),
394
+ * otherwise the guard treats the destroyed build as valid, skips the rebuild,
395
+ * and the GET route serves the holding page forever (permanent spinner).
396
+ *
397
+ * @param {?PageState} state pageState[endpoint] (may be null/torn down).
398
+ * @returns {boolean}
399
+ */
400
+ function hasFreshBuild(state) {
401
+ return !!state && !state.building && !!state.compiled && !state.compiled.error;
402
+ }
403
+
366
404
  module.exports = function (RED) {
367
405
  return createHelpers(RED);
368
406
  };
@@ -373,6 +411,8 @@ module.exports.quickCheckSyntax = quickCheckSyntax;
373
411
  module.exports.formatEsbuildError = formatEsbuildError;
374
412
  module.exports.extractPortalUser = extractPortalUser;
375
413
  module.exports.findMissingComponentRefs = findMissingComponentRefs;
414
+ module.exports.serveableHash = serveableHash;
415
+ module.exports.hasFreshBuild = hasFreshBuild;
376
416
  module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
377
417
  module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
378
418
 
@@ -593,6 +633,8 @@ function createHelpers(RED) {
593
633
  quickCheckSyntax,
594
634
  generateCSS,
595
635
  extractPortalUser,
636
+ serveableHash,
637
+ hasFreshBuild,
596
638
  removeRoute,
597
639
  isSafeName,
598
640
  validateSubPath,
@@ -218,6 +218,16 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
218
218
  // Flushed in onopen so node status can go red even when the
219
219
  // exception fires synchronously during initial bundle execution.
220
220
  _pendingRuntimeError: null,
221
+ // Page-load timestamp + guarded reload: never reload within 2s of
222
+ // load, so a briefly-inconsistent server (error page served while a
223
+ // "ready" version hash is advertised over WS) cannot drive a tight
224
+ // reload loop. Caps recovery to one reload / 2s.
225
+ _loadT: Date.now(),
226
+ _reload() {
227
+ const wait = 2000 - (Date.now() - this._loadT);
228
+ if (wait > 0) setTimeout(() => location.reload(), wait);
229
+ else location.reload();
230
+ },
221
231
  _user: ${user ? escScript(JSON.stringify(user)) : "null"},
222
232
 
223
233
  connect() {
@@ -253,7 +263,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
253
263
  // exception caught locally renders the same overlay node and would
254
264
  // otherwise loop reload to runtime-error to reload forever.
255
265
  if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
256
- location.reload();
266
+ this._reload();
257
267
  return;
258
268
  }
259
269
  this._version = m.hash;
@@ -379,6 +389,16 @@ function buildErrorPage(title, error, wsPath) {
379
389
  const st = document.getElementById('__err_status');
380
390
  const pre = document.querySelector('#__error_overlay pre');
381
391
  let retries = 0;
392
+ // Reload guard: never reload within 2s of this page loading. If the
393
+ // server is briefly inconsistent (serves this error page yet
394
+ // advertises a "ready" version hash over WS), an unguarded reload
395
+ // turns into a tight loop. The guard caps it to one reload / 2s.
396
+ const __loadT = Date.now();
397
+ function __reload() {
398
+ const wait = 2000 - (Date.now() - __loadT);
399
+ if (wait > 0) { setTimeout(function(){ location.reload(); }, wait); }
400
+ else { location.reload(); }
401
+ }
382
402
  function setStatus(text, ok) {
383
403
  if (!st) return;
384
404
  st.textContent = text;
@@ -394,7 +414,7 @@ function buildErrorPage(title, error, wsPath) {
394
414
  ws.onmessage = function(e) {
395
415
  try {
396
416
  const m = JSON.parse(e.data);
397
- if (m.type === 'version' && m.hash) location.reload();
417
+ if (m.type === 'version' && m.hash) __reload();
398
418
  if (m.type === 'error' && pre) pre.textContent = m.message;
399
419
  } catch(_) {}
400
420
  };
@@ -544,6 +544,8 @@ module.exports = function (RED) {
544
544
  quickCheckSyntax,
545
545
  generateCSS,
546
546
  extractPortalUser,
547
+ serveableHash,
548
+ hasFreshBuild,
547
549
  removeRoute,
548
550
  isSafeName,
549
551
  validateSubPath,
@@ -1548,8 +1550,11 @@ module.exports = function (RED) {
1548
1550
  );
1549
1551
  const prevSig = portalSig[nodeId];
1550
1552
  const existing = pageState[endpoint];
1551
- const hasValidBuild =
1552
- !!existing && !existing.building && !existing.compiled?.error;
1553
+ // hasFreshBuild also requires `compiled` to be present — close() nulls it on
1554
+ // redeploy, and a guard that ignored that (checking only building/error)
1555
+ // would treat the destroyed build as valid and skip the rebuild, leaving the
1556
+ // GET route serving the holding page forever. See helpers.hasFreshBuild.
1557
+ const hasValidBuild = hasFreshBuild(existing);
1553
1558
  portalSig[nodeId] = sig;
1554
1559
 
1555
1560
  if (prevSig !== sig || !hasValidBuild) {
@@ -1564,7 +1569,11 @@ module.exports = function (RED) {
1564
1569
  RED.httpNode.get(endpoint, async function (_req, res) {
1565
1570
  try {
1566
1571
  const state = pageState[endpoint];
1567
- if (!state || state.building) {
1572
+ // `!state.compiled` is the teardown window between node close
1573
+ // (which nulls compiled) and the next rebuild. Treat it like
1574
+ // "building" — serve the holding page (200), never fall through to
1575
+ // `state.compiled.error` which would throw and 500 via the catch.
1576
+ if (!state || state.building || !state.compiled) {
1568
1577
  const bWsPath = state?.wsPath || wsPath;
1569
1578
  res
1570
1579
  .set("Cache-Control", "no-store")
@@ -1721,10 +1730,11 @@ module.exports = function (RED) {
1721
1730
  // In degraded mode (current build failed but lastGood served), advertise
1722
1731
  // the lastGood hash so the freshly reloaded client matches the JS we sent.
1723
1732
  const cs = pageState[endpoint];
1724
- const contentHash =
1725
- cs?.compiled?.error && cs?.lastGood
1726
- ? cs.lastGood.contentHash
1727
- : cs?.contentHash || "";
1733
+ // Only advertise a non-empty hash when there is a page the GET route
1734
+ // can actually serve (real build, or degraded lastGood). Advertising
1735
+ // a stale hash for a nulled/error state drives the served error page
1736
+ // into a reload loop. See helpers.serveableHash.
1737
+ const contentHash = serveableHash(cs);
1728
1738
  wsSend(ws, { type: "version", hash: contentHash });
1729
1739
 
1730
1740
  // Send assigned portalClient to browser
@@ -1964,6 +1974,11 @@ module.exports = function (RED) {
1964
1974
  st.cssReady = null;
1965
1975
  st.compiled = null;
1966
1976
  st.css = null;
1977
+ // Clear the advertised hash too. Leaving a stale non-empty
1978
+ // contentHash while compiled is null makes the WS `version` frame
1979
+ // advertise a "ready" page that the GET route can no longer serve,
1980
+ // which drives the served error/building page into a reload loop.
1981
+ st.contentHash = "";
1967
1982
  }
1968
1983
 
1969
1984
  // Clean up route only on full removal/disable (not on redeploy).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-portal-react",
3
- "version": "0.1.0-alpha.23",
3
+ "version": "0.1.0-alpha.25",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "type": "commonjs",
6
6
  "keywords": [