@alepha/react 0.14.2 → 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 +960 -195
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -4
- package/dist/core/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 +92 -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 +961 -196
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/__tests__/$auth.spec.ts +188 -0
- package/src/core/__tests__/Router.spec.tsx +169 -0
- package/src/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/core/hooks/useAction.ts +11 -0
- package/src/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/head/helpers/SeoExpander.spec.ts +203 -0
- package/src/head/hooks/useHead.spec.tsx +288 -0
- package/src/head/index.ts +11 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
- 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/i18n/__tests__/integration.spec.tsx +239 -0
- package/src/i18n/components/Localize.spec.tsx +357 -0
- package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
- package/src/i18n/providers/I18nProvider.spec.ts +389 -0
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/router/__tests__/seo-head.spec.ts +121 -0
- 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 +702 -0
- package/src/router/primitives/$page.spec.tsx +702 -0
- 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.spec.tsx +316 -0
- package/src/router/providers/ReactServerProvider.ts +331 -315
- 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
|
@@ -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
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,9 +249,9 @@ export class ReactServerProvider {
|
|
|
216
249
|
return;
|
|
217
250
|
}
|
|
218
251
|
|
|
219
|
-
this.
|
|
252
|
+
const url = `http://localhost:${this.alepha.env.SERVER_PORT ?? "5173"}`;
|
|
220
253
|
|
|
221
|
-
|
|
254
|
+
this.log.info("SSR (dev) OK", { url });
|
|
222
255
|
|
|
223
256
|
await this.registerPages(() =>
|
|
224
257
|
fetch(`${url}/index.html`)
|
|
@@ -228,95 +261,41 @@ export class ReactServerProvider {
|
|
|
228
261
|
}
|
|
229
262
|
|
|
230
263
|
/**
|
|
231
|
-
*
|
|
264
|
+
* Create the request handler for a page route.
|
|
232
265
|
*/
|
|
233
|
-
public async render(
|
|
234
|
-
name: string,
|
|
235
|
-
options: PagePrimitiveRenderOptions = {},
|
|
236
|
-
): Promise<PagePrimitiveRenderResult> {
|
|
237
|
-
const page = this.pageApi.page(name);
|
|
238
|
-
const url = new URL(this.pageApi.url(name, options));
|
|
239
|
-
const entry: Partial<ReactRouterState> = {
|
|
240
|
-
url,
|
|
241
|
-
params: options.params ?? {},
|
|
242
|
-
query: options.query ?? {},
|
|
243
|
-
onError: () => null,
|
|
244
|
-
layers: [],
|
|
245
|
-
meta: {},
|
|
246
|
-
};
|
|
247
|
-
const state = entry as ReactRouterState;
|
|
248
|
-
|
|
249
|
-
this.log.trace("Rendering", {
|
|
250
|
-
url,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
await this.alepha.events.emit("react:server:render:begin", {
|
|
254
|
-
state,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const { redirect } = await this.pageApi.createLayers(
|
|
258
|
-
page,
|
|
259
|
-
state as ReactRouterState,
|
|
260
|
-
);
|
|
261
|
-
|
|
262
|
-
if (redirect) {
|
|
263
|
-
return { state, html: "", redirect };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (!options.html) {
|
|
267
|
-
this.alepha.store.set("alepha.react.router.state", state);
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
state,
|
|
271
|
-
html: renderToString(this.pageApi.root(state)),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const template = this.template ?? "";
|
|
276
|
-
const html = this.renderToHtml(template, state, options.hydration);
|
|
277
|
-
|
|
278
|
-
if (html instanceof Redirection) {
|
|
279
|
-
return { state, html: "", redirect };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const result = {
|
|
283
|
-
state,
|
|
284
|
-
html,
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
await this.alepha.events.emit("react:server:render:end", result);
|
|
288
|
-
|
|
289
|
-
return result;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
266
|
protected createHandler(
|
|
293
267
|
route: PageRoute,
|
|
294
268
|
templateLoader: TemplateLoader,
|
|
295
269
|
): ServerHandler {
|
|
296
270
|
return async (serverRequest) => {
|
|
297
271
|
const { url, reply, query, params } = serverRequest;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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();
|
|
301
281
|
}
|
|
302
282
|
|
|
303
|
-
this.log.trace("Rendering page", {
|
|
304
|
-
name: route.name,
|
|
305
|
-
});
|
|
283
|
+
this.log.trace("Rendering page", { name: route.name });
|
|
306
284
|
|
|
307
|
-
|
|
285
|
+
// Initialize router state
|
|
286
|
+
const state: ReactRouterState = {
|
|
308
287
|
url,
|
|
309
288
|
params,
|
|
310
289
|
query,
|
|
290
|
+
name: route.name,
|
|
311
291
|
onError: () => null,
|
|
312
292
|
layers: [],
|
|
293
|
+
meta: {},
|
|
294
|
+
head: {},
|
|
313
295
|
};
|
|
314
296
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
state.name = route.name;
|
|
318
|
-
|
|
319
|
-
if (this.alepha.has(ServerLinksProvider)) {
|
|
297
|
+
// Set up API links if available
|
|
298
|
+
if (this.hasServerLinksProvider) {
|
|
320
299
|
this.alepha.store.set(
|
|
321
300
|
"alepha.server.request.apiLinks",
|
|
322
301
|
await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
@@ -326,13 +305,13 @@ export class ReactServerProvider {
|
|
|
326
305
|
);
|
|
327
306
|
}
|
|
328
307
|
|
|
329
|
-
|
|
308
|
+
// Check access permissions
|
|
309
|
+
let target: PageRoute | undefined = route;
|
|
330
310
|
while (target) {
|
|
331
311
|
if (route.can && !route.can()) {
|
|
332
312
|
this.log.warn(
|
|
333
313
|
`Access to page '${route.name}' is forbidden by can() check`,
|
|
334
|
-
)
|
|
335
|
-
// if the page is not accessible, return 403
|
|
314
|
+
);
|
|
336
315
|
reply.status = 403;
|
|
337
316
|
reply.headers["content-type"] = "text/plain";
|
|
338
317
|
return "Forbidden";
|
|
@@ -340,220 +319,257 @@ export class ReactServerProvider {
|
|
|
340
319
|
target = target.parent;
|
|
341
320
|
}
|
|
342
321
|
|
|
343
|
-
// TODO: SSR strategies
|
|
344
|
-
// - only when googlebot
|
|
345
|
-
// - only child pages
|
|
346
|
-
// if (page.client) {
|
|
347
|
-
// // if the page is a client-only page, return 404
|
|
348
|
-
// reply.status = 200;
|
|
349
|
-
// reply.headers["content-type"] = "text/html";
|
|
350
|
-
// reply.body = template;
|
|
351
|
-
// return;
|
|
352
|
-
// }
|
|
353
|
-
|
|
354
322
|
await this.alepha.events.emit("react:server:render:begin", {
|
|
355
323
|
request: serverRequest,
|
|
356
324
|
state,
|
|
357
325
|
});
|
|
358
326
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
362
|
-
|
|
363
|
-
this.serverTimingProvider.endTiming("createLayers");
|
|
364
|
-
|
|
365
|
-
if (redirect) {
|
|
366
|
-
this.log.debug("Resolver resulted in redirection", {
|
|
367
|
-
redirect,
|
|
368
|
-
});
|
|
369
|
-
return reply.redirect(redirect);
|
|
370
|
-
}
|
|
327
|
+
// Apply SSR headers early
|
|
328
|
+
Object.assign(reply.headers, this.SSR_HEADERS);
|
|
371
329
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
// by default, disable caching for SSR responses
|
|
375
|
-
// some plugins may override this
|
|
376
|
-
reply.headers["cache-control"] =
|
|
377
|
-
"no-store, no-cache, must-revalidate, proxy-revalidate";
|
|
378
|
-
reply.headers.pragma = "no-cache";
|
|
379
|
-
reply.headers.expires = "0";
|
|
380
|
-
|
|
381
|
-
const html = this.renderToHtml(template, state);
|
|
382
|
-
if (html instanceof Redirection) {
|
|
383
|
-
reply.redirect(
|
|
384
|
-
typeof html.redirect === "string"
|
|
385
|
-
? html.redirect
|
|
386
|
-
: this.pageApi.href(html.redirect),
|
|
387
|
-
);
|
|
388
|
-
this.log.debug("Rendering resulted in redirection", {
|
|
389
|
-
redirect: html.redirect,
|
|
390
|
-
});
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
330
|
+
// Resolve global head for early streaming (htmlAttributes only)
|
|
331
|
+
const globalHead = this.serverHeadProvider.resolveGlobalHead();
|
|
393
332
|
|
|
394
|
-
|
|
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);
|
|
395
339
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
}
|
|
401
345
|
|
|
402
|
-
|
|
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
|
+
);
|
|
403
362
|
|
|
363
|
+
this.log.trace("Page streaming started (early head optimization)");
|
|
404
364
|
route.onServerResponse?.(serverRequest);
|
|
405
|
-
|
|
406
|
-
this.log.trace("Page rendered", {
|
|
407
|
-
name: route.name,
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
return event.html;
|
|
365
|
+
reply.body = htmlStream;
|
|
411
366
|
};
|
|
412
367
|
}
|
|
413
368
|
|
|
414
|
-
|
|
415
|
-
|
|
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,
|
|
416
389
|
state: ReactRouterState,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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");
|
|
420
395
|
|
|
421
|
-
|
|
422
|
-
|
|
396
|
+
if (redirect) {
|
|
397
|
+
this.log.debug("Resolver resulted in redirection", { redirect });
|
|
398
|
+
return { redirect };
|
|
399
|
+
}
|
|
423
400
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
try {
|
|
427
|
-
app = renderToString(element);
|
|
428
|
-
} catch (error) {
|
|
429
|
-
this.log.error(
|
|
430
|
-
"renderToString has failed, fallback to error handler",
|
|
431
|
-
error,
|
|
432
|
-
);
|
|
433
|
-
const element = state.onError(error as Error, state);
|
|
434
|
-
if (element instanceof Redirection) {
|
|
435
|
-
// if the error is a redirection, return the redirection URL
|
|
436
|
-
return element;
|
|
437
|
-
}
|
|
401
|
+
// Fill head from route config
|
|
402
|
+
this.serverHeadProvider.fillHead(state);
|
|
438
403
|
|
|
439
|
-
|
|
440
|
-
|
|
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];
|
|
441
409
|
}
|
|
442
|
-
this.serverTimingProvider.endTiming("renderToString");
|
|
443
410
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
};
|
|
411
|
+
// Render React to stream
|
|
412
|
+
this.serverTimingProvider.beginTiming("renderToStream");
|
|
447
413
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
this.alepha.context.als?.getStore() ?? {}; /// TODO: als must be protected, find a way to iterate on alepha.state
|
|
451
|
-
|
|
452
|
-
const hydrationData: ReactHydrationState = {
|
|
453
|
-
...store,
|
|
454
|
-
// map react.router.state to the hydration state
|
|
455
|
-
"alepha.react.router.state": undefined,
|
|
456
|
-
layers: state.layers.map((it) => ({
|
|
457
|
-
...it,
|
|
458
|
-
error: it.error
|
|
459
|
-
? {
|
|
460
|
-
...it.error,
|
|
461
|
-
name: it.error.name,
|
|
462
|
-
message: it.error.message,
|
|
463
|
-
stack: !this.alepha.isProduction() ? it.error.stack : undefined,
|
|
464
|
-
}
|
|
465
|
-
: undefined,
|
|
466
|
-
index: undefined,
|
|
467
|
-
path: undefined,
|
|
468
|
-
element: undefined,
|
|
469
|
-
route: undefined,
|
|
470
|
-
})),
|
|
471
|
-
};
|
|
414
|
+
const element = this.pageApi.root(state);
|
|
415
|
+
this.alepha.store.set("alepha.react.router.state", state);
|
|
472
416
|
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
});
|
|
475
428
|
|
|
476
|
-
|
|
477
|
-
this.fillTemplate(response, app, script);
|
|
478
|
-
}
|
|
429
|
+
this.serverTimingProvider.endTiming("renderToStream");
|
|
479
430
|
|
|
480
|
-
return
|
|
431
|
+
return { reactStream };
|
|
481
432
|
}
|
|
482
433
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
|
|
487
|
-
|
|
488
|
-
const beforeScript = template.substring(0, bodyCloseIndex);
|
|
489
|
-
const afterScript = template.substring(bodyCloseIndex);
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Testing utilities - kept for backwards compatibility with tests
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
490
437
|
|
|
491
|
-
|
|
492
|
-
|
|
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
|
+
};
|
|
493
460
|
|
|
494
|
-
|
|
495
|
-
// Split around the existing root div content
|
|
496
|
-
const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
|
|
497
|
-
const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
|
|
498
|
-
const afterDiv = beforeScript.substring(afterDivStart);
|
|
461
|
+
this.log.trace("Rendering", { url });
|
|
499
462
|
|
|
500
|
-
|
|
501
|
-
const afterApp = `</div>${afterDiv}`;
|
|
463
|
+
await this.alepha.events.emit("react:server:render:begin", { state });
|
|
502
464
|
|
|
503
|
-
|
|
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();
|
|
504
469
|
}
|
|
505
470
|
|
|
506
|
-
//
|
|
507
|
-
const
|
|
508
|
-
if (bodyMatch) {
|
|
509
|
-
const beforeBody = beforeScript.substring(
|
|
510
|
-
0,
|
|
511
|
-
bodyMatch.index! + bodyMatch[0].length,
|
|
512
|
-
);
|
|
513
|
-
const afterBody = beforeScript.substring(
|
|
514
|
-
bodyMatch.index! + bodyMatch[0].length,
|
|
515
|
-
);
|
|
471
|
+
// Use shared rendering logic
|
|
472
|
+
const result = await this.renderPage(page, state);
|
|
516
473
|
|
|
517
|
-
|
|
518
|
-
|
|
474
|
+
if (result.redirect) {
|
|
475
|
+
return { state, html: "", redirect: result.redirect };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const reactStream = result.reactStream!;
|
|
519
479
|
|
|
520
|
-
|
|
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 };
|
|
521
484
|
}
|
|
522
485
|
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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 };
|
|
530
498
|
}
|
|
531
499
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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();
|
|
540
519
|
}
|
|
541
520
|
|
|
542
|
-
|
|
543
|
-
response.html =
|
|
544
|
-
this.preprocessedTemplate.beforeApp +
|
|
545
|
-
app +
|
|
546
|
-
this.preprocessedTemplate.afterApp +
|
|
547
|
-
script +
|
|
548
|
-
this.preprocessedTemplate.afterScript;
|
|
521
|
+
return chunks.join("");
|
|
549
522
|
}
|
|
550
523
|
}
|
|
551
524
|
|
|
525
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
526
|
+
|
|
552
527
|
type TemplateLoader = () => Promise<string | undefined>;
|
|
553
528
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
+
}
|
|
559
575
|
}
|