@alepha/react 0.14.3 → 0.15.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.
- package/README.md +10 -0
- package/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.d.ts +4 -4
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +950 -194
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +118 -118
- package/dist/core/index.d.ts.map +1 -1
- package/dist/form/index.d.ts +27 -28
- package/dist/form/index.d.ts.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 +105 -576
- 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/i18n/index.d.ts +33 -33
- package/dist/i18n/index.d.ts.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 +827 -403
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +951 -195
- package/dist/router/index.js.map +1 -1
- package/dist/websocket/index.d.ts +38 -39
- package/dist/websocket/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/auth/__tests__/$auth.spec.ts +10 -11
- 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 +321 -316
- package/src/router/providers/ReactServerTemplateProvider.ts +793 -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
|
|
|
@@ -107,7 +78,7 @@ export class ReactServerProvider {
|
|
|
107
78
|
|
|
108
79
|
// non-serverless mode only -> serve static files
|
|
109
80
|
if (!this.alepha.isServerless()) {
|
|
110
|
-
root = this.getPublicDirectory();
|
|
81
|
+
root = await this.getPublicDirectory();
|
|
111
82
|
if (!root) {
|
|
112
83
|
this.log.warn(
|
|
113
84
|
"Missing static files, static file server will be disabled",
|
|
@@ -146,27 +117,39 @@ export class ReactServerProvider {
|
|
|
146
117
|
},
|
|
147
118
|
});
|
|
148
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Get the current HTML template.
|
|
122
|
+
*/
|
|
149
123
|
public get template() {
|
|
150
124
|
return (
|
|
151
125
|
this.alepha.store.get("alepha.react.server.template") ??
|
|
152
|
-
"<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
|
|
126
|
+
"<!DOCTYPE html><html lang='en'><head></head><body><div id='root'></div></body></html>"
|
|
153
127
|
);
|
|
154
128
|
}
|
|
155
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Register all pages as server routes.
|
|
132
|
+
*/
|
|
156
133
|
protected async registerPages(templateLoader: TemplateLoader) {
|
|
157
|
-
//
|
|
134
|
+
// Parse template once at startup
|
|
158
135
|
const template = await templateLoader();
|
|
159
136
|
if (template) {
|
|
160
|
-
this.
|
|
137
|
+
this.templateProvider.parseTemplate(template);
|
|
161
138
|
}
|
|
162
139
|
|
|
140
|
+
// Set up early head content (entry assets preloads)
|
|
141
|
+
this.setupEarlyHeadContent();
|
|
142
|
+
|
|
143
|
+
// Cache ServerLinksProvider check at startup
|
|
144
|
+
this.hasServerLinksProvider = this.alepha.has(ServerLinksProvider);
|
|
145
|
+
|
|
163
146
|
for (const page of this.pageApi.getPages()) {
|
|
164
147
|
if (page.component || page.lazy) {
|
|
165
148
|
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
166
149
|
|
|
167
150
|
this.serverRouterProvider.createRoute({
|
|
168
151
|
...page,
|
|
169
|
-
schema: undefined, // schema is handled by the page primitive provider
|
|
152
|
+
schema: undefined, // schema is handled by the page primitive provider
|
|
170
153
|
method: "GET",
|
|
171
154
|
path: page.match,
|
|
172
155
|
handler: this.createHandler(page, templateLoader),
|
|
@@ -175,17 +158,63 @@ export class ReactServerProvider {
|
|
|
175
158
|
}
|
|
176
159
|
}
|
|
177
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Set up early head content with entry assets.
|
|
163
|
+
*
|
|
164
|
+
* This content is sent immediately when streaming starts, before page loaders run,
|
|
165
|
+
* allowing the browser to start downloading entry.js and CSS files early.
|
|
166
|
+
*
|
|
167
|
+
* Uses <script type="module"> instead of <link rel="modulepreload"> for JS
|
|
168
|
+
* because the script needs to execute anyway - this way the browser starts
|
|
169
|
+
* downloading, parsing, AND will execute as soon as ready.
|
|
170
|
+
*
|
|
171
|
+
* Also strips these assets from the original template head to avoid duplicates.
|
|
172
|
+
*/
|
|
173
|
+
protected setupEarlyHeadContent(): void {
|
|
174
|
+
const assets = this.ssrManifestProvider.getEntryAssets();
|
|
175
|
+
if (!assets) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
|
|
181
|
+
// Add CSS stylesheets (critical for rendering)
|
|
182
|
+
for (const css of assets.css) {
|
|
183
|
+
parts.push(`<link rel="stylesheet" href="${css}" crossorigin="">`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Add entry JS as script module (not just modulepreload)
|
|
187
|
+
// This starts download, parse, AND execution immediately
|
|
188
|
+
if (assets.js) {
|
|
189
|
+
parts.push(
|
|
190
|
+
`<script type="module" crossorigin="" src="${assets.js}"></script>`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (parts.length > 0) {
|
|
195
|
+
// Pass assets so they get stripped from original head content
|
|
196
|
+
this.templateProvider.setEarlyHeadContent(
|
|
197
|
+
parts.join("\n") + "\n",
|
|
198
|
+
assets,
|
|
199
|
+
);
|
|
200
|
+
this.log.debug("Early head content set", {
|
|
201
|
+
css: assets.css.length,
|
|
202
|
+
js: assets.js ? 1 : 0,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
178
207
|
/**
|
|
179
208
|
* Get the public directory path where static files are located.
|
|
180
209
|
*/
|
|
181
|
-
protected getPublicDirectory(): string {
|
|
210
|
+
protected async getPublicDirectory(): Promise<string> {
|
|
182
211
|
const maybe = [
|
|
183
212
|
join(process.cwd(), `dist/${this.options.publicDir}`),
|
|
184
213
|
join(process.cwd(), this.options.publicDir),
|
|
185
214
|
];
|
|
186
215
|
|
|
187
216
|
for (const it of maybe) {
|
|
188
|
-
if (
|
|
217
|
+
if (await this.fs.exists(it)) {
|
|
189
218
|
return it;
|
|
190
219
|
}
|
|
191
220
|
}
|
|
@@ -208,7 +237,7 @@ export class ReactServerProvider {
|
|
|
208
237
|
}
|
|
209
238
|
|
|
210
239
|
/**
|
|
211
|
-
* Configure Vite for SSR.
|
|
240
|
+
* Configure Vite for SSR in development mode.
|
|
212
241
|
*/
|
|
213
242
|
protected async configureVite(ssrEnabled: boolean) {
|
|
214
243
|
if (!ssrEnabled) {
|
|
@@ -216,8 +245,7 @@ export class ReactServerProvider {
|
|
|
216
245
|
return;
|
|
217
246
|
}
|
|
218
247
|
|
|
219
|
-
const
|
|
220
|
-
const url = `http://localhost:${env.SERVER_PORT ?? "5173"}`;
|
|
248
|
+
const url = `http://localhost:${this.alepha.env.SERVER_PORT ?? "5173"}`;
|
|
221
249
|
|
|
222
250
|
this.log.info("SSR (dev) OK", { url });
|
|
223
251
|
|
|
@@ -229,95 +257,41 @@ export class ReactServerProvider {
|
|
|
229
257
|
}
|
|
230
258
|
|
|
231
259
|
/**
|
|
232
|
-
*
|
|
260
|
+
* Create the request handler for a page route.
|
|
233
261
|
*/
|
|
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
262
|
protected createHandler(
|
|
294
263
|
route: PageRoute,
|
|
295
264
|
templateLoader: TemplateLoader,
|
|
296
265
|
): ServerHandler {
|
|
297
266
|
return async (serverRequest) => {
|
|
298
267
|
const { url, reply, query, params } = serverRequest;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
268
|
+
|
|
269
|
+
// Ensure template is parsed (handles dev mode where template may change)
|
|
270
|
+
if (!this.templateProvider.isReady()) {
|
|
271
|
+
const template = await templateLoader();
|
|
272
|
+
if (!template) {
|
|
273
|
+
throw new AlephaError("Missing template for SSR rendering");
|
|
274
|
+
}
|
|
275
|
+
this.templateProvider.parseTemplate(template);
|
|
276
|
+
this.setupEarlyHeadContent();
|
|
302
277
|
}
|
|
303
278
|
|
|
304
|
-
this.log.trace("Rendering page", {
|
|
305
|
-
name: route.name,
|
|
306
|
-
});
|
|
279
|
+
this.log.trace("Rendering page", { name: route.name });
|
|
307
280
|
|
|
308
|
-
|
|
281
|
+
// Initialize router state
|
|
282
|
+
const state: ReactRouterState = {
|
|
309
283
|
url,
|
|
310
284
|
params,
|
|
311
285
|
query,
|
|
286
|
+
name: route.name,
|
|
312
287
|
onError: () => null,
|
|
313
288
|
layers: [],
|
|
289
|
+
meta: {},
|
|
290
|
+
head: {},
|
|
314
291
|
};
|
|
315
292
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
state.name = route.name;
|
|
319
|
-
|
|
320
|
-
if (this.alepha.has(ServerLinksProvider)) {
|
|
293
|
+
// Set up API links if available
|
|
294
|
+
if (this.hasServerLinksProvider) {
|
|
321
295
|
this.alepha.store.set(
|
|
322
296
|
"alepha.server.request.apiLinks",
|
|
323
297
|
await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
@@ -327,13 +301,13 @@ export class ReactServerProvider {
|
|
|
327
301
|
);
|
|
328
302
|
}
|
|
329
303
|
|
|
330
|
-
|
|
304
|
+
// Check access permissions
|
|
305
|
+
let target: PageRoute | undefined = route;
|
|
331
306
|
while (target) {
|
|
332
307
|
if (route.can && !route.can()) {
|
|
333
308
|
this.log.warn(
|
|
334
309
|
`Access to page '${route.name}' is forbidden by can() check`,
|
|
335
|
-
)
|
|
336
|
-
// if the page is not accessible, return 403
|
|
310
|
+
);
|
|
337
311
|
reply.status = 403;
|
|
338
312
|
reply.headers["content-type"] = "text/plain";
|
|
339
313
|
return "Forbidden";
|
|
@@ -341,220 +315,251 @@ export class ReactServerProvider {
|
|
|
341
315
|
target = target.parent;
|
|
342
316
|
}
|
|
343
317
|
|
|
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
318
|
await this.alepha.events.emit("react:server:render:begin", {
|
|
356
319
|
request: serverRequest,
|
|
357
320
|
state,
|
|
358
321
|
});
|
|
359
322
|
|
|
360
|
-
|
|
323
|
+
// Apply SSR headers early
|
|
324
|
+
Object.assign(reply.headers, this.SSR_HEADERS);
|
|
361
325
|
|
|
362
|
-
|
|
326
|
+
// Resolve global head for early streaming (htmlAttributes only)
|
|
327
|
+
const globalHead = this.serverHeadProvider.resolveGlobalHead();
|
|
363
328
|
|
|
364
|
-
|
|
329
|
+
// Create optimized HTML stream with early head
|
|
330
|
+
const htmlStream = this.templateProvider.createEarlyHtmlStream(
|
|
331
|
+
globalHead,
|
|
332
|
+
async () => {
|
|
333
|
+
// === ASYNC WORK (runs while early head is being sent) ===
|
|
334
|
+
const result = await this.renderPage(route, state);
|
|
365
335
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
336
|
+
if (result.redirect) {
|
|
337
|
+
// Return redirect URL - template provider will inject meta refresh
|
|
338
|
+
// since HTTP headers have already been sent
|
|
339
|
+
return { redirect: result.redirect };
|
|
340
|
+
}
|
|
372
341
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
this.log.debug("Rendering resulted in redirection", {
|
|
390
|
-
redirect: html.redirect,
|
|
391
|
-
});
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
342
|
+
return { state, reactStream: result.reactStream! };
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
hydration: true,
|
|
346
|
+
onError: (error) => {
|
|
347
|
+
if (error instanceof Redirection) {
|
|
348
|
+
this.log.debug("Streaming resulted in redirection", {
|
|
349
|
+
redirect: error.redirect,
|
|
350
|
+
});
|
|
351
|
+
// Can't do redirect after streaming started - already handled above
|
|
352
|
+
} else {
|
|
353
|
+
this.log.error("HTML stream error", error);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
);
|
|
394
358
|
|
|
395
|
-
this.log.trace("Page
|
|
359
|
+
this.log.trace("Page streaming started (early head optimization)");
|
|
360
|
+
route.onServerResponse?.(serverRequest);
|
|
361
|
+
reply.body = htmlStream;
|
|
362
|
+
};
|
|
363
|
+
}
|
|
396
364
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
html,
|
|
401
|
-
};
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Core rendering logic - shared between SSR handler and static prerendering
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
402
368
|
|
|
403
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Core page rendering logic shared between SSR handler and static prerendering.
|
|
371
|
+
*
|
|
372
|
+
* Handles:
|
|
373
|
+
* - Layer resolution (loaders)
|
|
374
|
+
* - Redirect detection
|
|
375
|
+
* - Head content filling
|
|
376
|
+
* - Preload link collection
|
|
377
|
+
* - React stream rendering
|
|
378
|
+
*
|
|
379
|
+
* @param route - The page route to render
|
|
380
|
+
* @param state - The router state
|
|
381
|
+
* @returns Render result with redirect or React stream
|
|
382
|
+
*/
|
|
383
|
+
protected async renderPage(
|
|
384
|
+
route: PageRoute,
|
|
385
|
+
state: ReactRouterState,
|
|
386
|
+
): Promise<{ redirect?: string; reactStream?: ReadableStream<Uint8Array> }> {
|
|
387
|
+
// Resolve page layers (loaders)
|
|
388
|
+
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
389
|
+
if (redirect) {
|
|
390
|
+
this.log.debug("Resolver resulted in redirection", { redirect });
|
|
391
|
+
return { redirect };
|
|
392
|
+
}
|
|
404
393
|
|
|
405
|
-
|
|
394
|
+
// Fill head from route config
|
|
395
|
+
this.serverHeadProvider.fillHead(state);
|
|
406
396
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
397
|
+
// Collect and inject modulepreload links for page-specific chunks
|
|
398
|
+
const preloadLinks = this.ssrManifestProvider.collectPreloadLinks(route);
|
|
399
|
+
if (preloadLinks.length > 0) {
|
|
400
|
+
state.head ??= {};
|
|
401
|
+
state.head.link = [...(state.head.link ?? []), ...preloadLinks];
|
|
402
|
+
}
|
|
410
403
|
|
|
411
|
-
|
|
412
|
-
};
|
|
413
|
-
}
|
|
404
|
+
// Render React to stream
|
|
414
405
|
|
|
415
|
-
public renderToHtml(
|
|
416
|
-
template: string,
|
|
417
|
-
state: ReactRouterState,
|
|
418
|
-
hydration = true,
|
|
419
|
-
): string | Redirection {
|
|
420
406
|
const element = this.pageApi.root(state);
|
|
421
|
-
|
|
422
|
-
// attach react router state to the http request context
|
|
423
407
|
this.alepha.store.set("alepha.react.router.state", state);
|
|
424
408
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
// if the error is a redirection, return the redirection URL
|
|
437
|
-
return element;
|
|
438
|
-
}
|
|
409
|
+
const reactStream = await renderToReadableStream(element, {
|
|
410
|
+
onError: (error: unknown) => {
|
|
411
|
+
if (error instanceof Redirection) {
|
|
412
|
+
this.log.warn("Redirect during streaming ignored", {
|
|
413
|
+
redirect: error.redirect,
|
|
414
|
+
});
|
|
415
|
+
} else {
|
|
416
|
+
this.log.error("Streaming render error", error);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
});
|
|
439
420
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
421
|
+
return { reactStream };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Testing utilities - kept for backwards compatibility with tests
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
444
427
|
|
|
445
|
-
|
|
446
|
-
|
|
428
|
+
/**
|
|
429
|
+
* For testing purposes, renders a page to HTML string.
|
|
430
|
+
* Uses the same streaming code path as production, then collects to string.
|
|
431
|
+
*
|
|
432
|
+
* @param name - Page name to render
|
|
433
|
+
* @param options - Render options (params, query, html, hydration)
|
|
434
|
+
*/
|
|
435
|
+
public async render(
|
|
436
|
+
name: string,
|
|
437
|
+
options: PagePrimitiveRenderOptions = {},
|
|
438
|
+
): Promise<PagePrimitiveRenderResult> {
|
|
439
|
+
const page = this.pageApi.page(name);
|
|
440
|
+
const url = new URL(this.pageApi.url(name, options));
|
|
441
|
+
const state: ReactRouterState = {
|
|
442
|
+
url,
|
|
443
|
+
params: options.params ?? {},
|
|
444
|
+
query: options.query ?? {},
|
|
445
|
+
onError: () => null,
|
|
446
|
+
layers: [],
|
|
447
|
+
meta: {},
|
|
448
|
+
head: {},
|
|
447
449
|
};
|
|
448
450
|
|
|
449
|
-
|
|
450
|
-
const { request, context, ...store } =
|
|
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
|
-
};
|
|
451
|
+
this.log.trace("Rendering", { url });
|
|
473
452
|
|
|
474
|
-
|
|
475
|
-
const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}</script>`;
|
|
453
|
+
await this.alepha.events.emit("react:server:render:begin", { state });
|
|
476
454
|
|
|
477
|
-
|
|
478
|
-
|
|
455
|
+
// Ensure template is parsed with early head content (entry.js, CSS)
|
|
456
|
+
if (!this.templateProvider.isReady()) {
|
|
457
|
+
this.templateProvider.parseTemplate(this.template);
|
|
458
|
+
this.setupEarlyHeadContent();
|
|
479
459
|
}
|
|
480
460
|
|
|
481
|
-
|
|
482
|
-
|
|
461
|
+
// Use shared rendering logic
|
|
462
|
+
const result = await this.renderPage(page, state);
|
|
483
463
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
|
|
464
|
+
if (result.redirect) {
|
|
465
|
+
return { state, html: "", redirect: result.redirect };
|
|
466
|
+
}
|
|
488
467
|
|
|
489
|
-
const
|
|
490
|
-
const afterScript = template.substring(bodyCloseIndex);
|
|
468
|
+
const reactStream = result.reactStream!;
|
|
491
469
|
|
|
492
|
-
//
|
|
493
|
-
|
|
470
|
+
// If full HTML page not requested, collect just the React content
|
|
471
|
+
if (!options.html) {
|
|
472
|
+
const html = await this.streamToString(reactStream);
|
|
473
|
+
return { state, html };
|
|
474
|
+
}
|
|
494
475
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
476
|
+
// Create full HTML stream and collect to string
|
|
477
|
+
const htmlStream = this.templateProvider.createHtmlStream(
|
|
478
|
+
reactStream,
|
|
479
|
+
state,
|
|
480
|
+
{ hydration: options.hydration ?? true },
|
|
481
|
+
);
|
|
500
482
|
|
|
501
|
-
|
|
502
|
-
const afterApp = `</div>${afterDiv}`;
|
|
483
|
+
const html = await this.streamToString(htmlStream);
|
|
503
484
|
|
|
504
|
-
|
|
505
|
-
}
|
|
485
|
+
await this.alepha.events.emit("react:server:render:end", { state, html });
|
|
506
486
|
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
);
|
|
487
|
+
return { state, html };
|
|
488
|
+
}
|
|
517
489
|
|
|
518
|
-
|
|
519
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Collect a ReadableStream into a string.
|
|
492
|
+
*/
|
|
493
|
+
protected async streamToString(
|
|
494
|
+
stream: ReadableStream<Uint8Array>,
|
|
495
|
+
): Promise<string> {
|
|
496
|
+
const reader = stream.getReader();
|
|
497
|
+
const decoder = new TextDecoder();
|
|
498
|
+
const chunks: string[] = [];
|
|
520
499
|
|
|
521
|
-
|
|
500
|
+
try {
|
|
501
|
+
while (true) {
|
|
502
|
+
const { done, value } = await reader.read();
|
|
503
|
+
if (done) break;
|
|
504
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
505
|
+
}
|
|
506
|
+
chunks.push(decoder.decode()); // Flush remaining
|
|
507
|
+
} finally {
|
|
508
|
+
reader.releaseLock();
|
|
522
509
|
}
|
|
523
510
|
|
|
524
|
-
|
|
525
|
-
return {
|
|
526
|
-
beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
|
|
527
|
-
afterApp: `</div>`,
|
|
528
|
-
beforeScript,
|
|
529
|
-
afterScript,
|
|
530
|
-
};
|
|
511
|
+
return chunks.join("");
|
|
531
512
|
}
|
|
513
|
+
}
|
|
532
514
|
|
|
533
|
-
|
|
534
|
-
response: { html: string },
|
|
535
|
-
app: string,
|
|
536
|
-
script: string,
|
|
537
|
-
) {
|
|
538
|
-
if (!this.preprocessedTemplate) {
|
|
539
|
-
// Fallback to old logic if preprocessing failed
|
|
540
|
-
this.preprocessedTemplate = this.preprocessTemplate(response.html);
|
|
541
|
-
}
|
|
515
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
542
516
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
517
|
+
type TemplateLoader = () => Promise<string | undefined>;
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
const envSchema = t.object({
|
|
523
|
+
REACT_SSR_ENABLED: t.optional(t.boolean()),
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
declare module "alepha" {
|
|
527
|
+
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
528
|
+
interface State {
|
|
529
|
+
"alepha.react.server.ssr"?: boolean;
|
|
530
|
+
"alepha.react.server.template"?: string;
|
|
550
531
|
}
|
|
551
532
|
}
|
|
552
533
|
|
|
553
|
-
|
|
534
|
+
/**
|
|
535
|
+
* React server provider configuration atom
|
|
536
|
+
*/
|
|
537
|
+
export const reactServerOptions = $atom({
|
|
538
|
+
name: "alepha.react.server.options",
|
|
539
|
+
schema: t.object({
|
|
540
|
+
publicDir: t.string(),
|
|
541
|
+
staticServer: t.object({
|
|
542
|
+
disabled: t.boolean(),
|
|
543
|
+
path: t.string({
|
|
544
|
+
description: "URL path where static files will be served.",
|
|
545
|
+
}),
|
|
546
|
+
}),
|
|
547
|
+
}),
|
|
548
|
+
default: {
|
|
549
|
+
publicDir: "public",
|
|
550
|
+
staticServer: {
|
|
551
|
+
disabled: false,
|
|
552
|
+
path: "/",
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
});
|
|
554
556
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
557
|
+
export type ReactServerProviderOptions = Static<
|
|
558
|
+
typeof reactServerOptions.schema
|
|
559
|
+
>;
|
|
560
|
+
|
|
561
|
+
declare module "alepha" {
|
|
562
|
+
interface State {
|
|
563
|
+
[reactServerOptions.key]: ReactServerProviderOptions;
|
|
564
|
+
}
|
|
560
565
|
}
|