@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.
@@ -0,0 +1,724 @@
1
+ /** @module nodes/lib/helpers */
2
+
3
+ /**
4
+ * Shared helper functions for portal-react. Pure helpers (hash, validators,
5
+ * esbuild wrappers) are exported at module level; runtime helpers that need
6
+ * `RED` (cache, tailwind, preInstall hook) are produced by the default export
7
+ * factory.
8
+ */
9
+
10
+ /**
11
+ * @typedef {Object} TranspileResult
12
+ * @property {string|null} js Compiled IIFE bundle, or null on error.
13
+ * @property {string|null} [error] Multi-line error description from esbuild.
14
+ * @property {Object} [metafile] Optional esbuild metafile (size analysis).
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} SubPathResult
19
+ * @property {boolean} ok
20
+ * @property {string} [value]
21
+ * @property {string} [error]
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} PortalUser
26
+ * @property {string} [userId]
27
+ * @property {string} [userName]
28
+ * @property {string} [username]
29
+ * @property {string} [email]
30
+ * @property {string} [role]
31
+ * @property {string|Array<string>}[groups]
32
+ */
33
+
34
+ const crypto = require("crypto");
35
+ const fs = require("fs");
36
+ const path = require("path");
37
+ const esbuild = require("esbuild");
38
+
39
+ // Cap on JSON-encoded `x-portal-user-groups` header — protects JSON.parse from
40
+ // being fed an unbounded payload by a misbehaving auth proxy / forged header.
41
+ // Same order of magnitude as Express default header size; trimming here so a
42
+ // 1MB string can't reach JSON.parse.
43
+ const MAX_GROUPS_HEADER_BYTES = 8 * 1024;
44
+
45
+ /**
46
+ * Short content hash used for cache keys (16 hex chars of sha256).
47
+ * @param {string} str
48
+ * @returns {string}
49
+ */
50
+ function hash(str) {
51
+ return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
52
+ }
53
+
54
+ const twCompile = require("tailwindcss").compile;
55
+ const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
56
+
57
+ let twCompiled = null;
58
+ /**
59
+ * Lazily compile the Tailwind base stylesheet. Result is memoized at module
60
+ * scope — first call resolves async stylesheet imports, every subsequent
61
+ * call returns the cached compiler.
62
+ *
63
+ * @returns {Promise<Object>} Tailwind `compile()` result with a `.build()` method.
64
+ */
65
+ async function getTwCompiled() {
66
+ if (twCompiled) return twCompiled;
67
+ twCompiled = await twCompile(`@import 'tailwindcss';`, {
68
+ loadStylesheet: async (id, base) => {
69
+ let resolved;
70
+ if (id === "tailwindcss") {
71
+ resolved = require.resolve("tailwindcss/index.css");
72
+ } else {
73
+ resolved = require.resolve(id, { paths: [base || __dirname] });
74
+ }
75
+ return {
76
+ content: fs.readFileSync(resolved, "utf8"),
77
+ base: path.dirname(resolved),
78
+ };
79
+ },
80
+ });
81
+ return twCompiled;
82
+ }
83
+
84
+ /**
85
+ * Generate the per-page Tailwind CSS bundle by scanning `source` for utility
86
+ * class candidates and feeding them to the compiled Tailwind core.
87
+ *
88
+ * @param {string} source Source text to scan for class candidates.
89
+ * @returns {Promise<{css: string, cssHash: string}>}
90
+ */
91
+ async function generateCSS(source) {
92
+ const cssHash = hash(source);
93
+ const compiled = await getTwCompiled();
94
+ const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
95
+ const css = compiled.build(candidates);
96
+ return { css, cssHash };
97
+ }
98
+
99
+ // Component / utility names become JavaScript identifiers in the generated
100
+ // bundle (`const Name = (() => ...)();` for components; raw top-level decls
101
+ // for utilities). Enforce strict identifier syntax up-front so the diagnostic
102
+ // surfaces on the offending node — not as a confusing esbuild parse error on
103
+ // some unrelated portal that happens to import it.
104
+ const NAME_RE = /^[A-Za-z_$][\w$]*$/;
105
+ const NAME_MAX_LEN = 64;
106
+ const NAME_BLACKLIST = new Set([
107
+ "__proto__",
108
+ "constructor",
109
+ "prototype",
110
+ "hasOwnProperty",
111
+ "isPrototypeOf",
112
+ "propertyIsEnumerable",
113
+ "toString",
114
+ "valueOf",
115
+ "toLocaleString",
116
+ ]);
117
+
118
+ /**
119
+ * True when `name` is a syntactically valid JavaScript identifier of bounded
120
+ * length that does not collide with Object prototype keys.
121
+ *
122
+ * Rules:
123
+ * - non-empty string
124
+ * - length ≤ 64
125
+ * - matches /^[A-Za-z_$][\w$]*$/
126
+ * - not in NAME_BLACKLIST (prototype pollution guard)
127
+ *
128
+ * @param {unknown} name
129
+ * @returns {boolean}
130
+ */
131
+ function isSafeName(name) {
132
+ return (
133
+ typeof name === "string" &&
134
+ name.length > 0 &&
135
+ name.length <= NAME_MAX_LEN &&
136
+ NAME_RE.test(name) &&
137
+ !NAME_BLACKLIST.has(name)
138
+ );
139
+ }
140
+
141
+ const SUB_PATH_SEGMENT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
142
+ const SUB_PATH_RESERVED = new Set(["public", "_ws"]);
143
+
144
+ /**
145
+ * Validate a portal sub-path (the part served under `/fromcubes/<sub-path>`).
146
+ *
147
+ * Rules: non-empty, no leading/trailing slash, no whitespace, segments match
148
+ * `[a-zA-Z0-9][a-zA-Z0-9._-]*`, reserved segments `public` and `_ws` are
149
+ * blocked case-insensitively. Multiple slash-separated segments are allowed.
150
+ *
151
+ * @param {unknown} input
152
+ * @returns {SubPathResult}
153
+ */
154
+ function validateSubPath(input) {
155
+ if (typeof input !== "string") {
156
+ return { ok: false, error: "Sub-path is required" };
157
+ }
158
+ const trimmed = input.trim();
159
+ if (trimmed.length === 0) {
160
+ return { ok: false, error: "Sub-path is required" };
161
+ }
162
+ if (/\s/.test(trimmed)) {
163
+ return { ok: false, error: "Sub-path must not contain whitespace" };
164
+ }
165
+ if (trimmed.startsWith("/")) {
166
+ return { ok: false, error: "Sub-path must not start with /" };
167
+ }
168
+ if (trimmed.endsWith("/")) {
169
+ return { ok: false, error: "Sub-path must not end with /" };
170
+ }
171
+ const segments = trimmed.split("/");
172
+ for (const seg of segments) {
173
+ if (seg.length === 0) {
174
+ return { ok: false, error: "Sub-path must not contain empty segments" };
175
+ }
176
+ if (seg === "." || seg === "..") {
177
+ return { ok: false, error: "Path traversal not allowed in sub-path" };
178
+ }
179
+ if (SUB_PATH_RESERVED.has(seg.toLowerCase())) {
180
+ return {
181
+ ok: false,
182
+ error: `Sub-path segment "${seg}" is reserved`,
183
+ };
184
+ }
185
+ if (!SUB_PATH_SEGMENT_RE.test(seg)) {
186
+ return {
187
+ ok: false,
188
+ error: `Sub-path segment "${seg}" contains invalid characters`,
189
+ };
190
+ }
191
+ }
192
+ return { ok: true, value: trimmed };
193
+ }
194
+
195
+ /**
196
+ * Build a PortalUser object from `x-portal-user-*` headers set by an upstream
197
+ * auth proxy. Returns null when no header is present.
198
+ *
199
+ * @param {Object<string,string>} headers HTTP request headers (lower-cased).
200
+ * @returns {PortalUser|null}
201
+ */
202
+ function extractPortalUser(headers) {
203
+ const user = {};
204
+ if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
205
+ if (headers["x-portal-user-name"])
206
+ user.userName = headers["x-portal-user-name"];
207
+ if (headers["x-portal-user-username"])
208
+ user.username = headers["x-portal-user-username"];
209
+ if (headers["x-portal-user-email"])
210
+ user.email = headers["x-portal-user-email"];
211
+ if (headers["x-portal-user-role"]) user.role = headers["x-portal-user-role"];
212
+ const groupsRaw = headers["x-portal-user-groups"];
213
+ if (typeof groupsRaw === "string" && groupsRaw.length > 0) {
214
+ if (groupsRaw.length > MAX_GROUPS_HEADER_BYTES) {
215
+ // Truncated → fall back to the raw string (safer than feeding multi-MB
216
+ // into JSON.parse). Auth proxy should never produce headers this large.
217
+ user.groups = groupsRaw.slice(0, MAX_GROUPS_HEADER_BYTES);
218
+ } else {
219
+ try {
220
+ user.groups = JSON.parse(groupsRaw);
221
+ } catch (_) {
222
+ user.groups = groupsRaw;
223
+ }
224
+ }
225
+ }
226
+ return Object.keys(user).length > 0 ? user : null;
227
+ }
228
+
229
+ /**
230
+ * Remove a single route mount-point from an Express router by exact path
231
+ * match. Used at deploy teardown to drop the previous portal's HTTP route
232
+ * before re-registering on the same path.
233
+ *
234
+ * @param {Object} router Express router (or Express app).
235
+ * @param {string} path Exact route path to remove (no glob/regex).
236
+ * @returns {void}
237
+ */
238
+ function removeRoute(router, path) {
239
+ if (!router || !router.stack) return;
240
+ router.stack = router.stack.filter(
241
+ (layer) => !(layer.route && layer.route.path === path),
242
+ );
243
+ }
244
+
245
+ /**
246
+ * @typedef {Object} EsbuildErrorLocation
247
+ * @property {number} line
248
+ */
249
+
250
+ /**
251
+ * @typedef {Object} EsbuildErrorEntry
252
+ * @property {string} text
253
+ * @property {EsbuildErrorLocation} [location]
254
+ */
255
+
256
+ /**
257
+ * @typedef {Object} EsbuildErrorLike
258
+ * @property {Array<EsbuildErrorEntry>} [errors]
259
+ * @property {string} [message]
260
+ */
261
+
262
+ /**
263
+ * Flatten an esbuild error object into a single multi-line string suitable
264
+ * for `node.error()` and the in-page error overlay. Includes line numbers
265
+ * when esbuild provides them; falls back to `.message` otherwise.
266
+ *
267
+ * @param {EsbuildErrorLike} e
268
+ * @returns {string}
269
+ */
270
+ function formatEsbuildError(e) {
271
+ return e.errors?.length
272
+ ? e.errors
273
+ .map(
274
+ (err) =>
275
+ `${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
276
+ )
277
+ .join("\n")
278
+ : e.message;
279
+ }
280
+
281
+ /**
282
+ * Fast JSX syntax validation via esbuild `transformSync` (no bundling, no
283
+ * filesystem lookup). Used at deploy time on individual component/utility
284
+ * snippets so the editor can attribute syntax errors to the offending node
285
+ * before any bundling pass runs on the portal.
286
+ *
287
+ * @param {string} jsx
288
+ * @returns {string|null} Multi-line error string, or null when JSX is syntactically valid.
289
+ */
290
+ function quickCheckSyntax(jsx) {
291
+ if (!jsx || !jsx.trim()) return null;
292
+ try {
293
+ esbuild.transformSync(jsx, {
294
+ loader: "jsx",
295
+ jsx: "transform",
296
+ jsxFactory: "React.createElement",
297
+ jsxFragment: "React.Fragment",
298
+ logLevel: "silent",
299
+ });
300
+ return null;
301
+ } catch (e) {
302
+ return formatEsbuildError(e);
303
+ }
304
+ }
305
+
306
+ // Identifiers that resolve at runtime through the bundler shim (React et al.)
307
+ // rather than through registry / utility / local-def lookup. Anything in this
308
+ // set is treated as "always satisfied" by findMissingComponentRefs.
309
+ const REACT_BUILTIN_TAGS = new Set([
310
+ "React",
311
+ "Fragment",
312
+ "Suspense",
313
+ "StrictMode",
314
+ "Profiler",
315
+ "ReactDOM",
316
+ "App",
317
+ ]);
318
+
319
+ const PASCAL_TAG_RE = /<\s*([A-Z][A-Za-z0-9_]*)/g;
320
+ const RE_ESCAPE = /[.*+?^${}()|[\]\\]/g;
321
+
322
+ // Cached identifier-boundary regexes shared by every "does this code
323
+ // reference identifier X" scan (component deps, utility selection, dirty-
324
+ // portal matching, topological sort). `\b` breaks for names that start or
325
+ // end with `$` — a legal identifier char that is not a regex word char — so
326
+ // lookarounds on the [\w$] class are used instead. Cache is bounded: cleared
327
+ // wholesale past 5000 entries (long-running process with many renames).
328
+ const IDENT_RE_CACHE = new Map();
329
+
330
+ /**
331
+ * Return a cached RegExp matching `name` as a standalone JS identifier
332
+ * (not as a prefix/suffix/substring of a longer identifier).
333
+ *
334
+ * @param {string} name Identifier to match (component/utility/symbol name).
335
+ * @returns {RegExp}
336
+ */
337
+ function identifierRe(name) {
338
+ let re = IDENT_RE_CACHE.get(name);
339
+ if (!re) {
340
+ if (IDENT_RE_CACHE.size > 5000) IDENT_RE_CACHE.clear();
341
+ const escaped = name.replace(RE_ESCAPE, "\\$&");
342
+ re = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
343
+ IDENT_RE_CACHE.set(name, re);
344
+ }
345
+ return re;
346
+ }
347
+
348
+ /**
349
+ * Find PascalCase JSX tags in `userCode` that have no visible definition.
350
+ *
351
+ * Used at deploy time to catch the case where a portal references a shared
352
+ * component (e.g. `<Header/>`) without the example flow that defines it
353
+ * being imported into Node-RED. Without this check the bundler silently
354
+ * skips the missing name and the browser crashes with `ReferenceError`.
355
+ *
356
+ * A tag is considered satisfied when ANY of:
357
+ * - its name is in `knownNames` (component registry / utility symbols)
358
+ * - its name is a React built-in (`React`, `Fragment`, `Suspense`, …)
359
+ * - the bare identifier appears outside JSX-tag context in `userCode`
360
+ * (covers `import {Name} from …`, `function Name(){…}`, `const Name = …`,
361
+ * `class Name extends …`)
362
+ *
363
+ * @param {string} userCode
364
+ * @param {Set<string>} knownNames
365
+ * @returns {Set<string>} PascalCase names referenced in JSX with nothing
366
+ * to satisfy them.
367
+ */
368
+ function findMissingComponentRefs(userCode, knownNames) {
369
+ if (!userCode || typeof userCode !== "string") return new Set();
370
+ const known = knownNames instanceof Set ? knownNames : new Set();
371
+
372
+ PASCAL_TAG_RE.lastIndex = 0;
373
+ const used = new Set();
374
+ let m;
375
+ while ((m = PASCAL_TAG_RE.exec(userCode)) !== null) used.add(m[1]);
376
+
377
+ const missing = new Set();
378
+ for (const name of used) {
379
+ if (REACT_BUILTIN_TAGS.has(name)) continue;
380
+ if (known.has(name)) continue;
381
+ const escaped = name.replace(RE_ESCAPE, "\\$&");
382
+ const stripped = userCode.replace(
383
+ new RegExp(`<\\s*/?\\s*${escaped}\\b`, "g"),
384
+ "",
385
+ );
386
+ if (new RegExp(`\\b${escaped}\\b`).test(stripped)) continue;
387
+ missing.add(name);
388
+ }
389
+ return missing;
390
+ }
391
+
392
+ /**
393
+ * Resolve the content hash the server may advertise to a browser over the WS
394
+ * `version` frame. The hash MUST only be non-empty when there is a page the
395
+ * GET route can actually serve — otherwise a stale hash makes the served error
396
+ * / building page reload in a tight loop (it reloads on any non-empty
397
+ * `version` hash).
398
+ *
399
+ * States, in order:
400
+ * - no state / nulled `compiled` (post-`close` teardown window) → ""
401
+ * - degraded (build error but a previous good build is kept) → lastGood hash
402
+ * - hard build error with no fallback → ""
403
+ * - real serveable build (`compiled.js` present) → contentHash
404
+ *
405
+ * @param {?PageState} state pageState[endpoint] (may be null/partially torn down).
406
+ * @returns {string} Hash to advertise, or "" when nothing is serveable.
407
+ */
408
+ function serveableHash(state) {
409
+ if (!state || !state.compiled) return "";
410
+ if (state.compiled.error) {
411
+ return state.lastGood ? state.lastGood.contentHash || "" : "";
412
+ }
413
+ return state.compiled.js ? state.contentHash || "" : "";
414
+ }
415
+
416
+ /**
417
+ * True only when `state` holds a real, serveable build. Used by the no-op
418
+ * redeploy guard to decide whether a rebuild can be skipped. MUST be false
419
+ * after close() nulls `compiled` (`{ ...compiled: null }` teardown window),
420
+ * otherwise the guard treats the destroyed build as valid, skips the rebuild,
421
+ * and the GET route serves the holding page forever (permanent spinner).
422
+ *
423
+ * @param {?PageState} state pageState[endpoint] (may be null/torn down).
424
+ * @returns {boolean}
425
+ */
426
+ function hasFreshBuild(state) {
427
+ return !!state && !state.building && !!state.compiled && !state.compiled.error;
428
+ }
429
+
430
+ /**
431
+ * Decide whether the `preInstall` hook may veto an npm install because the
432
+ * module is already on disk. Vetoing is ONLY safe for plain installs of an
433
+ * already-present package (the offline/Docker case). It must never swallow:
434
+ *
435
+ * - **upgrades** (`event.isUpgrade`) — the palette manager's "update"
436
+ * button routes through the same install path; vetoing it makes Node-RED
437
+ * mark the module `pendingUpdated` and demand a restart while the old
438
+ * files stay on disk — the update silently never lands,
439
+ * - **explicit version requests** that differ from the installed version
440
+ * (e.g. a `libs` entry bumped from `^4.4.0`) — npm itself no-ops when
441
+ * the range is already satisfied, so letting it run is the safe side.
442
+ *
443
+ * An unreadable/corrupt package.json also proceeds with the install (npm
444
+ * repairs what we cannot verify).
445
+ *
446
+ * @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
447
+ * `preInstall` hook payload from `@node-red/registry`.
448
+ * @returns {boolean} true → hook should return false (skip npm install).
449
+ */
450
+ function shouldSkipInstall(event) {
451
+ if (event.isUpgrade) return false;
452
+ const modDir = path.join(event.dir, "node_modules", event.module);
453
+ if (!fs.existsSync(modDir)) return false;
454
+ if (event.version) {
455
+ let installed;
456
+ try {
457
+ installed = JSON.parse(
458
+ fs.readFileSync(path.join(modDir, "package.json"), "utf8"),
459
+ ).version;
460
+ } catch (_) {
461
+ return false;
462
+ }
463
+ if (installed !== event.version) return false;
464
+ }
465
+ return true;
466
+ }
467
+
468
+ module.exports = function (RED) {
469
+ return createHelpers(RED);
470
+ };
471
+
472
+ module.exports.validateSubPath = validateSubPath;
473
+ module.exports.isSafeName = isSafeName;
474
+ module.exports.quickCheckSyntax = quickCheckSyntax;
475
+ module.exports.formatEsbuildError = formatEsbuildError;
476
+ module.exports.extractPortalUser = extractPortalUser;
477
+ module.exports.findMissingComponentRefs = findMissingComponentRefs;
478
+ module.exports.identifierRe = identifierRe;
479
+ module.exports.serveableHash = serveableHash;
480
+ module.exports.hasFreshBuild = hasFreshBuild;
481
+ module.exports.shouldSkipInstall = shouldSkipInstall;
482
+ module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
483
+ module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
484
+
485
+ /**
486
+ * Factory that produces the runtime helper bag bound to a Node-RED instance.
487
+ * Returns the union of pure helpers (re-exported from module scope) and
488
+ * runtime helpers that need `RED` (disk cache, preInstall hook, transpile).
489
+ *
490
+ * @param {Object} RED Node-RED runtime object.
491
+ * @returns {Object} Helper bag — see the `return { … }` at the bottom of the function for the full surface.
492
+ */
493
+ function createHelpers(RED) {
494
+ // Package root — where react/react-dom live (this package's own node_modules)
495
+ const pkgRoot = path.join(__dirname, "../..");
496
+ // userDir — where dynamicModuleList installs user packages
497
+ const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
498
+
499
+ /**
500
+ * `preInstall` hook (Node-RED 1.3+) — vetoes an npm install when the
501
+ * package is already on disk. Useful for offline/Docker setups where
502
+ * Node-RED's auto-install pass would otherwise try to hit the registry
503
+ * and fail. Upgrades and mismatched explicit versions are never vetoed
504
+ * (see {@link shouldSkipInstall}) — vetoing an upgrade leaves the old
505
+ * files on disk while Node-RED demands a restart, so the update never
506
+ * installs. Hook itself is *optional by design*: any unexpected error
507
+ * inside the body MUST NOT bubble up (it would cancel an install path
508
+ * the user actually expected to run). We log at `trace` level so the
509
+ * developer can opt into diagnostics via `logging.level = trace` in
510
+ * `settings.js`, without spamming default-level logs.
511
+ *
512
+ * @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
513
+ * @returns {boolean|void} `false` skips the install; anything else proceeds.
514
+ * @listens RED.hooks#preInstall.portalReact
515
+ */
516
+ RED.hooks.add("preInstall.portalReact", (event) => {
517
+ try {
518
+ if (shouldSkipInstall(event)) {
519
+ RED.log.info(
520
+ `[portal-react] ${event.module} already in node_modules, skipping install`,
521
+ );
522
+ return false;
523
+ }
524
+ } catch (e) {
525
+ RED.log.trace("[portal-react] preInstall hook err: " + e.message);
526
+ }
527
+ });
528
+
529
+ // ── Disk cache for JS bundles and CSS ────────────────────────
530
+ const cacheDir = path.join(userDir, "fromcubes", "cache");
531
+ fs.mkdirSync(cacheDir, { recursive: true });
532
+
533
+ /**
534
+ * Load a cached compiled bundle from disk by JSX hash.
535
+ *
536
+ * @param {string} jsxHash
537
+ * @returns {?{js: string, metafile: ?Object, error: null}} null on cache miss.
538
+ */
539
+ function readCachedJS(jsxHash) {
540
+ try {
541
+ const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
542
+ let metafile = null;
543
+ try {
544
+ metafile = JSON.parse(
545
+ fs.readFileSync(path.join(cacheDir, jsxHash + ".meta.json"), "utf8"),
546
+ );
547
+ } catch (_) {}
548
+ return { js, metafile, error: null };
549
+ } catch (_) {
550
+ return null;
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Persist a compiled bundle (and optional metafile) to disk under `<hash>.js`.
556
+ *
557
+ * @param {string} jsxHash
558
+ * @param {string} js
559
+ * @param {Object} [metafile] esbuild metafile for size analysis.
560
+ * @returns {void}
561
+ */
562
+ function writeCachedJS(jsxHash, js, metafile) {
563
+ try {
564
+ fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
565
+ if (metafile) {
566
+ fs.writeFileSync(
567
+ path.join(cacheDir, jsxHash + ".meta.json"),
568
+ JSON.stringify(metafile),
569
+ "utf8",
570
+ );
571
+ }
572
+ } catch (e) {
573
+ RED.log.warn("[portal-react] cache write failed: " + e.message);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Load a cached Tailwind CSS bundle from disk by JSX hash.
579
+ *
580
+ * @param {string} jsxHash
581
+ * @returns {?{css: string, cssHash: string}} null on cache miss.
582
+ */
583
+ function readCachedCSS(jsxHash) {
584
+ try {
585
+ const css = fs.readFileSync(
586
+ path.join(cacheDir, jsxHash + ".css"),
587
+ "utf8",
588
+ );
589
+ return { css, cssHash: jsxHash };
590
+ } catch (_) {
591
+ return null;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Persist a Tailwind CSS bundle to disk under `<hash>.css`.
597
+ *
598
+ * @param {string} jsxHash
599
+ * @param {string} css
600
+ * @returns {void}
601
+ */
602
+ function writeCachedCSS(jsxHash, css) {
603
+ try {
604
+ fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
605
+ } catch (e) {
606
+ RED.log.warn("[portal-react] CSS cache write failed: " + e.message);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Remove cached `.js`, `.css`, and `.meta.json` files for a given hash.
612
+ * No-op when `jsxHash` is falsy. Errors are swallowed (best-effort cleanup).
613
+ *
614
+ * @param {?string} jsxHash
615
+ * @returns {void}
616
+ */
617
+ function deleteCacheFiles(jsxHash) {
618
+ if (!jsxHash) return;
619
+ for (const ext of [".js", ".css", ".meta.json"]) {
620
+ try {
621
+ fs.unlinkSync(path.join(cacheDir, jsxHash + ext));
622
+ } catch (_) {}
623
+ }
624
+ }
625
+
626
+ /**
627
+ * True when any other portal endpoint currently relies on `jsxHash`.
628
+ * Used before `deleteCacheFiles` so a still-active sibling portal does not
629
+ * lose its cache when a different portal redeploys to a new hash.
630
+ *
631
+ * @param {string} jsxHash
632
+ * @param {Object<string, {jsxHash: string}>} pageState Endpoint → state.
633
+ * @param {string} excludeEndpoint Endpoint to skip in the scan (the one whose hash is being replaced).
634
+ * @returns {boolean}
635
+ */
636
+ function isHashInUse(jsxHash, pageState, excludeEndpoint) {
637
+ for (const ep in pageState) {
638
+ if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
639
+ return true;
640
+ }
641
+ return false;
642
+ }
643
+
644
+ /**
645
+ * Bundle the user JSX (with utility/library/import code already concatenated)
646
+ * into a minified IIFE. Pre-validates with `quickCheckSyntax` first so
647
+ * malformed input never reaches the bundler. Uses the async `esbuild.build`
648
+ * API — a large bundle (three.js et al.) runs in esbuild's service process
649
+ * without blocking the Node-RED event loop. The `react`/`react-dom` alias
650
+ * points at this package's own copies so peer-dep packages share a single
651
+ * React instance.
652
+ *
653
+ * @param {string} jsx
654
+ * @returns {Promise<TranspileResult>}
655
+ */
656
+ async function transpile(jsx) {
657
+ // Pre-validate with transformSync (fast, no bundling) so syntax errors get
658
+ // clean line-numbered diagnostics before any resolution work
659
+ const syntaxErr = quickCheckSyntax(jsx);
660
+ if (syntaxErr) return { js: null, error: syntaxErr };
661
+ // Syntax OK — bundle with full resolution
662
+ try {
663
+ const buildResult = await esbuild.build({
664
+ stdin: {
665
+ contents: jsx,
666
+ resolveDir: pkgRoot,
667
+ loader: "jsx",
668
+ },
669
+ bundle: true,
670
+ format: "iife",
671
+ minify: true,
672
+ write: false,
673
+ target: ["es2020"],
674
+ jsx: "transform",
675
+ jsxFactory: "React.createElement",
676
+ jsxFragment: "React.Fragment",
677
+ define: { "process.env.NODE_ENV": '"production"' },
678
+ metafile: true,
679
+ logLevel: "silent",
680
+ logOverride: { "import-is-undefined": "silent" },
681
+ nodePaths: [path.join(userDir, "node_modules")],
682
+ alias: {
683
+ react: path.dirname(
684
+ require.resolve("react/package.json", { paths: [pkgRoot] }),
685
+ ),
686
+ "react-dom": path.dirname(
687
+ require.resolve("react-dom/package.json", { paths: [pkgRoot] }),
688
+ ),
689
+ },
690
+ });
691
+ return {
692
+ js: buildResult.outputFiles[0].text,
693
+ metafile: buildResult.metafile,
694
+ error: null,
695
+ };
696
+ } catch (e) {
697
+ return { js: null, error: formatEsbuildError(e) };
698
+ }
699
+ }
700
+
701
+ return {
702
+ hash,
703
+ transpile,
704
+ quickCheckSyntax,
705
+ generateCSS,
706
+ extractPortalUser,
707
+ serveableHash,
708
+ hasFreshBuild,
709
+ removeRoute,
710
+ isSafeName,
711
+ validateSubPath,
712
+ findMissingComponentRefs,
713
+ identifierRe,
714
+ pkgRoot,
715
+ userDir,
716
+ cacheDir,
717
+ readCachedJS,
718
+ writeCachedJS,
719
+ readCachedCSS,
720
+ writeCachedCSS,
721
+ deleteCacheFiles,
722
+ isHashInUse,
723
+ };
724
+ }