@ecopages/core 0.2.0-rc.5 → 0.2.0-rc.7

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 (31) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -3
  3. package/src/declarations.d.ts +10 -1
  4. package/src/islands/README.md +5 -1
  5. package/src/plugins/README.md +1 -0
  6. package/src/plugins/processor.d.ts +4 -0
  7. package/src/plugins/processor.js +4 -0
  8. package/src/route-renderer/README.md +2 -2
  9. package/src/route-renderer/orchestration/document-shell/document-shell-render.service.d.ts +7 -2
  10. package/src/route-renderer/orchestration/document-shell/document-shell-render.service.js +2 -0
  11. package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.d.ts +3 -2
  12. package/src/route-renderer/orchestration/foreign-child/foreign-subtree-execution.service.js +1 -1
  13. package/src/route-renderer/orchestration/integration-renderer.d.ts +4 -3
  14. package/src/route-renderer/orchestration/integration-renderer.js +3 -3
  15. package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.d.ts +1 -1
  16. package/src/route-renderer/orchestration/ownership-graph/component-graph-collectors.js +2 -2
  17. package/src/route-renderer/orchestration/route-pipeline/route-render-orchestrator.js +3 -2
  18. package/src/route-renderer/orchestration/string-markup-renderer.d.ts +1 -1
  19. package/src/route-renderer/orchestration/string-markup-renderer.js +2 -1
  20. package/src/router/README.md +5 -1
  21. package/src/router/server/route-registry.d.ts +13 -0
  22. package/src/router/server/route-registry.js +46 -32
  23. package/src/services/README.md +2 -0
  24. package/src/services/assets/asset-processing-service/browser-runtime-entry-resolution.d.ts +6 -0
  25. package/src/services/assets/asset-processing-service/browser-runtime-entry-resolution.js +24 -5
  26. package/src/services/invalidation/development-invalidation.service.js +10 -3
  27. package/src/services/module-loading/README.md +2 -0
  28. package/src/services/module-loading/collection-server-module-build.service.d.ts +1 -0
  29. package/src/services/module-loading/collection-server-module-build.service.js +5 -2
  30. package/src/types/internal-types.d.ts +5 -1
  31. package/src/watchers/project-watcher.js +5 -1
package/README.md CHANGED
@@ -53,7 +53,10 @@ flowchart TD
53
53
  B --> C[DevelopmentInvalidationService]
54
54
  C --> D{Change kind}
55
55
  D -->|Route or server source| E[Invalidate server modules]
56
- D -->|Public or include| F[Reload browser]
56
+ D -->|Additional watch| E
57
+ E --> N[Notify processors]
58
+ N --> F[Reload browser]
59
+ D -->|Public asset| F
57
60
  D -->|Processor-owned asset| G[Notify processor only]
58
61
  D -->|HMR-eligible source| H[Core HMR manager]
59
62
  H --> I[Strategy selection]
@@ -74,6 +77,8 @@ The manager/orchestration layer is core-owned, but framework-specific strategies
74
77
  - `ConfigBuilder` seeds one app-owned build ownership path, adapter, manifest, executor, dev graph, and runtime registry.
75
78
  - `BrowserBundleService` is the shared browser build seam used by HMR and asset-oriented browser output paths.
76
79
  - `ServerModuleTranspiler` is the shared server-side source loading seam used by runtime bootstrap and HMR metadata loading.
80
+ - `RouteRegistry` gives exact and dynamic Pages priority over catch-alls, then selects the most specific matching catch-all prefix.
81
+ - Browser runtime package entries resolve from the application root with ESM import conditions, so framework-local packages cannot shadow app dependencies.
77
82
  - `createApp()` stays the universal runtime entrypoint, while Vite and Nitro hosts own their advanced dev and build workflows.
78
83
  - One bundled adapter is the default bundler. Vite-based apps route through the `ViteHostBuildAdapter` boundary marker instead.
79
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-rc.5",
3
+ "version": "0.2.0-rc.7",
4
4
  "description": "Core package for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -20,7 +20,7 @@
20
20
  "directory": "packages/core"
21
21
  },
22
22
  "dependencies": {
23
- "@ecopages/file-system": "0.2.0-rc.5",
23
+ "@ecopages/file-system": "0.2.0-rc.7",
24
24
  "@ecopages/logger": "^0.2.3",
25
25
  "@ecopages/scripts-injector": "^0.1.5",
26
26
  "@oxc-project/runtime": "0.141.0",
@@ -34,7 +34,7 @@
34
34
  "@standard-schema/utils": "^0.3.0"
35
35
  },
36
36
  "peerDependencies": {
37
- "@ecopages/dev-toolbar": "0.2.0-rc.5"
37
+ "@ecopages/dev-toolbar": "0.2.0-rc.7"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@ecopages/dev-toolbar": {
@@ -1,4 +1,4 @@
1
- /// <reference path="./css-imports.d.js" />
1
+ import './css-imports.d.js';
2
2
 
3
3
  import type { EcoPagesAppConfig } from './types/internal-types';
4
4
  import type { EcoNavigationRuntime } from './router/client/navigation-coordinator';
@@ -6,6 +6,12 @@ import type { EcoNavigationRuntime } from './router/client/navigation-coordinato
6
6
  type HMRHandler = (url: string) => Promise<void>;
7
7
  type CleanupPageRootFunction = () => void;
8
8
  type EcoPageRoot = { render: (node: unknown) => void; unmount: () => void };
9
+ type EcoIslandComponent = (props: Record<string, unknown>) => unknown;
10
+ /** Shared React island diagnostics and root lookup state exposed to devtools. */
11
+ type EcoIslandRuntime = {
12
+ islandRoots: Record<string, EcoPageRoot>;
13
+ islandComponents: Record<string, EcoIslandComponent>;
14
+ };
9
15
  type EcoPageData = {
10
16
  module: string;
11
17
  props: Record<string, unknown>;
@@ -17,7 +23,10 @@ type EcoPagesWindowRuntime = {
17
23
  cleanupPageRoot?: CleanupPageRootFunction;
18
24
  pageRoot?: EcoPageRoot | null;
19
25
  };
26
+ /** Roots keyed by SSR instance ID; component keys are never used for lookup. */
20
27
  islandRoots?: Record<string, EcoPageRoot>;
28
+ islandComponents?: Record<string, EcoIslandComponent>;
29
+ __ecoIslandRuntime?: EcoIslandRuntime;
21
30
  page?: EcoPageData;
22
31
  };
23
32
 
@@ -12,7 +12,11 @@ Hydratable component instances stamp a small, integration-agnostic attribute set
12
12
  | `data-eco-component-key` | Optional module key used by client hydration (React) |
13
13
  | `data-eco-props` | Optional base64 JSON props snapshot for hydration |
14
14
 
15
- React may still replace the SSR host with `<eco-island>` after hydration; the marker is applied at SSR time on the component root.
15
+ React integrations may emit `<eco-island>` as the SSR host. The host uses
16
+ `display: contents` so it does not introduce a layout box, while the host and
17
+ its children remain in place as the client calls `hydrateRoot()` on that host.
18
+ React island integrations may add `data-eco-hydrated` after the initial client
19
+ commit for development diagnostics.
16
20
 
17
21
  ## Where stamping happens
18
22
 
@@ -37,6 +37,7 @@ These contracts are responsible for:
37
37
  2. Core seals the app-owned build manifest.
38
38
  3. Runtime startup calls runtime-only setup hooks.
39
39
  4. Request-time rendering and development invalidation reuse those finalized contracts.
40
+ 5. `DevelopmentInvalidationService.invalidateServerModules()` calls `Processor.invalidateServerArtifacts()` so processors can discard compiled server artifacts that are not part of the route-module graph.
40
41
 
41
42
  ## Discovered dependency metadata
42
43
 
@@ -147,6 +147,10 @@ export declare abstract class Processor<TOptions = Record<string, unknown>> {
147
147
  didChange(): boolean;
148
148
  abstract setup(): Promise<void>;
149
149
  abstract process(input: unknown, filePath?: string): Promise<unknown>;
150
+ /**
151
+ * Discards cached server build artifacts when server modules are invalidated.
152
+ */
153
+ invalidateServerArtifacts(): void;
150
154
  /**
151
155
  * Releases runtime resources owned by the processor.
152
156
  *
@@ -108,6 +108,10 @@ export class Processor {
108
108
  didChange() {
109
109
  return false;
110
110
  }
111
+ /**
112
+ * Discards cached server build artifacts when server modules are invalidated.
113
+ */
114
+ invalidateServerArtifacts() { }
111
115
  /**
112
116
  * Releases runtime resources owned by the processor.
113
117
  *
@@ -95,12 +95,12 @@ Important:
95
95
 
96
96
  - route-level fallback resolution is gone; unresolved artifacts are now a hard failure
97
97
  - ownership is declared from canonical component identity, not inferred from final HTML
98
- - declared page dependencies are resolved from final render inputs and carried to the owning integration; integrations may use them for renderer-specific lifecycle work such as HMR ownership
98
+ - declared page dependencies are resolved from final render inputs and carried as dependency roots to the owning integration; those roots participate in asset, integration, ownership, and renderer-specific lifecycle collection
99
99
  - same-integration children stay renderer-local and do not need to pass through a universal transport
100
100
 
101
101
  ## Declared Foreign Child Contract
102
102
 
103
- Mixed-integration component configs must declare every possible foreign child in `config.dependencies.components`.
103
+ Static mixed-integration component configs must declare every possible foreign child in `config.dependencies.components`. Page dependency resolvers can additionally provide request-time component roots when a route loads a component dynamically.
104
104
 
105
105
  `OwnershipValidationService` surfaces missing metadata or unknown integrations during `prepareRenderOptions()` and throws before render execution starts.
106
106
 
@@ -1,4 +1,5 @@
1
- import type { BaseIntegrationContext, ComponentRenderInput, ComponentRenderResult, EcoComponent, PageMetadataProps } from '../../../types/public-types.js';
1
+ import type { BaseIntegrationContext, ComponentRenderResult, EcoComponent, PageMetadataProps } from '../../../types/public-types.js';
2
+ import type { InternalComponentRenderInput } from '../../../types/internal-types.js';
2
3
  import type { ProcessedAsset } from '../../../services/assets/asset-processing-service/index.js';
3
4
  import { HtmlTransformerService, type HtmlDocumentContribution } from '../../../services/html/html-transformer.service.js';
4
5
  export type DocumentShellLayoutInput = {
@@ -36,6 +37,8 @@ export type DocumentShellComposeChildrenHook = (context: DocumentShellComposeChi
36
37
  export type DocumentShellComposeInput = {
37
38
  primaryComponent: EcoComponent;
38
39
  primaryProps: Record<string, unknown>;
40
+ /** Components whose ownership graphs must join the foreign-child decision. */
41
+ foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>;
39
42
  layout?: DocumentShellLayoutInput;
40
43
  layouts?: DocumentShellLayoutInput[];
41
44
  composeChildren?: DocumentShellComposeChildrenHook;
@@ -47,6 +50,8 @@ export type DocumentShellPageRenderInput = {
47
50
  component: EcoComponent;
48
51
  props: Record<string, unknown>;
49
52
  };
53
+ /** Components whose ownership graphs must join the foreign-child decision. */
54
+ foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>;
50
55
  layout?: DocumentShellLayoutInput;
51
56
  layouts?: DocumentShellLayoutInput[];
52
57
  composeChildren?: DocumentShellComposeChildrenHook;
@@ -57,7 +62,7 @@ export type DocumentShellPageRenderInput = {
57
62
  transformDocumentHtml?: (html: string) => string;
58
63
  };
59
64
  export type DocumentShellRenderDependencies = {
60
- renderComponentWithForeignChildren(input: ComponentRenderInput): Promise<ComponentRenderResult>;
65
+ renderComponentWithForeignChildren(input: InternalComponentRenderInput): Promise<ComponentRenderResult>;
61
66
  appendProcessedDependencies(...assetGroups: Array<readonly ProcessedAsset[] | undefined>): ProcessedAsset[];
62
67
  };
63
68
  export type DocumentShellAttributeStamping = {
@@ -68,6 +68,7 @@ export async function composeDocumentShell(dependencies, input) {
68
68
  primaryRender = await dependencies.renderComponentWithForeignChildren({
69
69
  component: input.primaryComponent,
70
70
  props: input.primaryProps,
71
+ foreignChildRoots: input.foreignChildRoots,
71
72
  integrationContext: { rendererCache },
72
73
  });
73
74
  const composed = await composeChildren({
@@ -113,6 +114,7 @@ export async function renderPageDocumentShell(dependencies, input, docType) {
113
114
  const { documentHtml: composedDocumentHtml } = await composeDocumentShell(dependencies, {
114
115
  primaryComponent: input.page.component,
115
116
  primaryProps: input.page.props,
117
+ foreignChildRoots: input.foreignChildRoots,
116
118
  layout: input.layout,
117
119
  layouts: input.layouts,
118
120
  composeChildren: input.composeChildren,
@@ -1,5 +1,6 @@
1
1
  import type { ProcessedAsset } from '../../../services/assets/asset-processing-service/index.js';
2
2
  import type { BaseIntegrationContext, ComponentRenderInput, ComponentRenderResult, EcoComponent, ForeignSubtreeRenderPayload } from '../../../types/public-types.js';
3
+ import type { InternalComponentRenderInput } from '../../../types/internal-types.js';
3
4
  import { type ForeignChildRuntime } from './component-render-context.js';
4
5
  export type QueuedForeignChildDecisionInput = {
5
6
  currentIntegration: string;
@@ -38,10 +39,10 @@ export interface ForeignSubtreeExecutionDecisionInput {
38
39
  }
39
40
  export interface ForeignSubtreeExecutionRenderOptions {
40
41
  currentIntegrationName: string;
41
- input: ComponentRenderInput;
42
+ input: InternalComponentRenderInput;
42
43
  renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult>;
43
44
  normalizeComponentRenderOutput(result: ComponentRenderResult): ComponentRenderResult;
44
- hasForeignChildDescendants(component: EcoComponent): boolean;
45
+ hasForeignChildDescendants(component: EcoComponent, foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>): boolean;
45
46
  createForeignChildRuntime(options: {
46
47
  renderInput: ComponentRenderInput;
47
48
  rendererCache: Map<string, ForeignSubtreeExecutionOwningRenderer>;
@@ -241,7 +241,7 @@ export class ForeignSubtreeExecutionService {
241
241
  if (delegatedForeignChildRender) {
242
242
  return delegatedForeignChildRender;
243
243
  }
244
- const hasForeignChildren = options.hasForeignChildDescendants(options.input.component) ||
244
+ const hasForeignChildren = options.hasForeignChildDescendants(options.input.component, options.input.foreignChildRoots) ||
245
245
  this.requiresForeignChildRuntime(options.input);
246
246
  const activeRenderContext = getComponentRenderContext();
247
247
  if (!hasForeignChildren) {
@@ -3,7 +3,7 @@
3
3
  * Every integration renderer should extend this class
4
4
  * @module
5
5
  */
6
- import type { EcoPagesAppConfig, IHmrManager } from '../../types/internal-types.js';
6
+ import type { EcoPagesAppConfig, IHmrManager, InternalComponentRenderInput } from '../../types/internal-types.js';
7
7
  import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoPageFile, EcoPagesElement, HtmlTemplateProps, IntegrationRendererRenderOptions, PageBrowserGraphContribution, PageBrowserGraphContributionContext, PageBrowserGraphResult, PageMetadataProps, RouteRendererBody, RouteRendererOptions, RouteRenderResult } from '../../types/public-types.js';
8
8
  import { type AssetProcessingService, type ProcessedAsset } from '../../services/assets/asset-processing-service/index.js';
9
9
  import { HtmlTransformerService } from '../../services/html/html-transformer.service.js';
@@ -217,6 +217,7 @@ export declare abstract class IntegrationRenderer<C = EcoPagesElement> {
217
217
  pageProps: Record<string, unknown>;
218
218
  documentProps?: Record<string, unknown>;
219
219
  transformDocumentHtml?: (html: string) => string;
220
+ foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>;
220
221
  }): Promise<string>;
221
222
  protected renderStringComponentWithSerializedChildren(input: ComponentRenderInput, component: (props: Record<string, unknown>) => Promise<EcoPagesElement> | EcoPagesElement): Promise<ComponentRenderResult>;
222
223
  /**
@@ -349,7 +350,7 @@ export declare abstract class IntegrationRenderer<C = EcoPagesElement> {
349
350
  * render them with no active foreign-child runtime, which bypasses the owning
350
351
  * renderer's nested foreign-child handoff.
351
352
  */
352
- renderComponentWithForeignChildren(input: ComponentRenderInput): Promise<ComponentRenderResult>;
353
+ renderComponentWithForeignChildren(input: InternalComponentRenderInput): Promise<ComponentRenderResult>;
353
354
  protected finalizeIslandComponentRender(input: ComponentRenderInput, result: ComponentRenderResult): ComponentRenderResult;
354
355
  private normalizeComponentRenderOutput;
355
356
  protected normalizeUnresolvedMarkerArtifactHtml(html: string): string;
@@ -360,7 +361,7 @@ export declare abstract class IntegrationRenderer<C = EcoPagesElement> {
360
361
  * This keeps foreign-child runtime setup narrow: same-integration trees can render
361
362
  * directly without paying the queue orchestration cost.
362
363
  */
363
- protected hasForeignChildDescendants(component: EcoComponent): boolean;
364
+ protected hasForeignChildDescendants(component: EcoComponent, foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>): boolean;
364
365
  /**
365
366
  * Render a view directly to a Response object.
366
367
  * Used for explicit routing where views are rendered from route handlers.
@@ -688,7 +688,7 @@ export class IntegrationRenderer {
688
688
  input,
689
689
  renderComponent: (renderInput) => this.renderComponent(renderInput),
690
690
  normalizeComponentRenderOutput: (result) => this.normalizeComponentRenderOutput(result),
691
- hasForeignChildDescendants: (component) => this.hasForeignChildDescendants(component),
691
+ hasForeignChildDescendants: (component, foreignChildRoots) => this.hasForeignChildDescendants(component, foreignChildRoots),
692
692
  createForeignChildRuntime: ({ renderInput, rendererCache }) => this.createForeignChildRuntime({
693
693
  renderInput,
694
694
  rendererCache: rendererCache,
@@ -725,8 +725,8 @@ export class IntegrationRenderer {
725
725
  * This keeps foreign-child runtime setup narrow: same-integration trees can render
726
726
  * directly without paying the queue orchestration cost.
727
727
  */
728
- hasForeignChildDescendants(component) {
729
- return hasForeignChildDescendantsInGraph(component, this.name);
728
+ hasForeignChildDescendants(component, foreignChildRoots) {
729
+ return hasForeignChildDescendantsInGraph(component, this.name, foreignChildRoots);
730
730
  }
731
731
  /**
732
732
  * Render a single component and return structured output for orchestration paths.
@@ -3,5 +3,5 @@ import type { EcoComponent, ResolvedLazyTrigger } from '../../../types/public-ty
3
3
  import type { ProcessedAsset } from '../../../services/assets/asset-processing-service/index.js';
4
4
  export declare function collectIntegrationNamesFromGraph(components: (EcoComponent | Partial<EcoComponent>)[], currentIntegrationName: string): Set<string>;
5
5
  export declare function collectResolvedLazyTriggersFromGraph(components: (EcoComponent | Partial<EcoComponent>)[], currentIntegrationName: string): ResolvedLazyTrigger[];
6
- export declare function hasForeignChildDescendantsInGraph(component: EcoComponent, currentIntegrationName: string): boolean;
6
+ export declare function hasForeignChildDescendantsInGraph(component: EcoComponent, currentIntegrationName: string, foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>): boolean;
7
7
  export declare function collectUsedIntegrationDependenciesFromGraph(appConfig: EcoPagesAppConfig, components: (EcoComponent | Partial<EcoComponent>)[], currentIntegrationName: string): ProcessedAsset[];
@@ -31,10 +31,10 @@ export function collectResolvedLazyTriggersFromGraph(components, currentIntegrat
31
31
  });
32
32
  return triggers;
33
33
  }
34
- export function hasForeignChildDescendantsInGraph(component, currentIntegrationName) {
34
+ export function hasForeignChildDescendantsInGraph(component, currentIntegrationName, foreignChildRoots) {
35
35
  let foundForeign = false;
36
36
  walkComponentGraph({
37
- roots: [{ component }],
37
+ roots: toGraphRoots([component, ...(foreignChildRoots ?? [])]),
38
38
  currentIntegrationName,
39
39
  onComponent: ({ component: currentComponent }) => {
40
40
  if (foundForeign) {
@@ -88,11 +88,12 @@ export class RouteRenderOrchestrator {
88
88
  dependencyInstanceKey,
89
89
  });
90
90
  const resolvedPageDependencyComponents = resolvedPageDependencies?.components ?? [];
91
+ const dependencyRoots = [...componentsToResolve, ...resolvedPageDependencyComponents];
91
92
  const allDependencies = [
92
93
  ...resolvedDependencies,
93
- ...collectUsedIntegrationDependenciesFromGraph(this.appConfig, componentsToResolve, adapter.name),
94
+ ...collectUsedIntegrationDependenciesFromGraph(this.appConfig, dependencyRoots, adapter.name),
94
95
  ];
95
- const triggers = collectResolvedLazyTriggersFromGraph(componentsToResolve, adapter.name);
96
+ const triggers = collectResolvedLazyTriggersFromGraph(dependencyRoots, adapter.name);
96
97
  const globalAssets = triggers.length > 0
97
98
  ? await buildGlobalInjectorAssets(this.appConfig, this.assetProcessingService, triggers, adapter.name)
98
99
  : [];
@@ -9,6 +9,6 @@ import { IntegrationRenderer, type RenderToResponseContext } from './integration
9
9
  */
10
10
  export declare abstract class StringMarkupRenderer extends IntegrationRenderer<EcoPagesElement> {
11
11
  renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult>;
12
- render({ params, query, props, locals, pageLocals, metadata, Page, Layout, layoutEntries, HtmlTemplate, pageProps, }: IntegrationRendererRenderOptions): Promise<RouteRendererBody>;
12
+ render({ params, query, props, locals, pageLocals, metadata, Page, Layout, layoutEntries, HtmlTemplate, pageProps, resolvedPageDependencyComponents, }: IntegrationRendererRenderOptions): Promise<RouteRendererBody>;
13
13
  renderToResponse<P = Record<string, unknown>>(view: EcoComponent<P>, props: P, ctx: RenderToResponseContext): Promise<Response>;
14
14
  }
@@ -14,7 +14,7 @@ export class StringMarkupRenderer extends IntegrationRenderer {
14
14
  }
15
15
  return this.renderStringComponentWithQueuedForeignSubtrees(input, input.component);
16
16
  }
17
- async render({ params, query, props, locals, pageLocals, metadata, Page, Layout, layoutEntries, HtmlTemplate, pageProps, }) {
17
+ async render({ params, query, props, locals, pageLocals, metadata, Page, Layout, layoutEntries, HtmlTemplate, pageProps, resolvedPageDependencyComponents, }) {
18
18
  try {
19
19
  return await this.renderPageWithDocumentShell({
20
20
  page: {
@@ -31,6 +31,7 @@ export class StringMarkupRenderer extends IntegrationRenderer {
31
31
  htmlTemplate: HtmlTemplate,
32
32
  metadata,
33
33
  pageProps: pageProps ?? props ?? {},
34
+ foreignChildRoots: resolvedPageDependencyComponents,
34
35
  });
35
36
  }
36
37
  catch (error) {
@@ -56,7 +56,11 @@ Match priority:
56
56
 
57
57
  1. `exact` — the pathname must equal the route pathname exactly.
58
58
  2. `dynamic` — the clean (bracket-stripped) prefix must appear in the pathname, and the segment counts must match.
59
- 3. `catch-all` — the clean prefix must appear in the pathname.
59
+ 3. `catch-all` — the prefix segments must match the pathname. When multiple
60
+ catch-all routes match, shared prefix segments are compared from left to right
61
+ so an earlier static segment wins over a dynamic one; when those patterns tie,
62
+ the route with the longer prefix wins, then pathname order provides a
63
+ deterministic tie-breaker.
60
64
 
61
65
  ## `client/`
62
66
 
@@ -62,6 +62,19 @@ export declare class RouteRegistry {
62
62
  reload(): Promise<void>;
63
63
  onReload(listener: () => void): () => void;
64
64
  matchRequest(requestUrl: string): RouteMatch | null;
65
+ /**
66
+ * Orders overlapping catch-all routes by the specificity of their prefix.
67
+ *
68
+ * @remarks
69
+ * Prefix segments are compared from left to right because an earlier static
70
+ * segment constrains a request more strongly than a later one. A longer
71
+ * prefix wins only after the shared segment pattern ties. The pathname tie
72
+ * breaker keeps equal patterns deterministic without depending on discovery
73
+ * order.
74
+ */
75
+ private compareCatchAllSpecificity;
76
+ private getCatchAllPrefix;
77
+ private isDynamicRouteSegment;
65
78
  listStaticPathExpansions(input: {
66
79
  runtimeOrigin: string;
67
80
  }): Promise<readonly StaticPathExpansion[]>;
@@ -49,49 +49,60 @@ export class RouteRegistry {
49
49
  const requestedPathname = normalizePathname(url.pathname);
50
50
  const query = this.getSearchParams(url);
51
51
  for (const route of this.templateRouteList) {
52
- if (route.kind !== 'exact') {
52
+ if (route.kind === 'exact') {
53
+ if (requestedPathname === route.pathname || requestedPathname === `${route.pathname}/`) {
54
+ return {
55
+ requestedPathname,
56
+ templateRoute: route,
57
+ params: {},
58
+ query,
59
+ };
60
+ }
53
61
  continue;
54
62
  }
55
- if (requestedPathname === route.pathname || requestedPathname === `${route.pathname}/`) {
63
+ const params = this.tryExtractParams(route, requestedPathname);
64
+ if (params) {
56
65
  return {
57
66
  requestedPathname,
58
67
  templateRoute: route,
59
- params: {},
68
+ params,
60
69
  query,
61
70
  };
62
71
  }
63
72
  }
64
- for (const route of this.templateRouteList) {
65
- if (route.kind !== 'dynamic') {
66
- continue;
67
- }
68
- const params = this.tryExtractParams(route, requestedPathname);
69
- if (!params) {
70
- continue;
71
- }
72
- return {
73
- requestedPathname,
74
- templateRoute: route,
75
- params,
76
- query,
77
- };
78
- }
79
- for (const route of this.templateRouteList) {
80
- if (route.kind !== 'catch-all') {
81
- continue;
82
- }
83
- const params = this.tryExtractParams(route, requestedPathname);
84
- if (!params) {
85
- continue;
73
+ return null;
74
+ }
75
+ /**
76
+ * Orders overlapping catch-all routes by the specificity of their prefix.
77
+ *
78
+ * @remarks
79
+ * Prefix segments are compared from left to right because an earlier static
80
+ * segment constrains a request more strongly than a later one. A longer
81
+ * prefix wins only after the shared segment pattern ties. The pathname tie
82
+ * breaker keeps equal patterns deterministic without depending on discovery
83
+ * order.
84
+ */
85
+ compareCatchAllSpecificity(left, right) {
86
+ const leftPrefix = this.getCatchAllPrefix(left.pathname);
87
+ const rightPrefix = this.getCatchAllPrefix(right.pathname);
88
+ const sharedLength = Math.min(leftPrefix.length, rightPrefix.length);
89
+ for (let index = 0; index < sharedLength; index += 1) {
90
+ const leftIsDynamic = this.isDynamicRouteSegment(leftPrefix[index]);
91
+ const rightIsDynamic = this.isDynamicRouteSegment(rightPrefix[index]);
92
+ if (leftIsDynamic !== rightIsDynamic) {
93
+ return leftIsDynamic ? 1 : -1;
86
94
  }
87
- return {
88
- requestedPathname,
89
- templateRoute: route,
90
- params,
91
- query,
92
- };
93
95
  }
94
- return null;
96
+ const prefixLengthDifference = rightPrefix.length - leftPrefix.length;
97
+ return prefixLengthDifference !== 0 ? prefixLengthDifference : left.pathname.localeCompare(right.pathname);
98
+ }
99
+ getCatchAllPrefix(pathname) {
100
+ const segments = pathname.split('/').filter(Boolean);
101
+ const catchAllIndex = segments.findIndex((segment) => segment.startsWith('[...') && segment.endsWith(']'));
102
+ return catchAllIndex === -1 ? segments : segments.slice(0, catchAllIndex);
103
+ }
104
+ isDynamicRouteSegment(segment) {
105
+ return segment.startsWith('[') && segment.endsWith(']');
95
106
  }
96
107
  async listStaticPathExpansions(input) {
97
108
  const expansions = [];
@@ -168,6 +179,9 @@ export class RouteRegistry {
168
179
  if (priorityDifference !== 0) {
169
180
  return priorityDifference;
170
181
  }
182
+ if (left.kind === 'catch-all' && right.kind === 'catch-all') {
183
+ return this.compareCatchAllSpecificity(left, right);
184
+ }
171
185
  if (left.pathname === '/') {
172
186
  return -1;
173
187
  }
@@ -30,6 +30,8 @@ so shared layout styles do not need to be rebuilt for every navigated page while
30
30
 
31
31
  Browser runtime module assets resolve bare package roots through their ESM import target. For legacy packages without an `exports` map, they prefer `package.json#module` over CJS `main`; CJS resolution is only a compatibility fallback. Generated entries use `export *` for ESM files and explicit named re-exports for CJS files (so bindings such as React `jsx` exist on the vendor). A default binding is added only when the selected entry exposes one. Runtime vendors are package-root contracts: subpath imports need their own vendor declaration or remain in the consuming bundle.
32
32
 
33
+ Package entry lookup is rooted at the application directory, so a framework-local dependency with the same name cannot shadow the application's installed package. ESM export conditions are evaluated in the same order as the generated vendor import (`node`, `import`, then `default`).
34
+
33
35
  ## Design Rule
34
36
 
35
37
  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.
@@ -3,6 +3,12 @@ export type BrowserRuntimeDefaultExportPolicy = 'emit-default' | 'skip-default';
3
3
  type RequireFromRoot = ReturnType<typeof createRequire>;
4
4
  /**
5
5
  * Resolves a package specifier through Node's ESM import conditions from the app root.
6
+ *
7
+ * @remarks
8
+ * The resolver is rooted at the application directory so package lookup cannot
9
+ * accidentally use a dependency with the same name from the framework's own
10
+ * installation. The condition order matches the generated vendor import: Node
11
+ * compatibility first, then ESM import, then the package default.
6
12
  */
7
13
  export declare function resolvePackageEsmEntryPath(specifier: string, rootDir: string): string | undefined;
8
14
  /**
@@ -1,14 +1,33 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { pathToFileURL, fileURLToPath } from 'node:url';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { ResolverFactory } from 'oxc-resolver';
4
5
  import { isBarePackageImportSpecifier } from '../../../plugins/tsconfig-import-resolver.js';
5
6
  import { toPackageRootSpecifier } from '../../../plugins/package-specifier.js';
7
+ const esmEntryResolvers = new Map();
8
+ function getEsmEntryResolver(rootDir) {
9
+ const normalizedRoot = path.resolve(rootDir);
10
+ const cached = esmEntryResolvers.get(normalizedRoot);
11
+ if (cached) {
12
+ return cached;
13
+ }
14
+ const resolver = new ResolverFactory({ conditionNames: ['node', 'import', 'default'] });
15
+ esmEntryResolvers.set(normalizedRoot, resolver);
16
+ return resolver;
17
+ }
6
18
  /**
7
19
  * Resolves a package specifier through Node's ESM import conditions from the app root.
20
+ *
21
+ * @remarks
22
+ * The resolver is rooted at the application directory so package lookup cannot
23
+ * accidentally use a dependency with the same name from the framework's own
24
+ * installation. The condition order matches the generated vendor import: Node
25
+ * compatibility first, then ESM import, then the package default.
8
26
  */
9
27
  export function resolvePackageEsmEntryPath(specifier, rootDir) {
10
28
  try {
11
- return fileURLToPath(import.meta.resolve(specifier, pathToFileURL(path.join(rootDir, 'package.json')).href));
29
+ const result = getEsmEntryResolver(rootDir).sync(path.resolve(rootDir), specifier);
30
+ return result.path;
12
31
  }
13
32
  catch {
14
33
  return undefined;
@@ -56,13 +75,13 @@ export function resolveBrowserRuntimeEntryPath(options) {
56
75
  if (specifier.startsWith('.')) {
57
76
  return requireFromRoot.resolve(specifier);
58
77
  }
59
- const esmResolvedPath = isBarePackageImportSpecifier(specifier, rootDir)
60
- ? resolvePackageEsmEntryPath(specifier, rootDir)
61
- : undefined;
62
78
  const legacyModulePath = resolveLegacyPackageModuleEntry({ specifier, requireFromRoot, rootDir });
63
79
  if (legacyModulePath) {
64
80
  return legacyModulePath;
65
81
  }
82
+ const esmResolvedPath = isBarePackageImportSpecifier(specifier, rootDir)
83
+ ? resolvePackageEsmEntryPath(specifier, rootDir)
84
+ : undefined;
66
85
  if (esmResolvedPath) {
67
86
  return esmResolvedPath;
68
87
  }
@@ -2,6 +2,7 @@ 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
4
  import { clearAppDevelopmentRouteModuleBuildCaches } from '../module-loading/route-module-build-cache-registry.js';
5
+ import { clearCollectionServerBuildArtifacts } from '../module-loading/collection-server-module-build.service.js';
5
6
  /**
6
7
  * Framework-owned development invalidation service.
7
8
  *
@@ -31,6 +32,10 @@ export class DevelopmentInvalidationService {
31
32
  getAppServerInvalidationState(this.appConfig).invalidateServerModules(changedFiles);
32
33
  this.appConfig.runtime?.appModuleLoader?.invalidateDevelopmentGraph();
33
34
  clearAppDevelopmentRouteModuleBuildCaches(this.appConfig);
35
+ clearCollectionServerBuildArtifacts(this.appConfig);
36
+ for (const processor of this.appConfig.processors.values()) {
37
+ processor.invalidateServerArtifacts?.();
38
+ }
34
39
  }
35
40
  /**
36
41
  * Registers an integration-owned handler for registered script entrypoint edits.
@@ -80,7 +85,7 @@ export class DevelopmentInvalidationService {
80
85
  if (this.matchesAdditionalWatchPaths(filePath)) {
81
86
  return {
82
87
  category: 'additional-watch',
83
- invalidateServerModules: false,
88
+ invalidateServerModules: true,
84
89
  refreshRoutes: false,
85
90
  reloadBrowser: true,
86
91
  delegateToHmr: false,
@@ -162,11 +167,13 @@ export class DevelopmentInvalidationService {
162
167
  return false;
163
168
  for (const pattern of patterns) {
164
169
  if (pattern.includes('*')) {
165
- const ext = pattern.replace(/\*\*?\/\*/, '');
170
+ const ext = pattern.replace(/\*\*?\/\*|\*+/g, '');
166
171
  if (normalizedPath.endsWith(ext))
167
172
  return true;
173
+ continue;
168
174
  }
169
- else if (normalizedPath.endsWith(pattern) || normalizedPath === path.resolve(pattern)) {
175
+ const resolvedPattern = path.isAbsolute(pattern) ? pattern : path.resolve(this.appConfig.rootDir, pattern);
176
+ if (normalizedPath === resolvedPattern || normalizedPath.startsWith(`${resolvedPattern}${path.sep}`)) {
170
177
  return true;
171
178
  }
172
179
  }
@@ -35,6 +35,8 @@ Call site (route scan, renderer, SSG, API)
35
35
 
36
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
+ Compiled collection server modules also include the app-owned server invalidation version in their output filename (`<collection>-<buildIdentity>-<invalidationVersion>.mjs`) so Node's ESM loader treats recompiled bundles as new modules after co-located helper files change.
39
+
38
40
  ## Files
39
41
 
40
42
  | File | Role |
@@ -5,6 +5,7 @@ export type CollectionServerBuildArtifact = {
5
5
  outputUrl: string;
6
6
  sourcePaths: string[];
7
7
  buildIdentity: string;
8
+ invalidationVersion: number;
8
9
  };
9
10
  export declare function getCollectionServerBuildArtifact(appConfig: EcoPagesAppConfig, collectionName: string): CollectionServerBuildArtifact | undefined;
10
11
  export declare function clearCollectionServerBuildArtifacts(appConfig: EcoPagesAppConfig): void;
@@ -7,6 +7,7 @@ import { createServerBuildRequest } from '../../build/runtime/build-request-poli
7
7
  import { recordCollectionBuild } from '../../diagnostics/request-pipeline-metrics.js';
8
8
  import { resolveInternalExecutionDir } from '../../utils/resolve-work-dir.js';
9
9
  import { fileSystem } from '@ecopages/file-system';
10
+ import { getAppServerInvalidationState } from '../runtime-state/server-invalidation-state.service.js';
10
11
  const artifactsByCollection = new WeakMap();
11
12
  function getArtifactMap(appConfig) {
12
13
  let artifacts = artifactsByCollection.get(appConfig);
@@ -51,13 +52,14 @@ export async function buildCollectionServerModule(input) {
51
52
  splitting: false,
52
53
  externalPackages: true,
53
54
  });
55
+ const invalidationVersion = getAppServerInvalidationState(appConfig).getServerInvalidationVersion();
54
56
  const buildIdentity = createCollectionBuildIdentity(createBuildRequestIdentity(buildRequest), sourceFilePath, ownedSourcePaths);
55
57
  const artifacts = getArtifactMap(appConfig);
56
58
  const existing = artifacts.get(collectionName);
57
- if (existing?.buildIdentity === buildIdentity) {
59
+ if (existing?.buildIdentity === buildIdentity && existing?.invalidationVersion === invalidationVersion) {
58
60
  return existing;
59
61
  }
60
- const outputFileName = `${collectionName}-${buildIdentity}.mjs`;
62
+ const outputFileName = `${collectionName}-${buildIdentity}-${invalidationVersion}.mjs`;
61
63
  const buildOptions = { ...buildRequest, naming: outputFileName.replace(/\.mjs$/u, '.[ext]') };
62
64
  recordCollectionBuild();
63
65
  const buildResult = await build(buildOptions);
@@ -77,6 +79,7 @@ export async function buildCollectionServerModule(input) {
77
79
  outputUrl: pathToFileURL(compiledOutput).href,
78
80
  sourcePaths: [...ownedSourcePaths, sourceFilePath],
79
81
  buildIdentity,
82
+ invalidationVersion,
80
83
  };
81
84
  artifacts.set(collectionName, artifact);
82
85
  return artifact;
@@ -5,7 +5,11 @@ import type { BuildRuntime } from '../build/runtime/build-runtime.js';
5
5
  import type { AnyIntegrationPlugin } from '../plugins/integration-plugin.js';
6
6
  import type { Processor } from '../plugins/processor.js';
7
7
  import type { EcoSourceTransform } from '../plugins/source-transform.js';
8
- import type { PageMetadataProps, SitemapConfig } from './public-types.js';
8
+ import type { ComponentRenderInput, EcoComponent, PageMetadataProps, SitemapConfig } from './public-types.js';
9
+ /** Internal render input carrying route-resolved dependency roots. */
10
+ export type InternalComponentRenderInput = ComponentRenderInput & {
11
+ foreignChildRoots?: ReadonlyArray<EcoComponent | Partial<EcoComponent>>;
12
+ };
9
13
  import type { RouteRegistry } from '../router/server/route-registry.js';
10
14
  import type { CacheConfig } from '../services/cache/cache.types.js';
11
15
  import type { DevGraphService } from '../services/runtime-state/dev-graph.service.js';
@@ -184,6 +184,7 @@ export class ProjectWatcher {
184
184
  await this.refreshRouterRoutesCallback();
185
185
  }
186
186
  if (plan.reloadBrowser) {
187
+ await this.notifyProcessors(filePath, event);
187
188
  this.requestBrowserReload();
188
189
  return;
189
190
  }
@@ -364,7 +365,10 @@ export class ProjectWatcher {
364
365
  processorPaths.add(this.appConfig.absolutePaths.publicDir);
365
366
  }
366
367
  for (const watchPath of this.appConfig.additionalWatchPaths) {
367
- processorPaths.add(watchPath);
368
+ const resolvedWatchPath = path.isAbsolute(watchPath) || watchPath.includes('*')
369
+ ? watchPath
370
+ : path.resolve(this.appConfig.rootDir, watchPath);
371
+ processorPaths.add(resolvedWatchPath);
368
372
  }
369
373
  const ignored = createProjectWatcherIgnorePredicate(this.appConfig.absolutePaths);
370
374
  this.watcher = chokidar.watch(Array.from(processorPaths), {