@aaqu/fromcubes-portal-react 0.1.0-alpha.27 → 0.1.0-alpha.29
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 +84 -12
- package/nodes/lib/portal-page-route.js +9 -4
- package/nodes/lib/registry-nodes.js +126 -17
- package/nodes/portal-react.js +112 -48
- 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
|
*
|
|
@@ -401,6 +427,44 @@ function hasFreshBuild(state) {
|
|
|
401
427
|
return !!state && !state.building && !!state.compiled && !state.compiled.error;
|
|
402
428
|
}
|
|
403
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
|
+
|
|
404
468
|
module.exports = function (RED) {
|
|
405
469
|
return createHelpers(RED);
|
|
406
470
|
};
|
|
@@ -411,8 +475,10 @@ module.exports.quickCheckSyntax = quickCheckSyntax;
|
|
|
411
475
|
module.exports.formatEsbuildError = formatEsbuildError;
|
|
412
476
|
module.exports.extractPortalUser = extractPortalUser;
|
|
413
477
|
module.exports.findMissingComponentRefs = findMissingComponentRefs;
|
|
478
|
+
module.exports.identifierRe = identifierRe;
|
|
414
479
|
module.exports.serveableHash = serveableHash;
|
|
415
480
|
module.exports.hasFreshBuild = hasFreshBuild;
|
|
481
|
+
module.exports.shouldSkipInstall = shouldSkipInstall;
|
|
416
482
|
module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
|
|
417
483
|
module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
|
|
418
484
|
|
|
@@ -434,20 +500,22 @@ function createHelpers(RED) {
|
|
|
434
500
|
* `preInstall` hook (Node-RED 1.3+) — vetoes an npm install when the
|
|
435
501
|
* package is already on disk. Useful for offline/Docker setups where
|
|
436
502
|
* Node-RED's auto-install pass would otherwise try to hit the registry
|
|
437
|
-
* and fail.
|
|
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
|
|
438
507
|
* inside the body MUST NOT bubble up (it would cancel an install path
|
|
439
508
|
* the user actually expected to run). We log at `trace` level so the
|
|
440
509
|
* developer can opt into diagnostics via `logging.level = trace` in
|
|
441
510
|
* `settings.js`, without spamming default-level logs.
|
|
442
511
|
*
|
|
443
|
-
* @param {{dir: string, module: string}} event
|
|
512
|
+
* @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
|
|
444
513
|
* @returns {boolean|void} `false` skips the install; anything else proceeds.
|
|
445
514
|
* @listens RED.hooks#preInstall.portalReact
|
|
446
515
|
*/
|
|
447
516
|
RED.hooks.add("preInstall.portalReact", (event) => {
|
|
448
517
|
try {
|
|
449
|
-
|
|
450
|
-
if (fs.existsSync(modDir)) {
|
|
518
|
+
if (shouldSkipInstall(event)) {
|
|
451
519
|
RED.log.info(
|
|
452
520
|
`[portal-react] ${event.module} already in node_modules, skipping install`,
|
|
453
521
|
);
|
|
@@ -575,21 +643,24 @@ function createHelpers(RED) {
|
|
|
575
643
|
|
|
576
644
|
/**
|
|
577
645
|
* Bundle the user JSX (with utility/library/import code already concatenated)
|
|
578
|
-
* into a minified IIFE. Pre-validates with `quickCheckSyntax` first
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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.
|
|
582
652
|
*
|
|
583
653
|
* @param {string} jsx
|
|
584
|
-
* @returns {TranspileResult}
|
|
654
|
+
* @returns {Promise<TranspileResult>}
|
|
585
655
|
*/
|
|
586
|
-
function transpile(jsx) {
|
|
587
|
-
// Pre-validate with transformSync (fast, no bundling)
|
|
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
|
|
588
659
|
const syntaxErr = quickCheckSyntax(jsx);
|
|
589
660
|
if (syntaxErr) return { js: null, error: syntaxErr };
|
|
590
661
|
// Syntax OK — bundle with full resolution
|
|
591
662
|
try {
|
|
592
|
-
const buildResult = esbuild.
|
|
663
|
+
const buildResult = await esbuild.build({
|
|
593
664
|
stdin: {
|
|
594
665
|
contents: jsx,
|
|
595
666
|
resolveDir: pkgRoot,
|
|
@@ -639,6 +710,7 @@ function createHelpers(RED) {
|
|
|
639
710
|
isSafeName,
|
|
640
711
|
validateSubPath,
|
|
641
712
|
findMissingComponentRefs,
|
|
713
|
+
identifierRe,
|
|
642
714
|
pkgRoot,
|
|
643
715
|
userDir,
|
|
644
716
|
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
|
@@ -290,6 +290,16 @@ module.exports = function (RED) {
|
|
|
290
290
|
}
|
|
291
291
|
const endpointOwners = RED.settings.portalReactEndpointOwners;
|
|
292
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
|
+
|
|
293
303
|
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
294
304
|
// Shared namespace with fc-portal-utility nodes so a component and a utility
|
|
295
305
|
// can never share the same name.
|
|
@@ -440,7 +450,9 @@ module.exports = function (RED) {
|
|
|
440
450
|
if (
|
|
441
451
|
(usedComps && usedComps.has(name)) ||
|
|
442
452
|
(usedUtils && usedUtils.has(name)) ||
|
|
443
|
-
|
|
453
|
+
// Identifier-boundary match, not substring — a dirty `Button`
|
|
454
|
+
// must not rebuild portals that only use `ButtonGroup`.
|
|
455
|
+
identifierRe(name).test(raw)
|
|
444
456
|
) {
|
|
445
457
|
targetIds.add(nodeId);
|
|
446
458
|
break;
|
|
@@ -452,14 +464,15 @@ module.exports = function (RED) {
|
|
|
452
464
|
const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
|
|
453
465
|
let i = 0;
|
|
454
466
|
/**
|
|
455
|
-
* 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
|
|
456
469
|
* iterations so a heavy queue does not block the HTTP server.
|
|
457
|
-
* @returns {void}
|
|
470
|
+
* @returns {Promise<void>}
|
|
458
471
|
* @private
|
|
459
472
|
*/
|
|
460
|
-
function next() {
|
|
473
|
+
async function next() {
|
|
461
474
|
if (i >= fns.length) return;
|
|
462
|
-
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); }
|
|
463
476
|
i++;
|
|
464
477
|
if (i < fns.length) setImmediate(next);
|
|
465
478
|
}
|
|
@@ -480,6 +493,7 @@ module.exports = function (RED) {
|
|
|
480
493
|
isSafeName,
|
|
481
494
|
validateSubPath,
|
|
482
495
|
findMissingComponentRefs,
|
|
496
|
+
identifierRe,
|
|
483
497
|
userDir,
|
|
484
498
|
readCachedJS,
|
|
485
499
|
writeCachedJS,
|
|
@@ -597,6 +611,25 @@ module.exports = function (RED) {
|
|
|
597
611
|
}
|
|
598
612
|
endpointOwners[endpoint] = nodeId;
|
|
599
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
|
+
|
|
600
633
|
// State
|
|
601
634
|
const clients = new Map(); // portalClient → ws
|
|
602
635
|
const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
|
|
@@ -699,11 +732,11 @@ module.exports = function (RED) {
|
|
|
699
732
|
* Errors set `pageState[endpoint].compiled.error` and the route handler
|
|
700
733
|
* serves an error page (or the previous lastGood build with a banner).
|
|
701
734
|
*
|
|
702
|
-
* @returns {void}
|
|
735
|
+
* @returns {Promise<void>}
|
|
703
736
|
* @throws never — internal exceptions are caught and stored in pageState.
|
|
704
737
|
* @private
|
|
705
738
|
*/
|
|
706
|
-
function rebuild() {
|
|
739
|
+
async function rebuild() {
|
|
707
740
|
try {
|
|
708
741
|
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
709
742
|
const prevState = pageState[endpoint];
|
|
@@ -721,27 +754,11 @@ module.exports = function (RED) {
|
|
|
721
754
|
const allEntries = Object.entries(registry);
|
|
722
755
|
const needed = new Set();
|
|
723
756
|
|
|
724
|
-
// Word-boundary identifier match. Avoids prefix collisions (e.g. a
|
|
725
|
-
// `Button` rebuild marking `ButtonGroup` users as needing rebuild)
|
|
726
|
-
// and matches identifiers, not substrings inside other words.
|
|
727
|
-
// Regexes are cached per name so we don't pay the constructor cost
|
|
728
|
-
// on every recursion of addWithDeps.
|
|
729
|
-
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
730
|
-
const nameReCache = new Map();
|
|
731
|
-
const refRe = (n) => {
|
|
732
|
-
let r = nameReCache.get(n);
|
|
733
|
-
if (!r) {
|
|
734
|
-
r = new RegExp(`\\b${escapeRe(n)}\\b`);
|
|
735
|
-
nameReCache.set(n, r);
|
|
736
|
-
}
|
|
737
|
-
return r;
|
|
738
|
-
};
|
|
739
|
-
|
|
740
757
|
/**
|
|
741
758
|
* Depth-first walk that pulls a component and every transitively
|
|
742
|
-
* referenced sibling into `needed`. Matching uses
|
|
743
|
-
* (cached) so a component named `Button` does
|
|
744
|
-
* `ButtonGroup`.
|
|
759
|
+
* referenced sibling into `needed`. Matching uses `identifierRe`
|
|
760
|
+
* (cached, identifier-boundary) so a component named `Button` does
|
|
761
|
+
* not pull in `ButtonGroup`.
|
|
745
762
|
* @param {string} name
|
|
746
763
|
* @returns {void}
|
|
747
764
|
* @private
|
|
@@ -752,14 +769,14 @@ module.exports = function (RED) {
|
|
|
752
769
|
if (!entry) return;
|
|
753
770
|
needed.add(name);
|
|
754
771
|
for (const [other] of allEntries) {
|
|
755
|
-
if (other !== name &&
|
|
772
|
+
if (other !== name && identifierRe(other).test(entry.code)) {
|
|
756
773
|
addWithDeps(other);
|
|
757
774
|
}
|
|
758
775
|
}
|
|
759
776
|
}
|
|
760
777
|
|
|
761
778
|
for (const [name] of allEntries) {
|
|
762
|
-
if (
|
|
779
|
+
if (identifierRe(name).test(componentCode)) {
|
|
763
780
|
addWithDeps(name);
|
|
764
781
|
}
|
|
765
782
|
}
|
|
@@ -780,10 +797,11 @@ module.exports = function (RED) {
|
|
|
780
797
|
const includedLibraryCode = [...needed]
|
|
781
798
|
.map((n) => registry[n]?.code || "")
|
|
782
799
|
.join("\n");
|
|
800
|
+
// identifierRe escapes regex metacharacters and handles `$`-edged
|
|
801
|
+
// names — symbols come from user code, so both matter here.
|
|
783
802
|
const referencesAnySymbol = (text, syms) => {
|
|
784
803
|
for (const s of syms) {
|
|
785
|
-
|
|
786
|
-
if (re.test(text)) return true;
|
|
804
|
+
if (identifierRe(s).test(text)) return true;
|
|
787
805
|
}
|
|
788
806
|
return false;
|
|
789
807
|
};
|
|
@@ -815,19 +833,44 @@ module.exports = function (RED) {
|
|
|
815
833
|
}
|
|
816
834
|
portalNeededUtilities[nodeId] = new Set(neededUtils);
|
|
817
835
|
|
|
818
|
-
// Topological sort
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
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
|
|
828
871
|
.map(
|
|
829
|
-
(
|
|
830
|
-
`// Library: ${name}\nconst ${name} = (() => {\n${
|
|
872
|
+
(name) =>
|
|
873
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${registry[name].code}\nreturn ${name};\n})();`,
|
|
831
874
|
)
|
|
832
875
|
.join("\n\n");
|
|
833
876
|
|
|
@@ -1041,7 +1084,12 @@ module.exports = function (RED) {
|
|
|
1041
1084
|
compiled = readCachedJS(jsxHash);
|
|
1042
1085
|
cacheHit = !!compiled;
|
|
1043
1086
|
if (!compiled) {
|
|
1044
|
-
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;
|
|
1045
1093
|
if (!compiled.error) {
|
|
1046
1094
|
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
1047
1095
|
}
|
|
@@ -1102,9 +1150,13 @@ module.exports = function (RED) {
|
|
|
1102
1150
|
cssHash: prevState.cssHash,
|
|
1103
1151
|
});
|
|
1104
1152
|
}
|
|
1105
|
-
|
|
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 }) => {
|
|
1106
1158
|
writeCachedCSS(jsxHash, css);
|
|
1107
|
-
return { css, cssHash };
|
|
1159
|
+
return { css, cssHash: jsxHash };
|
|
1108
1160
|
});
|
|
1109
1161
|
})().catch((err) => {
|
|
1110
1162
|
// CSS generation failed (Tailwind compile error, missing
|
|
@@ -1123,10 +1175,19 @@ module.exports = function (RED) {
|
|
|
1123
1175
|
lastJsxHash = jsxHash;
|
|
1124
1176
|
|
|
1125
1177
|
// Preserve last successful build so that on transpile errors we keep
|
|
1126
|
-
// 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.
|
|
1127
1182
|
const lastGood = compiled.error
|
|
1128
1183
|
? prevState?.lastGood || null
|
|
1129
|
-
:
|
|
1184
|
+
: {
|
|
1185
|
+
compiledJs: compiled.js,
|
|
1186
|
+
contentHash,
|
|
1187
|
+
cssHash: "",
|
|
1188
|
+
pageTitle,
|
|
1189
|
+
customHead,
|
|
1190
|
+
};
|
|
1130
1191
|
|
|
1131
1192
|
pageState[endpoint] = {
|
|
1132
1193
|
compiled,
|
|
@@ -1495,7 +1556,9 @@ module.exports = function (RED) {
|
|
|
1495
1556
|
}
|
|
1496
1557
|
lastBroadcastCache.set(endpoint, cached);
|
|
1497
1558
|
}
|
|
1498
|
-
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.
|
|
1499
1562
|
done();
|
|
1500
1563
|
} catch (err) {
|
|
1501
1564
|
// Catch-node propagation: done(err) lets the runtime route the
|
|
@@ -1581,6 +1644,7 @@ module.exports = function (RED) {
|
|
|
1581
1644
|
if (removed) {
|
|
1582
1645
|
lastBroadcastCache.delete(endpoint);
|
|
1583
1646
|
delete portalSig[nodeId];
|
|
1647
|
+
delete nodeEndpoints[nodeId];
|
|
1584
1648
|
}
|
|
1585
1649
|
|
|
1586
1650
|
// Clear the userIndex — WS clients are already closed above, but
|