@ecopages/react 0.2.0-alpha.1

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 (59) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -0
  4. package/package.json +76 -0
  5. package/src/declarations.d.ts +6 -0
  6. package/src/react-hmr-strategy.d.ts +143 -0
  7. package/src/react-hmr-strategy.js +332 -0
  8. package/src/react-hmr-strategy.ts +444 -0
  9. package/src/react-renderer.d.ts +106 -0
  10. package/src/react-renderer.js +302 -0
  11. package/src/react-renderer.ts +403 -0
  12. package/src/react.plugin.d.ts +147 -0
  13. package/src/react.plugin.js +126 -0
  14. package/src/react.plugin.ts +241 -0
  15. package/src/router-adapter.d.ts +87 -0
  16. package/src/router-adapter.js +0 -0
  17. package/src/router-adapter.ts +95 -0
  18. package/src/services/react-bundle.service.d.ts +68 -0
  19. package/src/services/react-bundle.service.js +145 -0
  20. package/src/services/react-bundle.service.ts +212 -0
  21. package/src/services/react-hmr-page-metadata-cache.d.ts +17 -0
  22. package/src/services/react-hmr-page-metadata-cache.js +19 -0
  23. package/src/services/react-hmr-page-metadata-cache.ts +24 -0
  24. package/src/services/react-hydration-asset.service.d.ts +75 -0
  25. package/src/services/react-hydration-asset.service.js +198 -0
  26. package/src/services/react-hydration-asset.service.ts +260 -0
  27. package/src/services/react-page-module.service.d.ts +80 -0
  28. package/src/services/react-page-module.service.js +155 -0
  29. package/src/services/react-page-module.service.ts +214 -0
  30. package/src/services/react-runtime-bundle.service.d.ts +38 -0
  31. package/src/services/react-runtime-bundle.service.js +207 -0
  32. package/src/services/react-runtime-bundle.service.ts +271 -0
  33. package/src/utils/client-graph-boundary-plugin.d.ts +43 -0
  34. package/src/utils/client-graph-boundary-plugin.js +356 -0
  35. package/src/utils/client-graph-boundary-plugin.ts +590 -0
  36. package/src/utils/client-only.d.ts +8 -0
  37. package/src/utils/client-only.js +19 -0
  38. package/src/utils/client-only.ts +27 -0
  39. package/src/utils/declared-modules.d.ts +42 -0
  40. package/src/utils/declared-modules.js +56 -0
  41. package/src/utils/declared-modules.ts +99 -0
  42. package/src/utils/dynamic.d.ts +15 -0
  43. package/src/utils/dynamic.js +12 -0
  44. package/src/utils/dynamic.ts +27 -0
  45. package/src/utils/hmr-scripts.d.ts +18 -0
  46. package/src/utils/hmr-scripts.js +31 -0
  47. package/src/utils/hmr-scripts.ts +47 -0
  48. package/src/utils/html-boundary.d.ts +7 -0
  49. package/src/utils/html-boundary.js +55 -0
  50. package/src/utils/html-boundary.ts +66 -0
  51. package/src/utils/hydration-scripts.d.ts +71 -0
  52. package/src/utils/hydration-scripts.js +222 -0
  53. package/src/utils/hydration-scripts.ts +338 -0
  54. package/src/utils/reachability-analyzer.d.ts +55 -0
  55. package/src/utils/reachability-analyzer.js +243 -0
  56. package/src/utils/reachability-analyzer.ts +440 -0
  57. package/src/utils/react-mdx-loader-plugin.d.ts +3 -0
  58. package/src/utils/react-mdx-loader-plugin.js +37 -0
  59. package/src/utils/react-mdx-loader-plugin.ts +40 -0
@@ -0,0 +1,302 @@
1
+ import { IntegrationRenderer } from "@ecopages/core/route-renderer/integration-renderer";
2
+ import { LocalsAccessError } from "@ecopages/core/errors/locals-access-error";
3
+ import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
4
+ import { rapidhash } from "@ecopages/core/hash";
5
+ import { createElement } from "react";
6
+ import { renderToReadableStream, renderToString } from "react-dom/server";
7
+ import { PLUGIN_NAME } from "./react.plugin.js";
8
+ import { hasSingleRootElement } from "./utils/html-boundary.js";
9
+ import { ReactBundleService } from "./services/react-bundle.service.js";
10
+ import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
11
+ import { ReactPageModuleService } from "./services/react-page-module.service.js";
12
+ import { ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
13
+ class ReactRenderError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "ReactRenderError";
17
+ }
18
+ }
19
+ class BundleError extends Error {
20
+ constructor(message, logs) {
21
+ super(message);
22
+ this.logs = logs;
23
+ this.name = "BundleError";
24
+ }
25
+ }
26
+ class ReactRenderer extends IntegrationRenderer {
27
+ name = PLUGIN_NAME;
28
+ componentDirectory = RESOLVED_ASSETS_DIR;
29
+ componentRenderSequence = 0;
30
+ static routerAdapter;
31
+ static mdxCompilerOptions;
32
+ static mdxExtensions = [".mdx"];
33
+ static hmrPageMetadataCache;
34
+ /**
35
+ * Enables explicit graph behavior for React page-entry bundling.
36
+ *
37
+ * When true, page-entry bundles disable AST server-only stripping and rely
38
+ * on explicit dependency declarations for browser graph composition.
39
+ */
40
+ static explicitGraphEnabled = false;
41
+ /** @internal */
42
+ bundleService;
43
+ /** @internal */
44
+ pageModuleService;
45
+ /** @internal */
46
+ hydrationAssetService;
47
+ constructor(options) {
48
+ super(options);
49
+ this.bundleService = new ReactBundleService({
50
+ rootDir: this.appConfig.rootDir,
51
+ routerAdapter: ReactRenderer.routerAdapter,
52
+ mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
53
+ });
54
+ this.pageModuleService = new ReactPageModuleService({
55
+ rootDir: this.appConfig.rootDir,
56
+ distDir: this.appConfig.absolutePaths.distDir,
57
+ layoutsDir: this.appConfig.absolutePaths.layoutsDir,
58
+ componentsDir: this.appConfig.absolutePaths.componentsDir,
59
+ mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
60
+ mdxExtensions: ReactRenderer.mdxExtensions,
61
+ integrationName: this.name,
62
+ hasRouterAdapter: Boolean(ReactRenderer.routerAdapter)
63
+ });
64
+ this.hydrationAssetService = new ReactHydrationAssetService({
65
+ srcDir: this.appConfig.srcDir,
66
+ routerAdapter: ReactRenderer.routerAdapter,
67
+ assetProcessingService: this.assetProcessingService,
68
+ bundleService: this.bundleService,
69
+ hmrPageMetadataCache: ReactRenderer.hmrPageMetadataCache
70
+ });
71
+ }
72
+ shouldRenderPageComponent() {
73
+ return false;
74
+ }
75
+ /**
76
+ * Renders a React component for component-level orchestration.
77
+ *
78
+ * Behavior:
79
+ * - SSR always returns the component's own root HTML (no synthetic wrapper).
80
+ * - For single-root output, a stable `data-eco-component-id` attribute is attached
81
+ * to the root element so the client island runtime can target it directly.
82
+ * - Island client scripts are emitted through `assets` and mounted independently.
83
+ *
84
+ * This preserves DOM shape for global CSS/layout selectors while keeping a
85
+ * deterministic mount target per component instance.
86
+ */
87
+ async renderComponent(input) {
88
+ const Component = input.component;
89
+ const componentConfig = input.component.config;
90
+ const element = input.children === void 0 ? createElement(Component, input.props) : createElement(Component, input.props, input.children);
91
+ let html = renderToString(element);
92
+ let canAttachAttributes = hasSingleRootElement(html);
93
+ let rootTag = this.getRootTagName(html);
94
+ const componentFile = componentConfig?.__eco?.file;
95
+ const context = input.integrationContext ?? {};
96
+ let rootAttributes;
97
+ let assets;
98
+ if (canAttachAttributes && componentFile && this.assetProcessingService) {
99
+ const componentInstanceId = context.componentInstanceId ?? `eco-component-${rapidhash(componentFile)}-${++this.componentRenderSequence}`;
100
+ assets = await this.hydrationAssetService.buildComponentRenderAssets(
101
+ componentFile,
102
+ componentInstanceId,
103
+ input.props,
104
+ componentConfig
105
+ );
106
+ rootAttributes = { "data-eco-component-id": componentInstanceId };
107
+ }
108
+ return {
109
+ html,
110
+ canAttachAttributes,
111
+ rootTag,
112
+ integrationName: this.name,
113
+ rootAttributes,
114
+ assets
115
+ };
116
+ }
117
+ /**
118
+ * Checks if the given file path corresponds to an MDX file based on configured extensions.
119
+ * @param filePath - The file path to check
120
+ * @returns True if the file is an MDX file
121
+ */
122
+ isMdxFile(filePath) {
123
+ return this.pageModuleService.isMdxFile(filePath);
124
+ }
125
+ /**
126
+ * Processes MDX-specific configuration dependencies including layout dependencies.
127
+ * @param pagePath - Absolute path to the MDX page file
128
+ * @returns Processed assets for MDX configuration dependencies
129
+ */
130
+ async processMdxConfigDependencies(pagePath) {
131
+ const { config } = await this.importPageFile(pagePath);
132
+ const resolvedLayout = config?.layout;
133
+ const components = [];
134
+ if (resolvedLayout?.config?.dependencies) {
135
+ const layoutConfig = this.pageModuleService.ensureConfigFileMetadata(resolvedLayout.config, pagePath);
136
+ components.push({ config: layoutConfig });
137
+ }
138
+ if (config?.dependencies) {
139
+ const configWithMeta = {
140
+ ...config,
141
+ __eco: { id: rapidhash(pagePath).toString(36), file: pagePath, integration: "react" }
142
+ };
143
+ components.push({ config: configWithMeta });
144
+ }
145
+ return this.processComponentDependencies(components);
146
+ }
147
+ async buildRouteRenderAssets(pagePath) {
148
+ try {
149
+ const pageModule = await this.importPageFile(pagePath);
150
+ const shouldHydrate = ReactRenderer.explicitGraphEnabled ? true : this.pageModuleService.shouldHydratePage(pageModule);
151
+ if (!shouldHydrate) {
152
+ return [];
153
+ }
154
+ const isMdx = this.pageModuleService.isMdxFile(pagePath);
155
+ const declaredModules = this.pageModuleService.collectPageDeclaredModules(pageModule);
156
+ const processedAssets = await this.hydrationAssetService.buildRouteRenderAssets(
157
+ pagePath,
158
+ isMdx,
159
+ declaredModules
160
+ );
161
+ if (isMdx) {
162
+ const mdxConfigAssets = await this.processMdxConfigDependencies(pagePath);
163
+ return [...processedAssets, ...mdxConfigAssets];
164
+ }
165
+ return processedAssets;
166
+ } catch (error) {
167
+ if (error instanceof BundleError) {
168
+ console.error("[ecopages] Bundle errors:", error.logs);
169
+ }
170
+ throw new ReactRenderError(
171
+ `Failed to generate hydration script: ${error instanceof Error ? error.message : String(error)}`
172
+ );
173
+ }
174
+ }
175
+ async importPageFile(file) {
176
+ const module = this.pageModuleService.isMdxFile(file) ? await this.pageModuleService.importMdxPageFile(file) : await super.importPageFile(file);
177
+ const { default: Page, getMetadata, config } = module;
178
+ if (this.pageModuleService.isMdxFile(file) && config) {
179
+ Page.config = config;
180
+ }
181
+ return {
182
+ default: Page,
183
+ getMetadata,
184
+ config
185
+ };
186
+ }
187
+ async render({
188
+ params,
189
+ query,
190
+ props,
191
+ locals,
192
+ pageLocals,
193
+ metadata,
194
+ Page,
195
+ Layout,
196
+ HtmlTemplate,
197
+ pageProps
198
+ }) {
199
+ try {
200
+ const pageElement = createElement(Page, { params, query, ...props, locals: pageLocals });
201
+ const contentElement = Layout ? createElement(Layout, { locals }, pageElement) : pageElement;
202
+ const safeLocals = this.getSerializableLocals(locals);
203
+ const allPageProps = {
204
+ ...pageProps,
205
+ params,
206
+ query,
207
+ ...safeLocals && { locals: safeLocals }
208
+ };
209
+ return await renderToReadableStream(
210
+ createElement(
211
+ HtmlTemplate,
212
+ {
213
+ metadata,
214
+ pageProps: allPageProps
215
+ },
216
+ contentElement
217
+ )
218
+ );
219
+ } catch (error) {
220
+ throw this.createRenderError("Failed to render component", error);
221
+ }
222
+ }
223
+ /**
224
+ * Safely extracts locals for client-side hydration.
225
+ *
226
+ * On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
227
+ * request-scoped data (e.g., session). This data needs to be serialized to the
228
+ * client for hydration to match the server-rendered output.
229
+ *
230
+ * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
231
+ * to prevent accidental use. This method safely detects that case and returns
232
+ * `undefined` instead of throwing.
233
+ *
234
+ * @param locals - The locals object from the render context
235
+ * @returns The locals object if serializable, undefined otherwise
236
+ */
237
+ getSerializableLocals(locals) {
238
+ try {
239
+ if (locals && Object.keys(locals).length > 0) {
240
+ return locals;
241
+ }
242
+ return void 0;
243
+ } catch (e) {
244
+ if (e instanceof LocalsAccessError) {
245
+ return void 0;
246
+ }
247
+ throw e;
248
+ }
249
+ }
250
+ async renderToResponse(view, props, ctx) {
251
+ try {
252
+ const viewConfig = view.config;
253
+ const Layout = viewConfig?.layout;
254
+ const ViewComponent = view;
255
+ const pageElement = createElement(ViewComponent, props || {});
256
+ if (ctx.partial) {
257
+ const stream = await renderToReadableStream(pageElement);
258
+ return this.createHtmlResponse(stream, ctx);
259
+ }
260
+ const contentElement = Layout ? createElement(Layout, {}, pageElement) : pageElement;
261
+ const HtmlTemplate = await this.getHtmlTemplate();
262
+ const metadata = view.metadata ? await view.metadata({
263
+ params: {},
264
+ query: {},
265
+ props,
266
+ appConfig: this.appConfig
267
+ }) : this.appConfig.defaultMetadata;
268
+ await this.prepareViewDependencies(view, Layout);
269
+ const viewFilePath = viewConfig?.__eco?.file;
270
+ if (viewFilePath) {
271
+ const hydrationAssets = await this.buildRouteRenderAssets(viewFilePath);
272
+ this.htmlTransformer.setProcessedDependencies([
273
+ ...this.htmlTransformer.getProcessedDependencies(),
274
+ ...hydrationAssets
275
+ ]);
276
+ }
277
+ const streamBody = await renderToReadableStream(
278
+ createElement(
279
+ HtmlTemplate,
280
+ {
281
+ metadata,
282
+ pageProps: props
283
+ },
284
+ contentElement
285
+ )
286
+ );
287
+ const transformedResponse = await this.htmlTransformer.transform(
288
+ new Response(streamBody, {
289
+ headers: { "Content-Type": "text/html" }
290
+ })
291
+ );
292
+ return this.createHtmlResponse(transformedResponse.body ?? "", ctx);
293
+ } catch (error) {
294
+ throw this.createRenderError("Failed to render view", error);
295
+ }
296
+ }
297
+ }
298
+ export {
299
+ BundleError,
300
+ ReactRenderError,
301
+ ReactRenderer
302
+ };
@@ -0,0 +1,403 @@
1
+ /**
2
+ * This module contains the React renderer
3
+ * @module
4
+ */
5
+
6
+ import type {
7
+ ComponentRenderInput,
8
+ ComponentRenderResult,
9
+ EcoComponent,
10
+ EcoComponentConfig,
11
+ EcoPageFile,
12
+ HtmlTemplateProps,
13
+ IntegrationRendererRenderOptions,
14
+ PageMetadataProps,
15
+ RequestLocals,
16
+ RouteRendererBody,
17
+ } from '@ecopages/core';
18
+ import { IntegrationRenderer, type RenderToResponseContext } from '@ecopages/core/route-renderer/integration-renderer';
19
+ import { LocalsAccessError } from '@ecopages/core/errors/locals-access-error';
20
+ import { RESOLVED_ASSETS_DIR } from '@ecopages/core/constants';
21
+ import { rapidhash } from '@ecopages/core/hash';
22
+ import type { ProcessedAsset } from '@ecopages/core/services/asset-processing-service';
23
+ import { createElement, type ReactNode } from 'react';
24
+ import { renderToReadableStream, renderToString } from 'react-dom/server';
25
+ import type { CompileOptions } from '@mdx-js/mdx';
26
+ import { PLUGIN_NAME } from './react.plugin.ts';
27
+ import type { ReactRouterAdapter } from './router-adapter.ts';
28
+ import { hasSingleRootElement } from './utils/html-boundary.ts';
29
+ import { ReactBundleService } from './services/react-bundle.service.ts';
30
+ import { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.ts';
31
+ import { ReactPageModuleService } from './services/react-page-module.service.ts';
32
+ import { ReactHydrationAssetService } from './services/react-hydration-asset.service.ts';
33
+
34
+ type ReactComponentRenderContext = {
35
+ componentInstanceId?: string;
36
+ };
37
+
38
+ /**
39
+ * Error thrown when an error occurs while rendering a React component.
40
+ */
41
+ export class ReactRenderError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = 'ReactRenderError';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Error thrown when an error occurs while bundling a React component.
50
+ */
51
+ export class BundleError extends Error {
52
+ constructor(
53
+ message: string,
54
+ public readonly logs: string[],
55
+ ) {
56
+ super(message);
57
+ this.name = 'BundleError';
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Renderer for React components.
63
+ * @extends IntegrationRenderer
64
+ */
65
+ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
66
+ name = PLUGIN_NAME;
67
+ componentDirectory = RESOLVED_ASSETS_DIR;
68
+ private componentRenderSequence = 0;
69
+ static routerAdapter: ReactRouterAdapter | undefined;
70
+ static mdxCompilerOptions: CompileOptions | undefined;
71
+ static mdxExtensions: string[] = ['.mdx'];
72
+ static hmrPageMetadataCache: ReactHmrPageMetadataCache | undefined;
73
+ /**
74
+ * Enables explicit graph behavior for React page-entry bundling.
75
+ *
76
+ * When true, page-entry bundles disable AST server-only stripping and rely
77
+ * on explicit dependency declarations for browser graph composition.
78
+ */
79
+ static explicitGraphEnabled = false;
80
+
81
+ /** @internal */
82
+ readonly bundleService: ReactBundleService;
83
+ /** @internal */
84
+ readonly pageModuleService: ReactPageModuleService;
85
+ /** @internal */
86
+ readonly hydrationAssetService: ReactHydrationAssetService;
87
+
88
+ constructor(options: {
89
+ appConfig: ConstructorParameters<typeof IntegrationRenderer>[0]['appConfig'];
90
+ assetProcessingService: ConstructorParameters<typeof IntegrationRenderer>[0]['assetProcessingService'];
91
+ resolvedIntegrationDependencies?: ProcessedAsset[];
92
+ runtimeOrigin: string;
93
+ }) {
94
+ super(options);
95
+
96
+ this.bundleService = new ReactBundleService({
97
+ rootDir: this.appConfig.rootDir,
98
+ routerAdapter: ReactRenderer.routerAdapter,
99
+ mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
100
+ });
101
+
102
+ this.pageModuleService = new ReactPageModuleService({
103
+ rootDir: this.appConfig.rootDir,
104
+ distDir: this.appConfig.absolutePaths.distDir,
105
+ layoutsDir: this.appConfig.absolutePaths.layoutsDir,
106
+ componentsDir: this.appConfig.absolutePaths.componentsDir,
107
+ mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
108
+ mdxExtensions: ReactRenderer.mdxExtensions,
109
+ integrationName: this.name,
110
+ hasRouterAdapter: Boolean(ReactRenderer.routerAdapter),
111
+ });
112
+
113
+ this.hydrationAssetService = new ReactHydrationAssetService({
114
+ srcDir: this.appConfig.srcDir,
115
+ routerAdapter: ReactRenderer.routerAdapter,
116
+ assetProcessingService: this.assetProcessingService,
117
+ bundleService: this.bundleService,
118
+ hmrPageMetadataCache: ReactRenderer.hmrPageMetadataCache,
119
+ });
120
+ }
121
+
122
+ protected override shouldRenderPageComponent(): boolean {
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Renders a React component for component-level orchestration.
128
+ *
129
+ * Behavior:
130
+ * - SSR always returns the component's own root HTML (no synthetic wrapper).
131
+ * - For single-root output, a stable `data-eco-component-id` attribute is attached
132
+ * to the root element so the client island runtime can target it directly.
133
+ * - Island client scripts are emitted through `assets` and mounted independently.
134
+ *
135
+ * This preserves DOM shape for global CSS/layout selectors while keeping a
136
+ * deterministic mount target per component instance.
137
+ */
138
+ override async renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult> {
139
+ const Component = input.component as unknown as React.FunctionComponent;
140
+ const componentConfig = input.component.config;
141
+ const element =
142
+ input.children === undefined
143
+ ? createElement(Component, input.props)
144
+ : createElement(Component, input.props, input.children);
145
+ let html = renderToString(element);
146
+ let canAttachAttributes = hasSingleRootElement(html);
147
+ let rootTag = this.getRootTagName(html);
148
+ const componentFile = componentConfig?.__eco?.file;
149
+ const context = (input.integrationContext as ReactComponentRenderContext | undefined) ?? {};
150
+
151
+ let rootAttributes: Record<string, string> | undefined;
152
+ let assets: ProcessedAsset[] | undefined;
153
+
154
+ if (canAttachAttributes && componentFile && this.assetProcessingService) {
155
+ const componentInstanceId =
156
+ context.componentInstanceId ??
157
+ `eco-component-${rapidhash(componentFile)}-${++this.componentRenderSequence}`;
158
+ assets = await this.hydrationAssetService.buildComponentRenderAssets(
159
+ componentFile,
160
+ componentInstanceId,
161
+ input.props,
162
+ componentConfig,
163
+ );
164
+ rootAttributes = { 'data-eco-component-id': componentInstanceId };
165
+ }
166
+
167
+ return {
168
+ html,
169
+ canAttachAttributes,
170
+ rootTag,
171
+ integrationName: this.name,
172
+ rootAttributes,
173
+ assets,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Checks if the given file path corresponds to an MDX file based on configured extensions.
179
+ * @param filePath - The file path to check
180
+ * @returns True if the file is an MDX file
181
+ */
182
+ public isMdxFile(filePath: string): boolean {
183
+ return this.pageModuleService.isMdxFile(filePath);
184
+ }
185
+
186
+ /**
187
+ * Processes MDX-specific configuration dependencies including layout dependencies.
188
+ * @param pagePath - Absolute path to the MDX page file
189
+ * @returns Processed assets for MDX configuration dependencies
190
+ */
191
+ private async processMdxConfigDependencies(pagePath: string): Promise<ProcessedAsset[]> {
192
+ const { config } = await this.importPageFile(pagePath);
193
+ const resolvedLayout = config?.layout;
194
+ const components: Partial<EcoComponent>[] = [];
195
+
196
+ if (resolvedLayout?.config?.dependencies) {
197
+ const layoutConfig = this.pageModuleService.ensureConfigFileMetadata(resolvedLayout.config, pagePath);
198
+ components.push({ config: layoutConfig });
199
+ }
200
+
201
+ if (config?.dependencies) {
202
+ const configWithMeta = {
203
+ ...config,
204
+ __eco: { id: rapidhash(pagePath).toString(36), file: pagePath, integration: 'react' },
205
+ };
206
+ components.push({ config: configWithMeta });
207
+ }
208
+
209
+ return this.processComponentDependencies(components);
210
+ }
211
+
212
+ override async buildRouteRenderAssets(pagePath: string): Promise<ProcessedAsset[]> {
213
+ try {
214
+ const pageModule = (await this.importPageFile(pagePath)) as EcoPageFile<{ config?: EcoComponentConfig }> & {
215
+ config?: EcoComponentConfig;
216
+ };
217
+ const shouldHydrate = ReactRenderer.explicitGraphEnabled
218
+ ? true
219
+ : this.pageModuleService.shouldHydratePage(pageModule);
220
+ if (!shouldHydrate) {
221
+ return [];
222
+ }
223
+
224
+ const isMdx = this.pageModuleService.isMdxFile(pagePath);
225
+ const declaredModules = this.pageModuleService.collectPageDeclaredModules(pageModule);
226
+ const processedAssets = await this.hydrationAssetService.buildRouteRenderAssets(
227
+ pagePath,
228
+ isMdx,
229
+ declaredModules,
230
+ );
231
+
232
+ if (isMdx) {
233
+ const mdxConfigAssets = await this.processMdxConfigDependencies(pagePath);
234
+ return [...processedAssets, ...mdxConfigAssets];
235
+ }
236
+
237
+ return processedAssets;
238
+ } catch (error) {
239
+ if (error instanceof BundleError) {
240
+ console.error('[ecopages] Bundle errors:', error.logs);
241
+ }
242
+
243
+ throw new ReactRenderError(
244
+ `Failed to generate hydration script: ${error instanceof Error ? error.message : String(error)}`,
245
+ );
246
+ }
247
+ }
248
+
249
+ protected override async importPageFile(file: string): Promise<EcoPageFile<{ config?: EcoComponentConfig }>> {
250
+ const module = (
251
+ this.pageModuleService.isMdxFile(file)
252
+ ? await this.pageModuleService.importMdxPageFile(file)
253
+ : await super.importPageFile(file)
254
+ ) as EcoPageFile<{ config?: EcoComponentConfig }> & {
255
+ config?: EcoComponentConfig;
256
+ };
257
+ const { default: Page, getMetadata, config } = module;
258
+
259
+ if (this.pageModuleService.isMdxFile(file) && config) {
260
+ Page.config = config;
261
+ }
262
+
263
+ return {
264
+ default: Page,
265
+ getMetadata,
266
+ config,
267
+ };
268
+ }
269
+
270
+ async render({
271
+ params,
272
+ query,
273
+ props,
274
+ locals,
275
+ pageLocals,
276
+ metadata,
277
+ Page,
278
+ Layout,
279
+ HtmlTemplate,
280
+ pageProps,
281
+ }: IntegrationRendererRenderOptions<ReactNode>): Promise<RouteRendererBody> {
282
+ try {
283
+ const pageElement = createElement(Page, { params, query, ...props, locals: pageLocals });
284
+ const contentElement = Layout
285
+ ? createElement(Layout as React.FunctionComponent, { locals } as object, pageElement)
286
+ : pageElement;
287
+
288
+ const safeLocals = this.getSerializableLocals(locals as RequestLocals);
289
+ const allPageProps: HtmlTemplateProps['pageProps'] = {
290
+ ...pageProps,
291
+ params,
292
+ query,
293
+ ...(safeLocals && { locals: safeLocals }),
294
+ };
295
+
296
+ return await renderToReadableStream(
297
+ createElement(
298
+ HtmlTemplate,
299
+ {
300
+ metadata,
301
+ pageProps: allPageProps,
302
+ } as HtmlTemplateProps,
303
+ contentElement,
304
+ ),
305
+ );
306
+ } catch (error) {
307
+ throw this.createRenderError('Failed to render component', error);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Safely extracts locals for client-side hydration.
313
+ *
314
+ * On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
315
+ * request-scoped data (e.g., session). This data needs to be serialized to the
316
+ * client for hydration to match the server-rendered output.
317
+ *
318
+ * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
319
+ * to prevent accidental use. This method safely detects that case and returns
320
+ * `undefined` instead of throwing.
321
+ *
322
+ * @param locals - The locals object from the render context
323
+ * @returns The locals object if serializable, undefined otherwise
324
+ */
325
+ private getSerializableLocals(locals: RequestLocals): RequestLocals | undefined {
326
+ try {
327
+ if (locals && Object.keys(locals).length > 0) {
328
+ return locals;
329
+ }
330
+ return undefined;
331
+ } catch (e) {
332
+ if (e instanceof LocalsAccessError) {
333
+ return undefined;
334
+ }
335
+ throw e;
336
+ }
337
+ }
338
+
339
+ async renderToResponse<P = Record<string, unknown>>(
340
+ view: EcoComponent<P>,
341
+ props: P,
342
+ ctx: RenderToResponseContext,
343
+ ): Promise<Response> {
344
+ try {
345
+ const viewConfig = view.config;
346
+ const Layout = viewConfig?.layout as React.FunctionComponent | undefined;
347
+
348
+ const ViewComponent = view as unknown as React.FunctionComponent;
349
+ const pageElement = createElement(ViewComponent, props || {});
350
+
351
+ if (ctx.partial) {
352
+ const stream = await renderToReadableStream(pageElement);
353
+ return this.createHtmlResponse(stream, ctx);
354
+ }
355
+
356
+ const contentElement = Layout
357
+ ? createElement(Layout as React.FunctionComponent, {}, pageElement)
358
+ : pageElement;
359
+
360
+ const HtmlTemplate = await this.getHtmlTemplate();
361
+ const metadata: PageMetadataProps = view.metadata
362
+ ? await view.metadata({
363
+ params: {},
364
+ query: {},
365
+ props,
366
+ appConfig: this.appConfig,
367
+ })
368
+ : this.appConfig.defaultMetadata;
369
+
370
+ await this.prepareViewDependencies(view, Layout as unknown as EcoComponent | undefined);
371
+
372
+ const viewFilePath = viewConfig?.__eco?.file;
373
+ if (viewFilePath) {
374
+ const hydrationAssets = await this.buildRouteRenderAssets(viewFilePath);
375
+ this.htmlTransformer.setProcessedDependencies([
376
+ ...this.htmlTransformer.getProcessedDependencies(),
377
+ ...hydrationAssets,
378
+ ]);
379
+ }
380
+
381
+ const streamBody = await renderToReadableStream(
382
+ createElement(
383
+ HtmlTemplate,
384
+ {
385
+ metadata,
386
+ pageProps: props,
387
+ } as HtmlTemplateProps,
388
+ contentElement,
389
+ ),
390
+ );
391
+
392
+ const transformedResponse = await this.htmlTransformer.transform(
393
+ new Response(streamBody, {
394
+ headers: { 'Content-Type': 'text/html' },
395
+ }),
396
+ );
397
+
398
+ return this.createHtmlResponse(transformedResponse.body ?? '', ctx);
399
+ } catch (error) {
400
+ throw this.createRenderError('Failed to render view', error);
401
+ }
402
+ }
403
+ }