@aaqu/fromcubes-portal-react 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +26 -0
- package/examples/008-utility-debounce-flow.json +72 -0
- package/nodes/lib/helpers.js +34 -28
- package/nodes/lib/page-builder.js +175 -47
- package/nodes/portal-react.html +582 -210
- package/nodes/portal-react.js +603 -60
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -81,6 +81,29 @@ The opt-out is page-wide — the strictest call wins. If any component on the pa
|
|
|
81
81
|
|
|
82
82
|
There is also a config node, **fc-portal-component**, that lets you define reusable React components once and reference them by name from any portal-react node. Referenced components (and their transitive dependencies) are injected at transpile time, so unused ones add nothing to the bundle.
|
|
83
83
|
|
|
84
|
+
For shared **non-component** code (helpers, custom hooks, constants), use **fc-portal-utility** — a sibling config node. Unlike component nodes (which export exactly one symbol via an IIFE wrapper), a utility node is injected raw at top level so it can declare any number of `function` / `const` / `let` / `class` symbols. Selective inclusion: a utility node lands in a portal's bundle only when the portal's JSX or any of its referenced library components mentions at least one of the symbols declared in that utility.
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
// fc-portal-utility, Module name = mathHelpers
|
|
88
|
+
const PI2 = Math.PI * 2;
|
|
89
|
+
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
|
90
|
+
function useDebounce(value, ms = 300) {
|
|
91
|
+
const [v, setV] = React.useState(value);
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
const t = setTimeout(() => setV(value), ms);
|
|
94
|
+
return () => clearTimeout(t);
|
|
95
|
+
}, [value, ms]);
|
|
96
|
+
return v;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// portal-react, JSX tab
|
|
100
|
+
function App() {
|
|
101
|
+
const { data } = useNodeRed();
|
|
102
|
+
const slow = useDebounce(data?.value);
|
|
103
|
+
return <div>{clamp(slow ?? 0, 0, 100)}</div>;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
84
107
|
## Editor features
|
|
85
108
|
|
|
86
109
|
- **Monaco** with full JSX support and `useNodeRed()` type declarations
|
|
@@ -88,6 +111,8 @@ There is also a config node, **fc-portal-component**, that lets you define reusa
|
|
|
88
111
|
- **JSX tag completion** — type tag name, Tab to expand
|
|
89
112
|
- **Self-close collapse** — type `/` inside empty `<tag></tag>` to convert to `<tag />`
|
|
90
113
|
- **Component completion** — registry components + any PascalCase word
|
|
114
|
+
- **Utility-symbol completion** — top-level identifiers from any `fc-portal-utility` node, suggested in JS context
|
|
115
|
+
- **Components / Utilities dialogs** — buttons in the JSX tab; Components inserts `<Tag></Tag>`, Utilities expands to the symbols declared in each node and inserts the bare identifier on click
|
|
91
116
|
- **Portal Assets sidebar** — file manager for static assets (GLB, textures, fonts…)
|
|
92
117
|
|
|
93
118
|
## Multi-user / Multi-tenancy
|
|
@@ -177,6 +202,7 @@ Import `001-shared-components-flow.json` first — it provides shared UI compone
|
|
|
177
202
|
| `005-threejs-portal-flow.json` | `three` | 3D scene with Three.js |
|
|
178
203
|
| `006-pixi-portal-flow.json` | `pixi.js`, `@pixi/react` | Clickable bunny sprites with PixiJS |
|
|
179
204
|
| `007-webgpu-tsl-flow.json` | `three` | WebGPU renderer + TSL animated shaders |
|
|
205
|
+
| `008-utility-debounce-flow.json` | — | `fc-portal-utility` demo: `useDebounce` custom hook + `clamp` helper |
|
|
180
206
|
|
|
181
207
|
## Troubleshooting
|
|
182
208
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-util-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Utility: useDebounce",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "fc-util-tick",
|
|
10
|
+
"type": "inject",
|
|
11
|
+
"z": "fc-util-flow",
|
|
12
|
+
"name": "Fast tick (10Hz)",
|
|
13
|
+
"props": [{ "p": "payload" }],
|
|
14
|
+
"repeat": "0.1",
|
|
15
|
+
"payload": "",
|
|
16
|
+
"payloadType": "date",
|
|
17
|
+
"x": 160,
|
|
18
|
+
"y": 160,
|
|
19
|
+
"wires": [["fc-util-noisy"]]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "fc-util-noisy",
|
|
23
|
+
"type": "function",
|
|
24
|
+
"z": "fc-util-flow",
|
|
25
|
+
"name": "Noisy signal",
|
|
26
|
+
"func": "// Random walk between 0..120 with bursts of noise — exactly the sort of\n// fast-changing input where a debounced display is much more readable\n// than the raw value.\nconst prev = context.get('v') ?? 50;\nconst drift = (Math.random() - 0.5) * 8;\nconst noise = Math.random() < 0.15 ? (Math.random() - 0.5) * 30 : 0;\nconst v = Math.max(0, Math.min(120, prev + drift + noise));\ncontext.set('v', v);\nmsg.payload = { value: +v.toFixed(2), ts: Date.now() };\nreturn msg;",
|
|
27
|
+
"outputs": 1,
|
|
28
|
+
"x": 360,
|
|
29
|
+
"y": 160,
|
|
30
|
+
"wires": [["fc-util-portal"]]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "fc-util-helpers",
|
|
34
|
+
"type": "fc-portal-utility",
|
|
35
|
+
"z": "fc-util-flow",
|
|
36
|
+
"name": "math helpers + useDebounce",
|
|
37
|
+
"utilName": "mathHelpers",
|
|
38
|
+
"utilCode": "// Constants — bundled once, shared across portals that reference them.\nconst CLAMP_MIN = 0;\nconst CLAMP_MAX = 100;\n\n// Pure helper.\nfunction clamp(n, min, max) {\n return Math.max(min, Math.min(max, n));\n}\n\n// Custom React hook — name MUST start with `use` for React's rules of hooks.\n// This node groups a constant + a helper + a hook in one block; portals that\n// reference any of `clamp`, `useDebounce`, `CLAMP_MIN`, `CLAMP_MAX` pull in\n// the whole node.\nfunction useDebounce(value, ms = 300) {\n const [v, setV] = React.useState(value);\n React.useEffect(() => {\n const t = setTimeout(() => setV(value), ms);\n return () => clearTimeout(t);\n }, [value, ms]);\n return v;\n}",
|
|
39
|
+
"x": 160,
|
|
40
|
+
"y": 280,
|
|
41
|
+
"wires": []
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "fc-util-bar",
|
|
45
|
+
"type": "fc-portal-component",
|
|
46
|
+
"z": "fc-util-flow",
|
|
47
|
+
"name": "Bar",
|
|
48
|
+
"compName": "Bar",
|
|
49
|
+
"compCode": "function Bar({ value = 0, label = '' }) {\n // Component uses `clamp` from the utility node — selective inclusion picks\n // up the dependency through the components' code, not just the user JSX.\n const pct = clamp(value, CLAMP_MIN, CLAMP_MAX);\n const color = pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500';\n return (\n <div className=\"flex items-center gap-3\">\n <div className=\"w-24 text-xs text-zinc-400 uppercase tracking-wider\">{label}</div>\n <div className=\"flex-1 h-3 bg-zinc-800 rounded-full overflow-hidden\">\n <div className={`h-full ${color} transition-all duration-150`} style={{ width: pct + '%' }} />\n </div>\n <div className=\"w-14 text-right font-mono text-sm tabular-nums text-zinc-200\">\n {value.toFixed(1)}\n </div>\n </div>\n );\n}",
|
|
50
|
+
"compInputs": "value,label",
|
|
51
|
+
"compOutputs": "",
|
|
52
|
+
"x": 360,
|
|
53
|
+
"y": 280,
|
|
54
|
+
"wires": []
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "fc-util-portal",
|
|
58
|
+
"type": "portal-react",
|
|
59
|
+
"z": "fc-util-flow",
|
|
60
|
+
"name": "Debounce demo",
|
|
61
|
+
"subPath": "debounce",
|
|
62
|
+
"pageTitle": "useDebounce demo",
|
|
63
|
+
"customHead": "",
|
|
64
|
+
"componentCode": "function App() {\n const { data } = useNodeRed();\n const raw = data?.value ?? 0;\n\n // Live raw signal vs debounced views at three different windows.\n // `useDebounce` is declared in the fc-portal-utility node — no import.\n const slow100 = useDebounce(raw, 100);\n const slow400 = useDebounce(raw, 400);\n const slow1500 = useDebounce(raw, 1500);\n\n return (\n <div className=\"min-h-screen bg-zinc-950 text-zinc-100 p-8 font-sans\">\n <h1 className=\"text-2xl font-light text-amber-400 mb-2\">useDebounce</h1>\n <p className=\"text-sm text-zinc-500 mb-8\">\n Custom hook from <code className=\"text-amber-300\">fc-portal-utility</code>. Same noisy 10 Hz input fed through different debounce windows.\n </p>\n <div className=\"max-w-xl space-y-4\">\n <Bar value={raw} label=\"raw\" />\n <Bar value={slow100} label=\"100 ms\" />\n <Bar value={slow400} label=\"400 ms\" />\n <Bar value={slow1500} label=\"1500 ms\" />\n </div>\n </div>\n );\n}",
|
|
65
|
+
"libs": [],
|
|
66
|
+
"portalAuth": false,
|
|
67
|
+
"showWsStatus": false,
|
|
68
|
+
"x": 580,
|
|
69
|
+
"y": 160,
|
|
70
|
+
"wires": [[]]
|
|
71
|
+
}
|
|
72
|
+
]
|
package/nodes/lib/helpers.js
CHANGED
|
@@ -121,12 +121,42 @@ function removeRoute(router, path) {
|
|
|
121
121
|
);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function formatEsbuildError(e) {
|
|
125
|
+
return e.errors?.length
|
|
126
|
+
? e.errors
|
|
127
|
+
.map(
|
|
128
|
+
(err) =>
|
|
129
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
130
|
+
)
|
|
131
|
+
.join("\n")
|
|
132
|
+
: e.message;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fast JSX syntax validation (no bundling). Returns null on OK, error string on fail.
|
|
136
|
+
function quickCheckSyntax(jsx) {
|
|
137
|
+
if (!jsx || !jsx.trim()) return null;
|
|
138
|
+
try {
|
|
139
|
+
esbuild.transformSync(jsx, {
|
|
140
|
+
loader: "jsx",
|
|
141
|
+
jsx: "transform",
|
|
142
|
+
jsxFactory: "React.createElement",
|
|
143
|
+
jsxFragment: "React.Fragment",
|
|
144
|
+
logLevel: "silent",
|
|
145
|
+
});
|
|
146
|
+
return null;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return formatEsbuildError(e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
124
152
|
module.exports = function (RED) {
|
|
125
153
|
return createHelpers(RED);
|
|
126
154
|
};
|
|
127
155
|
|
|
128
156
|
module.exports.validateSubPath = validateSubPath;
|
|
129
157
|
module.exports.isSafeName = isSafeName;
|
|
158
|
+
module.exports.quickCheckSyntax = quickCheckSyntax;
|
|
159
|
+
module.exports.formatEsbuildError = formatEsbuildError;
|
|
130
160
|
|
|
131
161
|
function createHelpers(RED) {
|
|
132
162
|
// Package root — where react/react-dom live (this package's own node_modules)
|
|
@@ -220,25 +250,8 @@ function createHelpers(RED) {
|
|
|
220
250
|
|
|
221
251
|
function transpile(jsx) {
|
|
222
252
|
// Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
loader: "jsx",
|
|
226
|
-
jsx: "transform",
|
|
227
|
-
jsxFactory: "React.createElement",
|
|
228
|
-
jsxFragment: "React.Fragment",
|
|
229
|
-
logLevel: "silent",
|
|
230
|
-
});
|
|
231
|
-
} catch (e) {
|
|
232
|
-
const msg = e.errors?.length
|
|
233
|
-
? e.errors
|
|
234
|
-
.map(
|
|
235
|
-
(err) =>
|
|
236
|
-
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
237
|
-
)
|
|
238
|
-
.join("\n")
|
|
239
|
-
: e.message;
|
|
240
|
-
return { js: null, error: msg };
|
|
241
|
-
}
|
|
253
|
+
const syntaxErr = quickCheckSyntax(jsx);
|
|
254
|
+
if (syntaxErr) return { js: null, error: syntaxErr };
|
|
242
255
|
// Syntax OK — bundle with full resolution
|
|
243
256
|
try {
|
|
244
257
|
const buildResult = esbuild.buildSync({
|
|
@@ -275,21 +288,14 @@ function createHelpers(RED) {
|
|
|
275
288
|
error: null,
|
|
276
289
|
};
|
|
277
290
|
} catch (e) {
|
|
278
|
-
|
|
279
|
-
? e.errors
|
|
280
|
-
.map(
|
|
281
|
-
(err) =>
|
|
282
|
-
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
283
|
-
)
|
|
284
|
-
.join("\n")
|
|
285
|
-
: e.message;
|
|
286
|
-
return { js: null, error: msg };
|
|
291
|
+
return { js: null, error: formatEsbuildError(e) };
|
|
287
292
|
}
|
|
288
293
|
}
|
|
289
294
|
|
|
290
295
|
return {
|
|
291
296
|
hash,
|
|
292
297
|
transpile,
|
|
298
|
+
quickCheckSyntax,
|
|
293
299
|
generateCSS,
|
|
294
300
|
extractPortalUser,
|
|
295
301
|
removeRoute,
|
|
@@ -14,6 +14,63 @@ function escScript(s) {
|
|
|
14
14
|
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const ERROR_OVERLAY_CSS = `
|
|
18
|
+
#__error_overlay {
|
|
19
|
+
position: fixed; inset: 0; z-index: 99999;
|
|
20
|
+
background: #1a0000; color: #f87171;
|
|
21
|
+
font-family: monospace; padding: 40px;
|
|
22
|
+
overflow: auto;
|
|
23
|
+
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
|
|
24
|
+
}
|
|
25
|
+
#__error_overlay h1 { color: #ff4444; margin: 0 0 16px; font-size: 24px }
|
|
26
|
+
#__error_overlay p.__hint { color: #888; margin: 0 0 16px }
|
|
27
|
+
#__error_overlay pre {
|
|
28
|
+
background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px;
|
|
29
|
+
padding: 20px; color: #fca5a5;
|
|
30
|
+
max-width: 90vw; max-height: 60vh; overflow: auto;
|
|
31
|
+
white-space: pre-wrap; margin: 0;
|
|
32
|
+
}
|
|
33
|
+
#__error_overlay p.__status { color: #4ade80; font-size: 12px; margin: 24px 0 0 }
|
|
34
|
+
#__error_overlay p.__status.__off { color: #888 }
|
|
35
|
+
#__error_banner {
|
|
36
|
+
position: fixed; top: 8px; right: 8px; z-index: 99998;
|
|
37
|
+
max-width: 360px; padding: 8px 12px;
|
|
38
|
+
background: #1a0000; color: #fca5a5;
|
|
39
|
+
border: 1px solid #ff4444; border-radius: 6px;
|
|
40
|
+
font-family: monospace; font-size: 12px;
|
|
41
|
+
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
|
42
|
+
cursor: pointer; user-select: none;
|
|
43
|
+
}
|
|
44
|
+
#__error_banner b { color: #ff4444; display: block; margin-bottom: 2px }
|
|
45
|
+
#__error_banner.__expanded {
|
|
46
|
+
max-width: 70vw; max-height: 60vh; overflow: auto;
|
|
47
|
+
cursor: default;
|
|
48
|
+
}
|
|
49
|
+
#__error_banner pre {
|
|
50
|
+
margin: 8px 0 0; padding: 8px; background: #0a0a0a;
|
|
51
|
+
border-radius: 4px; white-space: pre-wrap; display: none;
|
|
52
|
+
}
|
|
53
|
+
#__error_banner.__expanded pre { display: block }
|
|
54
|
+
#__error_banner .__close {
|
|
55
|
+
float: right; padding: 0 4px; color: #888; cursor: pointer;
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
// Shared error overlay markup (HTML inside #__error_overlay).
|
|
60
|
+
// Used by buildPage (WS error frame, runtime try/catch) and buildErrorPage.
|
|
61
|
+
function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
|
|
62
|
+
return (
|
|
63
|
+
`<h1>${esc(title)}</h1>` +
|
|
64
|
+
(hint ? `<p class="__hint">${esc(hint)}</p>` : "") +
|
|
65
|
+
`<pre>${esc(message)}</pre>` +
|
|
66
|
+
(statusLine
|
|
67
|
+
? `<p class="__status${statusOk ? "" : " __off"}" id="__err_status">${esc(statusLine)}</p>`
|
|
68
|
+
: "")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
|
|
73
|
+
|
|
17
74
|
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
|
|
18
75
|
return `<!DOCTYPE html>
|
|
19
76
|
<html lang="en">
|
|
@@ -23,6 +80,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
23
80
|
<title>${esc(title)}</title>
|
|
24
81
|
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
25
82
|
${escScript(customHead)}
|
|
83
|
+
<style>${ERROR_OVERLAY_CSS}</style>
|
|
26
84
|
${showWsStatus ? `<style>
|
|
27
85
|
#__cs {
|
|
28
86
|
position: fixed; bottom: 6px; right: 6px;
|
|
@@ -39,6 +97,51 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
39
97
|
<div id="root"></div>
|
|
40
98
|
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
41
99
|
<script>
|
|
100
|
+
function __safe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
101
|
+
function __renderErrorOverlay(title, message, hint) {
|
|
102
|
+
const root = document.getElementById('root');
|
|
103
|
+
if (root) root.style.display = 'none';
|
|
104
|
+
const bo = document.getElementById('__building_overlay');
|
|
105
|
+
if (bo) bo.remove();
|
|
106
|
+
const bn = document.getElementById('__error_banner');
|
|
107
|
+
if (bn) bn.remove();
|
|
108
|
+
let ov = document.getElementById('__error_overlay');
|
|
109
|
+
if (!ov) { ov = document.createElement('div'); ov.id = '__error_overlay'; document.body.appendChild(ov); }
|
|
110
|
+
ov.innerHTML = '<h1>' + __safe(title) + '</h1>'
|
|
111
|
+
+ (hint ? '<p class="__hint">' + __safe(hint) + '</p>' : '')
|
|
112
|
+
+ '<pre>' + __safe(message) + '</pre>'
|
|
113
|
+
+ '<p class="__status __off" id="__err_status">Waiting for redeploy\\u2026</p>';
|
|
114
|
+
}
|
|
115
|
+
function __renderErrorBanner(message) {
|
|
116
|
+
const ov = document.getElementById('__error_overlay');
|
|
117
|
+
if (ov) ov.remove();
|
|
118
|
+
const bo = document.getElementById('__building_overlay');
|
|
119
|
+
if (bo) bo.remove();
|
|
120
|
+
const root = document.getElementById('root');
|
|
121
|
+
if (root) root.style.display = '';
|
|
122
|
+
let bn = document.getElementById('__error_banner');
|
|
123
|
+
if (!bn) {
|
|
124
|
+
bn = document.createElement('div');
|
|
125
|
+
bn.id = '__error_banner';
|
|
126
|
+
document.body.appendChild(bn);
|
|
127
|
+
bn.addEventListener('click', function(e) {
|
|
128
|
+
if (e.target && e.target.className === '__close') { bn.remove(); return; }
|
|
129
|
+
bn.classList.toggle('__expanded');
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
bn.innerHTML = '<span class="__close" title="Dismiss">\\u00d7</span>'
|
|
133
|
+
+ '<b>\\u26a0 Latest deploy failed</b>'
|
|
134
|
+
+ '<span>Running previous version. Click for details.</span>'
|
|
135
|
+
+ '<pre>' + __safe(message) + '</pre>';
|
|
136
|
+
}
|
|
137
|
+
function __clearErrorOverlay() {
|
|
138
|
+
const ov = document.getElementById('__error_overlay');
|
|
139
|
+
if (ov) ov.remove();
|
|
140
|
+
const bn = document.getElementById('__error_banner');
|
|
141
|
+
if (bn) bn.remove();
|
|
142
|
+
const root = document.getElementById('root');
|
|
143
|
+
if (root) root.style.display = '';
|
|
144
|
+
}
|
|
42
145
|
window.__NR = {
|
|
43
146
|
_ws: null,
|
|
44
147
|
_listeners: new Set(),
|
|
@@ -48,6 +151,13 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
48
151
|
_wasConnected: false,
|
|
49
152
|
_version: null,
|
|
50
153
|
_portalClient: null,
|
|
154
|
+
// Set on building/error WS frames. Next version with real hash reloads,
|
|
155
|
+
// independent of hash diff (build may produce same hash again).
|
|
156
|
+
_buildErrorActive: false,
|
|
157
|
+
// Pending runtime error message captured before WS opened.
|
|
158
|
+
// Flushed in onopen so node status can go red even when the
|
|
159
|
+
// exception fires synchronously during initial bundle execution.
|
|
160
|
+
_pendingRuntimeError: null,
|
|
51
161
|
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
52
162
|
|
|
53
163
|
connect() {
|
|
@@ -57,9 +167,15 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
57
167
|
const s = document.getElementById('__cs');
|
|
58
168
|
|
|
59
169
|
ws.onopen = () => {
|
|
60
|
-
if (s) { s.textContent = 'fromcubes
|
|
170
|
+
if (s) { s.textContent = 'fromcubes • connected'; s.className = 'ok'; }
|
|
61
171
|
this._retries = 0;
|
|
62
172
|
this._wasConnected = true;
|
|
173
|
+
const es = document.getElementById('__err_status');
|
|
174
|
+
if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
|
|
175
|
+
if (this._pendingRuntimeError) {
|
|
176
|
+
try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
|
|
177
|
+
this._pendingRuntimeError = null;
|
|
178
|
+
}
|
|
63
179
|
};
|
|
64
180
|
|
|
65
181
|
ws.onmessage = (e) => {
|
|
@@ -69,16 +185,26 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
69
185
|
this._portalClient = m.portalClient;
|
|
70
186
|
}
|
|
71
187
|
if (m.type === 'version') {
|
|
72
|
-
|
|
73
|
-
if (
|
|
188
|
+
// Empty hash = server still in building/error state, ignore (overlay stays).
|
|
189
|
+
if (!m.hash) return;
|
|
190
|
+
// Reload when server was in build-error/building (recover regardless of
|
|
191
|
+
// hash) OR when hash differs from prior known hash (deploy detected).
|
|
192
|
+
// Do NOT reload merely because an overlay is in the DOM: a runtime
|
|
193
|
+
// exception caught locally renders the same overlay node and would
|
|
194
|
+
// otherwise loop reload to runtime-error to reload forever.
|
|
195
|
+
if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
|
|
196
|
+
location.reload();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
74
199
|
this._version = m.hash;
|
|
75
200
|
}
|
|
76
201
|
if (m.type === 'building') {
|
|
202
|
+
this._buildErrorActive = true;
|
|
77
203
|
document.getElementById('root').style.display = 'none';
|
|
78
|
-
|
|
204
|
+
const eo = document.getElementById('__error_overlay');
|
|
79
205
|
if (eo) eo.remove();
|
|
80
206
|
if (!document.getElementById('__building_overlay')) {
|
|
81
|
-
|
|
207
|
+
const ov = document.createElement('div');
|
|
82
208
|
ov.id = '__building_overlay';
|
|
83
209
|
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';
|
|
84
210
|
ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
|
|
@@ -88,22 +214,14 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
88
214
|
}
|
|
89
215
|
}
|
|
90
216
|
if (m.type === 'error') {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#1a0000;color:#f87171;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace;padding:40px';
|
|
99
|
-
document.body.appendChild(ov);
|
|
217
|
+
this._buildErrorActive = true;
|
|
218
|
+
if (m.degraded) {
|
|
219
|
+
__renderErrorBanner(m.message);
|
|
220
|
+
} else {
|
|
221
|
+
__renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
|
|
222
|
+
const es2 = document.getElementById('__err_status');
|
|
223
|
+
if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
|
|
100
224
|
}
|
|
101
|
-
ov.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px;font-size:24px">JSX Transpile Error</h1>'
|
|
102
|
-
+ '<p style="color:#888;margin-bottom:16px">Fix the component code in Node-RED and deploy again.</p>'
|
|
103
|
-
+ '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;max-width:90vw;max-height:60vh;overflow:auto;white-space:pre-wrap">'
|
|
104
|
-
+ m.message.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
105
|
-
+ '</pre>'
|
|
106
|
-
+ '<p style="color:#4ade80;font-size:12px;margin-top:24px">Connected \\u2014 will reload on redeploy</p>';
|
|
107
225
|
}
|
|
108
226
|
if (m.type === 'data') {
|
|
109
227
|
this._lastData = m.payload;
|
|
@@ -121,8 +239,10 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
121
239
|
};
|
|
122
240
|
|
|
123
241
|
ws.onclose = () => {
|
|
124
|
-
if (s) { s.textContent = 'fromcubes
|
|
242
|
+
if (s) { s.textContent = 'fromcubes • disconnected'; s.className = 'err'; }
|
|
125
243
|
this._ws = null;
|
|
244
|
+
const es = document.getElementById('__err_status');
|
|
245
|
+
if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
|
|
126
246
|
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
127
247
|
this._retries++;
|
|
128
248
|
setTimeout(() => this.connect(), delay);
|
|
@@ -147,12 +267,17 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
147
267
|
<script>
|
|
148
268
|
try { ${escScript(transpiledJs)}
|
|
149
269
|
} catch(__e) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
270
|
+
const __m = (__e && (__e.stack || __e.message)) || String(__e);
|
|
271
|
+
__renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
|
|
272
|
+
// Report back to server so node status goes red. WS may not be open
|
|
273
|
+
// yet (sync throw during initial bundle); queue until onopen.
|
|
274
|
+
try {
|
|
275
|
+
if (window.__NR && window.__NR._ws && window.__NR._ws.readyState === 1) {
|
|
276
|
+
window.__NR._ws.send(JSON.stringify({ type: 'runtime_error', message: __m }));
|
|
277
|
+
} else if (window.__NR) {
|
|
278
|
+
window.__NR._pendingRuntimeError = __m;
|
|
279
|
+
}
|
|
280
|
+
} catch(_) {}
|
|
156
281
|
}
|
|
157
282
|
<\/script>
|
|
158
283
|
</body>
|
|
@@ -165,40 +290,43 @@ function buildErrorPage(title, error, wsPath) {
|
|
|
165
290
|
<head>
|
|
166
291
|
<meta charset="UTF-8">
|
|
167
292
|
<title>${esc(title)} — Error</title>
|
|
168
|
-
<style>
|
|
169
|
-
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
170
|
-
h1 { color: #ff4444; margin-bottom: 16px }
|
|
171
|
-
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
172
|
-
.status { color: #888; font-size: 12px; margin-top: 24px }
|
|
173
|
-
.status.ok { color: #4ade80 }
|
|
174
|
-
</style>
|
|
293
|
+
<style>${ERROR_OVERLAY_CSS}</style>
|
|
175
294
|
</head>
|
|
176
295
|
<body>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
296
|
+
<div id="__error_overlay">${errorOverlayInnerHtml({
|
|
297
|
+
title: "Build Error",
|
|
298
|
+
hint: DEFAULT_HINT,
|
|
299
|
+
message: error,
|
|
300
|
+
statusLine: "Waiting for redeploy…",
|
|
301
|
+
statusOk: false,
|
|
302
|
+
})}</div>
|
|
181
303
|
<script>
|
|
182
304
|
(function() {
|
|
183
|
-
|
|
184
|
-
|
|
305
|
+
const st = document.getElementById('__err_status');
|
|
306
|
+
const pre = document.querySelector('#__error_overlay pre');
|
|
307
|
+
let retries = 0;
|
|
308
|
+
function setStatus(text, ok) {
|
|
309
|
+
if (!st) return;
|
|
310
|
+
st.textContent = text;
|
|
311
|
+
st.className = '__status' + (ok ? '' : ' __off');
|
|
312
|
+
}
|
|
185
313
|
function connect() {
|
|
186
|
-
|
|
187
|
-
|
|
314
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
315
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
188
316
|
ws.onopen = function() {
|
|
189
317
|
retries = 0;
|
|
190
|
-
|
|
318
|
+
setStatus('Connected \\u2014 will reload on redeploy', true);
|
|
191
319
|
};
|
|
192
320
|
ws.onmessage = function(e) {
|
|
193
321
|
try {
|
|
194
|
-
|
|
322
|
+
const m = JSON.parse(e.data);
|
|
195
323
|
if (m.type === 'version' && m.hash) location.reload();
|
|
196
|
-
if (m.type === 'error'
|
|
324
|
+
if (m.type === 'error' && pre) pre.textContent = m.message;
|
|
197
325
|
} catch(_) {}
|
|
198
326
|
};
|
|
199
327
|
ws.onclose = function() {
|
|
200
|
-
|
|
201
|
-
|
|
328
|
+
setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
|
|
329
|
+
const delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
202
330
|
retries++;
|
|
203
331
|
setTimeout(connect, delay);
|
|
204
332
|
};
|