@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.
@@ -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
- try {
224
- esbuild.transformSync(jsx, {
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
- const msg = e.errors?.length
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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 \u2022 connected'; s.className = 'ok'; }
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
- var hasOverlay = document.getElementById('__building_overlay') || document.getElementById('__error_overlay');
73
- if (hasOverlay || (this._version && this._version !== m.hash)) { location.reload(); return; }
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
- document.getElementById('root').style.display = 'none';
92
- var bo = document.getElementById('__building_overlay');
93
- if (bo) bo.remove();
94
- var ov = document.getElementById('__error_overlay');
95
- if (!ov) {
96
- ov = document.createElement('div');
97
- ov.id = '__error_overlay';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
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 \u2022 disconnected'; s.className = 'err'; }
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 __r = document.getElementById('root');
151
- __r.style.cssText = 'font-family:monospace;background:#1a0000;color:#f87171;padding:40px;min-height:100vh;margin:0';
152
- __r.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px">Runtime Error</h1>'
153
- + '<p style="color:#888">Fix the component code in Node-RED and deploy again.</p>'
154
- + '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;white-space:pre-wrap">'
155
- + (__e.message || __e).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
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
- <h1>JSX Transpile Error</h1>
178
- <p>Fix the component code in Node-RED and deploy again.</p>
179
- <pre>${esc(error)}</pre>
180
- <p class="status" id="st">Waiting for redeploy…</p>
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('st');
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
- if (st) { st.textContent = 'Connected \\u2014 will reload on redeploy'; st.className = 'status ok'; }
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') { var pre = document.querySelector('pre'); if (pre) pre.textContent = m.message; }
324
+ if (m.type === 'error' && pre) pre.textContent = m.message;
197
325
  } catch(_) {}
198
326
  };
199
327
  ws.onclose = function() {
200
- if (st) { st.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; st.className = 'status'; }
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);
@@ -499,7 +499,10 @@
499
499
 
500
500
  var diagOpts = {
501
501
  noSemanticValidation: true,
502
- noSyntaxValidation: false,
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
  };
@@ -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
- registry[compName] = { code: newCode };
220
+ const syntaxErr = quickCheckSyntax(newCode);
221
+ registry[compName] = { code: newCode, error: syntaxErr };
219
222
 
220
- node.status({ fill: "green", shape: "dot", text: compName });
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} [${n} client${n !== 1 ? "s" : ""}]`,
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
- if (!hasReturn) {
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
- const jsxHash = hash(fullJsx);
494
-
495
- // ── JS: disk cache → transpile ──
496
- let compiled = readCachedJS(jsxHash);
497
- let cacheHit = !!compiled;
498
- if (!compiled) {
499
- compiled = transpile(fullJsx);
500
- if (!compiled.error) {
501
- writeCachedJS(jsxHash, compiled.js, compiled.metafile);
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("JSX transpile error: " + compiled.error);
507
- RED.log.warn(
508
- `[portal-react] ${endpoint} JSX transpile error: ${compiled.error}`,
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
- node.status({ fill: "red", shape: "dot", text: "transpile error" });
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
- node.status({ fill: "red", shape: "dot", text: "rebuild error" });
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
- node.status({ fill: "grey", shape: "ring", text: `${endpoint} [0 clients]` });
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
- const contentHash = pageState[endpoint]?.contentHash || "";
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.20",
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.2"
33
+ "tailwindcss": "^4.2.4"
34
34
  },
35
35
  "devDependencies": {
36
- "node-red": "5.0.0-beta.4",
37
- "prettier": "^3.8.2",
36
+ "node-red": "5.0.0-beta.5",
37
+ "prettier": "^3.8.3",
38
38
  "supertest": "^7.2.2",
39
- "vitest": "^4.1.4"
39
+ "vitest": "^4.1.5"
40
40
  },
41
41
  "scripts": {
42
42
  "start": "node-red",