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