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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,69 +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
- if (!RED.settings.fcCompNameOwners) {
70
- RED.settings.fcCompNameOwners = {};
292
+ // Shared namespace with fc-portal-utility nodes so a component and a utility
293
+ // can never share the same name.
294
+ if (!RED.settings.portalReactCompNameOwners) {
295
+ RED.settings.portalReactCompNameOwners = {};
296
+ }
297
+ const compNameOwners = RED.settings.portalReactCompNameOwners;
298
+
299
+ // Utility registry: populated by fc-portal-utility canvas nodes at deploy time.
300
+ // Keyed by node-level utilName (e.g. "mathHelpers"), value { code, error }.
301
+ // Each utility node may declare multiple top-level symbols inside its code block.
302
+ if (!RED.settings.portalReactUtilities) {
303
+ RED.settings.portalReactUtilities = {};
71
304
  }
72
- const compNameOwners = RED.settings.fcCompNameOwners;
305
+ const utilities = RED.settings.portalReactUtilities;
306
+
307
+ // Per-portal set of utility node names this portal depends on (transitively).
308
+ // Lets utility code changes target only portals that reference at least one
309
+ // symbol declared by the changed utility node.
310
+ if (!RED.settings.portalReactNeededUtilities) {
311
+ RED.settings.portalReactNeededUtilities = {};
312
+ }
313
+ const portalNeededUtilities = RED.settings.portalReactNeededUtilities;
314
+
315
+ // Track owner of each top-level symbol declared inside fc-portal-utility nodes:
316
+ // { symbol: utilName }. Lets us catch collisions upfront (component-name vs
317
+ // utility-symbol, utility-symbol vs utility-symbol from another node) before
318
+ // they reach esbuild, where the diagnostic would surface on the portal node
319
+ // as a confusing transpile error rather than on the offending utility node.
320
+ if (!RED.settings.portalReactUtilSymbolOwners) {
321
+ RED.settings.portalReactUtilSymbolOwners = {};
322
+ }
323
+ const utilSymbolOwners = RED.settings.portalReactUtilSymbolOwners;
73
324
 
74
325
  // Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
75
326
  // portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
76
- if (!RED.settings.fcPortalSig) {
77
- 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
+ }
78
410
  }
79
- const portalSig = RED.settings.fcPortalSig;
80
411
 
81
412
  // Debounced selective rebuild: coalesces multiple component changes into one pass.
82
413
  // Yields event loop between builds so HTTP server stays responsive.
@@ -90,6 +421,13 @@ module.exports = function (RED) {
90
421
  // rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
91
422
  // collapses to exactly one rebuild pass.
92
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
+ */
93
431
  function _endStartupPhase() {
94
432
  if (!_startupPhase) return;
95
433
  _startupPhase = false;
@@ -99,6 +437,11 @@ module.exports = function (RED) {
99
437
  }
100
438
  }
101
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.
102
445
  if (RED.events && typeof RED.events.once === "function") {
103
446
  RED.events.once("flows:started", _endStartupPhase);
104
447
  }
@@ -106,22 +449,49 @@ module.exports = function (RED) {
106
449
  // Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
107
450
  setTimeout(_endStartupPhase, 2000).unref?.();
108
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
+ */
109
458
  function _armRebuild() {
110
459
  if (_startupPhase) return; // gated — _endStartupPhase will flush
111
460
  if (_rebuildTimer) clearTimeout(_rebuildTimer);
112
461
  _rebuildTimer = setTimeout(_flushRebuild, 50);
113
462
  _rebuildTimer.unref?.();
114
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
+ */
115
469
  function scheduleRebuildSelf(nodeId) {
116
470
  if (!nodeId) return;
117
471
  _dirtyPortals.add(nodeId);
118
472
  _armRebuild();
119
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
+ */
120
480
  function scheduleRebuildUsing(compName) {
121
481
  if (!compName) return;
122
482
  _dirtyComps.add(compName);
123
483
  _armRebuild();
124
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
+ */
125
495
  function _flushRebuild() {
126
496
  _rebuildTimer = null;
127
497
  const dirty = new Set(_dirtyComps);
@@ -133,10 +503,15 @@ module.exports = function (RED) {
133
503
  if (dirty.size > 0) {
134
504
  for (const nodeId of Object.keys(rebuildCallbacks)) {
135
505
  if (targetIds.has(nodeId)) continue;
136
- const used = portalNeeded[nodeId];
506
+ const usedComps = portalNeeded[nodeId];
507
+ const usedUtils = portalNeededUtilities[nodeId];
137
508
  const raw = portalCode[nodeId] || "";
138
509
  for (const name of dirty) {
139
- if ((used && used.has(name)) || raw.includes(name)) {
510
+ if (
511
+ (usedComps && usedComps.has(name)) ||
512
+ (usedUtils && usedUtils.has(name)) ||
513
+ raw.includes(name)
514
+ ) {
140
515
  targetIds.add(nodeId);
141
516
  break;
142
517
  }
@@ -146,6 +521,12 @@ module.exports = function (RED) {
146
521
 
147
522
  const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
148
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
+ */
149
530
  function next() {
150
531
  if (i >= fns.length) return;
151
532
  try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
@@ -166,6 +547,7 @@ module.exports = function (RED) {
166
547
  removeRoute,
167
548
  isSafeName,
168
549
  validateSubPath,
550
+ findMissingComponentRefs,
169
551
  userDir,
170
552
  readCachedJS,
171
553
  writeCachedJS,
@@ -186,6 +568,22 @@ module.exports = function (RED) {
186
568
 
187
569
  // ── Canvas node: shared component ─────────────────────────────
188
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
+ */
189
587
  function PortalComponentNode(config) {
190
588
  RED.nodes.createNode(this, config);
191
589
  const node = this;
@@ -206,10 +604,29 @@ module.exports = function (RED) {
206
604
  node.status({
207
605
  fill: "red",
208
606
  shape: "ring",
209
- text: "duplicate: " + compName,
607
+ text: shortStatus("dup: " + compName),
210
608
  });
211
609
  node.on("close", function (_removed, done) {
212
- if (done) done();
610
+ done();
611
+ });
612
+ return;
613
+ }
614
+
615
+ // Cross-namespace collision: compName matches an existing utility's
616
+ // internal top-level symbol → bundle would have two declarations of the
617
+ // same identifier (component IIFE + utility raw decl).
618
+ const utilSymOwner = utilSymbolOwners[compName];
619
+ if (utilSymOwner) {
620
+ node.error(
621
+ `Component name "${compName}" conflicts with a top-level symbol declared in utility "${utilSymOwner}"`,
622
+ );
623
+ node.status({
624
+ fill: "red",
625
+ shape: "ring",
626
+ text: shortStatus("dup sym: " + compName),
627
+ });
628
+ node.on("close", function (_removed, done) {
629
+ done();
213
630
  });
214
631
  return;
215
632
  }
@@ -222,10 +639,10 @@ module.exports = function (RED) {
222
639
 
223
640
  if (syntaxErr) {
224
641
  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 });
642
+ const short = syntaxErr.split("\n")[0];
643
+ node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
227
644
  } else {
228
- node.status({ fill: "green", shape: "dot", text: compName });
645
+ node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
229
646
  }
230
647
 
231
648
  // Only rebuild portals that reference this component, and only if the code actually changed.
@@ -240,13 +657,205 @@ module.exports = function (RED) {
240
657
  delete registry[compName];
241
658
  // Portals depending on this component must rebuild (topology changed or name resolution breaks).
242
659
  scheduleRebuildUsing(compName);
243
- if (done) done();
660
+ done();
244
661
  });
245
662
  }
246
663
  RED.nodes.registerType("fc-portal-component", PortalComponentNode);
247
664
 
665
+ // ── Canvas node: shared utility (helpers / hooks / constants) ─
666
+
667
+ // Parse top-level symbols from utility code: function/const/let/class declarations.
668
+ // Used for selective inclusion (does user JSX reference any of these symbols?)
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
+ */
686
+ function extractUtilitySymbols(code) {
687
+ const names = new Set();
688
+ if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
689
+ const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
690
+ let m;
691
+ while ((m = re.exec(code))) names.add(m[1]);
692
+ return names;
693
+ }
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
+ */
708
+ function PortalUtilityNode(config) {
709
+ RED.nodes.createNode(this, config);
710
+ const node = this;
711
+ const utilName = (config.utilName || "").trim();
712
+
713
+ if (!isSafeName(utilName)) {
714
+ node.error("Invalid utility name: " + utilName);
715
+ node.status({ fill: "red", shape: "dot", text: "invalid name" });
716
+ return;
717
+ }
718
+
719
+ // Duplicate name check across components AND utilities (shared namespace)
720
+ const existingOwner = compNameOwners[utilName];
721
+ if (existingOwner && existingOwner !== node.id) {
722
+ node.error(
723
+ `Name "${utilName}" is already used by another component or utility`,
724
+ );
725
+ node.status({
726
+ fill: "red",
727
+ shape: "ring",
728
+ text: shortStatus("dup: " + utilName),
729
+ });
730
+ node.on("close", function (_removed, done) {
731
+ done();
732
+ });
733
+ return;
734
+ }
735
+ compNameOwners[utilName] = node.id;
736
+
737
+ const newCode = config.utilCode || "";
738
+ const prevCode = utilities[utilName]?.code;
739
+ const prevSyms = extractUtilitySymbols(prevCode || "");
740
+ const newSyms = extractUtilitySymbols(newCode);
741
+
742
+ // Free this node's previously-registered symbols before checking new ones,
743
+ // so a redeploy that simply renames an internal symbol doesn't see itself
744
+ // as a collision.
745
+ for (const s of prevSyms) {
746
+ if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
747
+ }
748
+
749
+ // Cross-namespace symbol collision check:
750
+ // - vs component names (a component declares `const Name = (() => ...)();`
751
+ // at top level — a utility-internal `function Name` would clash)
752
+ // - vs other utility nodes' internal symbols (raw top-level decls clash)
753
+ const conflicts = [];
754
+ for (const s of newSyms) {
755
+ if (Object.prototype.hasOwnProperty.call(registry, s)) {
756
+ conflicts.push(`${s} (component)`);
757
+ continue;
758
+ }
759
+ const symOwner = utilSymbolOwners[s];
760
+ if (symOwner && symOwner !== utilName) {
761
+ conflicts.push(`${s} (utility ${symOwner})`);
762
+ }
763
+ }
764
+
765
+ const syntaxErr = quickCheckSyntax(newCode);
766
+ const dupErr =
767
+ conflicts.length > 0
768
+ ? "duplicate symbols: " + conflicts.join(", ")
769
+ : null;
770
+ // Syntax error takes precedence (most actionable). If both, both are
771
+ // surfaced in the node.error message but status text shows syntax.
772
+ const combinedErr = syntaxErr || dupErr;
773
+
774
+ utilities[utilName] = { code: newCode, error: combinedErr };
775
+
776
+ if (combinedErr) {
777
+ const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
778
+ node.error(`Utility "${utilName}": ${msgs}`);
779
+ if (syntaxErr) {
780
+ const short = syntaxErr.split("\n")[0];
781
+ node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
782
+ } else {
783
+ const firstSym = conflicts[0].split(" ")[0];
784
+ node.status({
785
+ fill: "red",
786
+ shape: "ring",
787
+ text: shortStatus("dup sym: " + firstSym),
788
+ });
789
+ }
790
+ // Don't register symbols on conflict — leave the namespace clean for
791
+ // whichever node actually owns them. Dependent portals will surface
792
+ // "broken: <utilName>" via the standard utility-error path.
793
+ } else {
794
+ // Register all new symbols as owned by this utility node
795
+ for (const s of newSyms) utilSymbolOwners[s] = utilName;
796
+ node.status({
797
+ fill: "green",
798
+ shape: "dot",
799
+ text: shortStatus(utilName),
800
+ });
801
+ }
802
+
803
+ // Trigger rebuild of portals using this utility (any of its inner symbols).
804
+ // Push BOTH the node-level utilName AND each top-level symbol into the
805
+ // dirty set so portals matching by either are caught by selective rebuild.
806
+ if (prevCode !== newCode) {
807
+ scheduleRebuildUsing(utilName);
808
+ for (const s of newSyms) scheduleRebuildUsing(s);
809
+ // Symbols removed in this edit must also force rebuild for portals that
810
+ // referenced them — those portals will fail to find the symbol and need
811
+ // to surface an error or recompile against the new utility code.
812
+ for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
813
+ }
814
+
815
+ node.on("close", function (removed, done) {
816
+ if (compNameOwners[utilName] === node.id) {
817
+ delete compNameOwners[utilName];
818
+ }
819
+ // Remove all symbols this node currently owns (none if it errored out
820
+ // on dup-check, all of newSyms otherwise — driven by the registration
821
+ // table, not by re-parsing the code).
822
+ for (const s of Object.keys(utilSymbolOwners)) {
823
+ if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
824
+ }
825
+ const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
826
+ delete utilities[utilName];
827
+ scheduleRebuildUsing(utilName);
828
+ for (const s of removedSyms) scheduleRebuildUsing(s);
829
+ done();
830
+ });
831
+ }
832
+ RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
833
+
248
834
  // ── Main node: portal-react ───────────────────────────────────
249
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
+ */
250
859
  function PortalReactNode(config) {
251
860
  RED.nodes.createNode(this, config);
252
861
  const node = this;
@@ -273,7 +882,7 @@ module.exports = function (RED) {
273
882
  node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
274
883
  }
275
884
  node.on("close", function (_removed, done) {
276
- if (done) done();
885
+ done();
277
886
  });
278
887
  return;
279
888
  }
@@ -296,10 +905,10 @@ module.exports = function (RED) {
296
905
  node.status({
297
906
  fill: "red",
298
907
  shape: "ring",
299
- text: "duplicate: " + endpoint,
908
+ text: shortStatus("dup: " + subPath),
300
909
  });
301
910
  node.on("close", function (_removed, done) {
302
- if (done) done();
911
+ done();
303
912
  });
304
913
  return;
305
914
  }
@@ -320,56 +929,96 @@ module.exports = function (RED) {
320
929
 
321
930
  const wsPath = nodeRoot + endpoint + "/_ws";
322
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
+ */
323
940
  function updateStatus() {
324
941
  if (isClosing) return;
325
942
  const st = pageState[endpoint];
326
943
  const n = clients.size;
327
- 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}]` : "";
328
948
 
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
949
  if (st && st.compiled && st.compiled.error) {
333
950
  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
- }
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
+ });
347
963
  return;
348
964
  }
349
- // Preserve building state — same reason.
350
965
  if (st && st.building) {
351
- node.status({ fill: "yellow", shape: "dot", text: "building..." });
966
+ node.status({ fill: "yellow", shape: "dot", text: "building" });
352
967
  return;
353
968
  }
354
- // Build succeeded but a connected browser threw at runtime
355
- // (e.g. ReferenceError to a missing component / undefined identifier).
356
969
  if (st && st.runtimeError) {
357
970
  node.status({
358
971
  fill: "red",
359
972
  shape: "ring",
360
- text: "runtime error" + clientTail,
973
+ text: shortStatus("runtime err" + tail),
361
974
  });
362
975
  return;
363
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),
985
+ });
986
+ return;
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.
364
992
  node.status({
365
993
  fill: n > 0 ? "green" : "grey",
366
994
  shape: n > 0 ? "dot" : "ring",
367
- text: `${endpoint}${clientTail || " [0 clients]"}`,
995
+ text: n > 0 ? "connected" + tail : "0 clients",
368
996
  });
369
997
  }
370
998
 
371
999
  // ── Rebuild: transpile JSX + update page state ────────────
372
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
+ */
373
1022
  function rebuild() {
374
1023
  try {
375
1024
  // ── Pre-build: clear cache, set building state, notify browsers ──
@@ -388,20 +1037,45 @@ module.exports = function (RED) {
388
1037
  const allEntries = Object.entries(registry);
389
1038
  const needed = new Set();
390
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
+ */
391
1065
  function addWithDeps(name) {
392
1066
  if (needed.has(name)) return;
393
1067
  const entry = registry[name];
394
1068
  if (!entry) return;
395
1069
  needed.add(name);
396
1070
  for (const [other] of allEntries) {
397
- if (other !== name && entry.code.includes(other)) {
1071
+ if (other !== name && refRe(other).test(entry.code)) {
398
1072
  addWithDeps(other);
399
1073
  }
400
1074
  }
401
1075
  }
402
1076
 
403
1077
  for (const [name] of allEntries) {
404
- if (componentCode.includes(name)) {
1078
+ if (refRe(name).test(componentCode)) {
405
1079
  addWithDeps(name);
406
1080
  }
407
1081
  }
@@ -410,6 +1084,53 @@ module.exports = function (RED) {
410
1084
  // can target only affected portals.
411
1085
  portalNeeded[nodeId] = new Set(needed);
412
1086
 
1087
+ // ── Selective utility injection ──
1088
+ // Each utility node contributes raw top-level code that may declare
1089
+ // multiple symbols. Pull the whole node in if the user JSX OR any
1090
+ // included library component references at least one of its symbols.
1091
+ const allUtilEntries = Object.entries(utilities);
1092
+ const utilSymbols = new Map(); // utilName -> Set of symbol names
1093
+ for (const [n, u] of allUtilEntries) {
1094
+ utilSymbols.set(n, extractUtilitySymbols(u.code || ""));
1095
+ }
1096
+ const includedLibraryCode = [...needed]
1097
+ .map((n) => registry[n]?.code || "")
1098
+ .join("\n");
1099
+ const referencesAnySymbol = (text, syms) => {
1100
+ for (const s of syms) {
1101
+ const re = new RegExp(`\\b${s}\\b`);
1102
+ if (re.test(text)) return true;
1103
+ }
1104
+ return false;
1105
+ };
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
+ */
1115
+ function addUtilWithDeps(name) {
1116
+ if (neededUtils.has(name)) return;
1117
+ const u = utilities[name];
1118
+ if (!u) return;
1119
+ neededUtils.add(name);
1120
+ // Transitively include other utilities referenced by this utility's code
1121
+ for (const [other, otherSyms] of utilSymbols) {
1122
+ if (other === name) continue;
1123
+ if (referencesAnySymbol(u.code || "", otherSyms)) {
1124
+ addUtilWithDeps(other);
1125
+ }
1126
+ }
1127
+ }
1128
+ const userScanText = componentCode + "\n" + includedLibraryCode;
1129
+ for (const [n, syms] of utilSymbols) {
1130
+ if (referencesAnySymbol(userScanText, syms)) addUtilWithDeps(n);
1131
+ }
1132
+ portalNeededUtilities[nodeId] = new Set(neededUtils);
1133
+
413
1134
  // Topological sort only needed components
414
1135
  const entries = allEntries.filter(([n]) => needed.has(n));
415
1136
  entries.sort((a, b) => {
@@ -426,17 +1147,57 @@ module.exports = function (RED) {
426
1147
  )
427
1148
  .join("\n\n");
428
1149
 
429
- // Extract import statements from library/user code so they appear at top level
1150
+ // Build utility block raw top-level concat of needed utility codes.
1151
+ // No IIFE wrapper: a single utility node may declare many symbols.
1152
+ const utilityJsx = [...neededUtils]
1153
+ .map((n) => `// Utility: ${n}\n${utilities[n].code}`)
1154
+ .join("\n\n");
1155
+
1156
+ // Extract import statements from library/utility/user code so they appear at top level
430
1157
  const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
431
1158
  const libImports = libraryJsx.match(importRe) || [];
432
1159
  const userImports = componentCode.match(importRe) || [];
1160
+ const utilImports = utilityJsx.match(importRe) || [];
433
1161
  const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
434
1162
  const cleanCompCode = componentCode.replace(importRe, "").trim();
1163
+ const cleanUtilJsx = utilityJsx.replace(importRe, "").trim();
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
+
1183
+ // Dedupe imports across all sources (libs may already pull React; user
1184
+ // and utility may import the same package).
1185
+ const seenImports = new Set();
1186
+ const dedupImports = (arr) =>
1187
+ arr.filter((s) => {
1188
+ const k = s.trim();
1189
+ if (seenImports.has(k)) return false;
1190
+ seenImports.add(k);
1191
+ return true;
1192
+ });
1193
+ const allLibImports = dedupImports(libImports);
1194
+ const allUserImports = dedupImports(userImports);
1195
+ const allUtilImports = dedupImports(utilImports);
435
1196
 
436
1197
  // Warn about import * (prevents tree-shaking)
437
1198
  const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
438
- const allCode = cleanLibJsx + "\n" + cleanCompCode;
439
- for (const imp of [...libImports, ...userImports]) {
1199
+ const allCode = cleanLibJsx + "\n" + cleanUtilJsx + "\n" + cleanCompCode;
1200
+ for (const imp of [...allLibImports, ...allUserImports, ...allUtilImports]) {
440
1201
  const m = imp.match(starRe);
441
1202
  if (!m) continue;
442
1203
  const [, localName, modulePath] = m;
@@ -461,8 +1222,9 @@ module.exports = function (RED) {
461
1222
  'import React from "react";',
462
1223
  'import ReactDOM from "react-dom";',
463
1224
  'import { createRoot } from "react-dom/client";',
464
- ...libImports,
465
- ...userImports,
1225
+ ...allLibImports,
1226
+ ...allUtilImports,
1227
+ ...allUserImports,
466
1228
  "",
467
1229
  "// ── React shorthand ──",
468
1230
  "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
@@ -487,6 +1249,9 @@ module.exports = function (RED) {
487
1249
  "}",
488
1250
  ].join("\n"),
489
1251
  "",
1252
+ "// ── Utilities (helpers / hooks / constants) ──",
1253
+ cleanUtilJsx,
1254
+ "",
490
1255
  "// ── Library components ──",
491
1256
  cleanLibJsx,
492
1257
  "",
@@ -499,14 +1264,25 @@ module.exports = function (RED) {
499
1264
 
500
1265
  const jsxHash = hash(fullJsx);
501
1266
 
502
- // ── Check: any used component has its own syntax error ──
1267
+ // ── Check: any used component or utility has its own syntax error ──
503
1268
  let errorSource = null;
1269
+ let errorSourceKind = null; // 'component' | 'utility'
504
1270
  for (const name of needed) {
505
1271
  if (registry[name]?.error) {
506
1272
  errorSource = name;
1273
+ errorSourceKind = "component";
507
1274
  break;
508
1275
  }
509
1276
  }
1277
+ if (!errorSource) {
1278
+ for (const name of neededUtils) {
1279
+ if (utilities[name]?.error) {
1280
+ errorSource = name;
1281
+ errorSourceKind = "utility";
1282
+ break;
1283
+ }
1284
+ }
1285
+ }
510
1286
 
511
1287
  // ── Check: missing return in App ──
512
1288
  let missingReturn = false;
@@ -527,13 +1303,33 @@ module.exports = function (RED) {
527
1303
  // ── Resolve compiled (success or unified error) ──
528
1304
  let compiled;
529
1305
  let cacheHit = false;
530
- let errorKind = null; // 'component' | 'missing-return' | 'transpile'
531
- 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.`;
532
1313
  compiled = {
533
1314
  js: null,
534
- error: `Component "${errorSource}" has a syntax error:\n\n${registry[errorSource].error}`,
1315
+ error: `Missing component${plural ? "s" : ""}: ${list}\n\n${hint}`,
535
1316
  };
536
- errorKind = "component";
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) {
1323
+ const srcErr =
1324
+ errorSourceKind === "utility"
1325
+ ? utilities[errorSource].error
1326
+ : registry[errorSource].error;
1327
+ const label = errorSourceKind === "utility" ? "Utility" : "Component";
1328
+ compiled = {
1329
+ js: null,
1330
+ error: `${label} "${errorSource}" has a syntax error:\n\n${srcErr}`,
1331
+ };
1332
+ errorKind = errorSourceKind;
537
1333
  } else if (missingReturn) {
538
1334
  compiled = {
539
1335
  js: null,
@@ -555,8 +1351,12 @@ module.exports = function (RED) {
555
1351
 
556
1352
  if (compiled.error) {
557
1353
  node.error(
558
- (errorKind === "component"
1354
+ (errorKind === "missing-component"
1355
+ ? "Missing component(s) in JSX: "
1356
+ : errorKind === "component"
559
1357
  ? `Component "${errorSource}" syntax error: `
1358
+ : errorKind === "utility"
1359
+ ? `Utility "${errorSource}" syntax error: `
560
1360
  : errorKind === "missing-return"
561
1361
  ? "App component has no return statement: "
562
1362
  : "JSX transpile error: ") + compiled.error,
@@ -605,7 +1405,15 @@ module.exports = function (RED) {
605
1405
  return { css, cssHash };
606
1406
  });
607
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.
608
1413
  node.warn("Tailwind CSS generation failed: " + err.message);
1414
+ const st = pageState[endpoint];
1415
+ if (st) st.cssError = true;
1416
+ updateStatus();
609
1417
  return { css: "", cssHash: "" };
610
1418
  })
611
1419
  : Promise.resolve({ css: "", cssHash: "" });
@@ -656,24 +1464,36 @@ module.exports = function (RED) {
656
1464
  });
657
1465
  }
658
1466
 
659
- cssReady.then(({ css, cssHash }) => {
660
- const state = pageState[endpoint];
661
- if (state && state.jsxHash === jsxHash) {
662
- state.css = css;
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
- };
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();
673
1488
  }
674
- updateStatus();
675
- }
676
- });
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
+ });
677
1497
  } catch (e) {
678
1498
  node.error("Rebuild failed: " + e.message);
679
1499
  // Surface as a regular build error so the lastGood/degraded path,
@@ -750,7 +1570,7 @@ module.exports = function (RED) {
750
1570
  .set("Cache-Control", "no-store")
751
1571
  .type("text/html")
752
1572
  .send(
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>`,
1573
+ `<!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(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
754
1574
  );
755
1575
  return;
756
1576
  }
@@ -835,7 +1655,11 @@ module.exports = function (RED) {
835
1655
 
836
1656
  try {
837
1657
  const WebSocket = require("ws");
838
- 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);
839
1663
 
840
1664
  // Remove previous upgrade handler for this node (dirty deploy)
841
1665
  if (upgradeHandlers[nodeId]) {
@@ -924,17 +1748,11 @@ module.exports = function (RED) {
924
1748
  }
925
1749
 
926
1750
  // Heartbeat — detect dead sockets via WS ping/pong. Browser
927
- // 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.
928
1754
  ws._isAlive = true;
929
1755
  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
1756
 
939
1757
  ws.on("message", (raw) => {
940
1758
  try {
@@ -964,6 +1782,9 @@ module.exports = function (RED) {
964
1782
  // inbound frame.
965
1783
  const client = { portalClient: ws._portalClient };
966
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.
967
1788
  Object.assign(client, ws._portalUser);
968
1789
  }
969
1790
  out._client = client;
@@ -979,7 +1800,8 @@ module.exports = function (RED) {
979
1800
  });
980
1801
 
981
1802
  const detach = () => {
982
- 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).
983
1805
  clients.delete(portalClient);
984
1806
  if (userId) {
985
1807
  const set = userIndex.get(userId);
@@ -1003,6 +1825,15 @@ module.exports = function (RED) {
1003
1825
  // sendTo: single point where every outbound frame passes through
1004
1826
  // the onCanSendTo hook. Strict-by-default — no opt-in per widget
1005
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
+ */
1006
1837
  function sendTo(ws, frame, msg) {
1007
1838
  if (!ws || ws.readyState !== 1) return false;
1008
1839
  if (!hooks.allow("onCanSendTo", ws, msg)) return false;
@@ -1016,93 +1847,156 @@ module.exports = function (RED) {
1016
1847
  }
1017
1848
 
1018
1849
  node.on("input", (msg, send, done) => {
1019
- const result = router.route(msg, { clients, userIndex, sendTo });
1020
- // Cache the latest broadcast payload so freshly-connected clients
1021
- // can recover it via the `recovery` frame on connect.
1022
- if (result.mode === "broadcast") {
1023
- 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);
1024
1873
  }
1025
- updateStatus();
1026
- if (done) done();
1027
1874
  });
1028
1875
 
1029
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.
1030
1892
 
1031
1893
  node.on("close", (removed, done) => {
1032
- 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?.();
1033
1904
 
1034
- // Remove upgrade handler
1035
- if (upgradeHandlers[nodeId]) {
1036
- RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
1037
- delete upgradeHandlers[nodeId];
1038
- }
1905
+ try {
1906
+ isClosing = true;
1039
1907
 
1040
- // Close all WS clients clear heartbeat interval BEFORE ws.close()
1041
- // so pending pings do not leak if the 'close' event is delayed.
1042
- clients.forEach((ws) => {
1043
- if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
1044
- try {
1045
- ws.close(1001, "node redeployed");
1046
- } catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
1047
- });
1048
- 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();
1049
1917
 
1050
- // Close WS server
1051
- if (wsServer) {
1052
- try {
1053
- wsServer.close();
1054
- } catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
1055
- wsServer = null;
1056
- }
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
+ }
1057
1924
 
1058
- // Unregister rebuild callback + selective-rebuild metadata
1059
- delete rebuildCallbacks[nodeId];
1060
- delete portalNeeded[nodeId];
1061
- 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
+ }
1062
1935
 
1063
- // Release endpoint ownership
1064
- if (endpointOwners[endpoint] === nodeId) {
1065
- delete endpointOwners[endpoint];
1066
- }
1936
+ // Unregister rebuild callback + selective-rebuild metadata
1937
+ delete rebuildCallbacks[nodeId];
1938
+ delete portalNeeded[nodeId];
1939
+ delete portalNeededUtilities[nodeId];
1940
+ delete portalCode[nodeId];
1067
1941
 
1068
- // Drop the recovery cache only on full node removal — on a
1069
- // redeploy we keep it so reconnecting clients still recover.
1070
- if (removed) {
1071
- lastBroadcastCache.delete(endpoint);
1072
- delete portalSig[nodeId];
1073
- }
1942
+ // Release endpoint ownership
1943
+ if (endpointOwners[endpoint] === nodeId) {
1944
+ delete endpointOwners[endpoint];
1945
+ }
1074
1946
 
1075
- // Clear the userIndex WS clients are already closed above, but
1076
- // the Map itself should not outlive the node instance.
1077
- userIndex.clear();
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
- }
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
+ }
1089
1953
 
1090
- // Clean up route only when node is fully removed (not redeployed)
1091
- if (removed) {
1092
- // Delete disk cache if no other endpoint uses this hash
1093
- if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
1094
- 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;
1095
1967
  }
1096
- delete pageState[endpoint];
1097
- removeRoute(RED.httpNode._router, endpoint);
1098
- delete registeredRoutes[endpoint];
1099
- }
1100
1968
 
1101
- 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
+ }
1102
1986
  });
1103
1987
 
1104
1988
  // ── Utilities ─────────────────────────────────────────────
1105
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
+ */
1106
2000
  function wsSend(ws, obj) {
1107
2001
  try {
1108
2002
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
@@ -1123,13 +2017,14 @@ module.exports = function (RED) {
1123
2017
  );
1124
2018
  RED.httpAdmin.use(
1125
2019
  "/portal-react/vs",
2020
+ PERM_READ,
1126
2021
  express.static(path.join(monacoPath, "min", "vs")),
1127
2022
  );
1128
2023
 
1129
2024
  // ── Tailwind class list endpoint ────────────────────────────
1130
2025
  const { generateCandidates } = require("./tw-candidates");
1131
2026
  let twClassesCache = null;
1132
- RED.httpAdmin.get("/portal-react/tw-classes", (_req, res) => {
2027
+ RED.httpAdmin.get("/portal-react/tw-classes", PERM_READ, (_req, res) => {
1133
2028
  if (!twClassesCache) {
1134
2029
  twClassesCache = generateCandidates();
1135
2030
  }
@@ -1137,8 +2032,41 @@ module.exports = function (RED) {
1137
2032
  });
1138
2033
 
1139
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.
1140
2064
  RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
1141
2065
  const reqHash = req.params.hash;
2066
+ if (!CSS_HASH_RE.test(reqHash)) {
2067
+ res.status(400).send("Bad request");
2068
+ return;
2069
+ }
1142
2070
  let css = null;
1143
2071
  for (const ep in pageState) {
1144
2072
  if (pageState[ep]?.cssHash === reqHash) {
@@ -1159,15 +2087,19 @@ module.exports = function (RED) {
1159
2087
 
1160
2088
  // ── Public assets folder ─────────────────────────────────────
1161
2089
  const { registerAssets } = require("./lib/assets");
1162
- 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
+ });
1163
2095
 
1164
2096
  // ── Admin API for component registry ──────────────────────────
1165
2097
 
1166
- RED.httpAdmin.get("/portal-react/registry", (_req, res) => {
2098
+ RED.httpAdmin.get("/portal-react/registry", PERM_READ, (_req, res) => {
1167
2099
  res.json(registry);
1168
2100
  });
1169
2101
 
1170
- 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) => {
1171
2103
  const { name, code } = req.body || {};
1172
2104
  if (!isSafeName(name))
1173
2105
  return res.status(400).json({ error: "invalid name" });
@@ -1180,7 +2112,7 @@ module.exports = function (RED) {
1180
2112
  res.json({ ok: true });
1181
2113
  });
1182
2114
 
1183
- RED.httpAdmin.delete("/portal-react/registry/:name", (req, res) => {
2115
+ RED.httpAdmin.delete("/portal-react/registry/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
1184
2116
  const name = req.params.name;
1185
2117
  if (!isSafeName(name))
1186
2118
  return res.status(400).json({ error: "invalid name" });
@@ -1191,4 +2123,85 @@ module.exports = function (RED) {
1191
2123
  }
1192
2124
  res.json({ ok: true });
1193
2125
  });
2126
+
2127
+ // ── Admin API for utility registry ────────────────────────────
2128
+
2129
+ RED.httpAdmin.get("/portal-react/utilities", PERM_READ, (_req, res) => {
2130
+ // Include parsed top-level symbols so the editor "Utilities" dialog can
2131
+ // list which identifiers each node exports.
2132
+ const out = {};
2133
+ for (const [name, u] of Object.entries(utilities)) {
2134
+ out[name] = {
2135
+ code: u.code,
2136
+ error: u.error || null,
2137
+ symbols: [...extractUtilitySymbols(u.code || "")],
2138
+ };
2139
+ }
2140
+ res.json(out);
2141
+ });
2142
+
2143
+ RED.httpAdmin.post("/portal-react/utilities", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
2144
+ const { name, code } = req.body || {};
2145
+ if (!isSafeName(name))
2146
+ return res.status(400).json({ error: "invalid name" });
2147
+ const newCode = code || "";
2148
+ const prevCode = utilities[name]?.code;
2149
+ const prevSyms = extractUtilitySymbols(prevCode || "");
2150
+ const newSyms = extractUtilitySymbols(newCode);
2151
+
2152
+ // Free previously-owned symbols before conflict check (mirror node ctor)
2153
+ for (const s of prevSyms) {
2154
+ if (utilSymbolOwners[s] === name) delete utilSymbolOwners[s];
2155
+ }
2156
+
2157
+ const conflicts = [];
2158
+ for (const s of newSyms) {
2159
+ if (Object.prototype.hasOwnProperty.call(registry, s)) {
2160
+ conflicts.push(`${s} (component)`);
2161
+ continue;
2162
+ }
2163
+ const symOwner = utilSymbolOwners[s];
2164
+ if (symOwner && symOwner !== name) {
2165
+ conflicts.push(`${s} (utility ${symOwner})`);
2166
+ }
2167
+ }
2168
+
2169
+ const syntaxErr = quickCheckSyntax(newCode);
2170
+ const dupErr =
2171
+ conflicts.length > 0
2172
+ ? "duplicate symbols: " + conflicts.join(", ")
2173
+ : null;
2174
+ const combinedErr = syntaxErr || dupErr;
2175
+
2176
+ utilities[name] = { code: newCode, error: combinedErr };
2177
+
2178
+ if (!combinedErr) {
2179
+ for (const s of newSyms) utilSymbolOwners[s] = name;
2180
+ }
2181
+
2182
+ if (prevCode !== newCode) {
2183
+ scheduleRebuildUsing(name);
2184
+ for (const s of newSyms) scheduleRebuildUsing(s);
2185
+ for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
2186
+ }
2187
+ res.json({ ok: true, error: combinedErr || null });
2188
+ });
2189
+
2190
+ RED.httpAdmin.delete("/portal-react/utilities/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
2191
+ const name = req.params.name;
2192
+ if (!isSafeName(name))
2193
+ return res.status(400).json({ error: "invalid name" });
2194
+ const prev = utilities[name];
2195
+ delete utilities[name];
2196
+ // Release all symbols owned by this utility
2197
+ for (const s of Object.keys(utilSymbolOwners)) {
2198
+ if (utilSymbolOwners[s] === name) delete utilSymbolOwners[s];
2199
+ }
2200
+ if (prev) {
2201
+ const removedSyms = extractUtilitySymbols(prev.code || "");
2202
+ scheduleRebuildUsing(name);
2203
+ for (const s of removedSyms) scheduleRebuildUsing(s);
2204
+ }
2205
+ res.json({ ok: true });
2206
+ });
1194
2207
  };