@ecopages/core 0.2.0-beta.37 → 0.2.0-beta.39

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.
Files changed (125) hide show
  1. package/README.md +1 -0
  2. package/package.json +35 -3
  3. package/src/adapters/bun/server-adapter.js +1 -0
  4. package/src/adapters/node/server-adapter.js +1 -0
  5. package/src/adapters/shared/http/explicit-static-render-preparation.js +1 -1
  6. package/src/adapters/shared/http/explicit-static-route-matcher.js +1 -1
  7. package/src/adapters/shared/http/fs-server-response-matcher.js +15 -7
  8. package/src/adapters/shared/runtime/collect-dev-prewarm-plan.d.ts +11 -0
  9. package/src/adapters/shared/runtime/collect-dev-prewarm-plan.js +29 -0
  10. package/src/adapters/shared/runtime/dev-static-route-prewarm.d.ts +16 -0
  11. package/src/adapters/shared/runtime/dev-static-route-prewarm.js +66 -0
  12. package/src/adapters/shared/runtime/render-context.js +1 -1
  13. package/src/adapters/shared/runtime/server-adapter.d.ts +7 -1
  14. package/src/adapters/shared/runtime/server-adapter.js +64 -5
  15. package/src/build/README.md +1 -1
  16. package/src/build/browser/browser-runtime-plugin.js +2 -5
  17. package/src/build/cache/production-build-cache.d.ts +3 -2
  18. package/src/build/cache/production-build-cache.js +2 -2
  19. package/src/build/contracts/content-virtual-modules.d.ts +12 -0
  20. package/src/build/contracts/content-virtual-modules.js +29 -0
  21. package/src/build/rolldown/rolldown-adapter-helpers.js +1 -1
  22. package/src/build/runtime/build-request-policy.d.ts +3 -4
  23. package/src/build/runtime/build-request-policy.js +4 -4
  24. package/src/cache/index.d.ts +2 -1
  25. package/src/cache/index.js +2 -1
  26. package/src/cache/module-parse-cache.d.ts +5 -0
  27. package/src/cache/module-parse-cache.js +27 -3
  28. package/src/cache/module-transform-profiler.d.ts +8 -0
  29. package/src/cache/module-transform-profiler.js +8 -0
  30. package/src/client/view-transitions.d.ts +17 -1
  31. package/src/client/view-transitions.js +45 -1
  32. package/src/client/view-transitions.test.browser.d.ts +1 -0
  33. package/src/client/view-transitions.test.browser.js +56 -0
  34. package/src/config/config-builder.d.ts +16 -9
  35. package/src/config/config-builder.js +24 -14
  36. package/src/dev/transform-server/dev-transform-vendor-registry.d.ts +1 -0
  37. package/src/dev/transform-server/dev-transform-vendor-registry.js +15 -2
  38. package/src/diagnostics/request-pipeline-metrics.d.ts +38 -0
  39. package/src/diagnostics/request-pipeline-metrics.js +121 -0
  40. package/src/eco/component-identity.d.ts +13 -0
  41. package/src/eco/component-identity.js +18 -0
  42. package/src/eco/eco-declared-component.d.ts +3 -2
  43. package/src/eco/eco-declared-component.js +6 -4
  44. package/src/eco/eco.browser.js +11 -4
  45. package/src/eco/eco.js +15 -8
  46. package/src/eco/eco.types.d.ts +34 -6
  47. package/src/errors/http-error.d.ts +8 -0
  48. package/src/errors/http-error.js +16 -0
  49. package/src/errors/undeclared-component-dependency-error.d.ts +1 -1
  50. package/src/errors/undeclared-component-dependency-error.js +2 -2
  51. package/src/index.browser.d.ts +1 -0
  52. package/src/index.browser.js +1 -0
  53. package/src/index.d.ts +1 -0
  54. package/src/index.js +1 -0
  55. package/src/plugins/README.md +2 -1
  56. package/src/plugins/eco-component-meta-plugin.d.ts +6 -102
  57. package/src/plugins/eco-component-meta-plugin.js +110 -391
  58. package/src/plugins/processor.d.ts +10 -0
  59. package/src/plugins/processor.js +9 -0
  60. package/src/route-renderer/README.md +12 -9
  61. package/src/route-renderer/orchestration/document-shell/document-shell-render.service.js +1 -1
  62. package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.js +1 -1
  63. package/src/route-renderer/orchestration/integration-renderer.d.ts +19 -11
  64. package/src/route-renderer/orchestration/integration-renderer.js +95 -27
  65. package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.js +5 -4
  66. package/src/route-renderer/orchestration/ownership-graph/component-graph.js +6 -4
  67. package/src/route-renderer/orchestration/ownership-graph/ownership-validation.service.d.ts +0 -1
  68. package/src/route-renderer/orchestration/ownership-graph/ownership-validation.service.js +10 -19
  69. package/src/route-renderer/orchestration/page-browser-graph/grouped-graph-build-plan.d.ts +15 -0
  70. package/src/route-renderer/orchestration/page-browser-graph/grouped-graph-build-plan.js +9 -0
  71. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-contribution.merge.d.ts +5 -0
  72. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-contribution.merge.js +30 -0
  73. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-session.d.ts +23 -6
  74. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-session.js +156 -67
  75. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph.service.d.ts +18 -2
  76. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph.service.js +63 -39
  77. package/src/route-renderer/orchestration/page-browser-graph/route-instance-key.d.ts +29 -0
  78. package/src/route-renderer/orchestration/page-browser-graph/route-instance-key.js +54 -0
  79. package/src/route-renderer/orchestration/route-pipeline/integration-route-render-adapter.d.ts +3 -0
  80. package/src/route-renderer/orchestration/route-pipeline/integration-route-render-adapter.js +3 -2
  81. package/src/route-renderer/orchestration/route-pipeline/route-prepared-options.builder.d.ts +1 -0
  82. package/src/route-renderer/orchestration/route-pipeline/route-prepared-options.builder.js +3 -5
  83. package/src/route-renderer/orchestration/route-pipeline/route-render-orchestrator.d.ts +14 -4
  84. package/src/route-renderer/orchestration/route-pipeline/route-render-orchestrator.js +91 -32
  85. package/src/route-renderer/page-loading/component-dependency-collection.js +3 -2
  86. package/src/route-renderer/page-loading/ecopages-virtual-imports.d.ts +0 -5
  87. package/src/route-renderer/page-loading/ecopages-virtual-imports.js +4 -10
  88. package/src/route-renderer/page-loading/file-scoped-dependency-components.d.ts +42 -0
  89. package/src/route-renderer/page-loading/file-scoped-dependency-components.js +94 -0
  90. package/src/route-renderer/page-loading/lazy-trigger-planning.js +2 -1
  91. package/src/route-renderer/page-loading/page-module-loader.d.ts +2 -1
  92. package/src/route-renderer/page-loading/page-module-loader.js +9 -3
  93. package/src/route-renderer/page-loading/resolved-page-dependencies.d.ts +12 -0
  94. package/src/route-renderer/page-loading/resolved-page-dependencies.js +38 -0
  95. package/src/router/README.md +8 -0
  96. package/src/services/README.md +5 -0
  97. package/src/services/assets/asset-processing-service/asset-processing.service.d.ts +1 -0
  98. package/src/services/assets/asset-processing-service/asset-processing.service.js +22 -5
  99. package/src/services/cache/cache.types.d.ts +4 -2
  100. package/src/services/cache/html-page-cache-dependency-index.d.ts +34 -0
  101. package/src/services/cache/html-page-cache-dependency-index.js +88 -0
  102. package/src/services/cache/index.d.ts +2 -0
  103. package/src/services/cache/index.js +2 -0
  104. package/src/services/cache/page-cache-service.d.ts +31 -1
  105. package/src/services/cache/page-cache-service.js +79 -3
  106. package/src/services/cache/page-request-cache-coordinator.service.js +4 -0
  107. package/src/services/module-loading/README.md +9 -6
  108. package/src/services/module-loading/app-server-module-transpiler.service.js +0 -3
  109. package/src/services/module-loading/collection-server-module-build.service.d.ts +19 -0
  110. package/src/services/module-loading/collection-server-module-build.service.js +83 -0
  111. package/src/services/module-loading/page-module-import.service.d.ts +5 -11
  112. package/src/services/module-loading/page-module-import.service.js +56 -73
  113. package/src/services/module-loading/route-module-build-cache.store.js +6 -6
  114. package/src/services/module-loading/route-module-build-manifest.d.ts +8 -5
  115. package/src/services/module-loading/route-module-build-manifest.js +20 -32
  116. package/src/services/module-loading/server-module-transpiler.service.d.ts +0 -2
  117. package/src/services/module-loading/server-module-transpiler.service.js +0 -3
  118. package/src/static-site-generator/production-page-browser-graph-prebuild.d.ts +10 -6
  119. package/src/static-site-generator/production-page-browser-graph-prebuild.js +29 -10
  120. package/src/static-site-generator/static-site-generator.js +6 -3
  121. package/src/types/internal-types.d.ts +4 -0
  122. package/src/types/public-types.d.ts +27 -19
  123. package/src/watchers/project-watcher.js +14 -0
  124. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-contribution.loader.d.ts +0 -9
  125. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-contribution.loader.js +0 -11
@@ -1,6 +1,7 @@
1
1
  import { invariant } from '../../utils/invariant.js';
2
2
  import { getAppModuleLoader } from '../../services/module-loading/app-server-module-transpiler.service.js';
3
3
  import { resolveInternalExecutionDir } from '../../utils/resolve-work-dir.js';
4
+ import { HttpError } from '../../errors/http-error.js';
4
5
  /**
5
6
  * Loads route page modules and normalizes their data hooks for rendering.
6
7
  *
@@ -39,7 +40,6 @@ export class PageModuleLoaderService {
39
40
  rootDir: this.appConfig.rootDir,
40
41
  outdir: `${resolveInternalExecutionDir(this.appConfig)}/.server-modules`,
41
42
  bypassCache: options?.bypassCache,
42
- cacheScope: options?.cacheScope,
43
43
  transpileErrorMessage: (details) => `Error transpiling page file: ${details}`,
44
44
  noOutputMessage: (targetFilePath) => `No transpiled output generated for page: ${targetFilePath}`,
45
45
  });
@@ -65,7 +65,11 @@ export class PageModuleLoaderService {
65
65
  })
66
66
  .then((data) => data)
67
67
  .catch((err) => {
68
- throw new Error(`Error fetching static props: ${err.message}`);
68
+ if (HttpError.isHttpError(err) || err instanceof Response) {
69
+ throw err;
70
+ }
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ throw new Error(`Error fetching static props: ${message}`, { cause: err });
69
73
  })
70
74
  : {
71
75
  props: {},
@@ -98,9 +102,11 @@ export class PageModuleLoaderService {
98
102
  * When both component static methods and module exports exist, component statics win.
99
103
  */
100
104
  async resolvePageModule(options) {
101
- const module = await (options.importPageFileFn ?? ((file) => this.importPageFile(file)))(options.file);
105
+ const module = options.pageModule ??
106
+ (await (options.importPageFileFn ?? ((file) => this.importPageFile(file)))(options.file));
102
107
  const { default: Page, getStaticProps: moduleGetStaticProps, getMetadata: moduleGetMetadata, ...integrationSpecificProps } = module;
103
108
  return {
109
+ module,
104
110
  Page,
105
111
  getStaticProps: Page.staticProps ?? moduleGetStaticProps,
106
112
  getMetadata: Page.metadata ?? moduleGetMetadata,
@@ -0,0 +1,12 @@
1
+ import type { EcoComponent, PageBrowserGraphContribution, PageBrowserGraphContributionContext } from '../../types/public-types.js';
2
+ import { collectFileScopedDependencyComponents } from './file-scoped-dependency-components.js';
3
+ export type ResolvedPageDependencies = {
4
+ ownerFile: string;
5
+ components: ReadonlyArray<EcoComponent | Partial<EcoComponent>>;
6
+ contribution?: PageBrowserGraphContribution;
7
+ };
8
+ export type ResolvePageDependenciesContribution = (dependencies: Parameters<typeof collectFileScopedDependencyComponents>[0]['dependencies'], ownerFile: string, components: ReadonlyArray<EcoComponent | Partial<EcoComponent>>) => Promise<PageBrowserGraphContribution | undefined>;
9
+ /**
10
+ * Resolves `eco.page().dependencies()` once for one graph contribution context.
11
+ */
12
+ export declare function resolvePageDependenciesFromContext(context: PageBrowserGraphContributionContext, integrationName: string, materializeContribution: ResolvePageDependenciesContribution): Promise<ResolvedPageDependencies | undefined>;
@@ -0,0 +1,38 @@
1
+ import { collectFileScopedDependencyComponents, splitPageDependenciesResult, } from './file-scoped-dependency-components.js';
2
+ /**
3
+ * Resolves `eco.page().dependencies()` once for one graph contribution context.
4
+ */
5
+ export async function resolvePageDependenciesFromContext(context, integrationName, materializeContribution) {
6
+ const pageComponent = context.pageModule.default;
7
+ const resolveDependencies = pageComponent.resolveDependencies;
8
+ if (!resolveDependencies) {
9
+ return undefined;
10
+ }
11
+ const dependenciesResult = await resolveDependencies({
12
+ props: (context.props ?? {}),
13
+ params: context.params,
14
+ query: context.query,
15
+ });
16
+ if (!dependenciesResult) {
17
+ return undefined;
18
+ }
19
+ const { dependencies, ownerFile } = splitPageDependenciesResult(dependenciesResult);
20
+ const resolvedOwnerFile = ownerFile ?? context.file;
21
+ const components = collectFileScopedDependencyComponents({
22
+ ownerFile: resolvedOwnerFile,
23
+ integrationName,
24
+ dependencies,
25
+ });
26
+ if (components.length === 0) {
27
+ return {
28
+ ownerFile: resolvedOwnerFile,
29
+ components,
30
+ };
31
+ }
32
+ const contribution = await materializeContribution(dependencies, resolvedOwnerFile, components);
33
+ return {
34
+ ownerFile: resolvedOwnerFile,
35
+ components,
36
+ contribution,
37
+ };
38
+ }
@@ -60,6 +60,14 @@ Match priority:
60
60
 
61
61
  ## `client/`
62
62
 
63
+ ### View transitions (`@ecopages/core/client/view-transitions`)
64
+
65
+ Shared helpers used by `@ecopages/browser-router` and `@ecopages/react-router`:
66
+
67
+ - `applyViewTransitionNames` / `clearViewTransitionNames` — named `data-view-transition` morph / fade styles (`eco-vt-dynamic-styles`).
68
+ - `ensureRootViewTransitionStyles` — persisted CSS (`eco-vt-root-styles`, `data-eco-persist`) with `html { view-transition-name: none }` when VT is enabled.
69
+ - `documentHasNamedViewTransitions` / `navigationHasNamedViewTransitions` — gate `startViewTransition` to pages with `data-view-transition` markup.
70
+
63
71
  ### Navigation Coordinator (`navigation-coordinator.ts`)
64
72
 
65
73
  A singleton browser-side runtime stored on `window.__ECO_PAGES__.navigation`.
@@ -18,11 +18,16 @@ Typical responsibilities include:
18
18
 
19
19
  - `module-loading/`: framework-owned config/app bootstrap loading and server-side source loading
20
20
  - `assets/`: shared browser build coordination and processed asset pipelines
21
+ - `cache/`: page HTML cache stores, selective source-path invalidation, and request coordination
21
22
  - `invalidation/`: file-change classification and invalidation policy
22
23
  - `runtime-state/`: app-owned invalidation state and dependency graphs
23
24
  - `runtime-manifest/`: node runtime manifest derivation and persistence
24
25
  - `html/`: final HTML dependency injection and rewriter selection
25
26
 
27
+ The asset-processing service caches emitted file assets by source identity in development as well as production.
28
+ When a source file changes, its source hash or explicit invalidation removes the cached asset before the next render,
29
+ so shared layout styles do not need to be rebuilt for every navigated page while HMR remains fresh.
30
+
26
31
  ## Design Rule
27
32
 
28
33
  If a concern affects more than one integration or more than one runtime adapter, it usually belongs here instead of in a package-specific implementation.
@@ -89,6 +89,7 @@ export declare class AssetProcessingService {
89
89
  * Stores one processed asset in the dependency cache.
90
90
  */
91
91
  private setCachedAsset;
92
+ private getFileSourceHash;
92
93
  /**
93
94
  * Clears all cached processed assets.
94
95
  */
@@ -229,16 +229,22 @@ export class AssetProcessingService {
229
229
  * Returns the cached processed asset for a dependency key when available.
230
230
  */
231
231
  getCachedAsset(dep, depKey) {
232
- if (process.env.NODE_ENV !== 'production' && dep.source === 'file' && dep.kind === 'stylesheet') {
233
- return null;
234
- }
235
232
  if (dep.kind === 'script' && dep.source === 'content') {
236
233
  return this.getCachedContentScriptAsset(dep, depKey);
237
234
  }
235
+ const sourceHash = this.getFileSourceHash(dep);
236
+ if (dep.source === 'file' && sourceHash === undefined) {
237
+ this.cache.delete(depKey);
238
+ return null;
239
+ }
238
240
  const cached = this.cache.get(depKey);
239
241
  if (!cached) {
240
242
  return null;
241
243
  }
244
+ if (cached.sourceHash !== sourceHash) {
245
+ this.cache.delete(depKey);
246
+ return null;
247
+ }
242
248
  if (cached.asset.filepath && !fileSystem.exists(cached.asset.filepath)) {
243
249
  this.cache.delete(depKey);
244
250
  return null;
@@ -269,7 +275,16 @@ export class AssetProcessingService {
269
275
  * Stores one processed asset in the dependency cache.
270
276
  */
271
277
  setCachedAsset(dep, depKey, asset) {
272
- this.cache.set(depKey, { asset });
278
+ this.cache.set(depKey, {
279
+ asset,
280
+ sourceHash: this.getFileSourceHash(dep),
281
+ });
282
+ }
283
+ getFileSourceHash(dep) {
284
+ if (dep.source !== 'file' || !('filepath' in dep) || !fileSystem.exists(dep.filepath)) {
285
+ return undefined;
286
+ }
287
+ return fileSystem.hash(dep.filepath);
273
288
  }
274
289
  /**
275
290
  * Clears all cached processed assets.
@@ -282,7 +297,9 @@ export class AssetProcessingService {
282
297
  */
283
298
  invalidateCacheForFile(filepath) {
284
299
  for (const [key, value] of this.cache.entries()) {
285
- if (value.asset.filepath === filepath) {
300
+ if (value.asset.filepath === filepath ||
301
+ value.asset.sourceFilepath === filepath ||
302
+ value.asset.bundledSourceFilepaths?.includes(filepath)) {
286
303
  this.cache.delete(key);
287
304
  }
288
305
  }
@@ -8,6 +8,8 @@
8
8
  export interface RenderResult {
9
9
  html: string;
10
10
  strategy: CacheStrategy;
11
+ /** Source paths that contributed to this render; used for selective HTML cache invalidation. */
12
+ sourceDependencyPaths?: readonly string[];
11
13
  }
12
14
  /**
13
15
  * Render strategy configuration for pages.
@@ -83,8 +85,8 @@ export interface CacheConfig {
83
85
  defaultStrategy?: CacheStrategy;
84
86
  /**
85
87
  * Whether caching is enabled.
86
- * Automatically disabled in dev mode unless explicitly set.
87
- * @default true (production), false (development)
88
+ * In watch mode, defaults to `false` unless explicitly enabled.
89
+ * @default true in production builds, false in watch when omitted
88
90
  */
89
91
  enabled?: boolean;
90
92
  /**
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Maps source dependency paths to rendered HTML cache keys for selective invalidation.
3
+ */
4
+ export declare class HtmlPageCacheDependencyIndex {
5
+ private readonly sourcePathToCacheKeys;
6
+ private readonly cacheKeyToSourcePaths;
7
+ /**
8
+ * Records which source paths contributed to one cached HTML entry.
9
+ */
10
+ register(cacheKey: string, sourcePaths: readonly string[]): void;
11
+ /**
12
+ * Removes dependency metadata for one cache key.
13
+ */
14
+ unregister(cacheKey: string): void;
15
+ /**
16
+ * Returns cache keys that depend on any of the given source paths.
17
+ */
18
+ resolveCacheKeysForSourcePaths(sourcePaths: readonly string[]): string[];
19
+ clear(): void;
20
+ }
21
+ type CollectHtmlCacheSourceDependencyPathsInput = {
22
+ routeFile: string;
23
+ processedAssets: readonly {
24
+ sourceFilepath?: string;
25
+ bundledSourceFilepaths?: readonly string[];
26
+ }[];
27
+ graphDependencyPaths?: ReadonlySet<string>;
28
+ additionalSourcePaths?: readonly string[];
29
+ };
30
+ /**
31
+ * Merges route, graph, processed-asset, and caller-supplied source paths for HTML cache invalidation.
32
+ */
33
+ export declare function collectHtmlCacheSourceDependencyPaths(input: CollectHtmlCacheSourceDependencyPathsInput): string[];
34
+ export {};
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Maps source dependency paths to rendered HTML cache keys for selective invalidation.
4
+ */
5
+ export class HtmlPageCacheDependencyIndex {
6
+ sourcePathToCacheKeys = new Map();
7
+ cacheKeyToSourcePaths = new Map();
8
+ /**
9
+ * Records which source paths contributed to one cached HTML entry.
10
+ */
11
+ register(cacheKey, sourcePaths) {
12
+ this.unregister(cacheKey);
13
+ if (sourcePaths.length === 0) {
14
+ return;
15
+ }
16
+ const normalizedSourcePaths = new Set(sourcePaths.map((sourcePath) => normalizeSourcePath(sourcePath)));
17
+ this.cacheKeyToSourcePaths.set(cacheKey, normalizedSourcePaths);
18
+ for (const sourcePath of normalizedSourcePaths) {
19
+ const cacheKeys = this.sourcePathToCacheKeys.get(sourcePath) ?? new Set();
20
+ cacheKeys.add(cacheKey);
21
+ this.sourcePathToCacheKeys.set(sourcePath, cacheKeys);
22
+ }
23
+ }
24
+ /**
25
+ * Removes dependency metadata for one cache key.
26
+ */
27
+ unregister(cacheKey) {
28
+ const sourcePaths = this.cacheKeyToSourcePaths.get(cacheKey);
29
+ if (!sourcePaths) {
30
+ return;
31
+ }
32
+ for (const sourcePath of sourcePaths) {
33
+ const cacheKeys = this.sourcePathToCacheKeys.get(sourcePath);
34
+ cacheKeys?.delete(cacheKey);
35
+ if (cacheKeys?.size === 0) {
36
+ this.sourcePathToCacheKeys.delete(sourcePath);
37
+ }
38
+ }
39
+ this.cacheKeyToSourcePaths.delete(cacheKey);
40
+ }
41
+ /**
42
+ * Returns cache keys that depend on any of the given source paths.
43
+ */
44
+ resolveCacheKeysForSourcePaths(sourcePaths) {
45
+ const cacheKeys = new Set();
46
+ for (const sourcePath of sourcePaths) {
47
+ const normalizedSourcePath = normalizeSourcePath(sourcePath);
48
+ const matches = this.sourcePathToCacheKeys.get(normalizedSourcePath);
49
+ if (!matches) {
50
+ continue;
51
+ }
52
+ for (const cacheKey of matches) {
53
+ cacheKeys.add(cacheKey);
54
+ }
55
+ }
56
+ return [...cacheKeys];
57
+ }
58
+ clear() {
59
+ this.sourcePathToCacheKeys.clear();
60
+ this.cacheKeyToSourcePaths.clear();
61
+ }
62
+ }
63
+ function normalizeSourcePath(sourcePath) {
64
+ return path.resolve(sourcePath);
65
+ }
66
+ /**
67
+ * Merges route, graph, processed-asset, and caller-supplied source paths for HTML cache invalidation.
68
+ */
69
+ export function collectHtmlCacheSourceDependencyPaths(input) {
70
+ const sourcePaths = new Set([normalizeSourcePath(input.routeFile)]);
71
+ for (const additionalPath of input.additionalSourcePaths ?? []) {
72
+ sourcePaths.add(normalizeSourcePath(additionalPath));
73
+ }
74
+ if (input.graphDependencyPaths) {
75
+ for (const graphPath of input.graphDependencyPaths) {
76
+ sourcePaths.add(normalizeSourcePath(graphPath));
77
+ }
78
+ }
79
+ for (const asset of input.processedAssets) {
80
+ if (asset.sourceFilepath) {
81
+ sourcePaths.add(normalizeSourcePath(asset.sourceFilepath));
82
+ }
83
+ for (const bundledSourceFilepath of asset.bundledSourceFilepaths ?? []) {
84
+ sourcePaths.add(normalizeSourcePath(bundledSourceFilepath));
85
+ }
86
+ }
87
+ return [...sourcePaths];
88
+ }
@@ -4,4 +4,6 @@
4
4
  */
5
5
  export type { CacheConfig, CacheEntry, CacheResult, CacheStats, CacheStore, CacheStrategy, RenderResult, } from './cache.types.js';
6
6
  export { MemoryCacheStore, type MemoryCacheStoreOptions } from './memory-cache-store.js';
7
+ export { HtmlPageCacheDependencyIndex } from './html-page-cache-dependency-index.js';
7
8
  export { getCacheControlHeader, PageCacheService, type PageCacheServiceOptions } from './page-cache-service.js';
9
+ export { clearAppPageCache, getAppPageCacheService, invalidateAppPageCacheBySourcePaths, registerAppPageCacheService, } from './page-cache-service.js';
@@ -3,4 +3,6 @@
3
3
  * @module
4
4
  */
5
5
  export { MemoryCacheStore } from './memory-cache-store.js';
6
+ export { HtmlPageCacheDependencyIndex } from './html-page-cache-dependency-index.js';
6
7
  export { getCacheControlHeader, PageCacheService } from './page-cache-service.js';
8
+ export { clearAppPageCache, getAppPageCacheService, invalidateAppPageCacheBySourcePaths, registerAppPageCacheService, } from './page-cache-service.js';
@@ -3,10 +3,13 @@
3
3
  * Handles stale-while-revalidate semantics and background regeneration.
4
4
  * @module
5
5
  */
6
+ import type { EcoPagesAppConfig } from '../../types/internal-types.js';
6
7
  import type { CacheResult, CacheStore, CacheStrategy, RenderResult } from './cache.types.js';
8
+ import { HtmlPageCacheDependencyIndex } from './html-page-cache-dependency-index.js';
7
9
  export interface PageCacheServiceOptions {
8
10
  store?: CacheStore;
9
11
  enabled?: boolean;
12
+ dependencyIndex?: HtmlPageCacheDependencyIndex;
10
13
  }
11
14
  /**
12
15
  * Core page caching service with ISR support.
@@ -14,7 +17,9 @@ export interface PageCacheServiceOptions {
14
17
  export declare class PageCacheService {
15
18
  private store;
16
19
  private enabled;
20
+ private readonly dependencyIndex;
17
21
  private regenerationPromises;
22
+ private missPromises;
18
23
  constructor(options?: PageCacheServiceOptions);
19
24
  /**
20
25
  * Generate a cache key from URL and optional params.
@@ -35,7 +40,11 @@ export declare class PageCacheService {
35
40
  * @param defaultStrategy - Default strategy if page doesn't specify one
36
41
  * @param renderFn - Function that renders the page and returns HTML + strategy
37
42
  */
38
- getOrCreate(key: string, defaultStrategy: CacheStrategy, renderFn: () => Promise<RenderResult>): Promise<CacheResult>;
43
+ getOrCreate(key: string, defaultStrategy: CacheStrategy, renderFn: () => Promise<RenderResult>, options?: {
44
+ sourceDependencyPaths?: readonly string[];
45
+ }): Promise<CacheResult>;
46
+ private resolveOrCreate;
47
+ private registerDependencyPaths;
39
48
  /**
40
49
  * Regenerate content in the background without blocking the response.
41
50
  * Uses promise deduplication to prevent multiple concurrent regenerations.
@@ -49,6 +58,11 @@ export declare class PageCacheService {
49
58
  * Invalidate cache entries by paths.
50
59
  */
51
60
  invalidateByPaths(paths: string[]): Promise<number>;
61
+ /**
62
+ * Invalidates cached HTML entries that registered the given source paths.
63
+ */
64
+ invalidateBySourceDependencyPaths(sourcePaths: readonly string[]): Promise<number>;
65
+ getDependencyIndex(): HtmlPageCacheDependencyIndex;
52
66
  /**
53
67
  * Clear all cached entries.
54
68
  */
@@ -68,3 +82,19 @@ export declare class PageCacheService {
68
82
  * Generate Cache-Control header value from cache strategy.
69
83
  */
70
84
  export declare function getCacheControlHeader(strategy: CacheStrategy | 'disabled'): string;
85
+ /**
86
+ * Registers the page cache service for one app config instance.
87
+ */
88
+ export declare function registerAppPageCacheService(appConfig: EcoPagesAppConfig, service: PageCacheService | null): void;
89
+ /**
90
+ * Returns the registered page cache service for one app config, if any.
91
+ */
92
+ export declare function getAppPageCacheService(appConfig: EcoPagesAppConfig): PageCacheService | null;
93
+ /**
94
+ * Clears rendered HTML cache entries for one app during development invalidation.
95
+ */
96
+ export declare function clearAppPageCache(appConfig: EcoPagesAppConfig): Promise<void>;
97
+ /**
98
+ * Invalidates rendered HTML cache entries that depend on the given source paths.
99
+ */
100
+ export declare function invalidateAppPageCacheBySourcePaths(appConfig: EcoPagesAppConfig, sourcePaths: readonly string[]): Promise<number>;
@@ -5,16 +5,20 @@
5
5
  */
6
6
  import { appLogger } from '../../global/app-logger.js';
7
7
  import { MemoryCacheStore } from './memory-cache-store.js';
8
+ import { HtmlPageCacheDependencyIndex } from './html-page-cache-dependency-index.js';
8
9
  /**
9
10
  * Core page caching service with ISR support.
10
11
  */
11
12
  export class PageCacheService {
12
13
  store;
13
14
  enabled;
15
+ dependencyIndex;
14
16
  regenerationPromises = new Map();
17
+ missPromises = new Map();
15
18
  constructor(options = {}) {
16
19
  this.store = options.store ?? new MemoryCacheStore();
17
20
  this.enabled = options.enabled ?? true;
21
+ this.dependencyIndex = options.dependencyIndex ?? new HtmlPageCacheDependencyIndex();
18
22
  }
19
23
  /**
20
24
  * Generate a cache key from URL and optional params.
@@ -62,20 +66,32 @@ export class PageCacheService {
62
66
  * @param defaultStrategy - Default strategy if page doesn't specify one
63
67
  * @param renderFn - Function that renders the page and returns HTML + strategy
64
68
  */
65
- async getOrCreate(key, defaultStrategy, renderFn) {
69
+ async getOrCreate(key, defaultStrategy, renderFn, options) {
66
70
  if (!this.enabled) {
67
71
  const { html, strategy } = await renderFn();
68
72
  return { html, status: 'miss', strategy };
69
73
  }
74
+ const pendingMiss = this.missPromises.get(key);
75
+ if (pendingMiss) {
76
+ return pendingMiss;
77
+ }
78
+ const missPromise = this.resolveOrCreate(key, defaultStrategy, renderFn, options).finally(() => {
79
+ this.missPromises.delete(key);
80
+ });
81
+ this.missPromises.set(key, missPromise);
82
+ return missPromise;
83
+ }
84
+ async resolveOrCreate(key, defaultStrategy, renderFn, options) {
70
85
  const entry = await this.store.get(key);
71
86
  if (!entry) {
72
- const { html, strategy } = await renderFn();
87
+ const { html, strategy, sourceDependencyPaths } = await renderFn();
73
88
  const effectiveStrategy = strategy ?? defaultStrategy;
74
89
  if (effectiveStrategy === 'dynamic') {
75
90
  return { html, status: 'miss', strategy: effectiveStrategy };
76
91
  }
77
92
  const newEntry = this.createEntry(html, effectiveStrategy);
78
93
  await this.store.set(key, newEntry);
94
+ this.registerDependencyPaths(key, options?.sourceDependencyPaths, sourceDependencyPaths);
79
95
  return { html, status: 'miss', strategy: effectiveStrategy };
80
96
  }
81
97
  if (!this.isStale(entry)) {
@@ -84,6 +100,18 @@ export class PageCacheService {
84
100
  this.regenerateInBackground(key, entry.strategy, renderFn);
85
101
  return { html: entry.html, status: 'stale', strategy: entry.strategy };
86
102
  }
103
+ registerDependencyPaths(key, initialPaths, renderedPaths) {
104
+ const mergedPaths = new Set();
105
+ for (const sourcePath of initialPaths ?? []) {
106
+ mergedPaths.add(sourcePath);
107
+ }
108
+ for (const sourcePath of renderedPaths ?? []) {
109
+ mergedPaths.add(sourcePath);
110
+ }
111
+ if (mergedPaths.size > 0) {
112
+ this.dependencyIndex.register(key, [...mergedPaths]);
113
+ }
114
+ }
87
115
  /**
88
116
  * Regenerate content in the background without blocking the response.
89
117
  * Uses promise deduplication to prevent multiple concurrent regenerations.
@@ -94,10 +122,11 @@ export class PageCacheService {
94
122
  }
95
123
  const regeneratePromise = (async () => {
96
124
  try {
97
- const { html, strategy } = await renderFn();
125
+ const { html, strategy, sourceDependencyPaths } = await renderFn();
98
126
  const effectiveStrategy = strategy ?? fallbackStrategy;
99
127
  const newEntry = this.createEntry(html, effectiveStrategy);
100
128
  await this.store.set(key, newEntry);
129
+ this.registerDependencyPaths(key, undefined, sourceDependencyPaths);
101
130
  return html;
102
131
  }
103
132
  finally {
@@ -123,10 +152,28 @@ export class PageCacheService {
123
152
  async invalidateByPaths(paths) {
124
153
  return this.store.invalidateByPaths(paths);
125
154
  }
155
+ /**
156
+ * Invalidates cached HTML entries that registered the given source paths.
157
+ */
158
+ async invalidateBySourceDependencyPaths(sourcePaths) {
159
+ const cacheKeys = this.dependencyIndex.resolveCacheKeysForSourcePaths(sourcePaths);
160
+ let count = 0;
161
+ for (const cacheKey of cacheKeys) {
162
+ if (await this.store.delete(cacheKey)) {
163
+ count += 1;
164
+ }
165
+ this.dependencyIndex.unregister(cacheKey);
166
+ }
167
+ return count;
168
+ }
169
+ getDependencyIndex() {
170
+ return this.dependencyIndex;
171
+ }
126
172
  /**
127
173
  * Clear all cached entries.
128
174
  */
129
175
  async clear() {
176
+ this.dependencyIndex.clear();
130
177
  return this.store.clear();
131
178
  }
132
179
  /**
@@ -161,3 +208,32 @@ export function getCacheControlHeader(strategy) {
161
208
  }
162
209
  return 'no-store';
163
210
  }
211
+ const pageCacheByAppConfig = new WeakMap();
212
+ /**
213
+ * Registers the page cache service for one app config instance.
214
+ */
215
+ export function registerAppPageCacheService(appConfig, service) {
216
+ if (service) {
217
+ pageCacheByAppConfig.set(appConfig, service);
218
+ return;
219
+ }
220
+ pageCacheByAppConfig.delete(appConfig);
221
+ }
222
+ /**
223
+ * Returns the registered page cache service for one app config, if any.
224
+ */
225
+ export function getAppPageCacheService(appConfig) {
226
+ return pageCacheByAppConfig.get(appConfig) ?? null;
227
+ }
228
+ /**
229
+ * Clears rendered HTML cache entries for one app during development invalidation.
230
+ */
231
+ export async function clearAppPageCache(appConfig) {
232
+ await getAppPageCacheService(appConfig)?.clear();
233
+ }
234
+ /**
235
+ * Invalidates rendered HTML cache entries that depend on the given source paths.
236
+ */
237
+ export async function invalidateAppPageCacheBySourcePaths(appConfig, sourcePaths) {
238
+ return (await getAppPageCacheService(appConfig)?.invalidateBySourceDependencyPaths(sourcePaths)) ?? 0;
239
+ }
@@ -1,4 +1,5 @@
1
1
  import { getCacheControlHeader } from './page-cache-service.js';
2
+ import { getRequestPipelineMetricsHeaderName, isRequestPipelineMetricsEnabled, serializeRequestPipelineMetricsHeader, } from '../../diagnostics/request-pipeline-metrics.js';
2
3
  /**
3
4
  * Coordinates request-time page caching concerns around one render invocation.
4
5
  *
@@ -104,6 +105,9 @@ export class PageRequestCacheCoordinator {
104
105
  'Cache-Control': getCacheControlHeader(cacheStatus === 'disabled' ? 'disabled' : strategy),
105
106
  'X-Cache': cacheStatus.toUpperCase(),
106
107
  };
108
+ if (isRequestPipelineMetricsEnabled()) {
109
+ headers[getRequestPipelineMetricsHeaderName()] = serializeRequestPipelineMetricsHeader();
110
+ }
107
111
  return new Response(html, { headers });
108
112
  }
109
113
  }
@@ -12,11 +12,11 @@ Call site (route scan, renderer, SSG, API)
12
12
  └─ PageModuleImportService.importModule()
13
13
  ├─ dev + host loader → host runtime (Node only, when configured)
14
14
  ├─ production disk cache → route-module build cache
15
- ├─ unified pages graph → prebuilt chunk import
15
+ ├─ unified pages graph → prebuilt chunk import (production only)
16
16
  └─ Rolldown per-page build → dynamic import of transpiled output
17
17
  ```
18
18
 
19
- `ServerModuleTranspiler` is a thin wrapper that injects `rootDir`, optional default plugins, and invalidation version into the same `PageModuleImportService` path. App code should prefer `getAppModuleLoader(appConfig)` or `getAppServerModuleTranspiler(appConfig)`.
19
+ `ServerModuleTranspiler` is a thin wrapper that injects `rootDir` and optional default plugins into the same `PageModuleImportService` path. App code should prefer `getAppModuleLoader(appConfig)` or `getAppServerModuleTranspiler(appConfig)`.
20
20
 
21
21
  ## Host vs app ownership
22
22
 
@@ -29,15 +29,18 @@ Call site (route scan, renderer, SSG, API)
29
29
 
30
30
  ## Caching layers
31
31
 
32
- 1. **In-memory promise cache** (`PageModuleImportService.importCache`) — keyed by file path, resolved `outdir`, JSX/plugin inputs, file hash, and invalidation version. Cleared by `invalidateDevelopmentGraph()`.
33
- 2. **Disk transpile cache** (`.eco/.server-modules/.build-cache.json`) — production only; see route-module build cache slice.
34
- 3. **Unified graph manifest** — production static export fast path; see build layer docs.
32
+ 1. **In-memory promise cache** (`PageModuleImportService.importCache`) — keyed by runtime, file path, content-derived reuse identity (`createRouteModuleReuseIdentity`), and source hash. Cleared by `invalidateDevelopmentGraph()`.
33
+ 2. **Disk transpile cache** (`.eco/.server-modules/.build-cache.json`) — production and stable development graphs when dependency hashes match. Manifest field `corePackageVersion` invalidates entries when the framework package changes.
34
+ 3. **Unified graph manifest** — production static export fast path only; see build layer docs.
35
+
36
+ Development `?update=` query params use `sourceHash` plus a per-service import generation counter so Node and Bun bust ESM module cache after `invalidateDevelopmentGraph()` without a process-wide invalidation version in reuse keys.
35
37
 
36
38
  ## Files
37
39
 
38
40
  | File | Role |
39
41
  | ----------------------------------------- | --------------------------------------------------------- |
40
42
  | `page-module-import.service.ts` | Core import/cache/build orchestration |
43
+ | `route-module-build-manifest.ts` | Content-derived reuse identity and disk cache manifest |
41
44
  | `app-module-loader.service.ts` | App-facing loader interface |
42
45
  | `app-server-module-transpiler.service.ts` | Factory for app-scoped loader + transpiler |
43
46
  | `server-module-transpiler.service.ts` | Injectable transpiler boundary for tests and bootstrap |
@@ -46,4 +49,4 @@ Call site (route scan, renderer, SSG, API)
46
49
 
47
50
  ## Development invalidation
48
51
 
49
- `DevelopmentInvalidationService.invalidateServerModules()` bumps the app-owned invalidation version and calls `appModuleLoader.invalidateDevelopmentGraph()`, which clears the in-memory import cache and increments the per-service invalidation counter used in cache keys and dev `?update=` query params.
52
+ `DevelopmentInvalidationService.invalidateServerModules()` calls `appModuleLoader.invalidateDevelopmentGraph()`, which clears the in-memory import cache, bumps the per-service dev import generation used only for `?update=` URLs, and clears dependency-hash memoization. Route-module disk reuse stays content-derived: unchanged source and dependency hashes keep their entries until the edited module or its graph changes.
@@ -43,7 +43,6 @@ export function setAppHostModuleLoader(appConfig, hostModuleLoader) {
43
43
  * {@link createServerBuildRequest}; this loader does not merge app plugins.
44
44
  */
45
45
  export function createAppModuleLoader(appConfig) {
46
- const invalidationService = new DevelopmentInvalidationService(appConfig);
47
46
  const pageModuleImportService = new PageModuleImportService(appConfig, {
48
47
  canLoadSourceModuleFromHost: (filePath) => shouldAppUseHostModuleLoader(appConfig, filePath),
49
48
  getHostModuleLoader: () => getAppHostModuleLoader(appConfig),
@@ -54,11 +53,9 @@ export function createAppModuleLoader(appConfig) {
54
53
  },
55
54
  pageModuleImportService,
56
55
  async importModule(options) {
57
- const invalidationVersion = options.invalidationVersion ?? invalidationService.getServerModuleInvalidationVersion();
58
56
  return await pageModuleImportService.importModule({
59
57
  ...options,
60
58
  buildExecutor: options.buildExecutor ?? requireBuildRuntime(appConfig).getProfile('route-module'),
61
- invalidationVersion,
62
59
  });
63
60
  },
64
61
  invalidateDevelopmentGraph() {