@aaqu/fromcubes-react 0.0.0-stage → 0.2.0

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