@aaqu/fromcubes-react 0.0.0-stage → 0.2.0

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.
Files changed (37) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +435 -2
  3. package/THIRD-PARTY-NOTICES.md +164 -0
  4. package/examples/D3 Poland Map.json +90 -0
  5. package/examples/Live Chart.json +101 -0
  6. package/examples/PixiJS Sprites.json +114 -0
  7. package/examples/Sensor Portal.json +93 -0
  8. package/examples/Shared Components.json +68 -0
  9. package/examples/Three.js Scene.json +118 -0
  10. package/examples/Utility Hooks.json +94 -0
  11. package/examples/WebGPU Shader.json +110 -0
  12. package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
  13. package/nodes/editor/ark-pixel.woff2 +0 -0
  14. package/nodes/editor/file-tree.js +403 -0
  15. package/nodes/editor/format.js +182 -0
  16. package/nodes/editor/icons.svg +46 -0
  17. package/nodes/editor/mark.svg +5 -0
  18. package/nodes/editor/provider-settings.js +192 -0
  19. package/nodes/editor/workspace.css +660 -0
  20. package/nodes/editor/workspace.js +1565 -0
  21. package/nodes/icons/fromcubes-component.svg +27 -0
  22. package/nodes/icons/fromcubes-react.svg +25 -0
  23. package/nodes/icons/fromcubes-utility.svg +21 -0
  24. package/nodes/lib/admin-api.js +238 -0
  25. package/nodes/lib/admin-security.js +161 -0
  26. package/nodes/lib/api.js +29 -0
  27. package/nodes/lib/helpers.js +990 -0
  28. package/nodes/lib/hooks.js +145 -0
  29. package/nodes/lib/page-builder.js +499 -0
  30. package/nodes/lib/portal-page-route.js +131 -0
  31. package/nodes/lib/providers.js +581 -0
  32. package/nodes/lib/registry-nodes.js +331 -0
  33. package/nodes/lib/router.js +103 -0
  34. package/nodes/lib/ws-heartbeat.js +58 -0
  35. package/nodes/portal-react.html +2539 -0
  36. package/nodes/portal-react.js +1652 -0
  37. package/package.json +73 -4
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Plugin hook system for @aaqu/fromcubes-react.
3
+ *
4
+ * Plugins register with Node-RED via:
5
+ * RED.plugins.registerPlugin("my-plugin", {
6
+ * type: "fromcubes-hooks",
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
+ * The type was "fromcubes-portal-react" before the packages were renamed;
23
+ * plugins still registering under it keep working, with a warning logged
24
+ * once per plugin. Drop LEGACY_PLUGIN_TYPE in the next major version.
25
+ */
26
+
27
+ const PLUGIN_TYPE = "fromcubes-hooks";
28
+ const LEGACY_PLUGIN_TYPE = "fromcubes-portal-react";
29
+
30
+ /**
31
+ * Build a plugin-hook dispatcher bound to `RED`.
32
+ *
33
+ * @param {Object} RED Node-RED runtime.
34
+ * @returns {{ allow: Function, transform: Function, hasHook: Function, PLUGIN_TYPE: string }}
35
+ */
36
+ module.exports = function (RED) {
37
+ const warnedLegacy = new Set();
38
+
39
+ /**
40
+ * Plugins of `type`, never throwing.
41
+ * @param {string} type
42
+ * @returns {Array<Object>}
43
+ */
44
+ function byType(type) {
45
+ try {
46
+ return RED.plugins.getByType(type) || [];
47
+ } catch (_) {
48
+ return [];
49
+ }
50
+ }
51
+ /**
52
+ * Collect hook functions for a given name across all registered plugins
53
+ * of type `PLUGIN_TYPE`. Returns an empty list when no plugins or when
54
+ * `RED.plugins` is unavailable.
55
+ *
56
+ * @param {string} name
57
+ * @returns {Array<{ fn: Function, id: string }>}
58
+ */
59
+ function getHooks(name) {
60
+ const legacy = byType(LEGACY_PLUGIN_TYPE);
61
+ for (const p of legacy) {
62
+ const id = (p && (p.id || p.name)) || "?";
63
+ if (warnedLegacy.has(id)) continue;
64
+ warnedLegacy.add(id);
65
+ RED.log.warn(
66
+ `[portal-react] plugin "${id}" uses the old hook type "${LEGACY_PLUGIN_TYPE}"; ` +
67
+ `register it as "${PLUGIN_TYPE}" — the old type goes away in the next major version`,
68
+ );
69
+ }
70
+ const plugins = [...byType(PLUGIN_TYPE), ...legacy];
71
+ const out = [];
72
+ for (const p of plugins) {
73
+ const fn = p && p.hooks && p.hooks[name];
74
+ if (typeof fn === "function") out.push({ fn, id: p.id || p.name || "?" });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * AND-fold across all registered hook implementations. A hook returning
81
+ * `false` (or throwing) blocks the action. No hooks → allowed.
82
+ *
83
+ * @param {string} name
84
+ * @param {...unknown} args
85
+ * @returns {boolean}
86
+ */
87
+ function allow(name, ...args) {
88
+ const hooks = getHooks(name);
89
+ if (hooks.length === 0) return true;
90
+ for (const h of hooks) {
91
+ let result;
92
+ try {
93
+ result = h.fn(...args);
94
+ } catch (e) {
95
+ RED.log.error(
96
+ `[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
97
+ );
98
+ return false;
99
+ }
100
+ if (result === false) return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Sequentially apply each hook to the running value. A hook returning
107
+ * `undefined` keeps the current value; returning `null` is treated as
108
+ * "drop this message" by callers (caller checks for null after transform).
109
+ * Throws are logged via `RED.log.error` and the hook is skipped.
110
+ *
111
+ * @template T
112
+ * @param {string} name
113
+ * @param {T} msg
114
+ * @param {...unknown} args
115
+ * @returns {T|null}
116
+ */
117
+ function transform(name, msg, ...args) {
118
+ const hooks = getHooks(name);
119
+ let current = msg;
120
+ for (const h of hooks) {
121
+ try {
122
+ const next = h.fn(current, ...args);
123
+ if (next !== undefined) current = next;
124
+ } catch (e) {
125
+ RED.log.error(
126
+ `[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
127
+ );
128
+ }
129
+ }
130
+ return current;
131
+ }
132
+
133
+ /**
134
+ * @param {string} name
135
+ * @returns {boolean} True when at least one plugin registers the hook.
136
+ */
137
+ function hasHook(name) {
138
+ return getHooks(name).length > 0;
139
+ }
140
+
141
+ return { allow, transform, hasHook, PLUGIN_TYPE };
142
+ };
143
+
144
+ module.exports.PLUGIN_TYPE = PLUGIN_TYPE;
145
+ module.exports.LEGACY_PLUGIN_TYPE = LEGACY_PLUGIN_TYPE;
@@ -0,0 +1,499 @@
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} nodeRoot `RED.settings.httpNodeRoot` (no trailing slash).
132
+ * @returns {string} Complete HTML5 document.
133
+ */
134
+ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, nodeRoot) {
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="${nodeRoot}/fromcubes/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
+ _authRequired: false,
210
+ _authListeners: new Set(),
211
+ _retries: 0,
212
+ _wasConnected: false,
213
+ _version: null,
214
+ _portalClient: null,
215
+ // Set on building/error WS frames. Next version with real hash reloads,
216
+ // independent of hash diff (build may produce same hash again).
217
+ _buildErrorActive: false,
218
+ // Pending runtime error message captured before WS opened.
219
+ // Flushed in onopen so node status can go red even when the
220
+ // exception fires synchronously during initial bundle execution.
221
+ _pendingRuntimeError: null,
222
+ // Page-load timestamp + guarded reload: never reload within 2s of
223
+ // load, so a briefly-inconsistent server (error page served while a
224
+ // "ready" version hash is advertised over WS) cannot drive a tight
225
+ // reload loop. Caps recovery to one reload / 2s.
226
+ _loadT: Date.now(),
227
+ _reload() {
228
+ const wait = 2000 - (Date.now() - this._loadT);
229
+ if (wait > 0) setTimeout(() => location.reload(), wait);
230
+ else location.reload();
231
+ },
232
+ _user: ${user ? escScript(JSON.stringify(user)) : "null"},
233
+
234
+ connect() {
235
+ const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
236
+ const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
237
+ this._ws = ws;
238
+ const s = document.getElementById('__cs');
239
+
240
+ ws.onopen = () => {
241
+ if (s) { s.textContent = 'fromcubes • connected'; s.className = 'ok'; }
242
+ this._retries = 0;
243
+ this._wasConnected = true;
244
+ const es = document.getElementById('__err_status');
245
+ if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
246
+ if (this._pendingRuntimeError) {
247
+ try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
248
+ this._pendingRuntimeError = null;
249
+ }
250
+ };
251
+
252
+ ws.onmessage = (e) => {
253
+ try {
254
+ const m = JSON.parse(e.data);
255
+ if (m.type === 'hello') {
256
+ this._portalClient = m.portalClient;
257
+ // Reset per-connection auth state: the auth_required frame
258
+ // (if still applicable) arrives right after hello, so a
259
+ // reconnect against a node whose authOnly was switched off
260
+ // does not keep a stale login hint on screen.
261
+ if (this._authRequired) {
262
+ this._authRequired = false;
263
+ this._authListeners.forEach(fn => fn(false));
264
+ }
265
+ }
266
+ if (m.type === 'reload') {
267
+ // Identity provider changed on the server: the user baked
268
+ // into this page is stale. The server closes the socket
269
+ // right after this frame regardless, so a client that
270
+ // ignores it keeps no identity it should not have.
271
+ location.reload();
272
+ return;
273
+ }
274
+ if (m.type === 'auth_required') {
275
+ this._authRequired = true;
276
+ this._authListeners.forEach(fn => fn(true));
277
+ }
278
+ if (m.type === 'version') {
279
+ // Empty hash = server still in building/error state, ignore (overlay stays).
280
+ if (!m.hash) return;
281
+ // Reload when server was in build-error/building (recover regardless of
282
+ // hash) OR when hash differs from prior known hash (deploy detected).
283
+ // Do NOT reload merely because an overlay is in the DOM: a runtime
284
+ // exception caught locally renders the same overlay node and would
285
+ // otherwise loop reload to runtime-error to reload forever.
286
+ if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
287
+ this._reload();
288
+ return;
289
+ }
290
+ this._version = m.hash;
291
+ }
292
+ if (m.type === 'building') {
293
+ this._buildErrorActive = true;
294
+ document.getElementById('root').style.display = 'none';
295
+ const eo = document.getElementById('__error_overlay');
296
+ if (eo) eo.remove();
297
+ if (!document.getElementById('__building_overlay')) {
298
+ const ov = document.createElement('div');
299
+ ov.id = '__building_overlay';
300
+ 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';
301
+ ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
302
+ + '<div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div>'
303
+ + '<style>@keyframes __sp{to{transform:rotate(360deg)}}</style>';
304
+ document.body.appendChild(ov);
305
+ }
306
+ }
307
+ if (m.type === 'error') {
308
+ this._buildErrorActive = true;
309
+ if (m.degraded) {
310
+ __renderErrorBanner(m.message);
311
+ } else {
312
+ __renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
313
+ const es2 = document.getElementById('__err_status');
314
+ if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
315
+ }
316
+ }
317
+ if (m.type === 'data') {
318
+ this._lastData = m.payload;
319
+ this._listeners.forEach(fn => fn(m.payload));
320
+ }
321
+ } catch (err) { console.error('WS parse', err); }
322
+ };
323
+
324
+ ws.onclose = () => {
325
+ if (s) { s.textContent = 'fromcubes • disconnected'; s.className = 'err'; }
326
+ this._ws = null;
327
+ const es = document.getElementById('__err_status');
328
+ if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
329
+ const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
330
+ this._retries++;
331
+ setTimeout(() => this.connect(), delay);
332
+ };
333
+
334
+ ws.onerror = () => ws.close();
335
+ },
336
+
337
+ subscribe(fn) {
338
+ this._listeners.add(fn);
339
+ if (this._lastData !== null) fn(this._lastData);
340
+ return () => this._listeners.delete(fn);
341
+ },
342
+
343
+ subscribeAuth(fn) {
344
+ this._authListeners.add(fn);
345
+ if (this._authRequired) fn(true);
346
+ return () => this._authListeners.delete(fn);
347
+ },
348
+
349
+ send(payload, topic) {
350
+ if (this._ws && this._ws.readyState === 1)
351
+ this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
352
+ }
353
+ };
354
+ window.__NR.connect();
355
+ <\/script>
356
+ <script>
357
+ try { ${escScript(transpiledJs)}
358
+ } catch(__e) {
359
+ const __m = (__e && (__e.stack || __e.message)) || String(__e);
360
+ __renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
361
+ // Report back to server so node status goes red. WS may not be open
362
+ // yet (sync throw during initial bundle); queue until onopen.
363
+ try {
364
+ if (window.__NR && window.__NR._ws && window.__NR._ws.readyState === 1) {
365
+ window.__NR._ws.send(JSON.stringify({ type: 'runtime_error', message: __m }));
366
+ } else if (window.__NR) {
367
+ window.__NR._pendingRuntimeError = __m;
368
+ }
369
+ } catch(_) {}
370
+ }
371
+ <\/script>
372
+ </body>
373
+ </html>`;
374
+ }
375
+
376
+ /**
377
+ * Build a minimal error page served when a portal build fails AND no
378
+ * previous good build exists for degraded-mode fallback. Includes:
379
+ *
380
+ * - The shared error overlay markup populated with the error message.
381
+ * - A WS reconnect loop that reloads the page on the next `version` frame.
382
+ * - An HTTP HEAD polling loop with linear backoff (1.5×, capped at 10 s)
383
+ * so the page also recovers when Node-RED itself was restarted (WS dies).
384
+ *
385
+ * @param {string} title Browser title (gets ` — Error` suffix).
386
+ * @param {string} error Multi-line error message rendered inside the overlay.
387
+ * @param {string} wsPath WebSocket URL path; pass empty string to skip WS wiring.
388
+ * @returns {string} Complete HTML5 document.
389
+ */
390
+ function buildErrorPage(title, error, wsPath) {
391
+ return `<!DOCTYPE html>
392
+ <html lang="en">
393
+ <head>
394
+ <meta charset="UTF-8">
395
+ <title>${esc(title)} — Error</title>
396
+ <style>${ERROR_OVERLAY_CSS}</style>
397
+ </head>
398
+ <body>
399
+ <div id="__error_overlay">${errorOverlayInnerHtml({
400
+ title: "Build Error",
401
+ hint: DEFAULT_HINT,
402
+ message: error,
403
+ statusLine: "Waiting for redeploy…",
404
+ statusOk: false,
405
+ })}</div>
406
+ <script>
407
+ (function() {
408
+ const st = document.getElementById('__err_status');
409
+ const pre = document.querySelector('#__error_overlay pre');
410
+ let retries = 0;
411
+ // Reload guard: never reload within 2s of this page loading. If the
412
+ // server is briefly inconsistent (serves this error page yet
413
+ // advertises a "ready" version hash over WS), an unguarded reload
414
+ // turns into a tight loop. The guard caps it to one reload / 2s.
415
+ const __loadT = Date.now();
416
+ function __reload() {
417
+ const wait = 2000 - (Date.now() - __loadT);
418
+ if (wait > 0) { setTimeout(function(){ location.reload(); }, wait); }
419
+ else { location.reload(); }
420
+ }
421
+ function setStatus(text, ok) {
422
+ if (!st) return;
423
+ st.textContent = text;
424
+ st.className = '__status' + (ok ? '' : ' __off');
425
+ }
426
+ function connect() {
427
+ const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
428
+ const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
429
+ ws.onopen = function() {
430
+ retries = 0;
431
+ setStatus('Connected \\u2014 will reload on redeploy', true);
432
+ };
433
+ ws.onmessage = function(e) {
434
+ try {
435
+ const m = JSON.parse(e.data);
436
+ if (m.type === 'version' && m.hash) __reload();
437
+ if (m.type === 'error' && pre) pre.textContent = m.message;
438
+ } catch(_) {}
439
+ };
440
+ ws.onclose = function() {
441
+ setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
442
+ const delay = Math.min(500 * Math.pow(2, retries), 8000);
443
+ retries++;
444
+ setTimeout(connect, delay);
445
+ };
446
+ ws.onerror = function() { ws.close(); };
447
+ }
448
+ ${wsPath ? "connect();" : ""}
449
+
450
+ /*
451
+ * Recovery polling loop.
452
+ *
453
+ * Why polling alongside the WebSocket? The WS reload pathway only
454
+ * fires when the next deploy actually broadcasts a "version" frame
455
+ * with a non-empty hash. If Node-RED is restarting (process down →
456
+ * up), the WS dies and reconnects do not help — we need an HTTP
457
+ * probe to notice when the runtime returns.
458
+ *
459
+ * Why backoff? A page sitting on a long-broken build would otherwise
460
+ * burn one HEAD request per 3 s indefinitely — across many open
461
+ * tabs that adds up. Linear-ish backoff (1.5×) caps cost while
462
+ * staying responsive to a freshly-recovered runtime.
463
+ *
464
+ * Cap at 10 s so the worst-case wait between successful redeploy
465
+ * and page recovery is bounded.
466
+ */
467
+ var __pollDelay = 3000;
468
+ var __pollIv = null;
469
+ function __schedulePoll() {
470
+ __pollIv = setTimeout(function poll() {
471
+ fetch(location.href, { method: 'HEAD', cache: 'no-store' })
472
+ .then(function(r) {
473
+ if (r.ok) {
474
+ // Stop the loop BEFORE reload — without this, a slow
475
+ // teardown could leave another setTimeout firing during
476
+ // the unload, briefly racing with the new page.
477
+ if (__pollIv) { clearTimeout(__pollIv); __pollIv = null; }
478
+ location.reload();
479
+ return;
480
+ }
481
+ // 4xx/5xx still means the server is up — keep delay short.
482
+ __pollDelay = 3000;
483
+ __schedulePoll();
484
+ })
485
+ .catch(function() {
486
+ // Network error → server probably down. Grow backoff.
487
+ __pollDelay = Math.min(Math.round(__pollDelay * 1.5), 10000);
488
+ __schedulePoll();
489
+ });
490
+ }, __pollDelay);
491
+ }
492
+ __schedulePoll();
493
+ })();
494
+ <\/script>
495
+ </body>
496
+ </html>`;
497
+ }
498
+
499
+ module.exports = { buildPage, buildErrorPage };