@alepha/react 0.14.3 → 0.14.4
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/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.js +959 -194
- package/dist/auth/index.js.map +1 -1
- package/dist/head/index.browser.js +59 -19
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +99 -560
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +91 -87
- package/dist/head/index.js.map +1 -1
- package/dist/router/index.browser.js +30 -15
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +616 -192
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +960 -195
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/core/__tests__/Router.spec.tsx +4 -4
- package/src/head/{__tests__/expandSeo.spec.ts → helpers/SeoExpander.spec.ts} +1 -1
- package/src/head/index.ts +10 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +1 -76
- package/src/head/providers/BrowserHeadProvider.ts +25 -19
- package/src/head/providers/HeadProvider.ts +76 -10
- package/src/head/providers/ServerHeadProvider.ts +22 -138
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/{head → router}/__tests__/seo-head.spec.ts +2 -2
- package/src/router/atoms/ssrManifestAtom.ts +60 -0
- package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
- package/src/router/errors/Redirection.ts +1 -1
- package/src/router/index.shared.ts +1 -0
- package/src/router/index.ts +16 -2
- package/src/router/primitives/$page.browser.spec.tsx +15 -15
- package/src/router/primitives/$page.spec.tsx +18 -18
- package/src/router/primitives/$page.ts +46 -10
- package/src/router/providers/ReactBrowserProvider.ts +14 -29
- package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
- package/src/router/providers/ReactPageProvider.ts +11 -4
- package/src/router/providers/ReactServerProvider.ts +331 -316
- package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
- package/src/router/providers/SSRManifestProvider.ts +365 -0
- package/src/router/services/ReactPageServerService.ts +5 -3
- package/src/router/services/ReactRouter.ts +3 -3
- package/src/head/__tests__/page-head.spec.ts +0 -39
- package/src/head/providers/ServerHeadProvider.spec.ts +0 -163
|
@@ -1,85 +1,56 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import { $atom, $env, $hook, $inject, $use, Alepha, AlephaError, type Static, t, } from "alepha";
|
|
3
|
+
import { FileSystemProvider } from "alepha/file";
|
|
4
4
|
import { $logger } from "alepha/logger";
|
|
5
|
-
import { type ServerHandler,
|
|
5
|
+
import { type ServerHandler, ServerRouterProvider, ServerTimingProvider, } from "alepha/server";
|
|
6
6
|
import { ServerLinksProvider } from "alepha/server/links";
|
|
7
7
|
import { ServerStaticProvider } from "alepha/server/static";
|
|
8
|
-
import {
|
|
8
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
9
|
+
import { ServerHeadProvider } from "@alepha/react/head";
|
|
9
10
|
import { Redirection } from "../errors/Redirection.ts";
|
|
10
11
|
import { $page, type PagePrimitiveRenderOptions, type PagePrimitiveRenderResult, } from "../primitives/$page.ts";
|
|
11
|
-
import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
|
|
12
12
|
import { type PageRoute, ReactPageProvider, type ReactRouterState, } from "./ReactPageProvider.ts";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const envSchema = t.object({
|
|
17
|
-
REACT_SSR_ENABLED: t.optional(t.boolean()),
|
|
18
|
-
REACT_ROOT_ID: t.text({ default: "root" }), // TODO: move to ReactPageProvider.options?
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
declare module "alepha" {
|
|
22
|
-
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
23
|
-
interface State {
|
|
24
|
-
"alepha.react.server.ssr"?: boolean;
|
|
25
|
-
"alepha.react.server.template"?: string;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* React server provider configuration atom
|
|
31
|
-
*/
|
|
32
|
-
export const reactServerOptions = $atom({
|
|
33
|
-
name: "alepha.react.server.options",
|
|
34
|
-
schema: t.object({
|
|
35
|
-
publicDir: t.string(),
|
|
36
|
-
staticServer: t.object({
|
|
37
|
-
disabled: t.boolean(),
|
|
38
|
-
path: t.string({
|
|
39
|
-
description: "URL path where static files will be served.",
|
|
40
|
-
}),
|
|
41
|
-
}),
|
|
42
|
-
}),
|
|
43
|
-
default: {
|
|
44
|
-
publicDir: "public",
|
|
45
|
-
staticServer: {
|
|
46
|
-
disabled: false,
|
|
47
|
-
path: "/",
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export type ReactServerProviderOptions = Static<
|
|
53
|
-
typeof reactServerOptions.schema
|
|
54
|
-
>;
|
|
55
|
-
|
|
56
|
-
declare module "alepha" {
|
|
57
|
-
interface State {
|
|
58
|
-
[reactServerOptions.key]: ReactServerProviderOptions;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ---------------------------------------------------------------------------------------------------------------------
|
|
13
|
+
import { ReactServerTemplateProvider } from "./ReactServerTemplateProvider.ts";
|
|
14
|
+
import { SSRManifestProvider } from "./SSRManifestProvider.ts";
|
|
63
15
|
|
|
64
16
|
/**
|
|
65
17
|
* React server provider responsible for SSR and static file serving.
|
|
66
18
|
*
|
|
67
|
-
*
|
|
19
|
+
* Coordinates between:
|
|
20
|
+
* - ReactPageProvider: Page routing and layer resolution
|
|
21
|
+
* - ReactServerTemplateProvider: HTML template parsing and streaming
|
|
22
|
+
* - ServerHeadProvider: Head content management
|
|
23
|
+
* - SSRManifestProvider: Module preload link collection
|
|
24
|
+
*
|
|
25
|
+
* Uses `react-dom/server` under the hood.
|
|
68
26
|
*/
|
|
69
27
|
export class ReactServerProvider {
|
|
28
|
+
/**
|
|
29
|
+
* SSR response headers - pre-allocated to avoid object creation per request.
|
|
30
|
+
*/
|
|
31
|
+
protected readonly SSR_HEADERS = {
|
|
32
|
+
"content-type": "text/html",
|
|
33
|
+
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
|
34
|
+
pragma: "no-cache",
|
|
35
|
+
expires: "0",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
protected readonly fs = $inject(FileSystemProvider);
|
|
70
39
|
protected readonly log = $logger();
|
|
71
40
|
protected readonly alepha = $inject(Alepha);
|
|
72
41
|
protected readonly env = $env(envSchema);
|
|
73
42
|
protected readonly pageApi = $inject(ReactPageProvider);
|
|
43
|
+
protected readonly templateProvider = $inject(ReactServerTemplateProvider);
|
|
44
|
+
protected readonly serverHeadProvider = $inject(ServerHeadProvider);
|
|
74
45
|
protected readonly serverStaticProvider = $inject(ServerStaticProvider);
|
|
75
46
|
protected readonly serverRouterProvider = $inject(ServerRouterProvider);
|
|
76
47
|
protected readonly serverTimingProvider = $inject(ServerTimingProvider);
|
|
48
|
+
protected readonly ssrManifestProvider = $inject(SSRManifestProvider);
|
|
77
49
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
protected preprocessedTemplate: PreprocessedTemplate | null = null;
|
|
50
|
+
/**
|
|
51
|
+
* Cached check for ServerLinksProvider - avoids has() lookup per request.
|
|
52
|
+
*/
|
|
53
|
+
protected hasServerLinksProvider = false;
|
|
83
54
|
|
|
84
55
|
protected readonly options = $use(reactServerOptions);
|
|
85
56
|
|
|
@@ -96,6 +67,10 @@ export class ReactServerProvider {
|
|
|
96
67
|
|
|
97
68
|
this.alepha.store.set("alepha.react.server.ssr", ssrEnabled);
|
|
98
69
|
|
|
70
|
+
if (ssrEnabled) {
|
|
71
|
+
this.log.info("SSR streaming enabled");
|
|
72
|
+
}
|
|
73
|
+
|
|
99
74
|
// development mode
|
|
100
75
|
if (this.alepha.isViteDev()) {
|
|
101
76
|
await this.configureVite(ssrEnabled);
|
|
@@ -107,7 +82,7 @@ export class ReactServerProvider {
|
|
|
107
82
|
|
|
108
83
|
// non-serverless mode only -> serve static files
|
|
109
84
|
if (!this.alepha.isServerless()) {
|
|
110
|
-
root = this.getPublicDirectory();
|
|
85
|
+
root = await this.getPublicDirectory();
|
|
111
86
|
if (!root) {
|
|
112
87
|
this.log.warn(
|
|
113
88
|
"Missing static files, static file server will be disabled",
|
|
@@ -146,27 +121,39 @@ export class ReactServerProvider {
|
|
|
146
121
|
},
|
|
147
122
|
});
|
|
148
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Get the current HTML template.
|
|
126
|
+
*/
|
|
149
127
|
public get template() {
|
|
150
128
|
return (
|
|
151
129
|
this.alepha.store.get("alepha.react.server.template") ??
|
|
152
|
-
"<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
|
|
130
|
+
"<!DOCTYPE html><html lang='en'><head></head><body><div id='root'></div></body></html>"
|
|
153
131
|
);
|
|
154
132
|
}
|
|
155
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Register all pages as server routes.
|
|
136
|
+
*/
|
|
156
137
|
protected async registerPages(templateLoader: TemplateLoader) {
|
|
157
|
-
//
|
|
138
|
+
// Parse template once at startup
|
|
158
139
|
const template = await templateLoader();
|
|
159
140
|
if (template) {
|
|
160
|
-
this.
|
|
141
|
+
this.templateProvider.parseTemplate(template);
|
|
161
142
|
}
|
|
162
143
|
|
|
144
|
+
// Set up early head content (entry assets preloads)
|
|
145
|
+
this.setupEarlyHeadContent();
|
|
146
|
+
|
|
147
|
+
// Cache ServerLinksProvider check at startup
|
|
148
|
+
this.hasServerLinksProvider = this.alepha.has(ServerLinksProvider);
|
|
149
|
+
|
|
163
150
|
for (const page of this.pageApi.getPages()) {
|
|
164
151
|
if (page.component || page.lazy) {
|
|
165
152
|
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
166
153
|
|
|
167
154
|
this.serverRouterProvider.createRoute({
|
|
168
155
|
...page,
|
|
169
|
-
schema: undefined, // schema is handled by the page primitive provider
|
|
156
|
+
schema: undefined, // schema is handled by the page primitive provider
|
|
170
157
|
method: "GET",
|
|
171
158
|
path: page.match,
|
|
172
159
|
handler: this.createHandler(page, templateLoader),
|
|
@@ -175,17 +162,63 @@ export class ReactServerProvider {
|
|
|
175
162
|
}
|
|
176
163
|
}
|
|
177
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Set up early head content with entry assets.
|
|
167
|
+
*
|
|
168
|
+
* This content is sent immediately when streaming starts, before page loaders run,
|
|
169
|
+
* allowing the browser to start downloading entry.js and CSS files early.
|
|
170
|
+
*
|
|
171
|
+
* Uses <script type="module"> instead of <link rel="modulepreload"> for JS
|
|
172
|
+
* because the script needs to execute anyway - this way the browser starts
|
|
173
|
+
* downloading, parsing, AND will execute as soon as ready.
|
|
174
|
+
*
|
|
175
|
+
* Also strips these assets from the original template head to avoid duplicates.
|
|
176
|
+
*/
|
|
177
|
+
protected setupEarlyHeadContent(): void {
|
|
178
|
+
const assets = this.ssrManifestProvider.getEntryAssets();
|
|
179
|
+
if (!assets) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parts: string[] = [];
|
|
184
|
+
|
|
185
|
+
// Add CSS stylesheets (critical for rendering)
|
|
186
|
+
for (const css of assets.css) {
|
|
187
|
+
parts.push(`<link rel="stylesheet" href="${css}" crossorigin="">`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Add entry JS as script module (not just modulepreload)
|
|
191
|
+
// This starts download, parse, AND execution immediately
|
|
192
|
+
if (assets.js) {
|
|
193
|
+
parts.push(
|
|
194
|
+
`<script type="module" crossorigin="" src="${assets.js}"></script>`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (parts.length > 0) {
|
|
199
|
+
// Pass assets so they get stripped from original head content
|
|
200
|
+
this.templateProvider.setEarlyHeadContent(
|
|
201
|
+
parts.join("\n") + "\n",
|
|
202
|
+
assets,
|
|
203
|
+
);
|
|
204
|
+
this.log.debug("Early head content set", {
|
|
205
|
+
css: assets.css.length,
|
|
206
|
+
js: assets.js ? 1 : 0,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
178
211
|
/**
|
|
179
212
|
* Get the public directory path where static files are located.
|
|
180
213
|
*/
|
|
181
|
-
protected getPublicDirectory(): string {
|
|
214
|
+
protected async getPublicDirectory(): Promise<string> {
|
|
182
215
|
const maybe = [
|
|
183
216
|
join(process.cwd(), `dist/${this.options.publicDir}`),
|
|
184
217
|
join(process.cwd(), this.options.publicDir),
|
|
185
218
|
];
|
|
186
219
|
|
|
187
220
|
for (const it of maybe) {
|
|
188
|
-
if (
|
|
221
|
+
if (await this.fs.exists(it)) {
|
|
189
222
|
return it;
|
|
190
223
|
}
|
|
191
224
|
}
|
|
@@ -208,7 +241,7 @@ export class ReactServerProvider {
|
|
|
208
241
|
}
|
|
209
242
|
|
|
210
243
|
/**
|
|
211
|
-
* Configure Vite for SSR.
|
|
244
|
+
* Configure Vite for SSR in development mode.
|
|
212
245
|
*/
|
|
213
246
|
protected async configureVite(ssrEnabled: boolean) {
|
|
214
247
|
if (!ssrEnabled) {
|
|
@@ -216,8 +249,7 @@ export class ReactServerProvider {
|
|
|
216
249
|
return;
|
|
217
250
|
}
|
|
218
251
|
|
|
219
|
-
const
|
|
220
|
-
const url = `http://localhost:${env.SERVER_PORT ?? "5173"}`;
|
|
252
|
+
const url = `http://localhost:${this.alepha.env.SERVER_PORT ?? "5173"}`;
|
|
221
253
|
|
|
222
254
|
this.log.info("SSR (dev) OK", { url });
|
|
223
255
|
|
|
@@ -229,95 +261,41 @@ export class ReactServerProvider {
|
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
/**
|
|
232
|
-
*
|
|
264
|
+
* Create the request handler for a page route.
|
|
233
265
|
*/
|
|
234
|
-
public async render(
|
|
235
|
-
name: string,
|
|
236
|
-
options: PagePrimitiveRenderOptions = {},
|
|
237
|
-
): Promise<PagePrimitiveRenderResult> {
|
|
238
|
-
const page = this.pageApi.page(name);
|
|
239
|
-
const url = new URL(this.pageApi.url(name, options));
|
|
240
|
-
const entry: Partial<ReactRouterState> = {
|
|
241
|
-
url,
|
|
242
|
-
params: options.params ?? {},
|
|
243
|
-
query: options.query ?? {},
|
|
244
|
-
onError: () => null,
|
|
245
|
-
layers: [],
|
|
246
|
-
meta: {},
|
|
247
|
-
};
|
|
248
|
-
const state = entry as ReactRouterState;
|
|
249
|
-
|
|
250
|
-
this.log.trace("Rendering", {
|
|
251
|
-
url,
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
await this.alepha.events.emit("react:server:render:begin", {
|
|
255
|
-
state,
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const { redirect } = await this.pageApi.createLayers(
|
|
259
|
-
page,
|
|
260
|
-
state as ReactRouterState,
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
if (redirect) {
|
|
264
|
-
return { state, html: "", redirect };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (!options.html) {
|
|
268
|
-
this.alepha.store.set("alepha.react.router.state", state);
|
|
269
|
-
|
|
270
|
-
return {
|
|
271
|
-
state,
|
|
272
|
-
html: renderToString(this.pageApi.root(state)),
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const template = this.template ?? "";
|
|
277
|
-
const html = this.renderToHtml(template, state, options.hydration);
|
|
278
|
-
|
|
279
|
-
if (html instanceof Redirection) {
|
|
280
|
-
return { state, html: "", redirect };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const result = {
|
|
284
|
-
state,
|
|
285
|
-
html,
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
await this.alepha.events.emit("react:server:render:end", result);
|
|
289
|
-
|
|
290
|
-
return result;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
266
|
protected createHandler(
|
|
294
267
|
route: PageRoute,
|
|
295
268
|
templateLoader: TemplateLoader,
|
|
296
269
|
): ServerHandler {
|
|
297
270
|
return async (serverRequest) => {
|
|
298
271
|
const { url, reply, query, params } = serverRequest;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
272
|
+
|
|
273
|
+
// Ensure template is parsed (handles dev mode where template may change)
|
|
274
|
+
if (!this.templateProvider.isReady()) {
|
|
275
|
+
const template = await templateLoader();
|
|
276
|
+
if (!template) {
|
|
277
|
+
throw new AlephaError("Missing template for SSR rendering");
|
|
278
|
+
}
|
|
279
|
+
this.templateProvider.parseTemplate(template);
|
|
280
|
+
this.setupEarlyHeadContent();
|
|
302
281
|
}
|
|
303
282
|
|
|
304
|
-
this.log.trace("Rendering page", {
|
|
305
|
-
name: route.name,
|
|
306
|
-
});
|
|
283
|
+
this.log.trace("Rendering page", { name: route.name });
|
|
307
284
|
|
|
308
|
-
|
|
285
|
+
// Initialize router state
|
|
286
|
+
const state: ReactRouterState = {
|
|
309
287
|
url,
|
|
310
288
|
params,
|
|
311
289
|
query,
|
|
290
|
+
name: route.name,
|
|
312
291
|
onError: () => null,
|
|
313
292
|
layers: [],
|
|
293
|
+
meta: {},
|
|
294
|
+
head: {},
|
|
314
295
|
};
|
|
315
296
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
state.name = route.name;
|
|
319
|
-
|
|
320
|
-
if (this.alepha.has(ServerLinksProvider)) {
|
|
297
|
+
// Set up API links if available
|
|
298
|
+
if (this.hasServerLinksProvider) {
|
|
321
299
|
this.alepha.store.set(
|
|
322
300
|
"alepha.server.request.apiLinks",
|
|
323
301
|
await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
@@ -327,13 +305,13 @@ export class ReactServerProvider {
|
|
|
327
305
|
);
|
|
328
306
|
}
|
|
329
307
|
|
|
330
|
-
|
|
308
|
+
// Check access permissions
|
|
309
|
+
let target: PageRoute | undefined = route;
|
|
331
310
|
while (target) {
|
|
332
311
|
if (route.can && !route.can()) {
|
|
333
312
|
this.log.warn(
|
|
334
313
|
`Access to page '${route.name}' is forbidden by can() check`,
|
|
335
|
-
)
|
|
336
|
-
// if the page is not accessible, return 403
|
|
314
|
+
);
|
|
337
315
|
reply.status = 403;
|
|
338
316
|
reply.headers["content-type"] = "text/plain";
|
|
339
317
|
return "Forbidden";
|
|
@@ -341,220 +319,257 @@ export class ReactServerProvider {
|
|
|
341
319
|
target = target.parent;
|
|
342
320
|
}
|
|
343
321
|
|
|
344
|
-
// TODO: SSR strategies
|
|
345
|
-
// - only when googlebot
|
|
346
|
-
// - only child pages
|
|
347
|
-
// if (page.client) {
|
|
348
|
-
// // if the page is a client-only page, return 404
|
|
349
|
-
// reply.status = 200;
|
|
350
|
-
// reply.headers["content-type"] = "text/html";
|
|
351
|
-
// reply.body = template;
|
|
352
|
-
// return;
|
|
353
|
-
// }
|
|
354
|
-
|
|
355
322
|
await this.alepha.events.emit("react:server:render:begin", {
|
|
356
323
|
request: serverRequest,
|
|
357
324
|
state,
|
|
358
325
|
});
|
|
359
326
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
363
|
-
|
|
364
|
-
this.serverTimingProvider.endTiming("createLayers");
|
|
365
|
-
|
|
366
|
-
if (redirect) {
|
|
367
|
-
this.log.debug("Resolver resulted in redirection", {
|
|
368
|
-
redirect,
|
|
369
|
-
});
|
|
370
|
-
return reply.redirect(redirect);
|
|
371
|
-
}
|
|
327
|
+
// Apply SSR headers early
|
|
328
|
+
Object.assign(reply.headers, this.SSR_HEADERS);
|
|
372
329
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// by default, disable caching for SSR responses
|
|
376
|
-
// some plugins may override this
|
|
377
|
-
reply.headers["cache-control"] =
|
|
378
|
-
"no-store, no-cache, must-revalidate, proxy-revalidate";
|
|
379
|
-
reply.headers.pragma = "no-cache";
|
|
380
|
-
reply.headers.expires = "0";
|
|
381
|
-
|
|
382
|
-
const html = this.renderToHtml(template, state);
|
|
383
|
-
if (html instanceof Redirection) {
|
|
384
|
-
reply.redirect(
|
|
385
|
-
typeof html.redirect === "string"
|
|
386
|
-
? html.redirect
|
|
387
|
-
: this.pageApi.href(html.redirect),
|
|
388
|
-
);
|
|
389
|
-
this.log.debug("Rendering resulted in redirection", {
|
|
390
|
-
redirect: html.redirect,
|
|
391
|
-
});
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
330
|
+
// Resolve global head for early streaming (htmlAttributes only)
|
|
331
|
+
const globalHead = this.serverHeadProvider.resolveGlobalHead();
|
|
394
332
|
|
|
395
|
-
|
|
333
|
+
// Create optimized HTML stream with early head
|
|
334
|
+
const htmlStream = this.templateProvider.createEarlyHtmlStream(
|
|
335
|
+
globalHead,
|
|
336
|
+
async () => {
|
|
337
|
+
// === ASYNC WORK (runs while early head is being sent) ===
|
|
338
|
+
const result = await this.renderPage(route, state);
|
|
396
339
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
340
|
+
if (result.redirect) {
|
|
341
|
+
// Note: redirect happens after early head is sent, handled by stream
|
|
342
|
+
reply.redirect(result.redirect);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
402
345
|
|
|
403
|
-
|
|
346
|
+
return { state, reactStream: result.reactStream! };
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
hydration: true,
|
|
350
|
+
onError: (error) => {
|
|
351
|
+
if (error instanceof Redirection) {
|
|
352
|
+
this.log.debug("Streaming resulted in redirection", {
|
|
353
|
+
redirect: error.redirect,
|
|
354
|
+
});
|
|
355
|
+
// Can't do redirect after streaming started - already handled above
|
|
356
|
+
} else {
|
|
357
|
+
this.log.error("HTML stream error", error);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
);
|
|
404
362
|
|
|
363
|
+
this.log.trace("Page streaming started (early head optimization)");
|
|
405
364
|
route.onServerResponse?.(serverRequest);
|
|
406
|
-
|
|
407
|
-
this.log.trace("Page rendered", {
|
|
408
|
-
name: route.name,
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
return event.html;
|
|
365
|
+
reply.body = htmlStream;
|
|
412
366
|
};
|
|
413
367
|
}
|
|
414
368
|
|
|
415
|
-
|
|
416
|
-
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Core rendering logic - shared between SSR handler and static prerendering
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Core page rendering logic shared between SSR handler and static prerendering.
|
|
375
|
+
*
|
|
376
|
+
* Handles:
|
|
377
|
+
* - Layer resolution (loaders)
|
|
378
|
+
* - Redirect detection
|
|
379
|
+
* - Head content filling
|
|
380
|
+
* - Preload link collection
|
|
381
|
+
* - React stream rendering
|
|
382
|
+
*
|
|
383
|
+
* @param route - The page route to render
|
|
384
|
+
* @param state - The router state
|
|
385
|
+
* @returns Render result with redirect or React stream
|
|
386
|
+
*/
|
|
387
|
+
protected async renderPage(
|
|
388
|
+
route: PageRoute,
|
|
417
389
|
state: ReactRouterState,
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
390
|
+
): Promise<{ redirect?: string; reactStream?: ReadableStream<Uint8Array> }> {
|
|
391
|
+
// Resolve page layers (loaders)
|
|
392
|
+
this.serverTimingProvider.beginTiming("createLayers");
|
|
393
|
+
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
394
|
+
this.serverTimingProvider.endTiming("createLayers");
|
|
421
395
|
|
|
422
|
-
|
|
423
|
-
|
|
396
|
+
if (redirect) {
|
|
397
|
+
this.log.debug("Resolver resulted in redirection", { redirect });
|
|
398
|
+
return { redirect };
|
|
399
|
+
}
|
|
424
400
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
try {
|
|
428
|
-
app = renderToString(element);
|
|
429
|
-
} catch (error) {
|
|
430
|
-
this.log.error(
|
|
431
|
-
"renderToString has failed, fallback to error handler",
|
|
432
|
-
error,
|
|
433
|
-
);
|
|
434
|
-
const element = state.onError(error as Error, state);
|
|
435
|
-
if (element instanceof Redirection) {
|
|
436
|
-
// if the error is a redirection, return the redirection URL
|
|
437
|
-
return element;
|
|
438
|
-
}
|
|
401
|
+
// Fill head from route config
|
|
402
|
+
this.serverHeadProvider.fillHead(state);
|
|
439
403
|
|
|
440
|
-
|
|
441
|
-
|
|
404
|
+
// Collect and inject modulepreload links for page-specific chunks
|
|
405
|
+
const preloadLinks = this.ssrManifestProvider.collectPreloadLinks(route);
|
|
406
|
+
if (preloadLinks.length > 0) {
|
|
407
|
+
state.head ??= {};
|
|
408
|
+
state.head.link = [...(state.head.link ?? []), ...preloadLinks];
|
|
442
409
|
}
|
|
443
|
-
this.serverTimingProvider.endTiming("renderToString");
|
|
444
410
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
};
|
|
411
|
+
// Render React to stream
|
|
412
|
+
this.serverTimingProvider.beginTiming("renderToStream");
|
|
448
413
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
this.alepha.context.als?.getStore() ?? {}; /// TODO: als must be protected, find a way to iterate on alepha.state
|
|
452
|
-
|
|
453
|
-
const hydrationData: ReactHydrationState = {
|
|
454
|
-
...store,
|
|
455
|
-
// map react.router.state to the hydration state
|
|
456
|
-
"alepha.react.router.state": undefined,
|
|
457
|
-
layers: state.layers.map((it) => ({
|
|
458
|
-
...it,
|
|
459
|
-
error: it.error
|
|
460
|
-
? {
|
|
461
|
-
...it.error,
|
|
462
|
-
name: it.error.name,
|
|
463
|
-
message: it.error.message,
|
|
464
|
-
stack: !this.alepha.isProduction() ? it.error.stack : undefined,
|
|
465
|
-
}
|
|
466
|
-
: undefined,
|
|
467
|
-
index: undefined,
|
|
468
|
-
path: undefined,
|
|
469
|
-
element: undefined,
|
|
470
|
-
route: undefined,
|
|
471
|
-
})),
|
|
472
|
-
};
|
|
414
|
+
const element = this.pageApi.root(state);
|
|
415
|
+
this.alepha.store.set("alepha.react.router.state", state);
|
|
473
416
|
|
|
474
|
-
|
|
475
|
-
|
|
417
|
+
const reactStream = await renderToReadableStream(element, {
|
|
418
|
+
onError: (error: unknown) => {
|
|
419
|
+
if (error instanceof Redirection) {
|
|
420
|
+
this.log.warn("Redirect during streaming ignored", {
|
|
421
|
+
redirect: error.redirect,
|
|
422
|
+
});
|
|
423
|
+
} else {
|
|
424
|
+
this.log.error("Streaming render error", error);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
});
|
|
476
428
|
|
|
477
|
-
|
|
478
|
-
this.fillTemplate(response, app, script);
|
|
479
|
-
}
|
|
429
|
+
this.serverTimingProvider.endTiming("renderToStream");
|
|
480
430
|
|
|
481
|
-
return
|
|
431
|
+
return { reactStream };
|
|
482
432
|
}
|
|
483
433
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
|
|
488
|
-
|
|
489
|
-
const beforeScript = template.substring(0, bodyCloseIndex);
|
|
490
|
-
const afterScript = template.substring(bodyCloseIndex);
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Testing utilities - kept for backwards compatibility with tests
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
491
437
|
|
|
492
|
-
|
|
493
|
-
|
|
438
|
+
/**
|
|
439
|
+
* For testing purposes, renders a page to HTML string.
|
|
440
|
+
* Uses the same streaming code path as production, then collects to string.
|
|
441
|
+
*
|
|
442
|
+
* @param name - Page name to render
|
|
443
|
+
* @param options - Render options (params, query, html, hydration)
|
|
444
|
+
*/
|
|
445
|
+
public async render(
|
|
446
|
+
name: string,
|
|
447
|
+
options: PagePrimitiveRenderOptions = {},
|
|
448
|
+
): Promise<PagePrimitiveRenderResult> {
|
|
449
|
+
const page = this.pageApi.page(name);
|
|
450
|
+
const url = new URL(this.pageApi.url(name, options));
|
|
451
|
+
const state: ReactRouterState = {
|
|
452
|
+
url,
|
|
453
|
+
params: options.params ?? {},
|
|
454
|
+
query: options.query ?? {},
|
|
455
|
+
onError: () => null,
|
|
456
|
+
layers: [],
|
|
457
|
+
meta: {},
|
|
458
|
+
head: {},
|
|
459
|
+
};
|
|
494
460
|
|
|
495
|
-
|
|
496
|
-
// Split around the existing root div content
|
|
497
|
-
const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
|
|
498
|
-
const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
|
|
499
|
-
const afterDiv = beforeScript.substring(afterDivStart);
|
|
461
|
+
this.log.trace("Rendering", { url });
|
|
500
462
|
|
|
501
|
-
|
|
502
|
-
const afterApp = `</div>${afterDiv}`;
|
|
463
|
+
await this.alepha.events.emit("react:server:render:begin", { state });
|
|
503
464
|
|
|
504
|
-
|
|
465
|
+
// Ensure template is parsed with early head content (entry.js, CSS)
|
|
466
|
+
if (!this.templateProvider.isReady()) {
|
|
467
|
+
this.templateProvider.parseTemplate(this.template);
|
|
468
|
+
this.setupEarlyHeadContent();
|
|
505
469
|
}
|
|
506
470
|
|
|
507
|
-
//
|
|
508
|
-
const
|
|
509
|
-
if (bodyMatch) {
|
|
510
|
-
const beforeBody = beforeScript.substring(
|
|
511
|
-
0,
|
|
512
|
-
bodyMatch.index! + bodyMatch[0].length,
|
|
513
|
-
);
|
|
514
|
-
const afterBody = beforeScript.substring(
|
|
515
|
-
bodyMatch.index! + bodyMatch[0].length,
|
|
516
|
-
);
|
|
471
|
+
// Use shared rendering logic
|
|
472
|
+
const result = await this.renderPage(page, state);
|
|
517
473
|
|
|
518
|
-
|
|
519
|
-
|
|
474
|
+
if (result.redirect) {
|
|
475
|
+
return { state, html: "", redirect: result.redirect };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const reactStream = result.reactStream!;
|
|
520
479
|
|
|
521
|
-
|
|
480
|
+
// If full HTML page not requested, collect just the React content
|
|
481
|
+
if (!options.html) {
|
|
482
|
+
const html = await this.streamToString(reactStream);
|
|
483
|
+
return { state, html };
|
|
522
484
|
}
|
|
523
485
|
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
486
|
+
// Create full HTML stream and collect to string
|
|
487
|
+
const htmlStream = this.templateProvider.createHtmlStream(
|
|
488
|
+
reactStream,
|
|
489
|
+
state,
|
|
490
|
+
{ hydration: options.hydration ?? true },
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const html = await this.streamToString(htmlStream);
|
|
494
|
+
|
|
495
|
+
await this.alepha.events.emit("react:server:render:end", { state, html });
|
|
496
|
+
|
|
497
|
+
return { state, html };
|
|
531
498
|
}
|
|
532
499
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
500
|
+
/**
|
|
501
|
+
* Collect a ReadableStream into a string.
|
|
502
|
+
*/
|
|
503
|
+
protected async streamToString(
|
|
504
|
+
stream: ReadableStream<Uint8Array>,
|
|
505
|
+
): Promise<string> {
|
|
506
|
+
const reader = stream.getReader();
|
|
507
|
+
const decoder = new TextDecoder();
|
|
508
|
+
const chunks: string[] = [];
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
while (true) {
|
|
512
|
+
const { done, value } = await reader.read();
|
|
513
|
+
if (done) break;
|
|
514
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
515
|
+
}
|
|
516
|
+
chunks.push(decoder.decode()); // Flush remaining
|
|
517
|
+
} finally {
|
|
518
|
+
reader.releaseLock();
|
|
541
519
|
}
|
|
542
520
|
|
|
543
|
-
|
|
544
|
-
response.html =
|
|
545
|
-
this.preprocessedTemplate.beforeApp +
|
|
546
|
-
app +
|
|
547
|
-
this.preprocessedTemplate.afterApp +
|
|
548
|
-
script +
|
|
549
|
-
this.preprocessedTemplate.afterScript;
|
|
521
|
+
return chunks.join("");
|
|
550
522
|
}
|
|
551
523
|
}
|
|
552
524
|
|
|
525
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
526
|
+
|
|
553
527
|
type TemplateLoader = () => Promise<string | undefined>;
|
|
554
528
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
const envSchema = t.object({
|
|
533
|
+
REACT_SSR_ENABLED: t.optional(t.boolean()),
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
declare module "alepha" {
|
|
537
|
+
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
538
|
+
interface State {
|
|
539
|
+
"alepha.react.server.ssr"?: boolean;
|
|
540
|
+
"alepha.react.server.template"?: string;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* React server provider configuration atom
|
|
546
|
+
*/
|
|
547
|
+
export const reactServerOptions = $atom({
|
|
548
|
+
name: "alepha.react.server.options",
|
|
549
|
+
schema: t.object({
|
|
550
|
+
publicDir: t.string(),
|
|
551
|
+
staticServer: t.object({
|
|
552
|
+
disabled: t.boolean(),
|
|
553
|
+
path: t.string({
|
|
554
|
+
description: "URL path where static files will be served.",
|
|
555
|
+
}),
|
|
556
|
+
}),
|
|
557
|
+
}),
|
|
558
|
+
default: {
|
|
559
|
+
publicDir: "public",
|
|
560
|
+
staticServer: {
|
|
561
|
+
disabled: false,
|
|
562
|
+
path: "/",
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
export type ReactServerProviderOptions = Static<
|
|
568
|
+
typeof reactServerOptions.schema
|
|
569
|
+
>;
|
|
570
|
+
|
|
571
|
+
declare module "alepha" {
|
|
572
|
+
interface State {
|
|
573
|
+
[reactServerOptions.key]: ReactServerProviderOptions;
|
|
574
|
+
}
|
|
560
575
|
}
|