@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 0.1.0-alpha.24
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 +334 -12
- package/nodes/lib/hooks.js +38 -0
- package/nodes/lib/page-builder.js +143 -10
- package/nodes/lib/router.js +25 -1
- package/nodes/portal-react.html +248 -9
- package/nodes/portal-react.js +865 -197
- 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); }
|
|
@@ -196,9 +544,11 @@ module.exports = function (RED) {
|
|
|
196
544
|
quickCheckSyntax,
|
|
197
545
|
generateCSS,
|
|
198
546
|
extractPortalUser,
|
|
547
|
+
serveableHash,
|
|
199
548
|
removeRoute,
|
|
200
549
|
isSafeName,
|
|
201
550
|
validateSubPath,
|
|
551
|
+
findMissingComponentRefs,
|
|
202
552
|
userDir,
|
|
203
553
|
readCachedJS,
|
|
204
554
|
writeCachedJS,
|
|
@@ -219,6 +569,22 @@ module.exports = function (RED) {
|
|
|
219
569
|
|
|
220
570
|
// ── Canvas node: shared component ─────────────────────────────
|
|
221
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Canvas node that registers a named React component into the shared
|
|
574
|
+
* component registry. One node = one named identifier; multiple
|
|
575
|
+
* components require multiple `fc-portal-component` nodes.
|
|
576
|
+
*
|
|
577
|
+
* Constructor side-effects:
|
|
578
|
+
* - validates `compName` (JS identifier + length + prototype blacklist)
|
|
579
|
+
* - checks duplicates against the cross-namespace owner table
|
|
580
|
+
* - syntax-checks the JSX via `quickCheckSyntax`
|
|
581
|
+
* - schedules a selective rebuild of every portal that references this name
|
|
582
|
+
*
|
|
583
|
+
* @param {ComponentNodeConfig} config
|
|
584
|
+
* @returns {void}
|
|
585
|
+
* @fires Node-RED#close via node.on("close", …) — removes registration on disable / delete.
|
|
586
|
+
* @private
|
|
587
|
+
*/
|
|
222
588
|
function PortalComponentNode(config) {
|
|
223
589
|
RED.nodes.createNode(this, config);
|
|
224
590
|
const node = this;
|
|
@@ -239,10 +605,10 @@ module.exports = function (RED) {
|
|
|
239
605
|
node.status({
|
|
240
606
|
fill: "red",
|
|
241
607
|
shape: "ring",
|
|
242
|
-
text: "
|
|
608
|
+
text: shortStatus("dup: " + compName),
|
|
243
609
|
});
|
|
244
610
|
node.on("close", function (_removed, done) {
|
|
245
|
-
|
|
611
|
+
done();
|
|
246
612
|
});
|
|
247
613
|
return;
|
|
248
614
|
}
|
|
@@ -258,10 +624,10 @@ module.exports = function (RED) {
|
|
|
258
624
|
node.status({
|
|
259
625
|
fill: "red",
|
|
260
626
|
shape: "ring",
|
|
261
|
-
text: "
|
|
627
|
+
text: shortStatus("dup sym: " + compName),
|
|
262
628
|
});
|
|
263
629
|
node.on("close", function (_removed, done) {
|
|
264
|
-
|
|
630
|
+
done();
|
|
265
631
|
});
|
|
266
632
|
return;
|
|
267
633
|
}
|
|
@@ -274,10 +640,10 @@ module.exports = function (RED) {
|
|
|
274
640
|
|
|
275
641
|
if (syntaxErr) {
|
|
276
642
|
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
277
|
-
const short = syntaxErr.split("\n")[0]
|
|
278
|
-
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
643
|
+
const short = syntaxErr.split("\n")[0];
|
|
644
|
+
node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
|
|
279
645
|
} else {
|
|
280
|
-
node.status({ fill: "green", shape: "dot", text: compName });
|
|
646
|
+
node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
|
|
281
647
|
}
|
|
282
648
|
|
|
283
649
|
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
@@ -292,7 +658,7 @@ module.exports = function (RED) {
|
|
|
292
658
|
delete registry[compName];
|
|
293
659
|
// Portals depending on this component must rebuild (topology changed or name resolution breaks).
|
|
294
660
|
scheduleRebuildUsing(compName);
|
|
295
|
-
|
|
661
|
+
done();
|
|
296
662
|
});
|
|
297
663
|
}
|
|
298
664
|
RED.nodes.registerType("fc-portal-component", PortalComponentNode);
|
|
@@ -302,14 +668,44 @@ module.exports = function (RED) {
|
|
|
302
668
|
// Parse top-level symbols from utility code: function/const/let/class declarations.
|
|
303
669
|
// Used for selective inclusion (does user JSX reference any of these symbols?)
|
|
304
670
|
// and for the editor "Utilities" dialog (lists exported identifiers per node).
|
|
671
|
+
// Code is rejected silently when oversize so the regex cannot be weaponized
|
|
672
|
+
// (ReDoS) by feeding multi-MB code into a sticky regex with `\s+` runs.
|
|
673
|
+
const MAX_UTIL_CODE_BYTES = 1_000_000;
|
|
674
|
+
/**
|
|
675
|
+
* Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
|
|
676
|
+
* names. Used to populate the symbol-ownership table (collision checks)
|
|
677
|
+
* and the editor's Utilities dialog. Oversize input is rejected silently
|
|
678
|
+
* (ReDoS guard — the regex has multiple `\s+` runs that could regress
|
|
679
|
+
* to near-linear on multi-MB payloads).
|
|
680
|
+
*
|
|
681
|
+
* @param {string} code
|
|
682
|
+
* @returns {Set<string>} set of declared top-level identifiers
|
|
683
|
+
* @example
|
|
684
|
+
* extractUtilitySymbols("const PI = 3.14;\nfunction add(a,b) { return a+b }");
|
|
685
|
+
* // → Set(2) { "PI", "add" }
|
|
686
|
+
*/
|
|
305
687
|
function extractUtilitySymbols(code) {
|
|
306
688
|
const names = new Set();
|
|
689
|
+
if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
|
|
307
690
|
const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
308
691
|
let m;
|
|
309
|
-
while ((m = re.exec(code
|
|
692
|
+
while ((m = re.exec(code))) names.add(m[1]);
|
|
310
693
|
return names;
|
|
311
694
|
}
|
|
312
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Canvas node that contributes raw top-level JavaScript (helpers / custom
|
|
698
|
+
* hooks / constants) to the bundle. Unlike `fc-portal-component`, the code
|
|
699
|
+
* is injected *without* an IIFE wrapper — one node can declare many
|
|
700
|
+
* top-level symbols. Each symbol is registered with the cross-namespace
|
|
701
|
+
* owner table so identifiers don't collide with component names or other
|
|
702
|
+
* utility nodes.
|
|
703
|
+
*
|
|
704
|
+
* @param {UtilityNodeConfig} config
|
|
705
|
+
* @returns {void}
|
|
706
|
+
* @fires Node-RED#close on disable / delete — frees registry + owned symbols.
|
|
707
|
+
* @private
|
|
708
|
+
*/
|
|
313
709
|
function PortalUtilityNode(config) {
|
|
314
710
|
RED.nodes.createNode(this, config);
|
|
315
711
|
const node = this;
|
|
@@ -330,10 +726,10 @@ module.exports = function (RED) {
|
|
|
330
726
|
node.status({
|
|
331
727
|
fill: "red",
|
|
332
728
|
shape: "ring",
|
|
333
|
-
text: "
|
|
729
|
+
text: shortStatus("dup: " + utilName),
|
|
334
730
|
});
|
|
335
731
|
node.on("close", function (_removed, done) {
|
|
336
|
-
|
|
732
|
+
done();
|
|
337
733
|
});
|
|
338
734
|
return;
|
|
339
735
|
}
|
|
@@ -382,14 +778,14 @@ module.exports = function (RED) {
|
|
|
382
778
|
const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
|
|
383
779
|
node.error(`Utility "${utilName}": ${msgs}`);
|
|
384
780
|
if (syntaxErr) {
|
|
385
|
-
const short = syntaxErr.split("\n")[0]
|
|
386
|
-
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
781
|
+
const short = syntaxErr.split("\n")[0];
|
|
782
|
+
node.status({ fill: "red", shape: "dot", text: shortStatus("syntax: " + short) });
|
|
387
783
|
} else {
|
|
388
784
|
const firstSym = conflicts[0].split(" ")[0];
|
|
389
785
|
node.status({
|
|
390
786
|
fill: "red",
|
|
391
787
|
shape: "ring",
|
|
392
|
-
text: "
|
|
788
|
+
text: shortStatus("dup sym: " + firstSym),
|
|
393
789
|
});
|
|
394
790
|
}
|
|
395
791
|
// Don't register symbols on conflict — leave the namespace clean for
|
|
@@ -401,7 +797,7 @@ module.exports = function (RED) {
|
|
|
401
797
|
node.status({
|
|
402
798
|
fill: "green",
|
|
403
799
|
shape: "dot",
|
|
404
|
-
text:
|
|
800
|
+
text: shortStatus(utilName),
|
|
405
801
|
});
|
|
406
802
|
}
|
|
407
803
|
|
|
@@ -431,13 +827,36 @@ module.exports = function (RED) {
|
|
|
431
827
|
delete utilities[utilName];
|
|
432
828
|
scheduleRebuildUsing(utilName);
|
|
433
829
|
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
434
|
-
|
|
830
|
+
done();
|
|
435
831
|
});
|
|
436
832
|
}
|
|
437
833
|
RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
|
|
438
834
|
|
|
439
835
|
// ── Main node: portal-react ───────────────────────────────────
|
|
440
836
|
|
|
837
|
+
/**
|
|
838
|
+
* Main canvas node: serves a React app at `/fromcubes/<subPath>` and
|
|
839
|
+
* bridges its WebSocket to Node-RED wires.
|
|
840
|
+
*
|
|
841
|
+
* Lifecycle (per deploy):
|
|
842
|
+
* 1. Validate subPath / legacy endpoint migration.
|
|
843
|
+
* 2. Take ownership of the endpoint (one portal per URL).
|
|
844
|
+
* 3. Compute the config signature — skip rebuild if unchanged.
|
|
845
|
+
* 4. Register rebuild callback in `rebuildCallbacks`.
|
|
846
|
+
* 5. Inside `setImmediate`: mount HTTP route, attach WS upgrade handler,
|
|
847
|
+
* wire `input` + `close` events.
|
|
848
|
+
*
|
|
849
|
+
* Input handler routes incoming `msg` through `lib/router.js` and caches
|
|
850
|
+
* the payload (deep-cloned) when broadcasting so freshly-connected clients
|
|
851
|
+
* recover the last value.
|
|
852
|
+
*
|
|
853
|
+
* @param {PortalConfig} config
|
|
854
|
+
* @returns {void}
|
|
855
|
+
* @fires Node-RED#close
|
|
856
|
+
* @listens Node-RED#input
|
|
857
|
+
* @listens ws#connection
|
|
858
|
+
* @private
|
|
859
|
+
*/
|
|
441
860
|
function PortalReactNode(config) {
|
|
442
861
|
RED.nodes.createNode(this, config);
|
|
443
862
|
const node = this;
|
|
@@ -464,7 +883,7 @@ module.exports = function (RED) {
|
|
|
464
883
|
node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
|
|
465
884
|
}
|
|
466
885
|
node.on("close", function (_removed, done) {
|
|
467
|
-
|
|
886
|
+
done();
|
|
468
887
|
});
|
|
469
888
|
return;
|
|
470
889
|
}
|
|
@@ -487,10 +906,10 @@ module.exports = function (RED) {
|
|
|
487
906
|
node.status({
|
|
488
907
|
fill: "red",
|
|
489
908
|
shape: "ring",
|
|
490
|
-
text: "
|
|
909
|
+
text: shortStatus("dup: " + subPath),
|
|
491
910
|
});
|
|
492
911
|
node.on("close", function (_removed, done) {
|
|
493
|
-
|
|
912
|
+
done();
|
|
494
913
|
});
|
|
495
914
|
return;
|
|
496
915
|
}
|
|
@@ -511,56 +930,96 @@ module.exports = function (RED) {
|
|
|
511
930
|
|
|
512
931
|
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
513
932
|
|
|
933
|
+
/**
|
|
934
|
+
* Refresh the canvas-node status indicator. Priority of states (highest
|
|
935
|
+
* first): build error > building > runtime error > CSS-fail >
|
|
936
|
+
* connected/0-clients. Returns early when the node is closing so a late
|
|
937
|
+
* rebuild promise can't flash a stale state on a torn-down node.
|
|
938
|
+
* @returns {void}
|
|
939
|
+
* @private
|
|
940
|
+
*/
|
|
514
941
|
function updateStatus() {
|
|
515
942
|
if (isClosing) return;
|
|
516
943
|
const st = pageState[endpoint];
|
|
517
944
|
const n = clients.size;
|
|
518
|
-
|
|
945
|
+
// Compact tail keeps within the 20-char status budget. Shape (ring vs
|
|
946
|
+
// dot) and fill carry the "0 clients" + "degraded" signals; we no
|
|
947
|
+
// longer pack them into the text.
|
|
948
|
+
const tail = n > 0 ? ` [${n}]` : "";
|
|
519
949
|
|
|
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
950
|
if (st && st.compiled && st.compiled.error) {
|
|
524
951
|
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
|
-
}
|
|
952
|
+
if (st.errorKind === "missing-component") base = "missing: " + st.errorSource;
|
|
953
|
+
else if (st.errorSource) base = "broken: " + st.errorSource;
|
|
954
|
+
else if (st.errorKind === "missing-return") base = "no return";
|
|
955
|
+
else if (st.errorKind === "rebuild") base = "rebuild err";
|
|
956
|
+
else base = "transpile err";
|
|
957
|
+
// Degraded mode = ring shape (already conveys "serving last good");
|
|
958
|
+
// hard failure = dot. Text stays ≤20.
|
|
959
|
+
node.status({
|
|
960
|
+
fill: "red",
|
|
961
|
+
shape: st.lastGood ? "ring" : "dot",
|
|
962
|
+
text: shortStatus(base + tail),
|
|
963
|
+
});
|
|
538
964
|
return;
|
|
539
965
|
}
|
|
540
|
-
// Preserve building state — same reason.
|
|
541
966
|
if (st && st.building) {
|
|
542
|
-
node.status({ fill: "yellow", shape: "dot", text: "building
|
|
967
|
+
node.status({ fill: "yellow", shape: "dot", text: "building…" });
|
|
543
968
|
return;
|
|
544
969
|
}
|
|
545
|
-
// Build succeeded but a connected browser threw at runtime
|
|
546
|
-
// (e.g. ReferenceError to a missing component / undefined identifier).
|
|
547
970
|
if (st && st.runtimeError) {
|
|
548
971
|
node.status({
|
|
549
972
|
fill: "red",
|
|
550
973
|
shape: "ring",
|
|
551
|
-
text: "runtime
|
|
974
|
+
text: shortStatus("runtime err" + tail),
|
|
975
|
+
});
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
// CSS generation failed but JS is fine — the portal still works,
|
|
979
|
+
// just unstyled. Yellow ring distinguishes from green-OK without
|
|
980
|
+
// claiming "broken". Cleared on the next successful CSS pass.
|
|
981
|
+
if (st && st.cssError) {
|
|
982
|
+
node.status({
|
|
983
|
+
fill: "yellow",
|
|
984
|
+
shape: "ring",
|
|
985
|
+
text: shortStatus("css-fail" + tail),
|
|
552
986
|
});
|
|
553
987
|
return;
|
|
554
988
|
}
|
|
989
|
+
// All status text stays as English literals for now. A full i18n
|
|
990
|
+
// catalog migration is tracked separately — until then, mixing one
|
|
991
|
+
// RED._(...) call with ~10 hardcoded strings would just confuse the
|
|
992
|
+
// reader without giving any locale coverage.
|
|
555
993
|
node.status({
|
|
556
994
|
fill: n > 0 ? "green" : "grey",
|
|
557
995
|
shape: n > 0 ? "dot" : "ring",
|
|
558
|
-
text:
|
|
996
|
+
text: n > 0 ? "connected" + tail : "0 clients",
|
|
559
997
|
});
|
|
560
998
|
}
|
|
561
999
|
|
|
562
1000
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
563
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* Transpile and bundle the portal's JSX into a fresh PageState entry.
|
|
1004
|
+
*
|
|
1005
|
+
* Pipeline (per call):
|
|
1006
|
+
* 1. Resolve needed components (transitive deps from registry)
|
|
1007
|
+
* 2. Resolve needed utility nodes (any symbol referenced anywhere)
|
|
1008
|
+
* 3. Hoist imports, dedupe across sources
|
|
1009
|
+
* 4. Pre-flight: syntax errors in components / utilities, missing
|
|
1010
|
+
* `return` in `function App`
|
|
1011
|
+
* 5. Read disk cache; on miss run `transpile()` (esbuild buildSync)
|
|
1012
|
+
* 6. Generate Tailwind CSS (cssReady promise)
|
|
1013
|
+
* 7. Snapshot lastGood for degraded-mode serving
|
|
1014
|
+
* 8. Broadcast `version` / `error` / `building` frames to live WS
|
|
1015
|
+
*
|
|
1016
|
+
* Errors set `pageState[endpoint].compiled.error` and the route handler
|
|
1017
|
+
* serves an error page (or the previous lastGood build with a banner).
|
|
1018
|
+
*
|
|
1019
|
+
* @returns {void}
|
|
1020
|
+
* @throws never — internal exceptions are caught and stored in pageState.
|
|
1021
|
+
* @private
|
|
1022
|
+
*/
|
|
564
1023
|
function rebuild() {
|
|
565
1024
|
try {
|
|
566
1025
|
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
@@ -579,20 +1038,45 @@ module.exports = function (RED) {
|
|
|
579
1038
|
const allEntries = Object.entries(registry);
|
|
580
1039
|
const needed = new Set();
|
|
581
1040
|
|
|
1041
|
+
// Word-boundary identifier match. Avoids prefix collisions (e.g. a
|
|
1042
|
+
// `Button` rebuild marking `ButtonGroup` users as needing rebuild)
|
|
1043
|
+
// and matches identifiers, not substrings inside other words.
|
|
1044
|
+
// Regexes are cached per name so we don't pay the constructor cost
|
|
1045
|
+
// on every recursion of addWithDeps.
|
|
1046
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1047
|
+
const nameReCache = new Map();
|
|
1048
|
+
const refRe = (n) => {
|
|
1049
|
+
let r = nameReCache.get(n);
|
|
1050
|
+
if (!r) {
|
|
1051
|
+
r = new RegExp(`\\b${escapeRe(n)}\\b`);
|
|
1052
|
+
nameReCache.set(n, r);
|
|
1053
|
+
}
|
|
1054
|
+
return r;
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Depth-first walk that pulls a component and every transitively
|
|
1059
|
+
* referenced sibling into `needed`. Matching uses `\b<name>\b`
|
|
1060
|
+
* (cached) so a component named `Button` does not pull in
|
|
1061
|
+
* `ButtonGroup`.
|
|
1062
|
+
* @param {string} name
|
|
1063
|
+
* @returns {void}
|
|
1064
|
+
* @private
|
|
1065
|
+
*/
|
|
582
1066
|
function addWithDeps(name) {
|
|
583
1067
|
if (needed.has(name)) return;
|
|
584
1068
|
const entry = registry[name];
|
|
585
1069
|
if (!entry) return;
|
|
586
1070
|
needed.add(name);
|
|
587
1071
|
for (const [other] of allEntries) {
|
|
588
|
-
if (other !== name && entry.code
|
|
1072
|
+
if (other !== name && refRe(other).test(entry.code)) {
|
|
589
1073
|
addWithDeps(other);
|
|
590
1074
|
}
|
|
591
1075
|
}
|
|
592
1076
|
}
|
|
593
1077
|
|
|
594
1078
|
for (const [name] of allEntries) {
|
|
595
|
-
if (
|
|
1079
|
+
if (refRe(name).test(componentCode)) {
|
|
596
1080
|
addWithDeps(name);
|
|
597
1081
|
}
|
|
598
1082
|
}
|
|
@@ -621,6 +1105,14 @@ module.exports = function (RED) {
|
|
|
621
1105
|
return false;
|
|
622
1106
|
};
|
|
623
1107
|
const neededUtils = new Set();
|
|
1108
|
+
/**
|
|
1109
|
+
* Same walk as `addWithDeps` but over utility nodes — a utility is
|
|
1110
|
+
* included as soon as any of its top-level symbols is referenced.
|
|
1111
|
+
* Transitive: pulled-in utilities can in turn reference others.
|
|
1112
|
+
* @param {string} name
|
|
1113
|
+
* @returns {void}
|
|
1114
|
+
* @private
|
|
1115
|
+
*/
|
|
624
1116
|
function addUtilWithDeps(name) {
|
|
625
1117
|
if (neededUtils.has(name)) return;
|
|
626
1118
|
const u = utilities[name];
|
|
@@ -671,6 +1163,24 @@ module.exports = function (RED) {
|
|
|
671
1163
|
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
672
1164
|
const cleanUtilJsx = utilityJsx.replace(importRe, "").trim();
|
|
673
1165
|
|
|
1166
|
+
// ── Check: JSX references a PascalCase tag with no definition ──
|
|
1167
|
+
// Catches the common foot-gun where a portal references a shared
|
|
1168
|
+
// component (e.g. <Header/>) without the example flow that defines
|
|
1169
|
+
// it being imported. Without this check the bundler silently skips
|
|
1170
|
+
// the missing name and the browser crashes with ReferenceError.
|
|
1171
|
+
let missingComps = null;
|
|
1172
|
+
{
|
|
1173
|
+
const knownNames = new Set(Object.keys(registry));
|
|
1174
|
+
for (const [, syms] of utilSymbols) {
|
|
1175
|
+
for (const s of syms) knownNames.add(s);
|
|
1176
|
+
}
|
|
1177
|
+
// Use raw componentCode (with imports intact) so the helper can see
|
|
1178
|
+
// `import {Canvas} from '@react-three/fiber'` and not flag Canvas
|
|
1179
|
+
// as a missing fc-portal-component.
|
|
1180
|
+
const miss = findMissingComponentRefs(componentCode, knownNames);
|
|
1181
|
+
if (miss.size > 0) missingComps = [...miss].sort();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
674
1184
|
// Dedupe imports across all sources (libs may already pull React; user
|
|
675
1185
|
// and utility may import the same package).
|
|
676
1186
|
const seenImports = new Set();
|
|
@@ -794,8 +1304,23 @@ module.exports = function (RED) {
|
|
|
794
1304
|
// ── Resolve compiled (success or unified error) ──
|
|
795
1305
|
let compiled;
|
|
796
1306
|
let cacheHit = false;
|
|
797
|
-
let errorKind = null; // 'component' | 'missing-return' | 'transpile'
|
|
798
|
-
if (
|
|
1307
|
+
let errorKind = null; // 'component' | 'utility' | 'missing-component' | 'missing-return' | 'transpile'
|
|
1308
|
+
if (missingComps) {
|
|
1309
|
+
const list = missingComps.join(", ");
|
|
1310
|
+
const plural = missingComps.length > 1;
|
|
1311
|
+
const hint = plural
|
|
1312
|
+
? `Make sure these fc-portal-components exist (e.g. import the "Shared Components" example flow): ${list}.`
|
|
1313
|
+
: `Make sure a fc-portal-component named "${missingComps[0]}" exists (e.g. import the "Shared Components" example flow), then redeploy.`;
|
|
1314
|
+
compiled = {
|
|
1315
|
+
js: null,
|
|
1316
|
+
error: `Missing component${plural ? "s" : ""}: ${list}\n\n${hint}`,
|
|
1317
|
+
};
|
|
1318
|
+
errorKind = "missing-component";
|
|
1319
|
+
// Re-use errorSource for status/text — first missing name + count tail.
|
|
1320
|
+
errorSource = plural
|
|
1321
|
+
? `${missingComps[0]} +${missingComps.length - 1}`
|
|
1322
|
+
: missingComps[0];
|
|
1323
|
+
} else if (errorSource) {
|
|
799
1324
|
const srcErr =
|
|
800
1325
|
errorSourceKind === "utility"
|
|
801
1326
|
? utilities[errorSource].error
|
|
@@ -827,7 +1352,9 @@ module.exports = function (RED) {
|
|
|
827
1352
|
|
|
828
1353
|
if (compiled.error) {
|
|
829
1354
|
node.error(
|
|
830
|
-
(errorKind === "component"
|
|
1355
|
+
(errorKind === "missing-component"
|
|
1356
|
+
? "Missing component(s) in JSX: "
|
|
1357
|
+
: errorKind === "component"
|
|
831
1358
|
? `Component "${errorSource}" syntax error: `
|
|
832
1359
|
: errorKind === "utility"
|
|
833
1360
|
? `Utility "${errorSource}" syntax error: `
|
|
@@ -879,7 +1406,15 @@ module.exports = function (RED) {
|
|
|
879
1406
|
return { css, cssHash };
|
|
880
1407
|
});
|
|
881
1408
|
})().catch((err) => {
|
|
1409
|
+
// CSS generation failed (Tailwind compile error, missing
|
|
1410
|
+
// entrypoint, etc.). Surface as warn + flag pageState so
|
|
1411
|
+
// updateStatus() shows a yellow ring "css-fail" — the portal
|
|
1412
|
+
// page still loads (empty CSS), just unstyled. Cleared on
|
|
1413
|
+
// the next successful build.
|
|
882
1414
|
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
1415
|
+
const st = pageState[endpoint];
|
|
1416
|
+
if (st) st.cssError = true;
|
|
1417
|
+
updateStatus();
|
|
883
1418
|
return { css: "", cssHash: "" };
|
|
884
1419
|
})
|
|
885
1420
|
: Promise.resolve({ css: "", cssHash: "" });
|
|
@@ -930,24 +1465,36 @@ module.exports = function (RED) {
|
|
|
930
1465
|
});
|
|
931
1466
|
}
|
|
932
1467
|
|
|
933
|
-
cssReady
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
state.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1468
|
+
cssReady
|
|
1469
|
+
.then(({ css, cssHash }) => {
|
|
1470
|
+
const state = pageState[endpoint];
|
|
1471
|
+
if (state && state.jsxHash === jsxHash) {
|
|
1472
|
+
state.css = css;
|
|
1473
|
+
state.cssHash = cssHash;
|
|
1474
|
+
// Clear css-fail flag on a successful generation. Only the
|
|
1475
|
+
// outer .catch above sets it, so we don't risk wiping a
|
|
1476
|
+
// freshly-set error mid-flight.
|
|
1477
|
+
if (cssHash) state.cssError = false;
|
|
1478
|
+
// Snapshot current good build so future failed builds can fall back.
|
|
1479
|
+
if (!state.compiled.error && state.compiled.js) {
|
|
1480
|
+
state.lastGood = {
|
|
1481
|
+
compiledJs: state.compiled.js,
|
|
1482
|
+
contentHash: state.contentHash,
|
|
1483
|
+
cssHash,
|
|
1484
|
+
pageTitle: state.pageTitle,
|
|
1485
|
+
customHead: state.customHead,
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
updateStatus();
|
|
947
1489
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1490
|
+
})
|
|
1491
|
+
.catch((e) => {
|
|
1492
|
+
// Tail-handler — generateCSS already has its own .catch upstream
|
|
1493
|
+
// that yields { css: "", cssHash: "" }. This guards against
|
|
1494
|
+
// exceptions thrown inside the state-update block above so the
|
|
1495
|
+
// process doesn't see an UnhandledPromiseRejection.
|
|
1496
|
+
RED.log.warn("[portal-react] cssReady tail: " + e.message);
|
|
1497
|
+
});
|
|
951
1498
|
} catch (e) {
|
|
952
1499
|
node.error("Rebuild failed: " + e.message);
|
|
953
1500
|
// Surface as a regular build error so the lastGood/degraded path,
|
|
@@ -1018,7 +1565,11 @@ module.exports = function (RED) {
|
|
|
1018
1565
|
RED.httpNode.get(endpoint, async function (_req, res) {
|
|
1019
1566
|
try {
|
|
1020
1567
|
const state = pageState[endpoint];
|
|
1021
|
-
|
|
1568
|
+
// `!state.compiled` is the teardown window between node close
|
|
1569
|
+
// (which nulls compiled) and the next rebuild. Treat it like
|
|
1570
|
+
// "building" — serve the holding page (200), never fall through to
|
|
1571
|
+
// `state.compiled.error` which would throw and 500 via the catch.
|
|
1572
|
+
if (!state || state.building || !state.compiled) {
|
|
1022
1573
|
const bWsPath = state?.wsPath || wsPath;
|
|
1023
1574
|
res
|
|
1024
1575
|
.set("Cache-Control", "no-store")
|
|
@@ -1109,7 +1660,11 @@ module.exports = function (RED) {
|
|
|
1109
1660
|
|
|
1110
1661
|
try {
|
|
1111
1662
|
const WebSocket = require("ws");
|
|
1112
|
-
|
|
1663
|
+
// 1 MB hard cap on incoming WS frames — far above typical msg.output
|
|
1664
|
+
// sizes (a few KB of JSON) and well below the 100 MB default. Blocks
|
|
1665
|
+
// a hostile client from spamming oversized frames.
|
|
1666
|
+
wsServer = new WebSocket.Server({ noServer: true, maxPayload: 1024 * 1024 });
|
|
1667
|
+
registerPingedServer(wsServer);
|
|
1113
1668
|
|
|
1114
1669
|
// Remove previous upgrade handler for this node (dirty deploy)
|
|
1115
1670
|
if (upgradeHandlers[nodeId]) {
|
|
@@ -1171,10 +1726,11 @@ module.exports = function (RED) {
|
|
|
1171
1726
|
// In degraded mode (current build failed but lastGood served), advertise
|
|
1172
1727
|
// the lastGood hash so the freshly reloaded client matches the JS we sent.
|
|
1173
1728
|
const cs = pageState[endpoint];
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1729
|
+
// Only advertise a non-empty hash when there is a page the GET route
|
|
1730
|
+
// can actually serve (real build, or degraded lastGood). Advertising
|
|
1731
|
+
// a stale hash for a nulled/error state drives the served error page
|
|
1732
|
+
// into a reload loop. See helpers.serveableHash.
|
|
1733
|
+
const contentHash = serveableHash(cs);
|
|
1178
1734
|
wsSend(ws, { type: "version", hash: contentHash });
|
|
1179
1735
|
|
|
1180
1736
|
// Send assigned portalClient to browser
|
|
@@ -1198,17 +1754,11 @@ module.exports = function (RED) {
|
|
|
1198
1754
|
}
|
|
1199
1755
|
|
|
1200
1756
|
// Heartbeat — detect dead sockets via WS ping/pong. Browser
|
|
1201
|
-
// auto-replies to ping frames, no client JS needed.
|
|
1757
|
+
// auto-replies to ping frames, no client JS needed. The actual
|
|
1758
|
+
// ping interval lives in the module-level `_pingSweep` tick;
|
|
1759
|
+
// each client only needs the alive flag and pong listener here.
|
|
1202
1760
|
ws._isAlive = true;
|
|
1203
1761
|
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
1762
|
|
|
1213
1763
|
ws.on("message", (raw) => {
|
|
1214
1764
|
try {
|
|
@@ -1238,6 +1788,9 @@ module.exports = function (RED) {
|
|
|
1238
1788
|
// inbound frame.
|
|
1239
1789
|
const client = { portalClient: ws._portalClient };
|
|
1240
1790
|
if (portalAuth && ws._portalUser) {
|
|
1791
|
+
// Source is extractPortalUser() — whitelist of named
|
|
1792
|
+
// header reads. No untrusted key can land here, so this
|
|
1793
|
+
// Object.assign cannot be turned into prototype pollution.
|
|
1241
1794
|
Object.assign(client, ws._portalUser);
|
|
1242
1795
|
}
|
|
1243
1796
|
out._client = client;
|
|
@@ -1253,7 +1806,8 @@ module.exports = function (RED) {
|
|
|
1253
1806
|
});
|
|
1254
1807
|
|
|
1255
1808
|
const detach = () => {
|
|
1256
|
-
|
|
1809
|
+
// No per-client interval to clear — heartbeat is centralised in
|
|
1810
|
+
// the shared `_pingSweep` tick (see registerPingedServer).
|
|
1257
1811
|
clients.delete(portalClient);
|
|
1258
1812
|
if (userId) {
|
|
1259
1813
|
const set = userIndex.get(userId);
|
|
@@ -1277,6 +1831,15 @@ module.exports = function (RED) {
|
|
|
1277
1831
|
// sendTo: single point where every outbound frame passes through
|
|
1278
1832
|
// the onCanSendTo hook. Strict-by-default — no opt-in per widget
|
|
1279
1833
|
// type like dashboard's acceptsClientConfig.
|
|
1834
|
+
/**
|
|
1835
|
+
* Send a pre-serialised frame to one WS client, gated by the
|
|
1836
|
+
* `onCanSendTo` plugin hook. Returns true on successful send.
|
|
1837
|
+
* @param {import("ws").WebSocket} ws
|
|
1838
|
+
* @param {string} frame JSON-encoded payload.
|
|
1839
|
+
* @param {MessagePayload} msg Inspected by plugin hooks.
|
|
1840
|
+
* @returns {boolean}
|
|
1841
|
+
* @private
|
|
1842
|
+
*/
|
|
1280
1843
|
function sendTo(ws, frame, msg) {
|
|
1281
1844
|
if (!ws || ws.readyState !== 1) return false;
|
|
1282
1845
|
if (!hooks.allow("onCanSendTo", ws, msg)) return false;
|
|
@@ -1290,94 +1853,161 @@ module.exports = function (RED) {
|
|
|
1290
1853
|
}
|
|
1291
1854
|
|
|
1292
1855
|
node.on("input", (msg, send, done) => {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1856
|
+
// Target Node-RED ≥4.0: `done` is always present. No defensive guard.
|
|
1857
|
+
try {
|
|
1858
|
+
const result = router.route(msg, { clients, userIndex, sendTo });
|
|
1859
|
+
// Cache the latest broadcast payload so freshly-connected clients
|
|
1860
|
+
// can recover it via the `recovery` frame on connect. Deep-clone via
|
|
1861
|
+
// RED.util.cloneMessage so a downstream mutation cannot retroactively
|
|
1862
|
+
// change what a fresh client sees on connect.
|
|
1863
|
+
if (result.mode === "broadcast") {
|
|
1864
|
+
let cached;
|
|
1865
|
+
try {
|
|
1866
|
+
cached = RED.util.cloneMessage({ p: msg.payload }).p;
|
|
1867
|
+
} catch (_) {
|
|
1868
|
+
cached = msg.payload;
|
|
1869
|
+
}
|
|
1870
|
+
lastBroadcastCache.set(endpoint, cached);
|
|
1871
|
+
}
|
|
1872
|
+
updateStatus();
|
|
1873
|
+
done();
|
|
1874
|
+
} catch (err) {
|
|
1875
|
+
// Catch-node propagation: done(err) lets the runtime route the
|
|
1876
|
+
// error to a Catch node on the same tab (Node-RED docs: "this will
|
|
1877
|
+
// trigger any Catch nodes present on the same tab").
|
|
1878
|
+
done(err);
|
|
1298
1879
|
}
|
|
1299
|
-
updateStatus();
|
|
1300
|
-
if (done) done();
|
|
1301
1880
|
});
|
|
1302
1881
|
|
|
1303
1882
|
// ── Cleanup on redeploy / shutdown ────────────────────────
|
|
1883
|
+
//
|
|
1884
|
+
// Teardown order (Node-RED gives us a 15 s budget before forcibly
|
|
1885
|
+
// killing the close handler):
|
|
1886
|
+
// 1. mark isClosing = true (refuse new WS upgrades)
|
|
1887
|
+
// 2. close all WS clients with 1001
|
|
1888
|
+
// 3. remove the upgrade listener from RED.server
|
|
1889
|
+
// 4. close the ws.Server
|
|
1890
|
+
// 5. clear timers / interval handles
|
|
1891
|
+
// 6. drop route & shared state (only when fully removed)
|
|
1892
|
+
// 7. done()
|
|
1893
|
+
//
|
|
1894
|
+
// `removed` is true when the node is deleted *or* disabled in the
|
|
1895
|
+
// editor (Node-RED docs). For both we drop persistent route + cache;
|
|
1896
|
+
// for redeploy (removed=false) we keep pageState[endpoint] so
|
|
1897
|
+
// reconnecting clients hit the same build with a smaller delay.
|
|
1304
1898
|
|
|
1305
1899
|
node.on("close", (removed, done) => {
|
|
1306
|
-
|
|
1900
|
+
let doneCalled = false;
|
|
1901
|
+
const callDone = (err) => {
|
|
1902
|
+
if (doneCalled) return;
|
|
1903
|
+
doneCalled = true;
|
|
1904
|
+
done(err);
|
|
1905
|
+
};
|
|
1906
|
+
// Safety net — runtime force-kills at 15 s. Resolve at 14 s if
|
|
1907
|
+
// teardown is somehow blocked so we don't get a hard timeout log.
|
|
1908
|
+
const safety = setTimeout(() => callDone(), 14_000);
|
|
1909
|
+
safety.unref?.();
|
|
1307
1910
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
|
|
1311
|
-
delete upgradeHandlers[nodeId];
|
|
1312
|
-
}
|
|
1911
|
+
try {
|
|
1912
|
+
isClosing = true;
|
|
1313
1913
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1914
|
+
// Close all WS clients. Heartbeat lives in the shared module-level
|
|
1915
|
+
// sweep tick — no per-client cleanup needed here. ws.close() is
|
|
1916
|
+
// non-blocking; we don't await drain.
|
|
1917
|
+
clients.forEach((ws) => {
|
|
1918
|
+
try {
|
|
1919
|
+
ws.close(1001, "node redeployed");
|
|
1920
|
+
} catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
|
|
1921
|
+
});
|
|
1922
|
+
clients.clear();
|
|
1323
1923
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
}
|
|
1924
|
+
// Remove upgrade handler before tearing down the WS server so a
|
|
1925
|
+
// late upgrade request doesn't race into a half-closed wsServer.
|
|
1926
|
+
if (upgradeHandlers[nodeId]) {
|
|
1927
|
+
RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
|
|
1928
|
+
delete upgradeHandlers[nodeId];
|
|
1929
|
+
}
|
|
1331
1930
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1931
|
+
// Close WS server — also drop it from the shared heartbeat tick.
|
|
1932
|
+
// When the last portal node tears down its server, the shared
|
|
1933
|
+
// interval auto-clears (see unregisterPingedServer).
|
|
1934
|
+
if (wsServer) {
|
|
1935
|
+
unregisterPingedServer(wsServer);
|
|
1936
|
+
try {
|
|
1937
|
+
wsServer.close();
|
|
1938
|
+
} catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
|
|
1939
|
+
wsServer = null;
|
|
1940
|
+
}
|
|
1337
1941
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
delete
|
|
1341
|
-
|
|
1942
|
+
// Unregister rebuild callback + selective-rebuild metadata
|
|
1943
|
+
delete rebuildCallbacks[nodeId];
|
|
1944
|
+
delete portalNeeded[nodeId];
|
|
1945
|
+
delete portalNeededUtilities[nodeId];
|
|
1946
|
+
delete portalCode[nodeId];
|
|
1342
1947
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
delete portalSig[nodeId];
|
|
1348
|
-
}
|
|
1948
|
+
// Release endpoint ownership
|
|
1949
|
+
if (endpointOwners[endpoint] === nodeId) {
|
|
1950
|
+
delete endpointOwners[endpoint];
|
|
1951
|
+
}
|
|
1349
1952
|
|
|
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
|
-
}
|
|
1953
|
+
// Drop the recovery cache on full removal/disable; on a plain
|
|
1954
|
+
// redeploy keep it so reconnecting clients still recover.
|
|
1955
|
+
if (removed) {
|
|
1956
|
+
lastBroadcastCache.delete(endpoint);
|
|
1957
|
+
delete portalSig[nodeId];
|
|
1958
|
+
}
|
|
1364
1959
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1960
|
+
// Clear the userIndex — WS clients are already closed above, but
|
|
1961
|
+
// the Map itself should not outlive the node instance.
|
|
1962
|
+
userIndex.clear();
|
|
1963
|
+
|
|
1964
|
+
// Break references to large objects / Promises in pageState even on
|
|
1965
|
+
// redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
|
|
1966
|
+
// between close and the new build these would retain closures over
|
|
1967
|
+
// the old clients/userIndex/rebuild scope.
|
|
1968
|
+
const st = pageState[endpoint];
|
|
1969
|
+
if (st) {
|
|
1970
|
+
st.cssReady = null;
|
|
1971
|
+
st.compiled = null;
|
|
1972
|
+
st.css = null;
|
|
1973
|
+
// Clear the advertised hash too. Leaving a stale non-empty
|
|
1974
|
+
// contentHash while compiled is null makes the WS `version` frame
|
|
1975
|
+
// advertise a "ready" page that the GET route can no longer serve,
|
|
1976
|
+
// which drives the served error/building page into a reload loop.
|
|
1977
|
+
st.contentHash = "";
|
|
1370
1978
|
}
|
|
1371
|
-
delete pageState[endpoint];
|
|
1372
|
-
removeRoute(RED.httpNode._router, endpoint);
|
|
1373
|
-
delete registeredRoutes[endpoint];
|
|
1374
|
-
}
|
|
1375
1979
|
|
|
1376
|
-
|
|
1980
|
+
// Clean up route only on full removal/disable (not on redeploy).
|
|
1981
|
+
if (removed) {
|
|
1982
|
+
// Delete disk cache if no other endpoint uses this hash
|
|
1983
|
+
if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
|
|
1984
|
+
deleteCacheFiles(lastJsxHash);
|
|
1985
|
+
}
|
|
1986
|
+
delete pageState[endpoint];
|
|
1987
|
+
removeRoute(RED.httpNode._router, endpoint);
|
|
1988
|
+
delete registeredRoutes[endpoint];
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
clearTimeout(safety);
|
|
1992
|
+
callDone();
|
|
1993
|
+
} catch (err) {
|
|
1994
|
+
clearTimeout(safety);
|
|
1995
|
+
callDone(err);
|
|
1996
|
+
}
|
|
1377
1997
|
});
|
|
1378
1998
|
|
|
1379
1999
|
// ── Utilities ─────────────────────────────────────────────
|
|
1380
2000
|
|
|
2001
|
+
/**
|
|
2002
|
+
* Best-effort `JSON.stringify` + `ws.send`. Swallows write errors at
|
|
2003
|
+
* trace level — used for status/control frames where dropping a
|
|
2004
|
+
* single packet has no semantic impact (the next deploy or heartbeat
|
|
2005
|
+
* will reconcile state).
|
|
2006
|
+
* @param {import("ws").WebSocket} ws
|
|
2007
|
+
* @param {Object} obj
|
|
2008
|
+
* @returns {void}
|
|
2009
|
+
* @private
|
|
2010
|
+
*/
|
|
1381
2011
|
function wsSend(ws, obj) {
|
|
1382
2012
|
try {
|
|
1383
2013
|
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
@@ -1398,13 +2028,14 @@ module.exports = function (RED) {
|
|
|
1398
2028
|
);
|
|
1399
2029
|
RED.httpAdmin.use(
|
|
1400
2030
|
"/portal-react/vs",
|
|
2031
|
+
PERM_READ,
|
|
1401
2032
|
express.static(path.join(monacoPath, "min", "vs")),
|
|
1402
2033
|
);
|
|
1403
2034
|
|
|
1404
2035
|
// ── Tailwind class list endpoint ────────────────────────────
|
|
1405
2036
|
const { generateCandidates } = require("./tw-candidates");
|
|
1406
2037
|
let twClassesCache = null;
|
|
1407
|
-
RED.httpAdmin.get("/portal-react/tw-classes", (_req, res) => {
|
|
2038
|
+
RED.httpAdmin.get("/portal-react/tw-classes", PERM_READ, (_req, res) => {
|
|
1408
2039
|
if (!twClassesCache) {
|
|
1409
2040
|
twClassesCache = generateCandidates();
|
|
1410
2041
|
}
|
|
@@ -1412,8 +2043,41 @@ module.exports = function (RED) {
|
|
|
1412
2043
|
});
|
|
1413
2044
|
|
|
1414
2045
|
// ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
|
|
2046
|
+
// Public (httpNode) — served to browsers loading the portal page, not the
|
|
2047
|
+
// editor. Hash is constrained to short hex so a hostile client cannot probe
|
|
2048
|
+
// for arbitrary pageState keys.
|
|
2049
|
+
const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
|
|
2050
|
+
RED.httpNode.get("/fromcubes/css/:hash.css", (req, res) => {
|
|
2051
|
+
const reqHash = req.params.hash;
|
|
2052
|
+
if (!CSS_HASH_RE.test(reqHash)) {
|
|
2053
|
+
res.status(400).send("Bad request");
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
let css = null;
|
|
2057
|
+
for (const ep in pageState) {
|
|
2058
|
+
if (pageState[ep]?.cssHash === reqHash) {
|
|
2059
|
+
css = pageState[ep].css;
|
|
2060
|
+
break;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
if (!css) {
|
|
2064
|
+
res.status(404).send("Not found");
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
res.set({
|
|
2068
|
+
"Content-Type": "text/css",
|
|
2069
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
2070
|
+
});
|
|
2071
|
+
res.send(css);
|
|
2072
|
+
});
|
|
2073
|
+
// Back-compat: legacy admin URL still works (page-builder may emit it for
|
|
2074
|
+
// existing builds). Apply the same hash whitelist.
|
|
1415
2075
|
RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
|
|
1416
2076
|
const reqHash = req.params.hash;
|
|
2077
|
+
if (!CSS_HASH_RE.test(reqHash)) {
|
|
2078
|
+
res.status(400).send("Bad request");
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
1417
2081
|
let css = null;
|
|
1418
2082
|
for (const ep in pageState) {
|
|
1419
2083
|
if (pageState[ep]?.cssHash === reqHash) {
|
|
@@ -1434,15 +2098,19 @@ module.exports = function (RED) {
|
|
|
1434
2098
|
|
|
1435
2099
|
// ── Public assets folder ─────────────────────────────────────
|
|
1436
2100
|
const { registerAssets } = require("./lib/assets");
|
|
1437
|
-
registerAssets(RED, express, path.join(userDir, "fromcubes", "public")
|
|
2101
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"), {
|
|
2102
|
+
csrfGuard,
|
|
2103
|
+
rateLimit,
|
|
2104
|
+
jsonLimit: JSON_BODY_LIMIT,
|
|
2105
|
+
});
|
|
1438
2106
|
|
|
1439
2107
|
// ── Admin API for component registry ──────────────────────────
|
|
1440
2108
|
|
|
1441
|
-
RED.httpAdmin.get("/portal-react/registry", (_req, res) => {
|
|
2109
|
+
RED.httpAdmin.get("/portal-react/registry", PERM_READ, (_req, res) => {
|
|
1442
2110
|
res.json(registry);
|
|
1443
2111
|
});
|
|
1444
2112
|
|
|
1445
|
-
RED.httpAdmin.post("/portal-react/registry", (req, res) => {
|
|
2113
|
+
RED.httpAdmin.post("/portal-react/registry", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
|
|
1446
2114
|
const { name, code } = req.body || {};
|
|
1447
2115
|
if (!isSafeName(name))
|
|
1448
2116
|
return res.status(400).json({ error: "invalid name" });
|
|
@@ -1455,7 +2123,7 @@ module.exports = function (RED) {
|
|
|
1455
2123
|
res.json({ ok: true });
|
|
1456
2124
|
});
|
|
1457
2125
|
|
|
1458
|
-
RED.httpAdmin.delete("/portal-react/registry/:name", (req, res) => {
|
|
2126
|
+
RED.httpAdmin.delete("/portal-react/registry/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
|
|
1459
2127
|
const name = req.params.name;
|
|
1460
2128
|
if (!isSafeName(name))
|
|
1461
2129
|
return res.status(400).json({ error: "invalid name" });
|
|
@@ -1469,7 +2137,7 @@ module.exports = function (RED) {
|
|
|
1469
2137
|
|
|
1470
2138
|
// ── Admin API for utility registry ────────────────────────────
|
|
1471
2139
|
|
|
1472
|
-
RED.httpAdmin.get("/portal-react/utilities", (_req, res) => {
|
|
2140
|
+
RED.httpAdmin.get("/portal-react/utilities", PERM_READ, (_req, res) => {
|
|
1473
2141
|
// Include parsed top-level symbols so the editor "Utilities" dialog can
|
|
1474
2142
|
// list which identifiers each node exports.
|
|
1475
2143
|
const out = {};
|
|
@@ -1483,7 +2151,7 @@ module.exports = function (RED) {
|
|
|
1483
2151
|
res.json(out);
|
|
1484
2152
|
});
|
|
1485
2153
|
|
|
1486
|
-
RED.httpAdmin.post("/portal-react/utilities", (req, res) => {
|
|
2154
|
+
RED.httpAdmin.post("/portal-react/utilities", PERM_WRITE, csrfGuard, rateLimit, express.json({ limit: JSON_BODY_LIMIT }), (req, res) => {
|
|
1487
2155
|
const { name, code } = req.body || {};
|
|
1488
2156
|
if (!isSafeName(name))
|
|
1489
2157
|
return res.status(400).json({ error: "invalid name" });
|
|
@@ -1530,7 +2198,7 @@ module.exports = function (RED) {
|
|
|
1530
2198
|
res.json({ ok: true, error: combinedErr || null });
|
|
1531
2199
|
});
|
|
1532
2200
|
|
|
1533
|
-
RED.httpAdmin.delete("/portal-react/utilities/:name", (req, res) => {
|
|
2201
|
+
RED.httpAdmin.delete("/portal-react/utilities/:name", PERM_WRITE, csrfGuard, rateLimit, (req, res) => {
|
|
1534
2202
|
const name = req.params.name;
|
|
1535
2203
|
if (!isSafeName(name))
|
|
1536
2204
|
return res.status(400).json({ error: "invalid name" });
|