@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react",
3
- "version": "0.2.0-alpha.53",
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.53",
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.53",
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 getGroupedTempOutputPattern;
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 { createBrowserRuntimeImportRewritePlugin } from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
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 = createBrowserRuntimeImportRewritePlugin({
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
- getGroupedTempOutputPattern(entrypointPath) {
203
+ getRolldownEntryKey(entrypointPath) {
193
204
  const srcDir = this.context.getSrcDir();
194
205
  const relativePath = path.relative(srcDir, entrypointPath);
195
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
196
- return {
197
- outputDir: path.join(this.context.getDistDir(), path.dirname(relativePathJs)),
198
- outputBaseName: path.basename(relativePathJs, ".js")
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
- const pagesDir = this.context.getPagesDir();
203
- const scannedFiles = await fileSystem.glob(
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 file of scannedFiles) {
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
- appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
294
- return { type: "none" };
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 declaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath) ? this.pageMetadataCache.getDeclaredModules(entrypointPath) : isMdx ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
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: entrypoints.map(({ entrypointPath }) => entrypointPath),
502
+ entrypoints: Object.fromEntries(
503
+ entrypoints.map(({ entrypointPath }) => [entryNameByPath.get(entrypointPath).key, entrypointPath])
504
+ ),
475
505
  outdir: this.context.getDistDir(),
476
- outbase: this.context.getSrcDir(),
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 { outputDir, outputBaseName } = this.getGroupedTempOutputPattern(entrypointPath);
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) === outputDir && path.basename(output.path).startsWith(`${outputBaseName}.`) && path.basename(output.path).includes(".tmp");
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(outputDir, `${outputBaseName}.[hash].tmp.js`));
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 tempPath;
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.
@@ -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.
@@ -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 esbuild plugin creation and bundle options
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 esbuild bundle configuration and plugin creation for React page/component builds.
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 esbuild bundle options for a page or component entry.
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 { createBrowserRuntimeImportRewritePlugin } from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
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 esbuild bundle options for a page or component entry.
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 = createBrowserRuntimeImportRewritePlugin({
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 { BuildExecutor } from '@ecopages/core/build/build-adapter';
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
- createBrowserRuntimeImportRewritePlugin,
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 createBrowserRuntimeImportRewritePlugin({
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 = createRuntimeSpecifierAliasPlugin(
97
- {
98
- react: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
99
- },
100
- { name: `react-plugin-runtime-specifier-alias-${mode}` }
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: [DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME]
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: [DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME],
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: [DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME],
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
- return createRuntimeSpecifierAliasPlugin(this.getRuntimeAliasMap(mode), {
168
- name: `react-plugin-runtime-alias-${mode}`
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 esbuild plugin responsible for securing the Ecopages
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 esbuild plugin.
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 esbuild plugin.
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 { parseSync } from "oxc-parser";
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 = parseSync(filename, source, {
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 = parseSync(filename, transformed, {
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) };