@alepha/react 0.14.3 → 0.15.0

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