@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.
- package/LICENSE +190 -0
- package/README.md +435 -2
- package/THIRD-PARTY-NOTICES.md +164 -0
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
- package/nodes/editor/ark-pixel.woff2 +0 -0
- package/nodes/editor/file-tree.js +403 -0
- package/nodes/editor/format.js +182 -0
- package/nodes/editor/icons.svg +46 -0
- package/nodes/editor/mark.svg +5 -0
- package/nodes/editor/provider-settings.js +192 -0
- package/nodes/editor/workspace.css +660 -0
- package/nodes/editor/workspace.js +1565 -0
- package/nodes/icons/fromcubes-component.svg +27 -0
- package/nodes/icons/fromcubes-react.svg +25 -0
- package/nodes/icons/fromcubes-utility.svg +21 -0
- package/nodes/lib/admin-api.js +238 -0
- package/nodes/lib/admin-security.js +161 -0
- package/nodes/lib/api.js +29 -0
- package/nodes/lib/helpers.js +990 -0
- package/nodes/lib/hooks.js +145 -0
- package/nodes/lib/page-builder.js +499 -0
- package/nodes/lib/portal-page-route.js +131 -0
- package/nodes/lib/providers.js +581 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +103 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +2539 -0
- package/nodes/portal-react.js +1652 -0
- 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
|
+
};
|