@alepha/react 0.10.6 → 0.11.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.
@@ -1,535 +1,567 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
- $env,
5
- $hook,
6
- $inject,
7
- Alepha,
8
- AlephaError,
9
- type Static,
10
- t,
4
+ $env,
5
+ $hook,
6
+ $inject,
7
+ Alepha,
8
+ AlephaError,
9
+ type Configurable,
10
+ type Static,
11
+ t,
11
12
  } from "@alepha/core";
12
13
  import { $logger } from "@alepha/logger";
13
14
  import {
14
- type ServerHandler,
15
- ServerProvider,
16
- ServerRouterProvider,
17
- ServerTimingProvider,
15
+ type ServerHandler,
16
+ ServerProvider,
17
+ ServerRouterProvider,
18
+ ServerTimingProvider,
18
19
  } from "@alepha/server";
19
20
  import { ServerLinksProvider } from "@alepha/server-links";
20
- import { ServerStaticProvider } from "@alepha/server-static";
21
+ import {
22
+ type ServeDescriptorOptions,
23
+ ServerStaticProvider,
24
+ } from "@alepha/server-static";
21
25
  import { renderToString } from "react-dom/server";
22
26
  import {
23
- $page,
24
- type PageDescriptorRenderOptions,
25
- type PageDescriptorRenderResult,
27
+ $page,
28
+ type PageDescriptorRenderOptions,
29
+ type PageDescriptorRenderResult,
26
30
  } from "../descriptors/$page.ts";
27
31
  import { Redirection } from "../errors/Redirection.ts";
28
32
  import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
29
33
  import {
30
- type PageRoute,
31
- ReactPageProvider,
32
- type ReactRouterState,
34
+ type PageRoute,
35
+ ReactPageProvider,
36
+ type ReactRouterState,
33
37
  } from "./ReactPageProvider.ts";
34
38
 
35
39
  const envSchema = t.object({
36
- REACT_SERVER_DIST: t.text({ default: "public" }),
37
- REACT_SERVER_PREFIX: t.text({ default: "" }),
38
- REACT_SSR_ENABLED: t.optional(t.boolean()),
39
- REACT_ROOT_ID: t.text({ default: "root" }),
40
- REACT_SERVER_TEMPLATE: t.optional(
41
- t.text({
42
- size: "rich",
43
- }),
44
- ),
40
+ REACT_SERVER_DIST: t.text({ default: "public" }),
41
+ REACT_SERVER_PREFIX: t.text({ default: "" }),
42
+ REACT_SSR_ENABLED: t.optional(t.boolean()),
43
+ REACT_ROOT_ID: t.text({ default: "root" }), // TODO: move to ReactPageProvider.options?
44
+ REACT_SERVER_TEMPLATE: t.optional(
45
+ t.text({
46
+ size: "rich",
47
+ }),
48
+ ),
45
49
  });
46
50
 
47
51
  declare module "@alepha/core" {
48
- interface Env extends Partial<Static<typeof envSchema>> {}
49
- interface State {
50
- "react.server.ssr"?: boolean;
51
- }
52
+ interface Env extends Partial<Static<typeof envSchema>> {}
53
+ interface State {
54
+ "react.server.ssr"?: boolean;
55
+ }
56
+ }
57
+
58
+ export interface ReactServerProviderOptions {
59
+ /**
60
+ * Override default options for the static file server.
61
+ * > Static file server is only created in non-serverless production mode.
62
+ */
63
+ static?: Partial<Omit<ServeDescriptorOptions, "root">>;
52
64
  }
53
65
 
54
- export class ReactServerProvider {
55
- protected readonly log = $logger();
56
- protected readonly alepha = $inject(Alepha);
57
- protected readonly pageApi = $inject(ReactPageProvider);
58
- protected readonly serverProvider = $inject(ServerProvider);
59
- protected readonly serverStaticProvider = $inject(ServerStaticProvider);
60
- protected readonly serverRouterProvider = $inject(ServerRouterProvider);
61
- protected readonly serverTimingProvider = $inject(ServerTimingProvider);
62
- protected readonly env = $env(envSchema);
63
- protected readonly ROOT_DIV_REGEX = new RegExp(
64
- `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
65
- "is",
66
- );
67
- protected preprocessedTemplate: PreprocessedTemplate | null = null;
68
-
69
- public readonly onConfigure = $hook({
70
- on: "configure",
71
- handler: async () => {
72
- const pages = this.alepha.descriptors($page);
73
-
74
- const ssrEnabled =
75
- pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
76
-
77
- this.alepha.state.set("react.server.ssr", ssrEnabled);
78
-
79
- for (const page of pages) {
80
- page.render = this.createRenderFunction(page.name);
81
- page.fetch = async (options) => {
82
- const response = await fetch(
83
- `${this.serverProvider.hostname}/${page.pathname(options)}`,
84
- );
85
- const html = await response.text();
86
- if (options?.html) return { html, response };
87
- // take only text inside the root div
88
- const match = html.match(this.ROOT_DIV_REGEX);
89
- if (match) {
90
- return { html: match[3], response };
91
- }
92
- throw new AlephaError("Invalid HTML response");
93
- };
94
- }
95
-
96
- // development mode
97
- if (this.alepha.isServerless() === "vite") {
98
- await this.configureVite(ssrEnabled);
99
- return;
100
- }
101
-
102
- // production mode
103
- let root = "";
104
-
105
- // non-serverless mode only -> serve static files
106
- if (!this.alepha.isServerless()) {
107
- root = this.getPublicDirectory();
108
- if (!root) {
109
- this.log.warn(
110
- "Missing static files, static file server will be disabled",
111
- );
112
- } else {
113
- this.log.debug(`Using static files from: ${root}`);
114
- await this.configureStaticServer(root);
115
- }
116
- }
117
-
118
- if (ssrEnabled) {
119
- await this.registerPages(async () => this.template);
120
- this.log.info("SSR OK");
121
- return;
122
- }
123
-
124
- // no SSR enabled, serve index.html for all unmatched routes
125
- this.log.info("SSR is disabled, use History API fallback");
126
- this.serverRouterProvider.createRoute({
127
- path: "*",
128
- handler: async ({ url, reply }) => {
129
- if (url.pathname.includes(".")) {
130
- // If the request is for a file (e.g., /style.css), do not fallback
131
- reply.headers["content-type"] = "text/plain";
132
- reply.body = "Not Found";
133
- reply.status = 404;
134
- return;
135
- }
136
-
137
- reply.headers["content-type"] = "text/html";
138
-
139
- // serve index.html for all unmatched routes
140
- return this.template;
141
- },
142
- });
143
- },
144
- });
145
-
146
- public get template() {
147
- return (
148
- this.alepha.env.REACT_SERVER_TEMPLATE ??
149
- "<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
150
- );
151
- }
152
-
153
- protected async registerPages(templateLoader: TemplateLoader) {
154
- // Preprocess template once
155
- const template = await templateLoader();
156
- if (template) {
157
- this.preprocessedTemplate = this.preprocessTemplate(template);
158
- }
159
-
160
- for (const page of this.pageApi.getPages()) {
161
- if (page.children?.length) {
162
- continue;
163
- }
164
-
165
- this.log.debug(`+ ${page.match} -> ${page.name}`);
166
-
167
- this.serverRouterProvider.createRoute({
168
- ...page,
169
- schema: undefined, // schema is handled by the page descriptor provider for now (shared by browser and server)
170
- method: "GET",
171
- path: page.match,
172
- handler: this.createHandler(page, templateLoader),
173
- });
174
- }
175
- }
176
-
177
- protected getPublicDirectory(): string {
178
- const maybe = [
179
- join(process.cwd(), `dist/${this.env.REACT_SERVER_DIST}`),
180
- join(process.cwd(), this.env.REACT_SERVER_DIST),
181
- ];
182
-
183
- for (const it of maybe) {
184
- if (existsSync(it)) {
185
- return it;
186
- }
187
- }
188
-
189
- return "";
190
- }
191
-
192
- protected async configureStaticServer(root: string) {
193
- await this.serverStaticProvider.createStaticServer({
194
- root,
195
- path: this.env.REACT_SERVER_PREFIX,
196
- });
197
- }
198
-
199
- protected async configureVite(ssrEnabled: boolean) {
200
- if (!ssrEnabled) {
201
- // do nothing, vite will handle everything for us
202
- return;
203
- }
204
-
205
- this.log.info("SSR (vite) OK");
206
-
207
- const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
208
-
209
- await this.registerPages(() =>
210
- fetch(`${url}/index.html`)
211
- .then((it) => it.text())
212
- .catch(() => undefined),
213
- );
214
- }
215
-
216
- /**
217
- * For testing purposes, creates a render function that can be used.
218
- */
219
- protected createRenderFunction(name: string, withIndex = false) {
220
- return async (
221
- options: PageDescriptorRenderOptions = {},
222
- ): Promise<PageDescriptorRenderResult> => {
223
- const page = this.pageApi.page(name);
224
- const url = new URL(this.pageApi.url(name, options));
225
-
226
- const entry: Partial<ReactRouterState> = {
227
- url,
228
- params: options.params ?? {},
229
- query: options.query ?? {},
230
- onError: () => null,
231
- layers: [],
232
- meta: {},
233
- };
234
-
235
- const state = entry as ReactRouterState;
236
-
237
- this.log.trace("Rendering", {
238
- url,
239
- });
240
-
241
- await this.alepha.events.emit("react:server:render:begin", {
242
- state,
243
- });
244
-
245
- const { redirect } = await this.pageApi.createLayers(
246
- page,
247
- state as ReactRouterState,
248
- );
249
-
250
- if (redirect) {
251
- return { state, html: "", redirect };
252
- }
253
-
254
- if (!withIndex && !options.html) {
255
- this.alepha.state.set("react.router.state", state);
256
-
257
- return {
258
- state,
259
- html: renderToString(this.pageApi.root(state)),
260
- };
261
- }
262
-
263
- const template = this.template ?? "";
264
- const html = this.renderToHtml(template, state, options.hydration);
265
-
266
- if (html instanceof Redirection) {
267
- return { state, html: "", redirect };
268
- }
269
-
270
- const result = {
271
- state,
272
- html,
273
- };
274
-
275
- await this.alepha.events.emit("react:server:render:end", result);
276
-
277
- return result;
278
- };
279
- }
280
-
281
- protected createHandler(
282
- route: PageRoute,
283
- templateLoader: TemplateLoader,
284
- ): ServerHandler {
285
- return async (serverRequest) => {
286
- const { url, reply, query, params } = serverRequest;
287
- const template = await templateLoader();
288
- if (!template) {
289
- throw new Error("Template not found");
290
- }
291
-
292
- this.log.trace("Rendering page", {
293
- name: route.name,
294
- });
295
-
296
- const entry: Partial<ReactRouterState> = {
297
- url,
298
- params,
299
- query,
300
- onError: () => null,
301
- layers: [],
302
- };
303
-
304
- const state = entry as ReactRouterState;
305
-
306
- if (this.alepha.has(ServerLinksProvider)) {
307
- this.alepha.state.set(
308
- "api",
309
- await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
310
- user: (serverRequest as any).user, // TODO: fix type
311
- authorization: serverRequest.headers.authorization,
312
- }),
313
- );
314
- }
315
-
316
- let target: PageRoute | undefined = route; // TODO: move to PageDescriptorProvider
317
- while (target) {
318
- if (route.can && !route.can()) {
319
- // if the page is not accessible, return 403
320
- reply.status = 403;
321
- reply.headers["content-type"] = "text/plain";
322
- return "Forbidden";
323
- }
324
- target = target.parent;
325
- }
326
-
327
- // TODO: SSR strategies
328
- // - only when googlebot
329
- // - only child pages
330
- // if (page.client) {
331
- // // if the page is a client-only page, return 404
332
- // reply.status = 200;
333
- // reply.headers["content-type"] = "text/html";
334
- // reply.body = template;
335
- // return;
336
- // }
337
-
338
- await this.alepha.events.emit("react:server:render:begin", {
339
- request: serverRequest,
340
- state,
341
- });
342
-
343
- this.serverTimingProvider.beginTiming("createLayers");
344
-
345
- const { redirect } = await this.pageApi.createLayers(route, state);
346
-
347
- this.serverTimingProvider.endTiming("createLayers");
348
-
349
- if (redirect) {
350
- return reply.redirect(redirect);
351
- }
352
-
353
- reply.headers["content-type"] = "text/html";
354
-
355
- // by default, disable caching for SSR responses
356
- // some plugins may override this
357
- reply.headers["cache-control"] =
358
- "no-store, no-cache, must-revalidate, proxy-revalidate";
359
- reply.headers.pragma = "no-cache";
360
- reply.headers.expires = "0";
361
-
362
- const html = this.renderToHtml(template, state);
363
- if (html instanceof Redirection) {
364
- reply.redirect(
365
- typeof html.redirect === "string"
366
- ? html.redirect
367
- : this.pageApi.href(html.redirect),
368
- );
369
- return;
370
- }
371
-
372
- const event = {
373
- request: serverRequest,
374
- state,
375
- html,
376
- };
377
-
378
- await this.alepha.events.emit("react:server:render:end", event);
379
-
380
- route.onServerResponse?.(serverRequest);
381
-
382
- this.log.trace("Page rendered", {
383
- name: route.name,
384
- });
385
-
386
- return event.html;
387
- };
388
- }
389
-
390
- public renderToHtml(
391
- template: string,
392
- state: ReactRouterState,
393
- hydration = true,
394
- ): string | Redirection {
395
- const element = this.pageApi.root(state);
396
-
397
- // attach react router state to the http request context
398
- this.alepha.state.set("react.router.state", state);
399
-
400
- this.serverTimingProvider.beginTiming("renderToString");
401
- let app = "";
402
- try {
403
- app = renderToString(element);
404
- } catch (error) {
405
- this.log.error(
406
- "renderToString has failed, fallback to error handler",
407
- error,
408
- );
409
- const element = state.onError(error as Error, state);
410
- if (element instanceof Redirection) {
411
- // if the error is a redirection, return the redirection URL
412
- return element;
413
- }
414
-
415
- app = renderToString(element);
416
- this.log.debug("Error handled successfully with fallback");
417
- }
418
- this.serverTimingProvider.endTiming("renderToString");
419
-
420
- const response = {
421
- html: template,
422
- };
423
-
424
- if (hydration) {
425
- const { request, context, ...store } =
426
- this.alepha.context.als?.getStore() ?? {}; /// TODO: als must be protected, find a way to iterate on alepha.state
427
-
428
- const hydrationData: ReactHydrationState = {
429
- ...store,
430
- // map react.router.state to the hydration state
431
- "react.router.state": undefined,
432
- layers: state.layers.map((it) => ({
433
- ...it,
434
- error: it.error
435
- ? {
436
- ...it.error,
437
- name: it.error.name,
438
- message: it.error.message,
439
- stack: !this.alepha.isProduction() ? it.error.stack : undefined,
440
- }
441
- : undefined,
442
- index: undefined,
443
- path: undefined,
444
- element: undefined,
445
- route: undefined,
446
- })),
447
- };
448
-
449
- // create hydration data
450
- const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}</script>`;
451
-
452
- // inject app into template
453
- this.fillTemplate(response, app, script);
454
- }
455
-
456
- return response.html;
457
- }
458
-
459
- protected preprocessTemplate(template: string): PreprocessedTemplate {
460
- // Find the body close tag for script injection
461
- const bodyCloseMatch = template.match(/<\/body>/i);
462
- const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
463
-
464
- const beforeScript = template.substring(0, bodyCloseIndex);
465
- const afterScript = template.substring(bodyCloseIndex);
466
-
467
- // Check if there's an existing root div
468
- const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
469
-
470
- if (rootDivMatch) {
471
- // Split around the existing root div content
472
- const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
473
- const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
474
- const afterDiv = beforeScript.substring(afterDivStart);
475
-
476
- const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
477
- const afterApp = `</div>${afterDiv}`;
478
-
479
- return { beforeApp, afterApp, beforeScript: "", afterScript };
480
- }
481
-
482
- // No existing root div, find body tag to inject new div
483
- const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
484
- if (bodyMatch) {
485
- const beforeBody = beforeScript.substring(
486
- 0,
487
- bodyMatch.index! + bodyMatch[0].length,
488
- );
489
- const afterBody = beforeScript.substring(
490
- bodyMatch.index! + bodyMatch[0].length,
491
- );
492
-
493
- const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
494
- const afterApp = `</div>${afterBody}`;
495
-
496
- return { beforeApp, afterApp, beforeScript: "", afterScript };
497
- }
498
-
499
- // Fallback: no body tag found, just wrap everything
500
- return {
501
- beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
502
- afterApp: `</div>`,
503
- beforeScript,
504
- afterScript,
505
- };
506
- }
507
-
508
- protected fillTemplate(
509
- response: { html: string },
510
- app: string,
511
- script: string,
512
- ) {
513
- if (!this.preprocessedTemplate) {
514
- // Fallback to old logic if preprocessing failed
515
- this.preprocessedTemplate = this.preprocessTemplate(response.html);
516
- }
517
-
518
- // Pure concatenation - no regex replacements needed
519
- response.html =
520
- this.preprocessedTemplate.beforeApp +
521
- app +
522
- this.preprocessedTemplate.afterApp +
523
- script +
524
- this.preprocessedTemplate.afterScript;
525
- }
66
+ export class ReactServerProvider implements Configurable {
67
+ protected readonly log = $logger();
68
+ protected readonly alepha = $inject(Alepha);
69
+ protected readonly env = $env(envSchema);
70
+ protected readonly pageApi = $inject(ReactPageProvider);
71
+ protected readonly serverProvider = $inject(ServerProvider);
72
+ protected readonly serverStaticProvider = $inject(ServerStaticProvider);
73
+ protected readonly serverRouterProvider = $inject(ServerRouterProvider);
74
+ protected readonly serverTimingProvider = $inject(ServerTimingProvider);
75
+
76
+ protected readonly ROOT_DIV_REGEX = new RegExp(
77
+ `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
78
+ "is",
79
+ );
80
+ protected preprocessedTemplate: PreprocessedTemplate | null = null;
81
+
82
+ public options: ReactServerProviderOptions = {};
83
+
84
+ /**
85
+ * Configure the React server provider.
86
+ */
87
+ public readonly onConfigure = $hook({
88
+ on: "configure",
89
+ handler: async () => {
90
+ const pages = this.alepha.descriptors($page);
91
+
92
+ const ssrEnabled =
93
+ pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
94
+
95
+ this.alepha.state.set("react.server.ssr", ssrEnabled);
96
+
97
+ for (const page of pages) {
98
+ page.render = this.createRenderFunction(page.name);
99
+ page.fetch = async (options) => {
100
+ const response = await fetch(
101
+ `${this.serverProvider.hostname}/${page.pathname(options)}`,
102
+ );
103
+ const html = await response.text();
104
+ if (options?.html) return { html, response };
105
+ // take only text inside the root div
106
+ const match = html.match(this.ROOT_DIV_REGEX);
107
+ if (match) {
108
+ return { html: match[3], response };
109
+ }
110
+ throw new AlephaError("Invalid HTML response");
111
+ };
112
+ }
113
+
114
+ // development mode
115
+ if (this.alepha.isViteDev()) {
116
+ await this.configureVite(ssrEnabled);
117
+ return;
118
+ }
119
+
120
+ // production mode
121
+ let root = "";
122
+
123
+ // non-serverless mode only -> serve static files
124
+ if (!this.alepha.isServerless()) {
125
+ root = this.getPublicDirectory();
126
+ if (!root) {
127
+ this.log.warn(
128
+ "Missing static files, static file server will be disabled",
129
+ );
130
+ } else {
131
+ this.log.debug(`Using static files from: ${root}`);
132
+ await this.configureStaticServer(root);
133
+ }
134
+ }
135
+
136
+ if (ssrEnabled) {
137
+ await this.registerPages(async () => this.template);
138
+ this.log.info("SSR OK");
139
+ return;
140
+ }
141
+
142
+ // no SSR enabled, serve index.html for all unmatched routes
143
+ this.log.info("SSR is disabled, use History API fallback");
144
+ this.serverRouterProvider.createRoute({
145
+ path: "*",
146
+ handler: async ({ url, reply }) => {
147
+ if (url.pathname.includes(".")) {
148
+ // If the request is for a file (e.g., /style.css), do not fallback
149
+ reply.headers["content-type"] = "text/plain";
150
+ reply.body = "Not Found";
151
+ reply.status = 404;
152
+ return;
153
+ }
154
+
155
+ reply.headers["content-type"] = "text/html";
156
+
157
+ // serve index.html for all unmatched routes
158
+ return this.template;
159
+ },
160
+ });
161
+ },
162
+ });
163
+
164
+ public get template() {
165
+ return (
166
+ this.alepha.env.REACT_SERVER_TEMPLATE ??
167
+ "<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
168
+ );
169
+ }
170
+
171
+ protected async registerPages(templateLoader: TemplateLoader) {
172
+ // Preprocess template once
173
+ const template = await templateLoader();
174
+ if (template) {
175
+ this.preprocessedTemplate = this.preprocessTemplate(template);
176
+ }
177
+
178
+ for (const page of this.pageApi.getPages()) {
179
+ if (page.children?.length) {
180
+ continue;
181
+ }
182
+
183
+ this.log.debug(`+ ${page.match} -> ${page.name}`);
184
+
185
+ this.serverRouterProvider.createRoute({
186
+ ...page,
187
+ schema: undefined, // schema is handled by the page descriptor provider for now (shared by browser and server)
188
+ method: "GET",
189
+ path: page.match,
190
+ handler: this.createHandler(page, templateLoader),
191
+ });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Get the public directory path where static files are located.
197
+ */
198
+ protected getPublicDirectory(): string {
199
+ const maybe = [
200
+ join(process.cwd(), `dist/${this.env.REACT_SERVER_DIST}`),
201
+ join(process.cwd(), this.env.REACT_SERVER_DIST),
202
+ ];
203
+
204
+ for (const it of maybe) {
205
+ if (existsSync(it)) {
206
+ return it;
207
+ }
208
+ }
209
+
210
+ return "";
211
+ }
212
+
213
+ /**
214
+ * Configure the static file server to serve files from the given root directory.
215
+ */
216
+ protected async configureStaticServer(root: string) {
217
+ await this.serverStaticProvider.createStaticServer({
218
+ root,
219
+ path: this.env.REACT_SERVER_PREFIX,
220
+ cacheControl: {
221
+ maxAge: 3600,
222
+ immutable: true,
223
+ },
224
+ ...this.options.static,
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Configure Vite for SSR.
230
+ */
231
+ protected async configureVite(ssrEnabled: boolean) {
232
+ if (!ssrEnabled) {
233
+ // do nothing, vite will handle everything for us
234
+ return;
235
+ }
236
+
237
+ this.log.info("SSR (vite) OK");
238
+
239
+ const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
240
+
241
+ await this.registerPages(() =>
242
+ fetch(`${url}/index.html`)
243
+ .then((it) => it.text())
244
+ .catch(() => undefined),
245
+ );
246
+ }
247
+
248
+ /**
249
+ * For testing purposes, creates a render function that can be used.
250
+ */
251
+ protected createRenderFunction(name: string, withIndex = false) {
252
+ return async (
253
+ options: PageDescriptorRenderOptions = {},
254
+ ): Promise<PageDescriptorRenderResult> => {
255
+ const page = this.pageApi.page(name);
256
+ const url = new URL(this.pageApi.url(name, options));
257
+
258
+ const entry: Partial<ReactRouterState> = {
259
+ url,
260
+ params: options.params ?? {},
261
+ query: options.query ?? {},
262
+ onError: () => null,
263
+ layers: [],
264
+ meta: {},
265
+ };
266
+
267
+ const state = entry as ReactRouterState;
268
+
269
+ this.log.trace("Rendering", {
270
+ url,
271
+ });
272
+
273
+ await this.alepha.events.emit("react:server:render:begin", {
274
+ state,
275
+ });
276
+
277
+ const { redirect } = await this.pageApi.createLayers(
278
+ page,
279
+ state as ReactRouterState,
280
+ );
281
+
282
+ if (redirect) {
283
+ return { state, html: "", redirect };
284
+ }
285
+
286
+ if (!withIndex && !options.html) {
287
+ this.alepha.state.set("react.router.state", state);
288
+
289
+ return {
290
+ state,
291
+ html: renderToString(this.pageApi.root(state)),
292
+ };
293
+ }
294
+
295
+ const template = this.template ?? "";
296
+ const html = this.renderToHtml(template, state, options.hydration);
297
+
298
+ if (html instanceof Redirection) {
299
+ return { state, html: "", redirect };
300
+ }
301
+
302
+ const result = {
303
+ state,
304
+ html,
305
+ };
306
+
307
+ await this.alepha.events.emit("react:server:render:end", result);
308
+
309
+ return result;
310
+ };
311
+ }
312
+
313
+ protected createHandler(
314
+ route: PageRoute,
315
+ templateLoader: TemplateLoader,
316
+ ): ServerHandler {
317
+ return async (serverRequest) => {
318
+ const { url, reply, query, params } = serverRequest;
319
+ const template = await templateLoader();
320
+ if (!template) {
321
+ throw new AlephaError("Template not found");
322
+ }
323
+
324
+ this.log.trace("Rendering page", {
325
+ name: route.name,
326
+ });
327
+
328
+ const entry: Partial<ReactRouterState> = {
329
+ url,
330
+ params,
331
+ query,
332
+ onError: () => null,
333
+ layers: [],
334
+ };
335
+
336
+ const state = entry as ReactRouterState;
337
+
338
+ if (this.alepha.has(ServerLinksProvider)) {
339
+ this.alepha.state.set(
340
+ "api",
341
+ await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
342
+ user: (serverRequest as any).user, // TODO: fix type
343
+ authorization: serverRequest.headers.authorization,
344
+ }),
345
+ );
346
+ }
347
+
348
+ let target: PageRoute | undefined = route; // TODO: move to PageDescriptorProvider
349
+ while (target) {
350
+ if (route.can && !route.can()) {
351
+ // if the page is not accessible, return 403
352
+ reply.status = 403;
353
+ reply.headers["content-type"] = "text/plain";
354
+ return "Forbidden";
355
+ }
356
+ target = target.parent;
357
+ }
358
+
359
+ // TODO: SSR strategies
360
+ // - only when googlebot
361
+ // - only child pages
362
+ // if (page.client) {
363
+ // // if the page is a client-only page, return 404
364
+ // reply.status = 200;
365
+ // reply.headers["content-type"] = "text/html";
366
+ // reply.body = template;
367
+ // return;
368
+ // }
369
+
370
+ await this.alepha.events.emit("react:server:render:begin", {
371
+ request: serverRequest,
372
+ state,
373
+ });
374
+
375
+ this.serverTimingProvider.beginTiming("createLayers");
376
+
377
+ const { redirect } = await this.pageApi.createLayers(route, state);
378
+
379
+ this.serverTimingProvider.endTiming("createLayers");
380
+
381
+ if (redirect) {
382
+ return reply.redirect(redirect);
383
+ }
384
+
385
+ reply.headers["content-type"] = "text/html";
386
+
387
+ // by default, disable caching for SSR responses
388
+ // some plugins may override this
389
+ reply.headers["cache-control"] =
390
+ "no-store, no-cache, must-revalidate, proxy-revalidate";
391
+ reply.headers.pragma = "no-cache";
392
+ reply.headers.expires = "0";
393
+
394
+ const html = this.renderToHtml(template, state);
395
+ if (html instanceof Redirection) {
396
+ reply.redirect(
397
+ typeof html.redirect === "string"
398
+ ? html.redirect
399
+ : this.pageApi.href(html.redirect),
400
+ );
401
+ return;
402
+ }
403
+
404
+ const event = {
405
+ request: serverRequest,
406
+ state,
407
+ html,
408
+ };
409
+
410
+ await this.alepha.events.emit("react:server:render:end", event);
411
+
412
+ route.onServerResponse?.(serverRequest);
413
+
414
+ this.log.trace("Page rendered", {
415
+ name: route.name,
416
+ });
417
+
418
+ return event.html;
419
+ };
420
+ }
421
+
422
+ public renderToHtml(
423
+ template: string,
424
+ state: ReactRouterState,
425
+ hydration = true,
426
+ ): string | Redirection {
427
+ const element = this.pageApi.root(state);
428
+
429
+ // attach react router state to the http request context
430
+ this.alepha.state.set("react.router.state", state);
431
+
432
+ this.serverTimingProvider.beginTiming("renderToString");
433
+ let app = "";
434
+ try {
435
+ app = renderToString(element);
436
+ } catch (error) {
437
+ this.log.error(
438
+ "renderToString has failed, fallback to error handler",
439
+ error,
440
+ );
441
+ const element = state.onError(error as Error, state);
442
+ if (element instanceof Redirection) {
443
+ // if the error is a redirection, return the redirection URL
444
+ return element;
445
+ }
446
+
447
+ app = renderToString(element);
448
+ this.log.debug("Error handled successfully with fallback");
449
+ }
450
+ this.serverTimingProvider.endTiming("renderToString");
451
+
452
+ const response = {
453
+ html: template,
454
+ };
455
+
456
+ if (hydration) {
457
+ const { request, context, ...store } =
458
+ this.alepha.context.als?.getStore() ?? {}; /// TODO: als must be protected, find a way to iterate on alepha.state
459
+
460
+ const hydrationData: ReactHydrationState = {
461
+ ...store,
462
+ // map react.router.state to the hydration state
463
+ "react.router.state": undefined,
464
+ layers: state.layers.map((it) => ({
465
+ ...it,
466
+ error: it.error
467
+ ? {
468
+ ...it.error,
469
+ name: it.error.name,
470
+ message: it.error.message,
471
+ stack: !this.alepha.isProduction() ? it.error.stack : undefined,
472
+ }
473
+ : undefined,
474
+ index: undefined,
475
+ path: undefined,
476
+ element: undefined,
477
+ route: undefined,
478
+ })),
479
+ };
480
+
481
+ // create hydration data
482
+ const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}</script>`;
483
+
484
+ // inject app into template
485
+ this.fillTemplate(response, app, script);
486
+ }
487
+
488
+ return response.html;
489
+ }
490
+
491
+ protected preprocessTemplate(template: string): PreprocessedTemplate {
492
+ // Find the body close tag for script injection
493
+ const bodyCloseMatch = template.match(/<\/body>/i);
494
+ const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
495
+
496
+ const beforeScript = template.substring(0, bodyCloseIndex);
497
+ const afterScript = template.substring(bodyCloseIndex);
498
+
499
+ // Check if there's an existing root div
500
+ const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
501
+
502
+ if (rootDivMatch) {
503
+ // Split around the existing root div content
504
+ const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
505
+ const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
506
+ const afterDiv = beforeScript.substring(afterDivStart);
507
+
508
+ const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
509
+ const afterApp = `</div>${afterDiv}`;
510
+
511
+ return { beforeApp, afterApp, beforeScript: "", afterScript };
512
+ }
513
+
514
+ // No existing root div, find body tag to inject new div
515
+ const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
516
+ if (bodyMatch) {
517
+ const beforeBody = beforeScript.substring(
518
+ 0,
519
+ bodyMatch.index! + bodyMatch[0].length,
520
+ );
521
+ const afterBody = beforeScript.substring(
522
+ bodyMatch.index! + bodyMatch[0].length,
523
+ );
524
+
525
+ const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
526
+ const afterApp = `</div>${afterBody}`;
527
+
528
+ return { beforeApp, afterApp, beforeScript: "", afterScript };
529
+ }
530
+
531
+ // Fallback: no body tag found, just wrap everything
532
+ return {
533
+ beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
534
+ afterApp: `</div>`,
535
+ beforeScript,
536
+ afterScript,
537
+ };
538
+ }
539
+
540
+ protected fillTemplate(
541
+ response: { html: string },
542
+ app: string,
543
+ script: string,
544
+ ) {
545
+ if (!this.preprocessedTemplate) {
546
+ // Fallback to old logic if preprocessing failed
547
+ this.preprocessedTemplate = this.preprocessTemplate(response.html);
548
+ }
549
+
550
+ // Pure concatenation - no regex replacements needed
551
+ response.html =
552
+ this.preprocessedTemplate.beforeApp +
553
+ app +
554
+ this.preprocessedTemplate.afterApp +
555
+ script +
556
+ this.preprocessedTemplate.afterScript;
557
+ }
526
558
  }
527
559
 
528
560
  type TemplateLoader = () => Promise<string | undefined>;
529
561
 
530
562
  interface PreprocessedTemplate {
531
- beforeApp: string;
532
- afterApp: string;
533
- beforeScript: string;
534
- afterScript: string;
563
+ beforeApp: string;
564
+ afterApp: string;
565
+ beforeScript: string;
566
+ afterScript: string;
535
567
  }