@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
@@ -3,14 +3,15 @@ import { $atom, $env, $hook, $inject, $module, $use, Alepha, AlephaError, KIND,
3
3
  import { AlephaDateTime, DateTimeProvider } from "alepha/datetime";
4
4
  import { $logger } from "alepha/logger";
5
5
  import { AlephaServerLinks, LinkProvider, ServerLinksProvider } from "alepha/server/links";
6
+ import { BrowserHeadProvider, ServerHeadProvider } from "@alepha/react/head";
6
7
  import { RouterProvider } from "alepha/router";
7
8
  import { StrictMode, createContext, createElement, memo, use, useRef, useState } from "react";
8
9
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
10
  import { AlephaServer, HttpClient, ServerProvider, ServerRouterProvider, ServerTimingProvider } from "alepha/server";
10
- import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
+ import { FileSystemProvider } from "alepha/file";
12
13
  import { ServerStaticProvider } from "alepha/server/static";
13
- import { renderToString } from "react-dom/server";
14
+ import { renderToReadableStream } from "react-dom/server";
14
15
  import { AlephaServerCache } from "alepha/server/cache";
15
16
  import { $auth, AlephaServerAuth, alephaServerAuthRoutes, tokenResponseSchema, userinfoResponseSchema } from "alepha/server/auth";
16
17
 
@@ -43,6 +44,15 @@ var ReactPageService = class {
43
44
  }
44
45
  };
45
46
 
47
+ //#endregion
48
+ //#region ../../src/router/constants/PAGE_PRELOAD_KEY.ts
49
+ /**
50
+ * Symbol key for SSR module preloading path.
51
+ * Using Symbol.for() allows the Vite plugin to inject this at build time.
52
+ * @internal
53
+ */
54
+ const PAGE_PRELOAD_KEY = Symbol.for("alepha.page.preload");
55
+
46
56
  //#endregion
47
57
  //#region ../../src/router/primitives/$page.ts
48
58
  /**
@@ -57,7 +67,7 @@ var ReactPageService = class {
57
67
  * - Type-safe URL parameter and query string validation
58
68
  *
59
69
  * **Data Loading**
60
- * - Server-side data fetching with the `resolve` function
70
+ * - Server-side data fetching with the `loader` function
61
71
  * - Automatic serialization and hydration for SSR
62
72
  * - Access to request context, URL params, and parent data
63
73
  *
@@ -94,7 +104,7 @@ var ReactPageService = class {
94
104
  * params: t.object({ id: t.integer() }),
95
105
  * query: t.object({ tab: t.optional(t.text()) })
96
106
  * },
97
- * resolve: async ({ params }) => {
107
+ * loader: async ({ params }) => {
98
108
  * const user = await userApi.getUser(params.id);
99
109
  * return { user };
100
110
  * },
@@ -107,7 +117,7 @@ var ReactPageService = class {
107
117
  * const projectSection = $page({
108
118
  * path: "/projects/:id",
109
119
  * children: () => [projectBoard, projectSettings],
110
- * resolve: async ({ params }) => {
120
+ * loader: async ({ params }) => {
111
121
  * const project = await projectApi.get(params.id);
112
122
  * return { project };
113
123
  * },
@@ -126,7 +136,7 @@ var ReactPageService = class {
126
136
  * static: {
127
137
  * entries: posts.map(p => ({ params: { slug: p.slug } }))
128
138
  * },
129
- * resolve: async ({ params }) => {
139
+ * loader: async ({ params }) => {
130
140
  * const post = await loadPost(params.slug);
131
141
  * return { post };
132
142
  * }
@@ -607,7 +617,7 @@ const RouterLayerContext = createContext(void 0);
607
617
  * import { Redirection } from "@alepha/react";
608
618
  *
609
619
  * const MyPage = $page({
610
- * resolve: async () => {
620
+ * loader: async () => {
611
621
  * if (needRedirect) {
612
622
  * throw new Redirection("/new-path");
613
623
  * }
@@ -763,13 +773,13 @@ function parseAnimation(animationLike, state, type = "enter") {
763
773
 
764
774
  //#endregion
765
775
  //#region ../../src/router/providers/ReactPageProvider.ts
766
- const envSchema$2 = t.object({ REACT_STRICT_MODE: t.boolean({ default: true }) });
776
+ const envSchema$1 = t.object({ REACT_STRICT_MODE: t.boolean({ default: true }) });
767
777
  /**
768
778
  * Handle page routes for React applications. (Browser and Server)
769
779
  */
770
780
  var ReactPageProvider = class {
771
781
  log = $logger();
772
- env = $env(envSchema$2);
782
+ env = $env(envSchema$1);
773
783
  alepha = $inject(Alepha);
774
784
  pages = [];
775
785
  getPages() {
@@ -889,11 +899,11 @@ var ReactPageProvider = class {
889
899
  }
890
900
  forceRefresh = true;
891
901
  }
892
- if (!route$1.resolve) continue;
902
+ if (!route$1.loader) continue;
893
903
  try {
894
904
  const args = Object.create(state);
895
905
  Object.assign(args, config, context);
896
- const props = await route$1.resolve?.(args) ?? {};
906
+ const props = await route$1.loader?.(args) ?? {};
897
907
  it.props = { ...props };
898
908
  context = {
899
909
  ...context,
@@ -901,7 +911,7 @@ var ReactPageProvider = class {
901
911
  };
902
912
  } catch (e) {
903
913
  if (e instanceof Redirection) return { redirect: e.redirect };
904
- this.log.error("Page resolver has failed", e);
914
+ this.log.error("Page loader has failed", e);
905
915
  it.error = e;
906
916
  break;
907
917
  }
@@ -1096,6 +1106,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
1096
1106
  log = $logger();
1097
1107
  alepha = $inject(Alepha);
1098
1108
  pageApi = $inject(ReactPageProvider);
1109
+ browserHeadProvider = $inject(BrowserHeadProvider);
1099
1110
  add(entry) {
1100
1111
  this.pageApi.add(entry);
1101
1112
  }
@@ -1166,6 +1177,7 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
1166
1177
  this.alepha.store.set("alepha.react.router.state", state);
1167
1178
  await this.alepha.events.emit("react:action:end", { type: "transition" });
1168
1179
  await this.alepha.events.emit("react:transition:end", { state });
1180
+ this.browserHeadProvider.fillAndRenderHead(state);
1169
1181
  }
1170
1182
  root(state) {
1171
1183
  return this.pageApi.root(state);
@@ -1174,7 +1186,6 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
1174
1186
 
1175
1187
  //#endregion
1176
1188
  //#region ../../src/router/providers/ReactBrowserProvider.ts
1177
- const envSchema$1 = t.object({ REACT_ROOT_ID: t.text({ default: "root" }) });
1178
1189
  /**
1179
1190
  * React browser renderer configuration atom
1180
1191
  */
@@ -1184,18 +1195,21 @@ const reactBrowserOptions = $atom({
1184
1195
  default: { scrollRestoration: "top" }
1185
1196
  });
1186
1197
  var ReactBrowserProvider = class {
1187
- env = $env(envSchema$1);
1188
1198
  log = $logger();
1189
1199
  client = $inject(LinkProvider);
1190
1200
  alepha = $inject(Alepha);
1191
1201
  router = $inject(ReactBrowserRouterProvider);
1192
1202
  dateTimeProvider = $inject(DateTimeProvider);
1203
+ browserHeadProvider = $inject(BrowserHeadProvider);
1193
1204
  options = $use(reactBrowserOptions);
1205
+ get rootId() {
1206
+ return "root";
1207
+ }
1194
1208
  getRootElement() {
1195
- const root = this.document.getElementById(this.env.REACT_ROOT_ID);
1209
+ const root = this.document.getElementById(this.rootId);
1196
1210
  if (root) return root;
1197
1211
  const div = this.document.createElement("div");
1198
- div.id = this.env.REACT_ROOT_ID;
1212
+ div.id = this.rootId;
1199
1213
  this.document.body.prepend(div);
1200
1214
  return div;
1201
1215
  }
@@ -1328,6 +1342,7 @@ var ReactBrowserProvider = class {
1328
1342
  hydration,
1329
1343
  state: this.state
1330
1344
  });
1345
+ this.browserHeadProvider.fillAndRenderHead(this.state);
1331
1346
  window.addEventListener("popstate", () => {
1332
1347
  if (this.base + this.state.url.pathname === this.location.pathname) return;
1333
1348
  this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
@@ -1470,46 +1485,750 @@ var ReactRouter = class {
1470
1485
  };
1471
1486
 
1472
1487
  //#endregion
1473
- //#region ../../src/router/providers/ReactServerProvider.ts
1474
- const envSchema = t.object({
1475
- REACT_SSR_ENABLED: t.optional(t.boolean()),
1476
- REACT_ROOT_ID: t.text({ default: "root" })
1477
- });
1488
+ //#region ../../src/router/providers/ReactServerTemplateProvider.ts
1478
1489
  /**
1479
- * React server provider configuration atom
1490
+ * Handles HTML template parsing, preprocessing, and streaming for SSR.
1491
+ *
1492
+ * Responsibilities:
1493
+ * - Parse template once at startup into logical slots
1494
+ * - Pre-encode static parts as Uint8Array for zero-copy streaming
1495
+ * - Render dynamic parts (attributes, head content) efficiently
1496
+ * - Build hydration data for client-side rehydration
1497
+ *
1498
+ * This provider is injected into ReactServerProvider to handle all
1499
+ * template-related operations, keeping ReactServerProvider focused
1500
+ * on request handling and React rendering coordination.
1480
1501
  */
1481
- const reactServerOptions = $atom({
1482
- name: "alepha.react.server.options",
1483
- schema: t.object({
1484
- publicDir: t.string(),
1485
- staticServer: t.object({
1486
- disabled: t.boolean(),
1487
- path: t.string({ description: "URL path where static files will be served." })
1488
- })
1489
- }),
1490
- default: {
1491
- publicDir: "public",
1492
- staticServer: {
1493
- disabled: false,
1494
- path: "/"
1502
+ var ReactServerTemplateProvider = class {
1503
+ log = $logger();
1504
+ alepha = $inject(Alepha);
1505
+ /**
1506
+ * Shared TextEncoder instance - reused across all requests.
1507
+ */
1508
+ encoder = new TextEncoder();
1509
+ /**
1510
+ * Pre-encoded common strings for streaming.
1511
+ */
1512
+ ENCODED = {
1513
+ HYDRATION_PREFIX: this.encoder.encode("<script>window.__ssr="),
1514
+ HYDRATION_SUFFIX: this.encoder.encode("<\/script>"),
1515
+ EMPTY: this.encoder.encode("")
1516
+ };
1517
+ /**
1518
+ * Cached template slots - parsed once, reused for all requests.
1519
+ */
1520
+ slots = null;
1521
+ /**
1522
+ * Root element ID for React mounting.
1523
+ */
1524
+ get rootId() {
1525
+ return "root";
1526
+ }
1527
+ /**
1528
+ * Regex pattern for matching the root div and extracting its content.
1529
+ */
1530
+ get rootDivRegex() {
1531
+ return new RegExp(`<div([^>]*)\\s+id=["']${this.rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`, "i");
1532
+ }
1533
+ /**
1534
+ * Extract the content inside the root div from HTML.
1535
+ *
1536
+ * @param html - Full HTML string
1537
+ * @returns The content inside the root div, or undefined if not found
1538
+ */
1539
+ extractRootContent(html) {
1540
+ return html.match(this.rootDivRegex)?.[3];
1541
+ }
1542
+ /**
1543
+ * Check if template has been parsed and slots are available.
1544
+ */
1545
+ isReady() {
1546
+ return this.slots !== null;
1547
+ }
1548
+ /**
1549
+ * Get the parsed template slots.
1550
+ * Throws if template hasn't been parsed yet.
1551
+ */
1552
+ getSlots() {
1553
+ if (!this.slots) throw new AlephaError("Template not parsed. Call parseTemplate() during configuration.");
1554
+ return this.slots;
1555
+ }
1556
+ /**
1557
+ * Parse an HTML template into logical slots for efficient streaming.
1558
+ *
1559
+ * This should be called once during server startup/configuration.
1560
+ * The parsed slots are cached and reused for all requests.
1561
+ *
1562
+ * @param template - The HTML template string (typically index.html)
1563
+ */
1564
+ parseTemplate(template) {
1565
+ this.log.debug("Parsing template into slots");
1566
+ const rootId = this.rootId;
1567
+ const doctypeMatch = template.match(/<!DOCTYPE[^>]*>/i);
1568
+ const doctype = doctypeMatch?.[0] ?? "<!DOCTYPE html>";
1569
+ let remaining = doctypeMatch ? template.slice(doctypeMatch.index + doctypeMatch[0].length) : template;
1570
+ const htmlMatch = remaining.match(/<html([^>]*)>/i);
1571
+ const htmlAttrsStr = htmlMatch?.[1]?.trim() ?? "";
1572
+ const htmlOriginalAttrs = this.parseAttributes(htmlAttrsStr);
1573
+ remaining = htmlMatch ? remaining.slice(htmlMatch.index + htmlMatch[0].length) : remaining;
1574
+ const headMatch = remaining.match(/<head([^>]*)>([\s\S]*?)<\/head>/i);
1575
+ const headOriginalContent = headMatch?.[2]?.trim() ?? "";
1576
+ remaining = headMatch ? remaining.slice(headMatch.index + headMatch[0].length) : remaining;
1577
+ const bodyMatch = remaining.match(/<body([^>]*)>/i);
1578
+ const bodyAttrsStr = bodyMatch?.[1]?.trim() ?? "";
1579
+ const bodyOriginalAttrs = this.parseAttributes(bodyAttrsStr);
1580
+ const bodyStartIndex = bodyMatch ? bodyMatch.index + bodyMatch[0].length : 0;
1581
+ remaining = remaining.slice(bodyStartIndex);
1582
+ const rootDivRegex = new RegExp(`<div([^>]*)\\s+id=["']${rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`, "i");
1583
+ const rootMatch = remaining.match(rootDivRegex);
1584
+ let beforeRoot = "";
1585
+ let afterRoot = "";
1586
+ let rootAttrs = "";
1587
+ if (rootMatch) {
1588
+ beforeRoot = remaining.slice(0, rootMatch.index).trim();
1589
+ const rootEndIndex = rootMatch.index + rootMatch[0].length;
1590
+ const bodyCloseIndex = remaining.indexOf("</body>");
1591
+ afterRoot = bodyCloseIndex > rootEndIndex ? remaining.slice(rootEndIndex, bodyCloseIndex).trim() : "";
1592
+ rootAttrs = `${rootMatch[1] ?? ""}${rootMatch[2] ?? ""}`.trim();
1593
+ } else {
1594
+ const bodyCloseIndex = remaining.indexOf("</body>");
1595
+ if (bodyCloseIndex > 0) beforeRoot = remaining.slice(0, bodyCloseIndex).trim();
1596
+ }
1597
+ const rootOpenTag = rootAttrs ? `<div ${rootAttrs} id="${rootId}">` : `<div id="${rootId}">`;
1598
+ this.slots = {
1599
+ doctype: this.encoder.encode(doctype + "\n"),
1600
+ htmlOpen: this.encoder.encode("<html"),
1601
+ htmlClose: this.encoder.encode(">\n"),
1602
+ headOpen: this.encoder.encode("<head>"),
1603
+ headClose: this.encoder.encode("</head>\n"),
1604
+ bodyOpen: this.encoder.encode("<body"),
1605
+ bodyClose: this.encoder.encode(">\n"),
1606
+ rootOpen: this.encoder.encode(rootOpenTag),
1607
+ rootClose: this.encoder.encode("</div>\n"),
1608
+ scriptClose: this.encoder.encode("</body>\n</html>"),
1609
+ htmlOriginalAttrs,
1610
+ bodyOriginalAttrs,
1611
+ headOriginalContent,
1612
+ beforeRoot,
1613
+ afterRoot
1614
+ };
1615
+ this.log.debug("Template parsed successfully", {
1616
+ hasHtmlAttrs: Object.keys(htmlOriginalAttrs).length > 0,
1617
+ hasBodyAttrs: Object.keys(bodyOriginalAttrs).length > 0,
1618
+ hasHeadContent: headOriginalContent.length > 0,
1619
+ hasBeforeRoot: beforeRoot.length > 0,
1620
+ hasAfterRoot: afterRoot.length > 0
1621
+ });
1622
+ return this.slots;
1623
+ }
1624
+ /**
1625
+ * Parse HTML attributes string into a record.
1626
+ *
1627
+ * Handles: key="value", key='value', key=value, and boolean key
1628
+ */
1629
+ parseAttributes(attrStr) {
1630
+ const attrs = {};
1631
+ if (!attrStr) return attrs;
1632
+ const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
1633
+ let match;
1634
+ while (match = attrRegex.exec(attrStr)) {
1635
+ const key = match[1];
1636
+ attrs[key] = match[2] ?? match[3] ?? match[4] ?? "";
1637
+ }
1638
+ return attrs;
1639
+ }
1640
+ /**
1641
+ * Render attributes record to HTML string.
1642
+ *
1643
+ * @param attrs - Attributes to render
1644
+ * @returns HTML attribute string like ` lang="en" class="dark"`
1645
+ */
1646
+ renderAttributes(attrs) {
1647
+ const entries = Object.entries(attrs);
1648
+ if (entries.length === 0) return "";
1649
+ return entries.map(([key, value]) => ` ${key}="${this.escapeHtml(value)}"`).join("");
1650
+ }
1651
+ /**
1652
+ * Render merged HTML attributes (original + dynamic).
1653
+ */
1654
+ renderMergedHtmlAttrs(dynamicAttrs) {
1655
+ const merged = {
1656
+ ...this.getSlots().htmlOriginalAttrs,
1657
+ ...dynamicAttrs
1658
+ };
1659
+ return this.renderAttributes(merged);
1660
+ }
1661
+ /**
1662
+ * Render merged body attributes (original + dynamic).
1663
+ */
1664
+ renderMergedBodyAttrs(dynamicAttrs) {
1665
+ const merged = {
1666
+ ...this.getSlots().bodyOriginalAttrs,
1667
+ ...dynamicAttrs
1668
+ };
1669
+ return this.renderAttributes(merged);
1670
+ }
1671
+ /**
1672
+ * Render head content (title, meta, link, script tags).
1673
+ *
1674
+ * @param head - Head data to render
1675
+ * @param includeOriginal - Whether to include original head content
1676
+ * @returns HTML string with head content
1677
+ */
1678
+ renderHeadContent(head, includeOriginal = true) {
1679
+ const slots = this.getSlots();
1680
+ let content = "";
1681
+ if (includeOriginal && slots.headOriginalContent) content += slots.headOriginalContent;
1682
+ if (!head) return content;
1683
+ if (head.title) if (content.includes("<title>")) content = content.replace(/<title>.*?<\/title>/i, `<title>${this.escapeHtml(head.title)}</title>`);
1684
+ else content += `<title>${this.escapeHtml(head.title)}</title>\n`;
1685
+ if (head.meta) for (const meta of head.meta) content += this.renderMetaTag(meta);
1686
+ if (head.link) for (const link of head.link) content += this.renderLinkTag(link);
1687
+ if (head.script) for (const script of head.script) content += this.renderScriptTag(script);
1688
+ return content;
1689
+ }
1690
+ /**
1691
+ * Render a meta tag.
1692
+ */
1693
+ renderMetaTag(meta) {
1694
+ if (meta.property) return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
1695
+ if (meta.name) return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
1696
+ return "";
1697
+ }
1698
+ /**
1699
+ * Render a link tag.
1700
+ */
1701
+ renderLinkTag(link) {
1702
+ let tag = `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
1703
+ if (link.as) tag += ` as="${this.escapeHtml(link.as)}"`;
1704
+ if (link.crossorigin != null) tag += " crossorigin=\"\"";
1705
+ tag += ">\n";
1706
+ return tag;
1707
+ }
1708
+ /**
1709
+ * Render a script tag.
1710
+ */
1711
+ renderScriptTag(script) {
1712
+ return `<script ${Object.entries(script).filter(([, value]) => value !== false).map(([key, value]) => {
1713
+ if (value === true) return key;
1714
+ return `${key}="${this.escapeHtml(String(value))}"`;
1715
+ }).join(" ")}><\/script>\n`;
1716
+ }
1717
+ /**
1718
+ * Escape HTML special characters.
1719
+ */
1720
+ escapeHtml(str) {
1721
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1722
+ }
1723
+ /**
1724
+ * Safely serialize data to JSON for embedding in HTML.
1725
+ * Escapes characters that could break out of script tags.
1726
+ */
1727
+ safeJsonSerialize(data) {
1728
+ return JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
1729
+ }
1730
+ /**
1731
+ * Build hydration data from router state.
1732
+ *
1733
+ * This creates the data structure that will be serialized to window.__ssr
1734
+ * for client-side rehydration.
1735
+ */
1736
+ buildHydrationData(state) {
1737
+ const { request, context, ...store } = this.alepha.context.als?.getStore() ?? {};
1738
+ const hydrationData = { layers: state.layers.map((layer) => ({
1739
+ name: layer.name,
1740
+ props: layer.props,
1741
+ config: layer.config,
1742
+ error: layer.error ? {
1743
+ ...layer.error,
1744
+ name: layer.error.name,
1745
+ message: layer.error.message,
1746
+ stack: !this.alepha.isProduction() ? layer.error.stack : void 0
1747
+ } : void 0
1748
+ })) };
1749
+ for (const [key, value] of Object.entries(store)) if (key.charAt(0) !== "_" && key !== "alepha.react.router.state" && key !== "registry") hydrationData[key] = value;
1750
+ return hydrationData;
1751
+ }
1752
+ /**
1753
+ * Encode a string to Uint8Array using the shared encoder.
1754
+ */
1755
+ encode(str) {
1756
+ return this.encoder.encode(str);
1757
+ }
1758
+ /**
1759
+ * Get the pre-encoded hydration script prefix.
1760
+ */
1761
+ get hydrationPrefix() {
1762
+ return this.ENCODED.HYDRATION_PREFIX;
1763
+ }
1764
+ /**
1765
+ * Get the pre-encoded hydration script suffix.
1766
+ */
1767
+ get hydrationSuffix() {
1768
+ return this.ENCODED.HYDRATION_SUFFIX;
1769
+ }
1770
+ /**
1771
+ * Create a ReadableStream that streams the HTML template with React content.
1772
+ *
1773
+ * This is the main entry point for SSR streaming. It:
1774
+ * 1. Sends <head> immediately (browser starts downloading assets)
1775
+ * 2. Streams React content as it renders
1776
+ * 3. Appends hydration script and closing tags
1777
+ *
1778
+ * @param reactStream - ReadableStream from renderToReadableStream
1779
+ * @param state - Router state with head data
1780
+ * @param options - Streaming options
1781
+ */
1782
+ createHtmlStream(reactStream, state, options = {}) {
1783
+ const { hydration = true, onError } = options;
1784
+ const slots = this.getSlots();
1785
+ const head = state.head;
1786
+ const encoder = this.encoder;
1787
+ return new ReadableStream({ start: async (controller) => {
1788
+ try {
1789
+ controller.enqueue(slots.doctype);
1790
+ controller.enqueue(slots.htmlOpen);
1791
+ controller.enqueue(encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)));
1792
+ controller.enqueue(slots.htmlClose);
1793
+ controller.enqueue(slots.headOpen);
1794
+ if (this.earlyHeadContent) controller.enqueue(encoder.encode(this.earlyHeadContent));
1795
+ controller.enqueue(encoder.encode(this.renderHeadContent(head)));
1796
+ controller.enqueue(slots.headClose);
1797
+ controller.enqueue(slots.bodyOpen);
1798
+ controller.enqueue(encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)));
1799
+ controller.enqueue(slots.bodyClose);
1800
+ if (slots.beforeRoot) controller.enqueue(encoder.encode(slots.beforeRoot));
1801
+ controller.enqueue(slots.rootOpen);
1802
+ const reader = reactStream.getReader();
1803
+ try {
1804
+ while (true) {
1805
+ const { done, value } = await reader.read();
1806
+ if (done) break;
1807
+ controller.enqueue(value);
1808
+ }
1809
+ } finally {
1810
+ reader.releaseLock();
1811
+ }
1812
+ controller.enqueue(slots.rootClose);
1813
+ if (slots.afterRoot) controller.enqueue(encoder.encode(slots.afterRoot));
1814
+ if (hydration) {
1815
+ const hydrationData = this.buildHydrationData(state);
1816
+ controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
1817
+ controller.enqueue(encoder.encode(this.safeJsonSerialize(hydrationData)));
1818
+ controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
1819
+ }
1820
+ controller.enqueue(slots.scriptClose);
1821
+ controller.close();
1822
+ } catch (error) {
1823
+ onError?.(error);
1824
+ controller.error(error);
1825
+ }
1826
+ } });
1827
+ }
1828
+ /**
1829
+ * Early head content for preloading.
1830
+ *
1831
+ * Contains entry assets (JS + CSS) that are always required and can be
1832
+ * sent before page loaders run.
1833
+ */
1834
+ earlyHeadContent = "";
1835
+ /**
1836
+ * Set the early head content (entry script + CSS).
1837
+ *
1838
+ * Also strips these assets from the original head content to avoid duplicates,
1839
+ * since we're moving them to the early phase.
1840
+ *
1841
+ * @param content - HTML string with entry assets
1842
+ * @param entryAssets - Entry asset paths to strip from original head
1843
+ */
1844
+ setEarlyHeadContent(content, entryAssets) {
1845
+ this.earlyHeadContent = content;
1846
+ if (entryAssets && this.slots) {
1847
+ let headContent = this.slots.headOriginalContent;
1848
+ if (entryAssets.js) {
1849
+ const scriptPattern = new RegExp(`<script[^>]*\\ssrc=["']${this.escapeRegExp(entryAssets.js)}["'][^>]*>\\s*<\/script>\\s*`, "gi");
1850
+ headContent = headContent.replace(scriptPattern, "");
1851
+ }
1852
+ for (const css of entryAssets.css) {
1853
+ const linkPattern = new RegExp(`<link[^>]*\\shref=["']${this.escapeRegExp(css)}["'][^>]*>\\s*`, "gi");
1854
+ headContent = headContent.replace(linkPattern, "");
1855
+ }
1856
+ this.slots.headOriginalContent = headContent.trim();
1495
1857
  }
1496
1858
  }
1859
+ /**
1860
+ * Escape special regex characters in a string.
1861
+ */
1862
+ escapeRegExp(str) {
1863
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1864
+ }
1865
+ /**
1866
+ * Create an optimized HTML stream with early head streaming.
1867
+ *
1868
+ * This version sends critical assets (entry.js, CSS) BEFORE page loaders run,
1869
+ * allowing the browser to start downloading them immediately.
1870
+ *
1871
+ * Flow:
1872
+ * 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE)
1873
+ * 2. Run async work (createLayers, etc.)
1874
+ * 3. Send rest of head, body, React content, hydration
1875
+ *
1876
+ * @param globalHead - Global head with htmlAttributes (from $head primitives)
1877
+ * @param asyncWork - Async function to run between early head and rest of stream
1878
+ * @param options - Streaming options
1879
+ */
1880
+ createEarlyHtmlStream(globalHead, asyncWork, options = {}) {
1881
+ const { hydration = true, onError } = options;
1882
+ const slots = this.getSlots();
1883
+ const encoder = this.encoder;
1884
+ return new ReadableStream({ start: async (controller) => {
1885
+ try {
1886
+ controller.enqueue(slots.doctype);
1887
+ controller.enqueue(slots.htmlOpen);
1888
+ controller.enqueue(encoder.encode(this.renderMergedHtmlAttrs(globalHead?.htmlAttributes)));
1889
+ controller.enqueue(slots.htmlClose);
1890
+ controller.enqueue(slots.headOpen);
1891
+ if (this.earlyHeadContent) controller.enqueue(encoder.encode(this.earlyHeadContent));
1892
+ const result = await asyncWork();
1893
+ if (!result || "redirect" in result) {
1894
+ if (result && "redirect" in result) {
1895
+ this.log.debug("Loader redirect detected after streaming started, using meta refresh", { redirect: result.redirect });
1896
+ controller.enqueue(encoder.encode(`<meta http-equiv="refresh" content="0; url=${this.escapeHtml(result.redirect)}">\n`));
1897
+ }
1898
+ controller.enqueue(slots.headClose);
1899
+ controller.enqueue(encoder.encode("<body></body></html>"));
1900
+ controller.close();
1901
+ return;
1902
+ }
1903
+ const { state, reactStream } = result;
1904
+ const head = state.head;
1905
+ controller.enqueue(encoder.encode(this.renderHeadContent(head)));
1906
+ controller.enqueue(slots.headClose);
1907
+ controller.enqueue(slots.bodyOpen);
1908
+ controller.enqueue(encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)));
1909
+ controller.enqueue(slots.bodyClose);
1910
+ if (slots.beforeRoot) controller.enqueue(encoder.encode(slots.beforeRoot));
1911
+ controller.enqueue(slots.rootOpen);
1912
+ const reader = reactStream.getReader();
1913
+ try {
1914
+ while (true) {
1915
+ const { done, value } = await reader.read();
1916
+ if (done) break;
1917
+ controller.enqueue(value);
1918
+ }
1919
+ } finally {
1920
+ reader.releaseLock();
1921
+ }
1922
+ controller.enqueue(slots.rootClose);
1923
+ if (slots.afterRoot) controller.enqueue(encoder.encode(slots.afterRoot));
1924
+ if (hydration) {
1925
+ const hydrationData = this.buildHydrationData(state);
1926
+ controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
1927
+ controller.enqueue(encoder.encode(this.safeJsonSerialize(hydrationData)));
1928
+ controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
1929
+ }
1930
+ controller.enqueue(slots.scriptClose);
1931
+ controller.close();
1932
+ } catch (error) {
1933
+ onError?.(error);
1934
+ controller.error(error);
1935
+ }
1936
+ } });
1937
+ }
1938
+ };
1939
+
1940
+ //#endregion
1941
+ //#region ../../src/router/atoms/ssrManifestAtom.ts
1942
+ /**
1943
+ * Schema for the SSR manifest atom.
1944
+ */
1945
+ const ssrManifestAtomSchema = t.object({
1946
+ preload: t.optional(t.record(t.string(), t.string())),
1947
+ ssr: t.optional(t.record(t.string(), t.array(t.string()))),
1948
+ client: t.optional(t.record(t.string(), t.object({
1949
+ file: t.string(),
1950
+ src: t.optional(t.string()),
1951
+ isEntry: t.optional(t.boolean()),
1952
+ isDynamicEntry: t.optional(t.boolean()),
1953
+ imports: t.optional(t.array(t.string())),
1954
+ dynamicImports: t.optional(t.array(t.string())),
1955
+ css: t.optional(t.array(t.string())),
1956
+ assets: t.optional(t.array(t.string()))
1957
+ })))
1958
+ });
1959
+ /**
1960
+ * SSR Manifest atom containing all manifest data for SSR module preloading.
1961
+ *
1962
+ * This atom is populated at build time by embedding manifest data into the
1963
+ * generated index.js. This approach is optimal for serverless deployments
1964
+ * as it eliminates filesystem reads at runtime.
1965
+ *
1966
+ * The manifest includes:
1967
+ * - preload: Maps short hash keys to source paths (from viteAlephaSsrPreload)
1968
+ * - ssr: Maps source files to their required chunks
1969
+ * - client: Maps source files to their output info including imports/css
1970
+ */
1971
+ const ssrManifestAtom = $atom({
1972
+ name: "alepha.react.ssr.manifest",
1973
+ description: "SSR manifest for module preloading",
1974
+ schema: ssrManifestAtomSchema,
1975
+ default: {}
1497
1976
  });
1977
+
1978
+ //#endregion
1979
+ //#region ../../src/router/providers/SSRManifestProvider.ts
1980
+ /**
1981
+ * Provider for SSR manifest data used for module preloading.
1982
+ *
1983
+ * The manifest is populated at build time by embedding data into the
1984
+ * generated index.js via the ssrManifestAtom. This eliminates filesystem
1985
+ * reads at runtime, making it optimal for serverless deployments.
1986
+ *
1987
+ * Manifest files are generated during `vite build`:
1988
+ * - manifest.json (client manifest)
1989
+ * - ssr-manifest.json (SSR manifest)
1990
+ * - preload-manifest.json (from viteAlephaSsrPreload plugin)
1991
+ */
1992
+ var SSRManifestProvider = class {
1993
+ alepha = $inject(Alepha);
1994
+ /**
1995
+ * Get the manifest from the store at runtime.
1996
+ * This ensures the manifest is available even when set after module load.
1997
+ */
1998
+ get manifest() {
1999
+ return this.alepha.store.get(ssrManifestAtom) ?? {};
2000
+ }
2001
+ /**
2002
+ * Get the preload manifest.
2003
+ */
2004
+ get preloadManifest() {
2005
+ return this.manifest.preload;
2006
+ }
2007
+ /**
2008
+ * Get the SSR manifest.
2009
+ */
2010
+ get ssrManifest() {
2011
+ return this.manifest.ssr;
2012
+ }
2013
+ /**
2014
+ * Get the client manifest.
2015
+ */
2016
+ get clientManifest() {
2017
+ return this.manifest.client;
2018
+ }
2019
+ /**
2020
+ * Resolve a preload key to its source path.
2021
+ *
2022
+ * The key is a short hash injected by viteAlephaSsrPreload plugin,
2023
+ * which maps to the full source path in the preload manifest.
2024
+ *
2025
+ * @param key - Short hash key (e.g., "a1b2c3d4")
2026
+ * @returns Source path (e.g., "src/pages/UserDetail.tsx") or undefined
2027
+ */
2028
+ resolvePreloadKey(key) {
2029
+ return this.preloadManifest?.[key];
2030
+ }
2031
+ /**
2032
+ * Get all chunks required for a source file, including transitive dependencies.
2033
+ *
2034
+ * Uses the client manifest to recursively resolve all imported chunks,
2035
+ * not just the direct chunks from the SSR manifest.
2036
+ *
2037
+ * @param sourcePath - Source file path (e.g., "src/pages/Home.tsx")
2038
+ * @returns Array of chunk URLs to preload, or empty array if not found
2039
+ */
2040
+ getChunks(sourcePath) {
2041
+ if (!this.clientManifest) return this.getChunksFromSSRManifest(sourcePath);
2042
+ if (!this.findManifestEntry(sourcePath)) return [];
2043
+ const chunks = /* @__PURE__ */ new Set();
2044
+ const visited = /* @__PURE__ */ new Set();
2045
+ this.collectChunksRecursive(sourcePath, chunks, visited);
2046
+ return Array.from(chunks);
2047
+ }
2048
+ /**
2049
+ * Find manifest entry for a source path, trying different extensions.
2050
+ */
2051
+ findManifestEntry(sourcePath) {
2052
+ if (!this.clientManifest) return void 0;
2053
+ if (this.clientManifest[sourcePath]) return this.clientManifest[sourcePath];
2054
+ const basePath = sourcePath.replace(/\.[^.]+$/, "");
2055
+ for (const ext of [
2056
+ ".tsx",
2057
+ ".ts",
2058
+ ".jsx",
2059
+ ".js"
2060
+ ]) {
2061
+ const pathWithExt = basePath + ext;
2062
+ if (this.clientManifest[pathWithExt]) return this.clientManifest[pathWithExt];
2063
+ }
2064
+ }
2065
+ /**
2066
+ * Recursively collect all chunk URLs for a manifest entry.
2067
+ */
2068
+ collectChunksRecursive(key, chunks, visited) {
2069
+ if (visited.has(key)) return;
2070
+ visited.add(key);
2071
+ if (!this.clientManifest) return;
2072
+ const entry = this.clientManifest[key];
2073
+ if (!entry) return;
2074
+ if (entry.file) chunks.add("/" + entry.file);
2075
+ if (entry.css) for (const css of entry.css) chunks.add("/" + css);
2076
+ if (entry.imports) for (const imp of entry.imports) {
2077
+ if (imp === "index.html" || imp.endsWith(".html")) continue;
2078
+ this.collectChunksRecursive(imp, chunks, visited);
2079
+ }
2080
+ }
2081
+ /**
2082
+ * Fallback to SSR manifest for chunk lookup.
2083
+ */
2084
+ getChunksFromSSRManifest(sourcePath) {
2085
+ if (!this.ssrManifest) return [];
2086
+ if (this.ssrManifest[sourcePath]) return this.ssrManifest[sourcePath];
2087
+ const basePath = sourcePath.replace(/\.[^.]+$/, "");
2088
+ for (const ext of [
2089
+ ".tsx",
2090
+ ".ts",
2091
+ ".jsx",
2092
+ ".js"
2093
+ ]) {
2094
+ const pathWithExt = basePath + ext;
2095
+ if (this.ssrManifest[pathWithExt]) return this.ssrManifest[pathWithExt];
2096
+ }
2097
+ return [];
2098
+ }
2099
+ /**
2100
+ * Collect modulepreload links for a route and its parent chain.
2101
+ */
2102
+ collectPreloadLinks(route) {
2103
+ if (!this.isAvailable()) return [];
2104
+ const preloadPaths = [];
2105
+ let current = route;
2106
+ while (current) {
2107
+ const preloadKey = current[PAGE_PRELOAD_KEY];
2108
+ if (preloadKey) {
2109
+ const sourcePath = this.resolvePreloadKey(preloadKey);
2110
+ if (sourcePath) preloadPaths.push(sourcePath);
2111
+ }
2112
+ current = current.parent;
2113
+ }
2114
+ if (preloadPaths.length === 0) return [];
2115
+ return this.getChunksForMultiple(preloadPaths).map((href) => {
2116
+ if (href.endsWith(".css")) return {
2117
+ rel: "preload",
2118
+ href,
2119
+ as: "style",
2120
+ crossorigin: ""
2121
+ };
2122
+ return {
2123
+ rel: "modulepreload",
2124
+ href
2125
+ };
2126
+ });
2127
+ }
2128
+ /**
2129
+ * Get all chunks for multiple source files.
2130
+ *
2131
+ * @param sourcePaths - Array of source file paths
2132
+ * @returns Deduplicated array of chunk URLs
2133
+ */
2134
+ getChunksForMultiple(sourcePaths) {
2135
+ const allChunks = /* @__PURE__ */ new Set();
2136
+ for (const path of sourcePaths) {
2137
+ const chunks = this.getChunks(path);
2138
+ for (const chunk of chunks) allChunks.add(chunk);
2139
+ }
2140
+ return Array.from(allChunks);
2141
+ }
2142
+ /**
2143
+ * Check if manifests are loaded and available.
2144
+ */
2145
+ isAvailable() {
2146
+ return this.clientManifest !== void 0 || this.ssrManifest !== void 0;
2147
+ }
2148
+ /**
2149
+ * Cached entry assets - computed once at first access.
2150
+ */
2151
+ cachedEntryAssets = null;
2152
+ /**
2153
+ * Get the entry point assets (main entry.js and associated CSS files).
2154
+ *
2155
+ * These assets are always required for all pages and can be preloaded
2156
+ * before page-specific loaders run.
2157
+ *
2158
+ * @returns Entry assets with js and css paths, or null if manifest unavailable
2159
+ */
2160
+ getEntryAssets() {
2161
+ if (this.cachedEntryAssets) return this.cachedEntryAssets;
2162
+ if (!this.clientManifest) return null;
2163
+ for (const [key, entry] of Object.entries(this.clientManifest)) if (entry.isEntry) {
2164
+ this.cachedEntryAssets = {
2165
+ js: "/" + entry.file,
2166
+ css: entry.css?.map((css) => "/" + css) ?? []
2167
+ };
2168
+ return this.cachedEntryAssets;
2169
+ }
2170
+ return null;
2171
+ }
2172
+ /**
2173
+ * Build preload link tags for entry assets.
2174
+ *
2175
+ * @returns Array of link objects ready to be rendered
2176
+ */
2177
+ getEntryPreloadLinks() {
2178
+ const assets = this.getEntryAssets();
2179
+ if (!assets) return [];
2180
+ const links = [];
2181
+ for (const css of assets.css) links.push({
2182
+ rel: "stylesheet",
2183
+ href: css,
2184
+ crossorigin: ""
2185
+ });
2186
+ if (assets.js) links.push({
2187
+ rel: "modulepreload",
2188
+ href: assets.js
2189
+ });
2190
+ return links;
2191
+ }
2192
+ };
2193
+
2194
+ //#endregion
2195
+ //#region ../../src/router/providers/ReactServerProvider.ts
1498
2196
  /**
1499
2197
  * React server provider responsible for SSR and static file serving.
1500
2198
  *
1501
- * Use `react-dom/server` under the hood.
2199
+ * Coordinates between:
2200
+ * - ReactPageProvider: Page routing and layer resolution
2201
+ * - ReactServerTemplateProvider: HTML template parsing and streaming
2202
+ * - ServerHeadProvider: Head content management
2203
+ * - SSRManifestProvider: Module preload link collection
2204
+ *
2205
+ * Uses `react-dom/server` under the hood.
1502
2206
  */
1503
2207
  var ReactServerProvider = class {
2208
+ /**
2209
+ * SSR response headers - pre-allocated to avoid object creation per request.
2210
+ */
2211
+ SSR_HEADERS = {
2212
+ "content-type": "text/html",
2213
+ "cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
2214
+ pragma: "no-cache",
2215
+ expires: "0"
2216
+ };
2217
+ fs = $inject(FileSystemProvider);
1504
2218
  log = $logger();
1505
2219
  alepha = $inject(Alepha);
1506
2220
  env = $env(envSchema);
1507
2221
  pageApi = $inject(ReactPageProvider);
2222
+ templateProvider = $inject(ReactServerTemplateProvider);
2223
+ serverHeadProvider = $inject(ServerHeadProvider);
1508
2224
  serverStaticProvider = $inject(ServerStaticProvider);
1509
2225
  serverRouterProvider = $inject(ServerRouterProvider);
1510
2226
  serverTimingProvider = $inject(ServerTimingProvider);
1511
- ROOT_DIV_REGEX = new RegExp(`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`, "is");
1512
- preprocessedTemplate = null;
2227
+ ssrManifestProvider = $inject(SSRManifestProvider);
2228
+ /**
2229
+ * Cached check for ServerLinksProvider - avoids has() lookup per request.
2230
+ */
2231
+ hasServerLinksProvider = false;
1513
2232
  options = $use(reactServerOptions);
1514
2233
  /**
1515
2234
  * Configure the React server provider.
@@ -1525,7 +2244,7 @@ var ReactServerProvider = class {
1525
2244
  }
1526
2245
  let root = "";
1527
2246
  if (!this.alepha.isServerless()) {
1528
- root = this.getPublicDirectory();
2247
+ root = await this.getPublicDirectory();
1529
2248
  if (!root) this.log.warn("Missing static files, static file server will be disabled");
1530
2249
  else {
1531
2250
  this.log.debug(`Using static files from: ${root}`);
@@ -1553,12 +2272,20 @@ var ReactServerProvider = class {
1553
2272
  });
1554
2273
  }
1555
2274
  });
2275
+ /**
2276
+ * Get the current HTML template.
2277
+ */
1556
2278
  get template() {
1557
- return this.alepha.store.get("alepha.react.server.template") ?? "<!DOCTYPE html><html lang='en'><head></head><body></body></html>";
2279
+ return this.alepha.store.get("alepha.react.server.template") ?? "<!DOCTYPE html><html lang='en'><head></head><body><div id='root'></div></body></html>";
1558
2280
  }
2281
+ /**
2282
+ * Register all pages as server routes.
2283
+ */
1559
2284
  async registerPages(templateLoader) {
1560
2285
  const template = await templateLoader();
1561
- if (template) this.preprocessedTemplate = this.preprocessTemplate(template);
2286
+ if (template) this.templateProvider.parseTemplate(template);
2287
+ this.setupEarlyHeadContent();
2288
+ this.hasServerLinksProvider = this.alepha.has(ServerLinksProvider);
1562
2289
  for (const page of this.pageApi.getPages()) if (page.component || page.lazy) {
1563
2290
  this.log.debug(`+ ${page.match} -> ${page.name}`);
1564
2291
  this.serverRouterProvider.createRoute({
@@ -1571,11 +2298,37 @@ var ReactServerProvider = class {
1571
2298
  }
1572
2299
  }
1573
2300
  /**
2301
+ * Set up early head content with entry assets.
2302
+ *
2303
+ * This content is sent immediately when streaming starts, before page loaders run,
2304
+ * allowing the browser to start downloading entry.js and CSS files early.
2305
+ *
2306
+ * Uses <script type="module"> instead of <link rel="modulepreload"> for JS
2307
+ * because the script needs to execute anyway - this way the browser starts
2308
+ * downloading, parsing, AND will execute as soon as ready.
2309
+ *
2310
+ * Also strips these assets from the original template head to avoid duplicates.
2311
+ */
2312
+ setupEarlyHeadContent() {
2313
+ const assets = this.ssrManifestProvider.getEntryAssets();
2314
+ if (!assets) return;
2315
+ const parts = [];
2316
+ for (const css of assets.css) parts.push(`<link rel="stylesheet" href="${css}" crossorigin="">`);
2317
+ if (assets.js) parts.push(`<script type="module" crossorigin="" src="${assets.js}"><\/script>`);
2318
+ if (parts.length > 0) {
2319
+ this.templateProvider.setEarlyHeadContent(parts.join("\n") + "\n", assets);
2320
+ this.log.debug("Early head content set", {
2321
+ css: assets.css.length,
2322
+ js: assets.js ? 1 : 0
2323
+ });
2324
+ }
2325
+ }
2326
+ /**
1574
2327
  * Get the public directory path where static files are located.
1575
2328
  */
1576
- getPublicDirectory() {
2329
+ async getPublicDirectory() {
1577
2330
  const maybe = [join(process.cwd(), `dist/${this.options.publicDir}`), join(process.cwd(), this.options.publicDir)];
1578
- for (const it of maybe) if (existsSync(it)) return it;
2331
+ for (const it of maybe) if (await this.fs.exists(it)) return it;
1579
2332
  return "";
1580
2333
  }
1581
2334
  /**
@@ -1592,72 +2345,38 @@ var ReactServerProvider = class {
1592
2345
  });
1593
2346
  }
1594
2347
  /**
1595
- * Configure Vite for SSR.
2348
+ * Configure Vite for SSR in development mode.
1596
2349
  */
1597
2350
  async configureVite(ssrEnabled) {
1598
2351
  if (!ssrEnabled) return;
1599
- const url = `http://localhost:${(this.alepha.store.get("env") ?? {}).SERVER_PORT ?? "5173"}`;
2352
+ const url = `http://localhost:${this.alepha.env.SERVER_PORT ?? "5173"}`;
1600
2353
  this.log.info("SSR (dev) OK", { url });
1601
2354
  await this.registerPages(() => fetch(`${url}/index.html`).then((it) => it.text()).catch(() => void 0));
1602
2355
  }
1603
2356
  /**
1604
- * For testing purposes, creates a render function that can be used.
2357
+ * Create the request handler for a page route.
1605
2358
  */
1606
- async render(name, options = {}) {
1607
- const page = this.pageApi.page(name);
1608
- const url = new URL(this.pageApi.url(name, options));
1609
- const state = {
1610
- url,
1611
- params: options.params ?? {},
1612
- query: options.query ?? {},
1613
- onError: () => null,
1614
- layers: [],
1615
- meta: {}
1616
- };
1617
- this.log.trace("Rendering", { url });
1618
- await this.alepha.events.emit("react:server:render:begin", { state });
1619
- const { redirect } = await this.pageApi.createLayers(page, state);
1620
- if (redirect) return {
1621
- state,
1622
- html: "",
1623
- redirect
1624
- };
1625
- if (!options.html) {
1626
- this.alepha.store.set("alepha.react.router.state", state);
1627
- return {
1628
- state,
1629
- html: renderToString(this.pageApi.root(state))
1630
- };
1631
- }
1632
- const template = this.template ?? "";
1633
- const html = this.renderToHtml(template, state, options.hydration);
1634
- if (html instanceof Redirection) return {
1635
- state,
1636
- html: "",
1637
- redirect
1638
- };
1639
- const result = {
1640
- state,
1641
- html
1642
- };
1643
- await this.alepha.events.emit("react:server:render:end", result);
1644
- return result;
1645
- }
1646
2359
  createHandler(route, templateLoader) {
1647
2360
  return async (serverRequest) => {
1648
2361
  const { url, reply, query, params } = serverRequest;
1649
- const template = await templateLoader();
1650
- if (!template) throw new AlephaError("Missing template for SSR rendering");
2362
+ if (!this.templateProvider.isReady()) {
2363
+ const template = await templateLoader();
2364
+ if (!template) throw new AlephaError("Missing template for SSR rendering");
2365
+ this.templateProvider.parseTemplate(template);
2366
+ this.setupEarlyHeadContent();
2367
+ }
1651
2368
  this.log.trace("Rendering page", { name: route.name });
1652
2369
  const state = {
1653
2370
  url,
1654
2371
  params,
1655
2372
  query,
2373
+ name: route.name,
1656
2374
  onError: () => null,
1657
- layers: []
2375
+ layers: [],
2376
+ meta: {},
2377
+ head: {}
1658
2378
  };
1659
- state.name = route.name;
1660
- if (this.alepha.has(ServerLinksProvider)) this.alepha.store.set("alepha.server.request.apiLinks", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
2379
+ if (this.hasServerLinksProvider) this.alepha.store.set("alepha.server.request.apiLinks", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
1661
2380
  user: serverRequest.user,
1662
2381
  authorization: serverRequest.headers.authorization
1663
2382
  }));
@@ -1675,114 +2394,148 @@ var ReactServerProvider = class {
1675
2394
  request: serverRequest,
1676
2395
  state
1677
2396
  });
1678
- this.serverTimingProvider.beginTiming("createLayers");
1679
- const { redirect } = await this.pageApi.createLayers(route, state);
1680
- this.serverTimingProvider.endTiming("createLayers");
1681
- if (redirect) {
1682
- this.log.debug("Resolver resulted in redirection", { redirect });
1683
- return reply.redirect(redirect);
1684
- }
1685
- reply.headers["content-type"] = "text/html";
1686
- reply.headers["cache-control"] = "no-store, no-cache, must-revalidate, proxy-revalidate";
1687
- reply.headers.pragma = "no-cache";
1688
- reply.headers.expires = "0";
1689
- const html = this.renderToHtml(template, state);
1690
- if (html instanceof Redirection) {
1691
- reply.redirect(typeof html.redirect === "string" ? html.redirect : this.pageApi.href(html.redirect));
1692
- this.log.debug("Rendering resulted in redirection", { redirect: html.redirect });
1693
- return;
1694
- }
1695
- this.log.trace("Page rendered to HTML successfully");
1696
- const event = {
1697
- request: serverRequest,
1698
- state,
1699
- html
1700
- };
1701
- await this.alepha.events.emit("react:server:render:end", event);
2397
+ Object.assign(reply.headers, this.SSR_HEADERS);
2398
+ const globalHead = this.serverHeadProvider.resolveGlobalHead();
2399
+ const htmlStream = this.templateProvider.createEarlyHtmlStream(globalHead, async () => {
2400
+ const result = await this.renderPage(route, state);
2401
+ if (result.redirect) return { redirect: result.redirect };
2402
+ return {
2403
+ state,
2404
+ reactStream: result.reactStream
2405
+ };
2406
+ }, {
2407
+ hydration: true,
2408
+ onError: (error) => {
2409
+ if (error instanceof Redirection) this.log.debug("Streaming resulted in redirection", { redirect: error.redirect });
2410
+ else this.log.error("HTML stream error", error);
2411
+ }
2412
+ });
2413
+ this.log.trace("Page streaming started (early head optimization)");
1702
2414
  route.onServerResponse?.(serverRequest);
1703
- this.log.trace("Page rendered", { name: route.name });
1704
- return event.html;
2415
+ reply.body = htmlStream;
1705
2416
  };
1706
2417
  }
1707
- renderToHtml(template, state, hydration = true) {
1708
- const element = this.pageApi.root(state);
1709
- this.alepha.store.set("alepha.react.router.state", state);
1710
- this.serverTimingProvider.beginTiming("renderToString");
1711
- let app = "";
1712
- try {
1713
- app = renderToString(element);
1714
- } catch (error) {
1715
- this.log.error("renderToString has failed, fallback to error handler", error);
1716
- const element$1 = state.onError(error, state);
1717
- if (element$1 instanceof Redirection) return element$1;
1718
- app = renderToString(element$1);
1719
- this.log.debug("Error handled successfully with fallback");
1720
- }
1721
- this.serverTimingProvider.endTiming("renderToString");
1722
- const response = { html: template };
1723
- if (hydration) {
1724
- const { request, context, ...store } = this.alepha.context.als?.getStore() ?? {};
1725
- const hydrationData = {
1726
- ...store,
1727
- "alepha.react.router.state": void 0,
1728
- layers: state.layers.map((it) => ({
1729
- ...it,
1730
- error: it.error ? {
1731
- ...it.error,
1732
- name: it.error.name,
1733
- message: it.error.message,
1734
- stack: !this.alepha.isProduction() ? it.error.stack : void 0
1735
- } : void 0,
1736
- index: void 0,
1737
- path: void 0,
1738
- element: void 0,
1739
- route: void 0
1740
- }))
1741
- };
1742
- const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}<\/script>`;
1743
- this.fillTemplate(response, app, script);
2418
+ /**
2419
+ * Core page rendering logic shared between SSR handler and static prerendering.
2420
+ *
2421
+ * Handles:
2422
+ * - Layer resolution (loaders)
2423
+ * - Redirect detection
2424
+ * - Head content filling
2425
+ * - Preload link collection
2426
+ * - React stream rendering
2427
+ *
2428
+ * @param route - The page route to render
2429
+ * @param state - The router state
2430
+ * @returns Render result with redirect or React stream
2431
+ */
2432
+ async renderPage(route, state) {
2433
+ const { redirect } = await this.pageApi.createLayers(route, state);
2434
+ if (redirect) {
2435
+ this.log.debug("Resolver resulted in redirection", { redirect });
2436
+ return { redirect };
1744
2437
  }
1745
- return response.html;
1746
- }
1747
- preprocessTemplate(template) {
1748
- const bodyCloseIndex = template.match(/<\/body>/i)?.index ?? template.length;
1749
- const beforeScript = template.substring(0, bodyCloseIndex);
1750
- const afterScript = template.substring(bodyCloseIndex);
1751
- const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
1752
- if (rootDivMatch) {
1753
- const beforeDiv = beforeScript.substring(0, rootDivMatch.index);
1754
- const afterDivStart = rootDivMatch.index + rootDivMatch[0].length;
1755
- const afterDiv = beforeScript.substring(afterDivStart);
1756
- return {
1757
- beforeApp: `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`,
1758
- afterApp: `</div>${afterDiv}`,
1759
- beforeScript: "",
1760
- afterScript
1761
- };
2438
+ this.serverHeadProvider.fillHead(state);
2439
+ const preloadLinks = this.ssrManifestProvider.collectPreloadLinks(route);
2440
+ if (preloadLinks.length > 0) {
2441
+ state.head ??= {};
2442
+ state.head.link = [...state.head.link ?? [], ...preloadLinks];
1762
2443
  }
1763
- const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
1764
- if (bodyMatch) {
1765
- const beforeBody = beforeScript.substring(0, bodyMatch.index + bodyMatch[0].length);
1766
- const afterBody = beforeScript.substring(bodyMatch.index + bodyMatch[0].length);
1767
- return {
1768
- beforeApp: `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`,
1769
- afterApp: `</div>${afterBody}`,
1770
- beforeScript: "",
1771
- afterScript
1772
- };
2444
+ const element = this.pageApi.root(state);
2445
+ this.alepha.store.set("alepha.react.router.state", state);
2446
+ return { reactStream: await renderToReadableStream(element, { onError: (error) => {
2447
+ if (error instanceof Redirection) this.log.warn("Redirect during streaming ignored", { redirect: error.redirect });
2448
+ else this.log.error("Streaming render error", error);
2449
+ } }) };
2450
+ }
2451
+ /**
2452
+ * For testing purposes, renders a page to HTML string.
2453
+ * Uses the same streaming code path as production, then collects to string.
2454
+ *
2455
+ * @param name - Page name to render
2456
+ * @param options - Render options (params, query, html, hydration)
2457
+ */
2458
+ async render(name, options = {}) {
2459
+ const page = this.pageApi.page(name);
2460
+ const url = new URL(this.pageApi.url(name, options));
2461
+ const state = {
2462
+ url,
2463
+ params: options.params ?? {},
2464
+ query: options.query ?? {},
2465
+ onError: () => null,
2466
+ layers: [],
2467
+ meta: {},
2468
+ head: {}
2469
+ };
2470
+ this.log.trace("Rendering", { url });
2471
+ await this.alepha.events.emit("react:server:render:begin", { state });
2472
+ if (!this.templateProvider.isReady()) {
2473
+ this.templateProvider.parseTemplate(this.template);
2474
+ this.setupEarlyHeadContent();
1773
2475
  }
2476
+ const result = await this.renderPage(page, state);
2477
+ if (result.redirect) return {
2478
+ state,
2479
+ html: "",
2480
+ redirect: result.redirect
2481
+ };
2482
+ const reactStream = result.reactStream;
2483
+ if (!options.html) return {
2484
+ state,
2485
+ html: await this.streamToString(reactStream)
2486
+ };
2487
+ const htmlStream = this.templateProvider.createHtmlStream(reactStream, state, { hydration: options.hydration ?? true });
2488
+ const html = await this.streamToString(htmlStream);
2489
+ await this.alepha.events.emit("react:server:render:end", {
2490
+ state,
2491
+ html
2492
+ });
1774
2493
  return {
1775
- beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
1776
- afterApp: `</div>`,
1777
- beforeScript,
1778
- afterScript
2494
+ state,
2495
+ html
1779
2496
  };
1780
2497
  }
1781
- fillTemplate(response, app, script) {
1782
- if (!this.preprocessedTemplate) this.preprocessedTemplate = this.preprocessTemplate(response.html);
1783
- response.html = this.preprocessedTemplate.beforeApp + app + this.preprocessedTemplate.afterApp + script + this.preprocessedTemplate.afterScript;
2498
+ /**
2499
+ * Collect a ReadableStream into a string.
2500
+ */
2501
+ async streamToString(stream) {
2502
+ const reader = stream.getReader();
2503
+ const decoder = new TextDecoder();
2504
+ const chunks = [];
2505
+ try {
2506
+ while (true) {
2507
+ const { done, value } = await reader.read();
2508
+ if (done) break;
2509
+ chunks.push(decoder.decode(value, { stream: true }));
2510
+ }
2511
+ chunks.push(decoder.decode());
2512
+ } finally {
2513
+ reader.releaseLock();
2514
+ }
2515
+ return chunks.join("");
1784
2516
  }
1785
2517
  };
2518
+ const envSchema = t.object({ REACT_SSR_ENABLED: t.optional(t.boolean()) });
2519
+ /**
2520
+ * React server provider configuration atom
2521
+ */
2522
+ const reactServerOptions = $atom({
2523
+ name: "alepha.react.server.options",
2524
+ schema: t.object({
2525
+ publicDir: t.string(),
2526
+ staticServer: t.object({
2527
+ disabled: t.boolean(),
2528
+ path: t.string({ description: "URL path where static files will be served." })
2529
+ })
2530
+ }),
2531
+ default: {
2532
+ publicDir: "public",
2533
+ staticServer: {
2534
+ disabled: false,
2535
+ path: "/"
2536
+ }
2537
+ }
2538
+ });
1786
2539
 
1787
2540
  //#endregion
1788
2541
  //#region ../../src/router/services/ReactPageServerService.ts
@@ -1791,6 +2544,7 @@ var ReactServerProvider = class {
1791
2544
  */
1792
2545
  var ReactPageServerService = class extends ReactPageService {
1793
2546
  reactServerProvider = $inject(ReactServerProvider);
2547
+ templateProvider = $inject(ReactServerTemplateProvider);
1794
2548
  serverProvider = $inject(ServerProvider);
1795
2549
  async render(name, options = {}) {
1796
2550
  return this.reactServerProvider.render(name, options);
@@ -1802,9 +2556,9 @@ var ReactPageServerService = class extends ReactPageService {
1802
2556
  html,
1803
2557
  response
1804
2558
  };
1805
- const match = html.match(this.reactServerProvider.ROOT_DIV_REGEX);
1806
- if (match) return {
1807
- html: match[3],
2559
+ const rootContent = this.templateProvider.extractRootContent(html);
2560
+ if (rootContent !== void 0) return {
2561
+ html: rootContent,
1808
2562
  response
1809
2563
  };
1810
2564
  throw new AlephaError("Invalid HTML response");
@@ -1820,7 +2574,7 @@ var ReactPageServerService = class extends ReactPageService {
1820
2574
  * - URL pattern matching with parameters (e.g., `/users/:id`)
1821
2575
  * - Nested routing with parent-child relationships
1822
2576
  * - Type-safe URL parameter and query string validation
1823
- * - Server-side data fetching with the `resolve` function
2577
+ * - Server-side data fetching with the `loader` function
1824
2578
  * - Lazy loading and code splitting
1825
2579
  * - Page animations and error handling
1826
2580
  *
@@ -1835,12 +2589,14 @@ const AlephaReactRouter = $module({
1835
2589
  ReactPageService,
1836
2590
  ReactRouter,
1837
2591
  ReactServerProvider,
2592
+ ReactServerTemplateProvider,
2593
+ SSRManifestProvider,
1838
2594
  ReactPageServerService
1839
2595
  ],
1840
2596
  register: (alepha) => alepha.with(AlephaReact).with(AlephaDateTime).with(AlephaServer).with(AlephaServerCache).with(AlephaServerLinks).with({
1841
2597
  provide: ReactPageService,
1842
2598
  use: ReactPageServerService
1843
- }).with(ReactServerProvider).with(ReactPageProvider).with(ReactRouter)
2599
+ }).with(SSRManifestProvider).with(ReactServerTemplateProvider).with(ReactServerProvider).with(ReactPageProvider).with(ReactRouter)
1844
2600
  });
1845
2601
 
1846
2602
  //#endregion