@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.
- package/README.md +244 -76
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/assets.js +342 -0
- package/nodes/lib/helpers.js +724 -0
- package/nodes/lib/hooks.js +120 -0
- package/nodes/lib/page-builder.js +480 -0
- package/nodes/lib/portal-page-route.js +133 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +80 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +1862 -287
- package/nodes/portal-react.js +1563 -471
- package/package.json +42 -12
- package/examples/sensor-portal-flow.json +0 -71
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
|
@@ -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, "&")
|
|
35
|
+
.replace(/</g, "<")
|
|
36
|
+
.replace(/>/g, ">")
|
|
37
|
+
.replace(/"/g, """);
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
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 };
|