@ecopages/react 0.2.0-alpha.1 → 0.2.0-alpha.11
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 +9 -43
- package/README.md +143 -17
- package/package.json +3 -3
- package/src/react-hmr-strategy.d.ts +25 -21
- package/src/react-hmr-strategy.js +78 -110
- package/src/react-renderer.d.ts +135 -12
- package/src/react-renderer.js +439 -82
- package/src/react.plugin.d.ts +17 -5
- package/src/react.plugin.js +45 -13
- package/src/router-adapter.d.ts +2 -2
- package/src/services/react-bundle.service.d.ts +4 -25
- package/src/services/react-bundle.service.js +37 -91
- package/src/services/react-hydration-asset.service.js +3 -3
- package/src/services/react-page-module.service.d.ts +3 -0
- package/src/services/react-page-module.service.js +24 -17
- package/src/services/react-runtime-bundle.service.d.ts +12 -12
- package/src/services/react-runtime-bundle.service.js +98 -180
- package/src/utils/client-graph-boundary-plugin.js +149 -11
- package/src/utils/declared-modules.js +4 -1
- package/src/utils/foreign-jsx-override-plugin.d.ts +19 -0
- package/src/utils/foreign-jsx-override-plugin.js +43 -0
- package/src/utils/hydration-scripts.d.ts +18 -1
- package/src/utils/hydration-scripts.js +95 -37
- package/src/utils/reachability-analyzer.d.ts +12 -1
- package/src/utils/reachability-analyzer.js +101 -5
- package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
- package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
- package/src/utils/react-mdx-loader-plugin.js +13 -5
- package/src/utils/react-runtime-specifier-map.d.ts +6 -0
- package/src/utils/react-runtime-specifier-map.js +37 -0
- package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
- package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
- package/src/react-hmr-strategy.ts +0 -444
- package/src/react-renderer.ts +0 -403
- package/src/react.plugin.ts +0 -241
- package/src/router-adapter.ts +0 -95
- package/src/services/react-bundle.service.ts +0 -212
- package/src/services/react-hmr-page-metadata-cache.ts +0 -24
- package/src/services/react-hydration-asset.service.ts +0 -260
- package/src/services/react-page-module.service.ts +0 -214
- package/src/services/react-runtime-bundle.service.ts +0 -271
- package/src/utils/client-graph-boundary-plugin.ts +0 -590
- package/src/utils/client-only.ts +0 -27
- package/src/utils/declared-modules.ts +0 -99
- package/src/utils/dynamic.ts +0 -27
- package/src/utils/hmr-scripts.ts +0 -47
- package/src/utils/html-boundary.ts +0 -66
- package/src/utils/hydration-scripts.ts +0 -338
- package/src/utils/reachability-analyzer.ts +0 -440
- package/src/utils/react-mdx-loader-plugin.ts +0 -40
package/src/react-renderer.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { IntegrationRenderer } from "@ecopages/core/route-renderer/integration-renderer";
|
|
2
2
|
import { LocalsAccessError } from "@ecopages/core/errors/locals-access-error";
|
|
3
3
|
import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
|
|
4
|
+
import { getAppBuildExecutor } from "@ecopages/core/build/build-adapter";
|
|
4
5
|
import { rapidhash } from "@ecopages/core/hash";
|
|
6
|
+
import { AssetFactory } from "@ecopages/core/services/asset-processing-service";
|
|
7
|
+
import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
|
|
8
|
+
import path from "node:path";
|
|
5
9
|
import { createElement } from "react";
|
|
6
10
|
import { renderToReadableStream, renderToString } from "react-dom/server";
|
|
7
11
|
import { PLUGIN_NAME } from "./react.plugin.js";
|
|
@@ -10,6 +14,21 @@ import { ReactBundleService } from "./services/react-bundle.service.js";
|
|
|
10
14
|
import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
|
|
11
15
|
import { ReactPageModuleService } from "./services/react-page-module.service.js";
|
|
12
16
|
import { ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
|
|
17
|
+
function decodeHtmlEntities(value) {
|
|
18
|
+
let decoded = value;
|
|
19
|
+
let previous;
|
|
20
|
+
do {
|
|
21
|
+
previous = decoded;
|
|
22
|
+
decoded = decoded.replaceAll(""", '"').replaceAll("'", "'").replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
23
|
+
} while (decoded !== previous);
|
|
24
|
+
return decoded;
|
|
25
|
+
}
|
|
26
|
+
function restoreEscapedComponentMarkers(html) {
|
|
27
|
+
return html.replace(
|
|
28
|
+
/&(?:amp;)?lt;eco-marker\b[\s\S]*?&(?:amp;)?gt;&(?:amp;)?lt;\/eco-marker&(?:amp;)?gt;/g,
|
|
29
|
+
(marker) => decodeHtmlEntities(marker)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
13
32
|
class ReactRenderError extends Error {
|
|
14
33
|
constructor(message) {
|
|
15
34
|
super(message);
|
|
@@ -17,16 +36,16 @@ class ReactRenderError extends Error {
|
|
|
17
36
|
}
|
|
18
37
|
}
|
|
19
38
|
class BundleError extends Error {
|
|
39
|
+
logs;
|
|
20
40
|
constructor(message, logs) {
|
|
21
41
|
super(message);
|
|
22
|
-
this.logs = logs;
|
|
23
42
|
this.name = "BundleError";
|
|
43
|
+
this.logs = logs;
|
|
24
44
|
}
|
|
25
45
|
}
|
|
26
46
|
class ReactRenderer extends IntegrationRenderer {
|
|
27
47
|
name = PLUGIN_NAME;
|
|
28
48
|
componentDirectory = RESOLVED_ASSETS_DIR;
|
|
29
|
-
componentRenderSequence = 0;
|
|
30
49
|
static routerAdapter;
|
|
31
50
|
static mdxCompilerOptions;
|
|
32
51
|
static mdxExtensions = [".mdx"];
|
|
@@ -49,11 +68,15 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
49
68
|
this.bundleService = new ReactBundleService({
|
|
50
69
|
rootDir: this.appConfig.rootDir,
|
|
51
70
|
routerAdapter: ReactRenderer.routerAdapter,
|
|
52
|
-
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
|
|
71
|
+
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
|
|
72
|
+
jsxImportSource: (this.appConfig.integrations ?? []).find((integration) => integration.name === this.name)?.jsxImportSource,
|
|
73
|
+
nonReactExtensions: (this.appConfig.integrations ?? []).filter((integration) => integration.name !== this.name).flatMap((integration) => integration.extensions)
|
|
53
74
|
});
|
|
54
75
|
this.pageModuleService = new ReactPageModuleService({
|
|
55
76
|
rootDir: this.appConfig.rootDir,
|
|
56
77
|
distDir: this.appConfig.absolutePaths.distDir,
|
|
78
|
+
workDir: this.appConfig.absolutePaths.workDir,
|
|
79
|
+
buildExecutor: getAppBuildExecutor(this.appConfig),
|
|
57
80
|
layoutsDir: this.appConfig.absolutePaths.layoutsDir,
|
|
58
81
|
componentsDir: this.appConfig.absolutePaths.componentsDir,
|
|
59
82
|
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
|
|
@@ -72,38 +95,256 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
72
95
|
shouldRenderPageComponent() {
|
|
73
96
|
return false;
|
|
74
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Reads the declared integration name for a component or layout.
|
|
100
|
+
*
|
|
101
|
+
* We honor both the explicit `config.integration` override and injected
|
|
102
|
+
* `config.__eco.integration` metadata because pages can arrive here through
|
|
103
|
+
* authored config as well as build-time component metadata.
|
|
104
|
+
*/
|
|
105
|
+
getComponentIntegration(component) {
|
|
106
|
+
return component?.config?.integration ?? component?.config?.__eco?.integration;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Returns whether a component should stay inside the React render lane.
|
|
110
|
+
*
|
|
111
|
+
* Components without explicit integration metadata are treated as React-owned
|
|
112
|
+
* here because this renderer only receives them after the route pipeline has
|
|
113
|
+
* already selected the React integration.
|
|
114
|
+
*/
|
|
115
|
+
isReactManagedComponent(component) {
|
|
116
|
+
const integration = this.getComponentIntegration(component);
|
|
117
|
+
return integration === void 0 || integration === this.name;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Creates the canonical page-props payload used by router hydration.
|
|
121
|
+
*
|
|
122
|
+
* React pages embedded in a non-React HTML shell still need to expose the same
|
|
123
|
+
* page-data contract as fully React-owned documents so navigation and hydration
|
|
124
|
+
* can read one marker consistently.
|
|
125
|
+
*/
|
|
126
|
+
buildRouterPageDataScript(pageProps) {
|
|
127
|
+
const safeJson = JSON.stringify(pageProps || {}).replace(/</g, "\\u003c");
|
|
128
|
+
return `<script id="__ECO_PAGE_DATA__" type="application/json">${safeJson}<\/script>`;
|
|
129
|
+
}
|
|
130
|
+
getRouterDocumentAttributes() {
|
|
131
|
+
if (!ReactRenderer.routerAdapter) {
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
[ECO_DOCUMENT_OWNER_ATTRIBUTE]: "react-router"
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Commits a framework-agnostic component to React semantics.
|
|
140
|
+
*
|
|
141
|
+
* This is one of the two real cast boundaries in this file. Core keeps
|
|
142
|
+
* `EcoComponent` broad so integrations can share the same public surface; once
|
|
143
|
+
* the React renderer is executing, `createElement()` needs a concrete React
|
|
144
|
+
* component signature.
|
|
145
|
+
*/
|
|
146
|
+
asReactComponent(component) {
|
|
147
|
+
return component;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Commits a mixed-shell component to the string-returning contract required by
|
|
151
|
+
* non-React layouts and HTML templates.
|
|
152
|
+
*
|
|
153
|
+
* This is the second real cast boundary: once we decide a shell is not managed
|
|
154
|
+
* by React, we call it directly and require serialized HTML back.
|
|
155
|
+
*/
|
|
156
|
+
asNonReactShellComponent(component) {
|
|
157
|
+
return component;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Builds the serialized page-props payload embedded into the final HTML.
|
|
161
|
+
*
|
|
162
|
+
* The document payload is intentionally narrower than the full server render
|
|
163
|
+
* input: only routing data, public page props, and explicitly allowed locals are
|
|
164
|
+
* exposed to the browser.
|
|
165
|
+
*/
|
|
166
|
+
buildSerializedPageProps(options) {
|
|
167
|
+
return {
|
|
168
|
+
...options.pageProps,
|
|
169
|
+
params: options.params,
|
|
170
|
+
query: options.query,
|
|
171
|
+
...options.safeLocals && { locals: options.safeLocals }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Appends route hydration assets for a concrete page/view file to the current
|
|
176
|
+
* HTML transformer state.
|
|
177
|
+
*/
|
|
178
|
+
async appendHydrationAssetsForFile(filePath) {
|
|
179
|
+
if (!filePath) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const hydrationAssets = await this.buildRouteRenderAssets(filePath);
|
|
183
|
+
this.htmlTransformer.setProcessedDependencies([
|
|
184
|
+
...this.htmlTransformer.getProcessedDependencies(),
|
|
185
|
+
...hydrationAssets
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Resolves metadata for direct `renderToResponse()` calls.
|
|
190
|
+
*
|
|
191
|
+
* View rendering bypasses the normal route-file pipeline, so metadata has to be
|
|
192
|
+
* evaluated here from either the component-level generator or the application
|
|
193
|
+
* default.
|
|
194
|
+
*/
|
|
195
|
+
async resolveViewMetadata(view, props) {
|
|
196
|
+
return view.metadata ? await view.metadata({
|
|
197
|
+
params: {},
|
|
198
|
+
query: {},
|
|
199
|
+
props,
|
|
200
|
+
appConfig: this.appConfig
|
|
201
|
+
}) : this.appConfig.defaultMetadata;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Renders a non-React layout or HTML template and enforces that mixed shells
|
|
205
|
+
* return serialized HTML.
|
|
206
|
+
*
|
|
207
|
+
* The React renderer can compose through another integration's shell, but only
|
|
208
|
+
* if that shell yields a string that can be inserted into the final document.
|
|
209
|
+
*/
|
|
210
|
+
async renderNonReactShellComponent(Component, props, label) {
|
|
211
|
+
const output = await Component(props);
|
|
212
|
+
if (typeof output === "string") {
|
|
213
|
+
return output;
|
|
214
|
+
}
|
|
215
|
+
throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Renders one React component boundary for marker-graph orchestration.
|
|
219
|
+
*
|
|
220
|
+
* When the marker resolver has already stitched child HTML for this boundary,
|
|
221
|
+
* the child payload must remain raw SSR output rather than a React string
|
|
222
|
+
* child, otherwise React would escape it. This helper renders a unique token
|
|
223
|
+
* through React and swaps that token back to the stitched HTML afterward.
|
|
224
|
+
*
|
|
225
|
+
* @param input Component render input reconstructed from marker metadata.
|
|
226
|
+
* @param context React-specific render context for stable token generation.
|
|
227
|
+
* @returns Serialized component HTML with stitched child markup preserved.
|
|
228
|
+
*/
|
|
229
|
+
renderComponentHtml(input, context) {
|
|
230
|
+
if (input.children === void 0) {
|
|
231
|
+
return restoreEscapedComponentMarkers(
|
|
232
|
+
renderToString(createElement(this.asReactComponent(input.component), input.props))
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
|
|
236
|
+
const html = renderToString(
|
|
237
|
+
createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
|
|
238
|
+
);
|
|
239
|
+
return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(input.children));
|
|
240
|
+
}
|
|
241
|
+
buildHydrationProps(props) {
|
|
242
|
+
if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
|
|
243
|
+
return props ?? {};
|
|
244
|
+
}
|
|
245
|
+
const { locals: _locals, ...hydrationProps } = props;
|
|
246
|
+
return hydrationProps;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Produces the page body before the final HTML template is applied.
|
|
250
|
+
*
|
|
251
|
+
* This method owns the React/non-React layout split. React-managed layouts stay
|
|
252
|
+
* as React elements so they can stream normally; non-React layouts are rendered
|
|
253
|
+
* to HTML first and then passed through as serialized content.
|
|
254
|
+
*/
|
|
255
|
+
async composePageContent(options) {
|
|
256
|
+
const pageElement = createElement(options.Page, options.pageProps);
|
|
257
|
+
const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
|
|
258
|
+
const layoutProps = options.locals ? { locals: options.locals } : {};
|
|
259
|
+
if (!options.Layout) {
|
|
260
|
+
return { contentNode: pageElement, contentHtml: pageHtml };
|
|
261
|
+
}
|
|
262
|
+
if (this.isReactManagedComponent(options.Layout)) {
|
|
263
|
+
const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
|
|
264
|
+
return {
|
|
265
|
+
contentNode: layoutElement,
|
|
266
|
+
contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const layoutHtml = await this.renderNonReactShellComponent(
|
|
270
|
+
this.asNonReactShellComponent(options.Layout),
|
|
271
|
+
{ ...layoutProps, children: pageHtml },
|
|
272
|
+
"Layout"
|
|
273
|
+
);
|
|
274
|
+
return { contentNode: layoutHtml, contentHtml: layoutHtml };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Wraps composed page content in the final document template.
|
|
278
|
+
*
|
|
279
|
+
* React-owned HTML templates stream directly. Non-React templates receive
|
|
280
|
+
* pre-rendered page HTML plus the canonical React page-data payload so the
|
|
281
|
+
* client runtime can recover page data after cross-integration handoff.
|
|
282
|
+
*/
|
|
283
|
+
async renderDocument(options) {
|
|
284
|
+
if (this.isReactManagedComponent(options.HtmlTemplate)) {
|
|
285
|
+
const rawChildrenToken = "__ECO_RAW_HTML_DOCUMENT_CHILD__";
|
|
286
|
+
const html = restoreEscapedComponentMarkers(
|
|
287
|
+
renderToString(
|
|
288
|
+
createElement(
|
|
289
|
+
this.asReactComponent(options.HtmlTemplate),
|
|
290
|
+
{
|
|
291
|
+
metadata: options.metadata,
|
|
292
|
+
pageProps: options.pageProps
|
|
293
|
+
},
|
|
294
|
+
rawChildrenToken
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
return html.split(rawChildrenToken).join(options.contentHtml);
|
|
299
|
+
}
|
|
300
|
+
const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
|
|
301
|
+
return this.renderNonReactShellComponent(
|
|
302
|
+
this.asNonReactShellComponent(options.HtmlTemplate),
|
|
303
|
+
{
|
|
304
|
+
metadata: options.metadata,
|
|
305
|
+
pageProps: options.pageProps,
|
|
306
|
+
children: options.contentHtml,
|
|
307
|
+
headContent
|
|
308
|
+
},
|
|
309
|
+
"HtmlTemplate"
|
|
310
|
+
);
|
|
311
|
+
}
|
|
75
312
|
/**
|
|
76
313
|
* Renders a React component for component-level orchestration.
|
|
77
314
|
*
|
|
78
315
|
* Behavior:
|
|
79
316
|
* - SSR always returns the component's own root HTML (no synthetic wrapper).
|
|
80
|
-
* -
|
|
81
|
-
*
|
|
82
|
-
* -
|
|
317
|
+
* - When an explicit component instance id is provided, a stable
|
|
318
|
+
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
319
|
+
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
320
|
+
* - When stitched child HTML is provided, that boundary is treated as a pure SSR
|
|
321
|
+
* composition step and does not emit hydration assets for the parent wrapper.
|
|
83
322
|
*
|
|
84
323
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
85
324
|
* deterministic mount target per component instance.
|
|
86
325
|
*/
|
|
87
326
|
async renderComponent(input) {
|
|
88
|
-
const Component = input.component;
|
|
89
327
|
const componentConfig = input.component.config;
|
|
90
|
-
const
|
|
91
|
-
|
|
328
|
+
const context = input.integrationContext ?? {};
|
|
329
|
+
const hasResolvedChildHtml = input.children !== void 0;
|
|
330
|
+
let html = this.renderComponentHtml(input, context);
|
|
92
331
|
let canAttachAttributes = hasSingleRootElement(html);
|
|
93
332
|
let rootTag = this.getRootTagName(html);
|
|
94
333
|
const componentFile = componentConfig?.__eco?.file;
|
|
95
|
-
const context = input.integrationContext ?? {};
|
|
96
334
|
let rootAttributes;
|
|
97
335
|
let assets;
|
|
98
|
-
if (canAttachAttributes && componentFile && this.assetProcessingService) {
|
|
99
|
-
const componentInstanceId = context.componentInstanceId
|
|
336
|
+
if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
|
|
337
|
+
const componentInstanceId = context.componentInstanceId;
|
|
100
338
|
assets = await this.hydrationAssetService.buildComponentRenderAssets(
|
|
101
339
|
componentFile,
|
|
102
340
|
componentInstanceId,
|
|
103
|
-
input.props,
|
|
341
|
+
this.buildHydrationProps(input.props),
|
|
104
342
|
componentConfig
|
|
105
343
|
);
|
|
106
|
-
rootAttributes = {
|
|
344
|
+
rootAttributes = {
|
|
345
|
+
"data-eco-component-id": componentInstanceId,
|
|
346
|
+
"data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
|
|
347
|
+
};
|
|
107
348
|
}
|
|
108
349
|
return {
|
|
109
350
|
html,
|
|
@@ -122,13 +363,33 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
122
363
|
isMdxFile(filePath) {
|
|
123
364
|
return this.pageModuleService.isMdxFile(filePath);
|
|
124
365
|
}
|
|
366
|
+
usesIntegrationPageImporter(file) {
|
|
367
|
+
return this.pageModuleService.isMdxFile(file);
|
|
368
|
+
}
|
|
369
|
+
async importIntegrationPageFile(file) {
|
|
370
|
+
return await this.pageModuleService.importMdxPageFile(file);
|
|
371
|
+
}
|
|
372
|
+
normalizeImportedPageFile(file, pageModule) {
|
|
373
|
+
const reactModule = pageModule;
|
|
374
|
+
const { default: Page, getMetadata, config } = reactModule;
|
|
375
|
+
if (this.pageModuleService.isMdxFile(file) && config) {
|
|
376
|
+
Page.config = config;
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
...pageModule,
|
|
380
|
+
default: Page,
|
|
381
|
+
getMetadata,
|
|
382
|
+
config
|
|
383
|
+
};
|
|
384
|
+
}
|
|
125
385
|
/**
|
|
126
386
|
* Processes MDX-specific configuration dependencies including layout dependencies.
|
|
127
387
|
* @param pagePath - Absolute path to the MDX page file
|
|
128
388
|
* @returns Processed assets for MDX configuration dependencies
|
|
129
389
|
*/
|
|
130
390
|
async processMdxConfigDependencies(pagePath) {
|
|
131
|
-
const
|
|
391
|
+
const pageModule = await this.importPageFile(pagePath);
|
|
392
|
+
const config = pageModule.config;
|
|
132
393
|
const resolvedLayout = config?.layout;
|
|
133
394
|
const components = [];
|
|
134
395
|
if (resolvedLayout?.config?.dependencies) {
|
|
@@ -142,7 +403,86 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
142
403
|
};
|
|
143
404
|
components.push({ config: configWithMeta });
|
|
144
405
|
}
|
|
145
|
-
|
|
406
|
+
const processedDependencies = await this.processComponentDependencies(components);
|
|
407
|
+
const eagerSsrLazyDependencies = await this.processDeclaredMdxSsrLazyDependencies(components, pagePath);
|
|
408
|
+
return [...processedDependencies, ...eagerSsrLazyDependencies];
|
|
409
|
+
}
|
|
410
|
+
async processDeclaredMdxSsrLazyDependencies(components, pagePath) {
|
|
411
|
+
if (!this.assetProcessingService?.processDependencies) {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
const dependencies = this.collectDeclaredMdxSsrLazyDependencies(components);
|
|
415
|
+
if (dependencies.length === 0) {
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
return this.assetProcessingService.processDependencies(dependencies, `react-mdx-ssr-lazy:${pagePath}`);
|
|
419
|
+
}
|
|
420
|
+
collectDeclaredMdxSsrLazyDependencies(components) {
|
|
421
|
+
const dependencies = [];
|
|
422
|
+
const visitedConfigs = /* @__PURE__ */ new Set();
|
|
423
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
424
|
+
const normalizeAttributes = (attributes) => ({
|
|
425
|
+
type: "module",
|
|
426
|
+
defer: "",
|
|
427
|
+
...attributes ?? {}
|
|
428
|
+
});
|
|
429
|
+
const collect = (config) => {
|
|
430
|
+
if (!config || visitedConfigs.has(config)) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
visitedConfigs.add(config);
|
|
434
|
+
const componentFile = config.__eco?.file;
|
|
435
|
+
if (componentFile) {
|
|
436
|
+
const componentDir = path.dirname(componentFile);
|
|
437
|
+
for (const script of config.dependencies?.scripts ?? []) {
|
|
438
|
+
if (typeof script === "string" || !script.lazy || script.ssr !== true) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const attributes = normalizeAttributes(script.attributes);
|
|
442
|
+
if (script.content) {
|
|
443
|
+
const key2 = `content:${script.content}:${JSON.stringify(attributes)}`;
|
|
444
|
+
if (seenKeys.has(key2)) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
seenKeys.add(key2);
|
|
448
|
+
dependencies.push(
|
|
449
|
+
AssetFactory.createContentScript({
|
|
450
|
+
position: "head",
|
|
451
|
+
content: script.content,
|
|
452
|
+
attributes
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!script.src) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const resolvedPath = path.resolve(componentDir, script.src);
|
|
461
|
+
const key = `file:${resolvedPath}:${JSON.stringify(attributes)}`;
|
|
462
|
+
if (seenKeys.has(key)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
seenKeys.add(key);
|
|
466
|
+
dependencies.push(
|
|
467
|
+
AssetFactory.createFileScript({
|
|
468
|
+
filepath: resolvedPath,
|
|
469
|
+
position: "head",
|
|
470
|
+
attributes
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (config.layout?.config) {
|
|
476
|
+
collect(config.layout.config);
|
|
477
|
+
}
|
|
478
|
+
for (const nestedComponent of config.dependencies?.components ?? []) {
|
|
479
|
+
collect(nestedComponent?.config);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
for (const component of components) {
|
|
483
|
+
collect(component.config);
|
|
484
|
+
}
|
|
485
|
+
return dependencies;
|
|
146
486
|
}
|
|
147
487
|
async buildRouteRenderAssets(pagePath) {
|
|
148
488
|
try {
|
|
@@ -172,18 +512,14 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
172
512
|
);
|
|
173
513
|
}
|
|
174
514
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
getMetadata,
|
|
184
|
-
config
|
|
185
|
-
};
|
|
186
|
-
}
|
|
515
|
+
/**
|
|
516
|
+
* Renders a full route response for the filesystem page pipeline.
|
|
517
|
+
*
|
|
518
|
+
* This path receives already-resolved route metadata, layout, locals, and HTML
|
|
519
|
+
* template instances from the shared renderer orchestration. Its main job is to
|
|
520
|
+
* serialize only the browser-safe page payload, compose the mixed React/non-
|
|
521
|
+
* React shell tree, and hand the result back as a document body.
|
|
522
|
+
*/
|
|
187
523
|
async render({
|
|
188
524
|
params,
|
|
189
525
|
query,
|
|
@@ -197,47 +533,63 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
197
533
|
pageProps
|
|
198
534
|
}) {
|
|
199
535
|
try {
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const allPageProps = {
|
|
204
|
-
...pageProps,
|
|
536
|
+
const safeLocals = this.getSerializableLocals(locals, Page.requires);
|
|
537
|
+
const allPageProps = this.buildSerializedPageProps({
|
|
538
|
+
pageProps,
|
|
205
539
|
params,
|
|
206
540
|
query,
|
|
207
|
-
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
541
|
+
safeLocals
|
|
542
|
+
});
|
|
543
|
+
const { contentNode, contentHtml } = await this.composePageContent({
|
|
544
|
+
Page: this.asReactComponent(Page),
|
|
545
|
+
Layout,
|
|
546
|
+
pageProps: { params, query, ...props, locals: pageLocals },
|
|
547
|
+
locals
|
|
548
|
+
});
|
|
549
|
+
return await this.renderDocument({
|
|
550
|
+
HtmlTemplate,
|
|
551
|
+
metadata,
|
|
552
|
+
pageProps: allPageProps,
|
|
553
|
+
contentNode,
|
|
554
|
+
contentHtml
|
|
555
|
+
});
|
|
219
556
|
} catch (error) {
|
|
220
557
|
throw this.createRenderError("Failed to render component", error);
|
|
221
558
|
}
|
|
222
559
|
}
|
|
560
|
+
getDocumentAttributes() {
|
|
561
|
+
return this.getRouterDocumentAttributes();
|
|
562
|
+
}
|
|
223
563
|
/**
|
|
224
|
-
* Safely extracts locals for client-side hydration.
|
|
564
|
+
* Safely extracts the declared subset of locals for client-side hydration.
|
|
225
565
|
*
|
|
226
566
|
* On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
|
|
227
|
-
* request-scoped data (e.g., session).
|
|
228
|
-
*
|
|
567
|
+
* request-scoped data (e.g., session). Only keys explicitly declared via
|
|
568
|
+
* `Page.requires` are serialized to the client so sensitive request-only data
|
|
569
|
+
* is not leaked into hydration payloads by default.
|
|
229
570
|
*
|
|
230
571
|
* On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
|
|
231
572
|
* to prevent accidental use. This method safely detects that case and returns
|
|
232
573
|
* `undefined` instead of throwing.
|
|
233
574
|
*
|
|
234
575
|
* @param locals - The locals object from the render context
|
|
235
|
-
* @
|
|
576
|
+
* @param requiredLocals - Keys explicitly requested for client hydration
|
|
577
|
+
* @returns The filtered locals object if serializable, undefined otherwise
|
|
236
578
|
*/
|
|
237
|
-
getSerializableLocals(locals) {
|
|
579
|
+
getSerializableLocals(locals, requiredLocals) {
|
|
238
580
|
try {
|
|
239
|
-
if (locals
|
|
240
|
-
return
|
|
581
|
+
if (!locals) {
|
|
582
|
+
return void 0;
|
|
583
|
+
}
|
|
584
|
+
const requiredKeys = requiredLocals ? Array.isArray(requiredLocals) ? requiredLocals : [requiredLocals] : [];
|
|
585
|
+
if (requiredKeys.length === 0) {
|
|
586
|
+
return void 0;
|
|
587
|
+
}
|
|
588
|
+
const serializedLocals = Object.fromEntries(
|
|
589
|
+
requiredKeys.filter((key) => Object.prototype.hasOwnProperty.call(locals, key)).map((key) => [key, locals[key]])
|
|
590
|
+
);
|
|
591
|
+
if (Object.keys(serializedLocals).length > 0) {
|
|
592
|
+
return serializedLocals;
|
|
241
593
|
}
|
|
242
594
|
return void 0;
|
|
243
595
|
} catch (e) {
|
|
@@ -247,49 +599,54 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
247
599
|
throw e;
|
|
248
600
|
}
|
|
249
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Renders an arbitrary React view through the application's HTML shell.
|
|
604
|
+
*
|
|
605
|
+
* Unlike route rendering, this path starts from a single component rather than a
|
|
606
|
+
* page module discovered by the router. It still needs to resolve metadata,
|
|
607
|
+
* layout dependencies, and hydration assets so direct `ctx.render()` calls match
|
|
608
|
+
* normal page responses.
|
|
609
|
+
*/
|
|
250
610
|
async renderToResponse(view, props, ctx) {
|
|
251
611
|
try {
|
|
252
612
|
const viewConfig = view.config;
|
|
253
613
|
const Layout = viewConfig?.layout;
|
|
254
|
-
const ViewComponent = view;
|
|
255
|
-
const
|
|
614
|
+
const ViewComponent = this.asReactComponent(view);
|
|
615
|
+
const normalizedProps = props ?? {};
|
|
256
616
|
if (ctx.partial) {
|
|
257
|
-
const stream = await renderToReadableStream(
|
|
617
|
+
const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
|
|
258
618
|
return this.createHtmlResponse(stream, ctx);
|
|
259
619
|
}
|
|
260
|
-
const contentElement = Layout ? createElement(Layout, {}, pageElement) : pageElement;
|
|
261
620
|
const HtmlTemplate = await this.getHtmlTemplate();
|
|
262
|
-
const metadata =
|
|
263
|
-
params: {},
|
|
264
|
-
query: {},
|
|
265
|
-
props,
|
|
266
|
-
appConfig: this.appConfig
|
|
267
|
-
}) : this.appConfig.defaultMetadata;
|
|
621
|
+
const metadata = await this.resolveViewMetadata(view, props);
|
|
268
622
|
await this.prepareViewDependencies(view, Layout);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
pageProps: props
|
|
283
|
-
},
|
|
284
|
-
contentElement
|
|
285
|
-
)
|
|
286
|
-
);
|
|
623
|
+
await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
|
|
624
|
+
const { contentNode, contentHtml } = await this.composePageContent({
|
|
625
|
+
Page: ViewComponent,
|
|
626
|
+
Layout,
|
|
627
|
+
pageProps: normalizedProps
|
|
628
|
+
});
|
|
629
|
+
const body = await this.renderDocument({
|
|
630
|
+
HtmlTemplate,
|
|
631
|
+
metadata,
|
|
632
|
+
pageProps: normalizedProps,
|
|
633
|
+
contentNode,
|
|
634
|
+
contentHtml
|
|
635
|
+
});
|
|
287
636
|
const transformedResponse = await this.htmlTransformer.transform(
|
|
288
|
-
new Response(
|
|
637
|
+
new Response(body, {
|
|
289
638
|
headers: { "Content-Type": "text/html" }
|
|
290
639
|
})
|
|
291
640
|
);
|
|
292
|
-
|
|
641
|
+
let transformedHtml = await transformedResponse.text();
|
|
642
|
+
const documentAttributes = this.getRouterDocumentAttributes();
|
|
643
|
+
if (documentAttributes) {
|
|
644
|
+
transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
|
|
645
|
+
transformedHtml,
|
|
646
|
+
documentAttributes
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return this.createHtmlResponse(transformedHtml, ctx);
|
|
293
650
|
} catch (error) {
|
|
294
651
|
throw this.createRenderError("Failed to render view", error);
|
|
295
652
|
}
|