@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30

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,322 +1,1345 @@
1
+ /** @module @aaqu/fromcubes-portal-react */
2
+
1
3
  /**
2
- * @aaqu/fromcubes-portal-react
4
+ * Node-RED node that serves React apps from configurable HTTP endpoints
5
+ * with live WebSocket data binding. JSX is transpiled server-side via esbuild
6
+ * at deploy time — browsers receive pre-compiled JS.
3
7
  *
4
- * Server-side JSX transpilation & bundling via esbuild.
5
- * Deploy-safe: handles rapid redeploys without leaking WS connections or stale routes.
6
- * Transpiled JS is cached by content hash — unchanged code skips recompilation on redeploy.
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");
10
- const fs = require("fs");
11
- const path = require("path");
12
- const esbuild = require("esbuild");
13
-
14
- const reactBundle = fs.readFileSync(
15
- path.join(__dirname, "vendor", "react-19.production.min.js"),
16
- "utf8",
17
- );
18
- const reactHash = crypto
19
- .createHash("sha256")
20
- .update(reactBundle)
21
- .digest("hex")
22
- .slice(0, 10);
98
+ const packageInfo = require("../package.json");
99
+
100
+ const CACHE_SCHEMA_VERSION = "portal-react-cache-v2";
23
101
 
24
102
  module.exports = function (RED) {
25
103
  // ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
26
104
  const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
27
105
  const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
28
106
 
29
- // ── Shared state ──────────────────────────────────────────────
30
- // Component registry: populated by fc-portal-component canvas nodes at deploy time
31
- if (!RED.settings.fcPortalRegistry) {
32
- RED.settings.fcPortalRegistry = {};
107
+ // ── Admin auth gate (Node-RED 4.x adminAuth) ─────────────────
108
+ // When the user configures `adminAuth` in settings.js, RED.auth.needsPermission
109
+ // returns an Express middleware that enforces the named permission scope.
110
+ // Without this gate, any endpoint mounted on RED.httpAdmin is reachable
111
+ // without auth and can read/modify the registry, utilities, and assets.
112
+ // Fallback no-op for runtimes that do not expose RED.auth.
113
+ /**
114
+ * Resolve a Node-RED permission middleware. Returns a no-op when the runtime
115
+ * does not expose `RED.auth.needsPermission` (test harnesses, Node-RED <1.0)
116
+ * so unit tests don't have to mock auth.
117
+ *
118
+ * @param {string} scope Permission scope (e.g. `"portal-react.write"`).
119
+ * @returns {import("express").RequestHandler}
120
+ * @private
121
+ */
122
+ function needsPerm(scope) {
123
+ if (RED.auth && typeof RED.auth.needsPermission === "function") {
124
+ return RED.auth.needsPermission(scope);
125
+ }
126
+ return function (_req, _res, next) { next(); };
127
+ }
128
+ const PERM_READ = needsPerm("portal-react.read");
129
+ const PERM_WRITE = needsPerm("portal-react.write");
130
+
131
+ // ── CSRF guard for admin write endpoints ──────────────────────
132
+ // The Node-RED editor sends `Node-RED-API-Version` on every XHR. Browsers
133
+ // refuse to attach custom headers on cross-origin form/fetch submissions
134
+ // without a CORS preflight, so requiring this header on write endpoints is
135
+ // sufficient CSRF protection without per-session tokens. Same trick the
136
+ // Node-RED core admin API uses for its own POST/PUT/DELETE routes.
137
+ /**
138
+ * Express middleware enforcing the `Node-RED-API-Version` header on write
139
+ * endpoints. The header is sent by the Node-RED editor XHR layer on every
140
+ * request; browser cross-origin form/fetch POSTs cannot attach custom
141
+ * headers without a CORS preflight, so requiring this header gives CSRF
142
+ * protection without per-session tokens.
143
+ *
144
+ * @param {import("express").Request} req
145
+ * @param {import("express").Response} res
146
+ * @param {Function} next
147
+ * @returns {void}
148
+ * @private
149
+ */
150
+ function csrfGuard(req, res, next) {
151
+ if (!req.get("Node-RED-API-Version")) {
152
+ return res.status(403).json({ error: "missing Node-RED-API-Version header" });
153
+ }
154
+ next();
155
+ }
156
+
157
+ // ── Token-bucket rate limiter keyed by req.ip ─────────────────
158
+ // Steady-state: RATE_LIMIT_TOKENS tokens refilled over RATE_LIMIT_WINDOW_MS.
159
+ // Default = 60 tokens / 60 s → 1 req/s sustained, 60 burst. Tunable via
160
+ // RED.settings.portalReact.rateLimit = { tokens, windowMs }. The buckets
161
+ // are pruned every 5 minutes to bound memory usage.
162
+ const rateLimitCfg = (RED.settings.portalReact &&
163
+ RED.settings.portalReact.rateLimit) || {};
164
+ const RATE_LIMIT_TOKENS = Number.isFinite(rateLimitCfg.tokens)
165
+ ? rateLimitCfg.tokens
166
+ : 60;
167
+ const RATE_LIMIT_WINDOW_MS = Number.isFinite(rateLimitCfg.windowMs)
168
+ ? rateLimitCfg.windowMs
169
+ : 60_000;
170
+ if (!RED.settings.portalReactRateBuckets) RED.settings.portalReactRateBuckets = new Map();
171
+ const rateBuckets = RED.settings.portalReactRateBuckets;
172
+
173
+ /**
174
+ * Token-bucket rate limit middleware keyed by `req.ip`. Each bucket refills
175
+ * `RATE_LIMIT_TOKENS` over `RATE_LIMIT_WINDOW_MS`. Steady-state default is
176
+ * 1 request/second with a 60-token burst. On exhaustion: HTTP 429 with
177
+ * `Retry-After`. Buckets idle > 10 minutes get pruned by a separate
178
+ * interval (see below).
179
+ *
180
+ * @param {import("express").Request} req
181
+ * @param {import("express").Response} res
182
+ * @param {Function} next
183
+ * @returns {void}
184
+ * @private
185
+ */
186
+ function rateLimit(req, res, next) {
187
+ const ip = req.ip || req.socket?.remoteAddress || "unknown";
188
+ const now = Date.now();
189
+ let b = rateBuckets.get(ip);
190
+ if (!b) {
191
+ b = { tokens: RATE_LIMIT_TOKENS, last: now };
192
+ rateBuckets.set(ip, b);
193
+ }
194
+ const elapsed = now - b.last;
195
+ b.tokens = Math.min(
196
+ RATE_LIMIT_TOKENS,
197
+ b.tokens + (elapsed * RATE_LIMIT_TOKENS) / RATE_LIMIT_WINDOW_MS,
198
+ );
199
+ b.last = now;
200
+ if (b.tokens < 1) {
201
+ res.set("Retry-After", Math.ceil(RATE_LIMIT_WINDOW_MS / 1000));
202
+ return res.status(429).json({ error: "rate limit exceeded" });
203
+ }
204
+ b.tokens -= 1;
205
+ next();
206
+ }
207
+
208
+ if (!RED.settings.portalReactRateBucketPruneIv) {
209
+ RED.settings.portalReactRateBucketPruneIv = setInterval(() => {
210
+ const now = Date.now();
211
+ for (const [ip, b] of rateBuckets) {
212
+ if (now - b.last > 10 * 60_000) rateBuckets.delete(ip);
213
+ }
214
+ }, 5 * 60_000);
215
+ RED.settings.portalReactRateBucketPruneIv.unref?.();
216
+ }
217
+
218
+ // ── Standard JSON parser with 1 MB limit ──────────────────────
219
+ // Applied per-route on POSTs that read req.body. 1 MB easily fits even
220
+ // large component files, and protects the registry endpoints from being
221
+ // used as a denial-of-service vector.
222
+ const JSON_BODY_LIMIT = "1mb";
223
+
224
+ // ── Status text helper ───────────────────────────────────────
225
+ // Node-RED appearance docs recommend status text "around 20 characters".
226
+ // Truncate with an ellipsis so long error fragments, component names, or
227
+ // endpoint paths don't spill past the node tile.
228
+ const STATUS_MAX = 20;
229
+ /**
230
+ * Truncate a status text to at most `STATUS_MAX` characters, replacing the
231
+ * final character with an ellipsis when truncation occurs. Keeps node tile
232
+ * width predictable per the Node-RED appearance guidelines.
233
+ *
234
+ * @param {*} s
235
+ * @returns {string}
236
+ */
237
+ function shortStatus(s) {
238
+ s = String(s == null ? "" : s);
239
+ return s.length <= STATUS_MAX ? s : s.slice(0, STATUS_MAX - 1) + "…";
33
240
  }
34
- const registry = RED.settings.fcPortalRegistry;
35
241
 
36
- // CSS cache: hash → css string
37
- if (!RED.settings.fcCssCache) {
38
- RED.settings.fcCssCache = {};
242
+ // ── Shared state ──────────────────────────────────────────────
243
+ // Component registry: populated by fc-portal-component canvas nodes at deploy time
244
+ if (!RED.settings.portalReactComponentRegistry) {
245
+ RED.settings.portalReactComponentRegistry = {};
39
246
  }
40
- const cssCache = RED.settings.fcCssCache;
247
+ const registry = RED.settings.portalReactComponentRegistry;
41
248
 
42
249
  // Active upgrade handlers per node id (for cleanup on redeploy)
43
- if (!RED.settings.fcUpgradeHandlers) {
44
- RED.settings.fcUpgradeHandlers = {};
250
+ if (!RED.settings.portalReactUpgradeHandlers) {
251
+ RED.settings.portalReactUpgradeHandlers = {};
45
252
  }
46
- const upgradeHandlers = RED.settings.fcUpgradeHandlers;
253
+ const upgradeHandlers = RED.settings.portalReactUpgradeHandlers;
47
254
 
48
255
  // Live page state per endpoint — route handlers read from this on each request
49
- if (!RED.settings.fcPageState) {
50
- RED.settings.fcPageState = {};
256
+ if (!RED.settings.portalReactPageState) {
257
+ RED.settings.portalReactPageState = {};
51
258
  }
52
- const pageState = RED.settings.fcPageState;
259
+ const pageState = RED.settings.portalReactPageState;
53
260
 
54
261
  // Track which endpoints already have a registered Express route
55
- if (!RED.settings.fcRegisteredRoutes) {
56
- RED.settings.fcRegisteredRoutes = {};
262
+ if (!RED.settings.portalReactRegisteredRoutes) {
263
+ RED.settings.portalReactRegisteredRoutes = {};
57
264
  }
58
- const registeredRoutes = RED.settings.fcRegisteredRoutes;
265
+ const registeredRoutes = RED.settings.portalReactRegisteredRoutes;
59
266
 
60
267
  // Rebuild callbacks: portal-react nodes register here so components can trigger re-transpile
61
- if (!RED.settings.fcRebuildCallbacks) {
62
- RED.settings.fcRebuildCallbacks = {};
268
+ if (!RED.settings.portalReactRebuildCallbacks) {
269
+ RED.settings.portalReactRebuildCallbacks = {};
63
270
  }
64
- const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
65
-
66
- // ── Helpers ───────────────────────────────────────────────────
271
+ const rebuildCallbacks = RED.settings.portalReactRebuildCallbacks;
67
272
 
68
- function hash(str) {
69
- return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
273
+ // Per-portal set of component names the portal depends on (needed set from last rebuild,
274
+ // including transitive deps). Lets component changes target only portals that use them.
275
+ if (!RED.settings.portalReactPortalNeeded) {
276
+ RED.settings.portalReactPortalNeeded = {};
70
277
  }
278
+ const portalNeeded = RED.settings.portalReactPortalNeeded;
71
279
 
72
- const twCompile = require("tailwindcss").compile;
73
- const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
74
-
75
- let twCompiled = null;
76
- async function getTwCompiled() {
77
- if (twCompiled) return twCompiled;
78
- twCompiled = await twCompile(`@import 'tailwindcss';`, {
79
- loadStylesheet: async (id, base) => {
80
- let resolved;
81
- if (id === "tailwindcss") {
82
- resolved = require.resolve("tailwindcss/index.css");
83
- } else {
84
- resolved = require.resolve(id, { paths: [base || __dirname] });
85
- }
86
- return {
87
- content: fs.readFileSync(resolved, "utf8"),
88
- base: path.dirname(resolved),
89
- };
90
- },
91
- });
92
- return twCompiled;
280
+ // Per-portal raw user JSX code string. Used as fallback to detect references to
281
+ // newly-added components that haven't been in a `needed` set yet.
282
+ if (!RED.settings.portalReactPortalCode) {
283
+ RED.settings.portalReactPortalCode = {};
93
284
  }
285
+ const portalCode = RED.settings.portalReactPortalCode;
94
286
 
95
- function transpile(jsx) {
96
- try {
97
- const buildResult = esbuild.buildSync({
98
- stdin: {
99
- contents: jsx,
100
- resolveDir: path.join(__dirname, "../../.."),
101
- loader: "jsx",
102
- },
103
- bundle: true,
104
- format: "iife",
105
- write: false,
106
- target: ["es2020"],
107
- jsx: "transform",
108
- jsxFactory: "React.createElement",
109
- jsxFragment: "React.Fragment",
110
- external: ["react", "react-dom"],
111
- define: { "process.env.NODE_ENV": '"production"' },
112
- });
113
- return { js: buildResult.outputFiles[0].text, error: null };
114
- } catch (e) {
115
- return { js: null, error: e.message };
116
- }
287
+ // Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
288
+ if (!RED.settings.portalReactEndpointOwners) {
289
+ RED.settings.portalReactEndpointOwners = {};
117
290
  }
118
-
119
- async function generateCSS(source) {
120
- const key = hash(source);
121
- if (cssCache[key]) return cssCache[key];
122
- const compiled = await getTwCompiled();
123
- const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
124
- const css = compiled.build(candidates);
125
- cssCache[key] = css;
126
- return css;
291
+ const endpointOwners = RED.settings.portalReactEndpointOwners;
292
+
293
+ // Track the endpoint each portal node currently serves: { nodeId: endpoint }.
294
+ // A sub-path change arrives as a plain redeploy (close(removed=false)), which
295
+ // intentionally keeps routes and pageState alive — without this map the old
296
+ // URL would keep serving the holding page forever. The constructor compares
297
+ // against this map and tears down the previous endpoint on mismatch.
298
+ if (!RED.settings.portalReactNodeEndpoints) {
299
+ RED.settings.portalReactNodeEndpoints = {};
127
300
  }
301
+ const nodeEndpoints = RED.settings.portalReactNodeEndpoints;
128
302
 
129
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
130
-
131
- function isSafeName(name) {
132
- return (
133
- typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
134
- );
303
+ // Track component name ownership: { compName: nodeId } — prevents duplicate component names
304
+ // Shared namespace with fc-portal-utility nodes so a component and a utility
305
+ // can never share the same name.
306
+ if (!RED.settings.portalReactCompNameOwners) {
307
+ RED.settings.portalReactCompNameOwners = {};
135
308
  }
309
+ const compNameOwners = RED.settings.portalReactCompNameOwners;
136
310
 
137
- function removeRoute(router, path) {
138
- if (!router || !router.stack) return;
139
- router.stack = router.stack.filter(
140
- (layer) => !(layer.route && layer.route.path === path),
141
- );
311
+ // Utility registry: populated by fc-portal-utility canvas nodes at deploy time.
312
+ // Keyed by node-level utilName (e.g. "mathHelpers"), value { code, error }.
313
+ // Each utility node may declare multiple top-level symbols inside its code block.
314
+ if (!RED.settings.portalReactUtilities) {
315
+ RED.settings.portalReactUtilities = {};
142
316
  }
317
+ const utilities = RED.settings.portalReactUtilities;
143
318
 
144
- // ── Canvas node: shared component ─────────────────────────────
145
-
146
- function PortalComponentNode(config) {
147
- RED.nodes.createNode(this, config);
148
- const node = this;
149
- const compName = (config.compName || "").trim();
319
+ // Per-portal set of utility node names this portal depends on (transitively).
320
+ // Lets utility code changes target only portals that reference at least one
321
+ // symbol declared by the changed utility node.
322
+ if (!RED.settings.portalReactNeededUtilities) {
323
+ RED.settings.portalReactNeededUtilities = {};
324
+ }
325
+ const portalNeededUtilities = RED.settings.portalReactNeededUtilities;
326
+
327
+ // Track owner of each top-level symbol declared inside fc-portal-utility nodes:
328
+ // { symbol: utilName }. Lets us catch collisions upfront (component-name vs
329
+ // utility-symbol, utility-symbol vs utility-symbol from another node) before
330
+ // they reach esbuild, where the diagnostic would surface on the portal node
331
+ // as a confusing transpile error rather than on the offending utility node.
332
+ if (!RED.settings.portalReactUtilSymbolOwners) {
333
+ RED.settings.portalReactUtilSymbolOwners = {};
334
+ }
335
+ const utilSymbolOwners = RED.settings.portalReactUtilSymbolOwners;
150
336
 
151
- if (!isSafeName(compName)) {
152
- node.error("Invalid component name: " + compName);
153
- node.status({ fill: "red", shape: "dot", text: "invalid name" });
154
- return;
337
+ // Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
338
+ // portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
339
+ if (!RED.settings.portalReactSig) {
340
+ RED.settings.portalReactSig = {};
341
+ }
342
+ const portalSig = RED.settings.portalReactSig;
343
+
344
+ const {
345
+ createWsHeartbeat,
346
+ } = require("./lib/ws-heartbeat");
347
+ const {
348
+ registerPingedServer,
349
+ unregisterPingedServer,
350
+ } = createWsHeartbeat(RED);
351
+
352
+ // Debounced selective rebuild: coalesces multiple component changes into one pass.
353
+ // Yields event loop between builds so HTTP server stays responsive.
354
+ let _rebuildTimer = null;
355
+ const _dirtyComps = new Set();
356
+ const _dirtyPortals = new Set();
357
+
358
+ // Startup gate: on first process start, Node-RED constructs portal/component nodes
359
+ // sequentially over a window longer than the 50ms debounce. Without gating, an early
360
+ // flush rebuilds a portal, then a late component registration triggers a second
361
+ // rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
362
+ // collapses to exactly one rebuild pass.
363
+ let _startupPhase = true;
364
+ /**
365
+ * Lift the startup gate and flush any pending rebuild work. Called once
366
+ * from `flows:started` and once again from a 2 s failsafe `setTimeout`;
367
+ * idempotent.
368
+ * @returns {void}
369
+ * @private
370
+ */
371
+ function _endStartupPhase() {
372
+ if (!_startupPhase) return;
373
+ _startupPhase = false;
374
+ if (_dirtyPortals.size > 0 || _dirtyComps.size > 0) {
375
+ if (_rebuildTimer) { clearTimeout(_rebuildTimer); _rebuildTimer = null; }
376
+ _flushRebuild();
377
+ }
378
+ }
379
+ try {
380
+ // Subscribed ONCE per process (not per deploy).
381
+ // Component registry is module-global state that persists across deploys.
382
+ // Re-running this handler on every deploy would duplicate work without
383
+ // benefit. For per-deploy hooks use RED.events.on('flows:started', ...)
384
+ // with dedup.
385
+ if (RED.events && typeof RED.events.once === "function") {
386
+ RED.events.once("flows:started", _endStartupPhase);
387
+ }
388
+ } catch (e) { RED.log.trace("[portal-react] events.once: " + e.message); }
389
+ // Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
390
+ setTimeout(_endStartupPhase, 2000).unref?.();
391
+
392
+ /**
393
+ * Debounce arm — defer the next rebuild flush by 50 ms. During the
394
+ * startup phase the arm is a no-op; `_endStartupPhase` flushes directly.
395
+ * @returns {void}
396
+ * @private
397
+ */
398
+ function _armRebuild() {
399
+ if (_startupPhase) return; // gated — _endStartupPhase will flush
400
+ if (_rebuildTimer) clearTimeout(_rebuildTimer);
401
+ _rebuildTimer = setTimeout(_flushRebuild, 50);
402
+ _rebuildTimer.unref?.();
403
+ }
404
+ /**
405
+ * Mark a portal node id as needing rebuild on the next flush. Used by
406
+ * the portal constructor when its own config signature changed.
407
+ * @param {string} nodeId
408
+ */
409
+ function scheduleRebuildSelf(nodeId) {
410
+ if (!nodeId) return;
411
+ _dirtyPortals.add(nodeId);
412
+ _armRebuild();
413
+ }
414
+ /**
415
+ * Mark a component / utility symbol as dirty. The next flush re-builds
416
+ * every portal that references the symbol (via `portalNeeded`,
417
+ * `portalNeededUtilities` or a raw-code regex scan in `portalCode`).
418
+ * @param {string} compName
419
+ */
420
+ function scheduleRebuildUsing(compName) {
421
+ if (!compName) return;
422
+ _dirtyComps.add(compName);
423
+ _armRebuild();
424
+ }
425
+ /**
426
+ * Run pending rebuild callbacks. Resolves the union of:
427
+ * - portal ids in `_dirtyPortals` (self-trigger), AND
428
+ * - every portal whose `portalNeeded` / `portalNeededUtilities` / raw
429
+ * code references any name in `_dirtyComps`.
430
+ * Each callback runs through `setImmediate` chaining so the event loop
431
+ * stays responsive between heavy esbuild passes.
432
+ * @returns {void}
433
+ * @private
434
+ */
435
+ function _flushRebuild() {
436
+ _rebuildTimer = null;
437
+ const dirty = new Set(_dirtyComps);
438
+ const selfIds = new Set(_dirtyPortals);
439
+ _dirtyComps.clear();
440
+ _dirtyPortals.clear();
441
+
442
+ const targetIds = new Set(selfIds);
443
+ if (dirty.size > 0) {
444
+ for (const nodeId of Object.keys(rebuildCallbacks)) {
445
+ if (targetIds.has(nodeId)) continue;
446
+ const usedComps = portalNeeded[nodeId];
447
+ const usedUtils = portalNeededUtilities[nodeId];
448
+ const raw = portalCode[nodeId] || "";
449
+ for (const name of dirty) {
450
+ if (
451
+ (usedComps && usedComps.has(name)) ||
452
+ (usedUtils && usedUtils.has(name)) ||
453
+ // Identifier-boundary match, not substring — a dirty `Button`
454
+ // must not rebuild portals that only use `ButtonGroup`.
455
+ identifierRe(name).test(raw)
456
+ ) {
457
+ targetIds.add(nodeId);
458
+ break;
459
+ }
460
+ }
461
+ }
155
462
  }
156
463
 
157
- registry[compName] = {
158
- code: config.compCode || "",
159
- inputs: config.compInputs
160
- ? config.compInputs
161
- .split(",")
162
- .map((s) => s.trim())
163
- .filter(Boolean)
164
- : [],
165
- outputs: config.compOutputs
166
- ? config.compOutputs
167
- .split(",")
168
- .map((s) => s.trim())
169
- .filter(Boolean)
170
- : [],
171
- };
172
-
173
- node.status({ fill: "green", shape: "dot", text: compName });
174
-
175
- // Trigger re-transpile on all portal-react nodes (after all nodes init)
176
- setImmediate(() => {
177
- Object.values(rebuildCallbacks).forEach((fn) => fn());
178
- });
179
-
180
- node.on("close", function (removed, done) {
181
- delete registry[compName];
182
- if (done) done();
183
- });
464
+ const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
465
+ let i = 0;
466
+ /**
467
+ * Trampoline over the rebuild list — awaits each (async) rebuild so
468
+ * builds run sequentially, and yields to the event loop between
469
+ * iterations so a heavy queue does not block the HTTP server.
470
+ * @returns {Promise<void>}
471
+ * @private
472
+ */
473
+ async function next() {
474
+ if (i >= fns.length) return;
475
+ try { await fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
476
+ i++;
477
+ if (i < fns.length) setImmediate(next);
478
+ }
479
+ next();
184
480
  }
185
- RED.nodes.registerType("fc-portal-component", PortalComponentNode);
481
+
482
+ // ── Load modules ─────────────────────────────────────────────
483
+ const helpers = require("./lib/helpers")(RED);
484
+ const {
485
+ hash,
486
+ transpile,
487
+ quickCheckSyntax,
488
+ generateCSS,
489
+ extractPortalUser,
490
+ serveableHash,
491
+ hasFreshBuild,
492
+ removeRoute,
493
+ isSafeName,
494
+ validateSubPath,
495
+ findMissingComponentRefs,
496
+ identifierRe,
497
+ userDir,
498
+ readCachedJS,
499
+ writeCachedJS,
500
+ readCachedCSS,
501
+ writeCachedCSS,
502
+ deleteCacheFiles,
503
+ isHashInUse,
504
+ } = helpers;
505
+ const { buildPage, buildErrorPage } = require("./lib/page-builder");
506
+ const { createPortalPageHandler } = require("./lib/portal-page-route");
507
+ const {
508
+ extractUtilitySymbols,
509
+ registerRegistryNodes,
510
+ } = require("./lib/registry-nodes");
511
+ const hooks = require("./lib/hooks")(RED);
512
+ const router = require("./lib/router");
513
+
514
+ // Per-process cache of the last broadcast payload per endpoint.
515
+ // Lets a freshly-connected client see the most recent broadcast value
516
+ // (similar to dashboard2's lastMsg recovery). Sent as a distinct
517
+ // `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
518
+ const lastBroadcastCache = new Map();
519
+
520
+ registerRegistryNodes(RED, {
521
+ registry,
522
+ utilities,
523
+ compNameOwners,
524
+ utilSymbolOwners,
525
+ isSafeName,
526
+ quickCheckSyntax,
527
+ shortStatus,
528
+ scheduleRebuildUsing,
529
+ });
186
530
 
187
531
  // ── Main node: portal-react ───────────────────────────────────
188
532
 
533
+ /**
534
+ * Main canvas node: serves a React app at `/fromcubes/<subPath>` and
535
+ * bridges its WebSocket to Node-RED wires.
536
+ *
537
+ * Lifecycle (per deploy):
538
+ * 1. Validate subPath / legacy endpoint migration.
539
+ * 2. Take ownership of the endpoint (one portal per URL).
540
+ * 3. Compute the config signature — skip rebuild if unchanged.
541
+ * 4. Register rebuild callback in `rebuildCallbacks`.
542
+ * 5. Inside `setImmediate`: mount HTTP route, attach WS upgrade handler,
543
+ * wire `input` + `close` events.
544
+ *
545
+ * Input handler routes incoming `msg` through `lib/router.js` and caches
546
+ * the payload (deep-cloned) when broadcasting so freshly-connected clients
547
+ * recover the last value.
548
+ *
549
+ * @param {PortalConfig} config
550
+ * @returns {void}
551
+ * @fires Node-RED#close
552
+ * @listens Node-RED#input
553
+ * @listens ws#connection
554
+ * @private
555
+ */
189
556
  function PortalReactNode(config) {
190
557
  RED.nodes.createNode(this, config);
191
558
  const node = this;
192
559
  const nodeId = node.id;
193
560
 
194
561
  // Config
195
- const endpoint = (config.endpoint || "/portal").replace(/\/+$/, "");
562
+ const subPathResult = validateSubPath(config.subPath);
563
+ const legacyEndpoint =
564
+ typeof config.endpoint === "string" && config.endpoint.trim().length > 0
565
+ ? config.endpoint.trim()
566
+ : null;
567
+
568
+ if (!subPathResult.ok) {
569
+ // No valid subPath. If there's a legacy endpoint, hard-fail with a
570
+ // migration message; otherwise fail on the sub-path error.
571
+ if (legacyEndpoint) {
572
+ node.error(
573
+ `Legacy 'endpoint' field detected ("${legacyEndpoint}"). ` +
574
+ "Open the node, set a Sub-path (served under /fromcubes/<sub-path>), and redeploy.",
575
+ );
576
+ node.status({ fill: "red", shape: "ring", text: "legacy endpoint" });
577
+ } else {
578
+ node.error("Invalid sub-path: " + subPathResult.error);
579
+ node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
580
+ }
581
+ node.on("close", function (_removed, done) {
582
+ done();
583
+ });
584
+ return;
585
+ }
586
+ const subPath = subPathResult.value;
587
+ const endpoint = "/fromcubes/" + subPath;
588
+
196
589
  const componentCode = config.componentCode || "";
197
590
  const pageTitle = config.pageTitle || "Portal";
198
591
  const customHead = config.customHead || "";
592
+ const portalAuth = config.portalAuth === true;
593
+ const showWsStatus = config.showWsStatus === true;
594
+ const libs = config.libs || [];
595
+
596
+ // ── Duplicate endpoint check ──
597
+ const existingOwner = endpointOwners[endpoint];
598
+ if (existingOwner && existingOwner !== nodeId) {
599
+ node.error(
600
+ `Endpoint "${endpoint}" is already used by another portal node`,
601
+ );
602
+ node.status({
603
+ fill: "red",
604
+ shape: "ring",
605
+ text: shortStatus("dup: " + subPath),
606
+ });
607
+ node.on("close", function (_removed, done) {
608
+ done();
609
+ });
610
+ return;
611
+ }
612
+ endpointOwners[endpoint] = nodeId;
613
+
614
+ // ── Sub-path changed on redeploy → tear down the previous endpoint ──
615
+ // Route, pageState, cache and recovery entries of the old URL must go,
616
+ // or it keeps serving the "Building…" holding page until restart.
617
+ const prevEndpoint = nodeEndpoints[nodeId];
618
+ if (prevEndpoint && prevEndpoint !== endpoint) {
619
+ const oldSt = pageState[prevEndpoint];
620
+ if (oldSt?.jsxHash && !isHashInUse(oldSt.jsxHash, pageState, prevEndpoint)) {
621
+ deleteCacheFiles(oldSt.jsxHash);
622
+ }
623
+ delete pageState[prevEndpoint];
624
+ removeRoute(RED.httpNode._router, prevEndpoint);
625
+ delete registeredRoutes[prevEndpoint];
626
+ if (endpointOwners[prevEndpoint] === nodeId) {
627
+ delete endpointOwners[prevEndpoint];
628
+ }
629
+ lastBroadcastCache.delete(prevEndpoint);
630
+ }
631
+ nodeEndpoints[nodeId] = endpoint;
199
632
 
200
633
  // State
201
- const clients = new Set();
202
- let lastPayload = null;
634
+ const clients = new Map(); // portalClient → ws
635
+ const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
203
636
  let wsServer = null;
204
637
  let isClosing = false;
638
+ let lastJsxHash = null;
639
+
640
+ if (libs.length > 0) {
641
+ node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
642
+ } else {
643
+ node.status({ fill: "yellow", shape: "ring", text: "starting..." });
644
+ }
205
645
 
206
646
  const wsPath = nodeRoot + endpoint + "/_ws";
207
647
 
648
+ /**
649
+ * Refresh the canvas-node status indicator. Priority of states (highest
650
+ * first): build error > building > runtime error > CSS-fail >
651
+ * connected/0-clients. Returns early when the node is closing so a late
652
+ * rebuild promise can't flash a stale state on a torn-down node.
653
+ * @returns {void}
654
+ * @private
655
+ */
656
+ function updateStatus() {
657
+ if (isClosing) return;
658
+ const st = pageState[endpoint];
659
+ const n = clients.size;
660
+ // Compact tail keeps within the 20-char status budget. Shape (ring vs
661
+ // dot) and fill carry the "0 clients" + "degraded" signals; we no
662
+ // longer pack them into the text.
663
+ const tail = n > 0 ? ` [${n}]` : "";
664
+
665
+ if (st && st.compiled && st.compiled.error) {
666
+ let base;
667
+ if (st.errorKind === "missing-component") base = "missing: " + st.errorSource;
668
+ else if (st.errorSource) base = "broken: " + st.errorSource;
669
+ else if (st.errorKind === "missing-app") base = "no App";
670
+ else if (st.errorKind === "missing-return") base = "no return";
671
+ else if (st.errorKind === "rebuild") base = "rebuild err";
672
+ else base = "transpile err";
673
+ // Degraded mode = ring shape (already conveys "serving last good");
674
+ // hard failure = dot. Text stays ≤20.
675
+ node.status({
676
+ fill: "red",
677
+ shape: st.lastGood ? "ring" : "dot",
678
+ text: shortStatus(base + tail),
679
+ });
680
+ return;
681
+ }
682
+ if (st && st.building) {
683
+ node.status({ fill: "yellow", shape: "dot", text: "building…" });
684
+ return;
685
+ }
686
+ if (st && st.runtimeError) {
687
+ node.status({
688
+ fill: "red",
689
+ shape: "ring",
690
+ text: shortStatus("runtime err" + tail),
691
+ });
692
+ return;
693
+ }
694
+ // CSS generation failed but JS is fine — the portal still works,
695
+ // just unstyled. Yellow ring distinguishes from green-OK without
696
+ // claiming "broken". Cleared on the next successful CSS pass.
697
+ if (st && st.cssError) {
698
+ node.status({
699
+ fill: "yellow",
700
+ shape: "ring",
701
+ text: shortStatus("css-fail" + tail),
702
+ });
703
+ return;
704
+ }
705
+ // All status text stays as English literals for now. A full i18n
706
+ // catalog migration is tracked separately — until then, mixing one
707
+ // RED._(...) call with ~10 hardcoded strings would just confuse the
708
+ // reader without giving any locale coverage.
709
+ node.status({
710
+ fill: n > 0 ? "green" : "grey",
711
+ shape: n > 0 ? "dot" : "ring",
712
+ text: n > 0 ? "connected" + tail : "0 clients",
713
+ });
714
+ }
715
+
208
716
  // ── Rebuild: transpile JSX + update page state ────────────
209
717
 
210
- function rebuild() {
211
- // Topological sort: components used by others come first
212
- const entries = Object.entries(registry);
213
- const names = entries.map(([n]) => n);
214
- entries.sort((a, b) => {
215
- const aUsesB = a[1].code.includes(b[0]);
216
- const bUsesA = b[1].code.includes(a[0]);
217
- if (aUsesB && !bUsesA) return 1; // a depends on b → b first
218
- if (bUsesA && !aUsesB) return -1; // b depends on a → a first
219
- return 0;
220
- });
221
- const libraryJsx = entries
222
- .map(([name, c]) =>
223
- `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`
224
- )
225
- .join("\n\n");
226
-
227
- const fullJsx = [
228
- "// ── React shorthand ──",
229
- "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
230
- "const { createContext, memo, forwardRef, Fragment } = React;",
231
- "",
232
- "// ── useNodeRed hook ──",
233
- `function useNodeRed() {
234
- const [data, setData] = React.useState(window.__NR._lastData);
235
- React.useEffect(() => {
236
- return window.__NR.subscribe(setData);
237
- }, []);
238
- const send = React.useCallback((payload, topic) => {
239
- window.__NR.send(payload, topic);
240
- }, []);
241
- return { data, send };
242
- }`,
243
- "",
244
- "// ── Library components ──",
245
- libraryJsx,
246
- "",
247
- "// ── View component ──",
248
- componentCode,
249
- "",
250
- "// ── Mount ──",
251
- "ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
252
- ].join("\n");
718
+ /**
719
+ * Transpile and bundle the portal's JSX into a fresh PageState entry.
720
+ *
721
+ * Pipeline (per call):
722
+ * 1. Resolve needed components (transitive deps from registry)
723
+ * 2. Resolve needed utility nodes (any symbol referenced anywhere)
724
+ * 3. Hoist imports, dedupe across sources
725
+ * 4. Pre-flight: syntax errors in components / utilities, missing
726
+ * `return` in `function App`
727
+ * 5. Read disk cache; on miss run `transpile()` (esbuild buildSync)
728
+ * 6. Generate Tailwind CSS (cssReady promise)
729
+ * 7. Snapshot lastGood for degraded-mode serving
730
+ * 8. Broadcast `version` / `error` / `building` frames to live WS
731
+ *
732
+ * Errors set `pageState[endpoint].compiled.error` and the route handler
733
+ * serves an error page (or the previous lastGood build with a banner).
734
+ *
735
+ * @returns {Promise<void>}
736
+ * @throws never internal exceptions are caught and stored in pageState.
737
+ * @private
738
+ */
739
+ async function rebuild() {
740
+ try {
741
+ // ── Pre-build: clear cache, set building state, notify browsers ──
742
+ const prevState = pageState[endpoint];
743
+ const prevHash = prevState?.jsxHash;
744
+ if (prevHash && !isHashInUse(prevHash, pageState, endpoint)) {
745
+ deleteCacheFiles(prevHash);
746
+ }
747
+ pageState[endpoint] = { building: true, wsPath, pageTitle };
748
+ updateStatus();
749
+ clients.forEach((ws) => {
750
+ try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
751
+ });
253
752
 
254
- const compiled = transpile(fullJsx);
753
+ // Selective injection: only include components referenced in user code (+ transitive deps)
754
+ const allEntries = Object.entries(registry);
755
+ const needed = new Set();
756
+
757
+ /**
758
+ * Depth-first walk that pulls a component and every transitively
759
+ * referenced sibling into `needed`. Matching uses `identifierRe`
760
+ * (cached, identifier-boundary) so a component named `Button` does
761
+ * not pull in `ButtonGroup`.
762
+ * @param {string} name
763
+ * @returns {void}
764
+ * @private
765
+ */
766
+ function addWithDeps(name) {
767
+ if (needed.has(name)) return;
768
+ const entry = registry[name];
769
+ if (!entry) return;
770
+ needed.add(name);
771
+ for (const [other] of allEntries) {
772
+ if (other !== name && identifierRe(other).test(entry.code)) {
773
+ addWithDeps(other);
774
+ }
775
+ }
776
+ }
255
777
 
256
- if (compiled.error) {
257
- node.error("JSX transpile error: " + compiled.error);
258
- node.status({ fill: "red", shape: "dot", text: "transpile error" });
259
- } else {
260
- node.status({ fill: "grey", shape: "ring", text: endpoint });
261
- }
778
+ for (const [name] of allEntries) {
779
+ if (identifierRe(name).test(componentCode)) {
780
+ addWithDeps(name);
781
+ }
782
+ }
262
783
 
263
- const cssHashReady = !compiled.error
264
- ? generateCSS(fullJsx)
265
- .then((css) => {
266
- node.status({ fill: "grey", shape: "ring", text: endpoint });
267
- return css ? hash(fullJsx) : "";
268
- })
269
- .catch((err) => {
784
+ // Remember which components this portal depends on, so component changes
785
+ // can target only affected portals.
786
+ portalNeeded[nodeId] = new Set(needed);
787
+
788
+ // ── Selective utility injection ──
789
+ // Each utility node contributes raw top-level code that may declare
790
+ // multiple symbols. Pull the whole node in if the user JSX OR any
791
+ // included library component references at least one of its symbols.
792
+ const allUtilEntries = Object.entries(utilities);
793
+ const utilSymbols = new Map(); // utilName -> Set of symbol names
794
+ for (const [n, u] of allUtilEntries) {
795
+ utilSymbols.set(n, extractUtilitySymbols(u.code || ""));
796
+ }
797
+ const includedLibraryCode = [...needed]
798
+ .map((n) => registry[n]?.code || "")
799
+ .join("\n");
800
+ // identifierRe escapes regex metacharacters and handles `$`-edged
801
+ // names — symbols come from user code, so both matter here.
802
+ const referencesAnySymbol = (text, syms) => {
803
+ for (const s of syms) {
804
+ if (identifierRe(s).test(text)) return true;
805
+ }
806
+ return false;
807
+ };
808
+ const neededUtils = new Set();
809
+ /**
810
+ * Same walk as `addWithDeps` but over utility nodes — a utility is
811
+ * included as soon as any of its top-level symbols is referenced.
812
+ * Transitive: pulled-in utilities can in turn reference others.
813
+ * @param {string} name
814
+ * @returns {void}
815
+ * @private
816
+ */
817
+ function addUtilWithDeps(name) {
818
+ if (neededUtils.has(name)) return;
819
+ const u = utilities[name];
820
+ if (!u) return;
821
+ neededUtils.add(name);
822
+ // Transitively include other utilities referenced by this utility's code
823
+ for (const [other, otherSyms] of utilSymbols) {
824
+ if (other === name) continue;
825
+ if (referencesAnySymbol(u.code || "", otherSyms)) {
826
+ addUtilWithDeps(other);
827
+ }
828
+ }
829
+ }
830
+ const userScanText = componentCode + "\n" + includedLibraryCode;
831
+ for (const [n, syms] of utilSymbols) {
832
+ if (referencesAnySymbol(userScanText, syms)) addUtilWithDeps(n);
833
+ }
834
+ portalNeededUtilities[nodeId] = new Set(neededUtils);
835
+
836
+ // Topological sort of needed components (Kahn). Edge b→a when a's
837
+ // code references b, i.e. b must be emitted first. A pairwise
838
+ // Array.sort comparator is NOT transitive over dependency chains
839
+ // (A→B→C without a direct A→C reference), so a real topo sort is
840
+ // required. Cycles (mutual references) fall back to registry
841
+ // insertion order for the remainder.
842
+ const neededNames = allEntries
843
+ .filter(([n]) => needed.has(n))
844
+ .map(([n]) => n);
845
+ const indegree = new Map(neededNames.map((n) => [n, 0]));
846
+ const dependents = new Map(neededNames.map((n) => [n, []]));
847
+ for (const a of neededNames) {
848
+ const codeA = registry[a].code;
849
+ for (const b of neededNames) {
850
+ if (a !== b && identifierRe(b).test(codeA)) {
851
+ dependents.get(b).push(a);
852
+ indegree.set(a, indegree.get(a) + 1);
853
+ }
854
+ }
855
+ }
856
+ const topoQueue = neededNames.filter((n) => indegree.get(n) === 0);
857
+ const ordered = [];
858
+ while (topoQueue.length > 0) {
859
+ const n = topoQueue.shift();
860
+ ordered.push(n);
861
+ for (const d of dependents.get(n)) {
862
+ indegree.set(d, indegree.get(d) - 1);
863
+ if (indegree.get(d) === 0) topoQueue.push(d);
864
+ }
865
+ }
866
+ if (ordered.length < neededNames.length) {
867
+ const emitted = new Set(ordered);
868
+ for (const n of neededNames) if (!emitted.has(n)) ordered.push(n);
869
+ }
870
+ const libraryJsx = ordered
871
+ .map(
872
+ (name) =>
873
+ `// Library: ${name}\nconst ${name} = (() => {\n${registry[name].code}\nreturn ${name};\n})();`,
874
+ )
875
+ .join("\n\n");
876
+
877
+ // Build utility block — raw top-level concat of needed utility codes.
878
+ // No IIFE wrapper: a single utility node may declare many symbols.
879
+ const utilityJsx = [...neededUtils]
880
+ .map((n) => `// Utility: ${n}\n${utilities[n].code}`)
881
+ .join("\n\n");
882
+
883
+ // Extract import statements from library/utility/user code so they appear at top level
884
+ const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
885
+ const libImports = libraryJsx.match(importRe) || [];
886
+ const userImports = componentCode.match(importRe) || [];
887
+ const utilImports = utilityJsx.match(importRe) || [];
888
+ const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
889
+ const cleanCompCode = componentCode.replace(importRe, "").trim();
890
+ const cleanUtilJsx = utilityJsx.replace(importRe, "").trim();
891
+
892
+ // ── Check: JSX references a PascalCase tag with no definition ──
893
+ // Catches the common foot-gun where a portal references a shared
894
+ // component (e.g. <Header/>) without the example flow that defines
895
+ // it being imported. Without this check the bundler silently skips
896
+ // the missing name and the browser crashes with ReferenceError.
897
+ let missingComps = null;
898
+ {
899
+ const knownNames = new Set(Object.keys(registry));
900
+ for (const [, syms] of utilSymbols) {
901
+ for (const s of syms) knownNames.add(s);
902
+ }
903
+ // Use raw componentCode (with imports intact) so the helper can see
904
+ // `import {Canvas} from '@react-three/fiber'` and not flag Canvas
905
+ // as a missing fc-portal-component.
906
+ const miss = findMissingComponentRefs(componentCode, knownNames);
907
+ if (miss.size > 0) missingComps = [...miss].sort();
908
+ }
909
+
910
+ // Dedupe imports across all sources (libs may already pull React; user
911
+ // and utility may import the same package).
912
+ const seenImports = new Set();
913
+ const dedupImports = (arr) =>
914
+ arr.filter((s) => {
915
+ const k = s.trim();
916
+ if (seenImports.has(k)) return false;
917
+ seenImports.add(k);
918
+ return true;
919
+ });
920
+ const allLibImports = dedupImports(libImports);
921
+ const allUserImports = dedupImports(userImports);
922
+ const allUtilImports = dedupImports(utilImports);
923
+
924
+ // Warn about import * (prevents tree-shaking)
925
+ const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
926
+ const allCode = cleanLibJsx + "\n" + cleanUtilJsx + "\n" + cleanCompCode;
927
+ for (const imp of [...allLibImports, ...allUserImports, ...allUtilImports]) {
928
+ const m = imp.match(starRe);
929
+ if (!m) continue;
930
+ const [, localName, modulePath] = m;
931
+ const propRe = new RegExp(
932
+ `\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`,
933
+ "g",
934
+ );
935
+ const props = new Set();
936
+ let pm;
937
+ while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
938
+ if (props.size > 0) {
939
+ const named = [...props].sort().join(", ");
940
+ node.warn(
941
+ `"import * as ${localName}" bundles entire ${modulePath} library. ` +
942
+ `For smaller builds use: import { ${named} } from '${modulePath}'`,
943
+ );
944
+ }
945
+ }
946
+
947
+ const fullJsx = [
948
+ "// ── Imports ──",
949
+ 'import React from "react";',
950
+ 'import ReactDOM from "react-dom";',
951
+ 'import { createRoot } from "react-dom/client";',
952
+ ...allLibImports,
953
+ ...allUtilImports,
954
+ ...allUserImports,
955
+ "",
956
+ "// ── React shorthand ──",
957
+ "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
958
+ "const { createContext, memo, forwardRef, Fragment } = React;",
959
+ "",
960
+ "// ── useNodeRed hook ──",
961
+ [
962
+ "function useNodeRed(opts) {",
963
+ " // opts.ignoreRecovery = true → ignore the cached last-broadcast",
964
+ " // frame the server sends on connect; data stays undefined until",
965
+ " // a fresh broadcast arrives. Latched once globally — strictest",
966
+ " // call wins (any caller asking to ignore disables recovery for all).",
967
+ " if (opts && opts.ignoreRecovery) window.__NR._ignoreRecovery = true;",
968
+ " const [data, setData] = React.useState(window.__NR._lastData);",
969
+ " React.useEffect(() => window.__NR.subscribe(setData), []);",
970
+ " const send = React.useCallback((payload, topic) => {",
971
+ " window.__NR.send(payload, topic);",
972
+ " }, []);",
973
+ " const user = window.__NR._user || null;",
974
+ " const portalClient = window.__NR._portalClient;",
975
+ " return { data, send, user, portalClient };",
976
+ "}",
977
+ ].join("\n"),
978
+ "",
979
+ "// ── Utilities (helpers / hooks / constants) ──",
980
+ cleanUtilJsx,
981
+ "",
982
+ "// ── Library components ──",
983
+ cleanLibJsx,
984
+ "",
985
+ "// ── View component ──",
986
+ cleanCompCode,
987
+ "",
988
+ "// ── Mount ──",
989
+ "createRoot(document.getElementById('root')).render(React.createElement(App));",
990
+ ].join("\n");
991
+
992
+ const jsxHash = hash(
993
+ [CACHE_SCHEMA_VERSION, packageInfo.version, fullJsx].join("\0"),
994
+ );
995
+
996
+ // ── Check: any used component or utility has its own syntax error ──
997
+ let errorSource = null;
998
+ let errorSourceKind = null; // 'component' | 'utility'
999
+ for (const name of needed) {
1000
+ if (registry[name]?.error) {
1001
+ errorSource = name;
1002
+ errorSourceKind = "component";
1003
+ break;
1004
+ }
1005
+ }
1006
+ if (!errorSource) {
1007
+ for (const name of neededUtils) {
1008
+ if (utilities[name]?.error) {
1009
+ errorSource = name;
1010
+ errorSourceKind = "utility";
1011
+ break;
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ // ── Check: App definition + missing return ──
1017
+ const hasAppDefinition =
1018
+ /\b(?:export\s+default\s+)?function\s+App\s*\(/.test(cleanCompCode) ||
1019
+ /\bclass\s+App\b/.test(cleanCompCode) ||
1020
+ /\b(?:const|let|var)\s+App\s*=/.test(cleanCompCode);
1021
+
1022
+ let missingReturn = false;
1023
+ const appFnMatch = cleanCompCode.match(
1024
+ /(?:export\s+default\s+)?function\s+App\s*\([^)]*\)\s*\{/,
1025
+ );
1026
+ if (appFnMatch) {
1027
+ let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
1028
+ let hasReturn = false;
1029
+ while (i < cleanCompCode.length && depth > 0) {
1030
+ const ch = cleanCompCode[i];
1031
+ if (ch === "{") depth++;
1032
+ else if (ch === "}") depth--;
1033
+ else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
1034
+ i++;
1035
+ }
1036
+ missingReturn = !hasReturn;
1037
+ }
1038
+
1039
+ // ── Resolve compiled (success or unified error) ──
1040
+ let compiled;
1041
+ let cacheHit = false;
1042
+ let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-app' | 'missing-return' | 'transpile'
1043
+ if (missingComps) {
1044
+ const list = missingComps.join(", ");
1045
+ const plural = missingComps.length > 1;
1046
+ const hint = plural
1047
+ ? `Make sure these fc-portal-components exist (e.g. import the "Shared Components" example flow): ${list}.`
1048
+ : `Make sure a fc-portal-component named "${missingComps[0]}" exists (e.g. import the "Shared Components" example flow), then redeploy.`;
1049
+ compiled = {
1050
+ js: null,
1051
+ error: `Missing component${plural ? "s" : ""}: ${list}\n\n${hint}`,
1052
+ };
1053
+ errorKind = "missing-component";
1054
+ // Re-use errorSource for status/text — first missing name + count tail.
1055
+ errorSource = plural
1056
+ ? `${missingComps[0]} +${missingComps.length - 1}`
1057
+ : missingComps[0];
1058
+ } else if (errorSource) {
1059
+ const srcErr =
1060
+ errorSourceKind === "utility"
1061
+ ? utilities[errorSource].error
1062
+ : registry[errorSource].error;
1063
+ const label = errorSourceKind === "utility" ? "Utility" : "Component";
1064
+ compiled = {
1065
+ js: null,
1066
+ error: `${label} "${errorSource}" has a syntax error:\n\n${srcErr}`,
1067
+ };
1068
+ errorKind = errorSourceKind;
1069
+ } else if (!hasAppDefinition) {
1070
+ compiled = {
1071
+ js: null,
1072
+ error:
1073
+ "App component is required.\n\nAdd a top-level App component, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
1074
+ };
1075
+ errorKind = "missing-app";
1076
+ } else if (missingReturn) {
1077
+ compiled = {
1078
+ js: null,
1079
+ error:
1080
+ "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
1081
+ };
1082
+ errorKind = "missing-return";
1083
+ } else {
1084
+ compiled = readCachedJS(jsxHash);
1085
+ cacheHit = !!compiled;
1086
+ if (!compiled) {
1087
+ compiled = await transpile(fullJsx);
1088
+ // The await opens a window where close() may have torn this node
1089
+ // down (redeploy/removal). Writing pageState now would resurrect
1090
+ // state for a dead node — bail out; the successor node's own
1091
+ // rebuild owns the endpoint from here.
1092
+ if (isClosing) return;
1093
+ if (!compiled.error) {
1094
+ writeCachedJS(jsxHash, compiled.js, compiled.metafile);
1095
+ }
1096
+ }
1097
+ if (compiled.error) errorKind = "transpile";
1098
+ }
1099
+
1100
+ if (compiled.error) {
1101
+ node.error(
1102
+ (errorKind === "missing-component"
1103
+ ? "Missing component(s) in JSX: "
1104
+ : errorKind === "component"
1105
+ ? `Component "${errorSource}" syntax error: `
1106
+ : errorKind === "utility"
1107
+ ? `Utility "${errorSource}" syntax error: `
1108
+ : errorKind === "missing-app"
1109
+ ? "App component is required: "
1110
+ : errorKind === "missing-return"
1111
+ ? "App component has no return statement: "
1112
+ : "JSX transpile error: ") + compiled.error,
1113
+ );
1114
+ // Status + WS frames handled below (lastGood-aware).
1115
+ } else {
1116
+ updateStatus();
1117
+ if (compiled.metafile) {
1118
+ const output = Object.values(compiled.metafile.outputs)[0];
1119
+ const sizes = output
1120
+ ? Object.entries(output.inputs)
1121
+ .map(([name, info]) => ({
1122
+ name: name
1123
+ .replace(/^.*node_modules\//, "")
1124
+ .replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
1125
+ bytes: info.bytesInOutput,
1126
+ }))
1127
+ .sort((a, b) => b.bytes - a.bytes)
1128
+ .slice(0, 5)
1129
+ : [];
1130
+ const totalKB = Math.round(compiled.js.length / 1024);
1131
+ const top = sizes
1132
+ .map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
1133
+ .join(" · ");
1134
+ node.log(
1135
+ `[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
1136
+ );
1137
+ }
1138
+ }
1139
+
1140
+ const contentHash = compiled.js ? hash(compiled.js) : "";
1141
+
1142
+ // ── CSS: disk cache → in-memory → generate ──
1143
+ const cssReady = !compiled.error
1144
+ ? (() => {
1145
+ const cachedCSS = readCachedCSS(jsxHash);
1146
+ if (cachedCSS) return Promise.resolve(cachedCSS);
1147
+ if (prevState?.jsxHash === jsxHash && prevState?.css) {
1148
+ return Promise.resolve({
1149
+ css: prevState.css,
1150
+ cssHash: prevState.cssHash,
1151
+ });
1152
+ }
1153
+ // cssHash is always jsxHash — generateCSS hashes raw fullJsx
1154
+ // (no schema-version prefix), which would give the same CSS a
1155
+ // different URL on cache-hit vs cache-miss builds and force a
1156
+ // pointless browser refetch after redeploy.
1157
+ return generateCSS(fullJsx).then(({ css }) => {
1158
+ writeCachedCSS(jsxHash, css);
1159
+ return { css, cssHash: jsxHash };
1160
+ });
1161
+ })().catch((err) => {
1162
+ // CSS generation failed (Tailwind compile error, missing
1163
+ // entrypoint, etc.). Surface as warn + flag pageState so
1164
+ // updateStatus() shows a yellow ring "css-fail" — the portal
1165
+ // page still loads (empty CSS), just unstyled. Cleared on
1166
+ // the next successful build.
270
1167
  node.warn("Tailwind CSS generation failed: " + err.message);
271
- return "";
1168
+ const st = pageState[endpoint];
1169
+ if (st) st.cssError = true;
1170
+ updateStatus();
1171
+ return { css: "", cssHash: "" };
272
1172
  })
273
- : Promise.resolve("");
1173
+ : Promise.resolve({ css: "", cssHash: "" });
1174
+
1175
+ lastJsxHash = jsxHash;
1176
+
1177
+ // Preserve last successful build so that on transpile errors we keep
1178
+ // serving the previous working JS instead of throwing clients to an
1179
+ // error page. On success, snapshot IMMEDIATELY (not after cssReady
1180
+ // resolves) — a rebuild failing inside that async window must still
1181
+ // find a fallback. cssHash is patched in when cssReady settles.
1182
+ const lastGood = compiled.error
1183
+ ? prevState?.lastGood || null
1184
+ : {
1185
+ compiledJs: compiled.js,
1186
+ contentHash,
1187
+ cssHash: "",
1188
+ pageTitle,
1189
+ customHead,
1190
+ };
1191
+
1192
+ pageState[endpoint] = {
1193
+ compiled,
1194
+ contentHash,
1195
+ cssReady,
1196
+ jsxHash,
1197
+ css: null,
1198
+ cssHash: "",
1199
+ pageTitle,
1200
+ wsPath,
1201
+ customHead,
1202
+ portalAuth,
1203
+ showWsStatus,
1204
+ errorSource,
1205
+ errorKind,
1206
+ lastGood,
1207
+ };
274
1208
 
275
- pageState[endpoint] = {
276
- compiled,
277
- cssHashReady,
278
- pageTitle,
279
- wsPath,
280
- customHead,
281
- };
1209
+ if (compiled.error) {
1210
+ // Status text (red) handled centrally by updateStatus — it formats
1211
+ // base + "(serving last good)" + client-count suffix consistently
1212
+ // across build and connect/disconnect events.
1213
+ updateStatus();
1214
+ const frame = lastGood
1215
+ ? JSON.stringify({ type: "error", message: compiled.error, degraded: true })
1216
+ : JSON.stringify({ type: "error", message: compiled.error });
1217
+ clients.forEach((ws) => {
1218
+ try { if (ws.readyState === 1) ws.send(frame); } catch (e) { RED.log.trace("[portal-react] ws send error: " + e.message); }
1219
+ });
1220
+ }
1221
+
1222
+ // Notify all connected browsers that build finished — triggers reload or overlay cleanup
1223
+ if (!compiled.error && contentHash) {
1224
+ const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
1225
+ clients.forEach((ws) => {
1226
+ try { if (ws.readyState === 1) ws.send(versionFrame); } catch (e) { RED.log.trace("[portal-react] ws send version: " + e.message); }
1227
+ });
1228
+ }
1229
+
1230
+ cssReady
1231
+ .then(({ css, cssHash }) => {
1232
+ const state = pageState[endpoint];
1233
+ if (state && state.jsxHash === jsxHash) {
1234
+ state.css = css;
1235
+ state.cssHash = cssHash;
1236
+ // Clear css-fail flag on a successful generation. Only the
1237
+ // outer .catch above sets it, so we don't risk wiping a
1238
+ // freshly-set error mid-flight.
1239
+ if (cssHash) state.cssError = false;
1240
+ // Snapshot current good build so future failed builds can fall back.
1241
+ if (!state.compiled.error && state.compiled.js) {
1242
+ state.lastGood = {
1243
+ compiledJs: state.compiled.js,
1244
+ contentHash: state.contentHash,
1245
+ cssHash,
1246
+ pageTitle: state.pageTitle,
1247
+ customHead: state.customHead,
1248
+ };
1249
+ }
1250
+ updateStatus();
1251
+ }
1252
+ })
1253
+ .catch((e) => {
1254
+ // Tail-handler — generateCSS already has its own .catch upstream
1255
+ // that yields { css: "", cssHash: "" }. This guards against
1256
+ // exceptions thrown inside the state-update block above so the
1257
+ // process doesn't see an UnhandledPromiseRejection.
1258
+ RED.log.warn("[portal-react] cssReady tail: " + e.message);
1259
+ });
1260
+ } catch (e) {
1261
+ node.error("Rebuild failed: " + e.message);
1262
+ // Surface as a regular build error so the lastGood/degraded path,
1263
+ // status formatting and FE error frame all run uniformly.
1264
+ const prev = pageState[endpoint];
1265
+ pageState[endpoint] = {
1266
+ compiled: { js: null, error: "Internal rebuild error: " + e.message },
1267
+ contentHash: "",
1268
+ cssReady: Promise.resolve({ css: "", cssHash: "" }),
1269
+ jsxHash: "",
1270
+ css: null,
1271
+ cssHash: "",
1272
+ pageTitle,
1273
+ wsPath,
1274
+ customHead,
1275
+ portalAuth,
1276
+ showWsStatus,
1277
+ errorSource: null,
1278
+ errorKind: "rebuild",
1279
+ lastGood: prev?.lastGood || null,
1280
+ };
1281
+ updateStatus();
1282
+ const frame = JSON.stringify({
1283
+ type: "error",
1284
+ message: "Internal rebuild error: " + e.message,
1285
+ degraded: !!prev?.lastGood,
1286
+ });
1287
+ clients.forEach((ws) => {
1288
+ try { if (ws.readyState === 1) ws.send(frame); } catch (err) { RED.log.trace("[portal-react] ws send rebuild err: " + err.message); }
1289
+ });
1290
+ }
282
1291
  }
283
1292
 
284
1293
  // Register rebuild callback so library components can trigger re-transpile
285
1294
  rebuildCallbacks[nodeId] = rebuild;
286
-
287
- // Delay initial build so all fc-portal-component nodes register first
1295
+ // Remember raw user JSX so selective rebuild can detect references to new components
1296
+ portalCode[nodeId] = componentCode;
1297
+
1298
+ // No-op redeploy detection: if nothing in the portal's config changed AND a valid
1299
+ // build already exists for this endpoint, skip rebuild. Node-RED Full deploy
1300
+ // reconstructs every node even when unchanged — without this check every portal
1301
+ // would rebuild on every Full deploy.
1302
+ const sig = hash(
1303
+ [
1304
+ componentCode,
1305
+ JSON.stringify(libs),
1306
+ pageTitle,
1307
+ customHead,
1308
+ String(portalAuth),
1309
+ String(showWsStatus),
1310
+ ].join("\0"),
1311
+ );
1312
+ const prevSig = portalSig[nodeId];
1313
+ const existing = pageState[endpoint];
1314
+ // hasFreshBuild also requires `compiled` to be present — close() nulls it on
1315
+ // redeploy, and a guard that ignored that (checking only building/error)
1316
+ // would treat the destroyed build as valid and skip the rebuild, leaving the
1317
+ // GET route serving the holding page forever. See helpers.hasFreshBuild.
1318
+ const hasValidBuild = hasFreshBuild(existing);
1319
+ portalSig[nodeId] = sig;
1320
+
1321
+ if (prevSig !== sig || !hasValidBuild) {
1322
+ scheduleRebuildSelf(nodeId);
1323
+ } else {
1324
+ node.log(`[${nodeId}] unchanged — skipping rebuild`);
1325
+ updateStatus();
1326
+ }
288
1327
  setImmediate(() => {
289
- rebuild();
290
-
291
1328
  // Register route only once per endpoint (persists across deploys)
292
1329
  if (!registeredRoutes[endpoint]) {
293
- RED.httpNode.get(endpoint, async function (_req, res) {
294
- const state = pageState[endpoint];
295
- if (!state) {
296
- res.status(404).send("Not found");
297
- return;
298
- }
299
- res.set("Cache-Control", "no-store");
300
- if (state.compiled.error) {
301
- res
302
- .status(500)
303
- .type("text/html")
304
- .send(buildErrorPage(state.pageTitle, state.compiled.error));
305
- return;
306
- }
307
- const cssHash = await state.cssHashReady;
308
- res
309
- .type("text/html")
310
- .send(
311
- buildPage(
312
- state.pageTitle,
313
- state.compiled.js,
314
- state.wsPath,
315
- state.customHead,
316
- cssHash,
317
- ),
318
- );
319
- });
1330
+ RED.httpNode.get(
1331
+ endpoint,
1332
+ createPortalPageHandler({
1333
+ endpoint,
1334
+ pageState,
1335
+ wsPath,
1336
+ pageTitle,
1337
+ adminRoot,
1338
+ buildPage,
1339
+ buildErrorPage,
1340
+ extractPortalUser,
1341
+ }),
1342
+ );
320
1343
  registeredRoutes[endpoint] = true;
321
1344
  }
322
1345
 
@@ -324,7 +1347,11 @@ module.exports = function (RED) {
324
1347
 
325
1348
  try {
326
1349
  const WebSocket = require("ws");
327
- wsServer = new WebSocket.Server({ noServer: true });
1350
+ // 1 MB hard cap on incoming WS frames — far above typical msg.output
1351
+ // sizes (a few KB of JSON) and well below the 100 MB default. Blocks
1352
+ // a hostile client from spamming oversized frames.
1353
+ wsServer = new WebSocket.Server({ noServer: true, maxPayload: 1024 * 1024 });
1354
+ registerPingedServer(wsServer);
328
1355
 
329
1356
  // Remove previous upgrade handler for this node (dirty deploy)
330
1357
  if (upgradeHandlers[nodeId]) {
@@ -342,6 +1369,12 @@ module.exports = function (RED) {
342
1369
  pathname = request.url;
343
1370
  }
344
1371
  if (pathname === wsPath) {
1372
+ // Plugin hook: plugins may reject the connection before upgrade.
1373
+ // Default (no plugins) = allowed, matches dashboard behavior.
1374
+ if (!hooks.allow("onIsValidConnection", request)) {
1375
+ try { socket.destroy(); } catch (e) { RED.log.trace("[portal-react] socket destroy: " + e.message); }
1376
+ return;
1377
+ }
345
1378
  wsServer.handleUpgrade(request, socket, head, (ws) => {
346
1379
  wsServer.emit("connection", ws, request);
347
1380
  });
@@ -351,42 +1384,130 @@ module.exports = function (RED) {
351
1384
  RED.server.on("upgrade", onUpgrade);
352
1385
  upgradeHandlers[nodeId] = onUpgrade;
353
1386
 
354
- wsServer.on("connection", (ws) => {
1387
+ wsServer.on("connection", (ws, request) => {
355
1388
  if (isClosing) {
356
1389
  ws.close();
357
1390
  return;
358
1391
  }
359
- clients.add(ws);
1392
+ const portalClient = crypto.randomUUID();
1393
+ ws._portalClient = portalClient;
1394
+ if (portalAuth) {
1395
+ ws._portalUser = extractPortalUser(request.headers);
1396
+ }
1397
+ clients.set(portalClient, ws);
1398
+
1399
+ // Index by userId for O(1) user-cast routing
1400
+ const userId = ws._portalUser && ws._portalUser.userId;
1401
+ if (userId) {
1402
+ let set = userIndex.get(userId);
1403
+ if (!set) {
1404
+ set = new Set();
1405
+ userIndex.set(userId, set);
1406
+ }
1407
+ set.add(ws);
1408
+ }
1409
+
360
1410
  updateStatus();
361
1411
 
362
- // Push current state to new client
363
- if (lastPayload !== null) {
364
- wsSend(ws, { type: "data", payload: lastPayload });
1412
+ // Send content version for deploy-reload detection.
1413
+ // In degraded mode (current build failed but lastGood served), advertise
1414
+ // the lastGood hash so the freshly reloaded client matches the JS we sent.
1415
+ const cs = pageState[endpoint];
1416
+ // Only advertise a non-empty hash when there is a page the GET route
1417
+ // can actually serve (real build, or degraded lastGood). Advertising
1418
+ // a stale hash for a nulled/error state drives the served error page
1419
+ // into a reload loop. See helpers.serveableHash.
1420
+ const contentHash = serveableHash(cs);
1421
+ wsSend(ws, { type: "version", hash: contentHash });
1422
+
1423
+ // Send assigned portalClient to browser
1424
+ wsSend(ws, { type: "hello", portalClient });
1425
+
1426
+ // Degraded warning — show banner, not full overlay.
1427
+ if (cs?.compiled?.error && cs?.lastGood) {
1428
+ wsSend(ws, {
1429
+ type: "error",
1430
+ message: cs.compiled.error,
1431
+ degraded: true,
1432
+ });
365
1433
  }
366
1434
 
1435
+ // Send the cached last broadcast (if any) as a distinct
1436
+ // `recovery` frame. The browser uses this to seed `data` on a
1437
+ // fresh connection. React components can opt out via
1438
+ // useNodeRed({ ignoreRecovery: true }).
1439
+ if (lastBroadcastCache.has(endpoint)) {
1440
+ wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
1441
+ }
1442
+
1443
+ // Heartbeat — detect dead sockets via WS ping/pong. Browser
1444
+ // auto-replies to ping frames, no client JS needed. The actual
1445
+ // ping interval lives in the module-level `_pingSweep` tick;
1446
+ // each client only needs the alive flag and pong listener here.
1447
+ ws._isAlive = true;
1448
+ ws.on("pong", () => { ws._isAlive = true; });
1449
+
367
1450
  ws.on("message", (raw) => {
368
1451
  try {
369
1452
  const msg = JSON.parse(raw.toString());
1453
+ if (msg.type === "runtime_error") {
1454
+ // Browser caught an exception while running the bundle —
1455
+ // surface it on node status so the editor shows red even
1456
+ // when the build itself succeeded (e.g. ReferenceError to
1457
+ // an undefined identifier or missing component).
1458
+ const st = pageState[endpoint];
1459
+ if (st && !(st.compiled && st.compiled.error)) {
1460
+ st.runtimeError = String(msg.message || "")
1461
+ .split("\n")[0]
1462
+ .slice(0, 200);
1463
+ node.error("Runtime error in browser: " + st.runtimeError);
1464
+ updateStatus();
1465
+ }
1466
+ return;
1467
+ }
370
1468
  if (msg.type === "output") {
371
- node.send({
1469
+ let out = {
372
1470
  payload: msg.payload,
373
1471
  topic: msg.topic || "",
374
- });
1472
+ };
1473
+ // Server-side identity injection — the client cannot forge
1474
+ // _client because we build it from ws state, not from the
1475
+ // inbound frame.
1476
+ const client = { portalClient: ws._portalClient };
1477
+ if (portalAuth && ws._portalUser) {
1478
+ // Source is extractPortalUser() — whitelist of named
1479
+ // header reads. No untrusted key can land here, so this
1480
+ // Object.assign cannot be turned into prototype pollution.
1481
+ Object.assign(client, ws._portalUser);
1482
+ }
1483
+ out._client = client;
1484
+ // Transform hook — plugins may mutate / drop the msg.
1485
+ // A hook returning null signals "drop this message".
1486
+ out = hooks.transform("onInbound", out, ws);
1487
+ if (out) node.send(out);
1488
+ return;
375
1489
  }
376
1490
  } catch (e) {
377
1491
  node.warn("Bad WS message: " + e.message);
378
1492
  }
379
1493
  });
380
1494
 
381
- ws.on("close", () => {
382
- clients.delete(ws);
1495
+ const detach = () => {
1496
+ // No per-client interval to clear — heartbeat is centralised in
1497
+ // the shared `_pingSweep` tick (see registerPingedServer).
1498
+ clients.delete(portalClient);
1499
+ if (userId) {
1500
+ const set = userIndex.get(userId);
1501
+ if (set) {
1502
+ set.delete(ws);
1503
+ if (set.size === 0) userIndex.delete(userId);
1504
+ }
1505
+ }
383
1506
  updateStatus();
384
- });
1507
+ };
385
1508
 
386
- ws.on("error", () => {
387
- clients.delete(ws);
388
- updateStatus();
389
- });
1509
+ ws.on("close", detach);
1510
+ ws.on("error", detach);
390
1511
  });
391
1512
  } catch (e) {
392
1513
  node.error("WebSocket setup failed: " + e.message);
@@ -394,244 +1515,215 @@ module.exports = function (RED) {
394
1515
 
395
1516
  // ── Input handler ─────────────────────────────────────────
396
1517
 
1518
+ // sendTo: single point where every outbound frame passes through
1519
+ // the onCanSendTo hook. Strict-by-default — no opt-in per widget
1520
+ // type like dashboard's acceptsClientConfig.
1521
+ /**
1522
+ * Send a pre-serialised frame to one WS client, gated by the
1523
+ * `onCanSendTo` plugin hook. Returns true on successful send.
1524
+ * @param {import("ws").WebSocket} ws
1525
+ * @param {string} frame JSON-encoded payload.
1526
+ * @param {MessagePayload} msg Inspected by plugin hooks.
1527
+ * @returns {boolean}
1528
+ * @private
1529
+ */
1530
+ function sendTo(ws, frame, msg) {
1531
+ if (!ws || ws.readyState !== 1) return false;
1532
+ if (!hooks.allow("onCanSendTo", ws, msg)) return false;
1533
+ try {
1534
+ ws.send(frame);
1535
+ return true;
1536
+ } catch (e) {
1537
+ RED.log.trace("[portal-react] ws send frame: " + e.message);
1538
+ return false;
1539
+ }
1540
+ }
1541
+
397
1542
  node.on("input", (msg, send, done) => {
398
- lastPayload = msg.payload;
399
- const frame = JSON.stringify({ type: "data", payload: msg.payload });
400
- clients.forEach((ws) => {
401
- if (ws.readyState === 1) ws.send(frame);
402
- });
403
- updateStatus();
404
- if (done) done();
1543
+ // Target Node-RED ≥4.0: `done` is always present. No defensive guard.
1544
+ try {
1545
+ const result = router.route(msg, { clients, userIndex, sendTo });
1546
+ // Cache the latest broadcast payload so freshly-connected clients
1547
+ // can recover it via the `recovery` frame on connect. Deep-clone via
1548
+ // RED.util.cloneMessage so a downstream mutation cannot retroactively
1549
+ // change what a fresh client sees on connect.
1550
+ if (result.mode === "broadcast") {
1551
+ let cached;
1552
+ try {
1553
+ cached = RED.util.cloneMessage({ p: msg.payload }).p;
1554
+ } catch (_) {
1555
+ cached = msg.payload;
1556
+ }
1557
+ lastBroadcastCache.set(endpoint, cached);
1558
+ }
1559
+ // No updateStatus() here — client count only changes on WS
1560
+ // connect/disconnect, and emitting a status event per routed msg
1561
+ // floods the editor comms channel on high-rate streams.
1562
+ done();
1563
+ } catch (err) {
1564
+ // Catch-node propagation: done(err) lets the runtime route the
1565
+ // error to a Catch node on the same tab (Node-RED docs: "this will
1566
+ // trigger any Catch nodes present on the same tab").
1567
+ done(err);
1568
+ }
405
1569
  });
406
1570
 
407
1571
  // ── Cleanup on redeploy / shutdown ────────────────────────
1572
+ //
1573
+ // Teardown order (Node-RED gives us a 15 s budget before forcibly
1574
+ // killing the close handler):
1575
+ // 1. mark isClosing = true (refuse new WS upgrades)
1576
+ // 2. close all WS clients with 1001
1577
+ // 3. remove the upgrade listener from RED.server
1578
+ // 4. close the ws.Server
1579
+ // 5. clear timers / interval handles
1580
+ // 6. drop route & shared state (only when fully removed)
1581
+ // 7. done()
1582
+ //
1583
+ // `removed` is true when the node is deleted *or* disabled in the
1584
+ // editor (Node-RED docs). For both we drop persistent route + cache;
1585
+ // for redeploy (removed=false) we keep pageState[endpoint] so
1586
+ // reconnecting clients hit the same build with a smaller delay.
408
1587
 
409
1588
  node.on("close", (removed, done) => {
410
- isClosing = true;
1589
+ let doneCalled = false;
1590
+ const callDone = (err) => {
1591
+ if (doneCalled) return;
1592
+ doneCalled = true;
1593
+ done(err);
1594
+ };
1595
+ // Safety net — runtime force-kills at 15 s. Resolve at 14 s if
1596
+ // teardown is somehow blocked so we don't get a hard timeout log.
1597
+ const safety = setTimeout(() => callDone(), 14_000);
1598
+ safety.unref?.();
411
1599
 
412
- // Remove upgrade handler
413
- if (upgradeHandlers[nodeId]) {
414
- RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
415
- delete upgradeHandlers[nodeId];
416
- }
1600
+ try {
1601
+ isClosing = true;
417
1602
 
418
- // Close all WS clients
419
- clients.forEach((ws) => {
420
- try {
421
- ws.close(1001, "node redeployed");
422
- } catch (_) {}
423
- });
424
- clients.clear();
1603
+ // Close all WS clients. Heartbeat lives in the shared module-level
1604
+ // sweep tick — no per-client cleanup needed here. ws.close() is
1605
+ // non-blocking; we don't await drain.
1606
+ clients.forEach((ws) => {
1607
+ try {
1608
+ ws.close(1001, "node redeployed");
1609
+ } catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
1610
+ });
1611
+ clients.clear();
425
1612
 
426
- // Close WS server
427
- if (wsServer) {
428
- try {
429
- wsServer.close();
430
- } catch (_) {}
431
- wsServer = null;
432
- }
1613
+ // Remove upgrade handler before tearing down the WS server so a
1614
+ // late upgrade request doesn't race into a half-closed wsServer.
1615
+ if (upgradeHandlers[nodeId]) {
1616
+ RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
1617
+ delete upgradeHandlers[nodeId];
1618
+ }
433
1619
 
434
- // Unregister rebuild callback
435
- delete rebuildCallbacks[nodeId];
1620
+ // Close WS server — also drop it from the shared heartbeat tick.
1621
+ // When the last portal node tears down its server, the shared
1622
+ // interval auto-clears (see unregisterPingedServer).
1623
+ if (wsServer) {
1624
+ unregisterPingedServer(wsServer);
1625
+ try {
1626
+ wsServer.close();
1627
+ } catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
1628
+ wsServer = null;
1629
+ }
436
1630
 
437
- // Clean up route only when node is fully removed (not redeployed)
438
- if (removed) {
439
- delete pageState[endpoint];
440
- removeRoute(RED.httpNode._router, endpoint);
441
- delete registeredRoutes[endpoint];
442
- }
1631
+ // Unregister rebuild callback + selective-rebuild metadata
1632
+ delete rebuildCallbacks[nodeId];
1633
+ delete portalNeeded[nodeId];
1634
+ delete portalNeededUtilities[nodeId];
1635
+ delete portalCode[nodeId];
1636
+
1637
+ // Release endpoint ownership
1638
+ if (endpointOwners[endpoint] === nodeId) {
1639
+ delete endpointOwners[endpoint];
1640
+ }
1641
+
1642
+ // Drop the recovery cache on full removal/disable; on a plain
1643
+ // redeploy keep it so reconnecting clients still recover.
1644
+ if (removed) {
1645
+ lastBroadcastCache.delete(endpoint);
1646
+ delete portalSig[nodeId];
1647
+ delete nodeEndpoints[nodeId];
1648
+ }
443
1649
 
444
- if (done) done();
1650
+ // Clear the userIndex — WS clients are already closed above, but
1651
+ // the Map itself should not outlive the node instance.
1652
+ userIndex.clear();
1653
+
1654
+ // Break references to large objects / Promises in pageState even on
1655
+ // redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
1656
+ // between close and the new build these would retain closures over
1657
+ // the old clients/userIndex/rebuild scope.
1658
+ const st = pageState[endpoint];
1659
+ if (st) {
1660
+ st.cssReady = null;
1661
+ st.compiled = null;
1662
+ st.css = null;
1663
+ // Clear the advertised hash too. Leaving a stale non-empty
1664
+ // contentHash while compiled is null makes the WS `version` frame
1665
+ // advertise a "ready" page that the GET route can no longer serve,
1666
+ // which drives the served error/building page into a reload loop.
1667
+ st.contentHash = "";
1668
+ }
1669
+
1670
+ // Clean up route only on full removal/disable (not on redeploy).
1671
+ if (removed) {
1672
+ // Delete disk cache if no other endpoint uses this hash
1673
+ if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
1674
+ deleteCacheFiles(lastJsxHash);
1675
+ }
1676
+ delete pageState[endpoint];
1677
+ removeRoute(RED.httpNode._router, endpoint);
1678
+ delete registeredRoutes[endpoint];
1679
+ }
1680
+
1681
+ clearTimeout(safety);
1682
+ callDone();
1683
+ } catch (err) {
1684
+ clearTimeout(safety);
1685
+ callDone(err);
1686
+ }
445
1687
  });
446
1688
 
447
1689
  // ── Utilities ─────────────────────────────────────────────
448
1690
 
1691
+ /**
1692
+ * Best-effort `JSON.stringify` + `ws.send`. Swallows write errors at
1693
+ * trace level — used for status/control frames where dropping a
1694
+ * single packet has no semantic impact (the next deploy or heartbeat
1695
+ * will reconcile state).
1696
+ * @param {import("ws").WebSocket} ws
1697
+ * @param {Object} obj
1698
+ * @returns {void}
1699
+ * @private
1700
+ */
449
1701
  function wsSend(ws, obj) {
450
1702
  try {
451
1703
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
452
- } catch (_) {}
1704
+ } catch (e) { RED.log.trace("[portal-react] wsSend: " + e.message); }
453
1705
  }
454
1706
 
455
- function updateStatus() {
456
- if (isClosing) return;
457
- const n = clients.size;
458
- node.status({
459
- fill: n > 0 ? "green" : "grey",
460
- shape: n > 0 ? "dot" : "ring",
461
- text: `${endpoint} [${n} client${n !== 1 ? "s" : ""}]`,
462
- });
463
- }
464
1707
  }); // end setImmediate
465
1708
  }
466
1709
 
467
- RED.nodes.registerType("portal-react", PortalReactNode);
468
-
469
- // ── Serve Monaco editor files locally ────────────────────────
470
- const express = require("express");
471
- const monacoPath = path.dirname(
472
- require.resolve("monaco-editor/package.json"),
473
- );
474
- RED.httpAdmin.use(
475
- "/portal-react/vs",
476
- express.static(path.join(monacoPath, "min", "vs")),
477
- );
478
-
479
- // ── Tailwind class list endpoint ────────────────────────────
480
- const { generateCandidates } = require("./tw-candidates");
481
- let twClassesCache = null;
482
- RED.httpAdmin.get("/portal-react/tw-classes", (_req, res) => {
483
- if (!twClassesCache) {
484
- twClassesCache = generateCandidates();
485
- }
486
- res.json(twClassesCache);
487
- });
488
-
489
- // ── Vendor CSS endpoint (per content hash) ─────────────────
490
- RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
491
- const css = cssCache[req.params.hash];
492
- if (!css) {
493
- res.status(404).send("Not found");
494
- return;
495
- }
496
- res.set({
497
- "Content-Type": "text/css",
498
- "Cache-Control": "public, max-age=31536000, immutable",
499
- });
500
- res.send(css);
501
- });
502
-
503
- // ── Vendor React bundle endpoint ────────────────────────────
504
- RED.httpAdmin.get("/portal-react/vendor/react.min.js", (_req, res) => {
505
- res.set({
506
- "Content-Type": "application/javascript",
507
- "Cache-Control": "public, max-age=31536000, immutable",
508
- ETag: `"${reactHash}"`,
509
- });
510
- res.send(reactBundle);
1710
+ RED.nodes.registerType("portal-react", PortalReactNode, {
1711
+ dynamicModuleList: "libs",
511
1712
  });
512
1713
 
513
- // ── Admin API for component registry ──────────────────────────
514
-
515
- RED.httpAdmin.get("/portal-react/registry", (_req, res) => {
516
- res.json(registry);
517
- });
518
-
519
- RED.httpAdmin.post("/portal-react/registry", (req, res) => {
520
- const { name, code, inputs, outputs } = req.body || {};
521
- if (!isSafeName(name))
522
- return res.status(400).json({ error: "invalid name" });
523
- registry[name] = { code, inputs: inputs || [], outputs: outputs || [] };
524
- res.json({ ok: true });
525
- });
526
-
527
- RED.httpAdmin.delete("/portal-react/registry/:name", (req, res) => {
528
- const name = req.params.name;
529
- if (!isSafeName(name))
530
- return res.status(400).json({ error: "invalid name" });
531
- delete registry[name];
532
- res.json({ ok: true });
1714
+ const express = require("express");
1715
+ const { registerAdminApi } = require("./lib/admin-api");
1716
+ registerAdminApi(RED, {
1717
+ express,
1718
+ permRead: PERM_READ,
1719
+ permWrite: PERM_WRITE,
1720
+ csrfGuard,
1721
+ rateLimit,
1722
+ jsonBodyLimit: JSON_BODY_LIMIT,
1723
+ userDir,
1724
+ pageState,
1725
+ registry,
1726
+ utilities,
1727
+ extractUtilitySymbols,
533
1728
  });
534
-
535
- // ── Page builders ─────────────────────────────────────────────
536
-
537
- function buildPage(title, transpiledJs, wsPath, customHead, cssHash) {
538
- return `<!DOCTYPE html>
539
- <html lang="en">
540
- <head>
541
- <meta charset="UTF-8">
542
- <meta name="viewport" content="width=device-width,initial-scale=1.0">
543
- <title>${esc(title)}</title>
544
- <script src="${adminRoot}/portal-react/vendor/react.min.js?v=${reactHash}"><\/script>
545
- ${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
546
- ${escScript(customHead)}
547
- <style>
548
- @layer base{
549
- *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
550
- body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0}
551
- #root{min-height:100vh}
552
- }
553
- #__cs{position:fixed;bottom:6px;right:6px;padding:3px 8px;font-size:10px;border-radius:3px;z-index:99999;background:#111;border:1px solid #333;opacity:.7;transition:opacity .2s}
554
- #__cs:hover{opacity:1}
555
- #__cs.ok{color:#4ade80}
556
- #__cs.err{color:#f87171}
557
- </style>
558
- </head>
559
- <body>
560
- <div id="root"></div>
561
- <div id="__cs" class="err">disconnected</div>
562
- <script>
563
- window.__NR={
564
- _ws:null,_listeners:new Set(),_lastData:null,_retries:0,_wasConnected:false,
565
- connect(){
566
- const p=location.protocol==='https:'?'wss:':'ws:';
567
- const ws=new WebSocket(p+'//'+location.host+'${wsPath}');
568
- this._ws=ws;
569
- const s=document.getElementById('__cs');
570
- ws.onopen=()=>{
571
- if(this._wasConnected){location.reload();return;}
572
- s.textContent='connected';s.className='ok';this._retries=0;this._wasConnected=true;
573
- };
574
- ws.onmessage=(e)=>{
575
- try{const m=JSON.parse(e.data);if(m.type==='data'){this._lastData=m.payload;this._listeners.forEach(fn=>fn(m.payload));}}
576
- catch(err){console.error('WS parse',err);}
577
- };
578
- ws.onclose=(e)=>{
579
- s.textContent='disconnected';s.className='err';
580
- this._ws=null;
581
- const delay=Math.min(500*Math.pow(2,this._retries),8000);
582
- this._retries++;
583
- setTimeout(()=>this.connect(),delay);
584
- };
585
- ws.onerror=()=>ws.close();
586
- },
587
- subscribe(fn){
588
- this._listeners.add(fn);
589
- if(this._lastData!==null)fn(this._lastData);
590
- return()=>this._listeners.delete(fn);
591
- },
592
- send(payload,topic){
593
- if(this._ws&&this._ws.readyState===1)
594
- this._ws.send(JSON.stringify({type:'output',payload,topic:topic||''}));
595
- }
596
- };
597
- window.__NR.connect();
598
- <\/script>
599
- <script>
600
- ${escScript(transpiledJs)}
601
- <\/script>
602
- </body>
603
- </html>`;
604
- }
605
-
606
- function buildErrorPage(title, error) {
607
- return `<!DOCTYPE html>
608
- <html lang="en">
609
- <head>
610
- <meta charset="UTF-8">
611
- <title>${esc(title)} — Error</title>
612
- <style>
613
- body{font-family:monospace;background:#1a0000;color:#f87171;padding:40px;line-height:1.6}
614
- h1{color:#ff4444;margin-bottom:16px}
615
- pre{background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5}
616
- </style>
617
- </head>
618
- <body>
619
- <h1>JSX Transpile Error</h1>
620
- <p>Fix the component code in Node-RED and deploy again.</p>
621
- <pre>${esc(error)}</pre>
622
- </body>
623
- </html>`;
624
- }
625
-
626
- function esc(s) {
627
- return String(s)
628
- .replace(/&/g, "&amp;")
629
- .replace(/</g, "&lt;")
630
- .replace(/>/g, "&gt;")
631
- .replace(/"/g, "&quot;");
632
- }
633
-
634
- function escScript(s) {
635
- return String(s).replace(/<\/(script)/gi, "<\\/$1");
636
- }
637
1729
  };