@ecopages/react 0.2.0-alpha.9 → 0.2.0-beta.0

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 (77) hide show
  1. package/README.md +30 -13
  2. package/package.json +23 -12
  3. package/src/eco-embed.d.ts +11 -0
  4. package/src/eco-embed.js +11 -0
  5. package/src/react-hmr-strategy.d.ts +102 -18
  6. package/src/react-hmr-strategy.js +427 -50
  7. package/src/react-renderer.d.ts +100 -92
  8. package/src/react-renderer.js +356 -340
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +25 -107
  12. package/src/react.plugin.js +109 -61
  13. package/src/react.types.d.ts +88 -0
  14. package/src/react.types.js +0 -0
  15. package/src/router-adapter.d.ts +7 -14
  16. package/src/runtime/use-sync-external-store-with-selector.d.ts +3 -0
  17. package/src/runtime/use-sync-external-store-with-selector.js +56 -0
  18. package/src/services/pages-index.d.ts +64 -0
  19. package/src/services/pages-index.js +73 -0
  20. package/src/services/react-bundle.service.d.ts +24 -9
  21. package/src/services/react-bundle.service.js +35 -24
  22. package/src/services/react-hmr-page-metadata-cache.d.ts +10 -1
  23. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  24. package/src/services/react-hydration-asset.service.d.ts +28 -19
  25. package/src/services/react-hydration-asset.service.js +83 -64
  26. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  27. package/src/services/react-mdx-config-dependency.service.js +122 -0
  28. package/src/services/react-page-module.service.d.ts +8 -3
  29. package/src/services/react-page-module.service.js +33 -26
  30. package/src/services/react-page-payload.service.d.ts +46 -0
  31. package/src/services/react-page-payload.service.js +67 -0
  32. package/src/services/react-runtime-bundle.service.d.ts +9 -2
  33. package/src/services/react-runtime-bundle.service.js +77 -16
  34. package/src/utils/client-graph-boundary-cache.d.ts +108 -0
  35. package/src/utils/client-graph-boundary-cache.js +116 -0
  36. package/src/utils/client-graph-boundary-plugin.d.ts +13 -5
  37. package/src/utils/client-graph-boundary-plugin.js +63 -5
  38. package/src/utils/component-config-traversal.d.ts +36 -0
  39. package/src/utils/component-config-traversal.js +54 -0
  40. package/src/utils/declared-modules.d.ts +1 -1
  41. package/src/utils/declared-modules.js +7 -16
  42. package/src/utils/dynamic.test.browser.d.ts +1 -0
  43. package/src/utils/dynamic.test.browser.js +33 -0
  44. package/src/utils/hydration-scripts.d.ts +9 -5
  45. package/src/utils/hydration-scripts.js +119 -34
  46. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  47. package/src/utils/hydration-scripts.test.browser.js +198 -0
  48. package/src/utils/react-dom-runtime-interop-plugin.d.ts +1 -1
  49. package/src/utils/react-dom-runtime-interop-plugin.js +9 -0
  50. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  51. package/src/utils/{react-runtime-specifier-map.d.ts → react-runtime-alias-map.d.ts} +3 -1
  52. package/src/utils/react-runtime-alias-map.js +90 -0
  53. package/CHANGELOG.md +0 -27
  54. package/src/react-hmr-strategy.ts +0 -386
  55. package/src/react-renderer.ts +0 -803
  56. package/src/react.plugin.ts +0 -276
  57. package/src/router-adapter.ts +0 -95
  58. package/src/services/react-bundle.service.ts +0 -108
  59. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  60. package/src/services/react-hydration-asset.service.ts +0 -263
  61. package/src/services/react-page-module.service.ts +0 -224
  62. package/src/services/react-runtime-bundle.service.ts +0 -172
  63. package/src/utils/client-graph-boundary-plugin.ts +0 -831
  64. package/src/utils/client-only.ts +0 -27
  65. package/src/utils/declared-modules.ts +0 -99
  66. package/src/utils/dynamic.ts +0 -27
  67. package/src/utils/hmr-scripts.ts +0 -47
  68. package/src/utils/html-boundary.ts +0 -66
  69. package/src/utils/hydration-scripts.ts +0 -459
  70. package/src/utils/reachability-analyzer.ts +0 -593
  71. package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
  72. package/src/utils/react-mdx-loader-plugin.ts +0 -63
  73. package/src/utils/react-runtime-specifier-map.js +0 -37
  74. package/src/utils/react-runtime-specifier-map.ts +0 -45
  75. package/src/utils/use-sync-external-store-shim-plugin.d.ts +0 -5
  76. package/src/utils/use-sync-external-store-shim-plugin.js +0 -41
  77. package/src/utils/use-sync-external-store-shim-plugin.ts +0 -45
@@ -1,803 +0,0 @@
1
- /**
2
- * This module contains the React renderer
3
- * @module
4
- */
5
-
6
- import type {
7
- DependencyAttributes,
8
- ComponentRenderInput,
9
- ComponentRenderResult,
10
- EcoComponent,
11
- EcoComponentConfig,
12
- EcoHtmlComponent,
13
- EcoPageFile,
14
- EcoPageLayoutComponent,
15
- EcoPagesElement,
16
- HtmlTemplateProps,
17
- IntegrationRendererRenderOptions,
18
- PageMetadataProps,
19
- RequestLocals,
20
- RouteRendererBody,
21
- } from '@ecopages/core';
22
- import { IntegrationRenderer, type RenderToResponseContext } from '@ecopages/core/route-renderer/integration-renderer';
23
- import { LocalsAccessError } from '@ecopages/core/errors/locals-access-error';
24
- import { RESOLVED_ASSETS_DIR } from '@ecopages/core/constants';
25
- import { getAppBuildExecutor } from '@ecopages/core/build/build-adapter';
26
- import { rapidhash } from '@ecopages/core/hash';
27
- import type { ProcessedAsset } from '@ecopages/core/services/asset-processing-service';
28
- import { AssetFactory, type AssetDefinition } from '@ecopages/core/services/asset-processing-service';
29
- import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from '@ecopages/core/router/navigation-coordinator';
30
- import path from 'node:path';
31
- import { createElement, type ReactNode } from 'react';
32
- import { renderToReadableStream, renderToString } from 'react-dom/server';
33
- import type { CompileOptions } from '@mdx-js/mdx';
34
- import { PLUGIN_NAME } from './react.plugin.ts';
35
- import type { ReactRouterAdapter } from './router-adapter.ts';
36
- import { hasSingleRootElement } from './utils/html-boundary.ts';
37
- import { ReactBundleService } from './services/react-bundle.service.ts';
38
- import { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.ts';
39
- import { ReactPageModuleService } from './services/react-page-module.service.ts';
40
- import { ReactHydrationAssetService } from './services/react-hydration-asset.service.ts';
41
-
42
- type ReactComponentRenderContext = {
43
- componentInstanceId?: string;
44
- };
45
-
46
- type SerializableProps = Record<string, unknown>;
47
-
48
- type ReactRenderableComponent<P extends SerializableProps = SerializableProps> = React.FunctionComponent<P> & {
49
- config?: EcoComponentConfig;
50
- requires?: string | readonly string[];
51
- };
52
-
53
- type NonReactLayoutProps = {
54
- children: string;
55
- locals?: RequestLocals;
56
- };
57
-
58
- type NonReactHtmlTemplateProps = {
59
- metadata: PageMetadataProps;
60
- pageProps: HtmlTemplateProps['pageProps'];
61
- children: string;
62
- headContent?: string;
63
- };
64
-
65
- type ReactPageModule = EcoPageFile<{ config?: EcoComponentConfig }> & {
66
- config?: EcoComponentConfig;
67
- };
68
-
69
- type RequiresAwareComponent = {
70
- requires?: string | readonly string[];
71
- };
72
-
73
- /**
74
- * Error thrown when an error occurs while rendering a React component.
75
- */
76
- export class ReactRenderError extends Error {
77
- constructor(message: string) {
78
- super(message);
79
- this.name = 'ReactRenderError';
80
- }
81
- }
82
-
83
- /**
84
- * Error thrown when an error occurs while bundling a React component.
85
- */
86
- export class BundleError extends Error {
87
- public readonly logs: string[];
88
-
89
- constructor(message: string, logs: string[]) {
90
- super(message);
91
- this.name = 'BundleError';
92
- this.logs = logs;
93
- }
94
- }
95
-
96
- /**
97
- * Renderer for React components.
98
- * @extends IntegrationRenderer
99
- */
100
- export class ReactRenderer extends IntegrationRenderer<ReactNode> {
101
- name = PLUGIN_NAME;
102
- componentDirectory = RESOLVED_ASSETS_DIR;
103
- static routerAdapter: ReactRouterAdapter | undefined;
104
- static mdxCompilerOptions: CompileOptions | undefined;
105
- static mdxExtensions: string[] = ['.mdx'];
106
- static hmrPageMetadataCache: ReactHmrPageMetadataCache | undefined;
107
- /**
108
- * Enables explicit graph behavior for React page-entry bundling.
109
- *
110
- * When true, page-entry bundles disable AST server-only stripping and rely
111
- * on explicit dependency declarations for browser graph composition.
112
- */
113
- static explicitGraphEnabled = false;
114
-
115
- /** @internal */
116
- readonly bundleService: ReactBundleService;
117
- /** @internal */
118
- readonly pageModuleService: ReactPageModuleService;
119
- /** @internal */
120
- readonly hydrationAssetService: ReactHydrationAssetService;
121
-
122
- constructor(options: {
123
- appConfig: ConstructorParameters<typeof IntegrationRenderer>[0]['appConfig'];
124
- assetProcessingService: ConstructorParameters<typeof IntegrationRenderer>[0]['assetProcessingService'];
125
- resolvedIntegrationDependencies?: ProcessedAsset[];
126
- runtimeOrigin: string;
127
- }) {
128
- super(options);
129
-
130
- this.bundleService = new ReactBundleService({
131
- rootDir: this.appConfig.rootDir,
132
- routerAdapter: ReactRenderer.routerAdapter,
133
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
134
- });
135
-
136
- this.pageModuleService = new ReactPageModuleService({
137
- rootDir: this.appConfig.rootDir,
138
- distDir: this.appConfig.absolutePaths.distDir,
139
- workDir: this.appConfig.absolutePaths.workDir,
140
- buildExecutor: getAppBuildExecutor(this.appConfig),
141
- layoutsDir: this.appConfig.absolutePaths.layoutsDir,
142
- componentsDir: this.appConfig.absolutePaths.componentsDir,
143
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
144
- mdxExtensions: ReactRenderer.mdxExtensions,
145
- integrationName: this.name,
146
- hasRouterAdapter: Boolean(ReactRenderer.routerAdapter),
147
- });
148
-
149
- this.hydrationAssetService = new ReactHydrationAssetService({
150
- srcDir: this.appConfig.srcDir,
151
- routerAdapter: ReactRenderer.routerAdapter,
152
- assetProcessingService: this.assetProcessingService,
153
- bundleService: this.bundleService,
154
- hmrPageMetadataCache: ReactRenderer.hmrPageMetadataCache,
155
- });
156
- }
157
-
158
- protected override shouldRenderPageComponent(): boolean {
159
- return false;
160
- }
161
-
162
- /**
163
- * Reads the declared integration name for a component or layout.
164
- *
165
- * We honor both the explicit `config.integration` override and injected
166
- * `config.__eco.integration` metadata because pages can arrive here through
167
- * authored config as well as build-time component metadata.
168
- */
169
- private getComponentIntegration(component?: { config?: EcoComponentConfig } | null): string | undefined {
170
- return component?.config?.integration ?? component?.config?.__eco?.integration;
171
- }
172
-
173
- /**
174
- * Returns whether a component should stay inside the React render lane.
175
- *
176
- * Components without explicit integration metadata are treated as React-owned
177
- * here because this renderer only receives them after the route pipeline has
178
- * already selected the React integration.
179
- */
180
- private isReactManagedComponent(component?: { config?: EcoComponentConfig } | null): boolean {
181
- const integration = this.getComponentIntegration(component);
182
- return integration === undefined || integration === this.name;
183
- }
184
-
185
- /**
186
- * Creates the canonical page-props payload used by router hydration.
187
- *
188
- * React pages embedded in a non-React HTML shell still need to expose the same
189
- * page-data contract as fully React-owned documents so navigation and hydration
190
- * can read one marker consistently.
191
- */
192
- private buildRouterPageDataScript(pageProps: HtmlTemplateProps['pageProps'] | undefined): string {
193
- const safeJson = JSON.stringify(pageProps || {}).replace(/</g, '\\u003c');
194
- return `<script id="__ECO_PAGE_DATA__" type="application/json">${safeJson}</script>`;
195
- }
196
-
197
- private getRouterDocumentAttributes(): Record<string, string> | undefined {
198
- if (!ReactRenderer.routerAdapter) {
199
- return undefined;
200
- }
201
-
202
- return {
203
- [ECO_DOCUMENT_OWNER_ATTRIBUTE]: 'react-router',
204
- };
205
- }
206
-
207
- /**
208
- * Commits a framework-agnostic component to React semantics.
209
- *
210
- * This is one of the two real cast boundaries in this file. Core keeps
211
- * `EcoComponent` broad so integrations can share the same public surface; once
212
- * the React renderer is executing, `createElement()` needs a concrete React
213
- * component signature.
214
- */
215
- private asReactComponent<P extends SerializableProps>(component: unknown): ReactRenderableComponent<P> {
216
- return component as ReactRenderableComponent<P>;
217
- }
218
-
219
- /**
220
- * Commits a mixed-shell component to the string-returning contract required by
221
- * non-React layouts and HTML templates.
222
- *
223
- * This is the second real cast boundary: once we decide a shell is not managed
224
- * by React, we call it directly and require serialized HTML back.
225
- */
226
- private asNonReactShellComponent<P extends SerializableProps>(
227
- component: unknown,
228
- ): (props: P) => EcoPagesElement | Promise<EcoPagesElement> {
229
- return component as (props: P) => EcoPagesElement | Promise<EcoPagesElement>;
230
- }
231
-
232
- /**
233
- * Builds the serialized page-props payload embedded into the final HTML.
234
- *
235
- * The document payload is intentionally narrower than the full server render
236
- * input: only routing data, public page props, and explicitly allowed locals are
237
- * exposed to the browser.
238
- */
239
- private buildSerializedPageProps(options: {
240
- pageProps?: HtmlTemplateProps['pageProps'];
241
- params: IntegrationRendererRenderOptions<ReactNode>['params'];
242
- query: IntegrationRendererRenderOptions<ReactNode>['query'];
243
- safeLocals?: RequestLocals;
244
- }): HtmlTemplateProps['pageProps'] {
245
- return {
246
- ...options.pageProps,
247
- params: options.params,
248
- query: options.query,
249
- ...(options.safeLocals && { locals: options.safeLocals }),
250
- };
251
- }
252
-
253
- /**
254
- * Appends route hydration assets for a concrete page/view file to the current
255
- * HTML transformer state.
256
- */
257
- private async appendHydrationAssetsForFile(filePath?: string): Promise<void> {
258
- if (!filePath) {
259
- return;
260
- }
261
-
262
- const hydrationAssets = await this.buildRouteRenderAssets(filePath);
263
- this.htmlTransformer.setProcessedDependencies([
264
- ...this.htmlTransformer.getProcessedDependencies(),
265
- ...hydrationAssets,
266
- ]);
267
- }
268
-
269
- /**
270
- * Resolves metadata for direct `renderToResponse()` calls.
271
- *
272
- * View rendering bypasses the normal route-file pipeline, so metadata has to be
273
- * evaluated here from either the component-level generator or the application
274
- * default.
275
- */
276
- private async resolveViewMetadata<P>(view: EcoComponent<P>, props: P): Promise<PageMetadataProps> {
277
- return view.metadata
278
- ? await view.metadata({
279
- params: {},
280
- query: {},
281
- props,
282
- appConfig: this.appConfig,
283
- })
284
- : this.appConfig.defaultMetadata;
285
- }
286
-
287
- /**
288
- * Renders a non-React layout or HTML template and enforces that mixed shells
289
- * return serialized HTML.
290
- *
291
- * The React renderer can compose through another integration's shell, but only
292
- * if that shell yields a string that can be inserted into the final document.
293
- */
294
- private async renderNonReactShellComponent<P extends SerializableProps>(
295
- Component: (props: P) => EcoPagesElement | Promise<EcoPagesElement>,
296
- props: P,
297
- label: 'Layout' | 'HtmlTemplate',
298
- ): Promise<string> {
299
- const output = await Component(props);
300
- if (typeof output === 'string') {
301
- return output;
302
- }
303
-
304
- throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
305
- }
306
-
307
- /**
308
- * Produces the page body before the final HTML template is applied.
309
- *
310
- * This method owns the React/non-React layout split. React-managed layouts stay
311
- * as React elements so they can stream normally; non-React layouts are rendered
312
- * to HTML first and then passed through as serialized content.
313
- */
314
- private async composePageContent(options: {
315
- Page: ReactRenderableComponent<SerializableProps>;
316
- Layout?: EcoPageLayoutComponent<any>;
317
- pageProps: SerializableProps;
318
- locals?: RequestLocals;
319
- }): Promise<{ contentNode: ReactNode; contentHtml: string }> {
320
- const pageElement = createElement(options.Page, options.pageProps);
321
- const pageHtml = renderToString(pageElement);
322
- const layoutProps = options.locals ? { locals: options.locals } : {};
323
-
324
- if (!options.Layout) {
325
- return { contentNode: pageElement, contentHtml: pageHtml };
326
- }
327
-
328
- if (this.isReactManagedComponent(options.Layout)) {
329
- const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
330
- return {
331
- contentNode: layoutElement,
332
- contentHtml: renderToString(layoutElement),
333
- };
334
- }
335
-
336
- const layoutHtml = await this.renderNonReactShellComponent(
337
- this.asNonReactShellComponent<NonReactLayoutProps>(options.Layout),
338
- { ...layoutProps, children: pageHtml },
339
- 'Layout',
340
- );
341
-
342
- return { contentNode: layoutHtml, contentHtml: layoutHtml };
343
- }
344
-
345
- /**
346
- * Wraps composed page content in the final document template.
347
- *
348
- * React-owned HTML templates stream directly. Non-React templates receive
349
- * pre-rendered page HTML plus the canonical React page-data payload so the
350
- * client runtime can recover page data after cross-integration handoff.
351
- */
352
- private async renderDocument(options: {
353
- HtmlTemplate: EcoHtmlComponent<ReactNode>;
354
- metadata: PageMetadataProps;
355
- pageProps: HtmlTemplateProps['pageProps'];
356
- contentNode: ReactNode;
357
- contentHtml: string;
358
- }): Promise<RouteRendererBody> {
359
- if (this.isReactManagedComponent(options.HtmlTemplate)) {
360
- return renderToReadableStream(
361
- createElement(
362
- this.asReactComponent(options.HtmlTemplate),
363
- {
364
- metadata: options.metadata,
365
- pageProps: options.pageProps,
366
- },
367
- options.contentNode,
368
- ),
369
- );
370
- }
371
-
372
- const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : undefined;
373
-
374
- return this.renderNonReactShellComponent(
375
- this.asNonReactShellComponent<NonReactHtmlTemplateProps>(options.HtmlTemplate),
376
- {
377
- metadata: options.metadata,
378
- pageProps: options.pageProps,
379
- children: options.contentHtml,
380
- headContent,
381
- },
382
- 'HtmlTemplate',
383
- );
384
- }
385
-
386
- /**
387
- * Renders a React component for component-level orchestration.
388
- *
389
- * Behavior:
390
- * - SSR always returns the component's own root HTML (no synthetic wrapper).
391
- * - When an explicit component instance id is provided, a stable
392
- * `data-eco-component-id` attribute is attached so island hydration can target it.
393
- * - Without an explicit instance id, component renders remain plain SSR output.
394
- *
395
- * This preserves DOM shape for global CSS/layout selectors while keeping a
396
- * deterministic mount target per component instance.
397
- */
398
- override async renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult> {
399
- const Component = this.asReactComponent(input.component);
400
- const componentConfig = input.component.config;
401
- const element =
402
- input.children === undefined
403
- ? createElement(Component, input.props)
404
- : createElement(Component, input.props, input.children);
405
- let html = renderToString(element);
406
- let canAttachAttributes = hasSingleRootElement(html);
407
- let rootTag = this.getRootTagName(html);
408
- const componentFile = componentConfig?.__eco?.file;
409
- const context = (input.integrationContext as ReactComponentRenderContext | undefined) ?? {};
410
-
411
- let rootAttributes: Record<string, string> | undefined;
412
- let assets: ProcessedAsset[] | undefined;
413
-
414
- if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
415
- const componentInstanceId = context.componentInstanceId;
416
- assets = await this.hydrationAssetService.buildComponentRenderAssets(
417
- componentFile,
418
- componentInstanceId,
419
- input.props,
420
- componentConfig,
421
- );
422
- rootAttributes = {
423
- 'data-eco-component-id': componentInstanceId,
424
- 'data-eco-props': btoa(JSON.stringify(input.props ?? {})),
425
- };
426
- }
427
-
428
- return {
429
- html,
430
- canAttachAttributes,
431
- rootTag,
432
- integrationName: this.name,
433
- rootAttributes,
434
- assets,
435
- };
436
- }
437
-
438
- /**
439
- * Checks if the given file path corresponds to an MDX file based on configured extensions.
440
- * @param filePath - The file path to check
441
- * @returns True if the file is an MDX file
442
- */
443
- public isMdxFile(filePath: string): boolean {
444
- return this.pageModuleService.isMdxFile(filePath);
445
- }
446
-
447
- /**
448
- * Processes MDX-specific configuration dependencies including layout dependencies.
449
- * @param pagePath - Absolute path to the MDX page file
450
- * @returns Processed assets for MDX configuration dependencies
451
- */
452
- private async processMdxConfigDependencies(pagePath: string): Promise<ProcessedAsset[]> {
453
- const { config } = await this.importPageFile(pagePath);
454
- const resolvedLayout = config?.layout;
455
- const components: Partial<EcoComponent>[] = [];
456
-
457
- if (resolvedLayout?.config?.dependencies) {
458
- const layoutConfig = this.pageModuleService.ensureConfigFileMetadata(resolvedLayout.config, pagePath);
459
- components.push({ config: layoutConfig });
460
- }
461
-
462
- if (config?.dependencies) {
463
- const configWithMeta = {
464
- ...config,
465
- __eco: { id: rapidhash(pagePath).toString(36), file: pagePath, integration: 'react' },
466
- };
467
- components.push({ config: configWithMeta });
468
- }
469
-
470
- const processedDependencies = await this.processComponentDependencies(components);
471
- const eagerSsrLazyDependencies = await this.processDeclaredMdxSsrLazyDependencies(components, pagePath);
472
-
473
- return [...processedDependencies, ...eagerSsrLazyDependencies];
474
- }
475
-
476
- private async processDeclaredMdxSsrLazyDependencies(
477
- components: Partial<EcoComponent>[],
478
- pagePath: string,
479
- ): Promise<ProcessedAsset[]> {
480
- if (!this.assetProcessingService?.processDependencies) {
481
- return [];
482
- }
483
-
484
- const dependencies = this.collectDeclaredMdxSsrLazyDependencies(components);
485
- if (dependencies.length === 0) {
486
- return [];
487
- }
488
-
489
- return this.assetProcessingService.processDependencies(dependencies, `react-mdx-ssr-lazy:${pagePath}`);
490
- }
491
-
492
- private collectDeclaredMdxSsrLazyDependencies(components: Partial<EcoComponent>[]): AssetDefinition[] {
493
- const dependencies: AssetDefinition[] = [];
494
- const visitedConfigs = new Set<EcoComponentConfig>();
495
- const seenKeys = new Set<string>();
496
-
497
- const normalizeAttributes = (attributes?: DependencyAttributes) => ({
498
- type: 'module',
499
- defer: '',
500
- ...(attributes ?? {}),
501
- });
502
-
503
- const collect = (config?: EcoComponentConfig) => {
504
- if (!config || visitedConfigs.has(config)) {
505
- return;
506
- }
507
-
508
- visitedConfigs.add(config);
509
-
510
- const componentFile = config.__eco?.file;
511
- if (componentFile) {
512
- const componentDir = path.dirname(componentFile);
513
- for (const script of config.dependencies?.scripts ?? []) {
514
- if (typeof script === 'string' || !script.lazy || script.ssr !== true) {
515
- continue;
516
- }
517
-
518
- const attributes = normalizeAttributes(script.attributes);
519
-
520
- if (script.content) {
521
- const key = `content:${script.content}:${JSON.stringify(attributes)}`;
522
- if (seenKeys.has(key)) {
523
- continue;
524
- }
525
-
526
- seenKeys.add(key);
527
- dependencies.push(
528
- AssetFactory.createContentScript({
529
- position: 'head',
530
- content: script.content,
531
- attributes,
532
- }),
533
- );
534
- continue;
535
- }
536
-
537
- if (!script.src) {
538
- continue;
539
- }
540
-
541
- const resolvedPath = path.resolve(componentDir, script.src);
542
- const key = `file:${resolvedPath}:${JSON.stringify(attributes)}`;
543
- if (seenKeys.has(key)) {
544
- continue;
545
- }
546
-
547
- seenKeys.add(key);
548
- dependencies.push(
549
- AssetFactory.createFileScript({
550
- filepath: resolvedPath,
551
- position: 'head',
552
- attributes,
553
- }),
554
- );
555
- }
556
- }
557
-
558
- if (config.layout?.config) {
559
- collect(config.layout.config);
560
- }
561
-
562
- for (const nestedComponent of config.dependencies?.components ?? []) {
563
- collect(nestedComponent?.config);
564
- }
565
- };
566
-
567
- for (const component of components) {
568
- collect(component.config);
569
- }
570
-
571
- return dependencies;
572
- }
573
-
574
- override async buildRouteRenderAssets(pagePath: string): Promise<ProcessedAsset[]> {
575
- try {
576
- const pageModule = await this.importPageFile(pagePath);
577
- const shouldHydrate = ReactRenderer.explicitGraphEnabled
578
- ? true
579
- : this.pageModuleService.shouldHydratePage(pageModule);
580
- if (!shouldHydrate) {
581
- return [];
582
- }
583
-
584
- const isMdx = this.pageModuleService.isMdxFile(pagePath);
585
- const declaredModules = this.pageModuleService.collectPageDeclaredModules(pageModule);
586
- const processedAssets = await this.hydrationAssetService.buildRouteRenderAssets(
587
- pagePath,
588
- isMdx,
589
- declaredModules,
590
- );
591
-
592
- if (isMdx) {
593
- const mdxConfigAssets = await this.processMdxConfigDependencies(pagePath);
594
- return [...processedAssets, ...mdxConfigAssets];
595
- }
596
-
597
- return processedAssets;
598
- } catch (error) {
599
- if (error instanceof BundleError) {
600
- console.error('[ecopages] Bundle errors:', error.logs);
601
- }
602
-
603
- throw new ReactRenderError(
604
- `Failed to generate hydration script: ${error instanceof Error ? error.message : String(error)}`,
605
- );
606
- }
607
- }
608
-
609
- /**
610
- * Imports a page module while normalizing React MDX modules to the same shape
611
- * as ordinary React page files.
612
- *
613
- * MDX page imports can expose `config` separately from the default export. The
614
- * React renderer reattaches that config to the page component so downstream
615
- * layout, dependency, and hydration logic can treat MDX and TSX pages the same.
616
- */
617
- protected override async importPageFile(file: string): Promise<ReactPageModule> {
618
- const module = (
619
- this.pageModuleService.isMdxFile(file)
620
- ? await this.pageModuleService.importMdxPageFile(file)
621
- : await super.importPageFile(file)
622
- ) as ReactPageModule;
623
- const { default: Page, getMetadata, config } = module;
624
-
625
- if (this.pageModuleService.isMdxFile(file) && config) {
626
- Page.config = config;
627
- }
628
-
629
- return {
630
- default: Page,
631
- getMetadata,
632
- config,
633
- };
634
- }
635
-
636
- /**
637
- * Renders a full route response for the filesystem page pipeline.
638
- *
639
- * This path receives already-resolved route metadata, layout, locals, and HTML
640
- * template instances from the shared renderer orchestration. Its main job is to
641
- * serialize only the browser-safe page payload, compose the mixed React/non-
642
- * React shell tree, and hand the result back as a document body.
643
- */
644
- async render({
645
- params,
646
- query,
647
- props,
648
- locals,
649
- pageLocals,
650
- metadata,
651
- Page,
652
- Layout,
653
- HtmlTemplate,
654
- pageProps,
655
- }: IntegrationRendererRenderOptions<ReactNode>): Promise<RouteRendererBody> {
656
- try {
657
- const safeLocals = this.getSerializableLocals(locals, (Page as RequiresAwareComponent).requires);
658
- const allPageProps = this.buildSerializedPageProps({
659
- pageProps,
660
- params,
661
- query,
662
- safeLocals,
663
- });
664
- const { contentNode, contentHtml } = await this.composePageContent({
665
- Page: this.asReactComponent(Page),
666
- Layout,
667
- pageProps: { params, query, ...props, locals: pageLocals },
668
- locals,
669
- });
670
-
671
- return await this.renderDocument({
672
- HtmlTemplate,
673
- metadata,
674
- pageProps: allPageProps,
675
- contentNode,
676
- contentHtml,
677
- });
678
- } catch (error) {
679
- throw this.createRenderError('Failed to render component', error);
680
- }
681
- }
682
-
683
- protected override getDocumentAttributes(): Record<string, string> | undefined {
684
- return this.getRouterDocumentAttributes();
685
- }
686
-
687
- /**
688
- * Safely extracts the declared subset of locals for client-side hydration.
689
- *
690
- * On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
691
- * request-scoped data (e.g., session). Only keys explicitly declared via
692
- * `Page.requires` are serialized to the client so sensitive request-only data
693
- * is not leaked into hydration payloads by default.
694
- *
695
- * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
696
- * to prevent accidental use. This method safely detects that case and returns
697
- * `undefined` instead of throwing.
698
- *
699
- * @param locals - The locals object from the render context
700
- * @param requiredLocals - Keys explicitly requested for client hydration
701
- * @returns The filtered locals object if serializable, undefined otherwise
702
- */
703
- private getSerializableLocals(
704
- locals: RequestLocals | undefined,
705
- requiredLocals?: string | readonly string[],
706
- ): RequestLocals | undefined {
707
- try {
708
- if (!locals) {
709
- return undefined;
710
- }
711
-
712
- const requiredKeys = requiredLocals
713
- ? Array.isArray(requiredLocals)
714
- ? requiredLocals
715
- : [requiredLocals]
716
- : [];
717
-
718
- if (requiredKeys.length === 0) {
719
- return undefined;
720
- }
721
-
722
- const serializedLocals = Object.fromEntries(
723
- requiredKeys
724
- .filter((key) => Object.prototype.hasOwnProperty.call(locals, key))
725
- .map((key) => [key, locals[key as keyof RequestLocals]]),
726
- ) as RequestLocals;
727
-
728
- if (Object.keys(serializedLocals).length > 0) {
729
- return serializedLocals;
730
- }
731
- return undefined;
732
- } catch (e) {
733
- if (e instanceof LocalsAccessError) {
734
- return undefined;
735
- }
736
- throw e;
737
- }
738
- }
739
-
740
- /**
741
- * Renders an arbitrary React view through the application's HTML shell.
742
- *
743
- * Unlike route rendering, this path starts from a single component rather than a
744
- * page module discovered by the router. It still needs to resolve metadata,
745
- * layout dependencies, and hydration assets so direct `ctx.render()` calls match
746
- * normal page responses.
747
- */
748
- async renderToResponse<P = Record<string, unknown>>(
749
- view: EcoComponent<P>,
750
- props: P,
751
- ctx: RenderToResponseContext,
752
- ): Promise<Response> {
753
- try {
754
- const viewConfig = view.config;
755
- const Layout = viewConfig?.layout;
756
- const ViewComponent = this.asReactComponent(view);
757
- const normalizedProps = (props ?? {}) as SerializableProps;
758
-
759
- if (ctx.partial) {
760
- const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
761
- return this.createHtmlResponse(stream, ctx);
762
- }
763
-
764
- const HtmlTemplate = await this.getHtmlTemplate();
765
- const metadata = await this.resolveViewMetadata(view, props);
766
-
767
- await this.prepareViewDependencies(view, Layout);
768
- await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
769
-
770
- const { contentNode, contentHtml } = await this.composePageContent({
771
- Page: ViewComponent,
772
- Layout,
773
- pageProps: normalizedProps,
774
- });
775
-
776
- const body = await this.renderDocument({
777
- HtmlTemplate,
778
- metadata,
779
- pageProps: normalizedProps,
780
- contentNode,
781
- contentHtml,
782
- });
783
-
784
- const transformedResponse = await this.htmlTransformer.transform(
785
- new Response(body as BodyInit, {
786
- headers: { 'Content-Type': 'text/html' },
787
- }),
788
- );
789
- let transformedHtml = await transformedResponse.text();
790
- const documentAttributes = this.getRouterDocumentAttributes();
791
- if (documentAttributes) {
792
- transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
793
- transformedHtml,
794
- documentAttributes,
795
- );
796
- }
797
-
798
- return this.createHtmlResponse(transformedHtml, ctx);
799
- } catch (error) {
800
- throw this.createRenderError('Failed to render view', error);
801
- }
802
- }
803
- }