@aaqu/fromcubes-portal-react 0.1.0-alpha.26 → 0.1.0-alpha.28
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/nodes/lib/helpers.js +39 -8
- package/nodes/lib/portal-page-route.js +9 -4
- package/nodes/lib/registry-nodes.js +126 -17
- package/nodes/portal-react.js +118 -49
- package/package.json +1 -1
package/nodes/lib/helpers.js
CHANGED
|
@@ -319,6 +319,32 @@ const REACT_BUILTIN_TAGS = new Set([
|
|
|
319
319
|
const PASCAL_TAG_RE = /<\s*([A-Z][A-Za-z0-9_]*)/g;
|
|
320
320
|
const RE_ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
|
321
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
|
+
|
|
322
348
|
/**
|
|
323
349
|
* Find PascalCase JSX tags in `userCode` that have no visible definition.
|
|
324
350
|
*
|
|
@@ -411,6 +437,7 @@ module.exports.quickCheckSyntax = quickCheckSyntax;
|
|
|
411
437
|
module.exports.formatEsbuildError = formatEsbuildError;
|
|
412
438
|
module.exports.extractPortalUser = extractPortalUser;
|
|
413
439
|
module.exports.findMissingComponentRefs = findMissingComponentRefs;
|
|
440
|
+
module.exports.identifierRe = identifierRe;
|
|
414
441
|
module.exports.serveableHash = serveableHash;
|
|
415
442
|
module.exports.hasFreshBuild = hasFreshBuild;
|
|
416
443
|
module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
|
|
@@ -575,21 +602,24 @@ function createHelpers(RED) {
|
|
|
575
602
|
|
|
576
603
|
/**
|
|
577
604
|
* Bundle the user JSX (with utility/library/import code already concatenated)
|
|
578
|
-
* into a minified IIFE. Pre-validates with `quickCheckSyntax` first
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
605
|
+
* into a minified IIFE. Pre-validates with `quickCheckSyntax` first so
|
|
606
|
+
* malformed input never reaches the bundler. Uses the async `esbuild.build`
|
|
607
|
+
* API — a large bundle (three.js et al.) runs in esbuild's service process
|
|
608
|
+
* without blocking the Node-RED event loop. The `react`/`react-dom` alias
|
|
609
|
+
* points at this package's own copies so peer-dep packages share a single
|
|
610
|
+
* React instance.
|
|
582
611
|
*
|
|
583
612
|
* @param {string} jsx
|
|
584
|
-
* @returns {TranspileResult}
|
|
613
|
+
* @returns {Promise<TranspileResult>}
|
|
585
614
|
*/
|
|
586
|
-
function transpile(jsx) {
|
|
587
|
-
// Pre-validate with transformSync (fast, no bundling)
|
|
615
|
+
async function transpile(jsx) {
|
|
616
|
+
// Pre-validate with transformSync (fast, no bundling) so syntax errors get
|
|
617
|
+
// clean line-numbered diagnostics before any resolution work
|
|
588
618
|
const syntaxErr = quickCheckSyntax(jsx);
|
|
589
619
|
if (syntaxErr) return { js: null, error: syntaxErr };
|
|
590
620
|
// Syntax OK — bundle with full resolution
|
|
591
621
|
try {
|
|
592
|
-
const buildResult = esbuild.
|
|
622
|
+
const buildResult = await esbuild.build({
|
|
593
623
|
stdin: {
|
|
594
624
|
contents: jsx,
|
|
595
625
|
resolveDir: pkgRoot,
|
|
@@ -639,6 +669,7 @@ function createHelpers(RED) {
|
|
|
639
669
|
isSafeName,
|
|
640
670
|
validateSubPath,
|
|
641
671
|
findMissingComponentRefs,
|
|
672
|
+
identifierRe,
|
|
642
673
|
pkgRoot,
|
|
643
674
|
userDir,
|
|
644
675
|
cacheDir,
|
|
@@ -40,6 +40,7 @@ function createPortalPageHandler(opts) {
|
|
|
40
40
|
} = opts;
|
|
41
41
|
|
|
42
42
|
return async function portalPageHandler(req, res) {
|
|
43
|
+
let cssTimer = null;
|
|
43
44
|
try {
|
|
44
45
|
const state = pageState[endpoint];
|
|
45
46
|
if (!state || state.building || !state.compiled) {
|
|
@@ -88,13 +89,16 @@ function createPortalPageHandler(opts) {
|
|
|
88
89
|
|
|
89
90
|
const { cssHash } = await Promise.race([
|
|
90
91
|
state.cssReady,
|
|
91
|
-
new Promise((_, reject) =>
|
|
92
|
-
setTimeout(
|
|
92
|
+
new Promise((_, reject) => {
|
|
93
|
+
cssTimer = setTimeout(
|
|
93
94
|
() => reject(new Error("CSS generation timeout")),
|
|
94
95
|
15000,
|
|
95
|
-
)
|
|
96
|
-
),
|
|
96
|
+
);
|
|
97
|
+
}),
|
|
97
98
|
]);
|
|
99
|
+
// Clear the losing timer — without this every request leaves a 15 s
|
|
100
|
+
// timer pending after the CSS promise wins the race.
|
|
101
|
+
clearTimeout(cssTimer);
|
|
98
102
|
const user = state.portalAuth ? extractPortalUser(req.headers) : null;
|
|
99
103
|
res
|
|
100
104
|
.type("text/html")
|
|
@@ -111,6 +115,7 @@ function createPortalPageHandler(opts) {
|
|
|
111
115
|
),
|
|
112
116
|
);
|
|
113
117
|
} catch (e) {
|
|
118
|
+
clearTimeout(cssTimer);
|
|
114
119
|
res
|
|
115
120
|
.status(500)
|
|
116
121
|
.type("text/html")
|
|
@@ -1,23 +1,74 @@
|
|
|
1
1
|
const MAX_UTIL_CODE_BYTES = 1_000_000;
|
|
2
2
|
|
|
3
|
+
const DECL_RE = /^(?:export\s+)?(?:async\s+)?(function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
|
|
5
7
|
* names. Used for selective inclusion, symbol collision checks, and the
|
|
6
8
|
* editor Utilities dialog. Oversize input is ignored so the regex cannot be
|
|
7
9
|
* weaponized with multi-MB code.
|
|
8
10
|
*
|
|
11
|
+
* Multi-declarator statements (`const a = 1, b = 2`) yield every name —
|
|
12
|
+
* initializer-internal commas are skipped via bracket/quote depth tracking.
|
|
13
|
+
* Destructuring declarations are not supported (never were).
|
|
14
|
+
*
|
|
9
15
|
* @param {string} code
|
|
10
16
|
* @returns {Set<string>}
|
|
11
17
|
*/
|
|
12
18
|
function extractUtilitySymbols(code) {
|
|
13
19
|
const names = new Set();
|
|
14
20
|
if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
|
|
15
|
-
|
|
21
|
+
DECL_RE.lastIndex = 0;
|
|
16
22
|
let m;
|
|
17
|
-
while ((m =
|
|
23
|
+
while ((m = DECL_RE.exec(code))) {
|
|
24
|
+
names.add(m[2]);
|
|
25
|
+
const kind = m[1];
|
|
26
|
+
if (kind === "const" || kind === "let" || kind === "var") {
|
|
27
|
+
collectExtraDeclarators(code, DECL_RE.lastIndex, names);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
18
30
|
return names;
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Walk a `const`/`let`/`var` statement from just past its first declared
|
|
35
|
+
* name and collect the names of any further comma-separated declarators.
|
|
36
|
+
* Commas inside `()`, `[]`, `{}` or string/template literals belong to the
|
|
37
|
+
* initializer expression and are ignored. Scanning stops at the first `;`
|
|
38
|
+
* (or unbalanced closer) at depth 0.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} code
|
|
41
|
+
* @param {number} from Index right after the first declarator name.
|
|
42
|
+
* @param {Set<string>} names Collector — extra names are added in place.
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
function collectExtraDeclarators(code, from, names) {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let i = from;
|
|
48
|
+
const n = code.length;
|
|
49
|
+
while (i < n) {
|
|
50
|
+
const ch = code[i];
|
|
51
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
52
|
+
i++;
|
|
53
|
+
while (i < n && code[i] !== ch) {
|
|
54
|
+
if (code[i] === "\\") i++;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
} else if (ch === "(" || ch === "[" || ch === "{") {
|
|
58
|
+
depth++;
|
|
59
|
+
} else if (ch === ")" || ch === "]" || ch === "}") {
|
|
60
|
+
if (depth === 0) return; // end of enclosing block / malformed
|
|
61
|
+
depth--;
|
|
62
|
+
} else if (ch === ";" && depth === 0) {
|
|
63
|
+
return;
|
|
64
|
+
} else if (ch === "," && depth === 0) {
|
|
65
|
+
const dm = code.slice(i + 1).match(/^\s*([A-Za-z_$][\w$]*)/);
|
|
66
|
+
if (dm) names.add(dm[1]);
|
|
67
|
+
}
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
21
72
|
/**
|
|
22
73
|
* Register fc-portal-component and fc-portal-utility config nodes.
|
|
23
74
|
*
|
|
@@ -37,6 +88,15 @@ function registerRegistryNodes(RED, deps) {
|
|
|
37
88
|
scheduleRebuildUsing,
|
|
38
89
|
} = deps;
|
|
39
90
|
|
|
91
|
+
// Name each component/utility node currently registers: { nodeId: name }.
|
|
92
|
+
// Registry entries survive a plain redeploy (close(removed=false) keeps
|
|
93
|
+
// them so an unchanged deploy stays a no-op) — this map is what lets a
|
|
94
|
+
// RENAME on redeploy free the old entry instead of leaking it.
|
|
95
|
+
if (!RED.settings.portalReactNodeNames) {
|
|
96
|
+
RED.settings.portalReactNodeNames = {};
|
|
97
|
+
}
|
|
98
|
+
const nodeNames = RED.settings.portalReactNodeNames;
|
|
99
|
+
|
|
40
100
|
function PortalComponentNode(config) {
|
|
41
101
|
RED.nodes.createNode(this, config);
|
|
42
102
|
const node = this;
|
|
@@ -48,6 +108,19 @@ function registerRegistryNodes(RED, deps) {
|
|
|
48
108
|
return;
|
|
49
109
|
}
|
|
50
110
|
|
|
111
|
+
// Renamed on redeploy → free the previous entry owned by this node.
|
|
112
|
+
const prevOwnedComp = nodeNames[node.id];
|
|
113
|
+
if (prevOwnedComp && prevOwnedComp !== compName) {
|
|
114
|
+
if (compNameOwners[prevOwnedComp] === node.id) {
|
|
115
|
+
delete compNameOwners[prevOwnedComp];
|
|
116
|
+
}
|
|
117
|
+
if (registry[prevOwnedComp]) {
|
|
118
|
+
delete registry[prevOwnedComp];
|
|
119
|
+
scheduleRebuildUsing(prevOwnedComp);
|
|
120
|
+
}
|
|
121
|
+
delete nodeNames[node.id];
|
|
122
|
+
}
|
|
123
|
+
|
|
51
124
|
const existingOwner = compNameOwners[compName];
|
|
52
125
|
if (existingOwner && existingOwner !== node.id) {
|
|
53
126
|
node.error(
|
|
@@ -80,6 +153,7 @@ function registerRegistryNodes(RED, deps) {
|
|
|
80
153
|
return;
|
|
81
154
|
}
|
|
82
155
|
compNameOwners[compName] = node.id;
|
|
156
|
+
nodeNames[node.id] = compName;
|
|
83
157
|
|
|
84
158
|
const newCode = config.compCode || "";
|
|
85
159
|
const prevCode = registry[compName]?.code;
|
|
@@ -100,12 +174,21 @@ function registerRegistryNodes(RED, deps) {
|
|
|
100
174
|
|
|
101
175
|
if (prevCode !== newCode) scheduleRebuildUsing(compName);
|
|
102
176
|
|
|
103
|
-
node.on("close", function (
|
|
104
|
-
|
|
105
|
-
|
|
177
|
+
node.on("close", function (removed, done) {
|
|
178
|
+
// Plain redeploy (removed=false): keep the registry entry. The new
|
|
179
|
+
// instance re-registers in the same deploy pass and can compare
|
|
180
|
+
// prevCode — an unchanged component deploy stays a true no-op.
|
|
181
|
+
// Only delete/disable (removed=true) drops the entry.
|
|
182
|
+
if (removed) {
|
|
183
|
+
if (compNameOwners[compName] === node.id) {
|
|
184
|
+
delete compNameOwners[compName];
|
|
185
|
+
}
|
|
186
|
+
if (nodeNames[node.id] === compName) {
|
|
187
|
+
delete nodeNames[node.id];
|
|
188
|
+
}
|
|
189
|
+
delete registry[compName];
|
|
190
|
+
scheduleRebuildUsing(compName);
|
|
106
191
|
}
|
|
107
|
-
delete registry[compName];
|
|
108
|
-
scheduleRebuildUsing(compName);
|
|
109
192
|
done();
|
|
110
193
|
});
|
|
111
194
|
}
|
|
@@ -122,6 +205,24 @@ function registerRegistryNodes(RED, deps) {
|
|
|
122
205
|
return;
|
|
123
206
|
}
|
|
124
207
|
|
|
208
|
+
// Renamed on redeploy → free the previous entry + its symbol ownership.
|
|
209
|
+
const prevOwnedUtil = nodeNames[node.id];
|
|
210
|
+
if (prevOwnedUtil && prevOwnedUtil !== utilName) {
|
|
211
|
+
if (compNameOwners[prevOwnedUtil] === node.id) {
|
|
212
|
+
delete compNameOwners[prevOwnedUtil];
|
|
213
|
+
}
|
|
214
|
+
const oldSyms = extractUtilitySymbols(utilities[prevOwnedUtil]?.code || "");
|
|
215
|
+
for (const s of oldSyms) {
|
|
216
|
+
if (utilSymbolOwners[s] === prevOwnedUtil) delete utilSymbolOwners[s];
|
|
217
|
+
}
|
|
218
|
+
if (utilities[prevOwnedUtil]) {
|
|
219
|
+
delete utilities[prevOwnedUtil];
|
|
220
|
+
scheduleRebuildUsing(prevOwnedUtil);
|
|
221
|
+
for (const s of oldSyms) scheduleRebuildUsing(s);
|
|
222
|
+
}
|
|
223
|
+
delete nodeNames[node.id];
|
|
224
|
+
}
|
|
225
|
+
|
|
125
226
|
const existingOwner = compNameOwners[utilName];
|
|
126
227
|
if (existingOwner && existingOwner !== node.id) {
|
|
127
228
|
node.error(
|
|
@@ -138,6 +239,7 @@ function registerRegistryNodes(RED, deps) {
|
|
|
138
239
|
return;
|
|
139
240
|
}
|
|
140
241
|
compNameOwners[utilName] = node.id;
|
|
242
|
+
nodeNames[node.id] = utilName;
|
|
141
243
|
|
|
142
244
|
const newCode = config.utilCode || "";
|
|
143
245
|
const prevCode = utilities[utilName]?.code;
|
|
@@ -202,17 +304,24 @@ function registerRegistryNodes(RED, deps) {
|
|
|
202
304
|
for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
|
|
203
305
|
}
|
|
204
306
|
|
|
205
|
-
node.on("close", function (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
307
|
+
node.on("close", function (removed, done) {
|
|
308
|
+
// Plain redeploy (removed=false): keep the utility entry + symbol
|
|
309
|
+
// ownership so an unchanged deploy stays a no-op (see component close).
|
|
310
|
+
if (removed) {
|
|
311
|
+
if (compNameOwners[utilName] === node.id) {
|
|
312
|
+
delete compNameOwners[utilName];
|
|
313
|
+
}
|
|
314
|
+
if (nodeNames[node.id] === utilName) {
|
|
315
|
+
delete nodeNames[node.id];
|
|
316
|
+
}
|
|
317
|
+
for (const s of Object.keys(utilSymbolOwners)) {
|
|
318
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
319
|
+
}
|
|
320
|
+
const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
|
|
321
|
+
delete utilities[utilName];
|
|
322
|
+
scheduleRebuildUsing(utilName);
|
|
323
|
+
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
211
324
|
}
|
|
212
|
-
const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
|
|
213
|
-
delete utilities[utilName];
|
|
214
|
-
scheduleRebuildUsing(utilName);
|
|
215
|
-
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
216
325
|
done();
|
|
217
326
|
});
|
|
218
327
|
}
|
package/nodes/portal-react.js
CHANGED
|
@@ -95,6 +95,9 @@
|
|
|
95
95
|
*/
|
|
96
96
|
|
|
97
97
|
const crypto = require("crypto");
|
|
98
|
+
const packageInfo = require("../package.json");
|
|
99
|
+
|
|
100
|
+
const CACHE_SCHEMA_VERSION = "portal-react-cache-v2";
|
|
98
101
|
|
|
99
102
|
module.exports = function (RED) {
|
|
100
103
|
// ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
|
|
@@ -287,6 +290,16 @@ module.exports = function (RED) {
|
|
|
287
290
|
}
|
|
288
291
|
const endpointOwners = RED.settings.portalReactEndpointOwners;
|
|
289
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 = {};
|
|
300
|
+
}
|
|
301
|
+
const nodeEndpoints = RED.settings.portalReactNodeEndpoints;
|
|
302
|
+
|
|
290
303
|
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
291
304
|
// Shared namespace with fc-portal-utility nodes so a component and a utility
|
|
292
305
|
// can never share the same name.
|
|
@@ -437,7 +450,9 @@ module.exports = function (RED) {
|
|
|
437
450
|
if (
|
|
438
451
|
(usedComps && usedComps.has(name)) ||
|
|
439
452
|
(usedUtils && usedUtils.has(name)) ||
|
|
440
|
-
|
|
453
|
+
// Identifier-boundary match, not substring — a dirty `Button`
|
|
454
|
+
// must not rebuild portals that only use `ButtonGroup`.
|
|
455
|
+
identifierRe(name).test(raw)
|
|
441
456
|
) {
|
|
442
457
|
targetIds.add(nodeId);
|
|
443
458
|
break;
|
|
@@ -449,14 +464,15 @@ module.exports = function (RED) {
|
|
|
449
464
|
const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
|
|
450
465
|
let i = 0;
|
|
451
466
|
/**
|
|
452
|
-
* Trampoline over the rebuild list —
|
|
467
|
+
* Trampoline over the rebuild list — awaits each (async) rebuild so
|
|
468
|
+
* builds run sequentially, and yields to the event loop between
|
|
453
469
|
* iterations so a heavy queue does not block the HTTP server.
|
|
454
|
-
* @returns {void}
|
|
470
|
+
* @returns {Promise<void>}
|
|
455
471
|
* @private
|
|
456
472
|
*/
|
|
457
|
-
function next() {
|
|
473
|
+
async function next() {
|
|
458
474
|
if (i >= fns.length) return;
|
|
459
|
-
try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
475
|
+
try { await fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
460
476
|
i++;
|
|
461
477
|
if (i < fns.length) setImmediate(next);
|
|
462
478
|
}
|
|
@@ -477,6 +493,7 @@ module.exports = function (RED) {
|
|
|
477
493
|
isSafeName,
|
|
478
494
|
validateSubPath,
|
|
479
495
|
findMissingComponentRefs,
|
|
496
|
+
identifierRe,
|
|
480
497
|
userDir,
|
|
481
498
|
readCachedJS,
|
|
482
499
|
writeCachedJS,
|
|
@@ -594,6 +611,25 @@ module.exports = function (RED) {
|
|
|
594
611
|
}
|
|
595
612
|
endpointOwners[endpoint] = nodeId;
|
|
596
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;
|
|
632
|
+
|
|
597
633
|
// State
|
|
598
634
|
const clients = new Map(); // portalClient → ws
|
|
599
635
|
const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
|
|
@@ -696,11 +732,11 @@ module.exports = function (RED) {
|
|
|
696
732
|
* Errors set `pageState[endpoint].compiled.error` and the route handler
|
|
697
733
|
* serves an error page (or the previous lastGood build with a banner).
|
|
698
734
|
*
|
|
699
|
-
* @returns {void}
|
|
735
|
+
* @returns {Promise<void>}
|
|
700
736
|
* @throws never — internal exceptions are caught and stored in pageState.
|
|
701
737
|
* @private
|
|
702
738
|
*/
|
|
703
|
-
function rebuild() {
|
|
739
|
+
async function rebuild() {
|
|
704
740
|
try {
|
|
705
741
|
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
706
742
|
const prevState = pageState[endpoint];
|
|
@@ -718,27 +754,11 @@ module.exports = function (RED) {
|
|
|
718
754
|
const allEntries = Object.entries(registry);
|
|
719
755
|
const needed = new Set();
|
|
720
756
|
|
|
721
|
-
// Word-boundary identifier match. Avoids prefix collisions (e.g. a
|
|
722
|
-
// `Button` rebuild marking `ButtonGroup` users as needing rebuild)
|
|
723
|
-
// and matches identifiers, not substrings inside other words.
|
|
724
|
-
// Regexes are cached per name so we don't pay the constructor cost
|
|
725
|
-
// on every recursion of addWithDeps.
|
|
726
|
-
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
727
|
-
const nameReCache = new Map();
|
|
728
|
-
const refRe = (n) => {
|
|
729
|
-
let r = nameReCache.get(n);
|
|
730
|
-
if (!r) {
|
|
731
|
-
r = new RegExp(`\\b${escapeRe(n)}\\b`);
|
|
732
|
-
nameReCache.set(n, r);
|
|
733
|
-
}
|
|
734
|
-
return r;
|
|
735
|
-
};
|
|
736
|
-
|
|
737
757
|
/**
|
|
738
758
|
* Depth-first walk that pulls a component and every transitively
|
|
739
|
-
* referenced sibling into `needed`. Matching uses
|
|
740
|
-
* (cached) so a component named `Button` does
|
|
741
|
-
* `ButtonGroup`.
|
|
759
|
+
* referenced sibling into `needed`. Matching uses `identifierRe`
|
|
760
|
+
* (cached, identifier-boundary) so a component named `Button` does
|
|
761
|
+
* not pull in `ButtonGroup`.
|
|
742
762
|
* @param {string} name
|
|
743
763
|
* @returns {void}
|
|
744
764
|
* @private
|
|
@@ -749,14 +769,14 @@ module.exports = function (RED) {
|
|
|
749
769
|
if (!entry) return;
|
|
750
770
|
needed.add(name);
|
|
751
771
|
for (const [other] of allEntries) {
|
|
752
|
-
if (other !== name &&
|
|
772
|
+
if (other !== name && identifierRe(other).test(entry.code)) {
|
|
753
773
|
addWithDeps(other);
|
|
754
774
|
}
|
|
755
775
|
}
|
|
756
776
|
}
|
|
757
777
|
|
|
758
778
|
for (const [name] of allEntries) {
|
|
759
|
-
if (
|
|
779
|
+
if (identifierRe(name).test(componentCode)) {
|
|
760
780
|
addWithDeps(name);
|
|
761
781
|
}
|
|
762
782
|
}
|
|
@@ -777,10 +797,11 @@ module.exports = function (RED) {
|
|
|
777
797
|
const includedLibraryCode = [...needed]
|
|
778
798
|
.map((n) => registry[n]?.code || "")
|
|
779
799
|
.join("\n");
|
|
800
|
+
// identifierRe escapes regex metacharacters and handles `$`-edged
|
|
801
|
+
// names — symbols come from user code, so both matter here.
|
|
780
802
|
const referencesAnySymbol = (text, syms) => {
|
|
781
803
|
for (const s of syms) {
|
|
782
|
-
|
|
783
|
-
if (re.test(text)) return true;
|
|
804
|
+
if (identifierRe(s).test(text)) return true;
|
|
784
805
|
}
|
|
785
806
|
return false;
|
|
786
807
|
};
|
|
@@ -812,19 +833,44 @@ module.exports = function (RED) {
|
|
|
812
833
|
}
|
|
813
834
|
portalNeededUtilities[nodeId] = new Set(neededUtils);
|
|
814
835
|
|
|
815
|
-
// Topological sort
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
const
|
|
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
|
|
825
871
|
.map(
|
|
826
|
-
(
|
|
827
|
-
`// Library: ${name}\nconst ${name} = (() => {\n${
|
|
872
|
+
(name) =>
|
|
873
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${registry[name].code}\nreturn ${name};\n})();`,
|
|
828
874
|
)
|
|
829
875
|
.join("\n\n");
|
|
830
876
|
|
|
@@ -943,7 +989,9 @@ module.exports = function (RED) {
|
|
|
943
989
|
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
944
990
|
].join("\n");
|
|
945
991
|
|
|
946
|
-
const jsxHash = hash(
|
|
992
|
+
const jsxHash = hash(
|
|
993
|
+
[CACHE_SCHEMA_VERSION, packageInfo.version, fullJsx].join("\0"),
|
|
994
|
+
);
|
|
947
995
|
|
|
948
996
|
// ── Check: any used component or utility has its own syntax error ──
|
|
949
997
|
let errorSource = null;
|
|
@@ -1036,7 +1084,12 @@ module.exports = function (RED) {
|
|
|
1036
1084
|
compiled = readCachedJS(jsxHash);
|
|
1037
1085
|
cacheHit = !!compiled;
|
|
1038
1086
|
if (!compiled) {
|
|
1039
|
-
compiled = transpile(fullJsx);
|
|
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;
|
|
1040
1093
|
if (!compiled.error) {
|
|
1041
1094
|
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
1042
1095
|
}
|
|
@@ -1097,9 +1150,13 @@ module.exports = function (RED) {
|
|
|
1097
1150
|
cssHash: prevState.cssHash,
|
|
1098
1151
|
});
|
|
1099
1152
|
}
|
|
1100
|
-
|
|
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 }) => {
|
|
1101
1158
|
writeCachedCSS(jsxHash, css);
|
|
1102
|
-
return { css, cssHash };
|
|
1159
|
+
return { css, cssHash: jsxHash };
|
|
1103
1160
|
});
|
|
1104
1161
|
})().catch((err) => {
|
|
1105
1162
|
// CSS generation failed (Tailwind compile error, missing
|
|
@@ -1118,10 +1175,19 @@ module.exports = function (RED) {
|
|
|
1118
1175
|
lastJsxHash = jsxHash;
|
|
1119
1176
|
|
|
1120
1177
|
// Preserve last successful build so that on transpile errors we keep
|
|
1121
|
-
// serving the previous working JS instead of throwing clients to an
|
|
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.
|
|
1122
1182
|
const lastGood = compiled.error
|
|
1123
1183
|
? prevState?.lastGood || null
|
|
1124
|
-
:
|
|
1184
|
+
: {
|
|
1185
|
+
compiledJs: compiled.js,
|
|
1186
|
+
contentHash,
|
|
1187
|
+
cssHash: "",
|
|
1188
|
+
pageTitle,
|
|
1189
|
+
customHead,
|
|
1190
|
+
};
|
|
1125
1191
|
|
|
1126
1192
|
pageState[endpoint] = {
|
|
1127
1193
|
compiled,
|
|
@@ -1490,7 +1556,9 @@ module.exports = function (RED) {
|
|
|
1490
1556
|
}
|
|
1491
1557
|
lastBroadcastCache.set(endpoint, cached);
|
|
1492
1558
|
}
|
|
1493
|
-
updateStatus()
|
|
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.
|
|
1494
1562
|
done();
|
|
1495
1563
|
} catch (err) {
|
|
1496
1564
|
// Catch-node propagation: done(err) lets the runtime route the
|
|
@@ -1576,6 +1644,7 @@ module.exports = function (RED) {
|
|
|
1576
1644
|
if (removed) {
|
|
1577
1645
|
lastBroadcastCache.delete(endpoint);
|
|
1578
1646
|
delete portalSig[nodeId];
|
|
1647
|
+
delete nodeEndpoints[nodeId];
|
|
1579
1648
|
}
|
|
1580
1649
|
|
|
1581
1650
|
// Clear the userIndex — WS clients are already closed above, but
|