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

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.
@@ -1,7 +1,34 @@
1
+ /** @module nodes/lib/page-builder */
2
+
3
+ /**
4
+ * HTML page builders for portal-react. Two entry points:
5
+ *
6
+ * - {@link buildPage} — full portal HTML with inlined JS bundle, WS bridge,
7
+ * error overlay, optional connection badge.
8
+ * - {@link buildErrorPage} — minimal HTML served when a build fails AND no
9
+ * previous good build exists for degraded-mode
10
+ * fallback. Polls + WS-reconnects for recovery.
11
+ *
12
+ * Both share the same `#__error_overlay` / `#__error_banner` CSS so the look
13
+ * is consistent across initial-load failure and live build/runtime errors.
14
+ */
15
+
1
16
  /**
2
- * HTML page builders for portal-react.
17
+ * @typedef {Object} ErrorOverlayParams
18
+ * @property {string} title Overlay heading (e.g. "Build Error").
19
+ * @property {string} [hint] Optional one-line user hint under the title.
20
+ * @property {string} message Multi-line error message rendered inside `<pre>`.
21
+ * @property {string} [statusLine] Optional small status line beneath the message.
22
+ * @property {boolean} [statusOk] When true, statusLine is rendered green; otherwise muted.
3
23
  */
4
24
 
25
+ /**
26
+ * HTML-escape a value for safe interpolation into element text or attribute
27
+ * context. Stringifies non-strings.
28
+ *
29
+ * @param {*} s
30
+ * @returns {string}
31
+ */
5
32
  function esc(s) {
6
33
  return String(s)
7
34
  .replace(/&/g, "&amp;")
@@ -10,6 +37,14 @@ function esc(s) {
10
37
  .replace(/"/g, "&quot;");
11
38
  }
12
39
 
40
+ /**
41
+ * Neutralize `</script>` sequences in user-supplied content that will be
42
+ * inlined inside a `<script>` block. Avoids accidental script-tag escape
43
+ * from a string that contains the closing tag literally.
44
+ *
45
+ * @param {*} s
46
+ * @returns {string}
47
+ */
13
48
  function escScript(s) {
14
49
  return String(s).replace(/<\/(script)/gi, "<\\/$1");
15
50
  }
@@ -56,8 +91,14 @@ const ERROR_OVERLAY_CSS = `
56
91
  }
57
92
  `;
58
93
 
59
- // Shared error overlay markup (HTML inside #__error_overlay).
60
- // Used by buildPage (WS error frame, runtime try/catch) and buildErrorPage.
94
+ /**
95
+ * Shared error overlay markup (HTML rendered inside `#__error_overlay`).
96
+ * Used by `buildPage` (WS error frame, runtime try/catch) and
97
+ * `buildErrorPage` so both surface look identical.
98
+ *
99
+ * @param {ErrorOverlayParams} params
100
+ * @returns {string} HTML fragment (no surrounding `<div>` — caller wraps).
101
+ */
61
102
  function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
62
103
  return (
63
104
  `<h1>${esc(title)}</h1>` +
@@ -71,6 +112,25 @@ function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
71
112
 
72
113
  const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
73
114
 
115
+ /**
116
+ * Build the full portal HTML page. Inlines the transpiled JS bundle, wires up
117
+ * the `window.__NR` WebSocket bridge (used by `useNodeRed()`), installs the
118
+ * shared error overlay/banner machinery, and optionally renders the
119
+ * connection-status badge in the bottom-right corner.
120
+ *
121
+ * The browser receives no compiler — the inlined `transpiledJs` is the
122
+ * already-bundled IIFE produced by esbuild at deploy time.
123
+ *
124
+ * @param {string} title Document title (`<title>` tag).
125
+ * @param {string} transpiledJs Pre-compiled IIFE bundle from esbuild.
126
+ * @param {string} wsPath WebSocket URL path (e.g. `/fromcubes/<sub>/_ws`).
127
+ * @param {string} customHead Raw HTML inserted into `<head>` (trusted-author).
128
+ * @param {string} cssHash Tailwind CSS bundle hash, or "" to skip the link tag.
129
+ * @param {?Object} user PortalUser object or null when Portal Auth is off.
130
+ * @param {boolean} showWsStatus Render the `#__cs` connection badge.
131
+ * @param {string} adminRoot `RED.settings.httpAdminRoot` (no trailing slash).
132
+ * @returns {string} Complete HTML5 document.
133
+ */
74
134
  function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
75
135
  return `<!DOCTYPE html>
76
136
  <html lang="en">
@@ -99,13 +159,13 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
99
159
  <script>
100
160
  function __safe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
101
161
  function __renderErrorOverlay(title, message, hint) {
102
- var root = document.getElementById('root');
162
+ const root = document.getElementById('root');
103
163
  if (root) root.style.display = 'none';
104
- var bo = document.getElementById('__building_overlay');
164
+ const bo = document.getElementById('__building_overlay');
105
165
  if (bo) bo.remove();
106
- var bn = document.getElementById('__error_banner');
166
+ const bn = document.getElementById('__error_banner');
107
167
  if (bn) bn.remove();
108
- var ov = document.getElementById('__error_overlay');
168
+ let ov = document.getElementById('__error_overlay');
109
169
  if (!ov) { ov = document.createElement('div'); ov.id = '__error_overlay'; document.body.appendChild(ov); }
110
170
  ov.innerHTML = '<h1>' + __safe(title) + '</h1>'
111
171
  + (hint ? '<p class="__hint">' + __safe(hint) + '</p>' : '')
@@ -113,13 +173,13 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
113
173
  + '<p class="__status __off" id="__err_status">Waiting for redeploy\\u2026</p>';
114
174
  }
115
175
  function __renderErrorBanner(message) {
116
- var ov = document.getElementById('__error_overlay');
176
+ const ov = document.getElementById('__error_overlay');
117
177
  if (ov) ov.remove();
118
- var bo = document.getElementById('__building_overlay');
178
+ const bo = document.getElementById('__building_overlay');
119
179
  if (bo) bo.remove();
120
- var root = document.getElementById('root');
180
+ const root = document.getElementById('root');
121
181
  if (root) root.style.display = '';
122
- var bn = document.getElementById('__error_banner');
182
+ let bn = document.getElementById('__error_banner');
123
183
  if (!bn) {
124
184
  bn = document.createElement('div');
125
185
  bn.id = '__error_banner';
@@ -135,11 +195,11 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
135
195
  + '<pre>' + __safe(message) + '</pre>';
136
196
  }
137
197
  function __clearErrorOverlay() {
138
- var ov = document.getElementById('__error_overlay');
198
+ const ov = document.getElementById('__error_overlay');
139
199
  if (ov) ov.remove();
140
- var bn = document.getElementById('__error_banner');
200
+ const bn = document.getElementById('__error_banner');
141
201
  if (bn) bn.remove();
142
- var root = document.getElementById('root');
202
+ const root = document.getElementById('root');
143
203
  if (root) root.style.display = '';
144
204
  }
145
205
  window.__NR = {
@@ -170,7 +230,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
170
230
  if (s) { s.textContent = 'fromcubes • connected'; s.className = 'ok'; }
171
231
  this._retries = 0;
172
232
  this._wasConnected = true;
173
- var es = document.getElementById('__err_status');
233
+ const es = document.getElementById('__err_status');
174
234
  if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
175
235
  if (this._pendingRuntimeError) {
176
236
  try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
@@ -201,10 +261,10 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
201
261
  if (m.type === 'building') {
202
262
  this._buildErrorActive = true;
203
263
  document.getElementById('root').style.display = 'none';
204
- var eo = document.getElementById('__error_overlay');
264
+ const eo = document.getElementById('__error_overlay');
205
265
  if (eo) eo.remove();
206
266
  if (!document.getElementById('__building_overlay')) {
207
- var ov = document.createElement('div');
267
+ const ov = document.createElement('div');
208
268
  ov.id = '__building_overlay';
209
269
  ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#111;color:#888;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace';
210
270
  ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
@@ -219,7 +279,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
219
279
  __renderErrorBanner(m.message);
220
280
  } else {
221
281
  __renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
222
- var es2 = document.getElementById('__err_status');
282
+ const es2 = document.getElementById('__err_status');
223
283
  if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
224
284
  }
225
285
  }
@@ -241,7 +301,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
241
301
  ws.onclose = () => {
242
302
  if (s) { s.textContent = 'fromcubes • disconnected'; s.className = 'err'; }
243
303
  this._ws = null;
244
- var es = document.getElementById('__err_status');
304
+ const es = document.getElementById('__err_status');
245
305
  if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
246
306
  const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
247
307
  this._retries++;
@@ -267,7 +327,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
267
327
  <script>
268
328
  try { ${escScript(transpiledJs)}
269
329
  } catch(__e) {
270
- var __m = (__e && (__e.stack || __e.message)) || String(__e);
330
+ const __m = (__e && (__e.stack || __e.message)) || String(__e);
271
331
  __renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
272
332
  // Report back to server so node status goes red. WS may not be open
273
333
  // yet (sync throw during initial bundle); queue until onopen.
@@ -284,6 +344,20 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
284
344
  </html>`;
285
345
  }
286
346
 
347
+ /**
348
+ * Build a minimal error page served when a portal build fails AND no
349
+ * previous good build exists for degraded-mode fallback. Includes:
350
+ *
351
+ * - The shared error overlay markup populated with the error message.
352
+ * - A WS reconnect loop that reloads the page on the next `version` frame.
353
+ * - An HTTP HEAD polling loop with linear backoff (1.5×, capped at 10 s)
354
+ * so the page also recovers when Node-RED itself was restarted (WS dies).
355
+ *
356
+ * @param {string} title Browser title (gets ` — Error` suffix).
357
+ * @param {string} error Multi-line error message rendered inside the overlay.
358
+ * @param {string} wsPath WebSocket URL path; pass empty string to skip WS wiring.
359
+ * @returns {string} Complete HTML5 document.
360
+ */
287
361
  function buildErrorPage(title, error, wsPath) {
288
362
  return `<!DOCTYPE html>
289
363
  <html lang="en">
@@ -302,42 +376,81 @@ function buildErrorPage(title, error, wsPath) {
302
376
  })}</div>
303
377
  <script>
304
378
  (function() {
305
- var st = document.getElementById('__err_status');
306
- var pre = document.querySelector('#__error_overlay pre');
307
- var retries = 0;
379
+ const st = document.getElementById('__err_status');
380
+ const pre = document.querySelector('#__error_overlay pre');
381
+ let retries = 0;
308
382
  function setStatus(text, ok) {
309
383
  if (!st) return;
310
384
  st.textContent = text;
311
385
  st.className = '__status' + (ok ? '' : ' __off');
312
386
  }
313
387
  function connect() {
314
- var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
315
- var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
388
+ const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
389
+ const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
316
390
  ws.onopen = function() {
317
391
  retries = 0;
318
392
  setStatus('Connected \\u2014 will reload on redeploy', true);
319
393
  };
320
394
  ws.onmessage = function(e) {
321
395
  try {
322
- var m = JSON.parse(e.data);
396
+ const m = JSON.parse(e.data);
323
397
  if (m.type === 'version' && m.hash) location.reload();
324
398
  if (m.type === 'error' && pre) pre.textContent = m.message;
325
399
  } catch(_) {}
326
400
  };
327
401
  ws.onclose = function() {
328
402
  setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
329
- var delay = Math.min(500 * Math.pow(2, retries), 8000);
403
+ const delay = Math.min(500 * Math.pow(2, retries), 8000);
330
404
  retries++;
331
405
  setTimeout(connect, delay);
332
406
  };
333
407
  ws.onerror = function() { ws.close(); };
334
408
  }
335
409
  ${wsPath ? "connect();" : ""}
336
- setInterval(function() {
337
- fetch(location.href, { method: 'HEAD', cache: 'no-store' })
338
- .then(function(r) { if (r.ok) location.reload(); })
339
- .catch(function() {});
340
- }, 3000);
410
+
411
+ /*
412
+ * Recovery polling loop.
413
+ *
414
+ * Why polling alongside the WebSocket? The WS reload pathway only
415
+ * fires when the next deploy actually broadcasts a "version" frame
416
+ * with a non-empty hash. If Node-RED is restarting (process down →
417
+ * up), the WS dies and reconnects do not help — we need an HTTP
418
+ * probe to notice when the runtime returns.
419
+ *
420
+ * Why backoff? A page sitting on a long-broken build would otherwise
421
+ * burn one HEAD request per 3 s indefinitely — across many open
422
+ * tabs that adds up. Linear-ish backoff (1.5×) caps cost while
423
+ * staying responsive to a freshly-recovered runtime.
424
+ *
425
+ * Cap at 10 s so the worst-case wait between successful redeploy
426
+ * and page recovery is bounded.
427
+ */
428
+ var __pollDelay = 3000;
429
+ var __pollIv = null;
430
+ function __schedulePoll() {
431
+ __pollIv = setTimeout(function poll() {
432
+ fetch(location.href, { method: 'HEAD', cache: 'no-store' })
433
+ .then(function(r) {
434
+ if (r.ok) {
435
+ // Stop the loop BEFORE reload — without this, a slow
436
+ // teardown could leave another setTimeout firing during
437
+ // the unload, briefly racing with the new page.
438
+ if (__pollIv) { clearTimeout(__pollIv); __pollIv = null; }
439
+ location.reload();
440
+ return;
441
+ }
442
+ // 4xx/5xx still means the server is up — keep delay short.
443
+ __pollDelay = 3000;
444
+ __schedulePoll();
445
+ })
446
+ .catch(function() {
447
+ // Network error → server probably down. Grow backoff.
448
+ __pollDelay = Math.min(Math.round(__pollDelay * 1.5), 10000);
449
+ __schedulePoll();
450
+ });
451
+ }, __pollDelay);
452
+ }
453
+ __schedulePoll();
341
454
  })();
342
455
  <\/script>
343
456
  </body>
@@ -1,3 +1,5 @@
1
+ /** @module nodes/lib/router */
2
+
1
3
  /**
2
4
  * Pure routing function for portal-react WS outbound messages.
3
5
  *
@@ -10,11 +12,33 @@
10
12
  * 3. msg._client.username → user-cast fallback (O(N) scan)
11
13
  * 4. otherwise → broadcast
12
14
  *
13
- * Returns a shallow summary { mode, delivered } for observability/tests.
15
+ * Returns a shallow summary `{ mode, delivered }` for observability/tests.
14
16
  * The caller is responsible for any side-effects keyed off the mode
15
17
  * (e.g. caching the last broadcast payload for new-client recovery).
16
18
  */
17
19
 
20
+ /**
21
+ * @typedef {Object} RouteContext
22
+ * @property {Map<string, import("ws").WebSocket>} clients portalClient → ws
23
+ * @property {Map<string, Set<import("ws").WebSocket>>} userIndex userId → ws set
24
+ * @property {(ws: any, frame: string, msg: Object) => boolean} sendTo
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} RouteResult
29
+ * @property {"unicast"|"user-cast"|"broadcast"} mode
30
+ * @property {number} delivered
31
+ */
32
+
33
+ /**
34
+ * Pure router — chooses delivery mode based on `msg._client` and dispatches
35
+ * via `ctx.sendTo` (which is responsible for hook checks and send-failure
36
+ * handling). Has no side-effects other than calling `sendTo`.
37
+ *
38
+ * @param {Object} msg
39
+ * @param {RouteContext} ctx
40
+ * @returns {RouteResult}
41
+ */
18
42
  function route(msg, ctx) {
19
43
  const { clients, userIndex, sendTo } = ctx;
20
44
  const target = msg && msg._client;