@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 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 +66 -10
- 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 +121 -8
- package/nodes/lib/router.js +25 -1
- package/nodes/portal-react.html +248 -9
- package/nodes/portal-react.js +849 -192
- 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/examples/008-utility-debounce-flow.json +0 -72
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,97 +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
292
|
// Shared namespace with fc-portal-utility nodes so a component and a utility
|
|
70
293
|
// can never share the same name.
|
|
71
|
-
if (!RED.settings.
|
|
72
|
-
RED.settings.
|
|
294
|
+
if (!RED.settings.portalReactCompNameOwners) {
|
|
295
|
+
RED.settings.portalReactCompNameOwners = {};
|
|
73
296
|
}
|
|
74
|
-
const compNameOwners = RED.settings.
|
|
297
|
+
const compNameOwners = RED.settings.portalReactCompNameOwners;
|
|
75
298
|
|
|
76
299
|
// Utility registry: populated by fc-portal-utility canvas nodes at deploy time.
|
|
77
300
|
// Keyed by node-level utilName (e.g. "mathHelpers"), value { code, error }.
|
|
78
301
|
// Each utility node may declare multiple top-level symbols inside its code block.
|
|
79
|
-
if (!RED.settings.
|
|
80
|
-
RED.settings.
|
|
302
|
+
if (!RED.settings.portalReactUtilities) {
|
|
303
|
+
RED.settings.portalReactUtilities = {};
|
|
81
304
|
}
|
|
82
|
-
const utilities = RED.settings.
|
|
305
|
+
const utilities = RED.settings.portalReactUtilities;
|
|
83
306
|
|
|
84
307
|
// Per-portal set of utility node names this portal depends on (transitively).
|
|
85
308
|
// Lets utility code changes target only portals that reference at least one
|
|
86
309
|
// symbol declared by the changed utility node.
|
|
87
|
-
if (!RED.settings.
|
|
88
|
-
RED.settings.
|
|
310
|
+
if (!RED.settings.portalReactNeededUtilities) {
|
|
311
|
+
RED.settings.portalReactNeededUtilities = {};
|
|
89
312
|
}
|
|
90
|
-
const portalNeededUtilities = RED.settings.
|
|
313
|
+
const portalNeededUtilities = RED.settings.portalReactNeededUtilities;
|
|
91
314
|
|
|
92
315
|
// Track owner of each top-level symbol declared inside fc-portal-utility nodes:
|
|
93
316
|
// { symbol: utilName }. Lets us catch collisions upfront (component-name vs
|
|
94
317
|
// utility-symbol, utility-symbol vs utility-symbol from another node) before
|
|
95
318
|
// they reach esbuild, where the diagnostic would surface on the portal node
|
|
96
319
|
// as a confusing transpile error rather than on the offending utility node.
|
|
97
|
-
if (!RED.settings.
|
|
98
|
-
RED.settings.
|
|
320
|
+
if (!RED.settings.portalReactUtilSymbolOwners) {
|
|
321
|
+
RED.settings.portalReactUtilSymbolOwners = {};
|
|
99
322
|
}
|
|
100
|
-
const utilSymbolOwners = RED.settings.
|
|
323
|
+
const utilSymbolOwners = RED.settings.portalReactUtilSymbolOwners;
|
|
101
324
|
|
|
102
325
|
// Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
|
|
103
326
|
// portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
|
|
104
|
-
if (!RED.settings.
|
|
105
|
-
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
|
+
}
|
|
106
410
|
}
|
|
107
|
-
const portalSig = RED.settings.fcPortalSig;
|
|
108
411
|
|
|
109
412
|
// Debounced selective rebuild: coalesces multiple component changes into one pass.
|
|
110
413
|
// Yields event loop between builds so HTTP server stays responsive.
|
|
@@ -118,6 +421,13 @@ module.exports = function (RED) {
|
|
|
118
421
|
// rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
|
|
119
422
|
// collapses to exactly one rebuild pass.
|
|
120
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
|
+
*/
|
|
121
431
|
function _endStartupPhase() {
|
|
122
432
|
if (!_startupPhase) return;
|
|
123
433
|
_startupPhase = false;
|
|
@@ -127,6 +437,11 @@ module.exports = function (RED) {
|
|
|
127
437
|
}
|
|
128
438
|
}
|
|
129
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.
|
|
130
445
|
if (RED.events && typeof RED.events.once === "function") {
|
|
131
446
|
RED.events.once("flows:started", _endStartupPhase);
|
|
132
447
|
}
|
|
@@ -134,22 +449,49 @@ module.exports = function (RED) {
|
|
|
134
449
|
// Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
|
|
135
450
|
setTimeout(_endStartupPhase, 2000).unref?.();
|
|
136
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
|
+
*/
|
|
137
458
|
function _armRebuild() {
|
|
138
459
|
if (_startupPhase) return; // gated — _endStartupPhase will flush
|
|
139
460
|
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
140
461
|
_rebuildTimer = setTimeout(_flushRebuild, 50);
|
|
141
462
|
_rebuildTimer.unref?.();
|
|
142
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
|
+
*/
|
|
143
469
|
function scheduleRebuildSelf(nodeId) {
|
|
144
470
|
if (!nodeId) return;
|
|
145
471
|
_dirtyPortals.add(nodeId);
|
|
146
472
|
_armRebuild();
|
|
147
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
|
+
*/
|
|
148
480
|
function scheduleRebuildUsing(compName) {
|
|
149
481
|
if (!compName) return;
|
|
150
482
|
_dirtyComps.add(compName);
|
|
151
483
|
_armRebuild();
|
|
152
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
|
+
*/
|
|
153
495
|
function _flushRebuild() {
|
|
154
496
|
_rebuildTimer = null;
|
|
155
497
|
const dirty = new Set(_dirtyComps);
|
|
@@ -179,6 +521,12 @@ module.exports = function (RED) {
|
|
|
179
521
|
|
|
180
522
|
const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
|
|
181
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
|
+
*/
|
|
182
530
|
function next() {
|
|
183
531
|
if (i >= fns.length) return;
|
|
184
532
|
try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
@@ -199,6 +547,7 @@ module.exports = function (RED) {
|
|
|
199
547
|
removeRoute,
|
|
200
548
|
isSafeName,
|
|
201
549
|
validateSubPath,
|
|
550
|
+
findMissingComponentRefs,
|
|
202
551
|
userDir,
|
|
203
552
|
readCachedJS,
|
|
204
553
|
writeCachedJS,
|
|
@@ -219,6 +568,22 @@ module.exports = function (RED) {
|
|
|
219
568
|
|
|
220
569
|
// ── Canvas node: shared component ─────────────────────────────
|
|
221
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
|
+
*/
|
|
222
587
|
function PortalComponentNode(config) {
|
|
223
588
|
RED.nodes.createNode(this, config);
|
|
224
589
|
const node = this;
|
|
@@ -239,10 +604,10 @@ module.exports = function (RED) {
|
|
|
239
604
|
node.status({
|
|
240
605
|
fill: "red",
|
|
241
606
|
shape: "ring",
|
|
242
|
-
text: "
|
|
607
|
+
text: shortStatus("dup: " + compName),
|
|
243
608
|
});
|
|
244
609
|
node.on("close", function (_removed, done) {
|
|
245
|
-
|
|
610
|
+
done();
|
|
246
611
|
});
|
|
247
612
|
return;
|
|
248
613
|
}
|
|
@@ -258,10 +623,10 @@ module.exports = function (RED) {
|
|
|
258
623
|
node.status({
|
|
259
624
|
fill: "red",
|
|
260
625
|
shape: "ring",
|
|
261
|
-
text: "
|
|
626
|
+
text: shortStatus("dup sym: " + compName),
|
|
262
627
|
});
|
|
263
628
|
node.on("close", function (_removed, done) {
|
|
264
|
-
|
|
629
|
+
done();
|
|
265
630
|
});
|
|
266
631
|
return;
|
|
267
632
|
}
|
|
@@ -274,10 +639,10 @@ module.exports = function (RED) {
|
|
|
274
639
|
|
|
275
640
|
if (syntaxErr) {
|
|
276
641
|
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
277
|
-
const short = syntaxErr.split("\n")[0]
|
|
278
|
-
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) });
|
|
279
644
|
} else {
|
|
280
|
-
node.status({ fill: "green", shape: "dot", text: compName });
|
|
645
|
+
node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
|
|
281
646
|
}
|
|
282
647
|
|
|
283
648
|
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
@@ -292,7 +657,7 @@ module.exports = function (RED) {
|
|
|
292
657
|
delete registry[compName];
|
|
293
658
|
// Portals depending on this component must rebuild (topology changed or name resolution breaks).
|
|
294
659
|
scheduleRebuildUsing(compName);
|
|
295
|
-
|
|
660
|
+
done();
|
|
296
661
|
});
|
|
297
662
|
}
|
|
298
663
|
RED.nodes.registerType("fc-portal-component", PortalComponentNode);
|
|
@@ -302,14 +667,44 @@ module.exports = function (RED) {
|
|
|
302
667
|
// Parse top-level symbols from utility code: function/const/let/class declarations.
|
|
303
668
|
// Used for selective inclusion (does user JSX reference any of these symbols?)
|
|
304
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
|
+
*/
|
|
305
686
|
function extractUtilitySymbols(code) {
|
|
306
687
|
const names = new Set();
|
|
688
|
+
if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
|
|
307
689
|
const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
308
690
|
let m;
|
|
309
|
-
while ((m = re.exec(code
|
|
691
|
+
while ((m = re.exec(code))) names.add(m[1]);
|
|
310
692
|
return names;
|
|
311
693
|
}
|
|
312
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
|
+
*/
|
|
313
708
|
function PortalUtilityNode(config) {
|
|
314
709
|
RED.nodes.createNode(this, config);
|
|
315
710
|
const node = this;
|
|
@@ -330,10 +725,10 @@ module.exports = function (RED) {
|
|
|
330
725
|
node.status({
|
|
331
726
|
fill: "red",
|
|
332
727
|
shape: "ring",
|
|
333
|
-
text: "
|
|
728
|
+
text: shortStatus("dup: " + utilName),
|
|
334
729
|
});
|
|
335
730
|
node.on("close", function (_removed, done) {
|
|
336
|
-
|
|
731
|
+
done();
|
|
337
732
|
});
|
|
338
733
|
return;
|
|
339
734
|
}
|
|
@@ -382,14 +777,14 @@ module.exports = function (RED) {
|
|
|
382
777
|
const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
|
|
383
778
|
node.error(`Utility "${utilName}": ${msgs}`);
|
|
384
779
|
if (syntaxErr) {
|
|
385
|
-
const short = syntaxErr.split("\n")[0]
|
|
386
|
-
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
780
|
+
const short = syntaxErr.split("\n")[0];
|
|
781
|
+
node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
|
|
387
782
|
} else {
|
|
388
783
|
const firstSym = conflicts[0].split(" ")[0];
|
|
389
784
|
node.status({
|
|
390
785
|
fill: "red",
|
|
391
786
|
shape: "ring",
|
|
392
|
-
text: "
|
|
787
|
+
text: shortStatus("dup sym: " + firstSym),
|
|
393
788
|
});
|
|
394
789
|
}
|
|
395
790
|
// Don't register symbols on conflict — leave the namespace clean for
|
|
@@ -401,7 +796,7 @@ module.exports = function (RED) {
|
|
|
401
796
|
node.status({
|
|
402
797
|
fill: "green",
|
|
403
798
|
shape: "dot",
|
|
404
|
-
text:
|
|
799
|
+
text: shortStatus(utilName),
|
|
405
800
|
});
|
|
406
801
|
}
|
|
407
802
|
|
|
@@ -431,13 +826,36 @@ module.exports = function (RED) {
|
|
|
431
826
|
delete utilities[utilName];
|
|
432
827
|
scheduleRebuildUsing(utilName);
|
|
433
828
|
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
434
|
-
|
|
829
|
+
done();
|
|
435
830
|
});
|
|
436
831
|
}
|
|
437
832
|
RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
|
|
438
833
|
|
|
439
834
|
// ── Main node: portal-react ───────────────────────────────────
|
|
440
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
|
+
*/
|
|
441
859
|
function PortalReactNode(config) {
|
|
442
860
|
RED.nodes.createNode(this, config);
|
|
443
861
|
const node = this;
|
|
@@ -464,7 +882,7 @@ module.exports = function (RED) {
|
|
|
464
882
|
node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
|
|
465
883
|
}
|
|
466
884
|
node.on("close", function (_removed, done) {
|
|
467
|
-
|
|
885
|
+
done();
|
|
468
886
|
});
|
|
469
887
|
return;
|
|
470
888
|
}
|
|
@@ -487,10 +905,10 @@ module.exports = function (RED) {
|
|
|
487
905
|
node.status({
|
|
488
906
|
fill: "red",
|
|
489
907
|
shape: "ring",
|
|
490
|
-
text: "
|
|
908
|
+
text: shortStatus("dup: " + subPath),
|
|
491
909
|
});
|
|
492
910
|
node.on("close", function (_removed, done) {
|
|
493
|
-
|
|
911
|
+
done();
|
|
494
912
|
});
|
|
495
913
|
return;
|
|
496
914
|
}
|
|
@@ -511,56 +929,96 @@ module.exports = function (RED) {
|
|
|
511
929
|
|
|
512
930
|
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
513
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
|
+
*/
|
|
514
940
|
function updateStatus() {
|
|
515
941
|
if (isClosing) return;
|
|
516
942
|
const st = pageState[endpoint];
|
|
517
943
|
const n = clients.size;
|
|
518
|
-
|
|
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}]` : "";
|
|
519
948
|
|
|
520
|
-
// Preserve build-error state — don't clobber with client count until JSX is fixed.
|
|
521
|
-
// Show "(serving last good)" suffix in degraded mode (ring shape) so it is
|
|
522
|
-
// obvious the portal still works for connected clients despite the broken build.
|
|
523
949
|
if (st && st.compiled && st.compiled.error) {
|
|
524
950
|
let base;
|
|
525
|
-
if (st.
|
|
526
|
-
else if (st.
|
|
527
|
-
else if (st.errorKind === "
|
|
528
|
-
else base = "
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
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
|
+
});
|
|
538
963
|
return;
|
|
539
964
|
}
|
|
540
|
-
// Preserve building state — same reason.
|
|
541
965
|
if (st && st.building) {
|
|
542
|
-
node.status({ fill: "yellow", shape: "dot", text: "building
|
|
966
|
+
node.status({ fill: "yellow", shape: "dot", text: "building…" });
|
|
543
967
|
return;
|
|
544
968
|
}
|
|
545
|
-
// Build succeeded but a connected browser threw at runtime
|
|
546
|
-
// (e.g. ReferenceError to a missing component / undefined identifier).
|
|
547
969
|
if (st && st.runtimeError) {
|
|
548
970
|
node.status({
|
|
549
971
|
fill: "red",
|
|
550
972
|
shape: "ring",
|
|
551
|
-
text: "runtime
|
|
973
|
+
text: shortStatus("runtime err" + tail),
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
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),
|
|
552
985
|
});
|
|
553
986
|
return;
|
|
554
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.
|
|
555
992
|
node.status({
|
|
556
993
|
fill: n > 0 ? "green" : "grey",
|
|
557
994
|
shape: n > 0 ? "dot" : "ring",
|
|
558
|
-
text:
|
|
995
|
+
text: n > 0 ? "connected" + tail : "0 clients",
|
|
559
996
|
});
|
|
560
997
|
}
|
|
561
998
|
|
|
562
999
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
563
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
|
+
*/
|
|
564
1022
|
function rebuild() {
|
|
565
1023
|
try {
|
|
566
1024
|
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
@@ -579,20 +1037,45 @@ module.exports = function (RED) {
|
|
|
579
1037
|
const allEntries = Object.entries(registry);
|
|
580
1038
|
const needed = new Set();
|
|
581
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
|
+
*/
|
|
582
1065
|
function addWithDeps(name) {
|
|
583
1066
|
if (needed.has(name)) return;
|
|
584
1067
|
const entry = registry[name];
|
|
585
1068
|
if (!entry) return;
|
|
586
1069
|
needed.add(name);
|
|
587
1070
|
for (const [other] of allEntries) {
|
|
588
|
-
if (other !== name && entry.code
|
|
1071
|
+
if (other !== name && refRe(other).test(entry.code)) {
|
|
589
1072
|
addWithDeps(other);
|
|
590
1073
|
}
|
|
591
1074
|
}
|
|
592
1075
|
}
|
|
593
1076
|
|
|
594
1077
|
for (const [name] of allEntries) {
|
|
595
|
-
if (
|
|
1078
|
+
if (refRe(name).test(componentCode)) {
|
|
596
1079
|
addWithDeps(name);
|
|
597
1080
|
}
|
|
598
1081
|
}
|
|
@@ -621,6 +1104,14 @@ module.exports = function (RED) {
|
|
|
621
1104
|
return false;
|
|
622
1105
|
};
|
|
623
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
|
+
*/
|
|
624
1115
|
function addUtilWithDeps(name) {
|
|
625
1116
|
if (neededUtils.has(name)) return;
|
|
626
1117
|
const u = utilities[name];
|
|
@@ -671,6 +1162,24 @@ module.exports = function (RED) {
|
|
|
671
1162
|
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
672
1163
|
const cleanUtilJsx = utilityJsx.replace(importRe, "").trim();
|
|
673
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
|
+
|
|
674
1183
|
// Dedupe imports across all sources (libs may already pull React; user
|
|
675
1184
|
// and utility may import the same package).
|
|
676
1185
|
const seenImports = new Set();
|
|
@@ -794,8 +1303,23 @@ module.exports = function (RED) {
|
|
|
794
1303
|
// ── Resolve compiled (success or unified error) ──
|
|
795
1304
|
let compiled;
|
|
796
1305
|
let cacheHit = false;
|
|
797
|
-
let errorKind = null; // 'component' | 'missing-return' | 'transpile'
|
|
798
|
-
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.`;
|
|
1313
|
+
compiled = {
|
|
1314
|
+
js: null,
|
|
1315
|
+
error: `Missing component${plural ? "s" : ""}: ${list}\n\n${hint}`,
|
|
1316
|
+
};
|
|
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) {
|
|
799
1323
|
const srcErr =
|
|
800
1324
|
errorSourceKind === "utility"
|
|
801
1325
|
? utilities[errorSource].error
|
|
@@ -827,7 +1351,9 @@ module.exports = function (RED) {
|
|
|
827
1351
|
|
|
828
1352
|
if (compiled.error) {
|
|
829
1353
|
node.error(
|
|
830
|
-
(errorKind === "component"
|
|
1354
|
+
(errorKind === "missing-component"
|
|
1355
|
+
? "Missing component(s) in JSX: "
|
|
1356
|
+
: errorKind === "component"
|
|
831
1357
|
? `Component "${errorSource}" syntax error: `
|
|
832
1358
|
: errorKind === "utility"
|
|
833
1359
|
? `Utility "${errorSource}" syntax error: `
|
|
@@ -879,7 +1405,15 @@ module.exports = function (RED) {
|
|
|
879
1405
|
return { css, cssHash };
|
|
880
1406
|
});
|
|
881
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.
|
|
882
1413
|
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
1414
|
+
const st = pageState[endpoint];
|
|
1415
|
+
if (st) st.cssError = true;
|
|
1416
|
+
updateStatus();
|
|
883
1417
|
return { css: "", cssHash: "" };
|
|
884
1418
|
})
|
|
885
1419
|
: Promise.resolve({ css: "", cssHash: "" });
|
|
@@ -930,24 +1464,36 @@ module.exports = function (RED) {
|
|
|
930
1464
|
});
|
|
931
1465
|
}
|
|
932
1466
|
|
|
933
|
-
cssReady
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
state.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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();
|
|
947
1488
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
+
});
|
|
951
1497
|
} catch (e) {
|
|
952
1498
|
node.error("Rebuild failed: " + e.message);
|
|
953
1499
|
// Surface as a regular build error so the lastGood/degraded path,
|
|
@@ -1109,7 +1655,11 @@ module.exports = function (RED) {
|
|
|
1109
1655
|
|
|
1110
1656
|
try {
|
|
1111
1657
|
const WebSocket = require("ws");
|
|
1112
|
-
|
|
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);
|
|
1113
1663
|
|
|
1114
1664
|
// Remove previous upgrade handler for this node (dirty deploy)
|
|
1115
1665
|
if (upgradeHandlers[nodeId]) {
|
|
@@ -1198,17 +1748,11 @@ module.exports = function (RED) {
|
|
|
1198
1748
|
}
|
|
1199
1749
|
|
|
1200
1750
|
// Heartbeat — detect dead sockets via WS ping/pong. Browser
|
|
1201
|
-
// 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.
|
|
1202
1754
|
ws._isAlive = true;
|
|
1203
1755
|
ws.on("pong", () => { ws._isAlive = true; });
|
|
1204
|
-
ws._pingIv = setInterval(() => {
|
|
1205
|
-
if (ws._isAlive === false) {
|
|
1206
|
-
try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
|
|
1207
|
-
return;
|
|
1208
|
-
}
|
|
1209
|
-
ws._isAlive = false;
|
|
1210
|
-
try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
|
|
1211
|
-
}, 30000);
|
|
1212
1756
|
|
|
1213
1757
|
ws.on("message", (raw) => {
|
|
1214
1758
|
try {
|
|
@@ -1238,6 +1782,9 @@ module.exports = function (RED) {
|
|
|
1238
1782
|
// inbound frame.
|
|
1239
1783
|
const client = { portalClient: ws._portalClient };
|
|
1240
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.
|
|
1241
1788
|
Object.assign(client, ws._portalUser);
|
|
1242
1789
|
}
|
|
1243
1790
|
out._client = client;
|
|
@@ -1253,7 +1800,8 @@ module.exports = function (RED) {
|
|
|
1253
1800
|
});
|
|
1254
1801
|
|
|
1255
1802
|
const detach = () => {
|
|
1256
|
-
|
|
1803
|
+
// No per-client interval to clear — heartbeat is centralised in
|
|
1804
|
+
// the shared `_pingSweep` tick (see registerPingedServer).
|
|
1257
1805
|
clients.delete(portalClient);
|
|
1258
1806
|
if (userId) {
|
|
1259
1807
|
const set = userIndex.get(userId);
|
|
@@ -1277,6 +1825,15 @@ module.exports = function (RED) {
|
|
|
1277
1825
|
// sendTo: single point where every outbound frame passes through
|
|
1278
1826
|
// the onCanSendTo hook. Strict-by-default — no opt-in per widget
|
|
1279
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
|
+
*/
|
|
1280
1837
|
function sendTo(ws, frame, msg) {
|
|
1281
1838
|
if (!ws || ws.readyState !== 1) return false;
|
|
1282
1839
|
if (!hooks.allow("onCanSendTo", ws, msg)) return false;
|
|
@@ -1290,94 +1847,156 @@ module.exports = function (RED) {
|
|
|
1290
1847
|
}
|
|
1291
1848
|
|
|
1292
1849
|
node.on("input", (msg, send, done) => {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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);
|
|
1298
1873
|
}
|
|
1299
|
-
updateStatus();
|
|
1300
|
-
if (done) done();
|
|
1301
1874
|
});
|
|
1302
1875
|
|
|
1303
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.
|
|
1304
1892
|
|
|
1305
1893
|
node.on("close", (removed, done) => {
|
|
1306
|
-
|
|
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?.();
|
|
1307
1904
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
|
|
1311
|
-
delete upgradeHandlers[nodeId];
|
|
1312
|
-
}
|
|
1905
|
+
try {
|
|
1906
|
+
isClosing = true;
|
|
1313
1907
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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();
|
|
1323
1917
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
}
|
|
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
|
+
}
|
|
1331
1924
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
+
}
|
|
1337
1935
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
delete
|
|
1341
|
-
|
|
1936
|
+
// Unregister rebuild callback + selective-rebuild metadata
|
|
1937
|
+
delete rebuildCallbacks[nodeId];
|
|
1938
|
+
delete portalNeeded[nodeId];
|
|
1939
|
+
delete portalNeededUtilities[nodeId];
|
|
1940
|
+
delete portalCode[nodeId];
|
|
1342
1941
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
delete portalSig[nodeId];
|
|
1348
|
-
}
|
|
1942
|
+
// Release endpoint ownership
|
|
1943
|
+
if (endpointOwners[endpoint] === nodeId) {
|
|
1944
|
+
delete endpointOwners[endpoint];
|
|
1945
|
+
}
|
|
1349
1946
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
// between close and the new build these would retain closures over
|
|
1357
|
-
// the old clients/userIndex/rebuild scope.
|
|
1358
|
-
const st = pageState[endpoint];
|
|
1359
|
-
if (st) {
|
|
1360
|
-
st.cssReady = null;
|
|
1361
|
-
st.compiled = null;
|
|
1362
|
-
st.css = null;
|
|
1363
|
-
}
|
|
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
|
+
}
|
|
1364
1953
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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;
|
|
1370
1967
|
}
|
|
1371
|
-
delete pageState[endpoint];
|
|
1372
|
-
removeRoute(RED.httpNode._router, endpoint);
|
|
1373
|
-
delete registeredRoutes[endpoint];
|
|
1374
|
-
}
|
|
1375
1968
|
|
|
1376
|
-
|
|
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
|
+
}
|
|
1377
1986
|
});
|
|
1378
1987
|
|
|
1379
1988
|
// ── Utilities ─────────────────────────────────────────────
|
|
1380
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
|
+
*/
|
|
1381
2000
|
function wsSend(ws, obj) {
|
|
1382
2001
|
try {
|
|
1383
2002
|
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
@@ -1398,13 +2017,14 @@ module.exports = function (RED) {
|
|
|
1398
2017
|
);
|
|
1399
2018
|
RED.httpAdmin.use(
|
|
1400
2019
|
"/portal-react/vs",
|
|
2020
|
+
PERM_READ,
|
|
1401
2021
|
express.static(path.join(monacoPath, "min", "vs")),
|
|
1402
2022
|
);
|
|
1403
2023
|
|
|
1404
2024
|
// ── Tailwind class list endpoint ────────────────────────────
|
|
1405
2025
|
const { generateCandidates } = require("./tw-candidates");
|
|
1406
2026
|
let twClassesCache = null;
|
|
1407
|
-
RED.httpAdmin.get("/portal-react/tw-classes", (_req, res) => {
|
|
2027
|
+
RED.httpAdmin.get("/portal-react/tw-classes", PERM_READ, (_req, res) => {
|
|
1408
2028
|
if (!twClassesCache) {
|
|
1409
2029
|
twClassesCache = generateCandidates();
|
|
1410
2030
|
}
|
|
@@ -1412,8 +2032,41 @@ module.exports = function (RED) {
|
|
|
1412
2032
|
});
|
|
1413
2033
|
|
|
1414
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.
|
|
1415
2064
|
RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
|
|
1416
2065
|
const reqHash = req.params.hash;
|
|
2066
|
+
if (!CSS_HASH_RE.test(reqHash)) {
|
|
2067
|
+
res.status(400).send("Bad request");
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
1417
2070
|
let css = null;
|
|
1418
2071
|
for (const ep in pageState) {
|
|
1419
2072
|
if (pageState[ep]?.cssHash === reqHash) {
|
|
@@ -1434,15 +2087,19 @@ module.exports = function (RED) {
|
|
|
1434
2087
|
|
|
1435
2088
|
// ── Public assets folder ─────────────────────────────────────
|
|
1436
2089
|
const { registerAssets } = require("./lib/assets");
|
|
1437
|
-
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
|
+
});
|
|
1438
2095
|
|
|
1439
2096
|
// ── Admin API for component registry ──────────────────────────
|
|
1440
2097
|
|
|
1441
|
-
RED.httpAdmin.get("/portal-react/registry", (_req, res) => {
|
|
2098
|
+
RED.httpAdmin.get("/portal-react/registry", PERM_READ, (_req, res) => {
|
|
1442
2099
|
res.json(registry);
|
|
1443
2100
|
});
|
|
1444
2101
|
|
|
1445
|
-
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) => {
|
|
1446
2103
|
const { name, code } = req.body || {};
|
|
1447
2104
|
if (!isSafeName(name))
|
|
1448
2105
|
return res.status(400).json({ error: "invalid name" });
|
|
@@ -1455,7 +2112,7 @@ module.exports = function (RED) {
|
|
|
1455
2112
|
res.json({ ok: true });
|
|
1456
2113
|
});
|
|
1457
2114
|
|
|
1458
|
-
RED.httpAdmin.delete("/portal-react/registry/:name", (req, res) => {
|
|
2115
|
+
RED.httpAdmin.delete("/portal-react/registry/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
|
|
1459
2116
|
const name = req.params.name;
|
|
1460
2117
|
if (!isSafeName(name))
|
|
1461
2118
|
return res.status(400).json({ error: "invalid name" });
|
|
@@ -1469,7 +2126,7 @@ module.exports = function (RED) {
|
|
|
1469
2126
|
|
|
1470
2127
|
// ── Admin API for utility registry ────────────────────────────
|
|
1471
2128
|
|
|
1472
|
-
RED.httpAdmin.get("/portal-react/utilities", (_req, res) => {
|
|
2129
|
+
RED.httpAdmin.get("/portal-react/utilities", PERM_READ, (_req, res) => {
|
|
1473
2130
|
// Include parsed top-level symbols so the editor "Utilities" dialog can
|
|
1474
2131
|
// list which identifiers each node exports.
|
|
1475
2132
|
const out = {};
|
|
@@ -1483,7 +2140,7 @@ module.exports = function (RED) {
|
|
|
1483
2140
|
res.json(out);
|
|
1484
2141
|
});
|
|
1485
2142
|
|
|
1486
|
-
RED.httpAdmin.post("/portal-react/utilities", (req, res) => {
|
|
2143
|
+
RED.httpAdmin.post("/portal-react/utilities", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
|
|
1487
2144
|
const { name, code } = req.body || {};
|
|
1488
2145
|
if (!isSafeName(name))
|
|
1489
2146
|
return res.status(400).json({ error: "invalid name" });
|
|
@@ -1530,7 +2187,7 @@ module.exports = function (RED) {
|
|
|
1530
2187
|
res.json({ ok: true, error: combinedErr || null });
|
|
1531
2188
|
});
|
|
1532
2189
|
|
|
1533
|
-
RED.httpAdmin.delete("/portal-react/utilities/:name", (req, res) => {
|
|
2190
|
+
RED.httpAdmin.delete("/portal-react/utilities/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
|
|
1534
2191
|
const name = req.params.name;
|
|
1535
2192
|
if (!isSafeName(name))
|
|
1536
2193
|
return res.status(400).json({ error: "invalid name" });
|