@aaqu/fromcubes-react 0.0.0-stage → 0.1.0
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/LICENSE +190 -0
- package/README.md +405 -2
- package/THIRD-PARTY-NOTICES.md +164 -0
- 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/editor/ark-pixel.LICENSE.txt +96 -0
- package/nodes/editor/ark-pixel.woff2 +0 -0
- package/nodes/editor/file-tree.js +403 -0
- package/nodes/editor/format.js +182 -0
- package/nodes/editor/icons.svg +46 -0
- package/nodes/editor/mark.svg +5 -0
- package/nodes/editor/provider-settings.js +192 -0
- package/nodes/editor/workspace.css +653 -0
- package/nodes/editor/workspace.js +1565 -0
- package/nodes/icons/fromcubes-component.svg +27 -0
- package/nodes/icons/fromcubes-react.svg +25 -0
- package/nodes/icons/fromcubes-utility.svg +21 -0
- package/nodes/lib/admin-api.js +238 -0
- package/nodes/lib/admin-security.js +161 -0
- package/nodes/lib/api.js +29 -0
- package/nodes/lib/helpers.js +990 -0
- package/nodes/lib/hooks.js +145 -0
- package/nodes/lib/page-builder.js +499 -0
- package/nodes/lib/portal-page-route.js +131 -0
- package/nodes/lib/providers.js +536 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +103 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +2539 -0
- package/nodes/portal-react.js +1652 -0
- package/package.json +76 -4
|
@@ -0,0 +1,990 @@
|
|
|
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} [login]
|
|
28
|
+
* @property {string} [displayName]
|
|
29
|
+
* @property {string} [email]
|
|
30
|
+
* @property {string} [role]
|
|
31
|
+
* @property {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
|
+
/**
|
|
40
|
+
* Short content hash used for cache keys (16 hex chars of sha256).
|
|
41
|
+
* @param {string} str
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function hash(str) {
|
|
45
|
+
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Component / utility names become JavaScript identifiers in the generated
|
|
49
|
+
// bundle (`const Name = (() => ...)();` for components; raw top-level decls
|
|
50
|
+
// for utilities). Enforce strict identifier syntax up-front so the diagnostic
|
|
51
|
+
// surfaces on the offending node — not as a confusing esbuild parse error on
|
|
52
|
+
// some unrelated portal that happens to import it.
|
|
53
|
+
const NAME_RE = /^[A-Za-z_$][\w$]*$/;
|
|
54
|
+
const NAME_MAX_LEN = 64;
|
|
55
|
+
const NAME_BLACKLIST = new Set([
|
|
56
|
+
"__proto__",
|
|
57
|
+
"constructor",
|
|
58
|
+
"prototype",
|
|
59
|
+
"hasOwnProperty",
|
|
60
|
+
"isPrototypeOf",
|
|
61
|
+
"propertyIsEnumerable",
|
|
62
|
+
"toString",
|
|
63
|
+
"valueOf",
|
|
64
|
+
"toLocaleString",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when `name` is a syntactically valid JavaScript identifier of bounded
|
|
69
|
+
* length that does not collide with Object prototype keys.
|
|
70
|
+
*
|
|
71
|
+
* Rules:
|
|
72
|
+
* - non-empty string
|
|
73
|
+
* - length ≤ 64
|
|
74
|
+
* - matches /^[A-Za-z_$][\w$]*$/
|
|
75
|
+
* - not in NAME_BLACKLIST (prototype pollution guard)
|
|
76
|
+
*
|
|
77
|
+
* @param {unknown} name
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function isSafeName(name) {
|
|
81
|
+
return (
|
|
82
|
+
typeof name === "string" &&
|
|
83
|
+
name.length > 0 &&
|
|
84
|
+
name.length <= NAME_MAX_LEN &&
|
|
85
|
+
NAME_RE.test(name) &&
|
|
86
|
+
!NAME_BLACKLIST.has(name)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SUB_PATH_SEGMENT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
91
|
+
const SUB_PATH_RESERVED = new Set(["public", "_ws"]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate a portal sub-path (the part served under `/fromcubes/<sub-path>`).
|
|
95
|
+
*
|
|
96
|
+
* Rules: non-empty, no leading/trailing slash, no whitespace, segments match
|
|
97
|
+
* `[a-zA-Z0-9][a-zA-Z0-9._-]*`, reserved segments `public` and `_ws` are
|
|
98
|
+
* blocked case-insensitively. Multiple slash-separated segments are allowed.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} input
|
|
101
|
+
* @returns {SubPathResult}
|
|
102
|
+
*/
|
|
103
|
+
function validateSubPath(input) {
|
|
104
|
+
if (typeof input !== "string") {
|
|
105
|
+
return { ok: false, error: "Sub-path is required" };
|
|
106
|
+
}
|
|
107
|
+
const trimmed = input.trim();
|
|
108
|
+
if (trimmed.length === 0) {
|
|
109
|
+
return { ok: false, error: "Sub-path is required" };
|
|
110
|
+
}
|
|
111
|
+
if (/\s/.test(trimmed)) {
|
|
112
|
+
return { ok: false, error: "Sub-path must not contain whitespace" };
|
|
113
|
+
}
|
|
114
|
+
if (trimmed.startsWith("/")) {
|
|
115
|
+
return { ok: false, error: "Sub-path must not start with /" };
|
|
116
|
+
}
|
|
117
|
+
if (trimmed.endsWith("/")) {
|
|
118
|
+
return { ok: false, error: "Sub-path must not end with /" };
|
|
119
|
+
}
|
|
120
|
+
const segments = trimmed.split("/");
|
|
121
|
+
for (const seg of segments) {
|
|
122
|
+
if (seg.length === 0) {
|
|
123
|
+
return { ok: false, error: "Sub-path must not contain empty segments" };
|
|
124
|
+
}
|
|
125
|
+
if (seg === "." || seg === "..") {
|
|
126
|
+
return { ok: false, error: "Path traversal not allowed in sub-path" };
|
|
127
|
+
}
|
|
128
|
+
if (SUB_PATH_RESERVED.has(seg.toLowerCase())) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `Sub-path segment "${seg}" is reserved`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!SUB_PATH_SEGMENT_RE.test(seg)) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `Sub-path segment "${seg}" contains invalid characters`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, value: trimmed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove a single route mount-point from an Express router by exact path
|
|
146
|
+
* match. Used at deploy teardown to drop the previous portal's HTTP route
|
|
147
|
+
* before re-registering on the same path.
|
|
148
|
+
*
|
|
149
|
+
* @param {Object} router Express router (or Express app).
|
|
150
|
+
* @param {string} path Exact route path to remove (no glob/regex).
|
|
151
|
+
* @returns {void}
|
|
152
|
+
*/
|
|
153
|
+
function removeRoute(router, path) {
|
|
154
|
+
if (!router || !router.stack) return;
|
|
155
|
+
router.stack = router.stack.filter(
|
|
156
|
+
(layer) => !(layer.route && layer.route.path === path),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @typedef {Object} EsbuildErrorLocation
|
|
162
|
+
* @property {number} line
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @typedef {Object} EsbuildErrorEntry
|
|
167
|
+
* @property {string} text
|
|
168
|
+
* @property {EsbuildErrorLocation} [location]
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @typedef {Object} EsbuildErrorLike
|
|
173
|
+
* @property {Array<EsbuildErrorEntry>} [errors]
|
|
174
|
+
* @property {string} [message]
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Flatten an esbuild error object into a single multi-line string suitable
|
|
179
|
+
* for `node.error()` and the in-page error overlay. Includes line numbers
|
|
180
|
+
* when esbuild provides them; falls back to `.message` otherwise.
|
|
181
|
+
*
|
|
182
|
+
* @param {EsbuildErrorLike} e
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function formatEsbuildError(e) {
|
|
186
|
+
return e.errors?.length
|
|
187
|
+
? e.errors
|
|
188
|
+
.map(
|
|
189
|
+
(err) =>
|
|
190
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
191
|
+
)
|
|
192
|
+
.join("\n")
|
|
193
|
+
: e.message;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Fast JSX syntax validation via esbuild `transformSync` (no bundling, no
|
|
198
|
+
* filesystem lookup). Used at deploy time on individual component/utility
|
|
199
|
+
* snippets so the editor can attribute syntax errors to the offending node
|
|
200
|
+
* before any bundling pass runs on the portal.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} jsx
|
|
203
|
+
* @returns {string|null} Multi-line error string, or null when JSX is syntactically valid.
|
|
204
|
+
*/
|
|
205
|
+
function quickCheckSyntax(jsx) {
|
|
206
|
+
if (!jsx || !jsx.trim()) return null;
|
|
207
|
+
try {
|
|
208
|
+
esbuild.transformSync(jsx, {
|
|
209
|
+
loader: "jsx",
|
|
210
|
+
jsx: "transform",
|
|
211
|
+
jsxFactory: "React.createElement",
|
|
212
|
+
jsxFragment: "React.Fragment",
|
|
213
|
+
logLevel: "silent",
|
|
214
|
+
});
|
|
215
|
+
return null;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return formatEsbuildError(e);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Identifiers that resolve at runtime through the bundler shim (React et al.)
|
|
222
|
+
// rather than through registry / utility / local-def lookup. Anything in this
|
|
223
|
+
// set is treated as "always satisfied" by findMissingComponentRefs.
|
|
224
|
+
const REACT_BUILTIN_TAGS = new Set([
|
|
225
|
+
"React",
|
|
226
|
+
"Fragment",
|
|
227
|
+
"Suspense",
|
|
228
|
+
"StrictMode",
|
|
229
|
+
"Profiler",
|
|
230
|
+
"ReactDOM",
|
|
231
|
+
"App",
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
const PASCAL_TAG_RE = /<\s*([A-Z][A-Za-z0-9_]*)/g;
|
|
235
|
+
const RE_ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
|
236
|
+
|
|
237
|
+
// Cached identifier-boundary regexes shared by every "does this code
|
|
238
|
+
// reference identifier X" scan (component deps, utility selection, dirty-
|
|
239
|
+
// portal matching, topological sort). `\b` breaks for names that start or
|
|
240
|
+
// end with `$` — a legal identifier char that is not a regex word char — so
|
|
241
|
+
// lookarounds on the [\w$] class are used instead. Cache is bounded: cleared
|
|
242
|
+
// wholesale past 5000 entries (long-running process with many renames).
|
|
243
|
+
const IDENT_RE_CACHE = new Map();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Return a cached RegExp matching `name` as a standalone JS identifier
|
|
247
|
+
* (not as a prefix/suffix/substring of a longer identifier).
|
|
248
|
+
*
|
|
249
|
+
* @param {string} name Identifier to match (component/utility/symbol name).
|
|
250
|
+
* @returns {RegExp}
|
|
251
|
+
*/
|
|
252
|
+
function identifierRe(name) {
|
|
253
|
+
let re = IDENT_RE_CACHE.get(name);
|
|
254
|
+
if (!re) {
|
|
255
|
+
if (IDENT_RE_CACHE.size > 5000) IDENT_RE_CACHE.clear();
|
|
256
|
+
const escaped = name.replace(RE_ESCAPE, "\\$&");
|
|
257
|
+
re = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
|
|
258
|
+
IDENT_RE_CACHE.set(name, re);
|
|
259
|
+
}
|
|
260
|
+
return re;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Keywords after which a `<` starts a JSX element even though the preceding
|
|
264
|
+
// significant character is a letter (`return <div/>` vs the comparison
|
|
265
|
+
// `a < b`). Used by the JSX-context tracking in stripComments.
|
|
266
|
+
const JSX_PREFIX_KEYWORDS = new Set([
|
|
267
|
+
"return",
|
|
268
|
+
"case",
|
|
269
|
+
"typeof",
|
|
270
|
+
"do",
|
|
271
|
+
"else",
|
|
272
|
+
"void",
|
|
273
|
+
"yield",
|
|
274
|
+
"await",
|
|
275
|
+
"in",
|
|
276
|
+
"of",
|
|
277
|
+
"new",
|
|
278
|
+
"delete",
|
|
279
|
+
"throw",
|
|
280
|
+
"instanceof",
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Replace every `//` and `/* */` comment in `code` with spaces, leaving
|
|
285
|
+
* strings, template literals, newlines — and JSX text — untouched. Output
|
|
286
|
+
* has exactly the same length and line structure as the input, so match
|
|
287
|
+
* offsets and line numbers computed against the stripped text are valid in
|
|
288
|
+
* the original.
|
|
289
|
+
*
|
|
290
|
+
* JSX-aware, matching esbuild's semantics: between JSX tags (children
|
|
291
|
+
* context) `//` and `/* */` are literal text, NOT comments — esbuild
|
|
292
|
+
* renders them — so they are left in place and any `<Tag/>` after them
|
|
293
|
+
* stays visible to the scans. Comments are only stripped in real JS
|
|
294
|
+
* contexts: top-level code, `{…}` expressions inside JSX, and inside
|
|
295
|
+
* opening tags between attributes (where esbuild does allow comments).
|
|
296
|
+
* `{/* … */}` in children is a `{…}` expression holding a block comment,
|
|
297
|
+
* so its body is blanked as before.
|
|
298
|
+
*
|
|
299
|
+
* Used by every regex-based code scan (missing-component preflight, import
|
|
300
|
+
* hoisting, App/return detection) so commented-out code is invisible to
|
|
301
|
+
* them — and NOT-commented-out code (like `// <Gauge/>` in children, which
|
|
302
|
+
* esbuild treats as text plus a live element) stays visible.
|
|
303
|
+
*
|
|
304
|
+
* The Monaco Ctrl+/ handler in portal-react.html carries a client-side
|
|
305
|
+
* mirror of this scanner (`__fcJsxContextAt`) — keep the two in sync.
|
|
306
|
+
*
|
|
307
|
+
* Regex literals are not tracked — an unescaped `//` inside a character
|
|
308
|
+
* class (`/[//]/`) is misread as a comment. The scans this feeds are
|
|
309
|
+
* advisory, and esbuild always parses the original source, so the worst
|
|
310
|
+
* case is a skipped preflight warning.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} code
|
|
313
|
+
* @param {boolean} [blankStrings=false] Also blank the interior of string
|
|
314
|
+
* and template literals (quotes stay). Used by scans where text inside a
|
|
315
|
+
* string must not look like code — e.g. `"see <Gauge/>"` is not a JSX
|
|
316
|
+
* usage. JSX children text is not a string literal and is never blanked.
|
|
317
|
+
* @returns {string} Same-length text with comment bodies blanked.
|
|
318
|
+
*/
|
|
319
|
+
function stripComments(code, blankStrings = false) {
|
|
320
|
+
if (!code || typeof code !== "string") return "";
|
|
321
|
+
const out = code.split("");
|
|
322
|
+
const n = code.length;
|
|
323
|
+
|
|
324
|
+
// Frame stack tracking JSX context; the bottom frame is plain JS:
|
|
325
|
+
// { t: "js", depth } – JS code / `{…}` expression inside JSX
|
|
326
|
+
// (depth = open braces inside this frame)
|
|
327
|
+
// { t: "tag", slash } – inside an opening tag `<Tag …>` (slash = last
|
|
328
|
+
// significant char was "/", i.e. self-closing)
|
|
329
|
+
// { t: "children" } – between `<Tag…>` and `</Tag>`: JSX text
|
|
330
|
+
const stack = [{ t: "js", depth: 0 }];
|
|
331
|
+
|
|
332
|
+
// Lookbehind for js frames, used to decide whether `<` starts JSX.
|
|
333
|
+
let prev = ""; // last significant char
|
|
334
|
+
let prev2 = ""; // significant char before prev
|
|
335
|
+
let word = ""; // identifier ending at prev ("" when prev is not ident)
|
|
336
|
+
let prevIdent = false; // was the immediately preceding raw char ident?
|
|
337
|
+
|
|
338
|
+
const IDENT_CH = /[A-Za-z0-9_$]/;
|
|
339
|
+
|
|
340
|
+
function sig(ch) {
|
|
341
|
+
prev2 = prev;
|
|
342
|
+
prev = ch;
|
|
343
|
+
if (IDENT_CH.test(ch)) {
|
|
344
|
+
word = prevIdent ? word + ch : ch;
|
|
345
|
+
prevIdent = true;
|
|
346
|
+
} else {
|
|
347
|
+
word = "";
|
|
348
|
+
prevIdent = false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function startsJsx(next) {
|
|
353
|
+
if (!/[A-Za-z_$>]/.test(next)) return false; // <ident, </? no, <> fragment yes
|
|
354
|
+
if (prev === "") return true;
|
|
355
|
+
if (prev === "<") return false; // `a << b`
|
|
356
|
+
if (/[A-Za-z0-9_$)\]}"'`]/.test(prev))
|
|
357
|
+
return JSX_PREFIX_KEYWORDS.has(word); // `return <div/>` vs `a < b`
|
|
358
|
+
if (prev === ">") return prev2 === "="; // arrow `=> <div/>`
|
|
359
|
+
return true; // ( , ? : = & | [ { ; ! …
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A complete JSX element that closes back into a js frame is an
|
|
363
|
+
// expression — mark the lookbehind expression-ish so a following `<`
|
|
364
|
+
// reads as a comparison, not a new element.
|
|
365
|
+
function poppedIntoJs() {
|
|
366
|
+
if (stack[stack.length - 1].t === "js") {
|
|
367
|
+
prev = ")";
|
|
368
|
+
prev2 = "";
|
|
369
|
+
word = "";
|
|
370
|
+
prevIdent = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function blankLineComment() {
|
|
375
|
+
out[i] = out[i + 1] = " ";
|
|
376
|
+
i += 2;
|
|
377
|
+
while (i < n && code[i] !== "\n") {
|
|
378
|
+
out[i] = " ";
|
|
379
|
+
i++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function blankBlockComment() {
|
|
384
|
+
out[i] = out[i + 1] = " ";
|
|
385
|
+
i += 2;
|
|
386
|
+
while (i < n && !(code[i] === "*" && code[i + 1] === "/")) {
|
|
387
|
+
if (code[i] !== "\n") out[i] = " ";
|
|
388
|
+
i++;
|
|
389
|
+
}
|
|
390
|
+
if (i < n) {
|
|
391
|
+
out[i] = out[i + 1] = " ";
|
|
392
|
+
i += 2;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function enterExpr() {
|
|
397
|
+
stack.push({ t: "js", depth: 0 });
|
|
398
|
+
prev = "";
|
|
399
|
+
prev2 = "";
|
|
400
|
+
word = "";
|
|
401
|
+
prevIdent = false;
|
|
402
|
+
i++;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let i = 0;
|
|
406
|
+
while (i < n) {
|
|
407
|
+
const top = stack[stack.length - 1];
|
|
408
|
+
const c = code[i];
|
|
409
|
+
const d = i + 1 < n ? code[i + 1] : "";
|
|
410
|
+
|
|
411
|
+
if (top.t === "children") {
|
|
412
|
+
if (c === "<" && d === "/") {
|
|
413
|
+
// closing tag — skip to ">" and leave the element
|
|
414
|
+
i += 2;
|
|
415
|
+
while (i < n && code[i] !== ">") i++;
|
|
416
|
+
i++;
|
|
417
|
+
stack.pop();
|
|
418
|
+
poppedIntoJs();
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (c === "<" && /[A-Za-z_$>]/.test(d)) {
|
|
422
|
+
stack.push({ t: "tag", slash: false });
|
|
423
|
+
i++;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (c === "{") {
|
|
427
|
+
enterExpr();
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
i++; // JSX text — `//`, `/*`, quotes are all literal content here
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (top.t === "tag") {
|
|
435
|
+
if (c === '"' || c === "'") {
|
|
436
|
+
// JSX attribute value — no escape processing in JSX strings
|
|
437
|
+
i++;
|
|
438
|
+
while (i < n && code[i] !== c) {
|
|
439
|
+
if (blankStrings && code[i] !== "\n") out[i] = " ";
|
|
440
|
+
i++;
|
|
441
|
+
}
|
|
442
|
+
i++;
|
|
443
|
+
top.slash = false;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (c === "/" && d === "/") {
|
|
447
|
+
blankLineComment(); // comments between attributes are real comments
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (c === "/" && d === "*") {
|
|
451
|
+
blankBlockComment();
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (c === "{") {
|
|
455
|
+
enterExpr();
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (c === ">") {
|
|
459
|
+
const selfClose = top.slash;
|
|
460
|
+
stack.pop();
|
|
461
|
+
if (selfClose) poppedIntoJs();
|
|
462
|
+
else stack.push({ t: "children" });
|
|
463
|
+
i++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!/\s/.test(c)) top.slash = c === "/";
|
|
467
|
+
i++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// js frame (top-level code or `{…}` expression inside JSX)
|
|
472
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
473
|
+
const q = c;
|
|
474
|
+
i++;
|
|
475
|
+
while (i < n && code[i] !== q) {
|
|
476
|
+
if (code[i] === "\\") {
|
|
477
|
+
if (blankStrings) {
|
|
478
|
+
out[i] = " ";
|
|
479
|
+
if (i + 1 < n && code[i + 1] !== "\n") out[i + 1] = " ";
|
|
480
|
+
}
|
|
481
|
+
i += 2;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (blankStrings && code[i] !== "\n") out[i] = " ";
|
|
485
|
+
i++;
|
|
486
|
+
}
|
|
487
|
+
i++;
|
|
488
|
+
sig(q);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (c === "/" && d === "/") {
|
|
492
|
+
blankLineComment();
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (c === "/" && d === "*") {
|
|
496
|
+
blankBlockComment();
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (c === "{") {
|
|
500
|
+
top.depth++;
|
|
501
|
+
sig(c);
|
|
502
|
+
i++;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (c === "}") {
|
|
506
|
+
if (top.depth === 0 && stack.length > 1) {
|
|
507
|
+
stack.pop(); // end of `{…}` expression — back to tag/children
|
|
508
|
+
if (stack[stack.length - 1].t === "tag")
|
|
509
|
+
stack[stack.length - 1].slash = false;
|
|
510
|
+
i++;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (top.depth > 0) top.depth--;
|
|
514
|
+
sig(c);
|
|
515
|
+
i++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (c === "<" && startsJsx(d)) {
|
|
519
|
+
stack.push({ t: "tag", slash: false });
|
|
520
|
+
i++;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (/\s/.test(c)) {
|
|
524
|
+
prevIdent = false;
|
|
525
|
+
i++;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
sig(c);
|
|
529
|
+
i++;
|
|
530
|
+
}
|
|
531
|
+
return out.join("");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Trailing whitespace must stay inside the line ([^\S\r\n], not \s): ranges
|
|
535
|
+
// are computed on comment-stripped text and sliced out of the original, so a
|
|
536
|
+
// match crossing the newline would swallow whatever the next line holds.
|
|
537
|
+
const IMPORT_RE = /^import\s+.+?from\s+['"].+?['"];?[^\S\r\n]*$/gm;
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Extract top-level `import … from '…'` statements from `code`, ignoring
|
|
541
|
+
* any that sit inside comments. Matching runs against the comment-stripped
|
|
542
|
+
* text (same offsets as the original), so a commented-out import survives
|
|
543
|
+
* in place as an inert comment instead of being hoisted into the bundle.
|
|
544
|
+
*
|
|
545
|
+
* @param {string} code
|
|
546
|
+
* @returns {{imports: string[], clean: string}} The live import statements
|
|
547
|
+
* in source order, and `code` with exactly those ranges removed
|
|
548
|
+
* (trimmed).
|
|
549
|
+
*/
|
|
550
|
+
function extractImports(code) {
|
|
551
|
+
if (!code || typeof code !== "string") return { imports: [], clean: "" };
|
|
552
|
+
const stripped = stripComments(code);
|
|
553
|
+
const imports = [];
|
|
554
|
+
const ranges = [];
|
|
555
|
+
IMPORT_RE.lastIndex = 0;
|
|
556
|
+
let m;
|
|
557
|
+
while ((m = IMPORT_RE.exec(stripped)) !== null) {
|
|
558
|
+
imports.push(code.slice(m.index, m.index + m[0].length).trim());
|
|
559
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
560
|
+
}
|
|
561
|
+
let clean = "";
|
|
562
|
+
let last = 0;
|
|
563
|
+
for (const [s, e] of ranges) {
|
|
564
|
+
clean += code.slice(last, s);
|
|
565
|
+
last = e;
|
|
566
|
+
}
|
|
567
|
+
clean += code.slice(last);
|
|
568
|
+
return { imports, clean: clean.trim() };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Detect whether `code` defines an `App` component and, for the
|
|
573
|
+
* `function App() { … }` form, whether its body contains a `return`.
|
|
574
|
+
* Comments are stripped first, so `// return null` inside the body or a
|
|
575
|
+
* commented-out `function App()` do not count.
|
|
576
|
+
*
|
|
577
|
+
* @param {string} code User JSX (imports may or may not be present).
|
|
578
|
+
* @returns {{hasApp: boolean, missingReturn: boolean}}
|
|
579
|
+
*/
|
|
580
|
+
function checkAppCode(code) {
|
|
581
|
+
const src = stripComments(code || "");
|
|
582
|
+
const hasApp =
|
|
583
|
+
/\b(?:export\s+default\s+)?function\s+App\s*\(/.test(src) ||
|
|
584
|
+
/\bclass\s+App\b/.test(src) ||
|
|
585
|
+
/\b(?:const|let|var)\s+App\s*=/.test(src);
|
|
586
|
+
|
|
587
|
+
let missingReturn = false;
|
|
588
|
+
const appFnMatch = src.match(
|
|
589
|
+
/(?:export\s+default\s+)?function\s+App\s*\([^)]*\)\s*\{/,
|
|
590
|
+
);
|
|
591
|
+
if (appFnMatch) {
|
|
592
|
+
let depth = 1;
|
|
593
|
+
let i = appFnMatch.index + appFnMatch[0].length;
|
|
594
|
+
let hasReturn = false;
|
|
595
|
+
while (i < src.length && depth > 0) {
|
|
596
|
+
const ch = src[i];
|
|
597
|
+
if (ch === "{") depth++;
|
|
598
|
+
else if (ch === "}") depth--;
|
|
599
|
+
else if (src.slice(i, i + 7) === "return ") hasReturn = true;
|
|
600
|
+
i++;
|
|
601
|
+
}
|
|
602
|
+
missingReturn = !hasReturn;
|
|
603
|
+
}
|
|
604
|
+
return { hasApp, missingReturn };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Find PascalCase JSX tags in `userCode` that have no visible definition.
|
|
609
|
+
*
|
|
610
|
+
* Used at deploy time to catch the case where a portal references a shared
|
|
611
|
+
* component (e.g. `<Header/>`) without the example flow that defines it
|
|
612
|
+
* being imported into Node-RED. Without this check the bundler silently
|
|
613
|
+
* skips the missing name and the browser crashes with `ReferenceError`.
|
|
614
|
+
*
|
|
615
|
+
* A tag is considered satisfied when ANY of:
|
|
616
|
+
* - its name is in `knownNames` (component registry / utility symbols)
|
|
617
|
+
* - its name is a React built-in (`React`, `Fragment`, `Suspense`, …)
|
|
618
|
+
* - the bare identifier appears outside JSX-tag context in `userCode`
|
|
619
|
+
* (covers `import {Name} from …`, `function Name(){…}`, `const Name = …`,
|
|
620
|
+
* `class Name extends …`)
|
|
621
|
+
*
|
|
622
|
+
* @param {string} userCode
|
|
623
|
+
* @param {Set<string>} knownNames
|
|
624
|
+
* @returns {Set<string>} PascalCase names referenced in JSX with nothing
|
|
625
|
+
* to satisfy them.
|
|
626
|
+
*/
|
|
627
|
+
function findMissingComponentRefs(userCode, knownNames) {
|
|
628
|
+
if (!userCode || typeof userCode !== "string") return new Set();
|
|
629
|
+
const known = knownNames instanceof Set ? knownNames : new Set();
|
|
630
|
+
|
|
631
|
+
// Scan with comments AND string interiors blanked: a commented-out <Tag/>
|
|
632
|
+
// is not a usage, a commented-out `const Tag = …` is not a definition, and
|
|
633
|
+
// `"see <Tag/> in the docs"` is neither.
|
|
634
|
+
const src = stripComments(userCode, true);
|
|
635
|
+
|
|
636
|
+
PASCAL_TAG_RE.lastIndex = 0;
|
|
637
|
+
const used = new Set();
|
|
638
|
+
let m;
|
|
639
|
+
while ((m = PASCAL_TAG_RE.exec(src)) !== null) used.add(m[1]);
|
|
640
|
+
|
|
641
|
+
const missing = new Set();
|
|
642
|
+
for (const name of used) {
|
|
643
|
+
if (REACT_BUILTIN_TAGS.has(name)) continue;
|
|
644
|
+
if (known.has(name)) continue;
|
|
645
|
+
const escaped = name.replace(RE_ESCAPE, "\\$&");
|
|
646
|
+
const stripped = src.replace(
|
|
647
|
+
new RegExp(`<\\s*/?\\s*${escaped}\\b`, "g"),
|
|
648
|
+
"",
|
|
649
|
+
);
|
|
650
|
+
if (new RegExp(`\\b${escaped}\\b`).test(stripped)) continue;
|
|
651
|
+
missing.add(name);
|
|
652
|
+
}
|
|
653
|
+
return missing;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Resolve the content hash the server may advertise to a browser over the WS
|
|
658
|
+
* `version` frame. The hash MUST only be non-empty when there is a page the
|
|
659
|
+
* GET route can actually serve — otherwise a stale hash makes the served error
|
|
660
|
+
* / building page reload in a tight loop (it reloads on any non-empty
|
|
661
|
+
* `version` hash).
|
|
662
|
+
*
|
|
663
|
+
* States, in order:
|
|
664
|
+
* - no state / nulled `compiled` (post-`close` teardown window) → ""
|
|
665
|
+
* - degraded (build error but a previous good build is kept) → lastGood hash
|
|
666
|
+
* - hard build error with no fallback → ""
|
|
667
|
+
* - real serveable build (`compiled.js` present) → contentHash
|
|
668
|
+
*
|
|
669
|
+
* @param {?PageState} state pageState[endpoint] (may be null/partially torn down).
|
|
670
|
+
* @returns {string} Hash to advertise, or "" when nothing is serveable.
|
|
671
|
+
*/
|
|
672
|
+
function serveableHash(state) {
|
|
673
|
+
if (!state || !state.compiled) return "";
|
|
674
|
+
if (state.compiled.error) {
|
|
675
|
+
return state.lastGood ? state.lastGood.contentHash || "" : "";
|
|
676
|
+
}
|
|
677
|
+
return state.compiled.js ? state.contentHash || "" : "";
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* True only when `state` holds a real, serveable build. Used by the no-op
|
|
682
|
+
* redeploy guard to decide whether a rebuild can be skipped. MUST be false
|
|
683
|
+
* after close() nulls `compiled` (`{ ...compiled: null }` teardown window),
|
|
684
|
+
* otherwise the guard treats the destroyed build as valid, skips the rebuild,
|
|
685
|
+
* and the GET route serves the holding page forever (permanent spinner).
|
|
686
|
+
*
|
|
687
|
+
* @param {?PageState} state pageState[endpoint] (may be null/torn down).
|
|
688
|
+
* @returns {boolean}
|
|
689
|
+
*/
|
|
690
|
+
function hasFreshBuild(state) {
|
|
691
|
+
return !!state && !state.building && !!state.compiled && !state.compiled.error;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Decide whether the `preInstall` hook may veto an npm install because the
|
|
696
|
+
* module is already on disk. Vetoing is ONLY safe for plain installs of an
|
|
697
|
+
* already-present package (the offline/Docker case). It must never swallow:
|
|
698
|
+
*
|
|
699
|
+
* - **upgrades** (`event.isUpgrade`) — the palette manager's "update"
|
|
700
|
+
* button routes through the same install path; vetoing it makes Node-RED
|
|
701
|
+
* mark the module `pendingUpdated` and demand a restart while the old
|
|
702
|
+
* files stay on disk — the update silently never lands,
|
|
703
|
+
* - **explicit version requests** that differ from the installed version
|
|
704
|
+
* (e.g. a `libs` entry bumped from `^4.4.0`) — npm itself no-ops when
|
|
705
|
+
* the range is already satisfied, so letting it run is the safe side.
|
|
706
|
+
*
|
|
707
|
+
* An unreadable/corrupt package.json also proceeds with the install (npm
|
|
708
|
+
* repairs what we cannot verify).
|
|
709
|
+
*
|
|
710
|
+
* @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
|
|
711
|
+
* `preInstall` hook payload from `@node-red/registry`.
|
|
712
|
+
* @returns {boolean} true → hook should return false (skip npm install).
|
|
713
|
+
*/
|
|
714
|
+
function shouldSkipInstall(event) {
|
|
715
|
+
if (event.isUpgrade) return false;
|
|
716
|
+
const modDir = path.join(event.dir, "node_modules", event.module);
|
|
717
|
+
if (!fs.existsSync(modDir)) return false;
|
|
718
|
+
if (event.version) {
|
|
719
|
+
let installed;
|
|
720
|
+
try {
|
|
721
|
+
installed = JSON.parse(
|
|
722
|
+
fs.readFileSync(path.join(modDir, "package.json"), "utf8"),
|
|
723
|
+
).version;
|
|
724
|
+
} catch (_) {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
if (installed !== event.version) return false;
|
|
728
|
+
}
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
module.exports = function (RED) {
|
|
733
|
+
return createHelpers(RED);
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
module.exports.validateSubPath = validateSubPath;
|
|
737
|
+
module.exports.isSafeName = isSafeName;
|
|
738
|
+
module.exports.quickCheckSyntax = quickCheckSyntax;
|
|
739
|
+
module.exports.formatEsbuildError = formatEsbuildError;
|
|
740
|
+
module.exports.findMissingComponentRefs = findMissingComponentRefs;
|
|
741
|
+
module.exports.stripComments = stripComments;
|
|
742
|
+
module.exports.extractImports = extractImports;
|
|
743
|
+
module.exports.checkAppCode = checkAppCode;
|
|
744
|
+
module.exports.identifierRe = identifierRe;
|
|
745
|
+
module.exports.serveableHash = serveableHash;
|
|
746
|
+
module.exports.hasFreshBuild = hasFreshBuild;
|
|
747
|
+
module.exports.shouldSkipInstall = shouldSkipInstall;
|
|
748
|
+
module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Factory that produces the runtime helper bag bound to a Node-RED instance.
|
|
752
|
+
* Returns the union of pure helpers (re-exported from module scope) and
|
|
753
|
+
* runtime helpers that need `RED` (disk cache, preInstall hook, transpile).
|
|
754
|
+
*
|
|
755
|
+
* @param {Object} RED Node-RED runtime object.
|
|
756
|
+
* @returns {Object} Helper bag — see the `return { … }` at the bottom of the function for the full surface.
|
|
757
|
+
*/
|
|
758
|
+
function createHelpers(RED) {
|
|
759
|
+
// Package root — where react/react-dom live (this package's own node_modules)
|
|
760
|
+
const pkgRoot = path.join(__dirname, "../..");
|
|
761
|
+
// userDir — where dynamicModuleList installs user packages
|
|
762
|
+
const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* `preInstall` hook (Node-RED 1.3+) — vetoes an npm install when the
|
|
766
|
+
* package is already on disk. Useful for offline/Docker setups where
|
|
767
|
+
* Node-RED's auto-install pass would otherwise try to hit the registry
|
|
768
|
+
* and fail. Upgrades and mismatched explicit versions are never vetoed
|
|
769
|
+
* (see {@link shouldSkipInstall}) — vetoing an upgrade leaves the old
|
|
770
|
+
* files on disk while Node-RED demands a restart, so the update never
|
|
771
|
+
* installs. Hook itself is *optional by design*: any unexpected error
|
|
772
|
+
* inside the body MUST NOT bubble up (it would cancel an install path
|
|
773
|
+
* the user actually expected to run). We log at `trace` level so the
|
|
774
|
+
* developer can opt into diagnostics via `logging.level = trace` in
|
|
775
|
+
* `settings.js`, without spamming default-level logs.
|
|
776
|
+
*
|
|
777
|
+
* @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
|
|
778
|
+
* @returns {boolean|void} `false` skips the install; anything else proceeds.
|
|
779
|
+
* @listens RED.hooks#preInstall.portalReact
|
|
780
|
+
*/
|
|
781
|
+
RED.hooks.add("preInstall.portalReact", (event) => {
|
|
782
|
+
try {
|
|
783
|
+
if (shouldSkipInstall(event)) {
|
|
784
|
+
RED.log.info(
|
|
785
|
+
`[portal-react] ${event.module} already in node_modules, skipping install`,
|
|
786
|
+
);
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
} catch (e) {
|
|
790
|
+
RED.log.trace("[portal-react] preInstall hook err: " + e.message);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// ── Disk cache for JS bundles and CSS ────────────────────────
|
|
795
|
+
const cacheDir = path.join(userDir, "fromcubes", "cache");
|
|
796
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Load a cached compiled bundle from disk by JSX hash.
|
|
800
|
+
*
|
|
801
|
+
* @param {string} jsxHash
|
|
802
|
+
* @returns {?{js: string, metafile: ?Object, error: null}} null on cache miss.
|
|
803
|
+
*/
|
|
804
|
+
function readCachedJS(jsxHash) {
|
|
805
|
+
try {
|
|
806
|
+
const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
|
|
807
|
+
let metafile = null;
|
|
808
|
+
try {
|
|
809
|
+
metafile = JSON.parse(
|
|
810
|
+
fs.readFileSync(path.join(cacheDir, jsxHash + ".meta.json"), "utf8"),
|
|
811
|
+
);
|
|
812
|
+
} catch (_) {}
|
|
813
|
+
return { js, metafile, error: null };
|
|
814
|
+
} catch (_) {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Persist a compiled bundle (and optional metafile) to disk under `<hash>.js`.
|
|
821
|
+
*
|
|
822
|
+
* @param {string} jsxHash
|
|
823
|
+
* @param {string} js
|
|
824
|
+
* @param {Object} [metafile] esbuild metafile for size analysis.
|
|
825
|
+
* @returns {void}
|
|
826
|
+
*/
|
|
827
|
+
function writeCachedJS(jsxHash, js, metafile) {
|
|
828
|
+
try {
|
|
829
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
|
|
830
|
+
if (metafile) {
|
|
831
|
+
fs.writeFileSync(
|
|
832
|
+
path.join(cacheDir, jsxHash + ".meta.json"),
|
|
833
|
+
JSON.stringify(metafile),
|
|
834
|
+
"utf8",
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
} catch (e) {
|
|
838
|
+
RED.log.warn("[portal-react] cache write failed: " + e.message);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Load a cached Tailwind CSS bundle from disk by JSX hash.
|
|
844
|
+
*
|
|
845
|
+
* @param {string} jsxHash
|
|
846
|
+
* @returns {?{css: string, cssHash: string}} null on cache miss.
|
|
847
|
+
*/
|
|
848
|
+
function readCachedCSS(jsxHash) {
|
|
849
|
+
try {
|
|
850
|
+
const css = fs.readFileSync(
|
|
851
|
+
path.join(cacheDir, jsxHash + ".css"),
|
|
852
|
+
"utf8",
|
|
853
|
+
);
|
|
854
|
+
return { css, cssHash: jsxHash };
|
|
855
|
+
} catch (_) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Persist a Tailwind CSS bundle to disk under `<hash>.css`.
|
|
862
|
+
*
|
|
863
|
+
* @param {string} jsxHash
|
|
864
|
+
* @param {string} css
|
|
865
|
+
* @returns {void}
|
|
866
|
+
*/
|
|
867
|
+
function writeCachedCSS(jsxHash, css) {
|
|
868
|
+
try {
|
|
869
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
|
|
870
|
+
} catch (e) {
|
|
871
|
+
RED.log.warn("[portal-react] CSS cache write failed: " + e.message);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Remove cached `.js`, `.css`, and `.meta.json` files for a given hash.
|
|
877
|
+
* No-op when `jsxHash` is falsy. Errors are swallowed (best-effort cleanup).
|
|
878
|
+
*
|
|
879
|
+
* @param {?string} jsxHash
|
|
880
|
+
* @returns {void}
|
|
881
|
+
*/
|
|
882
|
+
function deleteCacheFiles(jsxHash) {
|
|
883
|
+
if (!jsxHash) return;
|
|
884
|
+
for (const ext of [".js", ".css", ".meta.json"]) {
|
|
885
|
+
try {
|
|
886
|
+
fs.unlinkSync(path.join(cacheDir, jsxHash + ext));
|
|
887
|
+
} catch (_) {}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* True when any other portal endpoint currently relies on `jsxHash`.
|
|
893
|
+
* Used before `deleteCacheFiles` so a still-active sibling portal does not
|
|
894
|
+
* lose its cache when a different portal redeploys to a new hash.
|
|
895
|
+
*
|
|
896
|
+
* @param {string} jsxHash
|
|
897
|
+
* @param {Object<string, {jsxHash: string}>} pageState Endpoint → state.
|
|
898
|
+
* @param {string} excludeEndpoint Endpoint to skip in the scan (the one whose hash is being replaced).
|
|
899
|
+
* @returns {boolean}
|
|
900
|
+
*/
|
|
901
|
+
function isHashInUse(jsxHash, pageState, excludeEndpoint) {
|
|
902
|
+
for (const ep in pageState) {
|
|
903
|
+
if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Bundle the user JSX (with utility/library/import code already concatenated)
|
|
911
|
+
* into a minified IIFE. Pre-validates with `quickCheckSyntax` first so
|
|
912
|
+
* malformed input never reaches the bundler. Uses the async `esbuild.build`
|
|
913
|
+
* API — a large bundle (three.js et al.) runs in esbuild's service process
|
|
914
|
+
* without blocking the Node-RED event loop. The `react`/`react-dom` alias
|
|
915
|
+
* points at this package's own copies so peer-dep packages share a single
|
|
916
|
+
* React instance.
|
|
917
|
+
*
|
|
918
|
+
* @param {string} jsx
|
|
919
|
+
* @returns {Promise<TranspileResult>}
|
|
920
|
+
*/
|
|
921
|
+
async function transpile(jsx) {
|
|
922
|
+
// Pre-validate with transformSync (fast, no bundling) so syntax errors get
|
|
923
|
+
// clean line-numbered diagnostics before any resolution work
|
|
924
|
+
const syntaxErr = quickCheckSyntax(jsx);
|
|
925
|
+
if (syntaxErr) return { js: null, error: syntaxErr };
|
|
926
|
+
// Syntax OK — bundle with full resolution
|
|
927
|
+
try {
|
|
928
|
+
const buildResult = await esbuild.build({
|
|
929
|
+
stdin: {
|
|
930
|
+
contents: jsx,
|
|
931
|
+
resolveDir: pkgRoot,
|
|
932
|
+
loader: "jsx",
|
|
933
|
+
},
|
|
934
|
+
bundle: true,
|
|
935
|
+
format: "iife",
|
|
936
|
+
minify: true,
|
|
937
|
+
write: false,
|
|
938
|
+
target: ["es2020"],
|
|
939
|
+
jsx: "transform",
|
|
940
|
+
jsxFactory: "React.createElement",
|
|
941
|
+
jsxFragment: "React.Fragment",
|
|
942
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
943
|
+
metafile: true,
|
|
944
|
+
logLevel: "silent",
|
|
945
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
946
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
947
|
+
alias: {
|
|
948
|
+
react: path.dirname(
|
|
949
|
+
require.resolve("react/package.json", { paths: [pkgRoot] }),
|
|
950
|
+
),
|
|
951
|
+
"react-dom": path.dirname(
|
|
952
|
+
require.resolve("react-dom/package.json", { paths: [pkgRoot] }),
|
|
953
|
+
),
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
return {
|
|
957
|
+
js: buildResult.outputFiles[0].text,
|
|
958
|
+
metafile: buildResult.metafile,
|
|
959
|
+
error: null,
|
|
960
|
+
};
|
|
961
|
+
} catch (e) {
|
|
962
|
+
return { js: null, error: formatEsbuildError(e) };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
hash,
|
|
968
|
+
transpile,
|
|
969
|
+
quickCheckSyntax,
|
|
970
|
+
serveableHash,
|
|
971
|
+
hasFreshBuild,
|
|
972
|
+
removeRoute,
|
|
973
|
+
isSafeName,
|
|
974
|
+
validateSubPath,
|
|
975
|
+
findMissingComponentRefs,
|
|
976
|
+
stripComments,
|
|
977
|
+
extractImports,
|
|
978
|
+
checkAppCode,
|
|
979
|
+
identifierRe,
|
|
980
|
+
pkgRoot,
|
|
981
|
+
userDir,
|
|
982
|
+
cacheDir,
|
|
983
|
+
readCachedJS,
|
|
984
|
+
writeCachedJS,
|
|
985
|
+
readCachedCSS,
|
|
986
|
+
writeCachedCSS,
|
|
987
|
+
deleteCacheFiles,
|
|
988
|
+
isHashInUse,
|
|
989
|
+
};
|
|
990
|
+
}
|