@ecopages/react 0.2.0-alpha.52 → 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.52",
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.52",
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.52",
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.
@@ -100,6 +110,9 @@ export declare class ReactHmrStrategy extends HmrStrategy {
100
110
  private isRouteTemplate;
101
111
  private resolveTemplateExtension;
102
112
  private ownsWatchedEntrypoint;
113
+ private configContainsFile;
114
+ private pageModuleRequiresLayoutRefresh;
115
+ private hasLayoutOwnedDependencyTarget;
103
116
  /**
104
117
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
105
118
  *
@@ -128,7 +141,8 @@ export declare class ReactHmrStrategy extends HmrStrategy {
128
141
  private isLayoutFile;
129
142
  private isPageEntrypoint;
130
143
  private getEntrypointOutput;
131
- private getGroupedTempOutputPattern;
144
+ private getRolldownEntryKey;
145
+ private getTempFileBasename;
132
146
  private collectReactPageBuildTargets;
133
147
  /**
134
148
  * Expands one HMR request into the full React page build cohort when needed.
@@ -182,6 +196,20 @@ export declare class ReactHmrStrategy extends HmrStrategy {
182
196
  */
183
197
  private bundleReactEntrypoints;
184
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;
185
213
  /**
186
214
  * Encodes dynamic route segments (brackets) in file paths.
187
215
  * Converts `[slug]` to `_slug_` to avoid filesystem issues.
@@ -1,14 +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";
11
+ import { someInConfigTree } from "./utils/component-config-traversal.js";
10
12
  import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
11
13
  import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-alias-map.js";
14
+ import { PagesIndex } from "./services/pages-index.js";
12
15
  const appLogger = new Logger("[ReactHmrStrategy]");
13
16
  class ReactHmrStrategy extends HmrStrategy {
14
17
  type = HmrStrategyType.INTEGRATION;
@@ -27,12 +30,20 @@ class ReactHmrStrategy extends HmrStrategy {
27
30
  pageMetadataCache;
28
31
  explicitGraphEnabled;
29
32
  runtimeManifest;
33
+ clientGraphBoundaryCache;
34
+ pagesIndex;
30
35
  constructor(options) {
31
36
  super();
32
37
  this.context = options.context;
33
38
  this.pageMetadataCache = options.pageMetadataCache;
34
39
  this.runtimeManifest = options.runtimeManifest;
35
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
+ });
36
47
  this.mdxCompilerOptions = options.mdxCompilerOptions;
37
48
  this.ownedTemplateExtensions = new Set(options.ownedTemplateExtensions ?? [".tsx"]);
38
49
  this.allTemplateExtensions = [...options.allTemplateExtensions ?? [".tsx"]].sort(
@@ -53,7 +64,7 @@ class ReactHmrStrategy extends HmrStrategy {
53
64
  const allowSpecifiers = getReactClientGraphAllowSpecifiers(
54
65
  this.runtimeManifest.assets.map((asset) => asset.specifier)
55
66
  );
56
- const runtimeRewritePlugin = createBrowserRuntimeImportRewritePlugin({
67
+ const runtimeRewritePlugin = createBrowserRuntimePlugin({
57
68
  name: "react-hmr-runtime-import-rewrite",
58
69
  manifest: this.runtimeManifest
59
70
  });
@@ -61,7 +72,8 @@ class ReactHmrStrategy extends HmrStrategy {
61
72
  createClientGraphBoundaryPlugin({
62
73
  absWorkingDir: path.dirname(this.context.getSrcDir()),
63
74
  alwaysAllowSpecifiers: allowSpecifiers,
64
- declaredModules
75
+ declaredModules,
76
+ cache: this.clientGraphBoundaryCache
65
77
  }),
66
78
  ...runtimeRewritePlugin ? [runtimeRewritePlugin] : [],
67
79
  ...this.context.getPlugins()
@@ -103,6 +115,32 @@ class ReactHmrStrategy extends HmrStrategy {
103
115
  ownsWatchedEntrypoint(filePath) {
104
116
  return this.pageMetadataCache.ownsEntrypoint(filePath);
105
117
  }
118
+ configContainsFile(config, filePath) {
119
+ const resolvedFilePath = path.resolve(filePath);
120
+ return someInConfigTree(config, (node) => {
121
+ if (!node.__eco?.file) {
122
+ return false;
123
+ }
124
+ return path.resolve(node.__eco.file) === resolvedFilePath;
125
+ });
126
+ }
127
+ pageModuleRequiresLayoutRefresh(pageModule, filePath) {
128
+ return [pageModule.default?.config, pageModule.config].some((config) => {
129
+ return this.configContainsFile(config?.layout?.config, filePath);
130
+ });
131
+ }
132
+ async hasLayoutOwnedDependencyTarget(changedFilePath, requestedTargets) {
133
+ for (const target of requestedTargets) {
134
+ if (!this.isPageEntrypoint(target.entrypointPath)) {
135
+ continue;
136
+ }
137
+ const pageModule = await this.importNodePageModule(target.entrypointPath);
138
+ if (this.pageModuleRequiresLayoutRefresh(pageModule, changedFilePath)) {
139
+ return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
106
144
  /**
107
145
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
108
146
  *
@@ -162,30 +200,24 @@ class ReactHmrStrategy extends HmrStrategy {
162
200
  const outputUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", encodedPathJs).split(path.sep).join("/")}`;
163
201
  return { outputPath, outputUrl };
164
202
  }
165
- getGroupedTempOutputPattern(entrypointPath) {
203
+ getRolldownEntryKey(entrypointPath) {
166
204
  const srcDir = this.context.getSrcDir();
167
205
  const relativePath = path.relative(srcDir, entrypointPath);
168
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
169
- return {
170
- outputDir: path.join(this.context.getDistDir(), path.dirname(relativePathJs)),
171
- outputBaseName: path.basename(relativePathJs, ".js")
172
- };
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);
173
215
  }
174
216
  async collectReactPageBuildTargets() {
175
- const pagesDir = this.context.getPagesDir();
176
- const scannedFiles = await fileSystem.glob(
177
- this.allTemplateExtensions.map((extension) => `**/*${extension}`),
178
- { cwd: pagesDir }
179
- );
217
+ await this.pagesIndex.refresh();
218
+ const indexed = this.pagesIndex.list();
180
219
  const targets = /* @__PURE__ */ new Map();
181
- for (const file of scannedFiles) {
182
- if (file.includes(".ecopages-node.")) {
183
- continue;
184
- }
185
- const entrypointPath = path.join(pagesDir, file);
186
- if (!this.isPageEntrypoint(entrypointPath)) {
187
- continue;
188
- }
220
+ for (const entrypointPath of indexed) {
189
221
  this.pageMetadataCache.markOwnedEntrypoint(entrypointPath);
190
222
  targets.set(entrypointPath, {
191
223
  entrypointPath,
@@ -257,32 +289,55 @@ class ReactHmrStrategy extends HmrStrategy {
257
289
  return { type: "none" };
258
290
  }
259
291
  const isLayout = this.isLayoutFile(_filePath);
292
+ const isChangedPageEntrypoint = this.isPageEntrypoint(_filePath);
260
293
  if (isLayout) {
261
294
  appLogger.debug(`Detected layout file change: ${_filePath}`);
262
295
  }
263
296
  const changedEntrypointOutput = watchedFiles.get(_filePath);
264
297
  if (changedEntrypointOutput && !this.ownsWatchedEntrypoint(_filePath)) {
265
- appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
266
- 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
+ }
267
304
  }
268
305
  const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(_filePath);
269
306
  const hasDependencyHits = dependencyHits.size > 0;
270
307
  const affectedEntrypoints = /* @__PURE__ */ new Map();
308
+ let hasOwnedLayoutDependencyHit = false;
309
+ let layoutOwnedPageTargets = [];
310
+ let hasLayoutOwnedRequestedTarget = false;
271
311
  if (hasDependencyHits && !changedEntrypointOutput) {
272
312
  for (const entrypoint of dependencyHits) {
273
313
  const outputUrl = watchedFiles.get(entrypoint);
274
314
  if (outputUrl && this.ownsWatchedEntrypoint(entrypoint)) {
275
315
  affectedEntrypoints.set(entrypoint, outputUrl);
316
+ continue;
317
+ }
318
+ if (this.isLayoutFile(entrypoint) && this.ownsWatchedEntrypoint(entrypoint)) {
319
+ hasOwnedLayoutDependencyHit = true;
276
320
  }
277
321
  }
278
- if (affectedEntrypoints.size === 0) {
322
+ if (affectedEntrypoints.size === 0 && !hasOwnedLayoutDependencyHit) {
279
323
  appLogger.debug(`Dependency hits found but none map to React-owned watched entrypoints`);
280
324
  return { type: "none" };
281
325
  }
282
326
  }
283
- const requestedTargets = changedEntrypointOutput ? [{ entrypointPath: _filePath, outputUrl: changedEntrypointOutput }] : hasDependencyHits ? Array.from(affectedEntrypoints, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl })) : Array.from(watchedFiles, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl }));
327
+ if (changedEntrypointOutput && !isLayout && !isChangedPageEntrypoint) {
328
+ layoutOwnedPageTargets = await this.collectReactPageBuildTargets();
329
+ hasLayoutOwnedRequestedTarget = await this.hasLayoutOwnedDependencyTarget(
330
+ _filePath,
331
+ layoutOwnedPageTargets
332
+ );
333
+ }
334
+ const requestedTargets = changedEntrypointOutput ? hasLayoutOwnedRequestedTarget ? [{ entrypointPath: _filePath, outputUrl: changedEntrypointOutput }, ...layoutOwnedPageTargets] : [{ entrypointPath: _filePath, outputUrl: changedEntrypointOutput }] : hasOwnedLayoutDependencyHit ? await this.collectReactPageBuildTargets() : hasDependencyHits ? Array.from(affectedEntrypoints, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl })) : Array.from(watchedFiles, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl }));
284
335
  const groupedPageTargets = await this.resolveBuildTargets(requestedTargets, _filePath);
285
336
  const { pageTargets, nonPageTargets } = this.partitionBuildTargets(requestedTargets, groupedPageTargets);
337
+ if (!changedEntrypointOutput) {
338
+ hasLayoutOwnedRequestedTarget = await this.hasLayoutOwnedDependencyTarget(_filePath, requestedTargets);
339
+ }
340
+ const requiresLayoutRefresh = isLayout || hasOwnedLayoutDependencyHit || hasLayoutOwnedRequestedTarget;
286
341
  const updates = [];
287
342
  const requestedOutputUrls = new Set(requestedTargets.map((target) => target.outputUrl));
288
343
  if (pageTargets.length > 1) {
@@ -313,7 +368,7 @@ class ReactHmrStrategy extends HmrStrategy {
313
368
  }
314
369
  }
315
370
  if (updates.length > 0) {
316
- if (isLayout) {
371
+ if (requiresLayoutRefresh) {
317
372
  appLogger.debug(`Layout update detected, sending layout-update event`);
318
373
  return {
319
374
  type: "broadcast",
@@ -354,12 +409,23 @@ class ReactHmrStrategy extends HmrStrategy {
354
409
  const isMdx = entrypointPath.endsWith(".mdx");
355
410
  const { outputPath } = this.getEntrypointOutput(entrypointPath);
356
411
  const tempDir = path.dirname(outputPath);
357
- 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
+ }
358
423
  const plugins = this.getBuildPlugins(declaredModules);
359
424
  if (isMdx && this.mdxCompilerOptions) {
360
425
  const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
361
426
  plugins.unshift(mdxPlugin);
362
427
  }
428
+ await this.clearHmrOutdir(tempDir);
363
429
  const result = await this.context.getBrowserBundleService().bundle({
364
430
  profile: "hmr-entrypoint",
365
431
  entrypoints: [entrypointPath],
@@ -423,12 +489,21 @@ class ReactHmrStrategy extends HmrStrategy {
423
489
  if (shouldEnableMdx && this.mdxCompilerOptions) {
424
490
  plugins.unshift(createReactMdxLoaderPlugin(this.mdxCompilerOptions));
425
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
+ }
426
500
  const result = await this.context.getBrowserBundleService().bundle({
427
501
  profile: "hmr-entrypoint",
428
- entrypoints: entrypoints.map(({ entrypointPath }) => entrypointPath),
502
+ entrypoints: Object.fromEntries(
503
+ entrypoints.map(({ entrypointPath }) => [entryNameByPath.get(entrypointPath).key, entrypointPath])
504
+ ),
429
505
  outdir: this.context.getDistDir(),
430
- outbase: this.context.getSrcDir(),
431
- naming: "[dir]/[name].[hash].tmp",
506
+ naming: "[name].[hash].tmp",
432
507
  splitting: true,
433
508
  plugins,
434
509
  minify: false
@@ -446,11 +521,12 @@ class ReactHmrStrategy extends HmrStrategy {
446
521
  const updatedOutputs = [];
447
522
  for (const { entrypointPath, outputUrl } of entrypoints) {
448
523
  const { outputPath } = this.getEntrypointOutput(entrypointPath);
449
- 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));
450
526
  const tempOutput = result.outputs.find((output) => {
451
- 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");
452
528
  })?.path;
453
- 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`));
454
530
  if (!resolvedTempOutput) {
455
531
  appLogger.debug(`Missing grouped temp output for ${outputUrl}`);
456
532
  continue;
@@ -471,7 +547,7 @@ class ReactHmrStrategy extends HmrStrategy {
471
547
  return tempPath;
472
548
  }
473
549
  if (!tempPath.includes("[hash]")) {
474
- return tempPath;
550
+ return null;
475
551
  }
476
552
  const directory = path.dirname(tempPath);
477
553
  const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
@@ -481,6 +557,35 @@ class ReactHmrStrategy extends HmrStrategy {
481
557
  }
482
558
  return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
483
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
+ }
484
589
  /**
485
590
  * Encodes dynamic route segments (brackets) in file paths.
486
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) };