@ecopages/react 0.2.0-alpha.53 → 0.2.0-alpha.54
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/package.json +3 -4
- package/src/react-hmr-strategy.d.ts +26 -1
- package/src/react-hmr-strategy.js +91 -32
- package/src/react.plugin.d.ts +1 -0
- package/src/react.plugin.js +5 -1
- package/src/services/pages-index.d.ts +64 -0
- package/src/services/pages-index.js +73 -0
- package/src/services/react-bundle.service.d.ts +3 -3
- package/src/services/react-bundle.service.js +3 -3
- package/src/services/react-hmr-page-metadata-cache.d.ts +1 -1
- package/src/services/react-page-module.service.d.ts +1 -1
- package/src/services/react-page-module.service.js +4 -1
- package/src/services/react-runtime-bundle.service.js +29 -17
- package/src/utils/client-graph-boundary-cache.d.ts +108 -0
- package/src/utils/client-graph-boundary-cache.js +116 -0
- package/src/utils/client-graph-boundary-plugin.d.ts +12 -4
- package/src/utils/client-graph-boundary-plugin.js +61 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.54",
|
|
4
4
|
"description": "React integration for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -69,16 +69,15 @@
|
|
|
69
69
|
"directory": "packages/integrations/react"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
72
|
+
"@ecopages/core": "0.2.0-alpha.54",
|
|
73
73
|
"@types/react": "^19",
|
|
74
74
|
"@types/react-dom": "^19",
|
|
75
75
|
"react": "^19",
|
|
76
76
|
"react-dom": "^19"
|
|
77
77
|
},
|
|
78
78
|
"dependencies": {
|
|
79
|
-
"@ecopages/file-system": "0.2.0-alpha.
|
|
79
|
+
"@ecopages/file-system": "0.2.0-alpha.54",
|
|
80
80
|
"@ecopages/logger": "^0.2.3",
|
|
81
|
-
"@mdx-js/esbuild": "^3.1.1",
|
|
82
81
|
"@mdx-js/mdx": "^3.1.1",
|
|
83
82
|
"oxc-parser": "^0.124.0",
|
|
84
83
|
"oxc-transform": "^0.124.0",
|
|
@@ -10,6 +10,7 @@ import { HmrStrategy, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
|
|
|
10
10
|
import type { BrowserRuntimeManifest } from '@ecopages/core/build/browser-runtime-manifest';
|
|
11
11
|
import type { DefaultHmrContext } from '@ecopages/core';
|
|
12
12
|
import type { CompileOptions } from '@mdx-js/mdx';
|
|
13
|
+
import { ClientGraphBoundaryCache } from './utils/client-graph-boundary-cache.js';
|
|
13
14
|
import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
|
|
14
15
|
export interface ReactHmrStrategyOptions {
|
|
15
16
|
context: DefaultHmrContext;
|
|
@@ -19,6 +20,13 @@ export interface ReactHmrStrategyOptions {
|
|
|
19
20
|
ownedTemplateExtensions?: string[];
|
|
20
21
|
allTemplateExtensions?: string[];
|
|
21
22
|
explicitGraphEnabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Per-app cache for client-graph-boundary transform results. Owned by
|
|
25
|
+
* the React plugin for the app's lifetime. When omitted, the strategy
|
|
26
|
+
* uses a fresh in-memory cache that does not persist across HMR
|
|
27
|
+
* rebuilds.
|
|
28
|
+
*/
|
|
29
|
+
clientGraphBoundaryCache?: ClientGraphBoundaryCache;
|
|
22
30
|
}
|
|
23
31
|
/**
|
|
24
32
|
* Strategy for handling React component HMR updates.
|
|
@@ -76,6 +84,8 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
76
84
|
private pageMetadataCache;
|
|
77
85
|
private explicitGraphEnabled;
|
|
78
86
|
private readonly runtimeManifest;
|
|
87
|
+
private readonly clientGraphBoundaryCache;
|
|
88
|
+
private readonly pagesIndex;
|
|
79
89
|
constructor(options: ReactHmrStrategyOptions);
|
|
80
90
|
/**
|
|
81
91
|
* Returns build plugins for React HMR bundling.
|
|
@@ -131,7 +141,8 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
131
141
|
private isLayoutFile;
|
|
132
142
|
private isPageEntrypoint;
|
|
133
143
|
private getEntrypointOutput;
|
|
134
|
-
private
|
|
144
|
+
private getRolldownEntryKey;
|
|
145
|
+
private getTempFileBasename;
|
|
135
146
|
private collectReactPageBuildTargets;
|
|
136
147
|
/**
|
|
137
148
|
* Expands one HMR request into the full React page build cohort when needed.
|
|
@@ -185,6 +196,20 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
185
196
|
*/
|
|
186
197
|
private bundleReactEntrypoints;
|
|
187
198
|
private resolveTempOutputPath;
|
|
199
|
+
/**
|
|
200
|
+
* Clears stale HMR output from a directory before a rebuild.
|
|
201
|
+
*
|
|
202
|
+
* Only removes:
|
|
203
|
+
* - `*.tmp.js` files (the per-build bundler output the strategy owns)
|
|
204
|
+
* - the `chunks/` subdirectory (the bundler's splitting target)
|
|
205
|
+
*
|
|
206
|
+
* The HMR runtime script (`_hmr_runtime.js`) and any user-authored
|
|
207
|
+
* assets in the outdir are preserved. This is the minimal set of
|
|
208
|
+
* files that, if left from a previous build, can cause the bundler
|
|
209
|
+
* to emit `ENOENT` for chunk references that point to entrypoints
|
|
210
|
+
* whose hash has since changed.
|
|
211
|
+
*/
|
|
212
|
+
private clearHmrOutdir;
|
|
188
213
|
/**
|
|
189
214
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
190
215
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
|
|
3
3
|
import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
|
|
4
|
-
import {
|
|
4
|
+
import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
|
|
5
5
|
import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
|
|
6
6
|
import { Logger } from "@ecopages/logger";
|
|
7
7
|
import { injectHmrHandler } from "./utils/hmr-scripts.js";
|
|
8
8
|
import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-plugin.js";
|
|
9
|
+
import { ClientGraphBoundaryCache } from "./utils/client-graph-boundary-cache.js";
|
|
9
10
|
import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
|
|
10
11
|
import { someInConfigTree } from "./utils/component-config-traversal.js";
|
|
11
12
|
import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
|
|
12
13
|
import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-alias-map.js";
|
|
14
|
+
import { PagesIndex } from "./services/pages-index.js";
|
|
13
15
|
const appLogger = new Logger("[ReactHmrStrategy]");
|
|
14
16
|
class ReactHmrStrategy extends HmrStrategy {
|
|
15
17
|
type = HmrStrategyType.INTEGRATION;
|
|
@@ -28,12 +30,20 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
28
30
|
pageMetadataCache;
|
|
29
31
|
explicitGraphEnabled;
|
|
30
32
|
runtimeManifest;
|
|
33
|
+
clientGraphBoundaryCache;
|
|
34
|
+
pagesIndex;
|
|
31
35
|
constructor(options) {
|
|
32
36
|
super();
|
|
33
37
|
this.context = options.context;
|
|
34
38
|
this.pageMetadataCache = options.pageMetadataCache;
|
|
35
39
|
this.runtimeManifest = options.runtimeManifest;
|
|
36
40
|
this.explicitGraphEnabled = options.explicitGraphEnabled ?? false;
|
|
41
|
+
this.clientGraphBoundaryCache = options.clientGraphBoundaryCache ?? new ClientGraphBoundaryCache();
|
|
42
|
+
this.pagesIndex = new PagesIndex({
|
|
43
|
+
pagesDir: this.context.getPagesDir(),
|
|
44
|
+
extensions: options.allTemplateExtensions,
|
|
45
|
+
isPageEntrypoint: (file) => this.isPageEntrypoint(file)
|
|
46
|
+
});
|
|
37
47
|
this.mdxCompilerOptions = options.mdxCompilerOptions;
|
|
38
48
|
this.ownedTemplateExtensions = new Set(options.ownedTemplateExtensions ?? [".tsx"]);
|
|
39
49
|
this.allTemplateExtensions = [...options.allTemplateExtensions ?? [".tsx"]].sort(
|
|
@@ -54,7 +64,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
54
64
|
const allowSpecifiers = getReactClientGraphAllowSpecifiers(
|
|
55
65
|
this.runtimeManifest.assets.map((asset) => asset.specifier)
|
|
56
66
|
);
|
|
57
|
-
const runtimeRewritePlugin =
|
|
67
|
+
const runtimeRewritePlugin = createBrowserRuntimePlugin({
|
|
58
68
|
name: "react-hmr-runtime-import-rewrite",
|
|
59
69
|
manifest: this.runtimeManifest
|
|
60
70
|
});
|
|
@@ -62,7 +72,8 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
62
72
|
createClientGraphBoundaryPlugin({
|
|
63
73
|
absWorkingDir: path.dirname(this.context.getSrcDir()),
|
|
64
74
|
alwaysAllowSpecifiers: allowSpecifiers,
|
|
65
|
-
declaredModules
|
|
75
|
+
declaredModules,
|
|
76
|
+
cache: this.clientGraphBoundaryCache
|
|
66
77
|
}),
|
|
67
78
|
...runtimeRewritePlugin ? [runtimeRewritePlugin] : [],
|
|
68
79
|
...this.context.getPlugins()
|
|
@@ -189,30 +200,24 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
189
200
|
const outputUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", encodedPathJs).split(path.sep).join("/")}`;
|
|
190
201
|
return { outputPath, outputUrl };
|
|
191
202
|
}
|
|
192
|
-
|
|
203
|
+
getRolldownEntryKey(entrypointPath) {
|
|
193
204
|
const srcDir = this.context.getSrcDir();
|
|
194
205
|
const relativePath = path.relative(srcDir, entrypointPath);
|
|
195
|
-
const
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
const relativePathNoExt = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, "");
|
|
207
|
+
return relativePathNoExt;
|
|
208
|
+
}
|
|
209
|
+
getTempFileBasename(entrypointPath) {
|
|
210
|
+
const srcDir = this.context.getSrcDir();
|
|
211
|
+
const relativePath = path.relative(srcDir, entrypointPath);
|
|
212
|
+
const relativePathNoExt = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, "");
|
|
213
|
+
const encodedPath = this.encodeDynamicSegments(relativePathNoExt);
|
|
214
|
+
return path.basename(encodedPath);
|
|
200
215
|
}
|
|
201
216
|
async collectReactPageBuildTargets() {
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
this.allTemplateExtensions.map((extension) => `**/*${extension}`),
|
|
205
|
-
{ cwd: pagesDir }
|
|
206
|
-
);
|
|
217
|
+
await this.pagesIndex.refresh();
|
|
218
|
+
const indexed = this.pagesIndex.list();
|
|
207
219
|
const targets = /* @__PURE__ */ new Map();
|
|
208
|
-
for (const
|
|
209
|
-
if (file.includes(".ecopages-node.")) {
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
const entrypointPath = path.join(pagesDir, file);
|
|
213
|
-
if (!this.isPageEntrypoint(entrypointPath)) {
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
220
|
+
for (const entrypointPath of indexed) {
|
|
216
221
|
this.pageMetadataCache.markOwnedEntrypoint(entrypointPath);
|
|
217
222
|
targets.set(entrypointPath, {
|
|
218
223
|
entrypointPath,
|
|
@@ -290,8 +295,12 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
290
295
|
}
|
|
291
296
|
const changedEntrypointOutput = watchedFiles.get(_filePath);
|
|
292
297
|
if (changedEntrypointOutput && !this.ownsWatchedEntrypoint(_filePath)) {
|
|
293
|
-
|
|
294
|
-
|
|
298
|
+
if (this.isReactEntrypoint(_filePath)) {
|
|
299
|
+
this.pageMetadataCache.markOwnedEntrypoint(_filePath);
|
|
300
|
+
} else {
|
|
301
|
+
appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
|
|
302
|
+
return { type: "none" };
|
|
303
|
+
}
|
|
295
304
|
}
|
|
296
305
|
const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(_filePath);
|
|
297
306
|
const hasDependencyHits = dependencyHits.size > 0;
|
|
@@ -400,12 +409,23 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
400
409
|
const isMdx = entrypointPath.endsWith(".mdx");
|
|
401
410
|
const { outputPath } = this.getEntrypointOutput(entrypointPath);
|
|
402
411
|
const tempDir = path.dirname(outputPath);
|
|
403
|
-
const
|
|
412
|
+
const cachedDeclared = this.pageMetadataCache.getDeclaredModules(entrypointPath);
|
|
413
|
+
let entrypointDeclaredModules;
|
|
414
|
+
let declaredModules;
|
|
415
|
+
if (cachedDeclared) {
|
|
416
|
+
entrypointDeclaredModules = cachedDeclared;
|
|
417
|
+
declaredModules = cachedDeclared;
|
|
418
|
+
} else {
|
|
419
|
+
entrypointDeclaredModules = isMdx ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
|
|
420
|
+
this.pageMetadataCache.setDeclaredModules(entrypointPath, entrypointDeclaredModules);
|
|
421
|
+
declaredModules = entrypointDeclaredModules;
|
|
422
|
+
}
|
|
404
423
|
const plugins = this.getBuildPlugins(declaredModules);
|
|
405
424
|
if (isMdx && this.mdxCompilerOptions) {
|
|
406
425
|
const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
|
|
407
426
|
plugins.unshift(mdxPlugin);
|
|
408
427
|
}
|
|
428
|
+
await this.clearHmrOutdir(tempDir);
|
|
409
429
|
const result = await this.context.getBrowserBundleService().bundle({
|
|
410
430
|
profile: "hmr-entrypoint",
|
|
411
431
|
entrypoints: [entrypointPath],
|
|
@@ -469,12 +489,21 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
469
489
|
if (shouldEnableMdx && this.mdxCompilerOptions) {
|
|
470
490
|
plugins.unshift(createReactMdxLoaderPlugin(this.mdxCompilerOptions));
|
|
471
491
|
}
|
|
492
|
+
await this.clearHmrOutdir(this.context.getDistDir());
|
|
493
|
+
const entryNameByPath = /* @__PURE__ */ new Map();
|
|
494
|
+
for (const { entrypointPath } of entrypoints) {
|
|
495
|
+
entryNameByPath.set(entrypointPath, {
|
|
496
|
+
key: this.getRolldownEntryKey(entrypointPath),
|
|
497
|
+
basename: this.getTempFileBasename(entrypointPath)
|
|
498
|
+
});
|
|
499
|
+
}
|
|
472
500
|
const result = await this.context.getBrowserBundleService().bundle({
|
|
473
501
|
profile: "hmr-entrypoint",
|
|
474
|
-
entrypoints:
|
|
502
|
+
entrypoints: Object.fromEntries(
|
|
503
|
+
entrypoints.map(({ entrypointPath }) => [entryNameByPath.get(entrypointPath).key, entrypointPath])
|
|
504
|
+
),
|
|
475
505
|
outdir: this.context.getDistDir(),
|
|
476
|
-
|
|
477
|
-
naming: "[dir]/[name].[hash].tmp",
|
|
506
|
+
naming: "[name].[hash].tmp",
|
|
478
507
|
splitting: true,
|
|
479
508
|
plugins,
|
|
480
509
|
minify: false
|
|
@@ -492,11 +521,12 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
492
521
|
const updatedOutputs = [];
|
|
493
522
|
for (const { entrypointPath, outputUrl } of entrypoints) {
|
|
494
523
|
const { outputPath } = this.getEntrypointOutput(entrypointPath);
|
|
495
|
-
const {
|
|
524
|
+
const { basename: tempBasename, key: entryKey } = entryNameByPath.get(entrypointPath);
|
|
525
|
+
const expectedSubdir = path.join(this.context.getDistDir(), path.dirname(entryKey));
|
|
496
526
|
const tempOutput = result.outputs.find((output) => {
|
|
497
|
-
return path.dirname(output.path) ===
|
|
527
|
+
return path.dirname(output.path) === expectedSubdir && path.basename(output.path).startsWith(`${tempBasename}.`) && path.basename(output.path).includes(".tmp");
|
|
498
528
|
})?.path;
|
|
499
|
-
const resolvedTempOutput = tempOutput ? await this.resolveTempOutputPath(tempOutput) : await this.resolveTempOutputPath(path.join(
|
|
529
|
+
const resolvedTempOutput = tempOutput ? await this.resolveTempOutputPath(tempOutput) : await this.resolveTempOutputPath(path.join(expectedSubdir, `${tempBasename}.[hash].tmp.js`));
|
|
500
530
|
if (!resolvedTempOutput) {
|
|
501
531
|
appLogger.debug(`Missing grouped temp output for ${outputUrl}`);
|
|
502
532
|
continue;
|
|
@@ -517,7 +547,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
517
547
|
return tempPath;
|
|
518
548
|
}
|
|
519
549
|
if (!tempPath.includes("[hash]")) {
|
|
520
|
-
return
|
|
550
|
+
return null;
|
|
521
551
|
}
|
|
522
552
|
const directory = path.dirname(tempPath);
|
|
523
553
|
const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
|
|
@@ -527,6 +557,35 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
527
557
|
}
|
|
528
558
|
return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
|
|
529
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* Clears stale HMR output from a directory before a rebuild.
|
|
562
|
+
*
|
|
563
|
+
* Only removes:
|
|
564
|
+
* - `*.tmp.js` files (the per-build bundler output the strategy owns)
|
|
565
|
+
* - the `chunks/` subdirectory (the bundler's splitting target)
|
|
566
|
+
*
|
|
567
|
+
* The HMR runtime script (`_hmr_runtime.js`) and any user-authored
|
|
568
|
+
* assets in the outdir are preserved. This is the minimal set of
|
|
569
|
+
* files that, if left from a previous build, can cause the bundler
|
|
570
|
+
* to emit `ENOENT` for chunk references that point to entrypoints
|
|
571
|
+
* whose hash has since changed.
|
|
572
|
+
*/
|
|
573
|
+
async clearHmrOutdir(outdir) {
|
|
574
|
+
if (!fileSystem.exists(outdir)) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const tempFiles = await fileSystem.glob(["**/*.tmp.js"], { cwd: outdir });
|
|
578
|
+
await Promise.all(
|
|
579
|
+
tempFiles.map((relativePath) => {
|
|
580
|
+
const absolutePath = path.isAbsolute(relativePath) ? relativePath : path.join(outdir, relativePath);
|
|
581
|
+
return fileSystem.removeAsync(absolutePath).catch(() => void 0);
|
|
582
|
+
})
|
|
583
|
+
);
|
|
584
|
+
const chunksDir = path.join(outdir, "chunks");
|
|
585
|
+
if (fileSystem.exists(chunksDir)) {
|
|
586
|
+
await fileSystem.removeAsync(chunksDir).catch(() => void 0);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
530
589
|
/**
|
|
531
590
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
532
591
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
package/src/react.plugin.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare class ReactPlugin extends IntegrationPlugin<React.ReactNode> {
|
|
|
22
22
|
private mdxLoaderPlugin;
|
|
23
23
|
private readonly runtimeBundleService;
|
|
24
24
|
private readonly hmrPageMetadataCache;
|
|
25
|
+
private readonly clientGraphBoundaryCache;
|
|
25
26
|
private runtimeDependenciesInitialized;
|
|
26
27
|
/**
|
|
27
28
|
* Indicates whether React explicit graph mode is enabled for renderer/HMR behavior.
|
package/src/react.plugin.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ReactHmrStrategy } from "./react-hmr-strategy.js";
|
|
|
8
8
|
import { ReactRuntimeBundleService } from "./services/react-runtime-bundle.service.js";
|
|
9
9
|
import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
|
|
10
10
|
import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
|
|
11
|
+
import { ClientGraphBoundaryCache } from "./utils/client-graph-boundary-cache.js";
|
|
11
12
|
const appLogger = new Logger("[ReactPlugin]");
|
|
12
13
|
const PLUGIN_NAME = REACT_PLUGIN_NAME;
|
|
13
14
|
const mergePluginLists = (...lists) => {
|
|
@@ -72,6 +73,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
72
73
|
mdxLoaderPlugin;
|
|
73
74
|
runtimeBundleService;
|
|
74
75
|
hmrPageMetadataCache;
|
|
76
|
+
clientGraphBoundaryCache;
|
|
75
77
|
runtimeDependenciesInitialized = false;
|
|
76
78
|
/**
|
|
77
79
|
* Indicates whether React explicit graph mode is enabled for renderer/HMR behavior.
|
|
@@ -93,6 +95,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
93
95
|
this.mdxEnabled = Boolean(rendererConfig.mdxCompilerOptions);
|
|
94
96
|
this.mdxExtensions = rendererConfig.mdxExtensions ?? [".mdx"];
|
|
95
97
|
this.hmrPageMetadataCache = rendererConfig.hmrPageMetadataCache ?? new ReactHmrPageMetadataCache();
|
|
98
|
+
this.clientGraphBoundaryCache = new ClientGraphBoundaryCache();
|
|
96
99
|
this.explicitGraphEnabled = rendererConfig.explicitGraphEnabled ?? false;
|
|
97
100
|
this.rendererConfig = {
|
|
98
101
|
...rendererConfig,
|
|
@@ -187,7 +190,8 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
187
190
|
mdxCompilerOptions: this.mdxCompilerOptions,
|
|
188
191
|
ownedTemplateExtensions: this.extensions,
|
|
189
192
|
allTemplateExtensions: this.appConfig.templatesExt,
|
|
190
|
-
explicitGraphEnabled: this.explicitGraphEnabled
|
|
193
|
+
explicitGraphEnabled: this.explicitGraphEnabled,
|
|
194
|
+
clientGraphBoundaryCache: this.clientGraphBoundaryCache
|
|
191
195
|
});
|
|
192
196
|
}
|
|
193
197
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-app index of React-owned page entrypoints.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The HMR strategy previously called `fileSystem.glob` on every
|
|
6
|
+
* layout change. For apps with many pages this is wasteful: O(pages)
|
|
7
|
+
* on every HMR event, and the result rarely changes between HMR
|
|
8
|
+
* rebuilds.
|
|
9
|
+
*
|
|
10
|
+
* `PagesIndex` maintains a `Set` of owned page entrypoint paths. The
|
|
11
|
+
* initial population is a single glob; subsequent mutations go through
|
|
12
|
+
* `add` / `remove` (called by the HMR strategy when it observes a
|
|
13
|
+
* file create/delete under `pagesDir`). The strategy reads via
|
|
14
|
+
* `list()` for an O(1) snapshot of the current set.
|
|
15
|
+
*
|
|
16
|
+
* Phase 1 ships the class and the `list()` consumer. Watcher-driven
|
|
17
|
+
* `add` / `remove` integration lands in a follow-up — for now the
|
|
18
|
+
* strategy calls `refresh()` lazily, which is equivalent to the old
|
|
19
|
+
* glob behavior but with a stable API for the future incremental
|
|
20
|
+
* path.
|
|
21
|
+
*/
|
|
22
|
+
export type PagesIndexOptions = {
|
|
23
|
+
pagesDir: string;
|
|
24
|
+
extensions?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Optional predicate to filter out non-page entrypoints (e.g.
|
|
27
|
+
* test fixtures, generated files). Defaults to including every
|
|
28
|
+
* file matching an extension.
|
|
29
|
+
*/
|
|
30
|
+
isPageEntrypoint?: (absolutePath: string) => boolean;
|
|
31
|
+
};
|
|
32
|
+
export declare class PagesIndex {
|
|
33
|
+
private readonly pagesDir;
|
|
34
|
+
private readonly extensions;
|
|
35
|
+
private readonly isPageEntrypoint;
|
|
36
|
+
private readonly pages;
|
|
37
|
+
private lastRefreshAt;
|
|
38
|
+
constructor(options: PagesIndexOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Rescan the pages directory and rebuild the index.
|
|
41
|
+
*
|
|
42
|
+
* Cheap to call multiple times in sequence (just a glob). The
|
|
43
|
+
* real value of `PagesIndex` is the `add` / `remove` path that
|
|
44
|
+
* callers can wire to the file watcher; for now this is the
|
|
45
|
+
* fallback used on layout changes.
|
|
46
|
+
*/
|
|
47
|
+
refresh(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Add a single entrypoint. Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
add(absolutePath: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Remove a single entrypoint. Idempotent.
|
|
54
|
+
*/
|
|
55
|
+
remove(absolutePath: string): void;
|
|
56
|
+
/** True if the index contains `absolutePath`. */
|
|
57
|
+
has(absolutePath: string): boolean;
|
|
58
|
+
/** Snapshot of the current set, sorted by absolute path. */
|
|
59
|
+
list(): string[];
|
|
60
|
+
/** Number of indexed entrypoints. */
|
|
61
|
+
get size(): number;
|
|
62
|
+
/** Last refresh timestamp (ms since epoch). */
|
|
63
|
+
get refreshedAt(): number;
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileSystem } from "@ecopages/file-system";
|
|
3
|
+
const DEFAULT_EXTENSIONS = [".tsx", ".kita.tsx", ".lit.tsx", ".eco.tsx", ".mdx", ".react.tsx"];
|
|
4
|
+
class PagesIndex {
|
|
5
|
+
pagesDir;
|
|
6
|
+
extensions;
|
|
7
|
+
isPageEntrypoint;
|
|
8
|
+
pages = /* @__PURE__ */ new Set();
|
|
9
|
+
lastRefreshAt = 0;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.pagesDir = options.pagesDir;
|
|
12
|
+
this.extensions = options.extensions ?? DEFAULT_EXTENSIONS;
|
|
13
|
+
this.isPageEntrypoint = options.isPageEntrypoint ?? (() => true);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Rescan the pages directory and rebuild the index.
|
|
17
|
+
*
|
|
18
|
+
* Cheap to call multiple times in sequence (just a glob). The
|
|
19
|
+
* real value of `PagesIndex` is the `add` / `remove` path that
|
|
20
|
+
* callers can wire to the file watcher; for now this is the
|
|
21
|
+
* fallback used on layout changes.
|
|
22
|
+
*/
|
|
23
|
+
async refresh() {
|
|
24
|
+
const files = await fileSystem.glob(
|
|
25
|
+
this.extensions.map((ext) => `**/*${ext}`),
|
|
26
|
+
{
|
|
27
|
+
cwd: this.pagesDir
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
const next = /* @__PURE__ */ new Set();
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const absolutePath = path.join(this.pagesDir, file);
|
|
33
|
+
if (!this.isPageEntrypoint(absolutePath)) continue;
|
|
34
|
+
if (file.includes(".ecopages-node.")) continue;
|
|
35
|
+
next.add(absolutePath);
|
|
36
|
+
}
|
|
37
|
+
this.pages.clear();
|
|
38
|
+
for (const p of next) this.pages.add(p);
|
|
39
|
+
this.lastRefreshAt = Date.now();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Add a single entrypoint. Idempotent.
|
|
43
|
+
*/
|
|
44
|
+
add(absolutePath) {
|
|
45
|
+
if (!this.isPageEntrypoint(absolutePath)) return;
|
|
46
|
+
this.pages.add(absolutePath);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove a single entrypoint. Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
remove(absolutePath) {
|
|
52
|
+
this.pages.delete(absolutePath);
|
|
53
|
+
}
|
|
54
|
+
/** True if the index contains `absolutePath`. */
|
|
55
|
+
has(absolutePath) {
|
|
56
|
+
return this.pages.has(absolutePath);
|
|
57
|
+
}
|
|
58
|
+
/** Snapshot of the current set, sorted by absolute path. */
|
|
59
|
+
list() {
|
|
60
|
+
return Array.from(this.pages).sort();
|
|
61
|
+
}
|
|
62
|
+
/** Number of indexed entrypoints. */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.pages.size;
|
|
65
|
+
}
|
|
66
|
+
/** Last refresh timestamp (ms since epoch). */
|
|
67
|
+
get refreshedAt() {
|
|
68
|
+
return this.lastRefreshAt;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
PagesIndex
|
|
73
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bundle configuration service for React integration.
|
|
3
3
|
*
|
|
4
|
-
* Encapsulates all
|
|
4
|
+
* Encapsulates all build plugin creation and bundle options
|
|
5
5
|
* for client-side React component builds.
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
@@ -34,7 +34,7 @@ export interface ReactClientBundleOptions {
|
|
|
34
34
|
splitting?: boolean;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Manages
|
|
37
|
+
* Manages bundle configuration and plugin creation for React page/component builds.
|
|
38
38
|
*/
|
|
39
39
|
export declare class ReactBundleService {
|
|
40
40
|
private readonly runtimeBundleService;
|
|
@@ -45,7 +45,7 @@ export declare class ReactBundleService {
|
|
|
45
45
|
*/
|
|
46
46
|
getRuntimeImports(): ReactRuntimeImports;
|
|
47
47
|
/**
|
|
48
|
-
* Creates
|
|
48
|
+
* Creates bundle options for a page or component entry.
|
|
49
49
|
*
|
|
50
50
|
* @remarks
|
|
51
51
|
* React derives runtime specifier mappings from the core browser runtime manifest
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
getReactClientGraphAllowSpecifiers,
|
|
4
4
|
getReactRuntimeExternalSpecifiers
|
|
5
5
|
} from "../utils/react-runtime-alias-map.js";
|
|
6
|
-
import {
|
|
6
|
+
import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
|
|
7
7
|
import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
|
|
8
8
|
import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
|
|
9
9
|
import { createReactMdxLoaderPlugin } from "../utils/react-mdx-loader-plugin.js";
|
|
@@ -24,7 +24,7 @@ class ReactBundleService {
|
|
|
24
24
|
return this.runtimeBundleService.getRuntimeImports();
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* Creates
|
|
27
|
+
* Creates bundle options for a page or component entry.
|
|
28
28
|
*
|
|
29
29
|
* @remarks
|
|
30
30
|
* React derives runtime specifier mappings from the core browser runtime manifest
|
|
@@ -67,7 +67,7 @@ class ReactBundleService {
|
|
|
67
67
|
foreignExtensions: this.config.nonReactExtensions ?? []
|
|
68
68
|
});
|
|
69
69
|
const runtimeManifest = this.runtimeBundleService.getRuntimeManifest();
|
|
70
|
-
const runtimeRewritePlugin =
|
|
70
|
+
const runtimeRewritePlugin = createBrowserRuntimePlugin({
|
|
71
71
|
name: "react-renderer-runtime-import-rewrite",
|
|
72
72
|
manifest: runtimeManifest
|
|
73
73
|
});
|
|
@@ -14,7 +14,7 @@ export declare class ReactHmrPageMetadataCache {
|
|
|
14
14
|
/**
|
|
15
15
|
* Stores the declared browser modules for a page entrypoint.
|
|
16
16
|
*/
|
|
17
|
-
setDeclaredModules(entrypointPath: string, declaredModules: string[]): void;
|
|
17
|
+
setDeclaredModules(entrypointPath: string, declaredModules: readonly string[]): void;
|
|
18
18
|
/**
|
|
19
19
|
* Returns the last known declared browser modules for a page entrypoint.
|
|
20
20
|
*/
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import type { EcoComponentConfig, EcoPageFile } from '@ecopages/core';
|
|
10
|
-
import type
|
|
10
|
+
import { type BuildExecutor } from '@ecopages/core/build/build-adapter';
|
|
11
11
|
import type { CompileOptions } from '@mdx-js/mdx';
|
|
12
12
|
/**
|
|
13
13
|
* Configuration for the ReactPageModuleService.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { rapidhash } from "@ecopages/core/hash";
|
|
4
3
|
import { build } from "@ecopages/core/build/build-adapter";
|
|
4
|
+
import { normalizeNodeRuntimeBuildOutputFile } from "@ecopages/core/build/runtime-build-output-normalizer";
|
|
5
|
+
import { rapidhash } from "@ecopages/core/hash";
|
|
5
6
|
import { fileSystem } from "@ecopages/file-system";
|
|
6
7
|
import { someInConfigTree } from "../utils/component-config-traversal.js";
|
|
7
8
|
import { collectDeclaredModulesInConfig } from "../utils/declared-modules.js";
|
|
@@ -51,6 +52,7 @@ class ReactPageModuleService {
|
|
|
51
52
|
splitting: false,
|
|
52
53
|
minify: false,
|
|
53
54
|
treeshaking: false,
|
|
55
|
+
externalPackages: true,
|
|
54
56
|
naming: outputNamingTemplate,
|
|
55
57
|
plugins: [mdxPlugin]
|
|
56
58
|
},
|
|
@@ -65,6 +67,7 @@ class ReactPageModuleService {
|
|
|
65
67
|
if (!compiledOutput) {
|
|
66
68
|
throw new Error(`No compiled MDX output generated for page: ${filePath}`);
|
|
67
69
|
}
|
|
70
|
+
normalizeNodeRuntimeBuildOutputFile(compiledOutput, this.config.rootDir);
|
|
68
71
|
const compiledOutputUrl = pathToFileURL(compiledOutput);
|
|
69
72
|
if (process?.env?.NODE_ENV === "development" || options?.cacheScope) {
|
|
70
73
|
compiledOutputUrl.searchParams.set(
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME
|
|
4
|
-
} from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
|
|
5
|
-
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
1
|
+
import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
|
|
2
|
+
import { DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME } from "@ecopages/core/build/browser-runtime-plugin";
|
|
6
3
|
import {
|
|
7
4
|
buildBrowserRuntimeAssetUrl,
|
|
8
5
|
createBrowserRuntimeModuleAsset,
|
|
@@ -50,7 +47,7 @@ class ReactRuntimeBundleService {
|
|
|
50
47
|
return mode === "development" ? "use-sync-external-store-with-selector.development.js" : "use-sync-external-store-with-selector.js";
|
|
51
48
|
}
|
|
52
49
|
createReactVendorImportRewritePlugin(mode) {
|
|
53
|
-
return
|
|
50
|
+
return createBrowserRuntimePlugin({
|
|
54
51
|
name: `react-plugin-vendor-runtime-import-rewrite-${mode}`,
|
|
55
52
|
manifest: createBrowserRuntimeManifest([
|
|
56
53
|
{
|
|
@@ -93,12 +90,17 @@ class ReactRuntimeBundleService {
|
|
|
93
90
|
const reactDomRuntimeInteropPlugin = createReactDomRuntimeInteropPlugin({
|
|
94
91
|
reactSpecifier: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
|
|
95
92
|
});
|
|
96
|
-
const reactRuntimeAliasPlugin =
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
const reactRuntimeAliasPlugin = createBrowserRuntimePlugin({
|
|
94
|
+
name: `react-plugin-runtime-specifier-alias-${mode}`,
|
|
95
|
+
manifest: createBrowserRuntimeManifest([
|
|
96
|
+
{
|
|
97
|
+
specifier: "react",
|
|
98
|
+
owner: "",
|
|
99
|
+
importPath: "react",
|
|
100
|
+
publicPath: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
|
|
101
|
+
}
|
|
102
|
+
])
|
|
103
|
+
});
|
|
102
104
|
const reactDomBundlePlugins = [reactRuntimeAliasPlugin, reactDomRuntimeInteropPlugin].filter(
|
|
103
105
|
(plugin) => plugin !== null
|
|
104
106
|
);
|
|
@@ -117,7 +119,7 @@ class ReactRuntimeBundleService {
|
|
|
117
119
|
rootDir: this.config.rootDir,
|
|
118
120
|
bundleOptions: {
|
|
119
121
|
define: this.createRuntimeDefines(mode),
|
|
120
|
-
excludeAppBuildPlugins: [
|
|
122
|
+
excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME]
|
|
121
123
|
}
|
|
122
124
|
}),
|
|
123
125
|
createBrowserRuntimeModuleAsset({
|
|
@@ -128,7 +130,7 @@ class ReactRuntimeBundleService {
|
|
|
128
130
|
rootDir: this.config.rootDir,
|
|
129
131
|
bundleOptions: {
|
|
130
132
|
define: this.createRuntimeDefines(mode),
|
|
131
|
-
excludeAppBuildPlugins: [
|
|
133
|
+
excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME],
|
|
132
134
|
plugins: reactDomBundlePlugins
|
|
133
135
|
}
|
|
134
136
|
}),
|
|
@@ -138,7 +140,7 @@ class ReactRuntimeBundleService {
|
|
|
138
140
|
fileName: this.getUseSyncExternalStoreWithSelectorVendorFileName(mode),
|
|
139
141
|
bundleOptions: {
|
|
140
142
|
define: this.createRuntimeDefines(mode),
|
|
141
|
-
excludeAppBuildPlugins: [
|
|
143
|
+
excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME],
|
|
142
144
|
plugins: [reactVendorImportRewritePlugin]
|
|
143
145
|
}
|
|
144
146
|
})
|
|
@@ -164,8 +166,18 @@ class ReactRuntimeBundleService {
|
|
|
164
166
|
return dependencies;
|
|
165
167
|
}
|
|
166
168
|
createRuntimeAliasPlugin(mode = this.getCurrentRuntimeMode()) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
const aliasMap = this.getRuntimeAliasMap(mode);
|
|
170
|
+
const manifest = createBrowserRuntimeManifest(
|
|
171
|
+
Object.entries(aliasMap).map(([specifier, publicPath]) => ({
|
|
172
|
+
specifier,
|
|
173
|
+
owner: "",
|
|
174
|
+
importPath: specifier,
|
|
175
|
+
publicPath
|
|
176
|
+
}))
|
|
177
|
+
);
|
|
178
|
+
return createBrowserRuntimePlugin({
|
|
179
|
+
name: `react-plugin-runtime-alias-${mode}`,
|
|
180
|
+
manifest
|
|
169
181
|
});
|
|
170
182
|
}
|
|
171
183
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-app persistent cache for `client-graph-boundary` plugin transforms.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The plugin's `transformModuleImports` function is the second most
|
|
6
|
+
* expensive operation in the React build path (after `parseSync`).
|
|
7
|
+
* It walks the AST to find reachable exports and prunes forbidden
|
|
8
|
+
* imports. On a no-op rebuild (file unchanged, no plugin signature
|
|
9
|
+
* change) the entire walk can be skipped if the inputs are identical.
|
|
10
|
+
*
|
|
11
|
+
* This cache is owned by the {@link ReactPlugin} for the app's lifetime.
|
|
12
|
+
* It persists across HMR rebuilds but is invalidated per-file when the
|
|
13
|
+
* watcher detects a source change. The `requestedExports` registry
|
|
14
|
+
* remains per-build (it's mutated during cross-file propagation and
|
|
15
|
+
* must not accumulate stale state across builds).
|
|
16
|
+
*
|
|
17
|
+
* The cache is content-hashed (via `rapidhash`) so a `touch`/`utimes`
|
|
18
|
+
* does not invalidate a still-valid entry.
|
|
19
|
+
*
|
|
20
|
+
* **`rulesAdded` semantics:** the map holds the **after-state** of
|
|
21
|
+
* every registry key this transform touched — both newly added keys
|
|
22
|
+
* and keys that were grown (Set union) or promoted to `'*'`. On
|
|
23
|
+
* replay the cache applies each entry via the same merge rules the
|
|
24
|
+
* live transform uses (`mergeRequestedExportRules`), so a key that
|
|
25
|
+
* was already in the live registry when the transform ran and grew
|
|
26
|
+
* during the transform is correctly grown on replay against a fresh
|
|
27
|
+
* registry as well.
|
|
28
|
+
*/
|
|
29
|
+
export type RequestedExportRules = Set<string> | '*';
|
|
30
|
+
export type CachedTransform = {
|
|
31
|
+
/** Hash of the source string at the time the transform was cached. */
|
|
32
|
+
sourceHash: number | bigint;
|
|
33
|
+
/** Hash of the globally-allowed modules map at the time the transform was cached. */
|
|
34
|
+
allowListHash: number | bigint;
|
|
35
|
+
/** The transformed source (or original if `modified` is false). */
|
|
36
|
+
transformed: string;
|
|
37
|
+
/** Whether the transform changed the source. */
|
|
38
|
+
modified: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Map of `requestedExports` keys this transform touched, mapped to
|
|
41
|
+
* their **after-state**. Includes:
|
|
42
|
+
* - newly-added keys
|
|
43
|
+
* - keys whose Set grew (union of pre-existing and newly reachable exports)
|
|
44
|
+
* - keys promoted to `'*'`
|
|
45
|
+
*
|
|
46
|
+
* On cache hit, each entry is replayed via `mergeRequestedExportRules`,
|
|
47
|
+
* which handles all three cases correctly. Defensively copied at
|
|
48
|
+
* `set()` time to prevent later mutation from corrupting the cache.
|
|
49
|
+
*/
|
|
50
|
+
rulesAdded: Map<string, RequestedExportRules>;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* LRU-bounded cache for client-graph-boundary transform results.
|
|
54
|
+
*
|
|
55
|
+
* One instance is owned by the React plugin for the app's lifetime and
|
|
56
|
+
* shared across all builds. The cache key is `(filePath, source, allowList)`
|
|
57
|
+
* so a file whose source or whose global allow list changes gets a fresh
|
|
58
|
+
* entry, while everything else hits.
|
|
59
|
+
*/
|
|
60
|
+
export declare class ClientGraphBoundaryCache {
|
|
61
|
+
private readonly entries;
|
|
62
|
+
private readonly maxEntries;
|
|
63
|
+
private hits;
|
|
64
|
+
private misses;
|
|
65
|
+
constructor(maxEntries?: number);
|
|
66
|
+
/**
|
|
67
|
+
* Look up a cached transform for `filePath`.
|
|
68
|
+
*
|
|
69
|
+
* Returns `undefined` if the source or allow list has changed since
|
|
70
|
+
* the entry was stored. The caller is responsible for re-running the
|
|
71
|
+
* transform in that case and calling `set` to update the entry.
|
|
72
|
+
*/
|
|
73
|
+
get(filePath: string, source: string, globallyAllowedSpecifiers: Iterable<string>): CachedTransform | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Store a transform result.
|
|
76
|
+
*
|
|
77
|
+
* Evicts the least recently used entry if the cache exceeds its
|
|
78
|
+
* capacity. Defensively copies any `Set`-typed rules in `rulesAdded`
|
|
79
|
+
* so later mutations to the caller's sets cannot corrupt the cache.
|
|
80
|
+
*/
|
|
81
|
+
set(filePath: string, source: string, globallyAllowedSpecifiers: Iterable<string>, entry: Omit<CachedTransform, 'sourceHash' | 'allowListHash'>): void;
|
|
82
|
+
/**
|
|
83
|
+
* Invalidate a single file's entry. Call this from the file watcher
|
|
84
|
+
* when `filePath`'s content has changed.
|
|
85
|
+
*/
|
|
86
|
+
invalidate(filePath: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Invalidate every file whose key matches a prefix. Useful for
|
|
89
|
+
* "anything under `src/pages/` changed" signals.
|
|
90
|
+
*/
|
|
91
|
+
invalidateMatching(predicate: (filePath: string) => boolean): number;
|
|
92
|
+
/** Clear all entries. */
|
|
93
|
+
clear(): void;
|
|
94
|
+
/** Current cache size. */
|
|
95
|
+
get size(): number;
|
|
96
|
+
/** Hit/miss counters for observability. */
|
|
97
|
+
stats(): {
|
|
98
|
+
hits: number;
|
|
99
|
+
misses: number;
|
|
100
|
+
size: number;
|
|
101
|
+
hitRate: number;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Default shared cache. The React plugin owns one of these; tests can
|
|
106
|
+
* create their own.
|
|
107
|
+
*/
|
|
108
|
+
export declare const clientGraphBoundaryCache: ClientGraphBoundaryCache;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { rapidhash } from "@ecopages/core/utils/hash";
|
|
2
|
+
const DEFAULT_MAX_ENTRIES = 5e3;
|
|
3
|
+
function cloneRules(rules) {
|
|
4
|
+
return rules instanceof Set ? new Set(rules) : rules;
|
|
5
|
+
}
|
|
6
|
+
class ClientGraphBoundaryCache {
|
|
7
|
+
entries = /* @__PURE__ */ new Map();
|
|
8
|
+
maxEntries;
|
|
9
|
+
hits = 0;
|
|
10
|
+
misses = 0;
|
|
11
|
+
constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
12
|
+
if (maxEntries <= 0) {
|
|
13
|
+
throw new Error(`ClientGraphBoundaryCache: maxEntries must be > 0, got ${maxEntries}`);
|
|
14
|
+
}
|
|
15
|
+
this.maxEntries = maxEntries;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Look up a cached transform for `filePath`.
|
|
19
|
+
*
|
|
20
|
+
* Returns `undefined` if the source or allow list has changed since
|
|
21
|
+
* the entry was stored. The caller is responsible for re-running the
|
|
22
|
+
* transform in that case and calling `set` to update the entry.
|
|
23
|
+
*/
|
|
24
|
+
get(filePath, source, globallyAllowedSpecifiers) {
|
|
25
|
+
const sourceHash = rapidhash(source);
|
|
26
|
+
const allowListHash = hashAllowList(globallyAllowedSpecifiers);
|
|
27
|
+
const existing = this.entries.get(filePath);
|
|
28
|
+
if (existing && existing.sourceHash === sourceHash && existing.allowListHash === allowListHash) {
|
|
29
|
+
this.hits += 1;
|
|
30
|
+
this.entries.delete(filePath);
|
|
31
|
+
this.entries.set(filePath, existing);
|
|
32
|
+
return existing;
|
|
33
|
+
}
|
|
34
|
+
this.misses += 1;
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Store a transform result.
|
|
39
|
+
*
|
|
40
|
+
* Evicts the least recently used entry if the cache exceeds its
|
|
41
|
+
* capacity. Defensively copies any `Set`-typed rules in `rulesAdded`
|
|
42
|
+
* so later mutations to the caller's sets cannot corrupt the cache.
|
|
43
|
+
*/
|
|
44
|
+
set(filePath, source, globallyAllowedSpecifiers, entry) {
|
|
45
|
+
const sourceHash = rapidhash(source);
|
|
46
|
+
const allowListHash = hashAllowList(globallyAllowedSpecifiers);
|
|
47
|
+
const rulesAddedCopy = /* @__PURE__ */ new Map();
|
|
48
|
+
for (const [key, rules] of entry.rulesAdded) {
|
|
49
|
+
rulesAddedCopy.set(key, cloneRules(rules));
|
|
50
|
+
}
|
|
51
|
+
const full = {
|
|
52
|
+
sourceHash,
|
|
53
|
+
allowListHash,
|
|
54
|
+
transformed: entry.transformed,
|
|
55
|
+
modified: entry.modified,
|
|
56
|
+
rulesAdded: rulesAddedCopy
|
|
57
|
+
};
|
|
58
|
+
this.entries.set(filePath, full);
|
|
59
|
+
if (this.entries.size > this.maxEntries) {
|
|
60
|
+
const oldestKey = this.entries.keys().next().value;
|
|
61
|
+
if (oldestKey !== void 0) {
|
|
62
|
+
this.entries.delete(oldestKey);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Invalidate a single file's entry. Call this from the file watcher
|
|
68
|
+
* when `filePath`'s content has changed.
|
|
69
|
+
*/
|
|
70
|
+
invalidate(filePath) {
|
|
71
|
+
this.entries.delete(filePath);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Invalidate every file whose key matches a prefix. Useful for
|
|
75
|
+
* "anything under `src/pages/` changed" signals.
|
|
76
|
+
*/
|
|
77
|
+
invalidateMatching(predicate) {
|
|
78
|
+
let removed = 0;
|
|
79
|
+
for (const key of this.entries.keys()) {
|
|
80
|
+
if (predicate(key)) {
|
|
81
|
+
this.entries.delete(key);
|
|
82
|
+
removed += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return removed;
|
|
86
|
+
}
|
|
87
|
+
/** Clear all entries. */
|
|
88
|
+
clear() {
|
|
89
|
+
this.entries.clear();
|
|
90
|
+
this.hits = 0;
|
|
91
|
+
this.misses = 0;
|
|
92
|
+
}
|
|
93
|
+
/** Current cache size. */
|
|
94
|
+
get size() {
|
|
95
|
+
return this.entries.size;
|
|
96
|
+
}
|
|
97
|
+
/** Hit/miss counters for observability. */
|
|
98
|
+
stats() {
|
|
99
|
+
const total = this.hits + this.misses;
|
|
100
|
+
return {
|
|
101
|
+
hits: this.hits,
|
|
102
|
+
misses: this.misses,
|
|
103
|
+
size: this.entries.size,
|
|
104
|
+
hitRate: total === 0 ? 0 : this.hits / total
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const clientGraphBoundaryCache = new ClientGraphBoundaryCache();
|
|
109
|
+
function hashAllowList(specifiers) {
|
|
110
|
+
const sorted = Array.from(specifiers).sort();
|
|
111
|
+
return rapidhash(sorted.join("\n"));
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
ClientGraphBoundaryCache,
|
|
115
|
+
clientGraphBoundaryCache
|
|
116
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module ClientGraphBoundaryPlugin
|
|
3
3
|
*
|
|
4
|
-
* This module defines the primary
|
|
4
|
+
* This module defines the primary build plugin responsible for securing the Ecopages
|
|
5
5
|
* isomorphic compilation pipeline. It ensures that backend-only code, sensitive Node.js APIs,
|
|
6
6
|
* and massive server utilities do not accidentally leak into the browser bundle.
|
|
7
7
|
*
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* inlines `fs.readFileSync(path.resolve(...))` calls to prevent server/client data mismatches.
|
|
16
16
|
*/
|
|
17
17
|
import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
|
|
18
|
+
import { ClientGraphBoundaryCache } from './client-graph-boundary-cache.js';
|
|
18
19
|
/**
|
|
19
|
-
* Configuration options for the Client Graph Boundary
|
|
20
|
+
* Configuration options for the Client Graph Boundary build plugin.
|
|
20
21
|
*
|
|
21
22
|
* This plugin serves as the primary security layer between server-only logic and the client-side JavaScript bundle.
|
|
22
23
|
* It prevents Node.js built-ins (`node:fs`, `node:path`) and backend-exclusive dependencies (e.g. `pg`, `redis`)
|
|
@@ -29,12 +30,19 @@ type ClientGraphBoundaryOptions = {
|
|
|
29
30
|
* Array of module specifiers that are explicitly whitelisted to be bundled in the client code.
|
|
30
31
|
* This is typically populated by parsing `modules: ["..."]` declarations in React/Lit components.
|
|
31
32
|
*/
|
|
32
|
-
declaredModules?: string[];
|
|
33
|
+
declaredModules?: readonly string[];
|
|
33
34
|
/** Array of emergency escape-hatch specifiers that always bypass the boundary checks regardless of component declarations. */
|
|
34
35
|
alwaysAllowSpecifiers?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Persistent per-app cache for transform results. Owned by the React
|
|
38
|
+
* plugin for the app's lifetime; survives across HMR rebuilds. When
|
|
39
|
+
* omitted, transforms are still memoized inside the plugin for the
|
|
40
|
+
* duration of a single build but not across builds.
|
|
41
|
+
*/
|
|
42
|
+
cache?: ClientGraphBoundaryCache;
|
|
35
43
|
};
|
|
36
44
|
/**
|
|
37
|
-
* Instantiates the client graph boundary
|
|
45
|
+
* Instantiates the client graph boundary build plugin.
|
|
38
46
|
*
|
|
39
47
|
* @param options - Configuration options for the graph boundary.
|
|
40
48
|
* @returns The resulting `EcoBuildPlugin`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, extname, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { cachedParseSync } from "@ecopages/core/cache";
|
|
4
|
+
import { ClientGraphBoundaryCache } from "./client-graph-boundary-cache.js";
|
|
4
5
|
import { analyzeReachability } from "./reachability-analyzer.js";
|
|
5
6
|
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
6
7
|
const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -180,10 +181,38 @@ function mergeRequestedExportRules(registry, moduleKey, rules) {
|
|
|
180
181
|
existing.add(rule);
|
|
181
182
|
}
|
|
182
183
|
}
|
|
184
|
+
function cloneRequestedExportRules(rules) {
|
|
185
|
+
return rules === "*" ? rules : new Set(rules);
|
|
186
|
+
}
|
|
187
|
+
function snapshotRegistry(registry) {
|
|
188
|
+
const out = /* @__PURE__ */ new Map();
|
|
189
|
+
for (const [key, rules] of registry) {
|
|
190
|
+
out.set(key, cloneRequestedExportRules(rules));
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
function diffRequestedExportRules(before, after) {
|
|
195
|
+
if (!before) {
|
|
196
|
+
return cloneRequestedExportRules(after);
|
|
197
|
+
}
|
|
198
|
+
if (before === "*") {
|
|
199
|
+
return void 0;
|
|
200
|
+
}
|
|
201
|
+
if (after === "*") {
|
|
202
|
+
return "*";
|
|
203
|
+
}
|
|
204
|
+
const addedRules = /* @__PURE__ */ new Set();
|
|
205
|
+
for (const rule of after) {
|
|
206
|
+
if (!before.has(rule)) {
|
|
207
|
+
addedRules.add(rule);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return addedRules.size > 0 ? addedRules : void 0;
|
|
211
|
+
}
|
|
183
212
|
function transformModuleImports(source, filename, globallyAllowed, requestedExports) {
|
|
184
213
|
let result;
|
|
185
214
|
try {
|
|
186
|
-
result =
|
|
215
|
+
result = cachedParseSync(filename, source, {
|
|
187
216
|
sourceType: "module",
|
|
188
217
|
lang: parserLanguageForFile(filename)
|
|
189
218
|
});
|
|
@@ -414,7 +443,7 @@ function transformModuleImports(source, filename, globallyAllowed, requestedExpo
|
|
|
414
443
|
}
|
|
415
444
|
let reparsedResult;
|
|
416
445
|
try {
|
|
417
|
-
reparsedResult =
|
|
446
|
+
reparsedResult = cachedParseSync(filename, transformed, {
|
|
418
447
|
sourceType: "module",
|
|
419
448
|
lang: parserLanguageForFile(filename)
|
|
420
449
|
});
|
|
@@ -432,11 +461,13 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
432
461
|
name: "ecopages-client-graph-boundary",
|
|
433
462
|
setup(build) {
|
|
434
463
|
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
464
|
+
const cache = options?.cache;
|
|
435
465
|
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
436
466
|
const requestedExports = /* @__PURE__ */ new Map();
|
|
437
467
|
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
438
468
|
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), "*");
|
|
439
469
|
}
|
|
470
|
+
const allowListForCache = Array.from(globallyDeclaredSources.keys()).sort();
|
|
440
471
|
build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
|
|
441
472
|
let source;
|
|
442
473
|
try {
|
|
@@ -444,6 +475,17 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
444
475
|
} catch {
|
|
445
476
|
return void 0;
|
|
446
477
|
}
|
|
478
|
+
if (cache) {
|
|
479
|
+
const cached = cache.get(args.path, source, allowListForCache);
|
|
480
|
+
if (cached) {
|
|
481
|
+
for (const [moduleKey, rules] of cached.rulesAdded) {
|
|
482
|
+
mergeRequestedExportRules(requestedExports, moduleKey, rules);
|
|
483
|
+
}
|
|
484
|
+
if (!cached.modified) return void 0;
|
|
485
|
+
const ext2 = extname(args.path).slice(1);
|
|
486
|
+
return { contents: cached.transformed, loader: ext2, resolveDir: dirname(args.path) };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
447
489
|
let transformed = source;
|
|
448
490
|
let modified = false;
|
|
449
491
|
if (source.includes("readFileSync")) {
|
|
@@ -472,6 +514,7 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
472
514
|
);
|
|
473
515
|
transformed = readFileTransformed;
|
|
474
516
|
}
|
|
517
|
+
const registryBefore = snapshotRegistry(requestedExports);
|
|
475
518
|
const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
|
|
476
519
|
transformed,
|
|
477
520
|
args.path,
|
|
@@ -482,6 +525,21 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
482
525
|
modified = true;
|
|
483
526
|
transformed = oxcTransformed;
|
|
484
527
|
}
|
|
528
|
+
if (cache) {
|
|
529
|
+
const rulesAdded = /* @__PURE__ */ new Map();
|
|
530
|
+
for (const [key, afterRules] of requestedExports) {
|
|
531
|
+
const beforeRules = registryBefore.get(key);
|
|
532
|
+
const diff = diffRequestedExportRules(beforeRules, afterRules);
|
|
533
|
+
if (!diff) continue;
|
|
534
|
+
rulesAdded.set(key, diff);
|
|
535
|
+
}
|
|
536
|
+
const entry = {
|
|
537
|
+
transformed,
|
|
538
|
+
modified,
|
|
539
|
+
rulesAdded
|
|
540
|
+
};
|
|
541
|
+
cache.set(args.path, source, allowListForCache, entry);
|
|
542
|
+
}
|
|
485
543
|
if (!modified) return void 0;
|
|
486
544
|
const ext = extname(args.path).slice(1);
|
|
487
545
|
return { contents: transformed, loader: ext, resolveDir: dirname(args.path) };
|