@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.
- package/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.js +960 -195
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -4
- package/dist/core/index.js.map +1 -1
- package/dist/head/index.browser.js +59 -19
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +99 -560
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +92 -87
- package/dist/head/index.js.map +1 -1
- package/dist/router/index.browser.js +30 -15
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +616 -192
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +961 -196
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/__tests__/$auth.spec.ts +188 -0
- package/src/core/__tests__/Router.spec.tsx +169 -0
- package/src/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/core/hooks/useAction.ts +11 -0
- package/src/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/head/helpers/SeoExpander.spec.ts +203 -0
- package/src/head/hooks/useHead.spec.tsx +288 -0
- package/src/head/index.ts +11 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
- package/src/head/providers/BrowserHeadProvider.ts +25 -19
- package/src/head/providers/HeadProvider.ts +76 -10
- package/src/head/providers/ServerHeadProvider.ts +22 -138
- package/src/i18n/__tests__/integration.spec.tsx +239 -0
- package/src/i18n/components/Localize.spec.tsx +357 -0
- package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
- package/src/i18n/providers/I18nProvider.spec.ts +389 -0
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/router/__tests__/seo-head.spec.ts +121 -0
- package/src/router/atoms/ssrManifestAtom.ts +60 -0
- package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
- package/src/router/errors/Redirection.ts +1 -1
- package/src/router/index.shared.ts +1 -0
- package/src/router/index.ts +16 -2
- package/src/router/primitives/$page.browser.spec.tsx +702 -0
- package/src/router/primitives/$page.spec.tsx +702 -0
- package/src/router/primitives/$page.ts +46 -10
- package/src/router/providers/ReactBrowserProvider.ts +14 -29
- package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
- package/src/router/providers/ReactPageProvider.ts +11 -4
- package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
- package/src/router/providers/ReactServerProvider.ts +331 -315
- package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
- package/src/router/providers/SSRManifestProvider.ts +365 -0
- package/src/router/services/ReactPageServerService.ts +5 -3
- package/src/router/services/ReactRouter.ts +3 -3
package/dist/auth/index.js
CHANGED
|
@@ -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 {
|
|
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 `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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$
|
|
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$
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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] ?? "";
|
|
1495
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;
|
|
1496
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
return {
|
|
1739
|
+
...store,
|
|
1740
|
+
"alepha.react.router.state": void 0,
|
|
1741
|
+
layers: state.layers.map((layer) => ({
|
|
1742
|
+
...layer,
|
|
1743
|
+
error: layer.error ? {
|
|
1744
|
+
...layer.error,
|
|
1745
|
+
name: layer.error.name,
|
|
1746
|
+
message: layer.error.message,
|
|
1747
|
+
stack: !this.alepha.isProduction() ? layer.error.stack : void 0
|
|
1748
|
+
} : void 0,
|
|
1749
|
+
index: void 0,
|
|
1750
|
+
path: void 0,
|
|
1751
|
+
element: void 0,
|
|
1752
|
+
route: void 0
|
|
1753
|
+
}))
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Encode a string to Uint8Array using the shared encoder.
|
|
1758
|
+
*/
|
|
1759
|
+
encode(str) {
|
|
1760
|
+
return this.encoder.encode(str);
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Get the pre-encoded hydration script prefix.
|
|
1764
|
+
*/
|
|
1765
|
+
get hydrationPrefix() {
|
|
1766
|
+
return this.ENCODED.HYDRATION_PREFIX;
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Get the pre-encoded hydration script suffix.
|
|
1770
|
+
*/
|
|
1771
|
+
get hydrationSuffix() {
|
|
1772
|
+
return this.ENCODED.HYDRATION_SUFFIX;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Create a ReadableStream that streams the HTML template with React content.
|
|
1776
|
+
*
|
|
1777
|
+
* This is the main entry point for SSR streaming. It:
|
|
1778
|
+
* 1. Sends <head> immediately (browser starts downloading assets)
|
|
1779
|
+
* 2. Streams React content as it renders
|
|
1780
|
+
* 3. Appends hydration script and closing tags
|
|
1781
|
+
*
|
|
1782
|
+
* @param reactStream - ReadableStream from renderToReadableStream
|
|
1783
|
+
* @param state - Router state with head data
|
|
1784
|
+
* @param options - Streaming options
|
|
1785
|
+
*/
|
|
1786
|
+
createHtmlStream(reactStream, state, options = {}) {
|
|
1787
|
+
const { hydration = true, onError } = options;
|
|
1788
|
+
const slots = this.getSlots();
|
|
1789
|
+
const head = state.head;
|
|
1790
|
+
const encoder = this.encoder;
|
|
1791
|
+
return new ReadableStream({ start: async (controller) => {
|
|
1792
|
+
try {
|
|
1793
|
+
controller.enqueue(slots.doctype);
|
|
1794
|
+
controller.enqueue(slots.htmlOpen);
|
|
1795
|
+
controller.enqueue(encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)));
|
|
1796
|
+
controller.enqueue(slots.htmlClose);
|
|
1797
|
+
controller.enqueue(slots.headOpen);
|
|
1798
|
+
if (this.earlyHeadContent) controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
1799
|
+
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
1800
|
+
controller.enqueue(slots.headClose);
|
|
1801
|
+
controller.enqueue(slots.bodyOpen);
|
|
1802
|
+
controller.enqueue(encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)));
|
|
1803
|
+
controller.enqueue(slots.bodyClose);
|
|
1804
|
+
if (slots.beforeRoot) controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
1805
|
+
controller.enqueue(slots.rootOpen);
|
|
1806
|
+
const reader = reactStream.getReader();
|
|
1807
|
+
try {
|
|
1808
|
+
while (true) {
|
|
1809
|
+
const { done, value } = await reader.read();
|
|
1810
|
+
if (done) break;
|
|
1811
|
+
controller.enqueue(value);
|
|
1812
|
+
}
|
|
1813
|
+
} finally {
|
|
1814
|
+
reader.releaseLock();
|
|
1815
|
+
}
|
|
1816
|
+
controller.enqueue(slots.rootClose);
|
|
1817
|
+
if (slots.afterRoot) controller.enqueue(encoder.encode(slots.afterRoot));
|
|
1818
|
+
if (hydration) {
|
|
1819
|
+
const hydrationData = this.buildHydrationData(state);
|
|
1820
|
+
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
1821
|
+
controller.enqueue(encoder.encode(this.safeJsonSerialize(hydrationData)));
|
|
1822
|
+
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
1823
|
+
}
|
|
1824
|
+
controller.enqueue(slots.scriptClose);
|
|
1825
|
+
controller.close();
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
onError?.(error);
|
|
1828
|
+
controller.error(error);
|
|
1829
|
+
}
|
|
1830
|
+
} });
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Early head content for preloading.
|
|
1834
|
+
*
|
|
1835
|
+
* Contains entry assets (JS + CSS) that are always required and can be
|
|
1836
|
+
* sent before page loaders run.
|
|
1837
|
+
*/
|
|
1838
|
+
earlyHeadContent = "";
|
|
1839
|
+
/**
|
|
1840
|
+
* Set the early head content (entry script + CSS).
|
|
1841
|
+
*
|
|
1842
|
+
* Also strips these assets from the original head content to avoid duplicates,
|
|
1843
|
+
* since we're moving them to the early phase.
|
|
1844
|
+
*
|
|
1845
|
+
* @param content - HTML string with entry assets
|
|
1846
|
+
* @param entryAssets - Entry asset paths to strip from original head
|
|
1847
|
+
*/
|
|
1848
|
+
setEarlyHeadContent(content, entryAssets) {
|
|
1849
|
+
this.earlyHeadContent = content;
|
|
1850
|
+
if (entryAssets && this.slots) {
|
|
1851
|
+
let headContent = this.slots.headOriginalContent;
|
|
1852
|
+
if (entryAssets.js) {
|
|
1853
|
+
const scriptPattern = new RegExp(`<script[^>]*\\ssrc=["']${this.escapeRegExp(entryAssets.js)}["'][^>]*>\\s*<\/script>\\s*`, "gi");
|
|
1854
|
+
headContent = headContent.replace(scriptPattern, "");
|
|
1855
|
+
}
|
|
1856
|
+
for (const css of entryAssets.css) {
|
|
1857
|
+
const linkPattern = new RegExp(`<link[^>]*\\shref=["']${this.escapeRegExp(css)}["'][^>]*>\\s*`, "gi");
|
|
1858
|
+
headContent = headContent.replace(linkPattern, "");
|
|
1859
|
+
}
|
|
1860
|
+
this.slots.headOriginalContent = headContent.trim();
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Escape special regex characters in a string.
|
|
1865
|
+
*/
|
|
1866
|
+
escapeRegExp(str) {
|
|
1867
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Create an optimized HTML stream with early head streaming.
|
|
1871
|
+
*
|
|
1872
|
+
* This version sends critical assets (entry.js, CSS) BEFORE page loaders run,
|
|
1873
|
+
* allowing the browser to start downloading them immediately.
|
|
1874
|
+
*
|
|
1875
|
+
* Flow:
|
|
1876
|
+
* 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE)
|
|
1877
|
+
* 2. Run async work (createLayers, etc.)
|
|
1878
|
+
* 3. Send rest of head, body, React content, hydration
|
|
1879
|
+
*
|
|
1880
|
+
* @param globalHead - Global head with htmlAttributes (from $head primitives)
|
|
1881
|
+
* @param asyncWork - Async function to run between early head and rest of stream
|
|
1882
|
+
* @param options - Streaming options
|
|
1883
|
+
*/
|
|
1884
|
+
createEarlyHtmlStream(globalHead, asyncWork, options = {}) {
|
|
1885
|
+
const { hydration = true, onError, onRedirect } = options;
|
|
1886
|
+
const slots = this.getSlots();
|
|
1887
|
+
const encoder = this.encoder;
|
|
1888
|
+
return new ReadableStream({ start: async (controller) => {
|
|
1889
|
+
try {
|
|
1890
|
+
controller.enqueue(slots.doctype);
|
|
1891
|
+
controller.enqueue(slots.htmlOpen);
|
|
1892
|
+
controller.enqueue(encoder.encode(this.renderMergedHtmlAttrs(globalHead?.htmlAttributes)));
|
|
1893
|
+
controller.enqueue(slots.htmlClose);
|
|
1894
|
+
controller.enqueue(slots.headOpen);
|
|
1895
|
+
if (this.earlyHeadContent) controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
1896
|
+
const result = await asyncWork();
|
|
1897
|
+
if (!result) {
|
|
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
|
-
*
|
|
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
|
-
|
|
1512
|
-
|
|
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.
|
|
@@ -1519,13 +2238,14 @@ var ReactServerProvider = class {
|
|
|
1519
2238
|
handler: async () => {
|
|
1520
2239
|
const ssrEnabled = this.alepha.primitives($page).length > 0 && this.env.REACT_SSR_ENABLED !== false;
|
|
1521
2240
|
this.alepha.store.set("alepha.react.server.ssr", ssrEnabled);
|
|
2241
|
+
if (ssrEnabled) this.log.info("SSR streaming enabled");
|
|
1522
2242
|
if (this.alepha.isViteDev()) {
|
|
1523
2243
|
await this.configureVite(ssrEnabled);
|
|
1524
2244
|
return;
|
|
1525
2245
|
}
|
|
1526
2246
|
let root = "";
|
|
1527
2247
|
if (!this.alepha.isServerless()) {
|
|
1528
|
-
root = this.getPublicDirectory();
|
|
2248
|
+
root = await this.getPublicDirectory();
|
|
1529
2249
|
if (!root) this.log.warn("Missing static files, static file server will be disabled");
|
|
1530
2250
|
else {
|
|
1531
2251
|
this.log.debug(`Using static files from: ${root}`);
|
|
@@ -1553,12 +2273,20 @@ var ReactServerProvider = class {
|
|
|
1553
2273
|
});
|
|
1554
2274
|
}
|
|
1555
2275
|
});
|
|
2276
|
+
/**
|
|
2277
|
+
* Get the current HTML template.
|
|
2278
|
+
*/
|
|
1556
2279
|
get template() {
|
|
1557
|
-
return this.alepha.store.get("alepha.react.server.template") ?? "<!DOCTYPE html><html lang='en'><head></head><body></body></html>";
|
|
2280
|
+
return this.alepha.store.get("alepha.react.server.template") ?? "<!DOCTYPE html><html lang='en'><head></head><body><div id='root'></div></body></html>";
|
|
1558
2281
|
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Register all pages as server routes.
|
|
2284
|
+
*/
|
|
1559
2285
|
async registerPages(templateLoader) {
|
|
1560
2286
|
const template = await templateLoader();
|
|
1561
|
-
if (template) this.
|
|
2287
|
+
if (template) this.templateProvider.parseTemplate(template);
|
|
2288
|
+
this.setupEarlyHeadContent();
|
|
2289
|
+
this.hasServerLinksProvider = this.alepha.has(ServerLinksProvider);
|
|
1562
2290
|
for (const page of this.pageApi.getPages()) if (page.component || page.lazy) {
|
|
1563
2291
|
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
1564
2292
|
this.serverRouterProvider.createRoute({
|
|
@@ -1571,11 +2299,37 @@ var ReactServerProvider = class {
|
|
|
1571
2299
|
}
|
|
1572
2300
|
}
|
|
1573
2301
|
/**
|
|
2302
|
+
* Set up early head content with entry assets.
|
|
2303
|
+
*
|
|
2304
|
+
* This content is sent immediately when streaming starts, before page loaders run,
|
|
2305
|
+
* allowing the browser to start downloading entry.js and CSS files early.
|
|
2306
|
+
*
|
|
2307
|
+
* Uses <script type="module"> instead of <link rel="modulepreload"> for JS
|
|
2308
|
+
* because the script needs to execute anyway - this way the browser starts
|
|
2309
|
+
* downloading, parsing, AND will execute as soon as ready.
|
|
2310
|
+
*
|
|
2311
|
+
* Also strips these assets from the original template head to avoid duplicates.
|
|
2312
|
+
*/
|
|
2313
|
+
setupEarlyHeadContent() {
|
|
2314
|
+
const assets = this.ssrManifestProvider.getEntryAssets();
|
|
2315
|
+
if (!assets) return;
|
|
2316
|
+
const parts = [];
|
|
2317
|
+
for (const css of assets.css) parts.push(`<link rel="stylesheet" href="${css}" crossorigin="">`);
|
|
2318
|
+
if (assets.js) parts.push(`<script type="module" crossorigin="" src="${assets.js}"><\/script>`);
|
|
2319
|
+
if (parts.length > 0) {
|
|
2320
|
+
this.templateProvider.setEarlyHeadContent(parts.join("\n") + "\n", assets);
|
|
2321
|
+
this.log.debug("Early head content set", {
|
|
2322
|
+
css: assets.css.length,
|
|
2323
|
+
js: assets.js ? 1 : 0
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
1574
2328
|
* Get the public directory path where static files are located.
|
|
1575
2329
|
*/
|
|
1576
|
-
getPublicDirectory() {
|
|
2330
|
+
async getPublicDirectory() {
|
|
1577
2331
|
const maybe = [join(process.cwd(), `dist/${this.options.publicDir}`), join(process.cwd(), this.options.publicDir)];
|
|
1578
|
-
for (const it of maybe) if (
|
|
2332
|
+
for (const it of maybe) if (await this.fs.exists(it)) return it;
|
|
1579
2333
|
return "";
|
|
1580
2334
|
}
|
|
1581
2335
|
/**
|
|
@@ -1592,72 +2346,38 @@ var ReactServerProvider = class {
|
|
|
1592
2346
|
});
|
|
1593
2347
|
}
|
|
1594
2348
|
/**
|
|
1595
|
-
* Configure Vite for SSR.
|
|
2349
|
+
* Configure Vite for SSR in development mode.
|
|
1596
2350
|
*/
|
|
1597
2351
|
async configureVite(ssrEnabled) {
|
|
1598
2352
|
if (!ssrEnabled) return;
|
|
1599
|
-
this.
|
|
1600
|
-
|
|
2353
|
+
const url = `http://localhost:${this.alepha.env.SERVER_PORT ?? "5173"}`;
|
|
2354
|
+
this.log.info("SSR (dev) OK", { url });
|
|
1601
2355
|
await this.registerPages(() => fetch(`${url}/index.html`).then((it) => it.text()).catch(() => void 0));
|
|
1602
2356
|
}
|
|
1603
2357
|
/**
|
|
1604
|
-
*
|
|
2358
|
+
* Create the request handler for a page route.
|
|
1605
2359
|
*/
|
|
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
2360
|
createHandler(route, templateLoader) {
|
|
1647
2361
|
return async (serverRequest) => {
|
|
1648
2362
|
const { url, reply, query, params } = serverRequest;
|
|
1649
|
-
|
|
1650
|
-
|
|
2363
|
+
if (!this.templateProvider.isReady()) {
|
|
2364
|
+
const template = await templateLoader();
|
|
2365
|
+
if (!template) throw new AlephaError("Missing template for SSR rendering");
|
|
2366
|
+
this.templateProvider.parseTemplate(template);
|
|
2367
|
+
this.setupEarlyHeadContent();
|
|
2368
|
+
}
|
|
1651
2369
|
this.log.trace("Rendering page", { name: route.name });
|
|
1652
2370
|
const state = {
|
|
1653
2371
|
url,
|
|
1654
2372
|
params,
|
|
1655
2373
|
query,
|
|
2374
|
+
name: route.name,
|
|
1656
2375
|
onError: () => null,
|
|
1657
|
-
layers: []
|
|
2376
|
+
layers: [],
|
|
2377
|
+
meta: {},
|
|
2378
|
+
head: {}
|
|
1658
2379
|
};
|
|
1659
|
-
|
|
1660
|
-
if (this.alepha.has(ServerLinksProvider)) this.alepha.store.set("alepha.server.request.apiLinks", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
2380
|
+
if (this.hasServerLinksProvider) this.alepha.store.set("alepha.server.request.apiLinks", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
1661
2381
|
user: serverRequest.user,
|
|
1662
2382
|
authorization: serverRequest.headers.authorization
|
|
1663
2383
|
}));
|
|
@@ -1675,114 +2395,156 @@ var ReactServerProvider = class {
|
|
|
1675
2395
|
request: serverRequest,
|
|
1676
2396
|
state
|
|
1677
2397
|
});
|
|
1678
|
-
this.
|
|
1679
|
-
const
|
|
1680
|
-
this.
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
state,
|
|
1699
|
-
html
|
|
1700
|
-
};
|
|
1701
|
-
await this.alepha.events.emit("react:server:render:end", event);
|
|
2398
|
+
Object.assign(reply.headers, this.SSR_HEADERS);
|
|
2399
|
+
const globalHead = this.serverHeadProvider.resolveGlobalHead();
|
|
2400
|
+
const htmlStream = this.templateProvider.createEarlyHtmlStream(globalHead, async () => {
|
|
2401
|
+
const result = await this.renderPage(route, state);
|
|
2402
|
+
if (result.redirect) {
|
|
2403
|
+
reply.redirect(result.redirect);
|
|
2404
|
+
return null;
|
|
2405
|
+
}
|
|
2406
|
+
return {
|
|
2407
|
+
state,
|
|
2408
|
+
reactStream: result.reactStream
|
|
2409
|
+
};
|
|
2410
|
+
}, {
|
|
2411
|
+
hydration: true,
|
|
2412
|
+
onError: (error) => {
|
|
2413
|
+
if (error instanceof Redirection) this.log.debug("Streaming resulted in redirection", { redirect: error.redirect });
|
|
2414
|
+
else this.log.error("HTML stream error", error);
|
|
2415
|
+
}
|
|
2416
|
+
});
|
|
2417
|
+
this.log.trace("Page streaming started (early head optimization)");
|
|
1702
2418
|
route.onServerResponse?.(serverRequest);
|
|
1703
|
-
|
|
1704
|
-
return event.html;
|
|
2419
|
+
reply.body = htmlStream;
|
|
1705
2420
|
};
|
|
1706
2421
|
}
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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);
|
|
2422
|
+
/**
|
|
2423
|
+
* Core page rendering logic shared between SSR handler and static prerendering.
|
|
2424
|
+
*
|
|
2425
|
+
* Handles:
|
|
2426
|
+
* - Layer resolution (loaders)
|
|
2427
|
+
* - Redirect detection
|
|
2428
|
+
* - Head content filling
|
|
2429
|
+
* - Preload link collection
|
|
2430
|
+
* - React stream rendering
|
|
2431
|
+
*
|
|
2432
|
+
* @param route - The page route to render
|
|
2433
|
+
* @param state - The router state
|
|
2434
|
+
* @returns Render result with redirect or React stream
|
|
2435
|
+
*/
|
|
2436
|
+
async renderPage(route, state) {
|
|
2437
|
+
this.serverTimingProvider.beginTiming("createLayers");
|
|
2438
|
+
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
2439
|
+
this.serverTimingProvider.endTiming("createLayers");
|
|
2440
|
+
if (redirect) {
|
|
2441
|
+
this.log.debug("Resolver resulted in redirection", { redirect });
|
|
2442
|
+
return { redirect };
|
|
1744
2443
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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
|
-
};
|
|
2444
|
+
this.serverHeadProvider.fillHead(state);
|
|
2445
|
+
const preloadLinks = this.ssrManifestProvider.collectPreloadLinks(route);
|
|
2446
|
+
if (preloadLinks.length > 0) {
|
|
2447
|
+
state.head ??= {};
|
|
2448
|
+
state.head.link = [...state.head.link ?? [], ...preloadLinks];
|
|
1762
2449
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
2450
|
+
this.serverTimingProvider.beginTiming("renderToStream");
|
|
2451
|
+
const element = this.pageApi.root(state);
|
|
2452
|
+
this.alepha.store.set("alepha.react.router.state", state);
|
|
2453
|
+
const reactStream = await renderToReadableStream(element, { onError: (error) => {
|
|
2454
|
+
if (error instanceof Redirection) this.log.warn("Redirect during streaming ignored", { redirect: error.redirect });
|
|
2455
|
+
else this.log.error("Streaming render error", error);
|
|
2456
|
+
} });
|
|
2457
|
+
this.serverTimingProvider.endTiming("renderToStream");
|
|
2458
|
+
return { reactStream };
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* For testing purposes, renders a page to HTML string.
|
|
2462
|
+
* Uses the same streaming code path as production, then collects to string.
|
|
2463
|
+
*
|
|
2464
|
+
* @param name - Page name to render
|
|
2465
|
+
* @param options - Render options (params, query, html, hydration)
|
|
2466
|
+
*/
|
|
2467
|
+
async render(name, options = {}) {
|
|
2468
|
+
const page = this.pageApi.page(name);
|
|
2469
|
+
const url = new URL(this.pageApi.url(name, options));
|
|
2470
|
+
const state = {
|
|
2471
|
+
url,
|
|
2472
|
+
params: options.params ?? {},
|
|
2473
|
+
query: options.query ?? {},
|
|
2474
|
+
onError: () => null,
|
|
2475
|
+
layers: [],
|
|
2476
|
+
meta: {},
|
|
2477
|
+
head: {}
|
|
2478
|
+
};
|
|
2479
|
+
this.log.trace("Rendering", { url });
|
|
2480
|
+
await this.alepha.events.emit("react:server:render:begin", { state });
|
|
2481
|
+
if (!this.templateProvider.isReady()) {
|
|
2482
|
+
this.templateProvider.parseTemplate(this.template);
|
|
2483
|
+
this.setupEarlyHeadContent();
|
|
1773
2484
|
}
|
|
2485
|
+
const result = await this.renderPage(page, state);
|
|
2486
|
+
if (result.redirect) return {
|
|
2487
|
+
state,
|
|
2488
|
+
html: "",
|
|
2489
|
+
redirect: result.redirect
|
|
2490
|
+
};
|
|
2491
|
+
const reactStream = result.reactStream;
|
|
2492
|
+
if (!options.html) return {
|
|
2493
|
+
state,
|
|
2494
|
+
html: await this.streamToString(reactStream)
|
|
2495
|
+
};
|
|
2496
|
+
const htmlStream = this.templateProvider.createHtmlStream(reactStream, state, { hydration: options.hydration ?? true });
|
|
2497
|
+
const html = await this.streamToString(htmlStream);
|
|
2498
|
+
await this.alepha.events.emit("react:server:render:end", {
|
|
2499
|
+
state,
|
|
2500
|
+
html
|
|
2501
|
+
});
|
|
1774
2502
|
return {
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
beforeScript,
|
|
1778
|
-
afterScript
|
|
2503
|
+
state,
|
|
2504
|
+
html
|
|
1779
2505
|
};
|
|
1780
2506
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
2507
|
+
/**
|
|
2508
|
+
* Collect a ReadableStream into a string.
|
|
2509
|
+
*/
|
|
2510
|
+
async streamToString(stream) {
|
|
2511
|
+
const reader = stream.getReader();
|
|
2512
|
+
const decoder = new TextDecoder();
|
|
2513
|
+
const chunks = [];
|
|
2514
|
+
try {
|
|
2515
|
+
while (true) {
|
|
2516
|
+
const { done, value } = await reader.read();
|
|
2517
|
+
if (done) break;
|
|
2518
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
2519
|
+
}
|
|
2520
|
+
chunks.push(decoder.decode());
|
|
2521
|
+
} finally {
|
|
2522
|
+
reader.releaseLock();
|
|
2523
|
+
}
|
|
2524
|
+
return chunks.join("");
|
|
1784
2525
|
}
|
|
1785
2526
|
};
|
|
2527
|
+
const envSchema = t.object({ REACT_SSR_ENABLED: t.optional(t.boolean()) });
|
|
2528
|
+
/**
|
|
2529
|
+
* React server provider configuration atom
|
|
2530
|
+
*/
|
|
2531
|
+
const reactServerOptions = $atom({
|
|
2532
|
+
name: "alepha.react.server.options",
|
|
2533
|
+
schema: t.object({
|
|
2534
|
+
publicDir: t.string(),
|
|
2535
|
+
staticServer: t.object({
|
|
2536
|
+
disabled: t.boolean(),
|
|
2537
|
+
path: t.string({ description: "URL path where static files will be served." })
|
|
2538
|
+
})
|
|
2539
|
+
}),
|
|
2540
|
+
default: {
|
|
2541
|
+
publicDir: "public",
|
|
2542
|
+
staticServer: {
|
|
2543
|
+
disabled: false,
|
|
2544
|
+
path: "/"
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
});
|
|
1786
2548
|
|
|
1787
2549
|
//#endregion
|
|
1788
2550
|
//#region ../../src/router/services/ReactPageServerService.ts
|
|
@@ -1791,6 +2553,7 @@ var ReactServerProvider = class {
|
|
|
1791
2553
|
*/
|
|
1792
2554
|
var ReactPageServerService = class extends ReactPageService {
|
|
1793
2555
|
reactServerProvider = $inject(ReactServerProvider);
|
|
2556
|
+
templateProvider = $inject(ReactServerTemplateProvider);
|
|
1794
2557
|
serverProvider = $inject(ServerProvider);
|
|
1795
2558
|
async render(name, options = {}) {
|
|
1796
2559
|
return this.reactServerProvider.render(name, options);
|
|
@@ -1802,9 +2565,9 @@ var ReactPageServerService = class extends ReactPageService {
|
|
|
1802
2565
|
html,
|
|
1803
2566
|
response
|
|
1804
2567
|
};
|
|
1805
|
-
const
|
|
1806
|
-
if (
|
|
1807
|
-
html:
|
|
2568
|
+
const rootContent = this.templateProvider.extractRootContent(html);
|
|
2569
|
+
if (rootContent !== void 0) return {
|
|
2570
|
+
html: rootContent,
|
|
1808
2571
|
response
|
|
1809
2572
|
};
|
|
1810
2573
|
throw new AlephaError("Invalid HTML response");
|
|
@@ -1820,7 +2583,7 @@ var ReactPageServerService = class extends ReactPageService {
|
|
|
1820
2583
|
* - URL pattern matching with parameters (e.g., `/users/:id`)
|
|
1821
2584
|
* - Nested routing with parent-child relationships
|
|
1822
2585
|
* - Type-safe URL parameter and query string validation
|
|
1823
|
-
* - Server-side data fetching with the `
|
|
2586
|
+
* - Server-side data fetching with the `loader` function
|
|
1824
2587
|
* - Lazy loading and code splitting
|
|
1825
2588
|
* - Page animations and error handling
|
|
1826
2589
|
*
|
|
@@ -1835,12 +2598,14 @@ const AlephaReactRouter = $module({
|
|
|
1835
2598
|
ReactPageService,
|
|
1836
2599
|
ReactRouter,
|
|
1837
2600
|
ReactServerProvider,
|
|
2601
|
+
ReactServerTemplateProvider,
|
|
2602
|
+
SSRManifestProvider,
|
|
1838
2603
|
ReactPageServerService
|
|
1839
2604
|
],
|
|
1840
2605
|
register: (alepha) => alepha.with(AlephaReact).with(AlephaDateTime).with(AlephaServer).with(AlephaServerCache).with(AlephaServerLinks).with({
|
|
1841
2606
|
provide: ReactPageService,
|
|
1842
2607
|
use: ReactPageServerService
|
|
1843
|
-
}).with(ReactServerProvider).with(ReactPageProvider).with(ReactRouter)
|
|
2608
|
+
}).with(SSRManifestProvider).with(ReactServerTemplateProvider).with(ReactServerProvider).with(ReactPageProvider).with(ReactRouter)
|
|
1844
2609
|
});
|
|
1845
2610
|
|
|
1846
2611
|
//#endregion
|