@ecopages/core 0.2.0-beta.41 → 0.2.0-beta.43

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 (30) hide show
  1. package/package.json +11 -3
  2. package/src/adapters/shared/http/fs-server-response-matcher.js +3 -1
  3. package/src/build/browser/browser-runtime-manifest.d.ts +4 -0
  4. package/src/build/browser/browser-runtime-manifest.js +9 -2
  5. package/src/build/browser/browser-runtime-plugin-helpers.d.ts +3 -5
  6. package/src/build/browser/browser-runtime-plugin-helpers.js +4 -6
  7. package/src/build/browser/browser-runtime-plugin.d.ts +11 -12
  8. package/src/build/browser/browser-runtime-plugin.js +36 -34
  9. package/src/build/rolldown/rolldown-adapter-helpers.js +10 -5
  10. package/src/dev/transform-server/dev-transform-import-rewriter.js +2 -1
  11. package/src/dev/transform-server/dev-transform-vendor-registry.js +11 -3
  12. package/src/plugins/package-specifier.d.ts +12 -0
  13. package/src/plugins/package-specifier.js +33 -0
  14. package/src/plugins/tsconfig-import-resolver.d.ts +5 -3
  15. package/src/plugins/tsconfig-import-resolver.js +38 -11
  16. package/src/route-renderer/orchestration/route-pipeline/route-prepared-options.builder.js +3 -8
  17. package/src/services/assets/asset-processing-service/asset-processing.service.d.ts +5 -0
  18. package/src/services/assets/asset-processing-service/asset-processing.service.js +30 -3
  19. package/src/services/assets/browser-runtime-asset-generation.d.ts +17 -0
  20. package/src/services/assets/browser-runtime-asset-generation.js +28 -0
  21. package/src/services/cache/page-request-cache-coordinator.service.d.ts +2 -1
  22. package/src/services/cache/page-request-cache-coordinator.service.js +7 -1
  23. package/src/services/invalidation/development-invalidation.service.js +3 -1
  24. package/src/services/module-loading/README.md +2 -2
  25. package/src/services/module-loading/page-module-import.service.js +15 -4
  26. package/src/services/module-loading/route-module-build-cache-registry.d.ts +2 -0
  27. package/src/services/module-loading/route-module-build-cache-registry.js +12 -0
  28. package/src/services/module-loading/route-module-build-cache.store.d.ts +10 -0
  29. package/src/services/module-loading/route-module-build-cache.store.js +18 -0
  30. package/src/types/public-types.d.ts +5 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-beta.41",
3
+ "version": "0.2.0-beta.43",
4
4
  "description": "Core package for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -17,7 +17,7 @@
17
17
  "directory": "packages/core"
18
18
  },
19
19
  "dependencies": {
20
- "@ecopages/file-system": "0.2.0-beta.41",
20
+ "@ecopages/file-system": "0.2.0-beta.43",
21
21
  "@ecopages/logger": "^0.2.3",
22
22
  "@ecopages/scripts-injector": "^0.1.5",
23
23
  "@oxc-project/runtime": "0.141.0",
@@ -32,7 +32,7 @@
32
32
  "@standard-schema/utils": "^0.3.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@ecopages/dev-toolbar": "0.2.0-beta.41"
35
+ "@ecopages/dev-toolbar": "0.2.0-beta.43"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@ecopages/dev-toolbar": {
@@ -283,6 +283,10 @@
283
283
  "types": "./src/plugins/tsconfig-import-resolver.d.ts",
284
284
  "default": "./src/plugins/tsconfig-import-resolver.js"
285
285
  },
286
+ "./plugins/package-specifier": {
287
+ "types": "./src/plugins/package-specifier.d.ts",
288
+ "default": "./src/plugins/package-specifier.js"
289
+ },
286
290
  "./plugins/jsx-import-source.utils": {
287
291
  "types": "./src/plugins/jsx-import-source.utils.d.ts",
288
292
  "default": "./src/plugins/jsx-import-source.utils.js"
@@ -576,6 +580,10 @@
576
580
  "types": "./src/plugins/tsconfig-import-resolver.d.ts",
577
581
  "default": "./src/plugins/tsconfig-import-resolver.js"
578
582
  },
583
+ "./plugins/package-specifier.ts": {
584
+ "types": "./src/plugins/package-specifier.d.ts",
585
+ "default": "./src/plugins/package-specifier.js"
586
+ },
579
587
  "./plugins/jsx-import-source.utils.ts": {
580
588
  "types": "./src/plugins/jsx-import-source.utils.d.ts",
581
589
  "default": "./src/plugins/jsx-import-source.utils.js"
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { appLogger } from '../../../global/app-logger.js';
3
3
  import { PageRequestCacheCoordinator } from '../../../services/cache/page-request-cache-coordinator.service.js';
4
+ import { getBrowserRuntimeAssetGeneration } from '../../../services/assets/browser-runtime-asset-generation.js';
4
5
  import { ServerUtils } from '../../../utils/server-utils.module.js';
5
6
  import { FileRouteMiddlewarePipeline } from './file-route-middleware-pipeline.js';
6
7
  import { HttpError } from '../../../errors/http-error.js';
@@ -27,7 +28,7 @@ export class FileSystemResponseMatcher {
27
28
  this.router = router;
28
29
  this.routeRendererFactory = routeRendererFactory;
29
30
  this.fileSystemResponseFactory = fileSystemResponseFactory;
30
- this.pageRequestCacheCoordinator = new PageRequestCacheCoordinator(cacheService, defaultCacheStrategy);
31
+ this.pageRequestCacheCoordinator = new PageRequestCacheCoordinator(cacheService, defaultCacheStrategy, () => getBrowserRuntimeAssetGeneration(this.appConfig));
31
32
  this.fileRouteMiddlewarePipeline = new FileRouteMiddlewarePipeline(cacheService);
32
33
  }
33
34
  /**
@@ -169,6 +170,7 @@ export class FileSystemResponseMatcher {
169
170
  const result = await routeRenderer.execute({
170
171
  file: templatePath,
171
172
  props,
173
+ locals: {},
172
174
  });
173
175
  return createHtmlResponse(result.body);
174
176
  }
@@ -29,3 +29,7 @@ export declare class BrowserRuntimeManifestConflictError extends Error {
29
29
  export declare function createBrowserRuntimeManifest(declarations?: readonly BrowserRuntimeAssetDeclaration[]): BrowserRuntimeManifest;
30
30
  export declare function mergeBrowserRuntimeManifests(...manifests: Array<BrowserRuntimeManifest | undefined>): BrowserRuntimeManifest;
31
31
  export declare function getBrowserRuntimeSpecifierMap(manifest: BrowserRuntimeManifest): ReadonlyMap<string, string>;
32
+ /** Resolves a runtime public URL for an exact specifier. */
33
+ export declare function resolveRuntimeSpecifierPublicPath(specifier: string, publicPathsBySpecifier: ReadonlyMap<string, string>): string | undefined;
34
+ /** Resolves a manifest-owned runtime public URL for an exact specifier. */
35
+ export declare function resolveBrowserRuntimePublicPath(specifier: string, manifest: BrowserRuntimeManifest): string | undefined;
@@ -18,8 +18,7 @@ function normalizeDeclaration(declaration) {
18
18
  };
19
19
  }
20
20
  function hasCompatibleRuntimeAsset(existing, incoming) {
21
- return (existing.owner === incoming.owner &&
22
- existing.importPath === incoming.importPath &&
21
+ return (existing.importPath === incoming.importPath &&
23
22
  existing.publicPath === incoming.publicPath &&
24
23
  existing.mode === incoming.mode &&
25
24
  arrayEquals(existing.externals, incoming.externals));
@@ -56,3 +55,11 @@ export function mergeBrowserRuntimeManifests(...manifests) {
56
55
  export function getBrowserRuntimeSpecifierMap(manifest) {
57
56
  return new Map(manifest.assets.map((asset) => [asset.specifier, asset.publicPath]));
58
57
  }
58
+ /** Resolves a runtime public URL for an exact specifier. */
59
+ export function resolveRuntimeSpecifierPublicPath(specifier, publicPathsBySpecifier) {
60
+ return publicPathsBySpecifier.get(specifier);
61
+ }
62
+ /** Resolves a manifest-owned runtime public URL for an exact specifier. */
63
+ export function resolveBrowserRuntimePublicPath(specifier, manifest) {
64
+ return manifest.bySpecifier.get(specifier)?.publicPath;
65
+ }
@@ -5,7 +5,7 @@
5
5
  * `createBrowserRuntimePlugin` needs to:
6
6
  *
7
7
  * 1. Escape each specifier for inclusion in a regex filter.
8
- * 2. Build a `RegExp` whose alternation matches any of those specifiers.
8
+ * 2. Build a `RegExp` whose alternation matches registered specifiers.
9
9
  *
10
10
  * These utilities are extracted here so the plugin does not duplicate
11
11
  * the same code path.
@@ -17,10 +17,8 @@
17
17
  */
18
18
  export declare function escapeRegExp(value: string): string;
19
19
  /**
20
- * Builds a `RegExp` whose alternation matches any of the keys in
21
- * `specifierMap`.
20
+ * Builds a `RegExp` that matches registered specifiers exactly.
22
21
  *
23
- * Returns `null` for an empty map so callers can short-circuit and
24
- * avoid registering a no-op `onResolve` / `onLoad` filter.
22
+ * Returns `null` for an empty map so callers can short-circuit registration.
25
23
  */
26
24
  export declare function buildSpecifierFilter(specifierMap: ReadonlyMap<string, string>): RegExp | null;
@@ -5,7 +5,7 @@
5
5
  * `createBrowserRuntimePlugin` needs to:
6
6
  *
7
7
  * 1. Escape each specifier for inclusion in a regex filter.
8
- * 2. Build a `RegExp` whose alternation matches any of those specifiers.
8
+ * 2. Build a `RegExp` whose alternation matches registered specifiers.
9
9
  *
10
10
  * These utilities are extracted here so the plugin does not duplicate
11
11
  * the same code path.
@@ -19,16 +19,14 @@ export function escapeRegExp(value) {
19
19
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
20
  }
21
21
  /**
22
- * Builds a `RegExp` whose alternation matches any of the keys in
23
- * `specifierMap`.
22
+ * Builds a `RegExp` that matches registered specifiers exactly.
24
23
  *
25
- * Returns `null` for an empty map so callers can short-circuit and
26
- * avoid registering a no-op `onResolve` / `onLoad` filter.
24
+ * Returns `null` for an empty map so callers can short-circuit registration.
27
25
  */
28
26
  export function buildSpecifierFilter(specifierMap) {
29
27
  if (specifierMap.size === 0) {
30
28
  return null;
31
29
  }
32
30
  const alternation = Array.from(specifierMap.keys()).map(escapeRegExp).join('|');
33
- return new RegExp(`^(${alternation})$`);
31
+ return new RegExp(`^(?:${alternation})$`);
34
32
  }
@@ -11,22 +11,16 @@
11
11
  * - `onLoad` for JS/TS files → AST-walking import/export rewrite against the
12
12
  * manifest specifier set, with a `code.includes(specifier)` fast path
13
13
  *
14
- * The plugin object carries the manifest's `specifier → publicPath` map
15
- * under `BROWSER_RUNTIME_IMPORT_REWRITE_MAP` so
16
- * `collectBrowserRuntimeImportRewriteMap` and the post-build rewriter can
17
- * read it without re-walking the manifest.
18
- *
19
- * This is the single source of truth for browser-runtime plugin behavior.
20
- * Per-bundler bridges can treat it as a single plugin.
14
+ * The plugin object carries the manifest under `BROWSER_RUNTIME_MANIFEST` so
15
+ * post-build rewriters can resolve exact and subpath imports without rebuilding
16
+ * the map by hand.
21
17
  */
22
18
  import type { EcoBuildPlugin } from '../contracts/build-types.js';
23
19
  import { type BrowserRuntimeManifest } from './browser-runtime-manifest.js';
24
20
  /**
25
- * Symbol used to attach the manifest's `specifier publicPath` map to a
26
- * plugin instance. Consumers read it via `getBrowserRuntimeImportRewriteMap`
27
- * or `collectBrowserRuntimeImportRewriteMap`.
21
+ * Symbol used to attach the browser-runtime manifest to a plugin instance.
28
22
  */
29
- export declare const BROWSER_RUNTIME_IMPORT_REWRITE_MAP: unique symbol;
23
+ export declare const BROWSER_RUNTIME_MANIFEST: unique symbol;
30
24
  /**
31
25
  * Default name used by `createBrowserRuntimePlugin` when the caller
32
26
  * does not provide one.
@@ -63,8 +57,13 @@ export type CreateBrowserRuntimePluginOptions = {
63
57
  *
64
58
  * Exposed for the post-build rewriter and for tests.
65
59
  */
66
- export declare function rewriteBrowserRuntimeImports(code: string, specifierMap: ReadonlyMap<string, string>, filePath?: string): string;
60
+ export declare function rewriteBrowserRuntimeImports(code: string, manifest: BrowserRuntimeManifest, filePath?: string): string;
61
+ export declare function getBrowserRuntimeManifestFromPlugin(plugin: EcoBuildPlugin): BrowserRuntimeManifest | undefined;
62
+ /**
63
+ * Returns the exact specifier → publicPath map derived from the plugin manifest.
64
+ */
67
65
  export declare function getBrowserRuntimeImportRewriteMap(plugin: EcoBuildPlugin): ReadonlyMap<string, string> | undefined;
66
+ export declare function collectBrowserRuntimeManifests(plugins: EcoBuildPlugin[] | undefined): BrowserRuntimeManifest[];
68
67
  export declare function collectBrowserRuntimeImportRewriteMap(plugins: EcoBuildPlugin[] | undefined): ReadonlyMap<string, string>;
69
68
  /**
70
69
  * Creates a build plugin that exposes the union of browser-runtime
@@ -11,25 +11,19 @@
11
11
  * - `onLoad` for JS/TS files → AST-walking import/export rewrite against the
12
12
  * manifest specifier set, with a `code.includes(specifier)` fast path
13
13
  *
14
- * The plugin object carries the manifest's `specifier → publicPath` map
15
- * under `BROWSER_RUNTIME_IMPORT_REWRITE_MAP` so
16
- * `collectBrowserRuntimeImportRewriteMap` and the post-build rewriter can
17
- * read it without re-walking the manifest.
18
- *
19
- * This is the single source of truth for browser-runtime plugin behavior.
20
- * Per-bundler bridges can treat it as a single plugin.
14
+ * The plugin object carries the manifest under `BROWSER_RUNTIME_MANIFEST` so
15
+ * post-build rewriters can resolve exact and subpath imports without rebuilding
16
+ * the map by hand.
21
17
  */
22
18
  import path from 'node:path';
23
19
  import { existsSync, readFileSync } from 'node:fs';
24
20
  import { parseModuleSource } from '../../cache/module-parse-cache.js';
25
- import { getBrowserRuntimeSpecifierMap } from './browser-runtime-manifest.js';
21
+ import { getBrowserRuntimeSpecifierMap, mergeBrowserRuntimeManifests, resolveBrowserRuntimePublicPath, } from './browser-runtime-manifest.js';
26
22
  import { buildSpecifierFilter } from './browser-runtime-plugin-helpers.js';
27
23
  /**
28
- * Symbol used to attach the manifest's `specifier publicPath` map to a
29
- * plugin instance. Consumers read it via `getBrowserRuntimeImportRewriteMap`
30
- * or `collectBrowserRuntimeImportRewriteMap`.
24
+ * Symbol used to attach the browser-runtime manifest to a plugin instance.
31
25
  */
32
- export const BROWSER_RUNTIME_IMPORT_REWRITE_MAP = Symbol.for('ecopages.browserRuntimeImportRewriteMap');
26
+ export const BROWSER_RUNTIME_MANIFEST = Symbol.for('ecopages.browserRuntimeManifest');
33
27
  /**
34
28
  * Default name used by `createBrowserRuntimePlugin` when the caller
35
29
  * does not provide one.
@@ -57,7 +51,7 @@ function queueReplacement(options) {
57
51
  if (!isRecord(options.source) || typeof options.source.value !== 'string') {
58
52
  return;
59
53
  }
60
- const mappedPath = options.specifierMap.get(options.source.value);
54
+ const mappedPath = resolveBrowserRuntimePublicPath(options.source.value, options.manifest);
61
55
  if (!mappedPath || typeof options.source.start !== 'number' || typeof options.source.end !== 'number') {
62
56
  return;
63
57
  }
@@ -74,8 +68,8 @@ function queueReplacement(options) {
74
68
  *
75
69
  * Exposed for the post-build rewriter and for tests.
76
70
  */
77
- export function rewriteBrowserRuntimeImports(code, specifierMap, filePath = 'browser-runtime-imports.js') {
78
- if (specifierMap.size === 0) {
71
+ export function rewriteBrowserRuntimeImports(code, manifest, filePath = 'browser-runtime-imports.js') {
72
+ if (manifest.assets.length === 0) {
79
73
  return code;
80
74
  }
81
75
  const edits = [];
@@ -88,11 +82,11 @@ export function rewriteBrowserRuntimeImports(code, specifierMap, filePath = 'bro
88
82
  if (node.type === 'ImportDeclaration' ||
89
83
  node.type === 'ExportNamedDeclaration' ||
90
84
  node.type === 'ExportAllDeclaration') {
91
- queueReplacement({ code, source: node.source, specifierMap, edits });
85
+ queueReplacement({ code, source: node.source, manifest, edits });
92
86
  }
93
87
  if (node.type === 'ImportExpression' && isRecord(node.source)) {
94
88
  if (node.source.type === 'StringLiteral' || node.source.type === 'Literal') {
95
- queueReplacement({ code, source: node.source, specifierMap, edits });
89
+ queueReplacement({ code, source: node.source, manifest, edits });
96
90
  }
97
91
  }
98
92
  for (const value of Object.values(node)) {
@@ -121,23 +115,31 @@ export function rewriteBrowserRuntimeImports(code, specifierMap, filePath = 'bro
121
115
  }
122
116
  return rewritten;
123
117
  }
118
+ function importMightReferenceRuntimeRoots(code, rootSpecifiers) {
119
+ return rootSpecifiers.some((specifier) => code.includes(specifier));
120
+ }
121
+ export function getBrowserRuntimeManifestFromPlugin(plugin) {
122
+ return plugin[BROWSER_RUNTIME_MANIFEST];
123
+ }
124
+ /**
125
+ * Returns the exact specifier → publicPath map derived from the plugin manifest.
126
+ */
124
127
  export function getBrowserRuntimeImportRewriteMap(plugin) {
125
- return plugin[BROWSER_RUNTIME_IMPORT_REWRITE_MAP];
128
+ const manifest = getBrowserRuntimeManifestFromPlugin(plugin);
129
+ return manifest ? getBrowserRuntimeSpecifierMap(manifest) : undefined;
126
130
  }
127
- export function collectBrowserRuntimeImportRewriteMap(plugins) {
128
- const merged = new Map();
131
+ export function collectBrowserRuntimeManifests(plugins) {
132
+ const manifests = [];
129
133
  for (const plugin of plugins ?? []) {
130
- const specifierMap = getBrowserRuntimeImportRewriteMap(plugin);
131
- if (!specifierMap) {
132
- continue;
133
- }
134
- for (const [specifier, publicPath] of specifierMap.entries()) {
135
- if (!merged.has(specifier)) {
136
- merged.set(specifier, publicPath);
137
- }
134
+ const manifest = getBrowserRuntimeManifestFromPlugin(plugin);
135
+ if (manifest) {
136
+ manifests.push(manifest);
138
137
  }
139
138
  }
140
- return merged;
139
+ return manifests;
140
+ }
141
+ export function collectBrowserRuntimeImportRewriteMap(plugins) {
142
+ return getBrowserRuntimeSpecifierMap(mergeBrowserRuntimeManifests(...collectBrowserRuntimeManifests(plugins)));
141
143
  }
142
144
  /**
143
145
  * Creates a build plugin that exposes the union of browser-runtime
@@ -159,12 +161,12 @@ export function createBrowserRuntimePlugin(options) {
159
161
  const rewriteImports = options.rewriteImports ?? true;
160
162
  const matchPublicPaths = options.matchPublicPaths ?? true;
161
163
  const publicPathSet = new Set(specifierMap.values());
162
- const specifierKeys = Array.from(specifierMap.keys());
164
+ const rootSpecifiers = manifest.assets.map((asset) => asset.specifier);
163
165
  const plugin = {
164
166
  name: options.name ?? DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME,
165
167
  setup(build) {
166
168
  build.onResolve({ filter: specifierFilter }, (args) => {
167
- const mappedPath = specifierMap.get(args.path);
169
+ const mappedPath = resolveBrowserRuntimePublicPath(args.path, manifest);
168
170
  if (!mappedPath) {
169
171
  return undefined;
170
172
  }
@@ -195,10 +197,10 @@ export function createBrowserRuntimePlugin(options) {
195
197
  * any manifest-owned specifiers. This avoids expensive oxc-parser calls
196
198
  * on every JS/TS file in the dependency graph.
197
199
  */
198
- if (!specifierKeys.some((specifier) => code.includes(specifier))) {
200
+ if (!importMightReferenceRuntimeRoots(code, rootSpecifiers)) {
199
201
  return undefined;
200
202
  }
201
- const rewritten = rewriteBrowserRuntimeImports(code, specifierMap, args.path);
203
+ const rewritten = rewriteBrowserRuntimeImports(code, manifest, args.path);
202
204
  if (rewritten === code) {
203
205
  return undefined;
204
206
  }
@@ -211,6 +213,6 @@ export function createBrowserRuntimePlugin(options) {
211
213
  }
212
214
  },
213
215
  };
214
- plugin[BROWSER_RUNTIME_IMPORT_REWRITE_MAP] = specifierMap;
216
+ plugin[BROWSER_RUNTIME_MANIFEST] = manifest;
215
217
  return plugin;
216
218
  }
@@ -12,7 +12,8 @@ import { builtinModules, createRequire } from 'node:module';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { isBarePackageImportSpecifier } from '../../plugins/tsconfig-import-resolver.js';
15
- import { collectBrowserRuntimeImportRewriteMap, rewriteBrowserRuntimeImports, } from '../browser/browser-runtime-plugin.js';
15
+ import { collectBrowserRuntimeManifests, rewriteBrowserRuntimeImports } from '../browser/browser-runtime-plugin.js';
16
+ import { mergeBrowserRuntimeManifests } from '../browser/browser-runtime-manifest.js';
16
17
  import { createServerSideCssShimPlugin } from './server-side-css-shim-plugin.js';
17
18
  import { createRolldownPluginBridge } from './rolldown-plugin-bridge.js';
18
19
  import { isDeclaredAppPackageImport, isWorkspacePackageImport, normalizeNodeRuntimeBuildOutputs, } from './runtime-build-output-normalizer.js';
@@ -374,13 +375,17 @@ export function rewriteBrowserRuntimeImportsInOutputs(result, contextRoot, plugi
374
375
  if (!result.success || result.outputs.length === 0) {
375
376
  return result;
376
377
  }
377
- const specifierMap = collectBrowserRuntimeImportRewriteMap(plugins);
378
- if (specifierMap.size === 0) {
378
+ const manifests = collectBrowserRuntimeManifests(plugins);
379
+ const manifest = mergeBrowserRuntimeManifests(...manifests);
380
+ if (manifest.assets.length === 0) {
379
381
  return result;
380
382
  }
381
383
  const moduleRequireFromContext = createRequire(path.join(contextRoot, 'package.json'));
382
384
  const fs = moduleRequireFromContext('node:fs');
383
- const cacheFingerprint = `${Array.from(specifierMap.entries()).sort().join('|')}`;
385
+ const cacheFingerprint = manifest.assets
386
+ .map((asset) => `${asset.specifier}->${asset.publicPath}`)
387
+ .sort()
388
+ .join('|');
384
389
  for (const output of result.outputs) {
385
390
  if (!/\.(?:[cm]?js)$/u.test(output.path)) {
386
391
  continue;
@@ -390,7 +395,7 @@ export function rewriteBrowserRuntimeImportsInOutputs(result, contextRoot, plugi
390
395
  if (rewriteCache.has(contentKey)) {
391
396
  continue;
392
397
  }
393
- const rewritten = rewriteBrowserRuntimeImports(code, specifierMap, output.path);
398
+ const rewritten = rewriteBrowserRuntimeImports(code, manifest, output.path);
394
399
  if (rewritten !== code) {
395
400
  fs.writeFileSync(output.path, rewritten);
396
401
  }
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { fileSystem } from '@ecopages/file-system';
3
3
  import { cachedParseSync } from '../../cache/module-parse-cache.js';
4
4
  import { isBarePackageImportSpecifier, resolveProjectModulePath } from '../../plugins/tsconfig-import-resolver.js';
5
+ import { resolveRuntimeSpecifierPublicPath } from '../../build/browser/browser-runtime-manifest.js';
5
6
  import { resolveDevTransformModuleUrl } from './dev-transform-url.js';
6
7
  /**
7
8
  * Builds a browser-importable dev-transform URL that changes when the source changes.
@@ -106,7 +107,7 @@ export async function rewriteModuleImports(options) {
106
107
  };
107
108
  }
108
109
  async function resolveImportSpecifier(options) {
109
- const runtimeUrl = options.runtimeSpecifierMap.get(options.specifier);
110
+ const runtimeUrl = resolveRuntimeSpecifierPublicPath(options.specifier, options.runtimeSpecifierMap);
110
111
  if (runtimeUrl) {
111
112
  return runtimeUrl;
112
113
  }
@@ -4,9 +4,17 @@ import { fileSystem } from '@ecopages/file-system';
4
4
  import { RESOLVED_ASSETS_VENDORS_DIR } from '../../config/constants.js';
5
5
  import { getAppBrowserBuildPlugins } from '../../build/build-adapter.js';
6
6
  import { resolveBarePackageBrowserEntry } from '../../plugins/tsconfig-import-resolver.js';
7
+ import { toPackageRootSpecifier } from '../../plugins/package-specifier.js';
8
+ import { resolveRuntimeSpecifierPublicPath } from '../../build/browser/browser-runtime-manifest.js';
7
9
  import { BrowserBundleService } from '../../services/assets/browser-bundle.service.js';
8
- const BROWSER_VENDOR_CONDITIONS = ['browser', 'module', 'import', 'default'];
10
+ const BROWSER_FIRST_VENDOR_CONDITIONS = ['browser', 'module', 'import', 'default'];
11
+ const MODULE_FIRST_VENDOR_CONDITIONS = ['module', 'import', 'default'];
9
12
  const BROWSER_NODE_BUILTIN_GUARD_MARKER = '[browser-build] Node builtin';
13
+ function getVendorBundleConditions(specifier) {
14
+ return toPackageRootSpecifier(specifier) === '@ecopages/core'
15
+ ? BROWSER_FIRST_VENDOR_CONDITIONS
16
+ : MODULE_FIRST_VENDOR_CONDITIONS;
17
+ }
10
18
  /**
11
19
  * Lazily prebundles bare npm imports into cacheable `/assets/vendors/*` chunks.
12
20
  */
@@ -27,7 +35,7 @@ export class DevTransformVendorRegistry {
27
35
  this.vendorsDir = path.join(distDir, RESOLVED_ASSETS_VENDORS_DIR);
28
36
  }
29
37
  resolveKnownVendorUrl(specifier) {
30
- return this.getRuntimeSpecifierMap().get(specifier);
38
+ return resolveRuntimeSpecifierPublicPath(specifier, this.getRuntimeSpecifierMap());
31
39
  }
32
40
  async resolveVendorUrl(specifier) {
33
41
  const known = this.resolveKnownVendorUrl(specifier);
@@ -114,7 +122,7 @@ export class DevTransformVendorRegistry {
114
122
  externalPackages: false,
115
123
  treeshaking: true,
116
124
  splitting: false,
117
- conditions: [...BROWSER_VENDOR_CONDITIONS],
125
+ conditions: [...getVendorBundleConditions(specifier)],
118
126
  excludeAppBuildPlugins: getAppBrowserBuildPlugins(this.appConfig).map((plugin) => plugin.name),
119
127
  plugins: [...vendorPlugins],
120
128
  });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalizes bare npm import specifiers to their package root.
3
+ *
4
+ * @example
5
+ * toPackageRootSpecifier('@scope/pkg/sub/file') -> '@scope/pkg'
6
+ * toPackageRootSpecifier('lodash/debounce') -> 'lodash'
7
+ */
8
+ export declare function toPackageRootSpecifier(specifier: string): string;
9
+ /**
10
+ * Returns true when `specifier` is a subpath import of `packageRoot`.
11
+ */
12
+ export declare function isSubpathOfPackageRoot(specifier: string, packageRoot: string): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Normalizes bare npm import specifiers to their package root.
3
+ *
4
+ * @example
5
+ * toPackageRootSpecifier('@scope/pkg/sub/file') -> '@scope/pkg'
6
+ * toPackageRootSpecifier('lodash/debounce') -> 'lodash'
7
+ */
8
+ export function toPackageRootSpecifier(specifier) {
9
+ if (specifier.startsWith('.') ||
10
+ specifier.startsWith('/') ||
11
+ specifier.startsWith('node:') ||
12
+ specifier.includes('://')) {
13
+ return specifier;
14
+ }
15
+ if (specifier.startsWith('@')) {
16
+ const [scope, name, ...rest] = specifier.split('/');
17
+ if (!scope || !name) {
18
+ return specifier;
19
+ }
20
+ return rest.length > 0 ? `${scope}/${name}` : specifier;
21
+ }
22
+ const [name] = specifier.split('/');
23
+ return name ?? specifier;
24
+ }
25
+ /**
26
+ * Returns true when `specifier` is a subpath import of `packageRoot`.
27
+ */
28
+ export function isSubpathOfPackageRoot(specifier, packageRoot) {
29
+ if (specifier === packageRoot) {
30
+ return false;
31
+ }
32
+ return specifier.startsWith(`${packageRoot}/`);
33
+ }
@@ -12,9 +12,11 @@ export declare function isBarePackageImportSpecifier(specifier: string, projectR
12
12
  * Resolves a bare npm package entry for browser vendor prebundles.
13
13
  *
14
14
  * @remarks
15
- * Prefer package `"browser"` / `"exports.browser"` over Node `"import"` entries.
16
- * `createRequire().resolve()` is wrong here it selects the server facade for
17
- * dual packages such as `@ecopages/core`.
15
+ * Framework-owned dual packages such as `@ecopages/core` keep browser-first
16
+ * `mainFields` and `conditionNames` so vendor prebundles pick the browser facade.
17
+ * Third-party packages prefer ESM (`module` / `import`) and omit the `browser`
18
+ * export condition so legacy UMD `exports.browser` entries cannot win during
19
+ * Rolldown bundling.
18
20
  */
19
21
  export declare function resolveBarePackageBrowserEntry(projectRoot: string, specifier: string): string | undefined;
20
22
  /**
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ResolverFactory } from 'oxc-resolver';
4
+ import { toPackageRootSpecifier } from './package-specifier.js';
4
5
  const RESOLVABLE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mdx'];
5
6
  const resolverCache = new Map();
6
7
  const pathPrefixCache = new Map();
@@ -111,33 +112,59 @@ function getResolverFactory(projectRoot) {
111
112
  resolverCache.set(projectRoot, resolver);
112
113
  return resolver;
113
114
  }
114
- const BROWSER_PACKAGE_CONDITION_NAMES = ['browser', 'module', 'import', 'default'];
115
- const BROWSER_PACKAGE_MAIN_FIELDS = ['browser', 'module', 'main'];
115
+ const BROWSER_FIRST_CONDITION_NAMES = ['browser', 'module', 'import', 'default'];
116
+ /**
117
+ * Omit `browser` so package `exports` cannot select a legacy UMD facade when both
118
+ * `browser` and `import` conditions are published. Legacy `mainFields.browser`
119
+ * remains available after `module` for packages without `exports`.
120
+ */
121
+ const MODULE_FIRST_CONDITION_NAMES = ['module', 'import', 'default'];
122
+ const BROWSER_FIRST_MAIN_FIELDS = ['browser', 'module', 'main'];
123
+ const MODULE_FIRST_MAIN_FIELDS = ['module', 'browser', 'main'];
124
+ /**
125
+ * Package roots that ship an explicit browser facade and must keep browser-first
126
+ * resolution during vendor prebundles.
127
+ *
128
+ * @remarks
129
+ * Prefer expanding this set only for dual packages whose `"browser"` / `exports.browser`
130
+ * entry is the intentional client facade (for example `@ecopages/core`).
131
+ */
132
+ const BROWSER_FIRST_PACKAGE_ROOTS = new Set(['@ecopages/core']);
116
133
  const browserPackageResolvers = new Map();
117
- function getBrowserPackageResolver(projectRoot) {
134
+ function usesBrowserFirstPackageResolution(specifier) {
135
+ const packageRoot = toPackageRootSpecifier(specifier);
136
+ return BROWSER_FIRST_PACKAGE_ROOTS.has(packageRoot);
137
+ }
138
+ function getBrowserPackageResolver(projectRoot, mainFields, conditionNames) {
118
139
  const normalizedRoot = path.resolve(projectRoot);
119
- const cached = browserPackageResolvers.get(normalizedRoot);
140
+ const cacheKey = `${normalizedRoot}\0${mainFields.join(',')}\0${conditionNames.join(',')}`;
141
+ const cached = browserPackageResolvers.get(cacheKey);
120
142
  if (cached) {
121
143
  return cached;
122
144
  }
123
145
  const resolver = new ResolverFactory({
124
- conditionNames: [...BROWSER_PACKAGE_CONDITION_NAMES],
125
- mainFields: [...BROWSER_PACKAGE_MAIN_FIELDS],
146
+ conditionNames: [...conditionNames],
147
+ mainFields: [...mainFields],
126
148
  extensions: [...RESOLVABLE_EXTENSIONS],
127
149
  });
128
- browserPackageResolvers.set(normalizedRoot, resolver);
150
+ browserPackageResolvers.set(cacheKey, resolver);
129
151
  return resolver;
130
152
  }
131
153
  /**
132
154
  * Resolves a bare npm package entry for browser vendor prebundles.
133
155
  *
134
156
  * @remarks
135
- * Prefer package `"browser"` / `"exports.browser"` over Node `"import"` entries.
136
- * `createRequire().resolve()` is wrong here it selects the server facade for
137
- * dual packages such as `@ecopages/core`.
157
+ * Framework-owned dual packages such as `@ecopages/core` keep browser-first
158
+ * `mainFields` and `conditionNames` so vendor prebundles pick the browser facade.
159
+ * Third-party packages prefer ESM (`module` / `import`) and omit the `browser`
160
+ * export condition so legacy UMD `exports.browser` entries cannot win during
161
+ * Rolldown bundling.
138
162
  */
139
163
  export function resolveBarePackageBrowserEntry(projectRoot, specifier) {
140
- const result = getBrowserPackageResolver(projectRoot).sync(projectRoot, specifier);
164
+ const browserFirst = usesBrowserFirstPackageResolution(specifier);
165
+ const mainFields = browserFirst ? BROWSER_FIRST_MAIN_FIELDS : MODULE_FIRST_MAIN_FIELDS;
166
+ const conditionNames = browserFirst ? BROWSER_FIRST_CONDITION_NAMES : MODULE_FIRST_CONDITION_NAMES;
167
+ const result = getBrowserPackageResolver(projectRoot, mainFields, conditionNames).sync(projectRoot, specifier);
141
168
  if (result.error || !result.path) {
142
169
  return undefined;
143
170
  }
@@ -5,7 +5,7 @@ import { createPageLocalsProxy } from './route-prepared-options.utils.js';
5
5
  * Assembles the final integration render options after route prep dependencies resolve.
6
6
  */
7
7
  export function buildPreparedRenderOptions(input) {
8
- const { routeOptions, resolvedInputs, resolvedPageDependencyComponents, resolvedDependencies, allDependencies, pageBrowserGraph, appConfig, } = input;
8
+ const { routeOptions, resolvedInputs, resolvedPageDependencyComponents, resolvedDependencies, allDependencies, pageBrowserGraph, } = input;
9
9
  const { Page, HtmlTemplate, Layouts, Layout, layoutEntries, props, metadata, integrationSpecificProps } = resolvedInputs;
10
10
  const dedupedDependencies = dedupeProcessedAssets(allDependencies);
11
11
  const pagePackage = createPagePackage(dedupedDependencies, { pageBrowserGraph });
@@ -16,13 +16,8 @@ export function buildPreparedRenderOptions(input) {
16
16
  query: routeOptions.query || {},
17
17
  };
18
18
  const cacheStrategy = Page.cache;
19
- const defaultCacheStrategy = appConfig.cache?.defaultStrategy ?? 'static';
20
- const effectiveCacheStrategy = cacheStrategy ?? defaultCacheStrategy;
21
- const localsAvailable = effectiveCacheStrategy === 'dynamic' && routeOptions.locals !== undefined;
22
- const pageLocals = localsAvailable
23
- ? routeOptions.locals
24
- : createPageLocalsProxy(routeOptions.file);
25
- const locals = localsAvailable ? routeOptions.locals : undefined;
19
+ const pageLocals = routeOptions.locals ?? createPageLocalsProxy(routeOptions.file);
20
+ const locals = routeOptions.locals;
26
21
  const preparedOptions = {
27
22
  ...routeOptions,
28
23
  resolvedPageDependencyComponents,
@@ -89,6 +89,11 @@ export declare class AssetProcessingService {
89
89
  * Stores one processed asset in the dependency cache.
90
90
  */
91
91
  private setCachedAsset;
92
+ /**
93
+ * Removes a cached asset and advances the development HTML cache generation
94
+ * when the removed asset can determine browser runtime URLs.
95
+ */
96
+ private invalidateCachedAsset;
92
97
  private getFileSourceHash;
93
98
  /**
94
99
  * Clears all cached processed assets.
@@ -9,6 +9,7 @@ import { isHmrAware } from './processor.interface.js';
9
9
  import { ProcessorRegistry } from './processor.registry.js';
10
10
  import { processUngroupedDependency } from './ungrouped-dependency-processing.js';
11
11
  import { materializeContentScriptAsset } from './materialize-content-script-asset.js';
12
+ import { bumpBrowserRuntimeAssetGeneration } from '../browser-runtime-asset-generation.js';
12
13
  import { ContentScriptProcessor, ContentStylesheetProcessor, FileScriptProcessor, FileStylesheetProcessor, NodeModuleScriptProcessor, } from './processors/index.js';
13
14
  /**
14
15
  * Processes declared component and page asset dependencies for one app instance.
@@ -234,7 +235,7 @@ export class AssetProcessingService {
234
235
  }
235
236
  const sourceHash = this.getFileSourceHash(dep);
236
237
  if (dep.source === 'file' && sourceHash === undefined) {
237
- this.cache.delete(depKey);
238
+ this.invalidateCachedAsset(depKey);
238
239
  return null;
239
240
  }
240
241
  const cached = this.cache.get(depKey);
@@ -242,11 +243,11 @@ export class AssetProcessingService {
242
243
  return null;
243
244
  }
244
245
  if (cached.sourceHash !== sourceHash) {
245
- this.cache.delete(depKey);
246
+ this.invalidateCachedAsset(depKey);
246
247
  return null;
247
248
  }
248
249
  if (cached.asset.filepath && !fileSystem.exists(cached.asset.filepath)) {
249
- this.cache.delete(depKey);
250
+ this.invalidateCachedAsset(depKey);
250
251
  return null;
251
252
  }
252
253
  return cached.asset;
@@ -275,10 +276,26 @@ export class AssetProcessingService {
275
276
  * Stores one processed asset in the dependency cache.
276
277
  */
277
278
  setCachedAsset(dep, depKey, asset) {
279
+ const previous = this.cache.get(depKey)?.asset;
280
+ const tracksBrowserRuntimeGeneration = dep.packageRole === 'runtime' || dep.packageRole === 'page-script';
278
281
  this.cache.set(depKey, {
279
282
  asset,
280
283
  sourceHash: this.getFileSourceHash(dep),
281
284
  });
285
+ if (tracksBrowserRuntimeGeneration && previous !== undefined && previous.filepath !== asset.filepath) {
286
+ void bumpBrowserRuntimeAssetGeneration(this.config);
287
+ }
288
+ }
289
+ /**
290
+ * Removes a cached asset and advances the development HTML cache generation
291
+ * when the removed asset can determine browser runtime URLs.
292
+ */
293
+ invalidateCachedAsset(depKey) {
294
+ const cached = this.cache.get(depKey);
295
+ this.cache.delete(depKey);
296
+ if (cached?.asset.packageRole === 'runtime' || cached?.asset.packageRole === 'page-script') {
297
+ void bumpBrowserRuntimeAssetGeneration(this.config);
298
+ }
282
299
  }
283
300
  getFileSourceHash(dep) {
284
301
  if (dep.source !== 'file' || !('filepath' in dep) || !fileSystem.exists(dep.filepath)) {
@@ -290,19 +307,29 @@ export class AssetProcessingService {
290
307
  * Clears all cached processed assets.
291
308
  */
292
309
  clearCache() {
310
+ const invalidatesBrowserRuntime = Array.from(this.cache.values()).some(({ asset }) => asset.packageRole === 'runtime' || asset.packageRole === 'page-script');
293
311
  this.cache.clear();
312
+ if (invalidatesBrowserRuntime) {
313
+ void bumpBrowserRuntimeAssetGeneration(this.config);
314
+ }
294
315
  }
295
316
  /**
296
317
  * Removes cached assets that were produced from the given file path.
297
318
  */
298
319
  invalidateCacheForFile(filepath) {
320
+ let invalidatesBrowserRuntime = false;
299
321
  for (const [key, value] of this.cache.entries()) {
300
322
  if (value.asset.filepath === filepath ||
301
323
  value.asset.sourceFilepath === filepath ||
302
324
  value.asset.bundledSourceFilepaths?.includes(filepath)) {
325
+ invalidatesBrowserRuntime ||=
326
+ value.asset.packageRole === 'runtime' || value.asset.packageRole === 'page-script';
303
327
  this.cache.delete(key);
304
328
  }
305
329
  }
330
+ if (invalidatesBrowserRuntime) {
331
+ void bumpBrowserRuntimeAssetGeneration(this.config);
332
+ }
306
333
  }
307
334
  /**
308
335
  * Creates a service prewired with the default core processors.
@@ -0,0 +1,17 @@
1
+ import type { EcoPagesAppConfig } from '../../types/internal-types.js';
2
+ /**
3
+ * Returns the current browser-runtime asset generation for one app.
4
+ *
5
+ * @remarks
6
+ * Development HTML caches include this generation so rebuilt bootstrap and
7
+ * vendor URLs cannot leave stale script references behind.
8
+ */
9
+ export declare function getBrowserRuntimeAssetGeneration(appConfig: EcoPagesAppConfig): number;
10
+ /**
11
+ * Bumps browser-runtime asset generation and clears rendered HTML cache entries.
12
+ *
13
+ * @remarks
14
+ * Only call this when a previously cached runtime/page-script asset filepath changes.
15
+ * First inserts must not bump generation or cold start thrash-clears the HTML cache.
16
+ */
17
+ export declare function bumpBrowserRuntimeAssetGeneration(appConfig: EcoPagesAppConfig): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { clearAppPageCache } from '../cache/page-cache-service.js';
2
+ import { isDevelopmentRuntime } from '../../utils/runtime.js';
3
+ const generationByAppConfig = new WeakMap();
4
+ /**
5
+ * Returns the current browser-runtime asset generation for one app.
6
+ *
7
+ * @remarks
8
+ * Development HTML caches include this generation so rebuilt bootstrap and
9
+ * vendor URLs cannot leave stale script references behind.
10
+ */
11
+ export function getBrowserRuntimeAssetGeneration(appConfig) {
12
+ return generationByAppConfig.get(appConfig) ?? 0;
13
+ }
14
+ /**
15
+ * Bumps browser-runtime asset generation and clears rendered HTML cache entries.
16
+ *
17
+ * @remarks
18
+ * Only call this when a previously cached runtime/page-script asset filepath changes.
19
+ * First inserts must not bump generation or cold start thrash-clears the HTML cache.
20
+ */
21
+ export async function bumpBrowserRuntimeAssetGeneration(appConfig) {
22
+ if (!isDevelopmentRuntime()) {
23
+ return;
24
+ }
25
+ const nextGeneration = getBrowserRuntimeAssetGeneration(appConfig) + 1;
26
+ generationByAppConfig.set(appConfig, nextGeneration);
27
+ await clearAppPageCache(appConfig);
28
+ }
@@ -10,7 +10,8 @@ import type { CacheStrategy, RenderResult } from './cache.types.js';
10
10
  export declare class PageRequestCacheCoordinator {
11
11
  private cacheService;
12
12
  private defaultCacheStrategy;
13
- constructor(cacheService: PageCacheService | null, defaultCacheStrategy: CacheStrategy);
13
+ private readonly getRuntimeAssetGeneration?;
14
+ constructor(cacheService: PageCacheService | null, defaultCacheStrategy: CacheStrategy, getRuntimeAssetGeneration?: () => number);
14
15
  /**
15
16
  * Builds the cache key used for page lookups.
16
17
  *
@@ -1,5 +1,6 @@
1
1
  import { getCacheControlHeader } from './page-cache-service.js';
2
2
  import { getRequestPipelineMetricsHeaderName, isRequestPipelineMetricsEnabled, serializeRequestPipelineMetricsHeader, } from '../../diagnostics/request-pipeline-metrics.js';
3
+ import { isDevelopmentRuntime } from '../../utils/runtime.js';
3
4
  /**
4
5
  * Coordinates request-time page caching concerns around one render invocation.
5
6
  *
@@ -10,9 +11,11 @@ import { getRequestPipelineMetricsHeaderName, isRequestPipelineMetricsEnabled, s
10
11
  export class PageRequestCacheCoordinator {
11
12
  cacheService;
12
13
  defaultCacheStrategy;
13
- constructor(cacheService, defaultCacheStrategy) {
14
+ getRuntimeAssetGeneration;
15
+ constructor(cacheService, defaultCacheStrategy, getRuntimeAssetGeneration) {
14
16
  this.cacheService = cacheService;
15
17
  this.defaultCacheStrategy = defaultCacheStrategy;
18
+ this.getRuntimeAssetGeneration = getRuntimeAssetGeneration;
16
19
  }
17
20
  /**
18
21
  * Builds the cache key used for page lookups.
@@ -29,6 +32,9 @@ export class PageRequestCacheCoordinator {
29
32
  const queryString = new URLSearchParams(input.query).toString();
30
33
  key += `?${queryString}`;
31
34
  }
35
+ if (this.getRuntimeAssetGeneration && isDevelopmentRuntime()) {
36
+ key += `#__eco_rt=${this.getRuntimeAssetGeneration()}`;
37
+ }
32
38
  return key;
33
39
  }
34
40
  /**
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { getAppServerInvalidationState } from '../runtime-state/server-invalidation-state.service.js';
3
3
  import { appLogger } from '../../global/app-logger.js';
4
+ import { clearAppDevelopmentRouteModuleBuildCaches } from '../module-loading/route-module-build-cache-registry.js';
4
5
  /**
5
6
  * Framework-owned development invalidation service.
6
7
  *
@@ -29,6 +30,7 @@ export class DevelopmentInvalidationService {
29
30
  invalidateServerModules(changedFiles) {
30
31
  getAppServerInvalidationState(this.appConfig).invalidateServerModules(changedFiles);
31
32
  this.appConfig.runtime?.appModuleLoader?.invalidateDevelopmentGraph();
33
+ clearAppDevelopmentRouteModuleBuildCaches(this.appConfig);
32
34
  }
33
35
  /**
34
36
  * Registers an integration-owned handler for registered script entrypoint edits.
@@ -57,8 +59,8 @@ export class DevelopmentInvalidationService {
57
59
  * Resets runtime-owned graph state and invalidates server modules.
58
60
  */
59
61
  resetRuntimeState(changedFiles) {
62
+ this.invalidateServerModules(changedFiles);
60
63
  const serverInvalidationState = getAppServerInvalidationState(this.appConfig);
61
- serverInvalidationState.invalidateServerModules(changedFiles);
62
64
  serverInvalidationState.reset();
63
65
  }
64
66
  /**
@@ -33,7 +33,7 @@ Call site (route scan, renderer, SSG, API)
33
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
34
  3. **Unified graph manifest** — production static export fast path only; see build layer docs.
35
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.
36
+ Development import URLs use `sourceHash` plus a per-service import generation counter. Node uses that value in its `?update=` query. Bun also receives a generation-specific compiled output filename because it retains a previously imported file when only its query changes. Both paths advance after `invalidateDevelopmentGraph()` without a process-wide invalidation version in reuse keys.
37
37
 
38
38
  ## Files
39
39
 
@@ -49,4 +49,4 @@ Development `?update=` query params use `sourceHash` plus a per-service import g
49
49
 
50
50
  ## Development invalidation
51
51
 
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.
52
+ `DevelopmentInvalidationService.invalidateServerModules()` calls `appModuleLoader.invalidateDevelopmentGraph()`, which clears the in-memory import cache, bumps the per-service dev import generation used for runtime URLs and Bun output filenames, and clears dependency-hash memoization. It also clears persisted route-module entries: externalized generated modules can change without appearing in a route bundle's dependency graph, so retaining those entries could reload stale server HTML.
@@ -127,10 +127,7 @@ export class PageModuleImportService {
127
127
  async loadModule(options) {
128
128
  const { filePath, fileHash, importCacheKey } = options;
129
129
  const { rootDir, outdir, splitting, externalPackages, transpileErrorMessage = (details) => `Error transpiling page module: ${details}`, noOutputMessage = (targetFilePath) => `No transpiled output generated for page module: ${targetFilePath}`, } = options;
130
- const outputFileName = resolvePageModuleOutputFileName({
131
- filePath,
132
- fileHash,
133
- });
130
+ const outputFileName = createRuntimeBuildOutputFileName(resolvePageModuleOutputFileName({ filePath, fileHash }), this.developmentImportGeneration);
134
131
  const outputNamingTemplate = outputFileName.replace(/\.mjs$/u, '.[ext]');
135
132
  const preferredOutputPath = path.join(outdir, outputFileName);
136
133
  const buildOptions = this.appConfig
@@ -229,3 +226,17 @@ function createRuntimeModuleUrl(filePath, fileHash, developmentImportGeneration)
229
226
  function shouldAddRuntimeUpdateQuery() {
230
227
  return process.env.NODE_ENV === 'development';
231
228
  }
229
+ /**
230
+ * Gives Bun a distinct compiled module path after development invalidation.
231
+ *
232
+ * @remarks
233
+ * Bun does not reload an already-imported module when only its URL query changes.
234
+ * A dependency edit can leave the entrypoint source hash unchanged, so the output
235
+ * filename must include the import generation as well as the runtime query.
236
+ */
237
+ function createRuntimeBuildOutputFileName(outputFileName, developmentImportGeneration) {
238
+ if (typeof Bun === 'undefined' || !shouldAddRuntimeUpdateQuery()) {
239
+ return outputFileName;
240
+ }
241
+ return outputFileName.replace(/\.mjs$/u, `-${developmentImportGeneration}.mjs`);
242
+ }
@@ -22,3 +22,5 @@ export declare const getRouteModuleBuildCacheOutdir: typeof getServerModuleBuild
22
22
  * generation during the same build process.
23
23
  */
24
24
  export declare function getSharedRouteModuleBuildCache(outdir: string, appConfig?: EcoPagesAppConfig): RouteModuleBuildCache;
25
+ /** Clears route-module cache entries that can retain stale external server modules during development. */
26
+ export declare function clearAppDevelopmentRouteModuleBuildCaches(appConfig: EcoPagesAppConfig): void;
@@ -49,3 +49,15 @@ export function getSharedRouteModuleBuildCache(outdir, appConfig) {
49
49
  caches.set(outdir, routeModuleBuildCache);
50
50
  return routeModuleBuildCache;
51
51
  }
52
+ /** Clears route-module cache entries that can retain stale external server modules during development. */
53
+ export function clearAppDevelopmentRouteModuleBuildCaches(appConfig) {
54
+ const serverModuleCacheOutdir = getServerModuleBuildCacheOutdir(appConfig);
55
+ const caches = appConfig.runtime?.routeModuleBuildCaches;
56
+ const serverModuleCache = caches?.get(serverModuleCacheOutdir) ?? new RouteModuleBuildCache(serverModuleCacheOutdir);
57
+ serverModuleCache.clearDevelopmentEntries();
58
+ for (const cache of caches?.values() ?? []) {
59
+ if (cache !== serverModuleCache) {
60
+ cache.clearDevelopmentEntries();
61
+ }
62
+ }
63
+ }
@@ -51,6 +51,16 @@ export declare class RouteModuleBuildCache {
51
51
  context: RouteModuleStaticRenderCacheContext;
52
52
  }): void;
53
53
  resetMemory(): void;
54
+ /**
55
+ * Removes every persisted route-module entry after development invalidation.
56
+ *
57
+ * @remarks
58
+ * Server bundles may externalize generated modules whose source dependencies
59
+ * are not present in the route bundle's dependency graph. Keeping an entry
60
+ * after its server graph changes can therefore reload a route that still
61
+ * imports an obsolete external bundle.
62
+ */
63
+ clearDevelopmentEntries(): void;
54
64
  pruneStaleRenderedOutputs(activePathnames: ReadonlySet<string>): string[];
55
65
  private loadManifest;
56
66
  private persistManifest;
@@ -164,6 +164,24 @@ export class RouteModuleBuildCache {
164
164
  this.manifest = undefined;
165
165
  this.manifestLoaded = false;
166
166
  }
167
+ /**
168
+ * Removes every persisted route-module entry after development invalidation.
169
+ *
170
+ * @remarks
171
+ * Server bundles may externalize generated modules whose source dependencies
172
+ * are not present in the route bundle's dependency graph. Keeping an entry
173
+ * after its server graph changes can therefore reload a route that still
174
+ * imports an obsolete external bundle.
175
+ */
176
+ clearDevelopmentEntries() {
177
+ if (!this.manifestLoaded && !this.dependencies.exists(this.manifestPath)) {
178
+ return;
179
+ }
180
+ const manifest = createEmptyRouteModuleBuildCacheManifest();
181
+ this.manifest = manifest;
182
+ this.manifestLoaded = true;
183
+ this.persistManifest(manifest);
184
+ }
167
185
  pruneStaleRenderedOutputs(activePathnames) {
168
186
  if (process.env.NODE_ENV !== 'production') {
169
187
  return [];
@@ -646,8 +646,9 @@ export type EcoLayoutComponent<T = EcoPagesElement> = EcoComponent<LayoutProps<T
646
646
  export type EcoHtmlComponent<T = EcoPagesElement> = EcoComponent<HtmlTemplateProps, T>;
647
647
  /**
648
648
  * Props type for the semantic `404.*` page template.
649
- * @remarks `message` and `stack` are declared for future error context. The
650
- * runtime does not currently pass these props when rendering the custom 404 page.
649
+ * @remarks Semantic error templates receive safe empty `pageLocals` rather than
650
+ * request-scoped locals. The runtime does not currently pass `message` / `stack`
651
+ * when rendering the custom 404 page.
651
652
  */
652
653
  export interface Error404TemplateProps extends Omit<HtmlTemplateProps, 'children'> {
653
654
  message: string;
@@ -658,7 +659,8 @@ export interface Error404TemplateProps extends Omit<HtmlTemplateProps, 'children
658
659
  * @remarks In development, the page-pipeline passes `message` and `stack` from the
659
660
  * thrown error when rendering this page after a failure. In production those
660
661
  * fields are omitted so stacks are not serialized into HTML. Direct visits to
661
- * `/500` also omit them.
662
+ * `/500` also omit them. Semantic error templates receive safe empty
663
+ * `pageLocals` rather than request-scoped locals.
662
664
  */
663
665
  export interface Error500TemplateProps extends Omit<HtmlTemplateProps, 'children'> {
664
666
  message?: string;