@aaqu/fromcubes-portal-react 0.1.0-alpha.20 → 0.1.0-alpha.21
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/nodes/lib/helpers.js +34 -28
- package/nodes/lib/page-builder.js +168 -40
- package/nodes/portal-react.html +4 -1
- package/nodes/portal-react.js +240 -53
- package/package.json +5 -5
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
|
+
var root = document.getElementById('root');
|
|
103
|
+
if (root) root.style.display = 'none';
|
|
104
|
+
var bo = document.getElementById('__building_overlay');
|
|
105
|
+
if (bo) bo.remove();
|
|
106
|
+
var bn = document.getElementById('__error_banner');
|
|
107
|
+
if (bn) bn.remove();
|
|
108
|
+
var 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
|
+
var ov = document.getElementById('__error_overlay');
|
|
117
|
+
if (ov) ov.remove();
|
|
118
|
+
var bo = document.getElementById('__building_overlay');
|
|
119
|
+
if (bo) bo.remove();
|
|
120
|
+
var root = document.getElementById('root');
|
|
121
|
+
if (root) root.style.display = '';
|
|
122
|
+
var 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
|
+
var ov = document.getElementById('__error_overlay');
|
|
139
|
+
if (ov) ov.remove();
|
|
140
|
+
var bn = document.getElementById('__error_banner');
|
|
141
|
+
if (bn) bn.remove();
|
|
142
|
+
var 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
|
+
var 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,11 +185,21 @@ 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
|
var eo = document.getElementById('__error_overlay');
|
|
79
205
|
if (eo) eo.remove();
|
|
@@ -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
|
+
var 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
|
+
var 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
|
-
var
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
270
|
+
var __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,39 +290,42 @@ 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
|
-
var st = document.getElementById('
|
|
305
|
+
var st = document.getElementById('__err_status');
|
|
306
|
+
var pre = document.querySelector('#__error_overlay pre');
|
|
184
307
|
var 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
314
|
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
187
315
|
var 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
|
var 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
|
-
|
|
328
|
+
setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
|
|
201
329
|
var delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
202
330
|
retries++;
|
|
203
331
|
setTimeout(connect, delay);
|
package/nodes/portal-react.html
CHANGED
|
@@ -499,7 +499,10 @@
|
|
|
499
499
|
|
|
500
500
|
var diagOpts = {
|
|
501
501
|
noSemanticValidation: true,
|
|
502
|
-
|
|
502
|
+
// Server-side esbuild does the real syntax check at deploy time;
|
|
503
|
+
// Monaco's TS parser produces noisy false positives on raw JSX
|
|
504
|
+
// (1109, 1005, 1128 etc.), so we silence its squiggles entirely.
|
|
505
|
+
noSyntaxValidation: true,
|
|
503
506
|
noSuggestionDiagnostics: true,
|
|
504
507
|
diagnosticCodesToIgnore: [17004],
|
|
505
508
|
};
|
package/nodes/portal-react.js
CHANGED
|
@@ -110,6 +110,7 @@ module.exports = function (RED) {
|
|
|
110
110
|
if (_startupPhase) return; // gated — _endStartupPhase will flush
|
|
111
111
|
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
112
112
|
_rebuildTimer = setTimeout(_flushRebuild, 50);
|
|
113
|
+
_rebuildTimer.unref?.();
|
|
113
114
|
}
|
|
114
115
|
function scheduleRebuildSelf(nodeId) {
|
|
115
116
|
if (!nodeId) return;
|
|
@@ -159,6 +160,7 @@ module.exports = function (RED) {
|
|
|
159
160
|
const {
|
|
160
161
|
hash,
|
|
161
162
|
transpile,
|
|
163
|
+
quickCheckSyntax,
|
|
162
164
|
generateCSS,
|
|
163
165
|
extractPortalUser,
|
|
164
166
|
removeRoute,
|
|
@@ -215,9 +217,16 @@ module.exports = function (RED) {
|
|
|
215
217
|
|
|
216
218
|
const newCode = config.compCode || "";
|
|
217
219
|
const prevCode = registry[compName]?.code;
|
|
218
|
-
|
|
220
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
221
|
+
registry[compName] = { code: newCode, error: syntaxErr };
|
|
219
222
|
|
|
220
|
-
|
|
223
|
+
if (syntaxErr) {
|
|
224
|
+
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
225
|
+
const short = syntaxErr.split("\n")[0].slice(0, 40);
|
|
226
|
+
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
227
|
+
} else {
|
|
228
|
+
node.status({ fill: "green", shape: "dot", text: compName });
|
|
229
|
+
}
|
|
221
230
|
|
|
222
231
|
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
223
232
|
if (prevCode !== newCode) {
|
|
@@ -313,11 +322,49 @@ module.exports = function (RED) {
|
|
|
313
322
|
|
|
314
323
|
function updateStatus() {
|
|
315
324
|
if (isClosing) return;
|
|
325
|
+
const st = pageState[endpoint];
|
|
316
326
|
const n = clients.size;
|
|
327
|
+
const clientTail = n > 0 ? ` [${n} client${n !== 1 ? "s" : ""}]` : "";
|
|
328
|
+
|
|
329
|
+
// Preserve build-error state — don't clobber with client count until JSX is fixed.
|
|
330
|
+
// Show "(serving last good)" suffix in degraded mode (ring shape) so it is
|
|
331
|
+
// obvious the portal still works for connected clients despite the broken build.
|
|
332
|
+
if (st && st.compiled && st.compiled.error) {
|
|
333
|
+
let base;
|
|
334
|
+
if (st.errorSource) base = "broken: " + st.errorSource;
|
|
335
|
+
else if (st.errorKind === "missing-return") base = "missing return";
|
|
336
|
+
else if (st.errorKind === "rebuild") base = "rebuild error";
|
|
337
|
+
else base = "transpile error";
|
|
338
|
+
if (st.lastGood) {
|
|
339
|
+
node.status({
|
|
340
|
+
fill: "red",
|
|
341
|
+
shape: "ring",
|
|
342
|
+
text: base + " (serving last good)" + clientTail,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
node.status({ fill: "red", shape: "dot", text: base + clientTail });
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// Preserve building state — same reason.
|
|
350
|
+
if (st && st.building) {
|
|
351
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Build succeeded but a connected browser threw at runtime
|
|
355
|
+
// (e.g. ReferenceError to a missing component / undefined identifier).
|
|
356
|
+
if (st && st.runtimeError) {
|
|
357
|
+
node.status({
|
|
358
|
+
fill: "red",
|
|
359
|
+
shape: "ring",
|
|
360
|
+
text: "runtime error" + clientTail,
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
317
364
|
node.status({
|
|
318
365
|
fill: n > 0 ? "green" : "grey",
|
|
319
366
|
shape: n > 0 ? "dot" : "ring",
|
|
320
|
-
text: `${endpoint}
|
|
367
|
+
text: `${endpoint}${clientTail || " [0 clients]"}`,
|
|
321
368
|
});
|
|
322
369
|
}
|
|
323
370
|
|
|
@@ -325,8 +372,6 @@ module.exports = function (RED) {
|
|
|
325
372
|
|
|
326
373
|
function rebuild() {
|
|
327
374
|
try {
|
|
328
|
-
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
329
|
-
|
|
330
375
|
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
331
376
|
const prevState = pageState[endpoint];
|
|
332
377
|
const prevHash = prevState?.jsxHash;
|
|
@@ -334,6 +379,7 @@ module.exports = function (RED) {
|
|
|
334
379
|
deleteCacheFiles(prevHash);
|
|
335
380
|
}
|
|
336
381
|
pageState[endpoint] = { building: true, wsPath, pageTitle };
|
|
382
|
+
updateStatus();
|
|
337
383
|
clients.forEach((ws) => {
|
|
338
384
|
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
|
|
339
385
|
});
|
|
@@ -451,7 +497,19 @@ module.exports = function (RED) {
|
|
|
451
497
|
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
452
498
|
].join("\n");
|
|
453
499
|
|
|
500
|
+
const jsxHash = hash(fullJsx);
|
|
501
|
+
|
|
502
|
+
// ── Check: any used component has its own syntax error ──
|
|
503
|
+
let errorSource = null;
|
|
504
|
+
for (const name of needed) {
|
|
505
|
+
if (registry[name]?.error) {
|
|
506
|
+
errorSource = name;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
454
511
|
// ── Check: missing return in App ──
|
|
512
|
+
let missingReturn = false;
|
|
455
513
|
const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
|
|
456
514
|
if (appFnMatch) {
|
|
457
515
|
let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
|
|
@@ -463,54 +521,47 @@ module.exports = function (RED) {
|
|
|
463
521
|
else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
|
|
464
522
|
i++;
|
|
465
523
|
}
|
|
466
|
-
|
|
467
|
-
node.error("App component has no return statement");
|
|
468
|
-
node.status({ fill: "red", shape: "dot", text: "missing return" });
|
|
469
|
-
const missingReturnError = {
|
|
470
|
-
js: null,
|
|
471
|
-
error: "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
472
|
-
};
|
|
473
|
-
pageState[endpoint] = {
|
|
474
|
-
compiled: missingReturnError,
|
|
475
|
-
contentHash: "",
|
|
476
|
-
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
477
|
-
jsxHash: "",
|
|
478
|
-
css: null,
|
|
479
|
-
cssHash: "",
|
|
480
|
-
pageTitle,
|
|
481
|
-
wsPath,
|
|
482
|
-
customHead,
|
|
483
|
-
portalAuth,
|
|
484
|
-
showWsStatus,
|
|
485
|
-
};
|
|
486
|
-
clients.forEach((ws) => {
|
|
487
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (e) { RED.log.trace("[portal-react] ws send error frame: " + e.message); }
|
|
488
|
-
});
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
524
|
+
missingReturn = !hasReturn;
|
|
491
525
|
}
|
|
492
526
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
let
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
527
|
+
// ── Resolve compiled (success or unified error) ──
|
|
528
|
+
let compiled;
|
|
529
|
+
let cacheHit = false;
|
|
530
|
+
let errorKind = null; // 'component' | 'missing-return' | 'transpile'
|
|
531
|
+
if (errorSource) {
|
|
532
|
+
compiled = {
|
|
533
|
+
js: null,
|
|
534
|
+
error: `Component "${errorSource}" has a syntax error:\n\n${registry[errorSource].error}`,
|
|
535
|
+
};
|
|
536
|
+
errorKind = "component";
|
|
537
|
+
} else if (missingReturn) {
|
|
538
|
+
compiled = {
|
|
539
|
+
js: null,
|
|
540
|
+
error:
|
|
541
|
+
"App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
542
|
+
};
|
|
543
|
+
errorKind = "missing-return";
|
|
544
|
+
} else {
|
|
545
|
+
compiled = readCachedJS(jsxHash);
|
|
546
|
+
cacheHit = !!compiled;
|
|
547
|
+
if (!compiled) {
|
|
548
|
+
compiled = transpile(fullJsx);
|
|
549
|
+
if (!compiled.error) {
|
|
550
|
+
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
551
|
+
}
|
|
502
552
|
}
|
|
553
|
+
if (compiled.error) errorKind = "transpile";
|
|
503
554
|
}
|
|
504
555
|
|
|
505
556
|
if (compiled.error) {
|
|
506
|
-
node.error(
|
|
507
|
-
|
|
508
|
-
|
|
557
|
+
node.error(
|
|
558
|
+
(errorKind === "component"
|
|
559
|
+
? `Component "${errorSource}" syntax error: `
|
|
560
|
+
: errorKind === "missing-return"
|
|
561
|
+
? "App component has no return statement: "
|
|
562
|
+
: "JSX transpile error: ") + compiled.error,
|
|
509
563
|
);
|
|
510
|
-
|
|
511
|
-
clients.forEach((ws) => {
|
|
512
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (e) { RED.log.trace("[portal-react] ws send transpile error: " + e.message); }
|
|
513
|
-
});
|
|
564
|
+
// Status + WS frames handled below (lastGood-aware).
|
|
514
565
|
} else {
|
|
515
566
|
updateStatus();
|
|
516
567
|
if (compiled.metafile) {
|
|
@@ -561,6 +612,12 @@ module.exports = function (RED) {
|
|
|
561
612
|
|
|
562
613
|
lastJsxHash = jsxHash;
|
|
563
614
|
|
|
615
|
+
// Preserve last successful build so that on transpile errors we keep
|
|
616
|
+
// serving the previous working JS instead of throwing clients to an error page.
|
|
617
|
+
const lastGood = compiled.error
|
|
618
|
+
? prevState?.lastGood || null
|
|
619
|
+
: null; // will be populated after cssReady resolves on success
|
|
620
|
+
|
|
564
621
|
pageState[endpoint] = {
|
|
565
622
|
compiled,
|
|
566
623
|
contentHash,
|
|
@@ -573,8 +630,24 @@ module.exports = function (RED) {
|
|
|
573
630
|
customHead,
|
|
574
631
|
portalAuth,
|
|
575
632
|
showWsStatus,
|
|
633
|
+
errorSource,
|
|
634
|
+
errorKind,
|
|
635
|
+
lastGood,
|
|
576
636
|
};
|
|
577
637
|
|
|
638
|
+
if (compiled.error) {
|
|
639
|
+
// Status text (red) handled centrally by updateStatus — it formats
|
|
640
|
+
// base + "(serving last good)" + client-count suffix consistently
|
|
641
|
+
// across build and connect/disconnect events.
|
|
642
|
+
updateStatus();
|
|
643
|
+
const frame = lastGood
|
|
644
|
+
? JSON.stringify({ type: "error", message: compiled.error, degraded: true })
|
|
645
|
+
: JSON.stringify({ type: "error", message: compiled.error });
|
|
646
|
+
clients.forEach((ws) => {
|
|
647
|
+
try { if (ws.readyState === 1) ws.send(frame); } catch (e) { RED.log.trace("[portal-react] ws send error: " + e.message); }
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
578
651
|
// Notify all connected browsers that build finished — triggers reload or overlay cleanup
|
|
579
652
|
if (!compiled.error && contentHash) {
|
|
580
653
|
const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
|
|
@@ -588,12 +661,49 @@ module.exports = function (RED) {
|
|
|
588
661
|
if (state && state.jsxHash === jsxHash) {
|
|
589
662
|
state.css = css;
|
|
590
663
|
state.cssHash = cssHash;
|
|
664
|
+
// Snapshot current good build so future failed builds can fall back.
|
|
665
|
+
if (!state.compiled.error && state.compiled.js) {
|
|
666
|
+
state.lastGood = {
|
|
667
|
+
compiledJs: state.compiled.js,
|
|
668
|
+
contentHash: state.contentHash,
|
|
669
|
+
cssHash,
|
|
670
|
+
pageTitle: state.pageTitle,
|
|
671
|
+
customHead: state.customHead,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
591
674
|
updateStatus();
|
|
592
675
|
}
|
|
593
676
|
});
|
|
594
677
|
} catch (e) {
|
|
595
678
|
node.error("Rebuild failed: " + e.message);
|
|
596
|
-
|
|
679
|
+
// Surface as a regular build error so the lastGood/degraded path,
|
|
680
|
+
// status formatting and FE error frame all run uniformly.
|
|
681
|
+
const prev = pageState[endpoint];
|
|
682
|
+
pageState[endpoint] = {
|
|
683
|
+
compiled: { js: null, error: "Internal rebuild error: " + e.message },
|
|
684
|
+
contentHash: "",
|
|
685
|
+
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
686
|
+
jsxHash: "",
|
|
687
|
+
css: null,
|
|
688
|
+
cssHash: "",
|
|
689
|
+
pageTitle,
|
|
690
|
+
wsPath,
|
|
691
|
+
customHead,
|
|
692
|
+
portalAuth,
|
|
693
|
+
showWsStatus,
|
|
694
|
+
errorSource: null,
|
|
695
|
+
errorKind: "rebuild",
|
|
696
|
+
lastGood: prev?.lastGood || null,
|
|
697
|
+
};
|
|
698
|
+
updateStatus();
|
|
699
|
+
const frame = JSON.stringify({
|
|
700
|
+
type: "error",
|
|
701
|
+
message: "Internal rebuild error: " + e.message,
|
|
702
|
+
degraded: !!prev?.lastGood,
|
|
703
|
+
});
|
|
704
|
+
clients.forEach((ws) => {
|
|
705
|
+
try { if (ws.readyState === 1) ws.send(frame); } catch (err) { RED.log.trace("[portal-react] ws send rebuild err: " + err.message); }
|
|
706
|
+
});
|
|
597
707
|
}
|
|
598
708
|
}
|
|
599
709
|
|
|
@@ -626,7 +736,7 @@ module.exports = function (RED) {
|
|
|
626
736
|
scheduleRebuildSelf(nodeId);
|
|
627
737
|
} else {
|
|
628
738
|
node.log(`[${nodeId}] unchanged — skipping rebuild`);
|
|
629
|
-
|
|
739
|
+
updateStatus();
|
|
630
740
|
}
|
|
631
741
|
setImmediate(() => {
|
|
632
742
|
// Register route only once per endpoint (persists across deploys)
|
|
@@ -638,15 +748,35 @@ module.exports = function (RED) {
|
|
|
638
748
|
const bWsPath = state?.wsPath || wsPath;
|
|
639
749
|
res
|
|
640
750
|
.set("Cache-Control", "no-store")
|
|
641
|
-
.set("Refresh", "3")
|
|
642
751
|
.type("text/html")
|
|
643
752
|
.send(
|
|
644
|
-
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){var r=0;function c(){var p=location.protocol==='https:'?'wss:':'ws:';var ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{var m=JSON.parse(e.data);if(m.type==='version'||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){var d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
|
|
753
|
+
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){var r=0;function c(){var p=location.protocol==='https:'?'wss:':'ws:';var ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{var m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){var d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
|
|
645
754
|
);
|
|
646
755
|
return;
|
|
647
756
|
}
|
|
648
757
|
res.set("Cache-Control", "no-store");
|
|
649
758
|
if (state.compiled.error) {
|
|
759
|
+
if (state.lastGood) {
|
|
760
|
+
// Degraded: serve previous good build, banner-only error UI.
|
|
761
|
+
const user = state.portalAuth
|
|
762
|
+
? extractPortalUser(_req.headers)
|
|
763
|
+
: null;
|
|
764
|
+
res
|
|
765
|
+
.type("text/html")
|
|
766
|
+
.send(
|
|
767
|
+
buildPage(
|
|
768
|
+
state.lastGood.pageTitle,
|
|
769
|
+
state.lastGood.compiledJs,
|
|
770
|
+
state.wsPath,
|
|
771
|
+
state.lastGood.customHead,
|
|
772
|
+
state.lastGood.cssHash,
|
|
773
|
+
user,
|
|
774
|
+
state.showWsStatus,
|
|
775
|
+
adminRoot,
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
650
780
|
res
|
|
651
781
|
.status(500)
|
|
652
782
|
.type("text/html")
|
|
@@ -763,13 +893,28 @@ module.exports = function (RED) {
|
|
|
763
893
|
|
|
764
894
|
updateStatus();
|
|
765
895
|
|
|
766
|
-
// Send content version for deploy-reload detection
|
|
767
|
-
|
|
896
|
+
// Send content version for deploy-reload detection.
|
|
897
|
+
// In degraded mode (current build failed but lastGood served), advertise
|
|
898
|
+
// the lastGood hash so the freshly reloaded client matches the JS we sent.
|
|
899
|
+
const cs = pageState[endpoint];
|
|
900
|
+
const contentHash =
|
|
901
|
+
cs?.compiled?.error && cs?.lastGood
|
|
902
|
+
? cs.lastGood.contentHash
|
|
903
|
+
: cs?.contentHash || "";
|
|
768
904
|
wsSend(ws, { type: "version", hash: contentHash });
|
|
769
905
|
|
|
770
906
|
// Send assigned portalClient to browser
|
|
771
907
|
wsSend(ws, { type: "hello", portalClient });
|
|
772
908
|
|
|
909
|
+
// Degraded warning — show banner, not full overlay.
|
|
910
|
+
if (cs?.compiled?.error && cs?.lastGood) {
|
|
911
|
+
wsSend(ws, {
|
|
912
|
+
type: "error",
|
|
913
|
+
message: cs.compiled.error,
|
|
914
|
+
degraded: true,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
773
918
|
// Send the cached last broadcast (if any) as a distinct
|
|
774
919
|
// `recovery` frame. The browser uses this to seed `data` on a
|
|
775
920
|
// fresh connection. React components can opt out via
|
|
@@ -778,9 +923,37 @@ module.exports = function (RED) {
|
|
|
778
923
|
wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
|
|
779
924
|
}
|
|
780
925
|
|
|
926
|
+
// Heartbeat — detect dead sockets via WS ping/pong. Browser
|
|
927
|
+
// auto-replies to ping frames, no client JS needed.
|
|
928
|
+
ws._isAlive = true;
|
|
929
|
+
ws.on("pong", () => { ws._isAlive = true; });
|
|
930
|
+
ws._pingIv = setInterval(() => {
|
|
931
|
+
if (ws._isAlive === false) {
|
|
932
|
+
try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
ws._isAlive = false;
|
|
936
|
+
try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
|
|
937
|
+
}, 30000);
|
|
938
|
+
|
|
781
939
|
ws.on("message", (raw) => {
|
|
782
940
|
try {
|
|
783
941
|
const msg = JSON.parse(raw.toString());
|
|
942
|
+
if (msg.type === "runtime_error") {
|
|
943
|
+
// Browser caught an exception while running the bundle —
|
|
944
|
+
// surface it on node status so the editor shows red even
|
|
945
|
+
// when the build itself succeeded (e.g. ReferenceError to
|
|
946
|
+
// an undefined identifier or missing component).
|
|
947
|
+
const st = pageState[endpoint];
|
|
948
|
+
if (st && !(st.compiled && st.compiled.error)) {
|
|
949
|
+
st.runtimeError = String(msg.message || "")
|
|
950
|
+
.split("\n")[0]
|
|
951
|
+
.slice(0, 200);
|
|
952
|
+
node.error("Runtime error in browser: " + st.runtimeError);
|
|
953
|
+
updateStatus();
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
784
957
|
if (msg.type === "output") {
|
|
785
958
|
let out = {
|
|
786
959
|
payload: msg.payload,
|
|
@@ -806,6 +979,7 @@ module.exports = function (RED) {
|
|
|
806
979
|
});
|
|
807
980
|
|
|
808
981
|
const detach = () => {
|
|
982
|
+
if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
|
|
809
983
|
clients.delete(portalClient);
|
|
810
984
|
if (userId) {
|
|
811
985
|
const set = userIndex.get(userId);
|
|
@@ -863,8 +1037,10 @@ module.exports = function (RED) {
|
|
|
863
1037
|
delete upgradeHandlers[nodeId];
|
|
864
1038
|
}
|
|
865
1039
|
|
|
866
|
-
// Close all WS clients
|
|
1040
|
+
// Close all WS clients — clear heartbeat interval BEFORE ws.close()
|
|
1041
|
+
// so pending pings do not leak if the 'close' event is delayed.
|
|
867
1042
|
clients.forEach((ws) => {
|
|
1043
|
+
if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
|
|
868
1044
|
try {
|
|
869
1045
|
ws.close(1001, "node redeployed");
|
|
870
1046
|
} catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
|
|
@@ -900,6 +1076,17 @@ module.exports = function (RED) {
|
|
|
900
1076
|
// the Map itself should not outlive the node instance.
|
|
901
1077
|
userIndex.clear();
|
|
902
1078
|
|
|
1079
|
+
// Break references to large objects / Promises in pageState even on
|
|
1080
|
+
// redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
|
|
1081
|
+
// between close and the new build these would retain closures over
|
|
1082
|
+
// the old clients/userIndex/rebuild scope.
|
|
1083
|
+
const st = pageState[endpoint];
|
|
1084
|
+
if (st) {
|
|
1085
|
+
st.cssReady = null;
|
|
1086
|
+
st.compiled = null;
|
|
1087
|
+
st.css = null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
903
1090
|
// Clean up route only when node is fully removed (not redeployed)
|
|
904
1091
|
if (removed) {
|
|
905
1092
|
// Delete disk cache if no other endpoint uses this hash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaqu/fromcubes-portal-react",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.21",
|
|
4
4
|
"description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
"monaco-editor": "^0.55.1",
|
|
31
31
|
"react": "^19.2.5",
|
|
32
32
|
"react-dom": "^19.2.5",
|
|
33
|
-
"tailwindcss": "^4.2.
|
|
33
|
+
"tailwindcss": "^4.2.4"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"node-red": "5.0.0-beta.
|
|
37
|
-
"prettier": "^3.8.
|
|
36
|
+
"node-red": "5.0.0-beta.5",
|
|
37
|
+
"prettier": "^3.8.3",
|
|
38
38
|
"supertest": "^7.2.2",
|
|
39
|
-
"vitest": "^4.1.
|
|
39
|
+
"vitest": "^4.1.5"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"start": "node-red",
|