@ecopages/core 0.2.0-alpha.25 → 0.2.0-alpha.27

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 (111) hide show
  1. package/README.md +63 -7
  2. package/package.json +4 -47
  3. package/src/adapters/bun/create-app.ts +54 -2
  4. package/src/adapters/bun/hmr-manager.test.ts +0 -2
  5. package/src/adapters/bun/hmr-manager.ts +1 -24
  6. package/src/adapters/bun/server-adapter.ts +30 -4
  7. package/src/adapters/node/node-hmr-manager.test.ts +0 -2
  8. package/src/adapters/node/node-hmr-manager.ts +2 -25
  9. package/src/adapters/shared/explicit-static-render-preparation.ts +58 -0
  10. package/src/adapters/shared/explicit-static-route-matcher.test.ts +6 -6
  11. package/src/adapters/shared/explicit-static-route-matcher.ts +22 -31
  12. package/src/adapters/shared/file-route-middleware-pipeline.test.ts +5 -10
  13. package/src/adapters/shared/file-route-middleware-pipeline.ts +8 -17
  14. package/src/adapters/shared/fs-server-response-factory.test.ts +32 -43
  15. package/src/adapters/shared/fs-server-response-factory.ts +15 -37
  16. package/src/adapters/shared/fs-server-response-matcher.test.ts +65 -39
  17. package/src/adapters/shared/fs-server-response-matcher.ts +94 -43
  18. package/src/adapters/shared/hmr-manager.contract.test.ts +0 -4
  19. package/src/adapters/shared/render-context.ts +3 -3
  20. package/src/adapters/shared/server-adapter.test.ts +53 -0
  21. package/src/adapters/shared/server-adapter.ts +228 -159
  22. package/src/adapters/shared/server-route-handler.test.ts +6 -5
  23. package/src/adapters/shared/server-route-handler.ts +4 -4
  24. package/src/adapters/shared/server-static-builder.test.ts +4 -4
  25. package/src/adapters/shared/server-static-builder.ts +4 -4
  26. package/src/config/README.md +1 -1
  27. package/src/config/config-builder.test.ts +0 -1
  28. package/src/config/config-builder.ts +2 -7
  29. package/src/dev/host-runtime.ts +34 -0
  30. package/src/eco/eco.browser.test.ts +2 -2
  31. package/src/eco/eco.browser.ts +2 -2
  32. package/src/eco/eco.test.ts +6 -6
  33. package/src/eco/eco.ts +12 -12
  34. package/src/eco/eco.types.ts +3 -3
  35. package/src/errors/index.ts +1 -0
  36. package/src/hmr/client/hmr-runtime.ts +4 -2
  37. package/src/hmr/strategies/js-hmr-strategy.test.ts +0 -1
  38. package/src/hmr/strategies/js-hmr-strategy.ts +0 -6
  39. package/src/integrations/ghtml/ghtml-renderer.test.ts +7 -7
  40. package/src/integrations/ghtml/ghtml-renderer.ts +1 -11
  41. package/src/plugins/eco-component-meta-plugin.ts +0 -1
  42. package/src/plugins/integration-plugin.test.ts +9 -14
  43. package/src/plugins/integration-plugin.ts +34 -22
  44. package/src/plugins/processor.ts +17 -0
  45. package/src/route-renderer/GRAPH.md +81 -289
  46. package/src/route-renderer/README.md +67 -105
  47. package/src/route-renderer/orchestration/component-render-context.ts +45 -38
  48. package/src/route-renderer/orchestration/declared-ownership-graph.ts +62 -0
  49. package/src/route-renderer/orchestration/foreign-subtree-execution.service.ts +383 -0
  50. package/src/route-renderer/orchestration/integration-renderer.test.ts +118 -121
  51. package/src/route-renderer/orchestration/integration-renderer.ts +362 -403
  52. package/src/route-renderer/orchestration/ownership-planning.service.ts +97 -0
  53. package/src/route-renderer/orchestration/ownership-validation.service.ts +76 -0
  54. package/src/route-renderer/orchestration/processed-asset-dedupe.ts +1 -1
  55. package/src/route-renderer/orchestration/{queued-boundary-runtime.service.test.ts → queued-foreign-subtree-resolution.service.test.ts} +76 -71
  56. package/src/route-renderer/orchestration/{queued-boundary-runtime.service.ts → queued-foreign-subtree-resolution.service.ts} +68 -63
  57. package/src/route-renderer/orchestration/render-output.utils.ts +21 -13
  58. package/src/route-renderer/orchestration/{render-preparation.service.test.ts → route-render-orchestrator.prepare-render-options.test.ts} +160 -85
  59. package/src/route-renderer/orchestration/route-render-orchestrator.test.ts +265 -0
  60. package/src/route-renderer/orchestration/{render-preparation.service.ts → route-render-orchestrator.ts} +244 -160
  61. package/src/route-renderer/page-loading/component-dependency-collection.ts +9 -3
  62. package/src/route-renderer/page-loading/declared-asset-collection.ts +2 -5
  63. package/src/route-renderer/page-loading/dependency-resolver.test.ts +107 -11
  64. package/src/route-renderer/page-loading/dependency-resolver.ts +6 -12
  65. package/src/route-renderer/page-loading/ecopages-virtual-imports.ts +1 -1
  66. package/src/route-renderer/page-loading/lazy-entry-collection.ts +1 -1
  67. package/src/route-renderer/page-loading/lazy-trigger-planning.ts +1 -1
  68. package/src/route-renderer/page-loading/module-declaration-aggregation.ts +1 -1
  69. package/src/route-renderer/page-loading/module-declaration-scripts.ts +1 -1
  70. package/src/route-renderer/page-loading/page-dependency-bundling.ts +105 -66
  71. package/src/route-renderer/route-renderer.ts +28 -31
  72. package/src/router/README.md +16 -19
  73. package/src/router/server/route-registry.test.ts +176 -0
  74. package/src/router/server/route-registry.ts +382 -0
  75. package/src/services/README.md +1 -2
  76. package/src/services/assets/asset-processing-service/asset-dependency-keys.ts +1 -1
  77. package/src/services/assets/asset-processing-service/asset-processing.service.test.ts +1 -4
  78. package/src/services/assets/asset-processing-service/asset-processing.service.ts +1 -2
  79. package/src/services/assets/asset-processing-service/assets.types.ts +3 -0
  80. package/src/services/assets/asset-processing-service/grouped-content-bundles.ts +1 -1
  81. package/src/services/assets/asset-processing-service/index.ts +1 -0
  82. package/src/{route-renderer/orchestration/page-packaging.service.test.ts → services/assets/asset-processing-service/page-package.test.ts} +38 -14
  83. package/src/services/assets/asset-processing-service/page-package.ts +93 -0
  84. package/src/services/assets/asset-processing-service/processors/base/base-script-processor.ts +4 -5
  85. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.test.ts +13 -10
  86. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.ts +3 -0
  87. package/src/services/assets/asset-processing-service/processors/script/file-script.processor.ts +6 -0
  88. package/src/services/assets/asset-processing-service/processors/script/node-module-script.processor.ts +2 -0
  89. package/src/services/assets/asset-processing-service/processors/stylesheet/content-stylesheet.processor.ts +1 -0
  90. package/src/services/assets/asset-processing-service/processors/stylesheet/file-stylesheet.processor.ts +2 -0
  91. package/src/services/assets/asset-processing-service/ungrouped-dependency-processing.ts +1 -1
  92. package/src/services/html/html-transformer.service.test.ts +1 -4
  93. package/src/services/module-loading/app-server-module-transpiler.service.ts +1 -3
  94. package/src/services/module-loading/node-bootstrap-plugin.ts +17 -3
  95. package/src/services/module-loading/page-module-import.service.ts +0 -1
  96. package/src/services/module-loading/source-module-support.ts +1 -1
  97. package/src/static-site-generator/static-site-generator.test.ts +124 -32
  98. package/src/static-site-generator/static-site-generator.ts +168 -185
  99. package/src/types/internal-types.ts +13 -12
  100. package/src/types/public-types.ts +55 -39
  101. package/src/watchers/project-watcher.test-helpers.ts +4 -3
  102. package/src/route-renderer/orchestration/boundary-planning.service.ts +0 -146
  103. package/src/route-renderer/orchestration/page-packaging.service.ts +0 -85
  104. package/src/route-renderer/orchestration/render-execution.service.test.ts +0 -196
  105. package/src/route-renderer/orchestration/render-execution.service.ts +0 -182
  106. package/src/route-renderer/orchestration/route-shell-composer.service.ts +0 -162
  107. package/src/router/server/fs-router-scanner.test.ts +0 -83
  108. package/src/router/server/fs-router-scanner.ts +0 -224
  109. package/src/router/server/fs-router.test.ts +0 -214
  110. package/src/router/server/fs-router.ts +0 -122
  111. package/src/services/runtime-state/runtime-specifier-registry.service.ts +0 -96
@@ -2,7 +2,7 @@ import type { Readable } from 'node:stream';
2
2
  import type { ApiResponseBuilder } from '../adapters/shared/api-response.ts';
3
3
  import type { BuildExecutor } from '../build/build-adapter.ts';
4
4
  import type { EcoBuildPlugin } from '../build/build-types.ts';
5
- import type { ComponentBoundaryRuntime } from '../route-renderer/orchestration/component-render-context.ts';
5
+ import type { ForeignChildRuntime } from '../route-renderer/orchestration/component-render-context.ts';
6
6
  import type { EcoPageComponent } from '../eco/eco.types.ts';
7
7
  import type { EcoPagesAppConfig } from './internal-types.ts';
8
8
  import type { HmrStrategy } from '../hmr/hmr-strategy.ts';
@@ -31,7 +31,7 @@ export type {
31
31
  StandardSchemaFailureResult,
32
32
  StandardSchemaIssue,
33
33
  InferOutput,
34
- ComponentBoundaryRuntime,
34
+ ForeignChildRuntime,
35
35
  };
36
36
 
37
37
  export type InteractionEventsString = ScriptsInjectorInteractionEventsString;
@@ -112,11 +112,6 @@ export interface DefaultHmrContext {
112
112
  */
113
113
  getWatchedFiles(): Map<string, string>;
114
114
 
115
- /**
116
- * Map of bare specifiers to runtime URLs for browser import resolution.
117
- */
118
- getSpecifierMap(): Map<string, string>;
119
-
120
115
  /**
121
116
  * Directory where HMR bundles are written.
122
117
  */
@@ -218,16 +213,6 @@ export interface IHmrManager {
218
213
  */
219
214
  registerScriptEntrypoint(entrypointPath: string): Promise<string>;
220
215
 
221
- /**
222
- * Registers mappings from bare specifiers to runtime URLs.
223
- *
224
- * @remarks
225
- * This is the shared registration seam for integration-owned runtime alias
226
- * maps. The registry may later back a broader import-map-style facility, but
227
- * the mappings themselves remain integration-owned.
228
- */
229
- registerSpecifierMap(map: Record<string, string>): void;
230
-
231
216
  /**
232
217
  * Registers a custom HMR strategy.
233
218
  */
@@ -263,11 +248,6 @@ export interface IHmrManager {
263
248
  */
264
249
  getWatchedFiles(): Map<string, string>;
265
250
 
266
- /**
267
- * Gets the registered bare-specifier map.
268
- */
269
- getSpecifierMap(): Map<string, string>;
270
-
271
251
  /**
272
252
  * Gets the HMR dist directory.
273
253
  */
@@ -783,7 +763,7 @@ export type IntegrationRendererRenderOptions<C = EcoPagesElement> = RouteRendere
783
763
  pageProps?: Record<string, unknown>;
784
764
  cacheStrategy?: CacheStrategy;
785
765
  pageLocals?: RequestLocals;
786
- boundaryPlan?: BoundaryPlan;
766
+ ownershipPlan?: OwnershipPlan;
787
767
  };
788
768
 
789
769
  /**
@@ -824,19 +804,34 @@ export interface PagePackageResult {
824
804
  dynamicChunks: ProcessedAsset[];
825
805
  }
826
806
 
827
- export type BoundaryValidationErrorCode = 'UNKNOWN_INTEGRATION_OWNER' | 'MISSING_COMPONENT_METADATA';
807
+ /**
808
+ * Page-scoped browser output planned before final HTML packaging.
809
+ *
810
+ * The initial seam keeps the graph payload intentionally small: integrations
811
+ * return the processed assets that belong to the Page browser graph, while the
812
+ * surrounding route pipeline remains free to evolve toward richer entry/lazy/
813
+ * shared chunk structure later.
814
+ */
815
+ export interface PageBrowserGraphResult {
816
+ /**
817
+ * Processed assets owned by the current Page browser graph.
818
+ */
819
+ assets: ProcessedAsset[];
820
+ }
821
+
822
+ export type OwnershipValidationErrorCode = 'UNKNOWN_INTEGRATION_OWNER' | 'MISSING_COMPONENT_METADATA';
828
823
 
829
- export interface BoundaryValidationError {
830
- code: BoundaryValidationErrorCode;
824
+ export interface OwnershipValidationError {
825
+ code: OwnershipValidationErrorCode;
831
826
  message: string;
832
827
  componentId?: string;
833
828
  componentFile?: string;
834
829
  integrationName?: string;
835
830
  }
836
831
 
837
- export type BoundaryPlanNodeSource = 'route' | 'page' | 'layout' | 'html-template' | 'dependency';
832
+ export type OwnershipPlanNodeSource = 'route' | 'page' | 'layout' | 'html-template' | 'dependency';
838
833
 
839
- export interface BoundaryOwnership {
834
+ export interface IntegrationOwnership {
840
835
  integrationName: string;
841
836
  componentId: string;
842
837
  componentFile?: string;
@@ -844,35 +839,35 @@ export interface BoundaryOwnership {
844
839
  isForeignToParent: boolean;
845
840
  }
846
841
 
847
- export interface BoundaryPlanNode {
842
+ export interface OwnershipPlanNode {
848
843
  id: string;
849
- source: BoundaryPlanNodeSource;
850
- ownership: BoundaryOwnership;
851
- children: BoundaryPlanNode[];
844
+ source: OwnershipPlanNodeSource;
845
+ ownership: IntegrationOwnership;
846
+ children: OwnershipPlanNode[];
852
847
  declaredDependenciesValid: boolean;
853
848
  }
854
849
 
855
- export interface BoundaryPlan {
856
- root: BoundaryPlanNode;
850
+ export interface OwnershipPlan {
851
+ root: OwnershipPlanNode;
857
852
  rendererNames: string[];
858
853
  foreignEdgeCount: number;
859
854
  hasValidationErrors: boolean;
860
- validationErrors: BoundaryValidationError[];
855
+ validationErrors: OwnershipValidationError[];
861
856
  }
862
857
 
863
- export type BoundaryAttachmentPolicy = { kind: 'none' } | { kind: 'first-element' };
858
+ export type ForeignSubtreeAttachmentPolicy = { kind: 'none' } | { kind: 'first-element' };
864
859
 
865
- export interface BoundaryRenderPayload {
860
+ export interface ForeignSubtreeRenderPayload {
866
861
  html: string;
867
862
  assets: ProcessedAsset[];
868
863
  rootTag?: string;
869
864
  rootAttributes?: Record<string, string>;
870
- attachmentPolicy: BoundaryAttachmentPolicy;
865
+ attachmentPolicy: ForeignSubtreeAttachmentPolicy;
871
866
  integrationName: string;
872
867
  }
873
868
 
874
869
  /**
875
- * Shared execution-scoped context threaded through component boundary renders.
870
+ * Shared execution-scoped context threaded through foreign-child renders.
876
871
  *
877
872
  * Integrations can extend this with renderer-local runtime keys, but the cache
878
873
  * and optional component instance identity are shared across all renderers.
@@ -1144,6 +1139,18 @@ export interface ApiHandlerContext<TRequest extends Request = Request, TServer =
1144
1139
  headers?: unknown;
1145
1140
  }
1146
1141
 
1142
+ /**
1143
+ * Context available to file-route page middleware.
1144
+ *
1145
+ * Page middleware can mutate locals, short-circuit the request, and use the
1146
+ * response helpers, but final document rendering stays owned by the page route
1147
+ * execution path.
1148
+ */
1149
+ export interface FileRouteMiddlewareContext<TRequest extends Request = Request, TServer = any> extends Omit<
1150
+ ApiHandlerContext<TRequest, TServer>,
1151
+ 'render' | 'renderPartial'
1152
+ > {}
1153
+
1147
1154
  /**
1148
1155
  * Next function for middleware chain.
1149
1156
  * Call to continue to the next middleware or final handler.
@@ -1185,6 +1192,15 @@ export type Middleware<
1185
1192
  TContext extends ApiHandlerContext<TRequest, TServer> = ApiHandlerContext<TRequest, TServer>,
1186
1193
  > = (context: TContext, next: MiddlewareNext) => Promise<Response> | Response;
1187
1194
 
1195
+ /**
1196
+ * Middleware contract for file-based page routes.
1197
+ */
1198
+ export type FileRouteMiddleware<
1199
+ TRequest extends Request = Request,
1200
+ TServer = any,
1201
+ TContext extends FileRouteMiddlewareContext<TRequest, TServer> = FileRouteMiddlewareContext<TRequest, TServer>,
1202
+ > = (context: TContext, next: MiddlewareNext) => Promise<Response> | Response;
1203
+
1188
1204
  /**
1189
1205
  * Helper type for defining middleware with extended context.
1190
1206
  * Automatically infers TRequest and TServer from the provided context type.
@@ -10,21 +10,22 @@ export const createMockHmrManager = (): IHmrManager =>
10
10
  setPlugins: vi.fn(() => {}),
11
11
  registerEntrypoint: vi.fn(async () => ''),
12
12
  registerScriptEntrypoint: vi.fn(async () => ''),
13
- registerSpecifierMap: vi.fn(() => {}),
14
13
  registerStrategy: vi.fn(() => {}),
15
14
  isEnabled: vi.fn(() => true),
16
15
  getOutputUrl: vi.fn(() => undefined),
17
16
  getWatchedFiles: vi.fn(() => new Map()),
18
- getSpecifierMap: vi.fn(() => new Map()),
19
17
  getDistDir: vi.fn(() => ''),
20
18
  getPlugins: vi.fn(() => []),
21
19
  getDefaultContext: vi.fn(() => ({
22
20
  getWatchedFiles: () => new Map(),
23
- getSpecifierMap: () => new Map(),
24
21
  getDistDir: () => '',
25
22
  getPlugins: () => [],
26
23
  getSrcDir: () => '',
27
24
  getLayoutsDir: () => '',
25
+ getPagesDir: () => '',
26
+ getBuildExecutor: () => ({ build: vi.fn(async () => ({ success: true, logs: [], outputs: [] })) }),
27
+ getBrowserBundleService: () => ({ bundle: vi.fn(async () => ({ success: true, logs: [], outputs: [] })) }),
28
+ importServerModule: vi.fn(async () => ({})),
28
29
  })),
29
30
  }) as unknown as IHmrManager;
30
31
 
@@ -1,146 +0,0 @@
1
- import type { EcoPagesAppConfig } from '../../types/internal-types.ts';
2
- import type {
3
- BoundaryPlan,
4
- BoundaryPlanNode,
5
- BoundaryPlanNodeSource,
6
- BoundaryValidationError,
7
- EcoComponent,
8
- } from '../../types/public-types.ts';
9
-
10
- type BoundaryPlanBuildInput = {
11
- routeFile: string;
12
- currentIntegrationName: string;
13
- HtmlTemplate: EcoComponent;
14
- Layout?: EcoComponent;
15
- Page: EcoComponent;
16
- };
17
-
18
- type BoundaryPlanBuildEntry = {
19
- component: EcoComponent;
20
- source: Extract<BoundaryPlanNodeSource, 'page' | 'layout' | 'html-template'>;
21
- };
22
-
23
- /**
24
- * Builds a declared ownership plan from the component dependency graph.
25
- *
26
- * The plan is intentionally conservative: it reflects declared component
27
- * dependencies available during render preparation and records diagnostics for
28
- * foreign ownership edges that cannot be validated against registered
29
- * integrations or stable component metadata.
30
- */
31
- export class BoundaryPlanningService {
32
- private readonly appConfig: EcoPagesAppConfig;
33
- private nextSyntheticId = 0;
34
-
35
- constructor(appConfig: EcoPagesAppConfig) {
36
- this.appConfig = appConfig;
37
- }
38
-
39
- buildPlan(input: BoundaryPlanBuildInput): BoundaryPlan {
40
- this.nextSyntheticId = 0;
41
-
42
- const validationErrors: BoundaryValidationError[] = [];
43
- const rendererNames = new Set<string>([input.currentIntegrationName]);
44
- let foreignEdgeCount = 0;
45
-
46
- const buildNode = (
47
- component: EcoComponent,
48
- source: Exclude<BoundaryPlanNodeSource, 'route'>,
49
- parentIntegrationName: string,
50
- lineage: Set<object>,
51
- ): BoundaryPlanNode => {
52
- const integrationName =
53
- component.config?.integration ?? component.config?.__eco?.integration ?? parentIntegrationName;
54
- const componentMeta = component.config?.__eco;
55
- const isForeignToParent = integrationName !== parentIntegrationName;
56
- const componentId = componentMeta?.id ?? componentMeta?.file ?? `${source}:${(this.nextSyntheticId += 1)}`;
57
-
58
- rendererNames.add(integrationName);
59
-
60
- if (isForeignToParent) {
61
- foreignEdgeCount += 1;
62
-
63
- if (!componentMeta) {
64
- validationErrors.push({
65
- code: 'MISSING_COMPONENT_METADATA',
66
- message: `[ecopages] Foreign boundary "${componentId}" must provide stable __eco metadata so ownership diagnostics stay actionable. Declared dependencies must include all possible foreign children.`,
67
- componentId,
68
- integrationName,
69
- });
70
- }
71
-
72
- if (!this.isRegisteredIntegration(integrationName, input.currentIntegrationName)) {
73
- validationErrors.push({
74
- code: 'UNKNOWN_INTEGRATION_OWNER',
75
- message: `[ecopages] Foreign boundary "${componentId}" references unknown integration owner "${integrationName}". Declared dependencies must include all possible foreign children and those integrations must be registered.`,
76
- componentId,
77
- componentFile: componentMeta?.file,
78
- integrationName,
79
- });
80
- }
81
- }
82
-
83
- const nextLineage = new Set(lineage);
84
- nextLineage.add(component);
85
- const children = (component.config?.dependencies?.components ?? []).flatMap((child) => {
86
- if (!child || nextLineage.has(child)) {
87
- return [];
88
- }
89
-
90
- return [buildNode(child, 'dependency', integrationName, nextLineage)];
91
- });
92
-
93
- return {
94
- id: componentId,
95
- source,
96
- ownership: {
97
- integrationName,
98
- componentId,
99
- componentFile: componentMeta?.file,
100
- isPageEntry: source === 'page',
101
- isForeignToParent,
102
- },
103
- children,
104
- declaredDependenciesValid: true,
105
- };
106
- };
107
-
108
- const roots: BoundaryPlanBuildEntry[] = [
109
- { component: input.HtmlTemplate, source: 'html-template' },
110
- ...(input.Layout ? [{ component: input.Layout, source: 'layout' as const }] : []),
111
- { component: input.Page, source: 'page' },
112
- ];
113
-
114
- const root: BoundaryPlanNode = {
115
- id: `route:${input.routeFile}`,
116
- source: 'route',
117
- ownership: {
118
- integrationName: input.currentIntegrationName,
119
- componentId: `route:${input.routeFile}`,
120
- componentFile: input.routeFile,
121
- isPageEntry: false,
122
- isForeignToParent: false,
123
- },
124
- children: roots.map(({ component, source }) =>
125
- buildNode(component, source, input.currentIntegrationName, new Set()),
126
- ),
127
- declaredDependenciesValid: validationErrors.length === 0,
128
- };
129
-
130
- return {
131
- root,
132
- rendererNames: Array.from(rendererNames),
133
- foreignEdgeCount,
134
- hasValidationErrors: validationErrors.length > 0,
135
- validationErrors,
136
- };
137
- }
138
-
139
- private isRegisteredIntegration(integrationName: string, currentIntegrationName: string): boolean {
140
- if (integrationName === currentIntegrationName) {
141
- return true;
142
- }
143
-
144
- return this.appConfig.integrations.some((integration) => integration.name === integrationName);
145
- }
146
- }
@@ -1,85 +0,0 @@
1
- import type { ProcessedAsset } from '../../services/assets/asset-processing-service/index.ts';
2
- import type { PagePackageResult } from '../../types/public-types.ts';
3
-
4
- /**
5
- * Creates the structured page package consumed by final HTML injection.
6
- *
7
- * This first pass is intentionally behavior-preserving. It establishes the
8
- * packaging seam while forwarding the current flat processed asset list.
9
- */
10
- export class PagePackagingService {
11
- /**
12
- * Partitions processed assets into the page-level groups used during final
13
- * HTML injection and post-processing.
14
- */
15
- createPagePackage(assets: ProcessedAsset[]): PagePackageResult {
16
- const inlineAssets: ProcessedAsset[] = [];
17
- const separateAssets: ProcessedAsset[] = [];
18
- const dynamicChunks: ProcessedAsset[] = [];
19
- let pageScript: ProcessedAsset | undefined;
20
- let pageStylesheet: ProcessedAsset | undefined;
21
-
22
- for (const asset of assets) {
23
- if (asset.inline) {
24
- inlineAssets.push(asset);
25
- continue;
26
- }
27
-
28
- if (asset.packageRole === 'dynamic-chunk') {
29
- dynamicChunks.push(asset);
30
- continue;
31
- }
32
-
33
- if (!pageScript && asset.packageRole === 'page-script') {
34
- pageScript = asset;
35
- continue;
36
- }
37
-
38
- if (!pageStylesheet && asset.packageRole === 'page-style') {
39
- pageStylesheet = asset;
40
- continue;
41
- }
42
-
43
- if (asset.packageRole === 'keep-separate' || asset.packageRole === 'runtime') {
44
- separateAssets.push(asset);
45
- continue;
46
- }
47
-
48
- if (!pageScript && asset.kind === 'script' && !asset.excludeFromHtml) {
49
- pageScript = asset;
50
- continue;
51
- }
52
-
53
- if (!pageStylesheet && asset.kind === 'stylesheet') {
54
- pageStylesheet = asset;
55
- continue;
56
- }
57
-
58
- separateAssets.push(asset);
59
- }
60
-
61
- const htmlAssets = assets.filter((asset) => this.shouldIncludeInHtml(asset));
62
-
63
- return {
64
- assets,
65
- htmlAssets,
66
- pageScript,
67
- pageStylesheet,
68
- inlineAssets,
69
- separateAssets,
70
- dynamicChunks,
71
- };
72
- }
73
-
74
- private shouldIncludeInHtml(asset: ProcessedAsset): boolean {
75
- if (asset.excludeFromHtml) {
76
- return false;
77
- }
78
-
79
- if (asset.packageRole === 'runtime') {
80
- return false;
81
- }
82
-
83
- return true;
84
- }
85
- }
@@ -1,196 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { getComponentRenderContext } from './component-render-context.ts';
3
- import type {
4
- EcoComponent,
5
- IntegrationRendererRenderOptions,
6
- RouteRendererBody,
7
- RouteRendererOptions,
8
- } from '../../types/public-types.ts';
9
- import { RenderExecutionService } from './render-execution.service.ts';
10
-
11
- describe('RenderExecutionService', () => {
12
- it('captures streamed render bodies before final HTML handling', async () => {
13
- const service = new RenderExecutionService();
14
- const encoder = new TextEncoder();
15
-
16
- const result = await service.captureHtmlRender(
17
- async () =>
18
- new ReadableStream({
19
- start(controller) {
20
- controller.enqueue(encoder.encode('<html><body><main>Streamed</main></body></html>'));
21
- controller.close();
22
- },
23
- }) as unknown as BodyInit,
24
- );
25
-
26
- expect(result.body).toBeInstanceOf(ReadableStream);
27
- expect(result.html).toContain('<main>Streamed</main>');
28
- });
29
-
30
- it('preserves streamed bodies when no boundary resolution or attribute stamping is required', async () => {
31
- const service = new RenderExecutionService();
32
- const HtmlTemplate = (() => '<html></html>') as EcoComponent<Record<string, unknown>>;
33
- const Page = (() => '<main>Page</main>') as EcoComponent<Record<string, unknown>>;
34
- const encoder = new TextEncoder();
35
-
36
- const result = await service.execute(
37
- {
38
- file: '/app/pages/index.tsx',
39
- params: {},
40
- query: {},
41
- } as unknown as RouteRendererOptions,
42
- {
43
- prepareRenderOptions: async () =>
44
- ({
45
- HtmlTemplate,
46
- Page,
47
- cacheStrategy: { revalidate: 60 },
48
- }) as unknown as IntegrationRendererRenderOptions<unknown>,
49
- render: async () =>
50
- new ReadableStream({
51
- start(controller) {
52
- controller.enqueue(encoder.encode('<html><body><main>Streamed</main></body></html>'));
53
- controller.close();
54
- },
55
- }) as unknown as BodyInit,
56
- getDocumentAttributes: () => undefined,
57
- applyAttributesToHtmlElement: (html) => html,
58
- applyAttributesToFirstBodyElement: (html) => html,
59
- transformResponse: async (response) => response.body as RouteRendererBody,
60
- },
61
- );
62
-
63
- expect(result.cacheStrategy).toEqual({ revalidate: 60 });
64
- expect(result.body).toBeInstanceOf(ReadableStream);
65
- expect(await new Response(result.body as BodyInit).text()).toContain('<main>Streamed</main>');
66
- });
67
-
68
- it('applies root and document attributes to fully resolved route HTML', async () => {
69
- const service = new RenderExecutionService();
70
- const HtmlTemplate = (() => '<html></html>') as EcoComponent<Record<string, unknown>>;
71
- const Page = (() => '<main>Page</main>') as EcoComponent<Record<string, unknown>>;
72
-
73
- const result = await service.execute(
74
- {
75
- file: '/app/pages/index.tsx',
76
- params: {},
77
- query: {},
78
- } as unknown as RouteRendererOptions,
79
- {
80
- prepareRenderOptions: async () =>
81
- ({
82
- HtmlTemplate,
83
- Page,
84
- cacheStrategy: { revalidate: 60 },
85
- componentRender: {
86
- canAttachAttributes: true,
87
- rootAttributes: { 'data-eco-component-id': 'eco-page-root' },
88
- },
89
- }) as unknown as IntegrationRendererRenderOptions<unknown>,
90
- render: async () => '<html><body><main>Resolved</main></body></html>',
91
- getDocumentAttributes: () => ({ 'data-eco-document-owner': 'react-router' }),
92
- applyAttributesToHtmlElement: (html, attributes) =>
93
- html.replace('<html', `<html data-eco-document-owner="${attributes['data-eco-document-owner']}"`),
94
- applyAttributesToFirstBodyElement: (html, attributes) =>
95
- html.replace('<main', `<main data-eco-component-id="${attributes['data-eco-component-id']}"`),
96
- transformResponse: async (response) => await response.text(),
97
- },
98
- );
99
-
100
- expect(result.cacheStrategy).toEqual({ revalidate: 60 });
101
- expect(result.body).toContain('<html data-eco-document-owner="react-router"><body>');
102
- expect(result.body).toContain('<main data-eco-component-id="eco-page-root">Resolved</main>');
103
- });
104
-
105
- it('throws when route HTML contains escaped unresolved boundary artifacts', async () => {
106
- const service = new RenderExecutionService();
107
- const HtmlTemplate = (() => '<html></html>') as EcoComponent<Record<string, unknown>>;
108
- const Page = (() => '<main>Page</main>') as EcoComponent<Record<string, unknown>>;
109
-
110
- await expect(
111
- service.execute(
112
- {
113
- file: '/app/pages/index.tsx',
114
- params: {},
115
- query: {},
116
- } as unknown as RouteRendererOptions,
117
- {
118
- prepareRenderOptions: async () =>
119
- ({
120
- HtmlTemplate,
121
- Page,
122
- cacheStrategy: 'dynamic',
123
- }) as unknown as IntegrationRendererRenderOptions<unknown>,
124
- render: async () =>
125
- '<html><body>&amp;lt;eco-marker data-eco-node-id=&quot;n_2&quot; data-eco-component-ref=&quot;page-component&quot; data-eco-props-ref=&quot;p_2&quot;&amp;gt;&amp;lt;/eco-marker&amp;gt;</body></html>',
126
- getDocumentAttributes: () => undefined,
127
- applyAttributesToHtmlElement: (html) => html,
128
- applyAttributesToFirstBodyElement: (html) => html,
129
- transformResponse: async (response) => await response.text(),
130
- },
131
- ),
132
- ).rejects.toThrow('Full-route unresolved-boundary fallback has been removed');
133
- });
134
-
135
- it('renders routes with no active component render context', async () => {
136
- const service = new RenderExecutionService();
137
- const HtmlTemplate = (() => '<html></html>') as EcoComponent<Record<string, unknown>>;
138
- const Page = (() => '<main>Page</main>') as EcoComponent<Record<string, unknown>>;
139
-
140
- const result = await service.execute(
141
- {
142
- file: '/app/pages/index.tsx',
143
- params: {},
144
- query: {},
145
- } as unknown as RouteRendererOptions,
146
- {
147
- prepareRenderOptions: async () =>
148
- ({
149
- HtmlTemplate,
150
- Page,
151
- cacheStrategy: 'dynamic',
152
- }) as unknown as IntegrationRendererRenderOptions<unknown>,
153
- render: async () => {
154
- expect(getComponentRenderContext()).toBeUndefined();
155
- return '<html><body><main>Plain render</main></body></html>';
156
- },
157
- getDocumentAttributes: () => undefined,
158
- applyAttributesToHtmlElement: (html) => html,
159
- applyAttributesToFirstBodyElement: (html) => html,
160
- transformResponse: async (response) => await response.text(),
161
- },
162
- );
163
-
164
- expect(result.body).toContain('<main>Plain render</main>');
165
- });
166
-
167
- it('throws when route HTML returns unresolved boundary artifact HTML', async () => {
168
- const service = new RenderExecutionService();
169
- const HtmlTemplate = (() => '<html></html>') as EcoComponent<Record<string, unknown>>;
170
- const Page = (() => '<main>Page</main>') as EcoComponent<Record<string, unknown>>;
171
-
172
- await expect(
173
- service.execute(
174
- {
175
- file: '/app/pages/index.tsx',
176
- params: {},
177
- query: {},
178
- } as unknown as RouteRendererOptions,
179
- {
180
- prepareRenderOptions: async () =>
181
- ({
182
- HtmlTemplate,
183
- Page,
184
- cacheStrategy: 'dynamic',
185
- }) as unknown as IntegrationRendererRenderOptions<unknown>,
186
- render: async () =>
187
- '<html><body><eco-marker data-eco-node-id="n_1" data-eco-component-ref="unexpected-marker" data-eco-props-ref="p_1"></eco-marker></body></html>',
188
- getDocumentAttributes: () => undefined,
189
- applyAttributesToHtmlElement: (html) => html,
190
- applyAttributesToFirstBodyElement: (html) => html,
191
- transformResponse: async (response) => await response.text(),
192
- },
193
- ),
194
- ).rejects.toThrow('Full-route unresolved-boundary fallback has been removed');
195
- });
196
- });