@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.
- package/README.md +91 -9
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +103 -0
- package/examples/PixiJS Sprites.json +118 -0
- package/examples/Sensor Portal.json +97 -0
- package/examples/Shared Components.json +78 -0
- package/examples/Three.js Scene.json +122 -0
- package/examples/Utility Hooks.json +96 -0
- package/examples/WebGPU Shader.json +114 -0
- package/nodes/lib/assets.js +141 -11
- package/nodes/lib/helpers.js +308 -12
- package/nodes/lib/hooks.js +38 -0
- package/nodes/lib/page-builder.js +145 -32
- package/nodes/lib/router.js +25 -1
- package/nodes/portal-react.html +826 -218
- package/nodes/portal-react.js +1193 -180
- package/package.json +20 -5
- package/examples/001-shared-components-flow.json +0 -68
- package/examples/002-sensor-portal-flow.json +0 -71
- package/examples/003-chart-portal-flow.json +0 -93
- package/examples/004-d3-poland-flow.json +0 -80
- package/examples/005-threejs-portal-flow.json +0 -87
- package/examples/006-pixi-portal-flow.json +0 -86
- package/examples/007-webgpu-tsl-flow.json +0 -85
|
@@ -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
|
-
*
|
|
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, "&")
|
|
@@ -10,6 +37,14 @@ function esc(s) {
|
|
|
10
37
|
.replace(/"/g, """);
|
|
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
|
-
|
|
60
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
101
161
|
function __renderErrorOverlay(title, message, hint) {
|
|
102
|
-
|
|
162
|
+
const root = document.getElementById('root');
|
|
103
163
|
if (root) root.style.display = 'none';
|
|
104
|
-
|
|
164
|
+
const bo = document.getElementById('__building_overlay');
|
|
105
165
|
if (bo) bo.remove();
|
|
106
|
-
|
|
166
|
+
const bn = document.getElementById('__error_banner');
|
|
107
167
|
if (bn) bn.remove();
|
|
108
|
-
|
|
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
|
-
|
|
176
|
+
const ov = document.getElementById('__error_overlay');
|
|
117
177
|
if (ov) ov.remove();
|
|
118
|
-
|
|
178
|
+
const bo = document.getElementById('__building_overlay');
|
|
119
179
|
if (bo) bo.remove();
|
|
120
|
-
|
|
180
|
+
const root = document.getElementById('root');
|
|
121
181
|
if (root) root.style.display = '';
|
|
122
|
-
|
|
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
|
-
|
|
198
|
+
const ov = document.getElementById('__error_overlay');
|
|
139
199
|
if (ov) ov.remove();
|
|
140
|
-
|
|
200
|
+
const bn = document.getElementById('__error_banner');
|
|
141
201
|
if (bn) bn.remove();
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
+
const eo = document.getElementById('__error_overlay');
|
|
205
265
|
if (eo) eo.remove();
|
|
206
266
|
if (!document.getElementById('__building_overlay')) {
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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>
|
package/nodes/lib/router.js
CHANGED
|
@@ -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;
|