@ecopages/core 0.2.0-beta.38 → 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 (108) hide show
  1. package/README.md +1 -0
  2. package/package.json +27 -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 +2 -2
  45. package/src/eco/eco.js +7 -6
  46. package/src/eco/eco.types.d.ts +6 -5
  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 +10 -8
  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 +0 -8
  64. package/src/route-renderer/orchestration/integration-renderer.js +6 -19
  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/page-browser-graph-session.d.ts +1 -0
  70. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph-session.js +12 -0
  71. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph.service.d.ts +9 -0
  72. package/src/route-renderer/orchestration/page-browser-graph/page-browser-graph.service.js +7 -0
  73. package/src/route-renderer/orchestration/route-pipeline/route-render-orchestrator.js +65 -33
  74. package/src/route-renderer/page-loading/component-dependency-collection.js +3 -2
  75. package/src/route-renderer/page-loading/ecopages-virtual-imports.d.ts +0 -5
  76. package/src/route-renderer/page-loading/ecopages-virtual-imports.js +4 -10
  77. package/src/route-renderer/page-loading/file-scoped-dependency-components.d.ts +2 -2
  78. package/src/route-renderer/page-loading/file-scoped-dependency-components.js +12 -11
  79. package/src/route-renderer/page-loading/lazy-trigger-planning.js +2 -1
  80. package/src/route-renderer/page-loading/page-module-loader.d.ts +1 -1
  81. package/src/route-renderer/page-loading/page-module-loader.js +8 -3
  82. package/src/router/README.md +8 -0
  83. package/src/services/README.md +5 -0
  84. package/src/services/assets/asset-processing-service/asset-processing.service.d.ts +1 -0
  85. package/src/services/assets/asset-processing-service/asset-processing.service.js +22 -5
  86. package/src/services/cache/cache.types.d.ts +4 -2
  87. package/src/services/cache/html-page-cache-dependency-index.d.ts +34 -0
  88. package/src/services/cache/html-page-cache-dependency-index.js +88 -0
  89. package/src/services/cache/index.d.ts +2 -0
  90. package/src/services/cache/index.js +2 -0
  91. package/src/services/cache/page-cache-service.d.ts +31 -1
  92. package/src/services/cache/page-cache-service.js +79 -3
  93. package/src/services/cache/page-request-cache-coordinator.service.js +4 -0
  94. package/src/services/module-loading/README.md +9 -6
  95. package/src/services/module-loading/app-server-module-transpiler.service.js +0 -3
  96. package/src/services/module-loading/collection-server-module-build.service.d.ts +19 -0
  97. package/src/services/module-loading/collection-server-module-build.service.js +83 -0
  98. package/src/services/module-loading/page-module-import.service.d.ts +5 -11
  99. package/src/services/module-loading/page-module-import.service.js +56 -73
  100. package/src/services/module-loading/route-module-build-cache.store.js +6 -6
  101. package/src/services/module-loading/route-module-build-manifest.d.ts +8 -5
  102. package/src/services/module-loading/route-module-build-manifest.js +20 -32
  103. package/src/services/module-loading/server-module-transpiler.service.d.ts +0 -2
  104. package/src/services/module-loading/server-module-transpiler.service.js +0 -3
  105. package/src/static-site-generator/static-site-generator.js +2 -2
  106. package/src/types/internal-types.d.ts +4 -0
  107. package/src/types/public-types.d.ts +18 -18
  108. package/src/watchers/project-watcher.js +14 -0
@@ -3,4 +3,5 @@
3
3
  *
4
4
  * @module @ecopages/core/cache
5
5
  */
6
- export { ModuleParseCache, moduleParseCache, cachedParseSync } from './module-parse-cache.js';
6
+ export { ModuleParseCache, moduleParseCache, cachedParseSync, parseModuleSource, parserLanguageForFile, } from './module-parse-cache.js';
7
+ export { recordModuleTransformProfile } from './module-transform-profiler.js';
@@ -22,6 +22,11 @@ export type ModuleParseOptions = ParserOptions & {
22
22
  */
23
23
  lang?: ParserOptions['lang'];
24
24
  };
25
+ export type ParserLanguage = 'js' | 'jsx' | 'ts' | 'tsx';
26
+ /** Resolves the Oxc dialect for a source module from its file extension. */
27
+ export declare function parserLanguageForFile(filePath: string): ParserLanguage;
28
+ /** Parses an ECMAScript module with the shared, normalized cache contract. */
29
+ export declare function parseModuleSource(filePath: string, source: string, options?: ModuleParseOptions): ParseResult;
25
30
  /**
26
31
  * LRU-bounded module parse cache.
27
32
  *
@@ -13,9 +13,25 @@
13
13
  * mtime-hashed so that `touch`/`utimes` does not invalidate a
14
14
  * still-valid parse.
15
15
  */
16
+ import { extname } from 'node:path';
16
17
  import { parseSync } from 'oxc-parser';
17
18
  import { rapidhash } from '../utils/hash.js';
18
19
  const DEFAULT_MAX_ENTRIES = 10_000;
20
+ /** Resolves the Oxc dialect for a source module from its file extension. */
21
+ export function parserLanguageForFile(filePath) {
22
+ const extension = extname(filePath).toLowerCase();
23
+ if (extension === '.tsx')
24
+ return 'tsx';
25
+ if (extension === '.ts' || extension === '.mts' || extension === '.cts')
26
+ return 'ts';
27
+ if (extension === '.jsx')
28
+ return 'jsx';
29
+ return 'js';
30
+ }
31
+ /** Parses an ECMAScript module with the shared, normalized cache contract. */
32
+ export function parseModuleSource(filePath, source, options = {}) {
33
+ return moduleParseCache.getOrParse(filePath, source, normalizeModuleParseOptions(filePath, options));
34
+ }
19
35
  /**
20
36
  * LRU-bounded module parse cache.
21
37
  *
@@ -39,8 +55,9 @@ export class ModuleParseCache {
39
55
  * @returns the {@link ParseResult} from `oxc-parser.parseSync`.
40
56
  */
41
57
  getOrParse(filePath, source, options = {}) {
58
+ const normalizedOptions = normalizeModuleParseOptions(filePath, options);
42
59
  const hash = rapidhash(source);
43
- const key = makeKey(filePath, hash, options);
60
+ const key = makeKey(filePath, hash, normalizedOptions);
44
61
  const existing = this.entries.get(key);
45
62
  if (existing && existing.hash === hash) {
46
63
  this.hits += 1;
@@ -50,7 +67,7 @@ export class ModuleParseCache {
50
67
  return existing.result;
51
68
  }
52
69
  this.misses += 1;
53
- const result = parseSync(filePath, source, options);
70
+ const result = parseSync(filePath, source, normalizedOptions);
54
71
  this.entries.set(key, { hash, result });
55
72
  if (this.entries.size > this.maxEntries) {
56
73
  // Map iteration order is insertion order; the first key is the
@@ -95,7 +112,14 @@ export const moduleParseCache = new ModuleParseCache();
95
112
  * on user/source files during a build.
96
113
  */
97
114
  export function cachedParseSync(filePath, source, options = {}) {
98
- return moduleParseCache.getOrParse(filePath, source, options);
115
+ return parseModuleSource(filePath, source, options);
116
+ }
117
+ function normalizeModuleParseOptions(filePath, options) {
118
+ return {
119
+ ...options,
120
+ lang: options.lang ?? parserLanguageForFile(filePath),
121
+ sourceType: options.sourceType ?? 'module',
122
+ };
99
123
  }
100
124
  function makeKey(filePath, sourceHash, options) {
101
125
  // Stringify only the option keys we actually use; `parseSync` does
@@ -0,0 +1,8 @@
1
+ type ProfileRecord = {
2
+ category: string;
3
+ phase: string;
4
+ ms: number;
5
+ cacheHit?: boolean;
6
+ };
7
+ export declare function recordModuleTransformProfile(record: ProfileRecord): void;
8
+ export {};
@@ -0,0 +1,8 @@
1
+ export function recordModuleTransformProfile(record) {
2
+ if (process.env.ECO_AST_PROFILE !== '1')
3
+ return;
4
+ console.info(`[ecopages:ast-profile] ${JSON.stringify({
5
+ ...record,
6
+ ms: Number(record.ms.toFixed(3)),
7
+ })}`);
8
+ }
@@ -1,7 +1,23 @@
1
1
  /**
2
- * View Transition API helpers for `data-view-transition` markup.
2
+ * View Transition API helpers for `data-view-transition` markup and safe root defaults.
3
3
  * @module
4
4
  */
5
+ /**
6
+ * Whether a document tree contains elements that participate in named view transitions.
7
+ */
8
+ export declare function documentHasNamedViewTransitions(doc: Document): boolean;
9
+ /**
10
+ * Whether SPA navigation should use `startViewTransition` (old or incoming page).
11
+ */
12
+ export declare function navigationHasNamedViewTransitions(current: Document, incoming: Document): boolean;
13
+ /**
14
+ * Injects persisted CSS that opts the document out of the root view-transition group.
15
+ *
16
+ * @remarks
17
+ * Uses `data-eco-persist` so head morphing does not drop the stylesheet between navigations.
18
+ * Named `data-view-transition` morph / fade rules live in a separate dynamic style tag.
19
+ */
20
+ export declare function ensureRootViewTransitionStyles(): void;
5
21
  /**
6
22
  * Assigns `view-transition-name` from `data-view-transition` and injects morph styles.
7
23
  *
@@ -1,11 +1,55 @@
1
1
  /**
2
- * View Transition API helpers for `data-view-transition` markup.
2
+ * View Transition API helpers for `data-view-transition` markup and safe root defaults.
3
3
  * @module
4
4
  */
5
5
  const VIEW_TRANSITION_ATTR = 'data-view-transition';
6
6
  const VIEW_TRANSITION_ANIMATE_ATTR = 'data-view-transition-animate';
7
7
  const VIEW_TRANSITION_DURATION_ATTR = 'data-view-transition-duration';
8
8
  const DYNAMIC_STYLES_ID = 'eco-vt-dynamic-styles';
9
+ const ROOT_STYLES_ID = 'eco-vt-root-styles';
10
+ /**
11
+ * @remarks
12
+ * UA default is `html { view-transition-name: root }`, which snapshots the whole
13
+ * document and crossfades with plus-lighter (flashes lighter on dark UIs). Routers
14
+ * inject this opt-out when `viewTransitions` is enabled. Named `data-view-transition`
15
+ * morphs still run. Full-page fades are not a supported default — use named elements
16
+ * or app-owned motion outside the View Transitions API.
17
+ */
18
+ const ROOT_STYLES_CSS = `html {
19
+ view-transition-name: none;
20
+ }
21
+ `;
22
+ /**
23
+ * Whether a document tree contains elements that participate in named view transitions.
24
+ */
25
+ export function documentHasNamedViewTransitions(doc) {
26
+ return doc.querySelector(`[${VIEW_TRANSITION_ATTR}]`) !== null;
27
+ }
28
+ /**
29
+ * Whether SPA navigation should use `startViewTransition` (old or incoming page).
30
+ */
31
+ export function navigationHasNamedViewTransitions(current, incoming) {
32
+ return documentHasNamedViewTransitions(current) || documentHasNamedViewTransitions(incoming);
33
+ }
34
+ /**
35
+ * Injects persisted CSS that opts the document out of the root view-transition group.
36
+ *
37
+ * @remarks
38
+ * Uses `data-eco-persist` so head morphing does not drop the stylesheet between navigations.
39
+ * Named `data-view-transition` morph / fade rules live in a separate dynamic style tag.
40
+ */
41
+ export function ensureRootViewTransitionStyles() {
42
+ let styleEl = document.getElementById(ROOT_STYLES_ID);
43
+ if (!styleEl) {
44
+ styleEl = document.createElement('style');
45
+ styleEl.id = ROOT_STYLES_ID;
46
+ styleEl.setAttribute('data-eco-persist', '');
47
+ document.head.append(styleEl);
48
+ }
49
+ if (styleEl.textContent !== ROOT_STYLES_CSS) {
50
+ styleEl.textContent = ROOT_STYLES_CSS;
51
+ }
52
+ }
9
53
  /**
10
54
  * Assigns `view-transition-name` from `data-view-transition` and injects morph styles.
11
55
  *
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { applyViewTransitionNames, clearViewTransitionNames, documentHasNamedViewTransitions, ensureRootViewTransitionStyles, navigationHasNamedViewTransitions, } from './view-transitions.js';
3
+ describe('documentHasNamedViewTransitions', () => {
4
+ afterEach(() => {
5
+ document.body.replaceChildren();
6
+ });
7
+ it('returns false when no markup is present', () => {
8
+ expect(documentHasNamedViewTransitions(document)).toBe(false);
9
+ });
10
+ it('returns true when data-view-transition is present', () => {
11
+ document.body.innerHTML = '<div data-view-transition="x"></div>';
12
+ expect(documentHasNamedViewTransitions(document)).toBe(true);
13
+ });
14
+ it('navigation checks current or incoming document', () => {
15
+ const incoming = new DOMParser().parseFromString('<html><body><div data-view-transition="hero"></div></body></html>', 'text/html');
16
+ expect(navigationHasNamedViewTransitions(document, incoming)).toBe(true);
17
+ });
18
+ });
19
+ describe('ensureRootViewTransitionStyles', () => {
20
+ afterEach(() => {
21
+ document.getElementById('eco-vt-root-styles')?.remove();
22
+ document.getElementById('eco-vt-dynamic-styles')?.remove();
23
+ document.body.replaceChildren();
24
+ });
25
+ it('opts the document out of the root view-transition group without !important', () => {
26
+ ensureRootViewTransitionStyles();
27
+ const style = document.getElementById('eco-vt-root-styles');
28
+ expect(style).not.toBeNull();
29
+ expect(style?.hasAttribute('data-eco-persist')).toBe(true);
30
+ expect(style?.textContent).toContain('view-transition-name: none');
31
+ expect(style?.textContent).not.toContain('!important');
32
+ expect(getComputedStyle(document.documentElement).viewTransitionName).toBe('none');
33
+ });
34
+ it('reuses a single style element', () => {
35
+ ensureRootViewTransitionStyles();
36
+ ensureRootViewTransitionStyles();
37
+ expect(document.querySelectorAll('#eco-vt-root-styles')).toHaveLength(1);
38
+ });
39
+ });
40
+ describe('applyViewTransitionNames with root styles', () => {
41
+ afterEach(() => {
42
+ document.getElementById('eco-vt-root-styles')?.remove();
43
+ document.getElementById('eco-vt-dynamic-styles')?.remove();
44
+ document.body.replaceChildren();
45
+ });
46
+ it('keeps root styles when clearing named morph styles', () => {
47
+ ensureRootViewTransitionStyles();
48
+ document.body.innerHTML = '<div data-view-transition="hero"></div>';
49
+ applyViewTransitionNames();
50
+ expect(document.getElementById('eco-vt-dynamic-styles')?.textContent).toContain('view-transition-old(hero)');
51
+ expect(document.getElementById('eco-vt-root-styles')?.textContent).toContain('view-transition-name: none');
52
+ clearViewTransitionNames();
53
+ expect(document.getElementById('eco-vt-dynamic-styles')?.textContent).toBe('');
54
+ expect(document.getElementById('eco-vt-root-styles')?.textContent).toContain('view-transition-name: none');
55
+ });
56
+ });
@@ -198,6 +198,20 @@ export declare class ConfigBuilder {
198
198
  * @returns The ConfigBuilder instance for method chaining
199
199
  */
200
200
  setAdditionalWatchPaths(additionalWatchPaths: string[]): this;
201
+ /**
202
+ * Sets static paths to SSR-prewarm in development.
203
+ *
204
+ * @param devPrewarmPaths - Absolute URL pathnames such as `/` or `/docs`.
205
+ * @returns The ConfigBuilder instance for method chaining.
206
+ */
207
+ setDevPrewarmPaths(devPrewarmPaths: string[]): this;
208
+ /**
209
+ * Sets static paths to SSR-prewarm before the development server reports ready.
210
+ *
211
+ * @param devPrewarmBeforeReadyPaths - Critical absolute URL pathnames such as `/`.
212
+ * @returns The ConfigBuilder instance for method chaining.
213
+ */
214
+ setDevPrewarmBeforeReadyPaths(devPrewarmBeforeReadyPaths: string[]): this;
201
215
  /**
202
216
  * Sets the processors to use for the application.
203
217
  * This replaces any existing processors.
@@ -265,15 +279,8 @@ export declare class ConfigBuilder {
265
279
  * Initializes default loaders that are required for EcoPages to function.
266
280
  *
267
281
  * @remarks
268
- * `eco-component-meta` is registered twice on purpose:
269
- *
270
- * - `sourceTransforms` is the canonical browser/HMR path. The Rolldown bridge
271
- * runs these after first-wins `onLoad` plugins rewrite module source.
272
- * - `loaders` keeps the same transform available to server-oriented builds
273
- * that still use competing `onLoad` handlers directly.
274
- *
275
- * Browser builds exclude the loader copy in {@link getAppBrowserBuildPlugins}
276
- * when the transform name is already present in `sourceTransforms`.
282
+ * Component identity attribution is a source transform so browser, HMR, and
283
+ * server module paths share one component-identity attribution mechanism.
277
284
  */
278
285
  private initializeDefaultLoaders;
279
286
  private reviewBaseUrl;
@@ -7,7 +7,6 @@ import { DEFAULT_ECOPAGES_DIST_DIR, DEFAULT_ECOPAGES_HOSTNAME, DEFAULT_ECOPAGES_
7
7
  import { collectConfiguredAppBuildManifestContributions, createBuildAdapter, setAppBuildAdapter, updateAppBuildManifest, } from '../build/build-adapter.js';
8
8
  import { GHTML_PLUGIN_NAME } from '../integrations/ghtml/ghtml.constants.js';
9
9
  import { ghtmlPlugin } from '../integrations/ghtml/ghtml.plugin.js';
10
- import { createEcoComponentMetaPlugin } from '../plugins/eco-component-meta-plugin.js';
11
10
  import { createEcoComponentMetaTransform } from '../plugins/eco-component-meta-plugin.js';
12
11
  import { NoopEntrypointDependencyGraph, setAppEntrypointDependencyGraph, } from '../services/runtime-state/entrypoint-dependency-graph.service.js';
13
12
  import { CounterServerInvalidationState, setAppServerInvalidationState, } from '../services/runtime-state/server-invalidation-state.service.js';
@@ -76,6 +75,8 @@ export class ConfigBuilder {
76
75
  description: 'This is a static site generated with Ecopages',
77
76
  },
78
77
  additionalWatchPaths: [],
78
+ devPrewarmPaths: [],
79
+ devPrewarmBeforeReadyPaths: [],
79
80
  templatesExt: [],
80
81
  absolutePaths: {
81
82
  config: '',
@@ -316,6 +317,26 @@ export class ConfigBuilder {
316
317
  this.config.additionalWatchPaths = additionalWatchPaths;
317
318
  return this;
318
319
  }
320
+ /**
321
+ * Sets static paths to SSR-prewarm in development.
322
+ *
323
+ * @param devPrewarmPaths - Absolute URL pathnames such as `/` or `/docs`.
324
+ * @returns The ConfigBuilder instance for method chaining.
325
+ */
326
+ setDevPrewarmPaths(devPrewarmPaths) {
327
+ this.config.devPrewarmPaths = devPrewarmPaths;
328
+ return this;
329
+ }
330
+ /**
331
+ * Sets static paths to SSR-prewarm before the development server reports ready.
332
+ *
333
+ * @param devPrewarmBeforeReadyPaths - Critical absolute URL pathnames such as `/`.
334
+ * @returns The ConfigBuilder instance for method chaining.
335
+ */
336
+ setDevPrewarmBeforeReadyPaths(devPrewarmBeforeReadyPaths) {
337
+ this.config.devPrewarmBeforeReadyPaths = devPrewarmBeforeReadyPaths;
338
+ return this;
339
+ }
319
340
  /**
320
341
  * Sets the processors to use for the application.
321
342
  * This replaces any existing processors.
@@ -580,25 +601,14 @@ export class ConfigBuilder {
580
601
  * Initializes default loaders that are required for EcoPages to function.
581
602
  *
582
603
  * @remarks
583
- * `eco-component-meta` is registered twice on purpose:
584
- *
585
- * - `sourceTransforms` is the canonical browser/HMR path. The Rolldown bridge
586
- * runs these after first-wins `onLoad` plugins rewrite module source.
587
- * - `loaders` keeps the same transform available to server-oriented builds
588
- * that still use competing `onLoad` handlers directly.
589
- *
590
- * Browser builds exclude the loader copy in {@link getAppBrowserBuildPlugins}
591
- * when the transform name is already present in `sourceTransforms`.
604
+ * Component identity attribution is a source transform so browser, HMR, and
605
+ * server module paths share one component-identity attribution mechanism.
592
606
  */
593
607
  async initializeDefaultLoaders() {
594
608
  const componentMetaTransform = createEcoComponentMetaTransform({ config: this.config });
595
609
  if (!this.config.sourceTransforms.has(componentMetaTransform.name)) {
596
610
  this.config.sourceTransforms.set(componentMetaTransform.name, componentMetaTransform);
597
611
  }
598
- const componentMetaPlugin = createEcoComponentMetaPlugin({ config: this.config });
599
- if (!this.config.loaders.has(componentMetaPlugin.name)) {
600
- this.config.loaders.set(componentMetaPlugin.name, componentMetaPlugin);
601
- }
602
612
  }
603
613
  reviewBaseUrl(baseUrl) {
604
614
  if (baseUrl) {
@@ -22,6 +22,7 @@ export declare class DevTransformVendorRegistry {
22
22
  tryHandleVendorRequest(pathname: string): Response | null;
23
23
  invalidateAll(): void;
24
24
  private createVendorResponse;
25
+ private resolveExistingVendorPath;
25
26
  private prebundleSpecifier;
26
27
  private resolvePackageEntry;
27
28
  }
@@ -31,7 +31,7 @@ export class DevTransformVendorRegistry {
31
31
  }
32
32
  async resolveVendorUrl(specifier) {
33
33
  const known = this.resolveKnownVendorUrl(specifier);
34
- if (known) {
34
+ if (known && this.resolveExistingVendorPath(known)) {
35
35
  return known;
36
36
  }
37
37
  const cached = this.cache.get(specifier);
@@ -60,7 +60,7 @@ export class DevTransformVendorRegistry {
60
60
  const filePath = path.join(this.vendorsDir, fileName);
61
61
  if (!fileSystem.exists(filePath)) {
62
62
  for (const entry of this.cache.values()) {
63
- if (entry.url === pathname) {
63
+ if (entry.url === pathname && fileSystem.exists(entry.filePath)) {
64
64
  return this.createVendorResponse(entry.filePath);
65
65
  }
66
66
  }
@@ -80,6 +80,19 @@ export class DevTransformVendorRegistry {
80
80
  },
81
81
  });
82
82
  }
83
+ resolveExistingVendorPath(url) {
84
+ const prefix = `/${RESOLVED_ASSETS_VENDORS_DIR}/`;
85
+ const pathname = new URL(url, 'http://ecopages.local').pathname;
86
+ if (!pathname.startsWith(prefix)) {
87
+ return undefined;
88
+ }
89
+ const fileName = pathname.slice(prefix.length);
90
+ if (!fileName || fileName.includes('..')) {
91
+ return undefined;
92
+ }
93
+ const filePath = path.join(this.vendorsDir, fileName);
94
+ return fileSystem.exists(filePath) ? filePath : undefined;
95
+ }
83
96
  async prebundleSpecifier(specifier) {
84
97
  const resolvedEntry = this.resolvePackageEntry(specifier);
85
98
  fileSystem.ensureDir(this.vendorsDir);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Per-request and process-wide counters for the development render pipeline.
3
+ *
4
+ * @remarks
5
+ * Gated behind {@link isRequestPipelineMetricsEnabled} so production paths stay quiet.
6
+ * Benchmarks and structural invariant tests read the snapshot via
7
+ * {@link getRequestPipelineMetricsSnapshot}.
8
+ */
9
+ export type PageModuleLoadOutcome = 'import-cache-hit' | 'disk-cache-hit' | 'cold-build' | 'host-loader' | 'unified-graph';
10
+ export type RouteRenderPhase = 'resolve-route-inputs' | 'ownership-validation' | 'resolve-dependencies' | 'page-browser-graph' | 'render-body' | 'transform-response';
11
+ export type RouteRenderPhaseRecord = {
12
+ count: number;
13
+ totalDurationMs: number;
14
+ lastDurationMs: number;
15
+ };
16
+ export type RequestPipelineMetricsSnapshot = {
17
+ runtime: 'node' | 'bun';
18
+ pageModuleLoads: number;
19
+ pageModuleBuilds: number;
20
+ pageModuleCacheHits: number;
21
+ hostLoaderLoads: number;
22
+ diskCacheHits: number;
23
+ routeModuleArtifacts: number;
24
+ mdxTransforms: number;
25
+ collectionBuilds: number;
26
+ routeRenderPhases: Partial<Record<RouteRenderPhase, RouteRenderPhaseRecord>>;
27
+ };
28
+ export declare function isRequestPipelineMetricsEnabled(): boolean;
29
+ export declare function getRequestPipelineMetricsHeaderName(): string;
30
+ export declare function resolveRequestPipelineRuntime(): 'node' | 'bun';
31
+ export declare function recordPageModuleLoad(outcome: PageModuleLoadOutcome, artifactCount?: number): void;
32
+ export declare function recordMdxTransform(): void;
33
+ export declare function recordCollectionBuild(): void;
34
+ export declare function recordRouteRenderPhase(phase: RouteRenderPhase, durationMs: number): void;
35
+ export declare function measureRouteRenderPhase<T>(phase: RouteRenderPhase, fn: () => Promise<T>): Promise<T>;
36
+ export declare function getRequestPipelineMetricsSnapshot(): RequestPipelineMetricsSnapshot;
37
+ export declare function serializeRequestPipelineMetricsHeader(): string;
38
+ export declare function resetRequestPipelineMetrics(): void;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Per-request and process-wide counters for the development render pipeline.
3
+ *
4
+ * @remarks
5
+ * Gated behind {@link isRequestPipelineMetricsEnabled} so production paths stay quiet.
6
+ * Benchmarks and structural invariant tests read the snapshot via
7
+ * {@link getRequestPipelineMetricsSnapshot}.
8
+ */
9
+ const METRICS_HEADER = 'x-ecopages-pipeline-metrics';
10
+ let pageModuleLoads = 0;
11
+ let pageModuleBuilds = 0;
12
+ let pageModuleCacheHits = 0;
13
+ let hostLoaderLoads = 0;
14
+ let diskCacheHits = 0;
15
+ let routeModuleArtifacts = 0;
16
+ let mdxTransforms = 0;
17
+ let collectionBuilds = 0;
18
+ const routeRenderPhases = new Map();
19
+ export function isRequestPipelineMetricsEnabled() {
20
+ return (process.env.ECOPAGES_REQUEST_PIPELINE_METRICS === '1' ||
21
+ process.env.ECOPAGES_ROLLDOWN_BUILD_METRICS === '1' ||
22
+ process.env.ECOPAGES_LOGGER_DEBUG === 'true');
23
+ }
24
+ export function getRequestPipelineMetricsHeaderName() {
25
+ return METRICS_HEADER;
26
+ }
27
+ export function resolveRequestPipelineRuntime() {
28
+ return typeof Bun !== 'undefined' ? 'bun' : 'node';
29
+ }
30
+ export function recordPageModuleLoad(outcome, artifactCount = 0) {
31
+ if (!isRequestPipelineMetricsEnabled()) {
32
+ return;
33
+ }
34
+ pageModuleLoads += 1;
35
+ switch (outcome) {
36
+ case 'import-cache-hit':
37
+ pageModuleCacheHits += 1;
38
+ break;
39
+ case 'disk-cache-hit':
40
+ diskCacheHits += 1;
41
+ break;
42
+ case 'cold-build':
43
+ pageModuleBuilds += 1;
44
+ routeModuleArtifacts += artifactCount;
45
+ break;
46
+ case 'host-loader':
47
+ hostLoaderLoads += 1;
48
+ break;
49
+ case 'unified-graph':
50
+ break;
51
+ }
52
+ }
53
+ export function recordMdxTransform() {
54
+ if (!isRequestPipelineMetricsEnabled()) {
55
+ return;
56
+ }
57
+ mdxTransforms += 1;
58
+ }
59
+ export function recordCollectionBuild() {
60
+ if (!isRequestPipelineMetricsEnabled()) {
61
+ return;
62
+ }
63
+ collectionBuilds += 1;
64
+ }
65
+ export function recordRouteRenderPhase(phase, durationMs) {
66
+ if (!isRequestPipelineMetricsEnabled()) {
67
+ return;
68
+ }
69
+ const existing = routeRenderPhases.get(phase);
70
+ if (existing) {
71
+ existing.count += 1;
72
+ existing.totalDurationMs += durationMs;
73
+ existing.lastDurationMs = durationMs;
74
+ return;
75
+ }
76
+ routeRenderPhases.set(phase, {
77
+ count: 1,
78
+ totalDurationMs: durationMs,
79
+ lastDurationMs: durationMs,
80
+ });
81
+ }
82
+ export async function measureRouteRenderPhase(phase, fn) {
83
+ if (!isRequestPipelineMetricsEnabled()) {
84
+ return fn();
85
+ }
86
+ const startedAt = performance.now();
87
+ try {
88
+ return await fn();
89
+ }
90
+ finally {
91
+ recordRouteRenderPhase(phase, performance.now() - startedAt);
92
+ }
93
+ }
94
+ export function getRequestPipelineMetricsSnapshot() {
95
+ return {
96
+ runtime: resolveRequestPipelineRuntime(),
97
+ pageModuleLoads,
98
+ pageModuleBuilds,
99
+ pageModuleCacheHits,
100
+ hostLoaderLoads,
101
+ diskCacheHits,
102
+ routeModuleArtifacts,
103
+ mdxTransforms,
104
+ collectionBuilds,
105
+ routeRenderPhases: Object.fromEntries(routeRenderPhases),
106
+ };
107
+ }
108
+ export function serializeRequestPipelineMetricsHeader() {
109
+ return JSON.stringify(getRequestPipelineMetricsSnapshot());
110
+ }
111
+ export function resetRequestPipelineMetrics() {
112
+ pageModuleLoads = 0;
113
+ pageModuleBuilds = 0;
114
+ pageModuleCacheHits = 0;
115
+ hostLoaderLoads = 0;
116
+ diskCacheHits = 0;
117
+ routeModuleArtifacts = 0;
118
+ mdxTransforms = 0;
119
+ collectionBuilds = 0;
120
+ routeRenderPhases.clear();
121
+ }
@@ -0,0 +1,13 @@
1
+ import type { EcoComponent, EcoComponentConfig } from '../types/public-types.js';
2
+ /** Stable attribution for a Component created by an Integration-owned module. */
3
+ export type ComponentIdentity = {
4
+ id: string;
5
+ file: string;
6
+ integration: string;
7
+ };
8
+ /** Gets canonical component attribution. */
9
+ export declare function getComponentIdentity(value: EcoComponent | EcoComponentConfig | undefined): ComponentIdentity | undefined;
10
+ /** Merges attribution into factory options. */
11
+ export declare function bindComponentIdentity<T extends object>(identity: ComponentIdentity, options: T): T & {
12
+ identity: ComponentIdentity;
13
+ };
@@ -0,0 +1,18 @@
1
+ function asConfig(value) {
2
+ if (!value)
3
+ return undefined;
4
+ if (typeof value === 'function')
5
+ return value.config;
6
+ if ('config' in value)
7
+ return value.config;
8
+ return value;
9
+ }
10
+ /** Gets canonical component attribution. */
11
+ export function getComponentIdentity(value) {
12
+ const config = asConfig(value);
13
+ return config?.identity;
14
+ }
15
+ /** Merges attribution into factory options. */
16
+ export function bindComponentIdentity(identity, options) {
17
+ return { ...options, identity };
18
+ }
@@ -3,11 +3,12 @@ export { getUndeclaredComponentDependencyMessage } from '../errors/undeclared-co
3
3
  /**
4
4
  * @remarks
5
5
  * Declared components are produced by `eco.component()`, `eco.layout()`, and
6
- * `eco.html()` after the component-meta plugin injects `config.__eco`.
6
+ * `eco.html()` after the component identity transform binds identity via
7
+ * `bindComponentIdentity`.
7
8
  */
8
9
  export declare function isEcoDeclaredComponent(component: unknown): component is EcoDeclaredComponent;
9
10
  /**
10
- * @throws {UndeclaredComponentDependencyError} When the value lacks `config.__eco`.
11
+ * @throws {UndeclaredComponentDependencyError} When the value lacks component identity.
11
12
  */
12
13
  export declare function assertEcoDeclaredComponent(component: unknown, context?: {
13
14
  parentComponentFile?: string;
@@ -1,17 +1,19 @@
1
1
  import { UndeclaredComponentDependencyError } from '../errors/undeclared-component-dependency-error.js';
2
+ import { getComponentIdentity } from './component-identity.js';
2
3
  export { getUndeclaredComponentDependencyMessage } from '../errors/undeclared-component-dependency-error.js';
3
4
  /**
4
5
  * @remarks
5
6
  * Declared components are produced by `eco.component()`, `eco.layout()`, and
6
- * `eco.html()` after the component-meta plugin injects `config.__eco`.
7
+ * `eco.html()` after the component identity transform binds identity via
8
+ * `bindComponentIdentity`.
7
9
  */
8
10
  export function isEcoDeclaredComponent(component) {
9
11
  return (typeof component === 'function' &&
10
- typeof component.config?.__eco?.file === 'string' &&
11
- typeof component.config?.__eco?.integration === 'string');
12
+ typeof getComponentIdentity(component)?.file === 'string' &&
13
+ typeof getComponentIdentity(component)?.integration === 'string');
12
14
  }
13
15
  /**
14
- * @throws {UndeclaredComponentDependencyError} When the value lacks `config.__eco`.
16
+ * @throws {UndeclaredComponentDependencyError} When the value lacks component identity.
15
17
  */
16
18
  export function assertEcoDeclaredComponent(component, context = {}) {
17
19
  if (isEcoDeclaredComponent(component)) {
@@ -2,7 +2,7 @@ import { applyPageLayoutConfig, mergeLayoutDependencies, normalizePageLayouts }
2
2
  function createComponentFactory(options) {
3
3
  const component = ((props) => options.render(props));
4
4
  component.config = {
5
- __eco: options.__eco,
5
+ identity: options.identity,
6
6
  integration: options.integration,
7
7
  dependencies: options.dependencies,
8
8
  };
@@ -29,7 +29,7 @@ function page(options) {
29
29
  ? mergeLayoutDependencies(undefined, layoutEntries)
30
30
  : mergeLayoutDependencies(dependenciesInput, layoutEntries);
31
31
  const pageComponent = createComponentFactory({
32
- __eco: options.__eco,
32
+ identity: options.identity,
33
33
  integration: options.integration,
34
34
  dependencies: staticDependencies,
35
35
  render,