@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.
- package/CHANGELOG.md +62 -0
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/package.json +76 -0
- package/src/declarations.d.ts +6 -0
- package/src/react-hmr-strategy.d.ts +143 -0
- package/src/react-hmr-strategy.js +332 -0
- package/src/react-hmr-strategy.ts +444 -0
- package/src/react-renderer.d.ts +106 -0
- package/src/react-renderer.js +302 -0
- package/src/react-renderer.ts +403 -0
- package/src/react.plugin.d.ts +147 -0
- package/src/react.plugin.js +126 -0
- package/src/react.plugin.ts +241 -0
- package/src/router-adapter.d.ts +87 -0
- package/src/router-adapter.js +0 -0
- package/src/router-adapter.ts +95 -0
- package/src/services/react-bundle.service.d.ts +68 -0
- package/src/services/react-bundle.service.js +145 -0
- package/src/services/react-bundle.service.ts +212 -0
- package/src/services/react-hmr-page-metadata-cache.d.ts +17 -0
- package/src/services/react-hmr-page-metadata-cache.js +19 -0
- package/src/services/react-hmr-page-metadata-cache.ts +24 -0
- package/src/services/react-hydration-asset.service.d.ts +75 -0
- package/src/services/react-hydration-asset.service.js +198 -0
- package/src/services/react-hydration-asset.service.ts +260 -0
- package/src/services/react-page-module.service.d.ts +80 -0
- package/src/services/react-page-module.service.js +155 -0
- package/src/services/react-page-module.service.ts +214 -0
- package/src/services/react-runtime-bundle.service.d.ts +38 -0
- package/src/services/react-runtime-bundle.service.js +207 -0
- package/src/services/react-runtime-bundle.service.ts +271 -0
- package/src/utils/client-graph-boundary-plugin.d.ts +43 -0
- package/src/utils/client-graph-boundary-plugin.js +356 -0
- package/src/utils/client-graph-boundary-plugin.ts +590 -0
- package/src/utils/client-only.d.ts +8 -0
- package/src/utils/client-only.js +19 -0
- package/src/utils/client-only.ts +27 -0
- package/src/utils/declared-modules.d.ts +42 -0
- package/src/utils/declared-modules.js +56 -0
- package/src/utils/declared-modules.ts +99 -0
- package/src/utils/dynamic.d.ts +15 -0
- package/src/utils/dynamic.js +12 -0
- package/src/utils/dynamic.ts +27 -0
- package/src/utils/hmr-scripts.d.ts +18 -0
- package/src/utils/hmr-scripts.js +31 -0
- package/src/utils/hmr-scripts.ts +47 -0
- package/src/utils/html-boundary.d.ts +7 -0
- package/src/utils/html-boundary.js +55 -0
- package/src/utils/html-boundary.ts +66 -0
- package/src/utils/hydration-scripts.d.ts +71 -0
- package/src/utils/hydration-scripts.js +222 -0
- package/src/utils/hydration-scripts.ts +338 -0
- package/src/utils/reachability-analyzer.d.ts +55 -0
- package/src/utils/reachability-analyzer.js +243 -0
- package/src/utils/reachability-analyzer.ts +440 -0
- package/src/utils/react-mdx-loader-plugin.d.ts +3 -0
- package/src/utils/react-mdx-loader-plugin.js +37 -0
- 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
|
+
}
|