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