@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Plugin hook system for @aaqu/fromcubes-portal-react.
3
+ *
4
+ * Plugins register with Node-RED via:
5
+ * RED.plugins.registerPlugin("my-plugin", {
6
+ * type: "fromcubes-portal-react",
7
+ * hooks: {
8
+ * onIsValidConnection(request) { return true },
9
+ * onCanSendTo(ws, msg) { return true },
10
+ * onInbound(msg, ws) { return msg },
11
+ * },
12
+ * })
13
+ *
14
+ * Semantics:
15
+ * - allow(name, ...args): every registered hook must return !== false
16
+ * (no hooks registered -> allowed). AND logic across plugins.
17
+ * - transform(name, msg, ...args): runs each hook sequentially, each
18
+ * may return a new msg. Returning undefined keeps the current msg.
19
+ * - Any thrown exception is treated as `false` for allow hooks and
20
+ * logged via RED.log.error. Transform hooks log and skip the step.
21
+ */
22
+
23
+ const PLUGIN_TYPE = "fromcubes-portal-react";
24
+
25
+ /**
26
+ * Build a plugin-hook dispatcher bound to `RED`.
27
+ *
28
+ * @param {Object} RED Node-RED runtime.
29
+ * @returns {{ allow: Function, transform: Function, hasHook: Function, PLUGIN_TYPE: string }}
30
+ */
31
+ module.exports = function (RED) {
32
+ /**
33
+ * Collect hook functions for a given name across all registered plugins
34
+ * of type `PLUGIN_TYPE`. Returns an empty list when no plugins or when
35
+ * `RED.plugins` is unavailable.
36
+ *
37
+ * @param {string} name
38
+ * @returns {Array<{ fn: Function, id: string }>}
39
+ */
40
+ function getHooks(name) {
41
+ let plugins = [];
42
+ try {
43
+ plugins = RED.plugins.getByType(PLUGIN_TYPE) || [];
44
+ } catch (_) {
45
+ return [];
46
+ }
47
+ const out = [];
48
+ for (const p of plugins) {
49
+ const fn = p && p.hooks && p.hooks[name];
50
+ if (typeof fn === "function") out.push({ fn, id: p.id || p.name || "?" });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * AND-fold across all registered hook implementations. A hook returning
57
+ * `false` (or throwing) blocks the action. No hooks → allowed.
58
+ *
59
+ * @param {string} name
60
+ * @param {...unknown} args
61
+ * @returns {boolean}
62
+ */
63
+ function allow(name, ...args) {
64
+ const hooks = getHooks(name);
65
+ if (hooks.length === 0) return true;
66
+ for (const h of hooks) {
67
+ let result;
68
+ try {
69
+ result = h.fn(...args);
70
+ } catch (e) {
71
+ RED.log.error(
72
+ `[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
73
+ );
74
+ return false;
75
+ }
76
+ if (result === false) return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * Sequentially apply each hook to the running value. A hook returning
83
+ * `undefined` keeps the current value; returning `null` is treated as
84
+ * "drop this message" by callers (caller checks for null after transform).
85
+ * Throws are logged via `RED.log.error` and the hook is skipped.
86
+ *
87
+ * @template T
88
+ * @param {string} name
89
+ * @param {T} msg
90
+ * @param {...unknown} args
91
+ * @returns {T|null}
92
+ */
93
+ function transform(name, msg, ...args) {
94
+ const hooks = getHooks(name);
95
+ let current = msg;
96
+ for (const h of hooks) {
97
+ try {
98
+ const next = h.fn(current, ...args);
99
+ if (next !== undefined) current = next;
100
+ } catch (e) {
101
+ RED.log.error(
102
+ `[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
103
+ );
104
+ }
105
+ }
106
+ return current;
107
+ }
108
+
109
+ /**
110
+ * @param {string} name
111
+ * @returns {boolean} True when at least one plugin registers the hook.
112
+ */
113
+ function hasHook(name) {
114
+ return getHooks(name).length > 0;
115
+ }
116
+
117
+ return { allow, transform, hasHook, PLUGIN_TYPE };
118
+ };
119
+
120
+ module.exports.PLUGIN_TYPE = PLUGIN_TYPE;
@@ -0,0 +1,480 @@
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
+
16
+ /**
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.
23
+ */
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
+ */
32
+ function esc(s) {
33
+ return String(s)
34
+ .replace(/&/g, "&amp;")
35
+ .replace(/</g, "&lt;")
36
+ .replace(/>/g, "&gt;")
37
+ .replace(/"/g, "&quot;");
38
+ }
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
+ */
48
+ function escScript(s) {
49
+ return String(s).replace(/<\/(script)/gi, "<\\/$1");
50
+ }
51
+
52
+ const ERROR_OVERLAY_CSS = `
53
+ #__error_overlay {
54
+ position: fixed; inset: 0; z-index: 99999;
55
+ background: #1a0000; color: #f87171;
56
+ font-family: monospace; padding: 40px;
57
+ overflow: auto;
58
+ display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
59
+ }
60
+ #__error_overlay h1 { color: #ff4444; margin: 0 0 16px; font-size: 24px }
61
+ #__error_overlay p.__hint { color: #888; margin: 0 0 16px }
62
+ #__error_overlay pre {
63
+ background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px;
64
+ padding: 20px; color: #fca5a5;
65
+ max-width: 90vw; max-height: 60vh; overflow: auto;
66
+ white-space: pre-wrap; margin: 0;
67
+ }
68
+ #__error_overlay p.__status { color: #4ade80; font-size: 12px; margin: 24px 0 0 }
69
+ #__error_overlay p.__status.__off { color: #888 }
70
+ #__error_banner {
71
+ position: fixed; top: 8px; right: 8px; z-index: 99998;
72
+ max-width: 360px; padding: 8px 12px;
73
+ background: #1a0000; color: #fca5a5;
74
+ border: 1px solid #ff4444; border-radius: 6px;
75
+ font-family: monospace; font-size: 12px;
76
+ box-shadow: 0 4px 12px rgba(0,0,0,.4);
77
+ cursor: pointer; user-select: none;
78
+ }
79
+ #__error_banner b { color: #ff4444; display: block; margin-bottom: 2px }
80
+ #__error_banner.__expanded {
81
+ max-width: 70vw; max-height: 60vh; overflow: auto;
82
+ cursor: default;
83
+ }
84
+ #__error_banner pre {
85
+ margin: 8px 0 0; padding: 8px; background: #0a0a0a;
86
+ border-radius: 4px; white-space: pre-wrap; display: none;
87
+ }
88
+ #__error_banner.__expanded pre { display: block }
89
+ #__error_banner .__close {
90
+ float: right; padding: 0 4px; color: #888; cursor: pointer;
91
+ }
92
+ `;
93
+
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
+ */
102
+ function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
103
+ return (
104
+ `<h1>${esc(title)}</h1>` +
105
+ (hint ? `<p class="__hint">${esc(hint)}</p>` : "") +
106
+ `<pre>${esc(message)}</pre>` +
107
+ (statusLine
108
+ ? `<p class="__status${statusOk ? "" : " __off"}" id="__err_status">${esc(statusLine)}</p>`
109
+ : "")
110
+ );
111
+ }
112
+
113
+ const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
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
+ */
134
+ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
135
+ return `<!DOCTYPE html>
136
+ <html lang="en">
137
+ <head>
138
+ <meta charset="UTF-8">
139
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
140
+ <title>${esc(title)}</title>
141
+ ${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
142
+ ${escScript(customHead)}
143
+ <style>${ERROR_OVERLAY_CSS}</style>
144
+ ${showWsStatus ? `<style>
145
+ #__cs {
146
+ position: fixed; bottom: 6px; right: 6px;
147
+ padding: 3px 8px; font-size: 10px; border-radius: 3px;
148
+ z-index: 99999; background: #111; border: 1px solid #333;
149
+ opacity: .7; transition: opacity .2s;
150
+ }
151
+ #__cs:hover { opacity: 1 }
152
+ #__cs.ok { color: #4ade80 }
153
+ #__cs.err { color: #f87171 }
154
+ </style>` : ""}
155
+ </head>
156
+ <body>
157
+ <div id="root"></div>
158
+ ${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
159
+ <script>
160
+ function __safe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
161
+ function __renderErrorOverlay(title, message, hint) {
162
+ const root = document.getElementById('root');
163
+ if (root) root.style.display = 'none';
164
+ const bo = document.getElementById('__building_overlay');
165
+ if (bo) bo.remove();
166
+ const bn = document.getElementById('__error_banner');
167
+ if (bn) bn.remove();
168
+ let ov = document.getElementById('__error_overlay');
169
+ if (!ov) { ov = document.createElement('div'); ov.id = '__error_overlay'; document.body.appendChild(ov); }
170
+ ov.innerHTML = '<h1>' + __safe(title) + '</h1>'
171
+ + (hint ? '<p class="__hint">' + __safe(hint) + '</p>' : '')
172
+ + '<pre>' + __safe(message) + '</pre>'
173
+ + '<p class="__status __off" id="__err_status">Waiting for redeploy\\u2026</p>';
174
+ }
175
+ function __renderErrorBanner(message) {
176
+ const ov = document.getElementById('__error_overlay');
177
+ if (ov) ov.remove();
178
+ const bo = document.getElementById('__building_overlay');
179
+ if (bo) bo.remove();
180
+ const root = document.getElementById('root');
181
+ if (root) root.style.display = '';
182
+ let bn = document.getElementById('__error_banner');
183
+ if (!bn) {
184
+ bn = document.createElement('div');
185
+ bn.id = '__error_banner';
186
+ document.body.appendChild(bn);
187
+ bn.addEventListener('click', function(e) {
188
+ if (e.target && e.target.className === '__close') { bn.remove(); return; }
189
+ bn.classList.toggle('__expanded');
190
+ });
191
+ }
192
+ bn.innerHTML = '<span class="__close" title="Dismiss">\\u00d7</span>'
193
+ + '<b>\\u26a0 Latest deploy failed</b>'
194
+ + '<span>Running previous version. Click for details.</span>'
195
+ + '<pre>' + __safe(message) + '</pre>';
196
+ }
197
+ function __clearErrorOverlay() {
198
+ const ov = document.getElementById('__error_overlay');
199
+ if (ov) ov.remove();
200
+ const bn = document.getElementById('__error_banner');
201
+ if (bn) bn.remove();
202
+ const root = document.getElementById('root');
203
+ if (root) root.style.display = '';
204
+ }
205
+ window.__NR = {
206
+ _ws: null,
207
+ _listeners: new Set(),
208
+ _lastData: null,
209
+ _ignoreRecovery: false,
210
+ _retries: 0,
211
+ _wasConnected: false,
212
+ _version: null,
213
+ _portalClient: null,
214
+ // Set on building/error WS frames. Next version with real hash reloads,
215
+ // independent of hash diff (build may produce same hash again).
216
+ _buildErrorActive: false,
217
+ // Pending runtime error message captured before WS opened.
218
+ // Flushed in onopen so node status can go red even when the
219
+ // exception fires synchronously during initial bundle execution.
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
+ },
231
+ _user: ${user ? escScript(JSON.stringify(user)) : "null"},
232
+
233
+ connect() {
234
+ const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
235
+ const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
236
+ this._ws = ws;
237
+ const s = document.getElementById('__cs');
238
+
239
+ ws.onopen = () => {
240
+ if (s) { s.textContent = 'fromcubes • connected'; s.className = 'ok'; }
241
+ this._retries = 0;
242
+ this._wasConnected = true;
243
+ const es = document.getElementById('__err_status');
244
+ if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
245
+ if (this._pendingRuntimeError) {
246
+ try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
247
+ this._pendingRuntimeError = null;
248
+ }
249
+ };
250
+
251
+ ws.onmessage = (e) => {
252
+ try {
253
+ const m = JSON.parse(e.data);
254
+ if (m.type === 'hello') {
255
+ this._portalClient = m.portalClient;
256
+ }
257
+ if (m.type === 'version') {
258
+ // Empty hash = server still in building/error state, ignore (overlay stays).
259
+ if (!m.hash) return;
260
+ // Reload when server was in build-error/building (recover regardless of
261
+ // hash) OR when hash differs from prior known hash (deploy detected).
262
+ // Do NOT reload merely because an overlay is in the DOM: a runtime
263
+ // exception caught locally renders the same overlay node and would
264
+ // otherwise loop reload to runtime-error to reload forever.
265
+ if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
266
+ this._reload();
267
+ return;
268
+ }
269
+ this._version = m.hash;
270
+ }
271
+ if (m.type === 'building') {
272
+ this._buildErrorActive = true;
273
+ document.getElementById('root').style.display = 'none';
274
+ const eo = document.getElementById('__error_overlay');
275
+ if (eo) eo.remove();
276
+ if (!document.getElementById('__building_overlay')) {
277
+ const ov = document.createElement('div');
278
+ ov.id = '__building_overlay';
279
+ 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';
280
+ ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
281
+ + '<div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div>'
282
+ + '<style>@keyframes __sp{to{transform:rotate(360deg)}}</style>';
283
+ document.body.appendChild(ov);
284
+ }
285
+ }
286
+ if (m.type === 'error') {
287
+ this._buildErrorActive = true;
288
+ if (m.degraded) {
289
+ __renderErrorBanner(m.message);
290
+ } else {
291
+ __renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
292
+ const es2 = document.getElementById('__err_status');
293
+ if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
294
+ }
295
+ }
296
+ if (m.type === 'data') {
297
+ this._lastData = m.payload;
298
+ this._listeners.forEach(fn => fn(m.payload));
299
+ }
300
+ if (m.type === 'recovery') {
301
+ // Cached last broadcast at connect time. Seeded into
302
+ // _lastData unless the page opted out via
303
+ // useNodeRed({ ignoreRecovery: true }).
304
+ if (this._ignoreRecovery) return;
305
+ this._lastData = m.payload;
306
+ this._listeners.forEach(fn => fn(m.payload));
307
+ }
308
+ } catch (err) { console.error('WS parse', err); }
309
+ };
310
+
311
+ ws.onclose = () => {
312
+ if (s) { s.textContent = 'fromcubes • disconnected'; s.className = 'err'; }
313
+ this._ws = null;
314
+ const es = document.getElementById('__err_status');
315
+ if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
316
+ const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
317
+ this._retries++;
318
+ setTimeout(() => this.connect(), delay);
319
+ };
320
+
321
+ ws.onerror = () => ws.close();
322
+ },
323
+
324
+ subscribe(fn) {
325
+ this._listeners.add(fn);
326
+ if (this._lastData !== null) fn(this._lastData);
327
+ return () => this._listeners.delete(fn);
328
+ },
329
+
330
+ send(payload, topic) {
331
+ if (this._ws && this._ws.readyState === 1)
332
+ this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
333
+ }
334
+ };
335
+ window.__NR.connect();
336
+ <\/script>
337
+ <script>
338
+ try { ${escScript(transpiledJs)}
339
+ } catch(__e) {
340
+ const __m = (__e && (__e.stack || __e.message)) || String(__e);
341
+ __renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
342
+ // Report back to server so node status goes red. WS may not be open
343
+ // yet (sync throw during initial bundle); queue until onopen.
344
+ try {
345
+ if (window.__NR && window.__NR._ws && window.__NR._ws.readyState === 1) {
346
+ window.__NR._ws.send(JSON.stringify({ type: 'runtime_error', message: __m }));
347
+ } else if (window.__NR) {
348
+ window.__NR._pendingRuntimeError = __m;
349
+ }
350
+ } catch(_) {}
351
+ }
352
+ <\/script>
353
+ </body>
354
+ </html>`;
355
+ }
356
+
357
+ /**
358
+ * Build a minimal error page served when a portal build fails AND no
359
+ * previous good build exists for degraded-mode fallback. Includes:
360
+ *
361
+ * - The shared error overlay markup populated with the error message.
362
+ * - A WS reconnect loop that reloads the page on the next `version` frame.
363
+ * - An HTTP HEAD polling loop with linear backoff (1.5×, capped at 10 s)
364
+ * so the page also recovers when Node-RED itself was restarted (WS dies).
365
+ *
366
+ * @param {string} title Browser title (gets ` — Error` suffix).
367
+ * @param {string} error Multi-line error message rendered inside the overlay.
368
+ * @param {string} wsPath WebSocket URL path; pass empty string to skip WS wiring.
369
+ * @returns {string} Complete HTML5 document.
370
+ */
371
+ function buildErrorPage(title, error, wsPath) {
372
+ return `<!DOCTYPE html>
373
+ <html lang="en">
374
+ <head>
375
+ <meta charset="UTF-8">
376
+ <title>${esc(title)} — Error</title>
377
+ <style>${ERROR_OVERLAY_CSS}</style>
378
+ </head>
379
+ <body>
380
+ <div id="__error_overlay">${errorOverlayInnerHtml({
381
+ title: "Build Error",
382
+ hint: DEFAULT_HINT,
383
+ message: error,
384
+ statusLine: "Waiting for redeploy…",
385
+ statusOk: false,
386
+ })}</div>
387
+ <script>
388
+ (function() {
389
+ const st = document.getElementById('__err_status');
390
+ const pre = document.querySelector('#__error_overlay pre');
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
+ }
402
+ function setStatus(text, ok) {
403
+ if (!st) return;
404
+ st.textContent = text;
405
+ st.className = '__status' + (ok ? '' : ' __off');
406
+ }
407
+ function connect() {
408
+ const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
409
+ const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
410
+ ws.onopen = function() {
411
+ retries = 0;
412
+ setStatus('Connected \\u2014 will reload on redeploy', true);
413
+ };
414
+ ws.onmessage = function(e) {
415
+ try {
416
+ const m = JSON.parse(e.data);
417
+ if (m.type === 'version' && m.hash) __reload();
418
+ if (m.type === 'error' && pre) pre.textContent = m.message;
419
+ } catch(_) {}
420
+ };
421
+ ws.onclose = function() {
422
+ setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
423
+ const delay = Math.min(500 * Math.pow(2, retries), 8000);
424
+ retries++;
425
+ setTimeout(connect, delay);
426
+ };
427
+ ws.onerror = function() { ws.close(); };
428
+ }
429
+ ${wsPath ? "connect();" : ""}
430
+
431
+ /*
432
+ * Recovery polling loop.
433
+ *
434
+ * Why polling alongside the WebSocket? The WS reload pathway only
435
+ * fires when the next deploy actually broadcasts a "version" frame
436
+ * with a non-empty hash. If Node-RED is restarting (process down →
437
+ * up), the WS dies and reconnects do not help — we need an HTTP
438
+ * probe to notice when the runtime returns.
439
+ *
440
+ * Why backoff? A page sitting on a long-broken build would otherwise
441
+ * burn one HEAD request per 3 s indefinitely — across many open
442
+ * tabs that adds up. Linear-ish backoff (1.5×) caps cost while
443
+ * staying responsive to a freshly-recovered runtime.
444
+ *
445
+ * Cap at 10 s so the worst-case wait between successful redeploy
446
+ * and page recovery is bounded.
447
+ */
448
+ var __pollDelay = 3000;
449
+ var __pollIv = null;
450
+ function __schedulePoll() {
451
+ __pollIv = setTimeout(function poll() {
452
+ fetch(location.href, { method: 'HEAD', cache: 'no-store' })
453
+ .then(function(r) {
454
+ if (r.ok) {
455
+ // Stop the loop BEFORE reload — without this, a slow
456
+ // teardown could leave another setTimeout firing during
457
+ // the unload, briefly racing with the new page.
458
+ if (__pollIv) { clearTimeout(__pollIv); __pollIv = null; }
459
+ location.reload();
460
+ return;
461
+ }
462
+ // 4xx/5xx still means the server is up — keep delay short.
463
+ __pollDelay = 3000;
464
+ __schedulePoll();
465
+ })
466
+ .catch(function() {
467
+ // Network error → server probably down. Grow backoff.
468
+ __pollDelay = Math.min(Math.round(__pollDelay * 1.5), 10000);
469
+ __schedulePoll();
470
+ });
471
+ }, __pollDelay);
472
+ }
473
+ __schedulePoll();
474
+ })();
475
+ <\/script>
476
+ </body>
477
+ </html>`;
478
+ }
479
+
480
+ module.exports = { buildPage, buildErrorPage };