@alepha/react 0.14.2 → 0.14.4

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