@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 0.1.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,97 @@
1
+ /** @module @aaqu/fromcubes-portal-react */
2
+
1
3
  /**
2
- * @aaqu/fromcubes-portal-react
3
- *
4
4
  * Node-RED node that serves React apps from configurable HTTP endpoints
5
5
  * with live WebSocket data binding. JSX is transpiled server-side via esbuild
6
6
  * at deploy time — browsers receive pre-compiled JS.
7
+ *
8
+ * Module-global state lives on `RED.settings.portalReact*` so it survives
9
+ * Full deploys (Node-RED closes and re-opens every node on a Full deploy;
10
+ * the `RED.settings` namespace persists across that cycle).
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} LibSpec
15
+ * @property {string} module npm package name (auto-installed at deploy).
16
+ * @property {string} [var] Optional global var name when bundled.
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} PortalConfig
21
+ * @property {string} subPath URL segment served under `/fromcubes/<subPath>`.
22
+ * @property {string} [endpoint] Legacy field — hard-fails on deploy if subPath empty.
23
+ * @property {string} componentCode User JSX entrypoint (must declare `function App`).
24
+ * @property {string} [pageTitle] `<title>` for the served HTML page.
25
+ * @property {string} [customHead] Raw HTML injected into `<head>` (trusted-author content).
26
+ * @property {boolean} [portalAuth] Read `x-portal-user-*` headers from incoming HTTP requests.
27
+ * @property {boolean} [showWsStatus] Render the in-page `#__cs` connection badge.
28
+ * @property {Array<LibSpec>} [libs] npm packages auto-installed at deploy via `dynamicModuleList`.
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} ClientInfo
33
+ * @property {string} portalClient Server-assigned per-tab UUID.
34
+ * @property {string} [userId]
35
+ * @property {string} [userName]
36
+ * @property {string} [username]
37
+ * @property {string} [email]
38
+ * @property {string} [role]
39
+ * @property {string|Array<string>} [groups]
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} MessagePayload
44
+ * @property {*} payload
45
+ * @property {string} [topic]
46
+ * @property {ClientInfo} [_client] Server-side identity (set on outbound WS msgs).
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} RouteResult
51
+ * @property {"unicast"|"user-cast"|"broadcast"} mode
52
+ * @property {number} delivered
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} CompiledBundle
57
+ * @property {?string} js
58
+ * @property {?string} error
59
+ * @property {Object} [metafile] esbuild metafile (size analysis).
60
+ */
61
+
62
+ /**
63
+ * @typedef {Object} ComponentNodeConfig
64
+ * @property {string} [name]
65
+ * @property {string} compName
66
+ * @property {string} compCode
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} UtilityNodeConfig
71
+ * @property {string} [name]
72
+ * @property {string} utilName
73
+ * @property {string} utilCode
74
+ */
75
+
76
+ /**
77
+ * @typedef {Object} PageState
78
+ * @property {CompiledBundle} compiled
79
+ * @property {string} contentHash sha256-16 of the compiled JS.
80
+ * @property {string} jsxHash
81
+ * @property {Promise<{css: string, cssHash: string}>} cssReady
82
+ * @property {?string} css
83
+ * @property {string} cssHash
84
+ * @property {string} pageTitle
85
+ * @property {string} wsPath
86
+ * @property {string} customHead
87
+ * @property {boolean} portalAuth
88
+ * @property {boolean} showWsStatus
89
+ * @property {?string} errorSource Component / utility name responsible for the build error.
90
+ * @property {?string} errorKind 'component' | 'utility' | 'missing-component' | 'missing-return' | 'transpile' | 'rebuild'
91
+ * @property {?Object} lastGood Snapshot of the previous successful build (degraded-mode fallback).
92
+ * @property {boolean} [cssError] Tailwind generation failed on the last attempt.
93
+ * @property {boolean} [building]
94
+ * @property {?string} [runtimeError] Browser-reported exception (truncated).
7
95
  */
8
96
 
9
97
  const crypto = require("crypto");
@@ -14,97 +102,312 @@ module.exports = function (RED) {
14
102
  const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
15
103
  const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
16
104
 
105
+ // ── Admin auth gate (Node-RED 4.x adminAuth) ─────────────────
106
+ // When the user configures `adminAuth` in settings.js, RED.auth.needsPermission
107
+ // returns an Express middleware that enforces the named permission scope.
108
+ // Without this gate, any endpoint mounted on RED.httpAdmin is reachable
109
+ // without auth and can read/modify the registry, utilities, and assets.
110
+ // Fallback no-op for runtimes that do not expose RED.auth.
111
+ /**
112
+ * Resolve a Node-RED permission middleware. Returns a no-op when the runtime
113
+ * does not expose `RED.auth.needsPermission` (test harnesses, Node-RED <1.0)
114
+ * so unit tests don't have to mock auth.
115
+ *
116
+ * @param {string} scope Permission scope (e.g. `"portal-react.write"`).
117
+ * @returns {import("express").RequestHandler}
118
+ * @private
119
+ */
120
+ function needsPerm(scope) {
121
+ if (RED.auth && typeof RED.auth.needsPermission === "function") {
122
+ return RED.auth.needsPermission(scope);
123
+ }
124
+ return function (_req, _res, next) { next(); };
125
+ }
126
+ const PERM_READ = needsPerm("portal-react.read");
127
+ const PERM_WRITE = needsPerm("portal-react.write");
128
+
129
+ // ── CSRF guard for admin write endpoints ──────────────────────
130
+ // The Node-RED editor sends `Node-RED-API-Version` on every XHR. Browsers
131
+ // refuse to attach custom headers on cross-origin form/fetch submissions
132
+ // without a CORS preflight, so requiring this header on write endpoints is
133
+ // sufficient CSRF protection without per-session tokens. Same trick the
134
+ // Node-RED core admin API uses for its own POST/PUT/DELETE routes.
135
+ /**
136
+ * Express middleware enforcing the `Node-RED-API-Version` header on write
137
+ * endpoints. The header is sent by the Node-RED editor XHR layer on every
138
+ * request; browser cross-origin form/fetch POSTs cannot attach custom
139
+ * headers without a CORS preflight, so requiring this header gives CSRF
140
+ * protection without per-session tokens.
141
+ *
142
+ * @param {import("express").Request} req
143
+ * @param {import("express").Response} res
144
+ * @param {Function} next
145
+ * @returns {void}
146
+ * @private
147
+ */
148
+ function csrfGuard(req, res, next) {
149
+ if (!req.get("Node-RED-API-Version")) {
150
+ return res.status(403).json({ error: "missing Node-RED-API-Version header" });
151
+ }
152
+ next();
153
+ }
154
+
155
+ // ── Token-bucket rate limiter keyed by req.ip ─────────────────
156
+ // Steady-state: RATE_LIMIT_TOKENS tokens refilled over RATE_LIMIT_WINDOW_MS.
157
+ // Default = 60 tokens / 60 s → 1 req/s sustained, 60 burst. Tunable via
158
+ // RED.settings.portalReact.rateLimit = { tokens, windowMs }. The buckets
159
+ // are pruned every 5 minutes to bound memory usage.
160
+ const rateLimitCfg = (RED.settings.portalReact &&
161
+ RED.settings.portalReact.rateLimit) || {};
162
+ const RATE_LIMIT_TOKENS = Number.isFinite(rateLimitCfg.tokens)
163
+ ? rateLimitCfg.tokens
164
+ : 60;
165
+ const RATE_LIMIT_WINDOW_MS = Number.isFinite(rateLimitCfg.windowMs)
166
+ ? rateLimitCfg.windowMs
167
+ : 60_000;
168
+ if (!RED.settings.portalReactRateBuckets) RED.settings.portalReactRateBuckets = new Map();
169
+ const rateBuckets = RED.settings.portalReactRateBuckets;
170
+
171
+ /**
172
+ * Token-bucket rate limit middleware keyed by `req.ip`. Each bucket refills
173
+ * `RATE_LIMIT_TOKENS` over `RATE_LIMIT_WINDOW_MS`. Steady-state default is
174
+ * 1 request/second with a 60-token burst. On exhaustion: HTTP 429 with
175
+ * `Retry-After`. Buckets idle > 10 minutes get pruned by a separate
176
+ * interval (see below).
177
+ *
178
+ * @param {import("express").Request} req
179
+ * @param {import("express").Response} res
180
+ * @param {Function} next
181
+ * @returns {void}
182
+ * @private
183
+ */
184
+ function rateLimit(req, res, next) {
185
+ const ip = req.ip || req.socket?.remoteAddress || "unknown";
186
+ const now = Date.now();
187
+ let b = rateBuckets.get(ip);
188
+ if (!b) {
189
+ b = { tokens: RATE_LIMIT_TOKENS, last: now };
190
+ rateBuckets.set(ip, b);
191
+ }
192
+ const elapsed = now - b.last;
193
+ b.tokens = Math.min(
194
+ RATE_LIMIT_TOKENS,
195
+ b.tokens + (elapsed * RATE_LIMIT_TOKENS) / RATE_LIMIT_WINDOW_MS,
196
+ );
197
+ b.last = now;
198
+ if (b.tokens < 1) {
199
+ res.set("Retry-After", Math.ceil(RATE_LIMIT_WINDOW_MS / 1000));
200
+ return res.status(429).json({ error: "rate limit exceeded" });
201
+ }
202
+ b.tokens -= 1;
203
+ next();
204
+ }
205
+
206
+ if (!RED.settings.portalReactRateBucketPruneIv) {
207
+ RED.settings.portalReactRateBucketPruneIv = setInterval(() => {
208
+ const now = Date.now();
209
+ for (const [ip, b] of rateBuckets) {
210
+ if (now - b.last > 10 * 60_000) rateBuckets.delete(ip);
211
+ }
212
+ }, 5 * 60_000);
213
+ RED.settings.portalReactRateBucketPruneIv.unref?.();
214
+ }
215
+
216
+ // ── Standard JSON parser with 1 MB limit ──────────────────────
217
+ // Applied per-route on POSTs that read req.body. 1 MB easily fits even
218
+ // large component files, and protects the registry endpoints from being
219
+ // used as a denial-of-service vector.
220
+ const JSON_BODY_LIMIT = "1mb";
221
+
222
+ // ── Status text helper ───────────────────────────────────────
223
+ // Node-RED appearance docs recommend status text "around 20 characters".
224
+ // Truncate with an ellipsis so long error fragments, component names, or
225
+ // endpoint paths don't spill past the node tile.
226
+ const STATUS_MAX = 20;
227
+ /**
228
+ * Truncate a status text to at most `STATUS_MAX` characters, replacing the
229
+ * final character with an ellipsis when truncation occurs. Keeps node tile
230
+ * width predictable per the Node-RED appearance guidelines.
231
+ *
232
+ * @param {*} s
233
+ * @returns {string}
234
+ */
235
+ function shortStatus(s) {
236
+ s = String(s == null ? "" : s);
237
+ return s.length <= STATUS_MAX ? s : s.slice(0, STATUS_MAX - 1) + "…";
238
+ }
239
+
17
240
  // ── Shared state ──────────────────────────────────────────────
18
241
  // Component registry: populated by fc-portal-component canvas nodes at deploy time
19
- if (!RED.settings.fcPortalRegistry) {
20
- RED.settings.fcPortalRegistry = {};
242
+ if (!RED.settings.portalReactComponentRegistry) {
243
+ RED.settings.portalReactComponentRegistry = {};
21
244
  }
22
- const registry = RED.settings.fcPortalRegistry;
245
+ const registry = RED.settings.portalReactComponentRegistry;
23
246
 
24
247
  // Active upgrade handlers per node id (for cleanup on redeploy)
25
- if (!RED.settings.fcUpgradeHandlers) {
26
- RED.settings.fcUpgradeHandlers = {};
248
+ if (!RED.settings.portalReactUpgradeHandlers) {
249
+ RED.settings.portalReactUpgradeHandlers = {};
27
250
  }
28
- const upgradeHandlers = RED.settings.fcUpgradeHandlers;
251
+ const upgradeHandlers = RED.settings.portalReactUpgradeHandlers;
29
252
 
30
253
  // Live page state per endpoint — route handlers read from this on each request
31
- if (!RED.settings.fcPageState) {
32
- RED.settings.fcPageState = {};
254
+ if (!RED.settings.portalReactPageState) {
255
+ RED.settings.portalReactPageState = {};
33
256
  }
34
- const pageState = RED.settings.fcPageState;
257
+ const pageState = RED.settings.portalReactPageState;
35
258
 
36
259
  // Track which endpoints already have a registered Express route
37
- if (!RED.settings.fcRegisteredRoutes) {
38
- RED.settings.fcRegisteredRoutes = {};
260
+ if (!RED.settings.portalReactRegisteredRoutes) {
261
+ RED.settings.portalReactRegisteredRoutes = {};
39
262
  }
40
- const registeredRoutes = RED.settings.fcRegisteredRoutes;
263
+ const registeredRoutes = RED.settings.portalReactRegisteredRoutes;
41
264
 
42
265
  // Rebuild callbacks: portal-react nodes register here so components can trigger re-transpile
43
- if (!RED.settings.fcRebuildCallbacks) {
44
- RED.settings.fcRebuildCallbacks = {};
266
+ if (!RED.settings.portalReactRebuildCallbacks) {
267
+ RED.settings.portalReactRebuildCallbacks = {};
45
268
  }
46
- const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
269
+ const rebuildCallbacks = RED.settings.portalReactRebuildCallbacks;
47
270
 
48
271
  // Per-portal set of component names the portal depends on (needed set from last rebuild,
49
272
  // including transitive deps). Lets component changes target only portals that use them.
50
- if (!RED.settings.fcPortalNeeded) {
51
- RED.settings.fcPortalNeeded = {};
273
+ if (!RED.settings.portalReactPortalNeeded) {
274
+ RED.settings.portalReactPortalNeeded = {};
52
275
  }
53
- const portalNeeded = RED.settings.fcPortalNeeded;
276
+ const portalNeeded = RED.settings.portalReactPortalNeeded;
54
277
 
55
278
  // Per-portal raw user JSX code string. Used as fallback to detect references to
56
279
  // newly-added components that haven't been in a `needed` set yet.
57
- if (!RED.settings.fcPortalCode) {
58
- RED.settings.fcPortalCode = {};
280
+ if (!RED.settings.portalReactPortalCode) {
281
+ RED.settings.portalReactPortalCode = {};
59
282
  }
60
- const portalCode = RED.settings.fcPortalCode;
283
+ const portalCode = RED.settings.portalReactPortalCode;
61
284
 
62
285
  // Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
63
- if (!RED.settings.fcEndpointOwners) {
64
- RED.settings.fcEndpointOwners = {};
286
+ if (!RED.settings.portalReactEndpointOwners) {
287
+ RED.settings.portalReactEndpointOwners = {};
65
288
  }
66
- const endpointOwners = RED.settings.fcEndpointOwners;
289
+ const endpointOwners = RED.settings.portalReactEndpointOwners;
67
290
 
68
291
  // Track component name ownership: { compName: nodeId } — prevents duplicate component names
69
292
  // Shared namespace with fc-portal-utility nodes so a component and a utility
70
293
  // can never share the same name.
71
- if (!RED.settings.fcCompNameOwners) {
72
- RED.settings.fcCompNameOwners = {};
294
+ if (!RED.settings.portalReactCompNameOwners) {
295
+ RED.settings.portalReactCompNameOwners = {};
73
296
  }
74
- const compNameOwners = RED.settings.fcCompNameOwners;
297
+ const compNameOwners = RED.settings.portalReactCompNameOwners;
75
298
 
76
299
  // Utility registry: populated by fc-portal-utility canvas nodes at deploy time.
77
300
  // Keyed by node-level utilName (e.g. "mathHelpers"), value { code, error }.
78
301
  // Each utility node may declare multiple top-level symbols inside its code block.
79
- if (!RED.settings.fcPortalUtilities) {
80
- RED.settings.fcPortalUtilities = {};
302
+ if (!RED.settings.portalReactUtilities) {
303
+ RED.settings.portalReactUtilities = {};
81
304
  }
82
- const utilities = RED.settings.fcPortalUtilities;
305
+ const utilities = RED.settings.portalReactUtilities;
83
306
 
84
307
  // Per-portal set of utility node names this portal depends on (transitively).
85
308
  // Lets utility code changes target only portals that reference at least one
86
309
  // symbol declared by the changed utility node.
87
- if (!RED.settings.fcPortalNeededUtilities) {
88
- RED.settings.fcPortalNeededUtilities = {};
310
+ if (!RED.settings.portalReactNeededUtilities) {
311
+ RED.settings.portalReactNeededUtilities = {};
89
312
  }
90
- const portalNeededUtilities = RED.settings.fcPortalNeededUtilities;
313
+ const portalNeededUtilities = RED.settings.portalReactNeededUtilities;
91
314
 
92
315
  // Track owner of each top-level symbol declared inside fc-portal-utility nodes:
93
316
  // { symbol: utilName }. Lets us catch collisions upfront (component-name vs
94
317
  // utility-symbol, utility-symbol vs utility-symbol from another node) before
95
318
  // they reach esbuild, where the diagnostic would surface on the portal node
96
319
  // as a confusing transpile error rather than on the offending utility node.
97
- if (!RED.settings.fcUtilSymbolOwners) {
98
- RED.settings.fcUtilSymbolOwners = {};
320
+ if (!RED.settings.portalReactUtilSymbolOwners) {
321
+ RED.settings.portalReactUtilSymbolOwners = {};
99
322
  }
100
- const utilSymbolOwners = RED.settings.fcUtilSymbolOwners;
323
+ const utilSymbolOwners = RED.settings.portalReactUtilSymbolOwners;
101
324
 
102
325
  // Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
103
326
  // portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
104
- if (!RED.settings.fcPortalSig) {
105
- RED.settings.fcPortalSig = {};
327
+ if (!RED.settings.portalReactSig) {
328
+ RED.settings.portalReactSig = {};
329
+ }
330
+ const portalSig = RED.settings.portalReactSig;
331
+
332
+ /**
333
+ * Shared WebSocket heartbeat tick.
334
+ *
335
+ * Replaces the previous per-client `setInterval(ping, 30 s)` pattern: with
336
+ * N connected browsers the old code held N intervals; this module-level
337
+ * tick walks every registered `WebSocket.Server` once every 30 s and pings
338
+ * every alive client. Result: 1 interval regardless of fan-out.
339
+ *
340
+ * The tick auto-starts when the first portal node registers its
341
+ * `WebSocket.Server` via `registerPingedServer()` and clears itself when
342
+ * the last server unregisters. `unref()` ensures it never blocks process
343
+ * exit.
344
+ *
345
+ * @description N→1 interval count; bounded liveness latency 30 s.
346
+ * @private
347
+ */
348
+ const PING_INTERVAL_MS = 30_000;
349
+ if (!RED.settings.portalReactPingedServers) {
350
+ RED.settings.portalReactPingedServers = new Set();
351
+ }
352
+ const pingedServers = RED.settings.portalReactPingedServers;
353
+
354
+ if (!RED.settings.portalReactPingTick) {
355
+ RED.settings.portalReactPingTick = { iv: null };
356
+ }
357
+ const pingTick = RED.settings.portalReactPingTick;
358
+
359
+ /**
360
+ * Single tick of the WS keep-alive sweep. For each registered ws.Server, drop
361
+ * sockets whose previous ping went unanswered (they are dead) and ping the
362
+ * survivors. The pong handler resets `_isAlive` back to true. Runs on a
363
+ * shared interval so multiple portals share one timer.
364
+ *
365
+ * @returns {void}
366
+ * @private
367
+ */
368
+ function _pingSweep() {
369
+ for (const srv of pingedServers) {
370
+ try {
371
+ srv.clients.forEach((ws) => {
372
+ if (ws._isAlive === false) {
373
+ try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
374
+ return;
375
+ }
376
+ ws._isAlive = false;
377
+ try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
378
+ });
379
+ } catch (e) {
380
+ RED.log.trace("[portal-react] ping sweep: " + e.message);
381
+ }
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Add a `WebSocket.Server` to the shared ping-tick rotation. Idempotent.
387
+ * Starts the global interval the first time the set is non-empty.
388
+ * @param {WebSocket.Server} wsServer
389
+ */
390
+ function registerPingedServer(wsServer) {
391
+ if (pingedServers.has(wsServer)) return;
392
+ pingedServers.add(wsServer);
393
+ if (!pingTick.iv) {
394
+ pingTick.iv = setInterval(_pingSweep, PING_INTERVAL_MS);
395
+ pingTick.iv.unref?.();
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Remove a `WebSocket.Server` from the shared rotation. Clears the
401
+ * interval automatically when the last server unregisters.
402
+ * @param {WebSocket.Server} wsServer
403
+ */
404
+ function unregisterPingedServer(wsServer) {
405
+ if (!pingedServers.delete(wsServer)) return;
406
+ if (pingedServers.size === 0 && pingTick.iv) {
407
+ clearInterval(pingTick.iv);
408
+ pingTick.iv = null;
409
+ }
106
410
  }
107
- const portalSig = RED.settings.fcPortalSig;
108
411
 
109
412
  // Debounced selective rebuild: coalesces multiple component changes into one pass.
110
413
  // Yields event loop between builds so HTTP server stays responsive.
@@ -118,6 +421,13 @@ module.exports = function (RED) {
118
421
  // rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
119
422
  // collapses to exactly one rebuild pass.
120
423
  let _startupPhase = true;
424
+ /**
425
+ * Lift the startup gate and flush any pending rebuild work. Called once
426
+ * from `flows:started` and once again from a 2 s failsafe `setTimeout`;
427
+ * idempotent.
428
+ * @returns {void}
429
+ * @private
430
+ */
121
431
  function _endStartupPhase() {
122
432
  if (!_startupPhase) return;
123
433
  _startupPhase = false;
@@ -127,6 +437,11 @@ module.exports = function (RED) {
127
437
  }
128
438
  }
129
439
  try {
440
+ // Subscribed ONCE per process (not per deploy).
441
+ // Component registry is module-global state that persists across deploys.
442
+ // Re-running this handler on every deploy would duplicate work without
443
+ // benefit. For per-deploy hooks use RED.events.on('flows:started', ...)
444
+ // with dedup.
130
445
  if (RED.events && typeof RED.events.once === "function") {
131
446
  RED.events.once("flows:started", _endStartupPhase);
132
447
  }
@@ -134,22 +449,49 @@ module.exports = function (RED) {
134
449
  // Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
135
450
  setTimeout(_endStartupPhase, 2000).unref?.();
136
451
 
452
+ /**
453
+ * Debounce arm — defer the next rebuild flush by 50 ms. During the
454
+ * startup phase the arm is a no-op; `_endStartupPhase` flushes directly.
455
+ * @returns {void}
456
+ * @private
457
+ */
137
458
  function _armRebuild() {
138
459
  if (_startupPhase) return; // gated — _endStartupPhase will flush
139
460
  if (_rebuildTimer) clearTimeout(_rebuildTimer);
140
461
  _rebuildTimer = setTimeout(_flushRebuild, 50);
141
462
  _rebuildTimer.unref?.();
142
463
  }
464
+ /**
465
+ * Mark a portal node id as needing rebuild on the next flush. Used by
466
+ * the portal constructor when its own config signature changed.
467
+ * @param {string} nodeId
468
+ */
143
469
  function scheduleRebuildSelf(nodeId) {
144
470
  if (!nodeId) return;
145
471
  _dirtyPortals.add(nodeId);
146
472
  _armRebuild();
147
473
  }
474
+ /**
475
+ * Mark a component / utility symbol as dirty. The next flush re-builds
476
+ * every portal that references the symbol (via `portalNeeded`,
477
+ * `portalNeededUtilities` or a raw-code regex scan in `portalCode`).
478
+ * @param {string} compName
479
+ */
148
480
  function scheduleRebuildUsing(compName) {
149
481
  if (!compName) return;
150
482
  _dirtyComps.add(compName);
151
483
  _armRebuild();
152
484
  }
485
+ /**
486
+ * Run pending rebuild callbacks. Resolves the union of:
487
+ * - portal ids in `_dirtyPortals` (self-trigger), AND
488
+ * - every portal whose `portalNeeded` / `portalNeededUtilities` / raw
489
+ * code references any name in `_dirtyComps`.
490
+ * Each callback runs through `setImmediate` chaining so the event loop
491
+ * stays responsive between heavy esbuild passes.
492
+ * @returns {void}
493
+ * @private
494
+ */
153
495
  function _flushRebuild() {
154
496
  _rebuildTimer = null;
155
497
  const dirty = new Set(_dirtyComps);
@@ -179,6 +521,12 @@ module.exports = function (RED) {
179
521
 
180
522
  const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
181
523
  let i = 0;
524
+ /**
525
+ * Trampoline over the rebuild list — yields to the event loop between
526
+ * iterations so a heavy queue does not block the HTTP server.
527
+ * @returns {void}
528
+ * @private
529
+ */
182
530
  function next() {
183
531
  if (i >= fns.length) return;
184
532
  try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
@@ -199,6 +547,7 @@ module.exports = function (RED) {
199
547
  removeRoute,
200
548
  isSafeName,
201
549
  validateSubPath,
550
+ findMissingComponentRefs,
202
551
  userDir,
203
552
  readCachedJS,
204
553
  writeCachedJS,
@@ -219,6 +568,22 @@ module.exports = function (RED) {
219
568
 
220
569
  // ── Canvas node: shared component ─────────────────────────────
221
570
 
571
+ /**
572
+ * Canvas node that registers a named React component into the shared
573
+ * component registry. One node = one named identifier; multiple
574
+ * components require multiple `fc-portal-component` nodes.
575
+ *
576
+ * Constructor side-effects:
577
+ * - validates `compName` (JS identifier + length + prototype blacklist)
578
+ * - checks duplicates against the cross-namespace owner table
579
+ * - syntax-checks the JSX via `quickCheckSyntax`
580
+ * - schedules a selective rebuild of every portal that references this name
581
+ *
582
+ * @param {ComponentNodeConfig} config
583
+ * @returns {void}
584
+ * @fires Node-RED#close via node.on("close", …) — removes registration on disable / delete.
585
+ * @private
586
+ */
222
587
  function PortalComponentNode(config) {
223
588
  RED.nodes.createNode(this, config);
224
589
  const node = this;
@@ -239,10 +604,10 @@ module.exports = function (RED) {
239
604
  node.status({
240
605
  fill: "red",
241
606
  shape: "ring",
242
- text: "duplicate: " + compName,
607
+ text: shortStatus("dup: " + compName),
243
608
  });
244
609
  node.on("close", function (_removed, done) {
245
- if (done) done();
610
+ done();
246
611
  });
247
612
  return;
248
613
  }
@@ -258,10 +623,10 @@ module.exports = function (RED) {
258
623
  node.status({
259
624
  fill: "red",
260
625
  shape: "ring",
261
- text: "duplicate symbol: " + compName,
626
+ text: shortStatus("dup sym: " + compName),
262
627
  });
263
628
  node.on("close", function (_removed, done) {
264
- if (done) done();
629
+ done();
265
630
  });
266
631
  return;
267
632
  }
@@ -274,10 +639,10 @@ module.exports = function (RED) {
274
639
 
275
640
  if (syntaxErr) {
276
641
  node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
277
- const short = syntaxErr.split("\n")[0].slice(0, 40);
278
- node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
642
+ const short = syntaxErr.split("\n")[0];
643
+ node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
279
644
  } else {
280
- node.status({ fill: "green", shape: "dot", text: compName });
645
+ node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
281
646
  }
282
647
 
283
648
  // Only rebuild portals that reference this component, and only if the code actually changed.
@@ -292,7 +657,7 @@ module.exports = function (RED) {
292
657
  delete registry[compName];
293
658
  // Portals depending on this component must rebuild (topology changed or name resolution breaks).
294
659
  scheduleRebuildUsing(compName);
295
- if (done) done();
660
+ done();
296
661
  });
297
662
  }
298
663
  RED.nodes.registerType("fc-portal-component", PortalComponentNode);
@@ -302,14 +667,44 @@ module.exports = function (RED) {
302
667
  // Parse top-level symbols from utility code: function/const/let/class declarations.
303
668
  // Used for selective inclusion (does user JSX reference any of these symbols?)
304
669
  // and for the editor "Utilities" dialog (lists exported identifiers per node).
670
+ // Code is rejected silently when oversize so the regex cannot be weaponized
671
+ // (ReDoS) by feeding multi-MB code into a sticky regex with `\s+` runs.
672
+ const MAX_UTIL_CODE_BYTES = 1_000_000;
673
+ /**
674
+ * Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
675
+ * names. Used to populate the symbol-ownership table (collision checks)
676
+ * and the editor's Utilities dialog. Oversize input is rejected silently
677
+ * (ReDoS guard — the regex has multiple `\s+` runs that could regress
678
+ * to near-linear on multi-MB payloads).
679
+ *
680
+ * @param {string} code
681
+ * @returns {Set<string>} set of declared top-level identifiers
682
+ * @example
683
+ * extractUtilitySymbols("const PI = 3.14;\nfunction add(a,b) { return a+b }");
684
+ * // → Set(2) { "PI", "add" }
685
+ */
305
686
  function extractUtilitySymbols(code) {
306
687
  const names = new Set();
688
+ if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
307
689
  const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
308
690
  let m;
309
- while ((m = re.exec(code || ""))) names.add(m[1]);
691
+ while ((m = re.exec(code))) names.add(m[1]);
310
692
  return names;
311
693
  }
312
694
 
695
+ /**
696
+ * Canvas node that contributes raw top-level JavaScript (helpers / custom
697
+ * hooks / constants) to the bundle. Unlike `fc-portal-component`, the code
698
+ * is injected *without* an IIFE wrapper — one node can declare many
699
+ * top-level symbols. Each symbol is registered with the cross-namespace
700
+ * owner table so identifiers don't collide with component names or other
701
+ * utility nodes.
702
+ *
703
+ * @param {UtilityNodeConfig} config
704
+ * @returns {void}
705
+ * @fires Node-RED#close on disable / delete — frees registry + owned symbols.
706
+ * @private
707
+ */
313
708
  function PortalUtilityNode(config) {
314
709
  RED.nodes.createNode(this, config);
315
710
  const node = this;
@@ -330,10 +725,10 @@ module.exports = function (RED) {
330
725
  node.status({
331
726
  fill: "red",
332
727
  shape: "ring",
333
- text: "duplicate: " + utilName,
728
+ text: shortStatus("dup: " + utilName),
334
729
  });
335
730
  node.on("close", function (_removed, done) {
336
- if (done) done();
731
+ done();
337
732
  });
338
733
  return;
339
734
  }
@@ -382,14 +777,14 @@ module.exports = function (RED) {
382
777
  const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
383
778
  node.error(`Utility "${utilName}": ${msgs}`);
384
779
  if (syntaxErr) {
385
- const short = syntaxErr.split("\n")[0].slice(0, 40);
386
- node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
780
+ const short = syntaxErr.split("\n")[0];
781
+ node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
387
782
  } else {
388
783
  const firstSym = conflicts[0].split(" ")[0];
389
784
  node.status({
390
785
  fill: "red",
391
786
  shape: "ring",
392
- text: "duplicate symbol: " + firstSym,
787
+ text: shortStatus("dup sym: " + firstSym),
393
788
  });
394
789
  }
395
790
  // Don't register symbols on conflict — leave the namespace clean for
@@ -401,7 +796,7 @@ module.exports = function (RED) {
401
796
  node.status({
402
797
  fill: "green",
403
798
  shape: "dot",
404
- text: "utility: " + utilName,
799
+ text: shortStatus(utilName),
405
800
  });
406
801
  }
407
802
 
@@ -431,13 +826,36 @@ module.exports = function (RED) {
431
826
  delete utilities[utilName];
432
827
  scheduleRebuildUsing(utilName);
433
828
  for (const s of removedSyms) scheduleRebuildUsing(s);
434
- if (done) done();
829
+ done();
435
830
  });
436
831
  }
437
832
  RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
438
833
 
439
834
  // ── Main node: portal-react ───────────────────────────────────
440
835
 
836
+ /**
837
+ * Main canvas node: serves a React app at `/fromcubes/<subPath>` and
838
+ * bridges its WebSocket to Node-RED wires.
839
+ *
840
+ * Lifecycle (per deploy):
841
+ * 1. Validate subPath / legacy endpoint migration.
842
+ * 2. Take ownership of the endpoint (one portal per URL).
843
+ * 3. Compute the config signature — skip rebuild if unchanged.
844
+ * 4. Register rebuild callback in `rebuildCallbacks`.
845
+ * 5. Inside `setImmediate`: mount HTTP route, attach WS upgrade handler,
846
+ * wire `input` + `close` events.
847
+ *
848
+ * Input handler routes incoming `msg` through `lib/router.js` and caches
849
+ * the payload (deep-cloned) when broadcasting so freshly-connected clients
850
+ * recover the last value.
851
+ *
852
+ * @param {PortalConfig} config
853
+ * @returns {void}
854
+ * @fires Node-RED#close
855
+ * @listens Node-RED#input
856
+ * @listens ws#connection
857
+ * @private
858
+ */
441
859
  function PortalReactNode(config) {
442
860
  RED.nodes.createNode(this, config);
443
861
  const node = this;
@@ -464,7 +882,7 @@ module.exports = function (RED) {
464
882
  node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
465
883
  }
466
884
  node.on("close", function (_removed, done) {
467
- if (done) done();
885
+ done();
468
886
  });
469
887
  return;
470
888
  }
@@ -487,10 +905,10 @@ module.exports = function (RED) {
487
905
  node.status({
488
906
  fill: "red",
489
907
  shape: "ring",
490
- text: "duplicate: " + endpoint,
908
+ text: shortStatus("dup: " + subPath),
491
909
  });
492
910
  node.on("close", function (_removed, done) {
493
- if (done) done();
911
+ done();
494
912
  });
495
913
  return;
496
914
  }
@@ -511,56 +929,96 @@ module.exports = function (RED) {
511
929
 
512
930
  const wsPath = nodeRoot + endpoint + "/_ws";
513
931
 
932
+ /**
933
+ * Refresh the canvas-node status indicator. Priority of states (highest
934
+ * first): build error > building > runtime error > CSS-fail >
935
+ * connected/0-clients. Returns early when the node is closing so a late
936
+ * rebuild promise can't flash a stale state on a torn-down node.
937
+ * @returns {void}
938
+ * @private
939
+ */
514
940
  function updateStatus() {
515
941
  if (isClosing) return;
516
942
  const st = pageState[endpoint];
517
943
  const n = clients.size;
518
- const clientTail = n > 0 ? ` [${n} client${n !== 1 ? "s" : ""}]` : "";
944
+ // Compact tail keeps within the 20-char status budget. Shape (ring vs
945
+ // dot) and fill carry the "0 clients" + "degraded" signals; we no
946
+ // longer pack them into the text.
947
+ const tail = n > 0 ? ` [${n}]` : "";
519
948
 
520
- // Preserve build-error state — don't clobber with client count until JSX is fixed.
521
- // Show "(serving last good)" suffix in degraded mode (ring shape) so it is
522
- // obvious the portal still works for connected clients despite the broken build.
523
949
  if (st && st.compiled && st.compiled.error) {
524
950
  let base;
525
- if (st.errorSource) base = "broken: " + st.errorSource;
526
- else if (st.errorKind === "missing-return") base = "missing return";
527
- else if (st.errorKind === "rebuild") base = "rebuild error";
528
- else base = "transpile error";
529
- if (st.lastGood) {
530
- node.status({
531
- fill: "red",
532
- shape: "ring",
533
- text: base + " (serving last good)" + clientTail,
534
- });
535
- } else {
536
- node.status({ fill: "red", shape: "dot", text: base + clientTail });
537
- }
951
+ if (st.errorKind === "missing-component") base = "missing: " + st.errorSource;
952
+ else if (st.errorSource) base = "broken: " + st.errorSource;
953
+ else if (st.errorKind === "missing-return") base = "no return";
954
+ else if (st.errorKind === "rebuild") base = "rebuild err";
955
+ else base = "transpile err";
956
+ // Degraded mode = ring shape (already conveys "serving last good");
957
+ // hard failure = dot. Text stays ≤20.
958
+ node.status({
959
+ fill: "red",
960
+ shape: st.lastGood ? "ring" : "dot",
961
+ text: shortStatus(base + tail),
962
+ });
538
963
  return;
539
964
  }
540
- // Preserve building state — same reason.
541
965
  if (st && st.building) {
542
- node.status({ fill: "yellow", shape: "dot", text: "building..." });
966
+ node.status({ fill: "yellow", shape: "dot", text: "building" });
543
967
  return;
544
968
  }
545
- // Build succeeded but a connected browser threw at runtime
546
- // (e.g. ReferenceError to a missing component / undefined identifier).
547
969
  if (st && st.runtimeError) {
548
970
  node.status({
549
971
  fill: "red",
550
972
  shape: "ring",
551
- text: "runtime error" + clientTail,
973
+ text: shortStatus("runtime err" + tail),
974
+ });
975
+ return;
976
+ }
977
+ // CSS generation failed but JS is fine — the portal still works,
978
+ // just unstyled. Yellow ring distinguishes from green-OK without
979
+ // claiming "broken". Cleared on the next successful CSS pass.
980
+ if (st && st.cssError) {
981
+ node.status({
982
+ fill: "yellow",
983
+ shape: "ring",
984
+ text: shortStatus("css-fail" + tail),
552
985
  });
553
986
  return;
554
987
  }
988
+ // All status text stays as English literals for now. A full i18n
989
+ // catalog migration is tracked separately — until then, mixing one
990
+ // RED._(...) call with ~10 hardcoded strings would just confuse the
991
+ // reader without giving any locale coverage.
555
992
  node.status({
556
993
  fill: n > 0 ? "green" : "grey",
557
994
  shape: n > 0 ? "dot" : "ring",
558
- text: `${endpoint}${clientTail || " [0 clients]"}`,
995
+ text: n > 0 ? "connected" + tail : "0 clients",
559
996
  });
560
997
  }
561
998
 
562
999
  // ── Rebuild: transpile JSX + update page state ────────────
563
1000
 
1001
+ /**
1002
+ * Transpile and bundle the portal's JSX into a fresh PageState entry.
1003
+ *
1004
+ * Pipeline (per call):
1005
+ * 1. Resolve needed components (transitive deps from registry)
1006
+ * 2. Resolve needed utility nodes (any symbol referenced anywhere)
1007
+ * 3. Hoist imports, dedupe across sources
1008
+ * 4. Pre-flight: syntax errors in components / utilities, missing
1009
+ * `return` in `function App`
1010
+ * 5. Read disk cache; on miss run `transpile()` (esbuild buildSync)
1011
+ * 6. Generate Tailwind CSS (cssReady promise)
1012
+ * 7. Snapshot lastGood for degraded-mode serving
1013
+ * 8. Broadcast `version` / `error` / `building` frames to live WS
1014
+ *
1015
+ * Errors set `pageState[endpoint].compiled.error` and the route handler
1016
+ * serves an error page (or the previous lastGood build with a banner).
1017
+ *
1018
+ * @returns {void}
1019
+ * @throws never — internal exceptions are caught and stored in pageState.
1020
+ * @private
1021
+ */
564
1022
  function rebuild() {
565
1023
  try {
566
1024
  // ── Pre-build: clear cache, set building state, notify browsers ──
@@ -579,20 +1037,45 @@ module.exports = function (RED) {
579
1037
  const allEntries = Object.entries(registry);
580
1038
  const needed = new Set();
581
1039
 
1040
+ // Word-boundary identifier match. Avoids prefix collisions (e.g. a
1041
+ // `Button` rebuild marking `ButtonGroup` users as needing rebuild)
1042
+ // and matches identifiers, not substrings inside other words.
1043
+ // Regexes are cached per name so we don't pay the constructor cost
1044
+ // on every recursion of addWithDeps.
1045
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1046
+ const nameReCache = new Map();
1047
+ const refRe = (n) => {
1048
+ let r = nameReCache.get(n);
1049
+ if (!r) {
1050
+ r = new RegExp(`\\b${escapeRe(n)}\\b`);
1051
+ nameReCache.set(n, r);
1052
+ }
1053
+ return r;
1054
+ };
1055
+
1056
+ /**
1057
+ * Depth-first walk that pulls a component and every transitively
1058
+ * referenced sibling into `needed`. Matching uses `\b<name>\b`
1059
+ * (cached) so a component named `Button` does not pull in
1060
+ * `ButtonGroup`.
1061
+ * @param {string} name
1062
+ * @returns {void}
1063
+ * @private
1064
+ */
582
1065
  function addWithDeps(name) {
583
1066
  if (needed.has(name)) return;
584
1067
  const entry = registry[name];
585
1068
  if (!entry) return;
586
1069
  needed.add(name);
587
1070
  for (const [other] of allEntries) {
588
- if (other !== name && entry.code.includes(other)) {
1071
+ if (other !== name && refRe(other).test(entry.code)) {
589
1072
  addWithDeps(other);
590
1073
  }
591
1074
  }
592
1075
  }
593
1076
 
594
1077
  for (const [name] of allEntries) {
595
- if (componentCode.includes(name)) {
1078
+ if (refRe(name).test(componentCode)) {
596
1079
  addWithDeps(name);
597
1080
  }
598
1081
  }
@@ -621,6 +1104,14 @@ module.exports = function (RED) {
621
1104
  return false;
622
1105
  };
623
1106
  const neededUtils = new Set();
1107
+ /**
1108
+ * Same walk as `addWithDeps` but over utility nodes — a utility is
1109
+ * included as soon as any of its top-level symbols is referenced.
1110
+ * Transitive: pulled-in utilities can in turn reference others.
1111
+ * @param {string} name
1112
+ * @returns {void}
1113
+ * @private
1114
+ */
624
1115
  function addUtilWithDeps(name) {
625
1116
  if (neededUtils.has(name)) return;
626
1117
  const u = utilities[name];
@@ -671,6 +1162,24 @@ module.exports = function (RED) {
671
1162
  const cleanCompCode = componentCode.replace(importRe, "").trim();
672
1163
  const cleanUtilJsx = utilityJsx.replace(importRe, "").trim();
673
1164
 
1165
+ // ── Check: JSX references a PascalCase tag with no definition ──
1166
+ // Catches the common foot-gun where a portal references a shared
1167
+ // component (e.g. <Header/>) without the example flow that defines
1168
+ // it being imported. Without this check the bundler silently skips
1169
+ // the missing name and the browser crashes with ReferenceError.
1170
+ let missingComps = null;
1171
+ {
1172
+ const knownNames = new Set(Object.keys(registry));
1173
+ for (const [, syms] of utilSymbols) {
1174
+ for (const s of syms) knownNames.add(s);
1175
+ }
1176
+ // Use raw componentCode (with imports intact) so the helper can see
1177
+ // `import {Canvas} from '@react-three/fiber'` and not flag Canvas
1178
+ // as a missing fc-portal-component.
1179
+ const miss = findMissingComponentRefs(componentCode, knownNames);
1180
+ if (miss.size > 0) missingComps = [...miss].sort();
1181
+ }
1182
+
674
1183
  // Dedupe imports across all sources (libs may already pull React; user
675
1184
  // and utility may import the same package).
676
1185
  const seenImports = new Set();
@@ -794,8 +1303,23 @@ module.exports = function (RED) {
794
1303
  // ── Resolve compiled (success or unified error) ──
795
1304
  let compiled;
796
1305
  let cacheHit = false;
797
- let errorKind = null; // 'component' | 'missing-return' | 'transpile'
798
- if (errorSource) {
1306
+ let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-return' | 'transpile'
1307
+ if (missingComps) {
1308
+ const list = missingComps.join(", ");
1309
+ const plural = missingComps.length > 1;
1310
+ const hint = plural
1311
+ ? `Make sure these fc-portal-components exist (e.g. import the "Shared Components" example flow): ${list}.`
1312
+ : `Make sure a fc-portal-component named "${missingComps[0]}" exists (e.g. import the "Shared Components" example flow), then redeploy.`;
1313
+ compiled = {
1314
+ js: null,
1315
+ error: `Missing component${plural ? "s" : ""}: ${list}\n\n${hint}`,
1316
+ };
1317
+ errorKind = "missing-component";
1318
+ // Re-use errorSource for status/text — first missing name + count tail.
1319
+ errorSource = plural
1320
+ ? `${missingComps[0]} +${missingComps.length - 1}`
1321
+ : missingComps[0];
1322
+ } else if (errorSource) {
799
1323
  const srcErr =
800
1324
  errorSourceKind === "utility"
801
1325
  ? utilities[errorSource].error
@@ -827,7 +1351,9 @@ module.exports = function (RED) {
827
1351
 
828
1352
  if (compiled.error) {
829
1353
  node.error(
830
- (errorKind === "component"
1354
+ (errorKind === "missing-component"
1355
+ ? "Missing component(s) in JSX: "
1356
+ : errorKind === "component"
831
1357
  ? `Component "${errorSource}" syntax error: `
832
1358
  : errorKind === "utility"
833
1359
  ? `Utility "${errorSource}" syntax error: `
@@ -879,7 +1405,15 @@ module.exports = function (RED) {
879
1405
  return { css, cssHash };
880
1406
  });
881
1407
  })().catch((err) => {
1408
+ // CSS generation failed (Tailwind compile error, missing
1409
+ // entrypoint, etc.). Surface as warn + flag pageState so
1410
+ // updateStatus() shows a yellow ring "css-fail" — the portal
1411
+ // page still loads (empty CSS), just unstyled. Cleared on
1412
+ // the next successful build.
882
1413
  node.warn("Tailwind CSS generation failed: " + err.message);
1414
+ const st = pageState[endpoint];
1415
+ if (st) st.cssError = true;
1416
+ updateStatus();
883
1417
  return { css: "", cssHash: "" };
884
1418
  })
885
1419
  : Promise.resolve({ css: "", cssHash: "" });
@@ -930,24 +1464,36 @@ module.exports = function (RED) {
930
1464
  });
931
1465
  }
932
1466
 
933
- cssReady.then(({ css, cssHash }) => {
934
- const state = pageState[endpoint];
935
- if (state && state.jsxHash === jsxHash) {
936
- state.css = css;
937
- state.cssHash = cssHash;
938
- // Snapshot current good build so future failed builds can fall back.
939
- if (!state.compiled.error && state.compiled.js) {
940
- state.lastGood = {
941
- compiledJs: state.compiled.js,
942
- contentHash: state.contentHash,
943
- cssHash,
944
- pageTitle: state.pageTitle,
945
- customHead: state.customHead,
946
- };
1467
+ cssReady
1468
+ .then(({ css, cssHash }) => {
1469
+ const state = pageState[endpoint];
1470
+ if (state && state.jsxHash === jsxHash) {
1471
+ state.css = css;
1472
+ state.cssHash = cssHash;
1473
+ // Clear css-fail flag on a successful generation. Only the
1474
+ // outer .catch above sets it, so we don't risk wiping a
1475
+ // freshly-set error mid-flight.
1476
+ if (cssHash) state.cssError = false;
1477
+ // Snapshot current good build so future failed builds can fall back.
1478
+ if (!state.compiled.error && state.compiled.js) {
1479
+ state.lastGood = {
1480
+ compiledJs: state.compiled.js,
1481
+ contentHash: state.contentHash,
1482
+ cssHash,
1483
+ pageTitle: state.pageTitle,
1484
+ customHead: state.customHead,
1485
+ };
1486
+ }
1487
+ updateStatus();
947
1488
  }
948
- updateStatus();
949
- }
950
- });
1489
+ })
1490
+ .catch((e) => {
1491
+ // Tail-handler — generateCSS already has its own .catch upstream
1492
+ // that yields { css: "", cssHash: "" }. This guards against
1493
+ // exceptions thrown inside the state-update block above so the
1494
+ // process doesn't see an UnhandledPromiseRejection.
1495
+ RED.log.warn("[portal-react] cssReady tail: " + e.message);
1496
+ });
951
1497
  } catch (e) {
952
1498
  node.error("Rebuild failed: " + e.message);
953
1499
  // Surface as a regular build error so the lastGood/degraded path,
@@ -1109,7 +1655,11 @@ module.exports = function (RED) {
1109
1655
 
1110
1656
  try {
1111
1657
  const WebSocket = require("ws");
1112
- wsServer = new WebSocket.Server({ noServer: true });
1658
+ // 1 MB hard cap on incoming WS frames — far above typical msg.output
1659
+ // sizes (a few KB of JSON) and well below the 100 MB default. Blocks
1660
+ // a hostile client from spamming oversized frames.
1661
+ wsServer = new WebSocket.Server({ noServer: true, maxPayload: 1024 * 1024 });
1662
+ registerPingedServer(wsServer);
1113
1663
 
1114
1664
  // Remove previous upgrade handler for this node (dirty deploy)
1115
1665
  if (upgradeHandlers[nodeId]) {
@@ -1198,17 +1748,11 @@ module.exports = function (RED) {
1198
1748
  }
1199
1749
 
1200
1750
  // Heartbeat — detect dead sockets via WS ping/pong. Browser
1201
- // auto-replies to ping frames, no client JS needed.
1751
+ // auto-replies to ping frames, no client JS needed. The actual
1752
+ // ping interval lives in the module-level `_pingSweep` tick;
1753
+ // each client only needs the alive flag and pong listener here.
1202
1754
  ws._isAlive = true;
1203
1755
  ws.on("pong", () => { ws._isAlive = true; });
1204
- ws._pingIv = setInterval(() => {
1205
- if (ws._isAlive === false) {
1206
- try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
1207
- return;
1208
- }
1209
- ws._isAlive = false;
1210
- try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
1211
- }, 30000);
1212
1756
 
1213
1757
  ws.on("message", (raw) => {
1214
1758
  try {
@@ -1238,6 +1782,9 @@ module.exports = function (RED) {
1238
1782
  // inbound frame.
1239
1783
  const client = { portalClient: ws._portalClient };
1240
1784
  if (portalAuth && ws._portalUser) {
1785
+ // Source is extractPortalUser() — whitelist of named
1786
+ // header reads. No untrusted key can land here, so this
1787
+ // Object.assign cannot be turned into prototype pollution.
1241
1788
  Object.assign(client, ws._portalUser);
1242
1789
  }
1243
1790
  out._client = client;
@@ -1253,7 +1800,8 @@ module.exports = function (RED) {
1253
1800
  });
1254
1801
 
1255
1802
  const detach = () => {
1256
- if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
1803
+ // No per-client interval to clear heartbeat is centralised in
1804
+ // the shared `_pingSweep` tick (see registerPingedServer).
1257
1805
  clients.delete(portalClient);
1258
1806
  if (userId) {
1259
1807
  const set = userIndex.get(userId);
@@ -1277,6 +1825,15 @@ module.exports = function (RED) {
1277
1825
  // sendTo: single point where every outbound frame passes through
1278
1826
  // the onCanSendTo hook. Strict-by-default — no opt-in per widget
1279
1827
  // type like dashboard's acceptsClientConfig.
1828
+ /**
1829
+ * Send a pre-serialised frame to one WS client, gated by the
1830
+ * `onCanSendTo` plugin hook. Returns true on successful send.
1831
+ * @param {import("ws").WebSocket} ws
1832
+ * @param {string} frame JSON-encoded payload.
1833
+ * @param {MessagePayload} msg Inspected by plugin hooks.
1834
+ * @returns {boolean}
1835
+ * @private
1836
+ */
1280
1837
  function sendTo(ws, frame, msg) {
1281
1838
  if (!ws || ws.readyState !== 1) return false;
1282
1839
  if (!hooks.allow("onCanSendTo", ws, msg)) return false;
@@ -1290,94 +1847,156 @@ module.exports = function (RED) {
1290
1847
  }
1291
1848
 
1292
1849
  node.on("input", (msg, send, done) => {
1293
- const result = router.route(msg, { clients, userIndex, sendTo });
1294
- // Cache the latest broadcast payload so freshly-connected clients
1295
- // can recover it via the `recovery` frame on connect.
1296
- if (result.mode === "broadcast") {
1297
- lastBroadcastCache.set(endpoint, msg.payload);
1850
+ // Target Node-RED ≥4.0: `done` is always present. No defensive guard.
1851
+ try {
1852
+ const result = router.route(msg, { clients, userIndex, sendTo });
1853
+ // Cache the latest broadcast payload so freshly-connected clients
1854
+ // can recover it via the `recovery` frame on connect. Deep-clone via
1855
+ // RED.util.cloneMessage so a downstream mutation cannot retroactively
1856
+ // change what a fresh client sees on connect.
1857
+ if (result.mode === "broadcast") {
1858
+ let cached;
1859
+ try {
1860
+ cached = RED.util.cloneMessage({ p: msg.payload }).p;
1861
+ } catch (_) {
1862
+ cached = msg.payload;
1863
+ }
1864
+ lastBroadcastCache.set(endpoint, cached);
1865
+ }
1866
+ updateStatus();
1867
+ done();
1868
+ } catch (err) {
1869
+ // Catch-node propagation: done(err) lets the runtime route the
1870
+ // error to a Catch node on the same tab (Node-RED docs: "this will
1871
+ // trigger any Catch nodes present on the same tab").
1872
+ done(err);
1298
1873
  }
1299
- updateStatus();
1300
- if (done) done();
1301
1874
  });
1302
1875
 
1303
1876
  // ── Cleanup on redeploy / shutdown ────────────────────────
1877
+ //
1878
+ // Teardown order (Node-RED gives us a 15 s budget before forcibly
1879
+ // killing the close handler):
1880
+ // 1. mark isClosing = true (refuse new WS upgrades)
1881
+ // 2. close all WS clients with 1001
1882
+ // 3. remove the upgrade listener from RED.server
1883
+ // 4. close the ws.Server
1884
+ // 5. clear timers / interval handles
1885
+ // 6. drop route & shared state (only when fully removed)
1886
+ // 7. done()
1887
+ //
1888
+ // `removed` is true when the node is deleted *or* disabled in the
1889
+ // editor (Node-RED docs). For both we drop persistent route + cache;
1890
+ // for redeploy (removed=false) we keep pageState[endpoint] so
1891
+ // reconnecting clients hit the same build with a smaller delay.
1304
1892
 
1305
1893
  node.on("close", (removed, done) => {
1306
- isClosing = true;
1894
+ let doneCalled = false;
1895
+ const callDone = (err) => {
1896
+ if (doneCalled) return;
1897
+ doneCalled = true;
1898
+ done(err);
1899
+ };
1900
+ // Safety net — runtime force-kills at 15 s. Resolve at 14 s if
1901
+ // teardown is somehow blocked so we don't get a hard timeout log.
1902
+ const safety = setTimeout(() => callDone(), 14_000);
1903
+ safety.unref?.();
1307
1904
 
1308
- // Remove upgrade handler
1309
- if (upgradeHandlers[nodeId]) {
1310
- RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
1311
- delete upgradeHandlers[nodeId];
1312
- }
1905
+ try {
1906
+ isClosing = true;
1313
1907
 
1314
- // Close all WS clients clear heartbeat interval BEFORE ws.close()
1315
- // so pending pings do not leak if the 'close' event is delayed.
1316
- clients.forEach((ws) => {
1317
- if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
1318
- try {
1319
- ws.close(1001, "node redeployed");
1320
- } catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
1321
- });
1322
- clients.clear();
1908
+ // Close all WS clients. Heartbeat lives in the shared module-level
1909
+ // sweep tick no per-client cleanup needed here. ws.close() is
1910
+ // non-blocking; we don't await drain.
1911
+ clients.forEach((ws) => {
1912
+ try {
1913
+ ws.close(1001, "node redeployed");
1914
+ } catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
1915
+ });
1916
+ clients.clear();
1323
1917
 
1324
- // Close WS server
1325
- if (wsServer) {
1326
- try {
1327
- wsServer.close();
1328
- } catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
1329
- wsServer = null;
1330
- }
1918
+ // Remove upgrade handler before tearing down the WS server so a
1919
+ // late upgrade request doesn't race into a half-closed wsServer.
1920
+ if (upgradeHandlers[nodeId]) {
1921
+ RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
1922
+ delete upgradeHandlers[nodeId];
1923
+ }
1331
1924
 
1332
- // Unregister rebuild callback + selective-rebuild metadata
1333
- delete rebuildCallbacks[nodeId];
1334
- delete portalNeeded[nodeId];
1335
- delete portalNeededUtilities[nodeId];
1336
- delete portalCode[nodeId];
1925
+ // Close WS server also drop it from the shared heartbeat tick.
1926
+ // When the last portal node tears down its server, the shared
1927
+ // interval auto-clears (see unregisterPingedServer).
1928
+ if (wsServer) {
1929
+ unregisterPingedServer(wsServer);
1930
+ try {
1931
+ wsServer.close();
1932
+ } catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
1933
+ wsServer = null;
1934
+ }
1337
1935
 
1338
- // Release endpoint ownership
1339
- if (endpointOwners[endpoint] === nodeId) {
1340
- delete endpointOwners[endpoint];
1341
- }
1936
+ // Unregister rebuild callback + selective-rebuild metadata
1937
+ delete rebuildCallbacks[nodeId];
1938
+ delete portalNeeded[nodeId];
1939
+ delete portalNeededUtilities[nodeId];
1940
+ delete portalCode[nodeId];
1342
1941
 
1343
- // Drop the recovery cache only on full node removal — on a
1344
- // redeploy we keep it so reconnecting clients still recover.
1345
- if (removed) {
1346
- lastBroadcastCache.delete(endpoint);
1347
- delete portalSig[nodeId];
1348
- }
1942
+ // Release endpoint ownership
1943
+ if (endpointOwners[endpoint] === nodeId) {
1944
+ delete endpointOwners[endpoint];
1945
+ }
1349
1946
 
1350
- // Clear the userIndex WS clients are already closed above, but
1351
- // the Map itself should not outlive the node instance.
1352
- userIndex.clear();
1353
-
1354
- // Break references to large objects / Promises in pageState even on
1355
- // redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
1356
- // between close and the new build these would retain closures over
1357
- // the old clients/userIndex/rebuild scope.
1358
- const st = pageState[endpoint];
1359
- if (st) {
1360
- st.cssReady = null;
1361
- st.compiled = null;
1362
- st.css = null;
1363
- }
1947
+ // Drop the recovery cache on full removal/disable; on a plain
1948
+ // redeploy keep it so reconnecting clients still recover.
1949
+ if (removed) {
1950
+ lastBroadcastCache.delete(endpoint);
1951
+ delete portalSig[nodeId];
1952
+ }
1364
1953
 
1365
- // Clean up route only when node is fully removed (not redeployed)
1366
- if (removed) {
1367
- // Delete disk cache if no other endpoint uses this hash
1368
- if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
1369
- deleteCacheFiles(lastJsxHash);
1954
+ // Clear the userIndex WS clients are already closed above, but
1955
+ // the Map itself should not outlive the node instance.
1956
+ userIndex.clear();
1957
+
1958
+ // Break references to large objects / Promises in pageState even on
1959
+ // redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
1960
+ // between close and the new build these would retain closures over
1961
+ // the old clients/userIndex/rebuild scope.
1962
+ const st = pageState[endpoint];
1963
+ if (st) {
1964
+ st.cssReady = null;
1965
+ st.compiled = null;
1966
+ st.css = null;
1370
1967
  }
1371
- delete pageState[endpoint];
1372
- removeRoute(RED.httpNode._router, endpoint);
1373
- delete registeredRoutes[endpoint];
1374
- }
1375
1968
 
1376
- if (done) done();
1969
+ // Clean up route only on full removal/disable (not on redeploy).
1970
+ if (removed) {
1971
+ // Delete disk cache if no other endpoint uses this hash
1972
+ if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
1973
+ deleteCacheFiles(lastJsxHash);
1974
+ }
1975
+ delete pageState[endpoint];
1976
+ removeRoute(RED.httpNode._router, endpoint);
1977
+ delete registeredRoutes[endpoint];
1978
+ }
1979
+
1980
+ clearTimeout(safety);
1981
+ callDone();
1982
+ } catch (err) {
1983
+ clearTimeout(safety);
1984
+ callDone(err);
1985
+ }
1377
1986
  });
1378
1987
 
1379
1988
  // ── Utilities ─────────────────────────────────────────────
1380
1989
 
1990
+ /**
1991
+ * Best-effort `JSON.stringify` + `ws.send`. Swallows write errors at
1992
+ * trace level — used for status/control frames where dropping a
1993
+ * single packet has no semantic impact (the next deploy or heartbeat
1994
+ * will reconcile state).
1995
+ * @param {import("ws").WebSocket} ws
1996
+ * @param {Object} obj
1997
+ * @returns {void}
1998
+ * @private
1999
+ */
1381
2000
  function wsSend(ws, obj) {
1382
2001
  try {
1383
2002
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
@@ -1398,13 +2017,14 @@ module.exports = function (RED) {
1398
2017
  );
1399
2018
  RED.httpAdmin.use(
1400
2019
  "/portal-react/vs",
2020
+ PERM_READ,
1401
2021
  express.static(path.join(monacoPath, "min", "vs")),
1402
2022
  );
1403
2023
 
1404
2024
  // ── Tailwind class list endpoint ────────────────────────────
1405
2025
  const { generateCandidates } = require("./tw-candidates");
1406
2026
  let twClassesCache = null;
1407
- RED.httpAdmin.get("/portal-react/tw-classes", (_req, res) => {
2027
+ RED.httpAdmin.get("/portal-react/tw-classes", PERM_READ, (_req, res) => {
1408
2028
  if (!twClassesCache) {
1409
2029
  twClassesCache = generateCandidates();
1410
2030
  }
@@ -1412,8 +2032,41 @@ module.exports = function (RED) {
1412
2032
  });
1413
2033
 
1414
2034
  // ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
2035
+ // Public (httpNode) — served to browsers loading the portal page, not the
2036
+ // editor. Hash is constrained to short hex so a hostile client cannot probe
2037
+ // for arbitrary pageState keys.
2038
+ const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
2039
+ RED.httpNode.get("/fromcubes/css/:hash.css", (req, res) => {
2040
+ const reqHash = req.params.hash;
2041
+ if (!CSS_HASH_RE.test(reqHash)) {
2042
+ res.status(400).send("Bad request");
2043
+ return;
2044
+ }
2045
+ let css = null;
2046
+ for (const ep in pageState) {
2047
+ if (pageState[ep]?.cssHash === reqHash) {
2048
+ css = pageState[ep].css;
2049
+ break;
2050
+ }
2051
+ }
2052
+ if (!css) {
2053
+ res.status(404).send("Not found");
2054
+ return;
2055
+ }
2056
+ res.set({
2057
+ "Content-Type": "text/css",
2058
+ "Cache-Control": "public, max-age=31536000, immutable",
2059
+ });
2060
+ res.send(css);
2061
+ });
2062
+ // Back-compat: legacy admin URL still works (page-builder may emit it for
2063
+ // existing builds). Apply the same hash whitelist.
1415
2064
  RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
1416
2065
  const reqHash = req.params.hash;
2066
+ if (!CSS_HASH_RE.test(reqHash)) {
2067
+ res.status(400).send("Bad request");
2068
+ return;
2069
+ }
1417
2070
  let css = null;
1418
2071
  for (const ep in pageState) {
1419
2072
  if (pageState[ep]?.cssHash === reqHash) {
@@ -1434,15 +2087,19 @@ module.exports = function (RED) {
1434
2087
 
1435
2088
  // ── Public assets folder ─────────────────────────────────────
1436
2089
  const { registerAssets } = require("./lib/assets");
1437
- registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
2090
+ registerAssets(RED, express, path.join(userDir, "fromcubes", "public"), {
2091
+ csrfGuard,
2092
+ rateLimit,
2093
+ jsonLimit: JSON_BODY_LIMIT,
2094
+ });
1438
2095
 
1439
2096
  // ── Admin API for component registry ──────────────────────────
1440
2097
 
1441
- RED.httpAdmin.get("/portal-react/registry", (_req, res) => {
2098
+ RED.httpAdmin.get("/portal-react/registry", PERM_READ, (_req, res) => {
1442
2099
  res.json(registry);
1443
2100
  });
1444
2101
 
1445
- RED.httpAdmin.post("/portal-react/registry", (req, res) => {
2102
+ RED.httpAdmin.post("/portal-react/registry", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
1446
2103
  const { name, code } = req.body || {};
1447
2104
  if (!isSafeName(name))
1448
2105
  return res.status(400).json({ error: "invalid name" });
@@ -1455,7 +2112,7 @@ module.exports = function (RED) {
1455
2112
  res.json({ ok: true });
1456
2113
  });
1457
2114
 
1458
- RED.httpAdmin.delete("/portal-react/registry/:name", (req, res) => {
2115
+ RED.httpAdmin.delete("/portal-react/registry/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
1459
2116
  const name = req.params.name;
1460
2117
  if (!isSafeName(name))
1461
2118
  return res.status(400).json({ error: "invalid name" });
@@ -1469,7 +2126,7 @@ module.exports = function (RED) {
1469
2126
 
1470
2127
  // ── Admin API for utility registry ────────────────────────────
1471
2128
 
1472
- RED.httpAdmin.get("/portal-react/utilities", (_req, res) => {
2129
+ RED.httpAdmin.get("/portal-react/utilities", PERM_READ, (_req, res) => {
1473
2130
  // Include parsed top-level symbols so the editor "Utilities" dialog can
1474
2131
  // list which identifiers each node exports.
1475
2132
  const out = {};
@@ -1483,7 +2140,7 @@ module.exports = function (RED) {
1483
2140
  res.json(out);
1484
2141
  });
1485
2142
 
1486
- RED.httpAdmin.post("/portal-react/utilities", (req, res) => {
2143
+ RED.httpAdmin.post("/portal-react/utilities", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
1487
2144
  const { name, code } = req.body || {};
1488
2145
  if (!isSafeName(name))
1489
2146
  return res.status(400).json({ error: "invalid name" });
@@ -1530,7 +2187,7 @@ module.exports = function (RED) {
1530
2187
  res.json({ ok: true, error: combinedErr || null });
1531
2188
  });
1532
2189
 
1533
- RED.httpAdmin.delete("/portal-react/utilities/:name", (req, res) => {
2190
+ RED.httpAdmin.delete("/portal-react/utilities/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
1534
2191
  const name = req.params.name;
1535
2192
  if (!isSafeName(name))
1536
2193
  return res.status(400).json({ error: "invalid name" });