alepha 0.15.2 → 0.15.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/README.md +68 -80
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +8 -0
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.d.ts +170 -170
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +1 -0
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +3 -0
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/notifications/index.browser.js +1 -0
- package/dist/api/notifications/index.browser.js.map +1 -1
- package/dist/api/notifications/index.js +1 -0
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +260 -260
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +10 -0
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/users/index.d.ts +12 -1
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +18 -2
- package/dist/api/users/index.js.map +1 -1
- package/dist/batch/index.d.ts +4 -4
- package/dist/bucket/index.d.ts +8 -0
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +7 -2
- package/dist/bucket/index.js.map +1 -1
- package/dist/cli/index.d.ts +196 -74
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +234 -50
- package/dist/cli/index.js.map +1 -1
- package/dist/command/index.d.ts +10 -0
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/index.js +67 -13
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js +28 -21
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +28 -21
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +28 -21
- package/dist/core/index.native.js.map +1 -1
- package/dist/email/index.d.ts +21 -13
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +10561 -4
- package/dist/email/index.js.map +1 -1
- package/dist/lock/core/index.d.ts +6 -1
- package/dist/lock/core/index.d.ts.map +1 -1
- package/dist/lock/core/index.js +9 -1
- package/dist/lock/core/index.js.map +1 -1
- package/dist/mcp/index.d.ts +5 -5
- package/dist/orm/index.bun.js +32 -16
- package/dist/orm/index.bun.js.map +1 -1
- package/dist/orm/index.d.ts +4 -1
- package/dist/orm/index.d.ts.map +1 -1
- package/dist/orm/index.js +34 -22
- package/dist/orm/index.js.map +1 -1
- package/dist/react/auth/index.browser.js +2 -1
- package/dist/react/auth/index.browser.js.map +1 -1
- package/dist/react/auth/index.js +2 -1
- package/dist/react/auth/index.js.map +1 -1
- package/dist/react/core/index.d.ts +3 -3
- package/dist/react/router/index.browser.js +9 -15
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +305 -407
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +581 -781
- package/dist/react/router/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +13 -1
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +42 -4
- package/dist/scheduler/index.js.map +1 -1
- package/dist/security/index.d.ts +42 -42
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +8 -7
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +167 -167
- package/dist/server/compress/index.d.ts.map +1 -1
- package/dist/server/compress/index.js +1 -0
- package/dist/server/compress/index.js.map +1 -1
- package/dist/server/health/index.d.ts +17 -17
- package/dist/server/links/index.d.ts +39 -39
- package/dist/server/links/index.js +1 -1
- package/dist/server/links/index.js.map +1 -1
- package/dist/server/static/index.js +7 -2
- package/dist/server/static/index.js.map +1 -1
- package/dist/server/swagger/index.d.ts +8 -0
- package/dist/server/swagger/index.d.ts.map +1 -1
- package/dist/server/swagger/index.js +7 -2
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/sms/index.d.ts +8 -0
- package/dist/sms/index.d.ts.map +1 -1
- package/dist/sms/index.js +7 -2
- package/dist/sms/index.js.map +1 -1
- package/dist/system/index.browser.js +734 -12
- package/dist/system/index.browser.js.map +1 -1
- package/dist/system/index.d.ts +8 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +7 -2
- package/dist/system/index.js.map +1 -1
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +42 -8
- package/dist/vite/index.js.map +1 -1
- package/dist/websocket/index.d.ts +34 -34
- package/dist/websocket/index.d.ts.map +1 -1
- package/package.json +9 -4
- package/src/api/audits/controllers/AdminAuditController.ts +8 -0
- package/src/api/files/controllers/AdminFileStatsController.ts +1 -0
- package/src/api/jobs/controllers/AdminJobController.ts +3 -0
- package/src/api/logs/TODO.md +13 -10
- package/src/api/notifications/controllers/AdminNotificationController.ts +1 -0
- package/src/api/parameters/controllers/AdminConfigController.ts +10 -0
- package/src/api/users/controllers/AdminIdentityController.ts +3 -0
- package/src/api/users/controllers/AdminSessionController.ts +3 -0
- package/src/api/users/controllers/AdminUserController.ts +5 -0
- package/src/cli/apps/AlephaPackageBuilderCli.ts +9 -0
- package/src/cli/atoms/buildOptions.ts +99 -9
- package/src/cli/commands/build.ts +150 -32
- package/src/cli/commands/db.ts +5 -7
- package/src/cli/commands/init.spec.ts +50 -6
- package/src/cli/commands/init.ts +28 -5
- package/src/cli/providers/ViteDevServerProvider.ts +31 -9
- package/src/cli/services/AlephaCliUtils.ts +16 -0
- package/src/cli/services/PackageManagerUtils.ts +2 -0
- package/src/cli/services/ProjectScaffolder.spec.ts +97 -0
- package/src/cli/services/ProjectScaffolder.ts +28 -6
- package/src/cli/templates/agentMd.ts +6 -1
- package/src/cli/templates/apiAppSecurityTs.ts +11 -0
- package/src/cli/templates/apiIndexTs.ts +18 -4
- package/src/cli/templates/webAppRouterTs.ts +25 -1
- package/src/cli/templates/webHelloComponentTsx.ts +15 -5
- package/src/command/helpers/Runner.spec.ts +135 -0
- package/src/command/helpers/Runner.ts +4 -1
- package/src/command/providers/CliProvider.spec.ts +325 -0
- package/src/command/providers/CliProvider.ts +117 -7
- package/src/core/Alepha.ts +32 -25
- package/src/email/index.workerd.ts +36 -0
- package/src/email/providers/WorkermailerEmailProvider.ts +221 -0
- package/src/lock/core/primitives/$lock.ts +13 -1
- package/src/orm/index.bun.ts +1 -1
- package/src/orm/index.ts +2 -6
- package/src/orm/providers/drivers/BunSqliteProvider.ts +4 -1
- package/src/orm/providers/drivers/CloudflareD1Provider.ts +57 -30
- package/src/orm/providers/drivers/DatabaseProvider.ts +9 -1
- package/src/orm/providers/drivers/NodeSqliteProvider.ts +4 -1
- package/src/react/auth/services/ReactAuth.ts +3 -1
- package/src/react/router/atoms/ssrManifestAtom.ts +7 -0
- package/src/react/router/hooks/useActive.ts +1 -1
- package/src/react/router/hooks/useRouter.ts +1 -1
- package/src/react/router/index.ts +4 -0
- package/src/react/router/primitives/$page.browser.spec.tsx +24 -24
- package/src/react/router/primitives/$page.spec.tsx +0 -32
- package/src/react/router/primitives/$page.ts +6 -14
- package/src/react/router/providers/ReactBrowserProvider.ts +6 -3
- package/src/react/router/providers/ReactPageProvider.ts +1 -1
- package/src/react/router/providers/ReactPreloadProvider.spec.ts +142 -0
- package/src/react/router/providers/ReactPreloadProvider.ts +85 -0
- package/src/react/router/providers/ReactServerProvider.ts +21 -82
- package/src/react/router/providers/ReactServerTemplateProvider.spec.ts +210 -0
- package/src/react/router/providers/ReactServerTemplateProvider.ts +228 -665
- package/src/react/router/providers/SSRManifestProvider.ts +7 -0
- package/src/react/router/services/ReactRouter.ts +13 -13
- package/src/scheduler/index.workerd.ts +43 -0
- package/src/scheduler/providers/CronProvider.ts +53 -6
- package/src/scheduler/providers/WorkerdCronProvider.ts +102 -0
- package/src/security/__tests__/ServerSecurityProvider.spec.ts +77 -0
- package/src/security/providers/ServerSecurityProvider.ts +30 -22
- package/src/server/compress/providers/ServerCompressProvider.ts +6 -0
- package/src/server/core/providers/NodeHttpServerProvider.spec.ts +9 -3
- package/src/server/links/providers/ServerLinksProvider.spec.ts +332 -0
- package/src/server/links/providers/ServerLinksProvider.ts +1 -1
- package/src/system/index.browser.ts +25 -0
- package/src/system/index.workerd.ts +1 -0
- package/src/system/providers/FileSystemProvider.ts +8 -0
- package/src/system/providers/NodeFileSystemProvider.ts +11 -2
- package/src/vite/tasks/buildServer.ts +2 -12
- package/src/vite/tasks/generateCloudflare.ts +47 -8
- package/src/vite/tasks/generateDocker.ts +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $inject, Alepha
|
|
1
|
+
import { $inject, Alepha } from "alepha";
|
|
2
2
|
import { $logger } from "alepha/logger";
|
|
3
3
|
import { AlephaContext } from "alepha/react";
|
|
4
4
|
import type { SimpleHead } from "alepha/react/head";
|
|
@@ -9,377 +9,148 @@ import { Redirection } from "../errors/Redirection.ts";
|
|
|
9
9
|
import type { ReactRouterState } from "./ReactPageProvider.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Handles HTML
|
|
12
|
+
* Handles HTML streaming for SSR.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
* - Pre-encode static parts as Uint8Array for zero-copy streaming
|
|
17
|
-
* - Render dynamic parts (attributes, head content) efficiently
|
|
18
|
-
* - Build hydration data for client-side rehydration
|
|
19
|
-
*
|
|
20
|
-
* This provider is injected into ReactServerProvider to handle all
|
|
21
|
-
* template-related operations, keeping ReactServerProvider focused
|
|
22
|
-
* on request handling and React rendering coordination.
|
|
14
|
+
* Uses hardcoded HTML structure - all customization via $head primitive.
|
|
15
|
+
* Pre-encodes static parts as Uint8Array for zero-copy streaming.
|
|
23
16
|
*/
|
|
24
17
|
export class ReactServerTemplateProvider {
|
|
25
18
|
protected readonly log = $logger();
|
|
26
19
|
protected readonly alepha = $inject(Alepha);
|
|
27
20
|
|
|
28
21
|
/**
|
|
29
|
-
* Shared TextEncoder
|
|
22
|
+
* Shared TextEncoder - reused across all requests.
|
|
30
23
|
*/
|
|
31
24
|
protected readonly encoder = new TextEncoder();
|
|
32
25
|
|
|
33
26
|
/**
|
|
34
|
-
* Pre-encoded
|
|
27
|
+
* Pre-encoded static HTML parts for zero-copy streaming.
|
|
35
28
|
*/
|
|
36
|
-
protected readonly
|
|
29
|
+
protected readonly SLOTS = {
|
|
30
|
+
DOCTYPE: this.encoder.encode("<!DOCTYPE html>\n"),
|
|
31
|
+
HTML_OPEN: this.encoder.encode("<html"),
|
|
32
|
+
HTML_CLOSE: this.encoder.encode(">\n"),
|
|
33
|
+
HEAD_OPEN: this.encoder.encode("<head>"),
|
|
34
|
+
HEAD_CLOSE: this.encoder.encode("</head>\n"),
|
|
35
|
+
BODY_OPEN: this.encoder.encode("<body"),
|
|
36
|
+
BODY_CLOSE: this.encoder.encode(">\n"),
|
|
37
|
+
ROOT_OPEN: this.encoder.encode('<div id="root">'),
|
|
38
|
+
ROOT_CLOSE: this.encoder.encode("</div>\n"),
|
|
39
|
+
BODY_HTML_CLOSE: this.encoder.encode("</body>\n</html>"),
|
|
37
40
|
HYDRATION_PREFIX: this.encoder.encode("<script>window.__ssr="),
|
|
38
41
|
HYDRATION_SUFFIX: this.encoder.encode("</script>"),
|
|
39
|
-
EMPTY: this.encoder.encode(""),
|
|
40
42
|
} as const;
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
*
|
|
45
|
+
* Early head content (charset, viewport, entry assets).
|
|
46
|
+
* Set once during configuration, reused for all requests.
|
|
44
47
|
*/
|
|
45
|
-
protected
|
|
48
|
+
protected earlyHeadContent = "";
|
|
46
49
|
|
|
47
50
|
/**
|
|
48
51
|
* Root element ID for React mounting.
|
|
49
52
|
*/
|
|
50
|
-
public
|
|
51
|
-
return "root";
|
|
52
|
-
}
|
|
53
|
+
public readonly rootId = "root";
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* Regex
|
|
56
|
+
* Regex for extracting root div content from HTML.
|
|
56
57
|
*/
|
|
57
|
-
public
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
}
|
|
58
|
+
public readonly rootDivRegex = new RegExp(
|
|
59
|
+
`<div[^>]*\\s+id=["']${this.rootId}["'][^>]*>([\\s\\S]*?)<\\/div>`,
|
|
60
|
+
"i",
|
|
61
|
+
);
|
|
63
62
|
|
|
64
63
|
/**
|
|
65
|
-
* Extract
|
|
66
|
-
*
|
|
67
|
-
* @param html - Full HTML string
|
|
68
|
-
* @returns The content inside the root div, or undefined if not found
|
|
64
|
+
* Extract content inside the root div from HTML.
|
|
69
65
|
*/
|
|
70
66
|
public extractRootContent(html: string): string | undefined {
|
|
71
|
-
|
|
72
|
-
return match?.[3];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Check if template has been parsed and slots are available.
|
|
77
|
-
*/
|
|
78
|
-
public isReady(): boolean {
|
|
79
|
-
return this.slots !== null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Get the parsed template slots.
|
|
84
|
-
* Throws if template hasn't been parsed yet.
|
|
85
|
-
*/
|
|
86
|
-
public getSlots(): TemplateSlots {
|
|
87
|
-
if (!this.slots) {
|
|
88
|
-
throw new AlephaError(
|
|
89
|
-
"Template not parsed. Call parseTemplate() during configuration.",
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return this.slots;
|
|
67
|
+
return html.match(this.rootDivRegex)?.[1];
|
|
93
68
|
}
|
|
94
69
|
|
|
95
70
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* This should be called once during server startup/configuration.
|
|
99
|
-
* The parsed slots are cached and reused for all requests.
|
|
71
|
+
* Set early head content (charset, viewport, entry assets).
|
|
72
|
+
* Called once during server configuration.
|
|
100
73
|
*/
|
|
101
|
-
public
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const doctype = doctypeMatch?.[0] ?? "<!DOCTYPE html>";
|
|
109
|
-
let remaining = doctypeMatch
|
|
110
|
-
? template.slice(doctypeMatch.index! + doctypeMatch[0].length)
|
|
111
|
-
: template;
|
|
112
|
-
|
|
113
|
-
// Extract <html> tag and attributes
|
|
114
|
-
const htmlMatch = remaining.match(/<html([^>]*)>/i);
|
|
115
|
-
const htmlAttrsStr = htmlMatch?.[1]?.trim() ?? "";
|
|
116
|
-
const htmlOriginalAttrs = this.parseAttributes(htmlAttrsStr);
|
|
117
|
-
remaining = htmlMatch
|
|
118
|
-
? remaining.slice(htmlMatch.index! + htmlMatch[0].length)
|
|
119
|
-
: remaining;
|
|
120
|
-
|
|
121
|
-
// Extract <head> content
|
|
122
|
-
const headMatch = remaining.match(/<head([^>]*)>([\s\S]*?)<\/head>/i);
|
|
123
|
-
const headOriginalContent = headMatch?.[2]?.trim() ?? "";
|
|
124
|
-
remaining = headMatch
|
|
125
|
-
? remaining.slice(headMatch.index! + headMatch[0].length)
|
|
126
|
-
: remaining;
|
|
127
|
-
|
|
128
|
-
// Extract <body> tag and attributes
|
|
129
|
-
const bodyMatch = remaining.match(/<body([^>]*)>/i);
|
|
130
|
-
const bodyAttrsStr = bodyMatch?.[1]?.trim() ?? "";
|
|
131
|
-
const bodyOriginalAttrs = this.parseAttributes(bodyAttrsStr);
|
|
132
|
-
const bodyStartIndex = bodyMatch
|
|
133
|
-
? bodyMatch.index! + bodyMatch[0].length
|
|
134
|
-
: 0;
|
|
135
|
-
remaining = remaining.slice(bodyStartIndex);
|
|
136
|
-
|
|
137
|
-
// Find root div
|
|
138
|
-
const rootDivRegex = new RegExp(
|
|
139
|
-
`<div([^>]*)\\s+id=["']${rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`,
|
|
140
|
-
"i",
|
|
141
|
-
);
|
|
142
|
-
const rootMatch = remaining.match(rootDivRegex);
|
|
143
|
-
|
|
144
|
-
let beforeRoot = "";
|
|
145
|
-
let afterRoot = "";
|
|
146
|
-
let rootAttrs = "";
|
|
147
|
-
|
|
148
|
-
if (rootMatch) {
|
|
149
|
-
beforeRoot = remaining.slice(0, rootMatch.index!).trim();
|
|
150
|
-
const rootEndIndex = rootMatch.index! + rootMatch[0].length;
|
|
151
|
-
// Find </body> for afterRoot
|
|
152
|
-
const bodyCloseIndex = remaining.indexOf("</body>");
|
|
153
|
-
afterRoot =
|
|
154
|
-
bodyCloseIndex > rootEndIndex
|
|
155
|
-
? remaining.slice(rootEndIndex, bodyCloseIndex).trim()
|
|
156
|
-
: "";
|
|
157
|
-
rootAttrs = `${rootMatch[1] ?? ""}${rootMatch[2] ?? ""}`.trim();
|
|
158
|
-
} else {
|
|
159
|
-
// No root div found - will inject one
|
|
160
|
-
const bodyCloseIndex = remaining.indexOf("</body>");
|
|
161
|
-
if (bodyCloseIndex > 0) {
|
|
162
|
-
beforeRoot = remaining.slice(0, bodyCloseIndex).trim();
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Build the root div opening tag
|
|
167
|
-
const rootOpenTag = rootAttrs
|
|
168
|
-
? `<div ${rootAttrs} id="${rootId}">`
|
|
169
|
-
: `<div id="${rootId}">`;
|
|
170
|
-
|
|
171
|
-
this.slots = {
|
|
172
|
-
// Pre-encoded static parts
|
|
173
|
-
doctype: this.encoder.encode(`${doctype}\n`),
|
|
174
|
-
htmlOpen: this.encoder.encode("<html"),
|
|
175
|
-
htmlClose: this.encoder.encode(">\n"),
|
|
176
|
-
headOpen: this.encoder.encode("<head>"),
|
|
177
|
-
headClose: this.encoder.encode("</head>\n"),
|
|
178
|
-
bodyOpen: this.encoder.encode("<body"),
|
|
179
|
-
bodyClose: this.encoder.encode(">\n"),
|
|
180
|
-
rootOpen: this.encoder.encode(rootOpenTag),
|
|
181
|
-
rootClose: this.encoder.encode("</div>\n"),
|
|
182
|
-
scriptClose: this.encoder.encode("</body>\n</html>"),
|
|
183
|
-
|
|
184
|
-
// Original content for merging
|
|
185
|
-
htmlOriginalAttrs,
|
|
186
|
-
bodyOriginalAttrs,
|
|
187
|
-
headOriginalContent,
|
|
188
|
-
beforeRoot,
|
|
189
|
-
afterRoot,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
this.log.debug("Template parsed successfully", {
|
|
193
|
-
hasHtmlAttrs: Object.keys(htmlOriginalAttrs).length > 0,
|
|
194
|
-
hasBodyAttrs: Object.keys(bodyOriginalAttrs).length > 0,
|
|
195
|
-
hasHeadContent: headOriginalContent.length > 0,
|
|
196
|
-
hasBeforeRoot: beforeRoot.length > 0,
|
|
197
|
-
hasAfterRoot: afterRoot.length > 0,
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
return this.slots;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Parse HTML attributes string into a record.
|
|
205
|
-
*
|
|
206
|
-
* Handles: key="value", key='value', key=value, and boolean key
|
|
207
|
-
*/
|
|
208
|
-
protected parseAttributes(attrStr: string): Record<string, string> {
|
|
209
|
-
const attrs: Record<string, string> = {};
|
|
210
|
-
if (!attrStr) return attrs;
|
|
211
|
-
|
|
212
|
-
// Match: key="value", key='value', key=value, or just key (boolean)
|
|
213
|
-
const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
214
|
-
|
|
215
|
-
for (const match of attrStr.matchAll(attrRegex)) {
|
|
216
|
-
const key = match[1];
|
|
217
|
-
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
218
|
-
attrs[key] = value;
|
|
219
|
-
}
|
|
74
|
+
public setEarlyHeadContent(
|
|
75
|
+
entryAssets: string,
|
|
76
|
+
globalHead?: SimpleHead,
|
|
77
|
+
): void {
|
|
78
|
+
const charset = globalHead?.charset ?? "UTF-8";
|
|
79
|
+
const viewport =
|
|
80
|
+
globalHead?.viewport ?? "width=device-width, initial-scale=1";
|
|
220
81
|
|
|
221
|
-
|
|
82
|
+
this.earlyHeadContent =
|
|
83
|
+
`<meta charset="${this.escapeHtml(charset)}">\n` +
|
|
84
|
+
`<meta name="viewport" content="${this.escapeHtml(viewport)}">\n` +
|
|
85
|
+
entryAssets;
|
|
222
86
|
}
|
|
223
87
|
|
|
224
88
|
/**
|
|
225
89
|
* Render attributes record to HTML string.
|
|
226
|
-
*
|
|
227
|
-
* @param attrs - Attributes to render
|
|
228
|
-
* @returns HTML attribute string like ` lang="en" class="dark"`
|
|
229
90
|
*/
|
|
230
|
-
public renderAttributes(attrs
|
|
91
|
+
public renderAttributes(attrs?: Record<string, string>): string {
|
|
92
|
+
if (!attrs) return "";
|
|
231
93
|
const entries = Object.entries(attrs);
|
|
232
94
|
if (entries.length === 0) return "";
|
|
233
|
-
|
|
234
95
|
return entries
|
|
235
96
|
.map(([key, value]) => ` ${key}="${this.escapeHtml(value)}"`)
|
|
236
97
|
.join("");
|
|
237
98
|
}
|
|
238
99
|
|
|
239
|
-
/**
|
|
240
|
-
* Render merged HTML attributes (original + dynamic).
|
|
241
|
-
*/
|
|
242
|
-
public renderMergedHtmlAttrs(dynamicAttrs?: Record<string, string>): string {
|
|
243
|
-
const slots = this.getSlots();
|
|
244
|
-
const merged = { ...slots.htmlOriginalAttrs, ...dynamicAttrs };
|
|
245
|
-
return this.renderAttributes(merged);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Render merged body attributes (original + dynamic).
|
|
250
|
-
*/
|
|
251
|
-
public renderMergedBodyAttrs(dynamicAttrs?: Record<string, string>): string {
|
|
252
|
-
const slots = this.getSlots();
|
|
253
|
-
const merged = { ...slots.bodyOriginalAttrs, ...dynamicAttrs };
|
|
254
|
-
return this.renderAttributes(merged);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
100
|
/**
|
|
258
101
|
* Render head content (title, meta, link, script tags).
|
|
259
|
-
*
|
|
260
|
-
* @param head - Head data to render
|
|
261
|
-
* @param includeOriginal - Whether to include original head content
|
|
262
|
-
* @returns HTML string with head content
|
|
263
102
|
*/
|
|
264
|
-
public renderHeadContent(head?: SimpleHead
|
|
265
|
-
|
|
266
|
-
let content = "";
|
|
267
|
-
|
|
268
|
-
// Include original head content first
|
|
269
|
-
if (includeOriginal && slots.headOriginalContent) {
|
|
270
|
-
content += slots.headOriginalContent;
|
|
271
|
-
}
|
|
103
|
+
public renderHeadContent(head?: SimpleHead): string {
|
|
104
|
+
if (!head) return "";
|
|
272
105
|
|
|
273
|
-
|
|
106
|
+
let content = "";
|
|
274
107
|
|
|
275
|
-
// Title - check if already exists in original content
|
|
276
108
|
if (head.title) {
|
|
277
|
-
|
|
278
|
-
// Replace existing title
|
|
279
|
-
content = content.replace(
|
|
280
|
-
/<title>.*?<\/title>/i,
|
|
281
|
-
`<title>${this.escapeHtml(head.title)}</title>`,
|
|
282
|
-
);
|
|
283
|
-
} else {
|
|
284
|
-
content += `<title>${this.escapeHtml(head.title)}</title>\n`;
|
|
285
|
-
}
|
|
109
|
+
content += `<title>${this.escapeHtml(head.title)}</title>\n`;
|
|
286
110
|
}
|
|
287
111
|
|
|
288
|
-
// Meta tags
|
|
289
112
|
if (head.meta) {
|
|
290
113
|
for (const meta of head.meta) {
|
|
291
|
-
|
|
114
|
+
if (meta.property) {
|
|
115
|
+
content += `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
116
|
+
} else if (meta.name) {
|
|
117
|
+
content += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
118
|
+
}
|
|
292
119
|
}
|
|
293
120
|
}
|
|
294
121
|
|
|
295
|
-
// Link tags
|
|
296
122
|
if (head.link) {
|
|
297
123
|
for (const link of head.link) {
|
|
298
|
-
content += this.
|
|
124
|
+
content += `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
|
|
125
|
+
if (link.type) content += ` type="${this.escapeHtml(link.type)}"`;
|
|
126
|
+
if (link.as) content += ` as="${this.escapeHtml(link.as)}"`;
|
|
127
|
+
if (link.crossorigin != null) content += ' crossorigin=""';
|
|
128
|
+
content += ">\n";
|
|
299
129
|
}
|
|
300
130
|
}
|
|
301
131
|
|
|
302
|
-
// Script tags
|
|
303
132
|
if (head.script) {
|
|
304
133
|
for (const script of head.script) {
|
|
305
|
-
|
|
134
|
+
if (typeof script === "string") {
|
|
135
|
+
content += `<script>${script}</script>\n`;
|
|
136
|
+
} else {
|
|
137
|
+
const { content: scriptContent, ...rest } = script;
|
|
138
|
+
const attrs = Object.entries(rest)
|
|
139
|
+
.filter(([, v]) => v !== false && v !== undefined)
|
|
140
|
+
.map(([k, v]) =>
|
|
141
|
+
v === true ? k : `${k}="${this.escapeHtml(String(v))}"`,
|
|
142
|
+
)
|
|
143
|
+
.join(" ");
|
|
144
|
+
content += scriptContent
|
|
145
|
+
? `<script ${attrs}>${scriptContent}</script>\n`
|
|
146
|
+
: `<script ${attrs}></script>\n`;
|
|
147
|
+
}
|
|
306
148
|
}
|
|
307
149
|
}
|
|
308
150
|
|
|
309
151
|
return content;
|
|
310
152
|
}
|
|
311
153
|
|
|
312
|
-
/**
|
|
313
|
-
* Render a meta tag.
|
|
314
|
-
*/
|
|
315
|
-
protected renderMetaTag(meta: {
|
|
316
|
-
name?: string;
|
|
317
|
-
property?: string;
|
|
318
|
-
content: string;
|
|
319
|
-
}): string {
|
|
320
|
-
if (meta.property) {
|
|
321
|
-
return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
322
|
-
}
|
|
323
|
-
if (meta.name) {
|
|
324
|
-
return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
325
|
-
}
|
|
326
|
-
return "";
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Render a link tag.
|
|
331
|
-
*/
|
|
332
|
-
protected renderLinkTag(link: {
|
|
333
|
-
rel: string;
|
|
334
|
-
href: string;
|
|
335
|
-
type?: string;
|
|
336
|
-
as?: string;
|
|
337
|
-
crossorigin?: string;
|
|
338
|
-
}): string {
|
|
339
|
-
let tag = `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
|
|
340
|
-
if (link.type) {
|
|
341
|
-
tag += ` type="${this.escapeHtml(link.type)}"`;
|
|
342
|
-
}
|
|
343
|
-
if (link.as) {
|
|
344
|
-
tag += ` as="${this.escapeHtml(link.as)}"`;
|
|
345
|
-
}
|
|
346
|
-
if (link.crossorigin != null) {
|
|
347
|
-
tag += ' crossorigin=""';
|
|
348
|
-
}
|
|
349
|
-
tag += ">\n";
|
|
350
|
-
return tag;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Render a script tag.
|
|
355
|
-
*/
|
|
356
|
-
protected renderScriptTag(
|
|
357
|
-
script:
|
|
358
|
-
| string
|
|
359
|
-
| (Record<string, string | boolean | undefined> & { content?: string }),
|
|
360
|
-
): string {
|
|
361
|
-
// Handle plain string as inline script
|
|
362
|
-
if (typeof script === "string") {
|
|
363
|
-
return `<script>${script}</script>\n`;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const { content, ...rest } = script;
|
|
367
|
-
const attrs = Object.entries(rest)
|
|
368
|
-
.filter(([, value]) => value !== false && value !== undefined)
|
|
369
|
-
.map(([key, value]) => {
|
|
370
|
-
if (value === true) return key;
|
|
371
|
-
return `${key}="${this.escapeHtml(String(value))}"`;
|
|
372
|
-
})
|
|
373
|
-
.join(" ");
|
|
374
|
-
|
|
375
|
-
if (content) {
|
|
376
|
-
return attrs
|
|
377
|
-
? `<script ${attrs}>${content}</script>\n`
|
|
378
|
-
: `<script>${content}</script>\n`;
|
|
379
|
-
}
|
|
380
|
-
return `<script ${attrs}></script>\n`;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
154
|
/**
|
|
384
155
|
* Escape HTML special characters.
|
|
385
156
|
*/
|
|
@@ -394,7 +165,6 @@ export class ReactServerTemplateProvider {
|
|
|
394
165
|
|
|
395
166
|
/**
|
|
396
167
|
* Safely serialize data to JSON for embedding in HTML.
|
|
397
|
-
* Escapes characters that could break out of script tags.
|
|
398
168
|
*/
|
|
399
169
|
public safeJsonSerialize(data: unknown): string {
|
|
400
170
|
return JSON.stringify(data)
|
|
@@ -405,19 +175,16 @@ export class ReactServerTemplateProvider {
|
|
|
405
175
|
|
|
406
176
|
/**
|
|
407
177
|
* Build hydration data from router state.
|
|
408
|
-
*
|
|
409
|
-
* This creates the data structure that will be serialized to window.__ssr
|
|
410
|
-
* for client-side rehydration.
|
|
411
178
|
*/
|
|
412
179
|
public buildHydrationData(state: ReactRouterState): HydrationData {
|
|
413
180
|
const { request, context, ...store } =
|
|
414
181
|
this.alepha.context.als?.getStore() ?? {};
|
|
415
182
|
|
|
416
183
|
const layers = state.layers.map((layer) => ({
|
|
417
|
-
part: layer.part,
|
|
418
|
-
name: layer.name,
|
|
419
|
-
config: layer.config,
|
|
420
|
-
props: layer.props,
|
|
184
|
+
part: layer.part,
|
|
185
|
+
name: layer.name,
|
|
186
|
+
config: layer.config,
|
|
187
|
+
props: layer.props,
|
|
421
188
|
error: layer.error
|
|
422
189
|
? {
|
|
423
190
|
...layer.error,
|
|
@@ -428,9 +195,7 @@ export class ReactServerTemplateProvider {
|
|
|
428
195
|
: undefined,
|
|
429
196
|
}));
|
|
430
197
|
|
|
431
|
-
const hydrationData: HydrationData = {
|
|
432
|
-
layers,
|
|
433
|
-
};
|
|
198
|
+
const hydrationData: HydrationData = { layers };
|
|
434
199
|
|
|
435
200
|
for (const [key, value] of Object.entries(store)) {
|
|
436
201
|
if (
|
|
@@ -445,250 +210,101 @@ export class ReactServerTemplateProvider {
|
|
|
445
210
|
return hydrationData;
|
|
446
211
|
}
|
|
447
212
|
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Core streaming methods
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
448
217
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* If an error occurs during React streaming, it injects error HTML instead of aborting,
|
|
452
|
-
* ensuring users see an error message rather than a white screen.
|
|
218
|
+
* Pipe React stream to controller with backpressure handling.
|
|
219
|
+
* Returns true if stream completed successfully, false if error occurred.
|
|
453
220
|
*/
|
|
454
|
-
protected async
|
|
221
|
+
protected async pipeReactStream(
|
|
455
222
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
456
223
|
reactStream: ReadableStream<Uint8Array>,
|
|
457
224
|
state: ReactRouterState,
|
|
458
|
-
|
|
459
|
-
): Promise<void> {
|
|
460
|
-
const slots = this.getSlots();
|
|
461
|
-
const encoder = this.encoder;
|
|
462
|
-
const head = state.head;
|
|
463
|
-
|
|
464
|
-
// <body ...>
|
|
465
|
-
controller.enqueue(slots.bodyOpen);
|
|
466
|
-
controller.enqueue(
|
|
467
|
-
encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
|
|
468
|
-
);
|
|
469
|
-
controller.enqueue(slots.bodyClose);
|
|
470
|
-
|
|
471
|
-
// Content before root (if any)
|
|
472
|
-
if (slots.beforeRoot) {
|
|
473
|
-
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// <div id="root">
|
|
477
|
-
controller.enqueue(slots.rootOpen);
|
|
478
|
-
|
|
479
|
-
// Stream React content - catch errors from the React stream
|
|
225
|
+
): Promise<boolean> {
|
|
480
226
|
const reader = reactStream.getReader();
|
|
481
|
-
let streamError: unknown = null;
|
|
482
227
|
|
|
483
228
|
try {
|
|
484
229
|
while (true) {
|
|
230
|
+
// Backpressure: wait if buffer is full
|
|
231
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
232
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
233
|
+
}
|
|
234
|
+
|
|
485
235
|
const { done, value } = await reader.read();
|
|
486
236
|
if (done) break;
|
|
487
237
|
controller.enqueue(value);
|
|
488
238
|
}
|
|
239
|
+
return true;
|
|
489
240
|
} catch (error) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
241
|
+
this.log.error("React stream error", error);
|
|
242
|
+
controller.enqueue(
|
|
243
|
+
this.encoder.encode(
|
|
244
|
+
this.renderErrorToString(
|
|
245
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
246
|
+
state,
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
return false;
|
|
493
251
|
} finally {
|
|
494
252
|
reader.releaseLock();
|
|
495
253
|
}
|
|
496
|
-
|
|
497
|
-
// If React stream errored, inject error HTML inside the root div
|
|
498
|
-
if (streamError) {
|
|
499
|
-
this.injectErrorHtml(controller, encoder, slots, streamError, state, {
|
|
500
|
-
headClosed: true,
|
|
501
|
-
bodyStarted: true,
|
|
502
|
-
});
|
|
503
|
-
// injectErrorHtml already closes the document, so return early
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// </div>
|
|
508
|
-
controller.enqueue(slots.rootClose);
|
|
509
|
-
|
|
510
|
-
// Content after root (if any)
|
|
511
|
-
if (slots.afterRoot) {
|
|
512
|
-
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Hydration script
|
|
516
|
-
if (hydration) {
|
|
517
|
-
const hydrationData = this.buildHydrationData(state);
|
|
518
|
-
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
519
|
-
controller.enqueue(encoder.encode(this.safeJsonSerialize(hydrationData)));
|
|
520
|
-
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// </body></html>
|
|
524
|
-
controller.enqueue(slots.scriptClose);
|
|
525
254
|
}
|
|
526
255
|
|
|
527
256
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* This is the main entry point for SSR streaming. It:
|
|
531
|
-
* 1. Sends <head> immediately (browser starts downloading assets)
|
|
532
|
-
* 2. Streams React content as it renders
|
|
533
|
-
* 3. Appends hydration script and closing tags
|
|
534
|
-
*
|
|
535
|
-
* @param reactStream - ReadableStream from renderToReadableStream
|
|
536
|
-
* @param state - Router state with head data
|
|
537
|
-
* @param options - Streaming options
|
|
257
|
+
* Stream complete HTML document (head already closed).
|
|
258
|
+
* Used by both createHtmlStream and late phase of createEarlyHtmlStream.
|
|
538
259
|
*/
|
|
539
|
-
|
|
260
|
+
protected async streamBodyAndClose(
|
|
261
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
540
262
|
reactStream: ReadableStream<Uint8Array>,
|
|
541
263
|
state: ReactRouterState,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
} = {},
|
|
546
|
-
): ReadableStream<Uint8Array> {
|
|
547
|
-
const { hydration = true, onError } = options;
|
|
548
|
-
const slots = this.getSlots();
|
|
549
|
-
const head = state.head;
|
|
550
|
-
const encoder = this.encoder;
|
|
551
|
-
|
|
552
|
-
return new ReadableStream<Uint8Array>({
|
|
553
|
-
start: async (controller) => {
|
|
554
|
-
try {
|
|
555
|
-
// DOCTYPE
|
|
556
|
-
controller.enqueue(slots.doctype);
|
|
557
|
-
|
|
558
|
-
// <html ...>
|
|
559
|
-
controller.enqueue(slots.htmlOpen);
|
|
560
|
-
controller.enqueue(
|
|
561
|
-
encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)),
|
|
562
|
-
);
|
|
563
|
-
controller.enqueue(slots.htmlClose);
|
|
564
|
-
|
|
565
|
-
// <head>...</head>
|
|
566
|
-
controller.enqueue(slots.headOpen);
|
|
567
|
-
if (this.earlyHeadContent) {
|
|
568
|
-
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
569
|
-
}
|
|
570
|
-
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
571
|
-
controller.enqueue(slots.headClose);
|
|
572
|
-
|
|
573
|
-
// Body content (body, root, React, hydration, closing tags)
|
|
574
|
-
await this.streamBodyContent(
|
|
575
|
-
controller,
|
|
576
|
-
reactStream,
|
|
577
|
-
state,
|
|
578
|
-
hydration,
|
|
579
|
-
);
|
|
580
|
-
|
|
581
|
-
controller.close();
|
|
582
|
-
} catch (error) {
|
|
583
|
-
onError?.(error);
|
|
584
|
-
controller.error(error);
|
|
585
|
-
}
|
|
586
|
-
},
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Early head content for preloading.
|
|
592
|
-
*
|
|
593
|
-
* Contains entry assets (JS + CSS) that are always required and can be
|
|
594
|
-
* sent before page loaders run.
|
|
595
|
-
*/
|
|
596
|
-
protected earlyHeadContent: string = "";
|
|
597
|
-
|
|
598
|
-
/**
|
|
599
|
-
* Set the early head content (entry script + CSS).
|
|
600
|
-
*
|
|
601
|
-
* Also strips these assets from the original head content to avoid duplicates,
|
|
602
|
-
* since we're moving them to the early phase.
|
|
603
|
-
*
|
|
604
|
-
* Automatically prepends critical meta tags (charset, viewport) if not present
|
|
605
|
-
* in $head configuration, ensuring they're sent as early as possible.
|
|
606
|
-
*
|
|
607
|
-
* @param content - HTML string with entry assets
|
|
608
|
-
* @param globalHead - Global head configuration from $head primitives
|
|
609
|
-
* @param entryAssets - Entry asset paths to strip from original head
|
|
610
|
-
*/
|
|
611
|
-
public setEarlyHeadContent(
|
|
612
|
-
content: string,
|
|
613
|
-
globalHead?: SimpleHead,
|
|
614
|
-
entryAssets?: { js?: string; css: string[] },
|
|
615
|
-
): void {
|
|
616
|
-
// Build early content with critical meta tags first
|
|
617
|
-
const criticalMeta: string[] = [];
|
|
618
|
-
|
|
619
|
-
// Add charset - use custom value from $head or default to UTF-8
|
|
620
|
-
const charset = globalHead?.charset ?? "UTF-8";
|
|
621
|
-
criticalMeta.push(`<meta charset="${this.escapeHtml(charset)}">`);
|
|
264
|
+
hydration: boolean,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const { encoder, SLOTS: slots } = this;
|
|
622
267
|
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
`<meta name="viewport" content="${this.escapeHtml(viewport)}">`,
|
|
268
|
+
// Body open
|
|
269
|
+
controller.enqueue(slots.BODY_OPEN);
|
|
270
|
+
controller.enqueue(
|
|
271
|
+
encoder.encode(this.renderAttributes(state.head?.bodyAttributes)),
|
|
628
272
|
);
|
|
273
|
+
controller.enqueue(slots.BODY_CLOSE);
|
|
629
274
|
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
: content;
|
|
635
|
-
|
|
636
|
-
// Strip entry assets from original head content to avoid duplicates
|
|
637
|
-
if (entryAssets && this.slots) {
|
|
638
|
-
let headContent = this.slots.headOriginalContent;
|
|
639
|
-
|
|
640
|
-
// Remove entry script tag
|
|
641
|
-
if (entryAssets.js) {
|
|
642
|
-
// Match script tag with this src (handles various attribute orders)
|
|
643
|
-
const scriptPattern = new RegExp(
|
|
644
|
-
`<script[^>]*\\ssrc=["']${this.escapeRegExp(entryAssets.js)}["'][^>]*>\\s*</script>\\s*`,
|
|
645
|
-
"gi",
|
|
646
|
-
);
|
|
647
|
-
headContent = headContent.replace(scriptPattern, "");
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Remove entry CSS link tags
|
|
651
|
-
for (const css of entryAssets.css) {
|
|
652
|
-
const linkPattern = new RegExp(
|
|
653
|
-
`<link[^>]*\\shref=["']${this.escapeRegExp(css)}["'][^>]*>\\s*`,
|
|
654
|
-
"gi",
|
|
655
|
-
);
|
|
656
|
-
headContent = headContent.replace(linkPattern, "");
|
|
657
|
-
}
|
|
275
|
+
// Root + React content
|
|
276
|
+
controller.enqueue(slots.ROOT_OPEN);
|
|
277
|
+
await this.pipeReactStream(controller, reactStream, state);
|
|
278
|
+
controller.enqueue(slots.ROOT_CLOSE);
|
|
658
279
|
|
|
659
|
-
|
|
280
|
+
// Hydration
|
|
281
|
+
if (hydration) {
|
|
282
|
+
controller.enqueue(slots.HYDRATION_PREFIX);
|
|
283
|
+
controller.enqueue(
|
|
284
|
+
encoder.encode(this.safeJsonSerialize(this.buildHydrationData(state))),
|
|
285
|
+
);
|
|
286
|
+
controller.enqueue(slots.HYDRATION_SUFFIX);
|
|
660
287
|
}
|
|
661
|
-
}
|
|
662
288
|
|
|
663
|
-
|
|
664
|
-
* Escape special regex characters in a string.
|
|
665
|
-
*/
|
|
666
|
-
protected escapeRegExp(str: string): string {
|
|
667
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
289
|
+
controller.enqueue(slots.BODY_HTML_CLOSE);
|
|
668
290
|
}
|
|
669
291
|
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Public streaming APIs
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
670
296
|
/**
|
|
671
|
-
* Create
|
|
672
|
-
*
|
|
673
|
-
* This version sends critical assets (entry.js, CSS) BEFORE page loaders run,
|
|
674
|
-
* allowing the browser to start downloading them immediately.
|
|
297
|
+
* Create HTML stream with early head optimization.
|
|
675
298
|
*
|
|
676
299
|
* Flow:
|
|
677
300
|
* 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE)
|
|
678
|
-
* 2. Run async work (
|
|
301
|
+
* 2. Run async work (page loaders)
|
|
679
302
|
* 3. Send rest of head, body, React content, hydration
|
|
680
|
-
*
|
|
681
|
-
* @param globalHead - Global head with htmlAttributes (from $head primitives)
|
|
682
|
-
* @param asyncWork - Async function to run between early head and rest of stream
|
|
683
|
-
* @param options - Streaming options
|
|
684
303
|
*/
|
|
685
304
|
public createEarlyHtmlStream(
|
|
686
305
|
globalHead: SimpleHead,
|
|
687
306
|
asyncWork: () => Promise<
|
|
688
|
-
| {
|
|
689
|
-
state: ReactRouterState;
|
|
690
|
-
reactStream: ReadableStream<Uint8Array>;
|
|
691
|
-
}
|
|
307
|
+
| { state: ReactRouterState; reactStream: ReadableStream<Uint8Array> }
|
|
692
308
|
| { redirect: string }
|
|
693
309
|
| null
|
|
694
310
|
>,
|
|
@@ -698,10 +314,8 @@ export class ReactServerTemplateProvider {
|
|
|
698
314
|
} = {},
|
|
699
315
|
): ReadableStream<Uint8Array> {
|
|
700
316
|
const { hydration = true, onError } = options;
|
|
701
|
-
const slots = this
|
|
702
|
-
const encoder = this.encoder;
|
|
317
|
+
const { encoder, SLOTS: slots } = this;
|
|
703
318
|
|
|
704
|
-
// Track streaming state for error recovery
|
|
705
319
|
let headClosed = false;
|
|
706
320
|
let bodyStarted = false;
|
|
707
321
|
let routerState: ReactRouterState | undefined;
|
|
@@ -710,42 +324,33 @@ export class ReactServerTemplateProvider {
|
|
|
710
324
|
start: async (controller) => {
|
|
711
325
|
try {
|
|
712
326
|
// === EARLY PHASE (before async work) ===
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
controller.enqueue(slots.doctype);
|
|
716
|
-
|
|
717
|
-
// <html ...> with global htmlAttributes only
|
|
718
|
-
controller.enqueue(slots.htmlOpen);
|
|
327
|
+
controller.enqueue(slots.DOCTYPE);
|
|
328
|
+
controller.enqueue(slots.HTML_OPEN);
|
|
719
329
|
controller.enqueue(
|
|
720
|
-
encoder.encode(
|
|
721
|
-
this.renderMergedHtmlAttrs(globalHead?.htmlAttributes),
|
|
722
|
-
),
|
|
330
|
+
encoder.encode(this.renderAttributes(globalHead?.htmlAttributes)),
|
|
723
331
|
);
|
|
724
|
-
controller.enqueue(slots.
|
|
725
|
-
|
|
726
|
-
// <head> open + entry preloads
|
|
727
|
-
controller.enqueue(slots.headOpen);
|
|
332
|
+
controller.enqueue(slots.HTML_CLOSE);
|
|
333
|
+
controller.enqueue(slots.HEAD_OPEN);
|
|
728
334
|
if (this.earlyHeadContent) {
|
|
729
335
|
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
730
336
|
}
|
|
731
337
|
|
|
732
|
-
// === ASYNC WORK
|
|
338
|
+
// === ASYNC WORK ===
|
|
733
339
|
const result = await asyncWork();
|
|
734
340
|
|
|
735
|
-
// Handle redirect
|
|
341
|
+
// Handle redirect
|
|
736
342
|
if (!result || "redirect" in result) {
|
|
737
343
|
if (result && "redirect" in result) {
|
|
738
|
-
this.log.debug(
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
);
|
|
344
|
+
this.log.debug("Loader redirect, using meta refresh", {
|
|
345
|
+
redirect: result.redirect,
|
|
346
|
+
});
|
|
742
347
|
controller.enqueue(
|
|
743
348
|
encoder.encode(
|
|
744
349
|
`<meta http-equiv="refresh" content="0; url=${this.escapeHtml(result.redirect)}">\n`,
|
|
745
350
|
),
|
|
746
351
|
);
|
|
747
352
|
}
|
|
748
|
-
controller.enqueue(slots.
|
|
353
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
749
354
|
controller.enqueue(encoder.encode("<body></body></html>"));
|
|
750
355
|
controller.close();
|
|
751
356
|
return;
|
|
@@ -755,43 +360,29 @@ export class ReactServerTemplateProvider {
|
|
|
755
360
|
routerState = state;
|
|
756
361
|
|
|
757
362
|
// === LATE PHASE (after async work) ===
|
|
758
|
-
|
|
759
|
-
// Rest of head content (title, meta, links from loaders)
|
|
760
363
|
controller.enqueue(
|
|
761
364
|
encoder.encode(this.renderHeadContent(state.head)),
|
|
762
365
|
);
|
|
763
|
-
controller.enqueue(slots.
|
|
366
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
764
367
|
headClosed = true;
|
|
765
|
-
|
|
766
|
-
// Body content (body, root, React, hydration, closing tags)
|
|
767
368
|
bodyStarted = true;
|
|
768
|
-
|
|
369
|
+
|
|
370
|
+
await this.streamBodyAndClose(
|
|
769
371
|
controller,
|
|
770
372
|
reactStream,
|
|
771
373
|
state,
|
|
772
374
|
hydration,
|
|
773
375
|
);
|
|
774
|
-
|
|
775
376
|
controller.close();
|
|
776
377
|
} catch (error) {
|
|
777
378
|
onError?.(error);
|
|
778
|
-
|
|
779
|
-
// Instead of aborting the stream, inject error HTML so user sees
|
|
780
|
-
// an error message instead of white screen.
|
|
781
|
-
// React 19 streaming SSR doesn't reliably trigger ErrorBoundary,
|
|
782
|
-
// so we must handle it at the stream level.
|
|
783
379
|
try {
|
|
784
|
-
this.injectErrorHtml(
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
error,
|
|
789
|
-
routerState,
|
|
790
|
-
{ headClosed, bodyStarted },
|
|
791
|
-
);
|
|
380
|
+
this.injectErrorHtml(controller, error, routerState, {
|
|
381
|
+
headClosed,
|
|
382
|
+
bodyStarted,
|
|
383
|
+
});
|
|
792
384
|
controller.close();
|
|
793
385
|
} catch {
|
|
794
|
-
// If error injection fails, abort as last resort
|
|
795
386
|
controller.error(error);
|
|
796
387
|
}
|
|
797
388
|
}
|
|
@@ -800,116 +391,123 @@ export class ReactServerTemplateProvider {
|
|
|
800
391
|
}
|
|
801
392
|
|
|
802
393
|
/**
|
|
803
|
-
*
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
394
|
+
* Create HTML stream (non-early version, for testing/prerender).
|
|
395
|
+
*/
|
|
396
|
+
public createHtmlStream(
|
|
397
|
+
reactStream: ReadableStream<Uint8Array>,
|
|
398
|
+
state: ReactRouterState,
|
|
399
|
+
options: { hydration?: boolean; onError?: (error: unknown) => void } = {},
|
|
400
|
+
): ReadableStream<Uint8Array> {
|
|
401
|
+
const { hydration = true, onError } = options;
|
|
402
|
+
const { encoder, SLOTS: slots } = this;
|
|
403
|
+
|
|
404
|
+
return new ReadableStream<Uint8Array>({
|
|
405
|
+
start: async (controller) => {
|
|
406
|
+
try {
|
|
407
|
+
// Head
|
|
408
|
+
controller.enqueue(slots.DOCTYPE);
|
|
409
|
+
controller.enqueue(slots.HTML_OPEN);
|
|
410
|
+
controller.enqueue(
|
|
411
|
+
encoder.encode(this.renderAttributes(state.head?.htmlAttributes)),
|
|
412
|
+
);
|
|
413
|
+
controller.enqueue(slots.HTML_CLOSE);
|
|
414
|
+
controller.enqueue(slots.HEAD_OPEN);
|
|
415
|
+
if (this.earlyHeadContent) {
|
|
416
|
+
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
417
|
+
}
|
|
418
|
+
controller.enqueue(
|
|
419
|
+
encoder.encode(this.renderHeadContent(state.head)),
|
|
420
|
+
);
|
|
421
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
422
|
+
|
|
423
|
+
// Body (shared logic)
|
|
424
|
+
await this.streamBodyAndClose(
|
|
425
|
+
controller,
|
|
426
|
+
reactStream,
|
|
427
|
+
state,
|
|
428
|
+
hydration,
|
|
429
|
+
);
|
|
430
|
+
controller.close();
|
|
431
|
+
} catch (error) {
|
|
432
|
+
onError?.(error);
|
|
433
|
+
controller.error(error);
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Error handling
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Inject error HTML when streaming fails.
|
|
816
445
|
*/
|
|
817
446
|
protected injectErrorHtml(
|
|
818
447
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
819
|
-
encoder: TextEncoder,
|
|
820
|
-
slots: TemplateSlots,
|
|
821
448
|
error: unknown,
|
|
822
449
|
routerState: ReactRouterState | undefined,
|
|
823
450
|
streamState: { headClosed: boolean; bodyStarted: boolean },
|
|
824
451
|
): void {
|
|
825
|
-
|
|
452
|
+
const { encoder, SLOTS: slots } = this;
|
|
453
|
+
|
|
826
454
|
if (!streamState.headClosed) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
controller.enqueue(slots.headClose);
|
|
455
|
+
controller.enqueue(
|
|
456
|
+
encoder.encode(this.renderHeadContent(routerState?.head)),
|
|
457
|
+
);
|
|
458
|
+
controller.enqueue(slots.HEAD_CLOSE);
|
|
833
459
|
}
|
|
834
460
|
|
|
835
|
-
// If body hasn't started, we need to open body and root div
|
|
836
461
|
if (!streamState.bodyStarted) {
|
|
837
|
-
|
|
838
|
-
controller.enqueue(slots.bodyOpen);
|
|
462
|
+
controller.enqueue(slots.BODY_OPEN);
|
|
839
463
|
controller.enqueue(
|
|
840
464
|
encoder.encode(
|
|
841
|
-
this.
|
|
465
|
+
this.renderAttributes(routerState?.head?.bodyAttributes),
|
|
842
466
|
),
|
|
843
467
|
);
|
|
844
|
-
controller.enqueue(slots.
|
|
845
|
-
|
|
846
|
-
// Content before root (if any)
|
|
847
|
-
if (slots.beforeRoot) {
|
|
848
|
-
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
controller.enqueue(slots.rootOpen);
|
|
468
|
+
controller.enqueue(slots.BODY_CLOSE);
|
|
469
|
+
controller.enqueue(slots.ROOT_OPEN);
|
|
852
470
|
}
|
|
853
471
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
472
|
+
controller.enqueue(
|
|
473
|
+
encoder.encode(
|
|
474
|
+
this.renderErrorToString(
|
|
475
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
476
|
+
routerState,
|
|
477
|
+
),
|
|
478
|
+
),
|
|
858
479
|
);
|
|
859
480
|
|
|
860
|
-
controller.enqueue(
|
|
861
|
-
|
|
862
|
-
// Close root div
|
|
863
|
-
controller.enqueue(slots.rootClose);
|
|
864
|
-
|
|
865
|
-
// Content after root (if any)
|
|
866
|
-
if (!streamState.bodyStarted && slots.afterRoot) {
|
|
867
|
-
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// Close document
|
|
871
|
-
controller.enqueue(slots.scriptClose);
|
|
481
|
+
controller.enqueue(slots.ROOT_CLOSE);
|
|
482
|
+
controller.enqueue(slots.BODY_HTML_CLOSE);
|
|
872
483
|
}
|
|
873
484
|
|
|
874
485
|
/**
|
|
875
|
-
* Render
|
|
876
|
-
*
|
|
877
|
-
* Falls back to ErrorViewer if:
|
|
878
|
-
* - No router state is available
|
|
879
|
-
* - The error handler returns null/undefined
|
|
880
|
-
* - The error handler itself throws
|
|
486
|
+
* Render error to HTML string.
|
|
881
487
|
*/
|
|
882
488
|
protected renderErrorToString(
|
|
883
489
|
error: Error,
|
|
884
490
|
routerState: ReactRouterState | undefined,
|
|
885
491
|
): string {
|
|
886
|
-
// Log the error with stack trace for debugging
|
|
887
492
|
this.log.error("SSR rendering error", error);
|
|
888
493
|
|
|
889
494
|
let errorElement: ReactNode;
|
|
890
495
|
|
|
891
|
-
// Try to use the router state's error handler
|
|
892
496
|
if (routerState?.onError) {
|
|
893
497
|
try {
|
|
894
498
|
const result = routerState.onError(error, routerState);
|
|
895
|
-
|
|
896
|
-
// If handler returns a Redirection, we can't handle it (headers already sent)
|
|
897
|
-
// Log and fall through to default error viewer
|
|
898
499
|
if (result instanceof Redirection) {
|
|
899
|
-
this.log.warn(
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
} else if (result !== null && result !== undefined) {
|
|
500
|
+
this.log.warn("Error handler returned Redirection but headers sent", {
|
|
501
|
+
redirect: result.redirect,
|
|
502
|
+
});
|
|
503
|
+
} else if (result != null) {
|
|
904
504
|
errorElement = result;
|
|
905
505
|
}
|
|
906
506
|
} catch (handlerError) {
|
|
907
|
-
this.log.error("Error handler threw
|
|
908
|
-
// Fall through to default error viewer
|
|
507
|
+
this.log.error("Error handler threw", handlerError);
|
|
909
508
|
}
|
|
910
509
|
}
|
|
911
510
|
|
|
912
|
-
// Fall back to ErrorViewer if no element was produced
|
|
913
511
|
if (!errorElement) {
|
|
914
512
|
errorElement = createElement(ErrorViewer, {
|
|
915
513
|
error,
|
|
@@ -917,7 +515,6 @@ export class ReactServerTemplateProvider {
|
|
|
917
515
|
});
|
|
918
516
|
}
|
|
919
517
|
|
|
920
|
-
// Wrap in AlephaContext.Provider so any components that need it can access it
|
|
921
518
|
const wrappedElement = createElement(
|
|
922
519
|
AlephaContext.Provider,
|
|
923
520
|
{ value: this.alepha },
|
|
@@ -927,53 +524,19 @@ export class ReactServerTemplateProvider {
|
|
|
927
524
|
try {
|
|
928
525
|
return renderToString(wrappedElement);
|
|
929
526
|
} catch (renderError) {
|
|
930
|
-
// If renderToString fails, return minimal fallback HTML
|
|
931
527
|
this.log.error("Failed to render error component", renderError);
|
|
932
528
|
return error.message;
|
|
933
529
|
}
|
|
934
530
|
}
|
|
935
531
|
}
|
|
936
532
|
|
|
937
|
-
// ---------------------------------------------------------------------------------------------------------------------
|
|
938
|
-
|
|
939
|
-
/**
|
|
940
|
-
* Template slots - the template split into logical parts for efficient streaming.
|
|
941
|
-
*
|
|
942
|
-
* Static parts are pre-encoded as Uint8Array for zero-copy streaming.
|
|
943
|
-
* Dynamic parts (attributes, head content) are kept as strings/objects for merging.
|
|
944
|
-
*/
|
|
945
|
-
export interface TemplateSlots {
|
|
946
|
-
// Pre-encoded static parts
|
|
947
|
-
doctype: Uint8Array;
|
|
948
|
-
htmlOpen: Uint8Array; // "<html"
|
|
949
|
-
htmlClose: Uint8Array; // ">"
|
|
950
|
-
headOpen: Uint8Array; // "<head>"
|
|
951
|
-
headClose: Uint8Array; // "</head>"
|
|
952
|
-
bodyOpen: Uint8Array; // "<body"
|
|
953
|
-
bodyClose: Uint8Array; // ">"
|
|
954
|
-
rootOpen: Uint8Array; // '<div id="root">'
|
|
955
|
-
rootClose: Uint8Array; // "</div>"
|
|
956
|
-
scriptClose: Uint8Array; // "</body></html>"
|
|
957
|
-
|
|
958
|
-
// Original content (kept for merging)
|
|
959
|
-
htmlOriginalAttrs: Record<string, string>;
|
|
960
|
-
bodyOriginalAttrs: Record<string, string>;
|
|
961
|
-
headOriginalContent: string;
|
|
962
|
-
beforeRoot: string; // content between <body> and root div
|
|
963
|
-
afterRoot: string; // content between root div and </body>
|
|
964
|
-
}
|
|
965
|
-
|
|
966
533
|
/**
|
|
967
|
-
* Hydration state
|
|
534
|
+
* Hydration state serialized to window.__ssr
|
|
968
535
|
*/
|
|
969
536
|
export interface HydrationData {
|
|
970
537
|
layers: Array<{
|
|
971
538
|
data?: unknown;
|
|
972
|
-
error?: {
|
|
973
|
-
name: string;
|
|
974
|
-
message: string;
|
|
975
|
-
stack?: string;
|
|
976
|
-
};
|
|
539
|
+
error?: { name: string; message: string; stack?: string };
|
|
977
540
|
}>;
|
|
978
541
|
[key: string]: unknown;
|
|
979
542
|
}
|