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