@alepha/react 0.10.5 → 0.10.7

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,535 @@
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 Static,
10
+ t,
11
11
  } from "@alepha/core";
12
12
  import { $logger } from "@alepha/logger";
13
13
  import {
14
- type ServerHandler,
15
- ServerProvider,
16
- ServerRouterProvider,
17
- ServerTimingProvider,
14
+ type ServerHandler,
15
+ ServerProvider,
16
+ ServerRouterProvider,
17
+ ServerTimingProvider,
18
18
  } from "@alepha/server";
19
19
  import { ServerLinksProvider } from "@alepha/server-links";
20
20
  import { ServerStaticProvider } from "@alepha/server-static";
21
21
  import { renderToString } from "react-dom/server";
22
22
  import {
23
- $page,
24
- type PageDescriptorRenderOptions,
25
- type PageDescriptorRenderResult,
23
+ $page,
24
+ type PageDescriptorRenderOptions,
25
+ type PageDescriptorRenderResult,
26
26
  } from "../descriptors/$page.ts";
27
27
  import { Redirection } from "../errors/Redirection.ts";
28
28
  import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
29
29
  import {
30
- type PageRoute,
31
- ReactPageProvider,
32
- type ReactRouterState,
30
+ type PageRoute,
31
+ ReactPageProvider,
32
+ type ReactRouterState,
33
33
  } from "./ReactPageProvider.ts";
34
34
 
35
35
  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
- ),
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
+ ),
45
45
  });
46
46
 
47
47
  declare module "@alepha/core" {
48
- interface Env extends Partial<Static<typeof envSchema>> {}
49
- interface State {
50
- "react.server.ssr"?: boolean;
51
- }
48
+ interface Env extends Partial<Static<typeof envSchema>> {}
49
+ interface State {
50
+ "react.server.ssr"?: boolean;
51
+ }
52
52
  }
53
53
 
54
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
- }
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.isViteDev()) {
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
+ }
526
526
  }
527
527
 
528
528
  type TemplateLoader = () => Promise<string | undefined>;
529
529
 
530
530
  interface PreprocessedTemplate {
531
- beforeApp: string;
532
- afterApp: string;
533
- beforeScript: string;
534
- afterScript: string;
531
+ beforeApp: string;
532
+ afterApp: string;
533
+ beforeScript: string;
534
+ afterScript: string;
535
535
  }