@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
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { $inject, Alepha, AlephaError } from "alepha";
|
|
2
|
+
import { $logger } from "alepha/logger";
|
|
3
|
+
import type { SimpleHead } from "@alepha/react/head";
|
|
4
|
+
import type { ReactRouterState } from "./ReactPageProvider.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handles HTML template parsing, preprocessing, and streaming for SSR.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Parse template once at startup into logical slots
|
|
11
|
+
* - Pre-encode static parts as Uint8Array for zero-copy streaming
|
|
12
|
+
* - Render dynamic parts (attributes, head content) efficiently
|
|
13
|
+
* - Build hydration data for client-side rehydration
|
|
14
|
+
*
|
|
15
|
+
* This provider is injected into ReactServerProvider to handle all
|
|
16
|
+
* template-related operations, keeping ReactServerProvider focused
|
|
17
|
+
* on request handling and React rendering coordination.
|
|
18
|
+
*/
|
|
19
|
+
export class ReactServerTemplateProvider {
|
|
20
|
+
protected readonly log = $logger();
|
|
21
|
+
protected readonly alepha = $inject(Alepha);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shared TextEncoder instance - reused across all requests.
|
|
25
|
+
*/
|
|
26
|
+
protected readonly encoder = new TextEncoder();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pre-encoded common strings for streaming.
|
|
30
|
+
*/
|
|
31
|
+
protected readonly ENCODED = {
|
|
32
|
+
HYDRATION_PREFIX: this.encoder.encode("<script>window.__ssr="),
|
|
33
|
+
HYDRATION_SUFFIX: this.encoder.encode("</script>"),
|
|
34
|
+
EMPTY: this.encoder.encode(""),
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cached template slots - parsed once, reused for all requests.
|
|
39
|
+
*/
|
|
40
|
+
protected slots: TemplateSlots | null = null;
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Root element ID for React mounting.
|
|
45
|
+
*/
|
|
46
|
+
public get rootId(): string {
|
|
47
|
+
return "root";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Regex pattern for matching the root div and extracting its content.
|
|
52
|
+
*/
|
|
53
|
+
public get rootDivRegex(): RegExp {
|
|
54
|
+
return new RegExp(
|
|
55
|
+
`<div([^>]*)\\s+id=["']${this.rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`,
|
|
56
|
+
"i",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract the content inside the root div from HTML.
|
|
62
|
+
*
|
|
63
|
+
* @param html - Full HTML string
|
|
64
|
+
* @returns The content inside the root div, or undefined if not found
|
|
65
|
+
*/
|
|
66
|
+
public extractRootContent(html: string): string | undefined {
|
|
67
|
+
const match = html.match(this.rootDivRegex);
|
|
68
|
+
return match?.[3];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if template has been parsed and slots are available.
|
|
73
|
+
*/
|
|
74
|
+
public isReady(): boolean {
|
|
75
|
+
return this.slots !== null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the parsed template slots.
|
|
80
|
+
* Throws if template hasn't been parsed yet.
|
|
81
|
+
*/
|
|
82
|
+
public getSlots(): TemplateSlots {
|
|
83
|
+
if (!this.slots) {
|
|
84
|
+
throw new AlephaError(
|
|
85
|
+
"Template not parsed. Call parseTemplate() during configuration.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return this.slots;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse an HTML template into logical slots for efficient streaming.
|
|
93
|
+
*
|
|
94
|
+
* This should be called once during server startup/configuration.
|
|
95
|
+
* The parsed slots are cached and reused for all requests.
|
|
96
|
+
*
|
|
97
|
+
* @param template - The HTML template string (typically index.html)
|
|
98
|
+
*/
|
|
99
|
+
public parseTemplate(template: string): TemplateSlots {
|
|
100
|
+
this.log.debug("Parsing template into slots");
|
|
101
|
+
|
|
102
|
+
const rootId = this.rootId;
|
|
103
|
+
|
|
104
|
+
// Extract doctype
|
|
105
|
+
const doctypeMatch = template.match(/<!DOCTYPE[^>]*>/i);
|
|
106
|
+
const doctype = doctypeMatch?.[0] ?? "<!DOCTYPE html>";
|
|
107
|
+
let remaining = doctypeMatch
|
|
108
|
+
? template.slice(doctypeMatch.index! + doctypeMatch[0].length)
|
|
109
|
+
: template;
|
|
110
|
+
|
|
111
|
+
// Extract <html> tag and attributes
|
|
112
|
+
const htmlMatch = remaining.match(/<html([^>]*)>/i);
|
|
113
|
+
const htmlAttrsStr = htmlMatch?.[1]?.trim() ?? "";
|
|
114
|
+
const htmlOriginalAttrs = this.parseAttributes(htmlAttrsStr);
|
|
115
|
+
remaining = htmlMatch
|
|
116
|
+
? remaining.slice(htmlMatch.index! + htmlMatch[0].length)
|
|
117
|
+
: remaining;
|
|
118
|
+
|
|
119
|
+
// Extract <head> content
|
|
120
|
+
const headMatch = remaining.match(/<head([^>]*)>([\s\S]*?)<\/head>/i);
|
|
121
|
+
const headOriginalContent = headMatch?.[2]?.trim() ?? "";
|
|
122
|
+
remaining = headMatch
|
|
123
|
+
? remaining.slice(headMatch.index! + headMatch[0].length)
|
|
124
|
+
: remaining;
|
|
125
|
+
|
|
126
|
+
// Extract <body> tag and attributes
|
|
127
|
+
const bodyMatch = remaining.match(/<body([^>]*)>/i);
|
|
128
|
+
const bodyAttrsStr = bodyMatch?.[1]?.trim() ?? "";
|
|
129
|
+
const bodyOriginalAttrs = this.parseAttributes(bodyAttrsStr);
|
|
130
|
+
const bodyStartIndex = bodyMatch
|
|
131
|
+
? bodyMatch.index! + bodyMatch[0].length
|
|
132
|
+
: 0;
|
|
133
|
+
remaining = remaining.slice(bodyStartIndex);
|
|
134
|
+
|
|
135
|
+
// Find root div
|
|
136
|
+
const rootDivRegex = new RegExp(
|
|
137
|
+
`<div([^>]*)\\s+id=["']${rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`,
|
|
138
|
+
"i",
|
|
139
|
+
);
|
|
140
|
+
const rootMatch = remaining.match(rootDivRegex);
|
|
141
|
+
|
|
142
|
+
let beforeRoot = "";
|
|
143
|
+
let afterRoot = "";
|
|
144
|
+
let rootAttrs = "";
|
|
145
|
+
|
|
146
|
+
if (rootMatch) {
|
|
147
|
+
beforeRoot = remaining.slice(0, rootMatch.index!).trim();
|
|
148
|
+
const rootEndIndex = rootMatch.index! + rootMatch[0].length;
|
|
149
|
+
// Find </body> for afterRoot
|
|
150
|
+
const bodyCloseIndex = remaining.indexOf("</body>");
|
|
151
|
+
afterRoot =
|
|
152
|
+
bodyCloseIndex > rootEndIndex
|
|
153
|
+
? remaining.slice(rootEndIndex, bodyCloseIndex).trim()
|
|
154
|
+
: "";
|
|
155
|
+
rootAttrs = `${rootMatch[1] ?? ""}${rootMatch[2] ?? ""}`.trim();
|
|
156
|
+
} else {
|
|
157
|
+
// No root div found - will inject one
|
|
158
|
+
const bodyCloseIndex = remaining.indexOf("</body>");
|
|
159
|
+
if (bodyCloseIndex > 0) {
|
|
160
|
+
beforeRoot = remaining.slice(0, bodyCloseIndex).trim();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Build the root div opening tag
|
|
165
|
+
const rootOpenTag = rootAttrs
|
|
166
|
+
? `<div ${rootAttrs} id="${rootId}">`
|
|
167
|
+
: `<div id="${rootId}">`;
|
|
168
|
+
|
|
169
|
+
this.slots = {
|
|
170
|
+
// Pre-encoded static parts
|
|
171
|
+
doctype: this.encoder.encode(doctype + "\n"),
|
|
172
|
+
htmlOpen: this.encoder.encode("<html"),
|
|
173
|
+
htmlClose: this.encoder.encode(">\n"),
|
|
174
|
+
headOpen: this.encoder.encode("<head>"),
|
|
175
|
+
headClose: this.encoder.encode("</head>\n"),
|
|
176
|
+
bodyOpen: this.encoder.encode("<body"),
|
|
177
|
+
bodyClose: this.encoder.encode(">\n"),
|
|
178
|
+
rootOpen: this.encoder.encode(rootOpenTag),
|
|
179
|
+
rootClose: this.encoder.encode("</div>\n"),
|
|
180
|
+
scriptClose: this.encoder.encode("</body>\n</html>"),
|
|
181
|
+
|
|
182
|
+
// Original content for merging
|
|
183
|
+
htmlOriginalAttrs,
|
|
184
|
+
bodyOriginalAttrs,
|
|
185
|
+
headOriginalContent,
|
|
186
|
+
beforeRoot,
|
|
187
|
+
afterRoot,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
this.log.debug("Template parsed successfully", {
|
|
191
|
+
hasHtmlAttrs: Object.keys(htmlOriginalAttrs).length > 0,
|
|
192
|
+
hasBodyAttrs: Object.keys(bodyOriginalAttrs).length > 0,
|
|
193
|
+
hasHeadContent: headOriginalContent.length > 0,
|
|
194
|
+
hasBeforeRoot: beforeRoot.length > 0,
|
|
195
|
+
hasAfterRoot: afterRoot.length > 0,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return this.slots;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse HTML attributes string into a record.
|
|
203
|
+
*
|
|
204
|
+
* Handles: key="value", key='value', key=value, and boolean key
|
|
205
|
+
*/
|
|
206
|
+
protected parseAttributes(attrStr: string): Record<string, string> {
|
|
207
|
+
const attrs: Record<string, string> = {};
|
|
208
|
+
if (!attrStr) return attrs;
|
|
209
|
+
|
|
210
|
+
// Match: key="value", key='value', key=value, or just key (boolean)
|
|
211
|
+
const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
212
|
+
let match: RegExpExecArray | null;
|
|
213
|
+
|
|
214
|
+
while ((match = attrRegex.exec(attrStr))) {
|
|
215
|
+
const key = match[1];
|
|
216
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
217
|
+
attrs[key] = value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return attrs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Render attributes record to HTML string.
|
|
225
|
+
*
|
|
226
|
+
* @param attrs - Attributes to render
|
|
227
|
+
* @returns HTML attribute string like ` lang="en" class="dark"`
|
|
228
|
+
*/
|
|
229
|
+
public renderAttributes(attrs: Record<string, string>): string {
|
|
230
|
+
const entries = Object.entries(attrs);
|
|
231
|
+
if (entries.length === 0) return "";
|
|
232
|
+
|
|
233
|
+
return entries
|
|
234
|
+
.map(([key, value]) => ` ${key}="${this.escapeHtml(value)}"`)
|
|
235
|
+
.join("");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Render merged HTML attributes (original + dynamic).
|
|
240
|
+
*/
|
|
241
|
+
public renderMergedHtmlAttrs(dynamicAttrs?: Record<string, string>): string {
|
|
242
|
+
const slots = this.getSlots();
|
|
243
|
+
const merged = { ...slots.htmlOriginalAttrs, ...dynamicAttrs };
|
|
244
|
+
return this.renderAttributes(merged);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Render merged body attributes (original + dynamic).
|
|
249
|
+
*/
|
|
250
|
+
public renderMergedBodyAttrs(dynamicAttrs?: Record<string, string>): string {
|
|
251
|
+
const slots = this.getSlots();
|
|
252
|
+
const merged = { ...slots.bodyOriginalAttrs, ...dynamicAttrs };
|
|
253
|
+
return this.renderAttributes(merged);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render head content (title, meta, link, script tags).
|
|
258
|
+
*
|
|
259
|
+
* @param head - Head data to render
|
|
260
|
+
* @param includeOriginal - Whether to include original head content
|
|
261
|
+
* @returns HTML string with head content
|
|
262
|
+
*/
|
|
263
|
+
public renderHeadContent(head?: SimpleHead, includeOriginal = true): string {
|
|
264
|
+
const slots = this.getSlots();
|
|
265
|
+
let content = "";
|
|
266
|
+
|
|
267
|
+
// Include original head content first
|
|
268
|
+
if (includeOriginal && slots.headOriginalContent) {
|
|
269
|
+
content += slots.headOriginalContent;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!head) return content;
|
|
273
|
+
|
|
274
|
+
// Title - check if already exists in original content
|
|
275
|
+
if (head.title) {
|
|
276
|
+
if (content.includes("<title>")) {
|
|
277
|
+
// Replace existing title
|
|
278
|
+
content = content.replace(
|
|
279
|
+
/<title>.*?<\/title>/i,
|
|
280
|
+
`<title>${this.escapeHtml(head.title)}</title>`,
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
content += `<title>${this.escapeHtml(head.title)}</title>\n`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Meta tags
|
|
288
|
+
if (head.meta) {
|
|
289
|
+
for (const meta of head.meta) {
|
|
290
|
+
content += this.renderMetaTag(meta);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Link tags
|
|
295
|
+
if (head.link) {
|
|
296
|
+
for (const link of head.link) {
|
|
297
|
+
content += this.renderLinkTag(link);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Script tags
|
|
302
|
+
if (head.script) {
|
|
303
|
+
for (const script of head.script) {
|
|
304
|
+
content += this.renderScriptTag(script);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return content;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Render a meta tag.
|
|
313
|
+
*/
|
|
314
|
+
protected renderMetaTag(meta: {
|
|
315
|
+
name?: string;
|
|
316
|
+
property?: string;
|
|
317
|
+
content: string;
|
|
318
|
+
}): string {
|
|
319
|
+
if (meta.property) {
|
|
320
|
+
return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
321
|
+
}
|
|
322
|
+
if (meta.name) {
|
|
323
|
+
return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
324
|
+
}
|
|
325
|
+
return "";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Render a link tag.
|
|
330
|
+
*/
|
|
331
|
+
protected renderLinkTag(link: {
|
|
332
|
+
rel: string;
|
|
333
|
+
href: string;
|
|
334
|
+
as?: string;
|
|
335
|
+
crossorigin?: string;
|
|
336
|
+
}): string {
|
|
337
|
+
let tag = `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
|
|
338
|
+
if (link.as) {
|
|
339
|
+
tag += ` as="${this.escapeHtml(link.as)}"`;
|
|
340
|
+
}
|
|
341
|
+
if (link.crossorigin != null) {
|
|
342
|
+
tag += ' crossorigin=""';
|
|
343
|
+
}
|
|
344
|
+
tag += ">\n";
|
|
345
|
+
return tag;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Render a script tag.
|
|
350
|
+
*/
|
|
351
|
+
protected renderScriptTag(script: Record<string, string | boolean>): string {
|
|
352
|
+
const attrs = Object.entries(script)
|
|
353
|
+
.filter(([, value]) => value !== false)
|
|
354
|
+
.map(([key, value]) => {
|
|
355
|
+
if (value === true) return key;
|
|
356
|
+
return `${key}="${this.escapeHtml(String(value))}"`;
|
|
357
|
+
})
|
|
358
|
+
.join(" ");
|
|
359
|
+
return `<script ${attrs}></script>\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Escape HTML special characters.
|
|
364
|
+
*/
|
|
365
|
+
public escapeHtml(str: string): string {
|
|
366
|
+
return str
|
|
367
|
+
.replace(/&/g, "&")
|
|
368
|
+
.replace(/</g, "<")
|
|
369
|
+
.replace(/>/g, ">")
|
|
370
|
+
.replace(/"/g, """)
|
|
371
|
+
.replace(/'/g, "'");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Safely serialize data to JSON for embedding in HTML.
|
|
376
|
+
* Escapes characters that could break out of script tags.
|
|
377
|
+
*/
|
|
378
|
+
public safeJsonSerialize(data: unknown): string {
|
|
379
|
+
return JSON.stringify(data)
|
|
380
|
+
.replace(/</g, "\\u003c")
|
|
381
|
+
.replace(/>/g, "\\u003e")
|
|
382
|
+
.replace(/&/g, "\\u0026");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Build hydration data from router state.
|
|
387
|
+
*
|
|
388
|
+
* This creates the data structure that will be serialized to window.__ssr
|
|
389
|
+
* for client-side rehydration.
|
|
390
|
+
*/
|
|
391
|
+
public buildHydrationData(state: ReactRouterState): HydrationData {
|
|
392
|
+
const { request, context, ...store } =
|
|
393
|
+
this.alepha.context.als?.getStore() ?? {};
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
...store,
|
|
397
|
+
"alepha.react.router.state": undefined,
|
|
398
|
+
layers: state.layers.map((layer) => ({
|
|
399
|
+
...layer,
|
|
400
|
+
error: layer.error
|
|
401
|
+
? {
|
|
402
|
+
...layer.error,
|
|
403
|
+
name: layer.error.name,
|
|
404
|
+
message: layer.error.message,
|
|
405
|
+
stack: !this.alepha.isProduction() ? layer.error.stack : undefined,
|
|
406
|
+
}
|
|
407
|
+
: undefined,
|
|
408
|
+
// Remove non-serializable properties
|
|
409
|
+
index: undefined,
|
|
410
|
+
path: undefined,
|
|
411
|
+
element: undefined,
|
|
412
|
+
route: undefined,
|
|
413
|
+
})),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Encode a string to Uint8Array using the shared encoder.
|
|
419
|
+
*/
|
|
420
|
+
public encode(str: string): Uint8Array {
|
|
421
|
+
return this.encoder.encode(str);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get the pre-encoded hydration script prefix.
|
|
426
|
+
*/
|
|
427
|
+
public get hydrationPrefix(): Uint8Array {
|
|
428
|
+
return this.ENCODED.HYDRATION_PREFIX;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get the pre-encoded hydration script suffix.
|
|
433
|
+
*/
|
|
434
|
+
public get hydrationSuffix(): Uint8Array {
|
|
435
|
+
return this.ENCODED.HYDRATION_SUFFIX;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Create a ReadableStream that streams the HTML template with React content.
|
|
440
|
+
*
|
|
441
|
+
* This is the main entry point for SSR streaming. It:
|
|
442
|
+
* 1. Sends <head> immediately (browser starts downloading assets)
|
|
443
|
+
* 2. Streams React content as it renders
|
|
444
|
+
* 3. Appends hydration script and closing tags
|
|
445
|
+
*
|
|
446
|
+
* @param reactStream - ReadableStream from renderToReadableStream
|
|
447
|
+
* @param state - Router state with head data
|
|
448
|
+
* @param options - Streaming options
|
|
449
|
+
*/
|
|
450
|
+
public createHtmlStream(
|
|
451
|
+
reactStream: ReadableStream<Uint8Array>,
|
|
452
|
+
state: ReactRouterState,
|
|
453
|
+
options: {
|
|
454
|
+
hydration?: boolean;
|
|
455
|
+
onError?: (error: unknown) => void;
|
|
456
|
+
} = {},
|
|
457
|
+
): ReadableStream<Uint8Array> {
|
|
458
|
+
const { hydration = true, onError } = options;
|
|
459
|
+
const slots = this.getSlots();
|
|
460
|
+
const head = state.head;
|
|
461
|
+
const encoder = this.encoder;
|
|
462
|
+
|
|
463
|
+
return new ReadableStream<Uint8Array>({
|
|
464
|
+
start: async (controller) => {
|
|
465
|
+
try {
|
|
466
|
+
// 1. DOCTYPE
|
|
467
|
+
controller.enqueue(slots.doctype);
|
|
468
|
+
|
|
469
|
+
// 2. <html ...>
|
|
470
|
+
controller.enqueue(slots.htmlOpen);
|
|
471
|
+
controller.enqueue(
|
|
472
|
+
encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)),
|
|
473
|
+
);
|
|
474
|
+
controller.enqueue(slots.htmlClose);
|
|
475
|
+
|
|
476
|
+
// 3. <head>...</head>
|
|
477
|
+
controller.enqueue(slots.headOpen);
|
|
478
|
+
// Include early head content (entry.js, CSS) if set
|
|
479
|
+
if (this.earlyHeadContent) {
|
|
480
|
+
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
481
|
+
}
|
|
482
|
+
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
483
|
+
controller.enqueue(slots.headClose);
|
|
484
|
+
|
|
485
|
+
// 4. <body ...>
|
|
486
|
+
controller.enqueue(slots.bodyOpen);
|
|
487
|
+
controller.enqueue(
|
|
488
|
+
encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
|
|
489
|
+
);
|
|
490
|
+
controller.enqueue(slots.bodyClose);
|
|
491
|
+
|
|
492
|
+
// 5. Content before root (if any)
|
|
493
|
+
if (slots.beforeRoot) {
|
|
494
|
+
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 6. <div id="root">
|
|
498
|
+
controller.enqueue(slots.rootOpen);
|
|
499
|
+
|
|
500
|
+
// 7. Stream React content
|
|
501
|
+
const reader = reactStream.getReader();
|
|
502
|
+
try {
|
|
503
|
+
while (true) {
|
|
504
|
+
const { done, value } = await reader.read();
|
|
505
|
+
if (done) break;
|
|
506
|
+
controller.enqueue(value);
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
reader.releaseLock();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 8. </div>
|
|
513
|
+
controller.enqueue(slots.rootClose);
|
|
514
|
+
|
|
515
|
+
// 9. Content after root (if any)
|
|
516
|
+
if (slots.afterRoot) {
|
|
517
|
+
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 10. Hydration script
|
|
521
|
+
if (hydration) {
|
|
522
|
+
const hydrationData = this.buildHydrationData(state);
|
|
523
|
+
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
524
|
+
controller.enqueue(
|
|
525
|
+
encoder.encode(this.safeJsonSerialize(hydrationData)),
|
|
526
|
+
);
|
|
527
|
+
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 11. </body></html>
|
|
531
|
+
controller.enqueue(slots.scriptClose);
|
|
532
|
+
|
|
533
|
+
controller.close();
|
|
534
|
+
} catch (error) {
|
|
535
|
+
onError?.(error);
|
|
536
|
+
controller.error(error);
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Early head content for preloading.
|
|
544
|
+
*
|
|
545
|
+
* Contains entry assets (JS + CSS) that are always required and can be
|
|
546
|
+
* sent before page loaders run.
|
|
547
|
+
*/
|
|
548
|
+
protected earlyHeadContent: string = "";
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Set the early head content (entry script + CSS).
|
|
552
|
+
*
|
|
553
|
+
* Also strips these assets from the original head content to avoid duplicates,
|
|
554
|
+
* since we're moving them to the early phase.
|
|
555
|
+
*
|
|
556
|
+
* @param content - HTML string with entry assets
|
|
557
|
+
* @param entryAssets - Entry asset paths to strip from original head
|
|
558
|
+
*/
|
|
559
|
+
public setEarlyHeadContent(
|
|
560
|
+
content: string,
|
|
561
|
+
entryAssets?: { js?: string; css: string[] },
|
|
562
|
+
): void {
|
|
563
|
+
this.earlyHeadContent = content;
|
|
564
|
+
|
|
565
|
+
// Strip entry assets from original head content to avoid duplicates
|
|
566
|
+
if (entryAssets && this.slots) {
|
|
567
|
+
let headContent = this.slots.headOriginalContent;
|
|
568
|
+
|
|
569
|
+
// Remove entry script tag
|
|
570
|
+
if (entryAssets.js) {
|
|
571
|
+
// Match script tag with this src (handles various attribute orders)
|
|
572
|
+
const scriptPattern = new RegExp(
|
|
573
|
+
`<script[^>]*\\ssrc=["']${this.escapeRegExp(entryAssets.js)}["'][^>]*>\\s*</script>\\s*`,
|
|
574
|
+
"gi",
|
|
575
|
+
);
|
|
576
|
+
headContent = headContent.replace(scriptPattern, "");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Remove entry CSS link tags
|
|
580
|
+
for (const css of entryAssets.css) {
|
|
581
|
+
const linkPattern = new RegExp(
|
|
582
|
+
`<link[^>]*\\shref=["']${this.escapeRegExp(css)}["'][^>]*>\\s*`,
|
|
583
|
+
"gi",
|
|
584
|
+
);
|
|
585
|
+
headContent = headContent.replace(linkPattern, "");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this.slots.headOriginalContent = headContent.trim();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Escape special regex characters in a string.
|
|
594
|
+
*/
|
|
595
|
+
protected escapeRegExp(str: string): string {
|
|
596
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Create an optimized HTML stream with early head streaming.
|
|
601
|
+
*
|
|
602
|
+
* This version sends critical assets (entry.js, CSS) BEFORE page loaders run,
|
|
603
|
+
* allowing the browser to start downloading them immediately.
|
|
604
|
+
*
|
|
605
|
+
* Flow:
|
|
606
|
+
* 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE)
|
|
607
|
+
* 2. Run async work (createLayers, etc.)
|
|
608
|
+
* 3. Send rest of head, body, React content, hydration
|
|
609
|
+
*
|
|
610
|
+
* @param globalHead - Global head with htmlAttributes (from $head primitives)
|
|
611
|
+
* @param asyncWork - Async function to run between early head and rest of stream
|
|
612
|
+
* @param options - Streaming options
|
|
613
|
+
*/
|
|
614
|
+
public createEarlyHtmlStream(
|
|
615
|
+
globalHead: SimpleHead,
|
|
616
|
+
asyncWork: () => Promise<{
|
|
617
|
+
state: ReactRouterState;
|
|
618
|
+
reactStream: ReadableStream<Uint8Array>;
|
|
619
|
+
} | null>,
|
|
620
|
+
options: {
|
|
621
|
+
hydration?: boolean;
|
|
622
|
+
onError?: (error: unknown) => void;
|
|
623
|
+
onRedirect?: (url: string) => void;
|
|
624
|
+
} = {},
|
|
625
|
+
): ReadableStream<Uint8Array> {
|
|
626
|
+
const { hydration = true, onError, onRedirect } = options;
|
|
627
|
+
const slots = this.getSlots();
|
|
628
|
+
const encoder = this.encoder;
|
|
629
|
+
|
|
630
|
+
return new ReadableStream<Uint8Array>({
|
|
631
|
+
start: async (controller) => {
|
|
632
|
+
try {
|
|
633
|
+
// === EARLY PHASE (before async work) ===
|
|
634
|
+
|
|
635
|
+
// 1. DOCTYPE
|
|
636
|
+
controller.enqueue(slots.doctype);
|
|
637
|
+
|
|
638
|
+
// 2. <html ...> with global htmlAttributes only
|
|
639
|
+
controller.enqueue(slots.htmlOpen);
|
|
640
|
+
controller.enqueue(
|
|
641
|
+
encoder.encode(
|
|
642
|
+
this.renderMergedHtmlAttrs(globalHead?.htmlAttributes),
|
|
643
|
+
),
|
|
644
|
+
);
|
|
645
|
+
controller.enqueue(slots.htmlClose);
|
|
646
|
+
|
|
647
|
+
// 3. <head> open + entry preloads
|
|
648
|
+
controller.enqueue(slots.headOpen);
|
|
649
|
+
if (this.earlyHeadContent) {
|
|
650
|
+
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// === ASYNC WORK (createLayers, etc.) ===
|
|
654
|
+
const result = await asyncWork();
|
|
655
|
+
|
|
656
|
+
// Handle redirect - can't undo what we've sent, but caller handles it
|
|
657
|
+
if (!result) {
|
|
658
|
+
// Redirect happened - close with minimal valid HTML
|
|
659
|
+
controller.enqueue(slots.headClose);
|
|
660
|
+
controller.enqueue(encoder.encode("<body></body></html>"));
|
|
661
|
+
controller.close();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const { state, reactStream } = result;
|
|
666
|
+
const head = state.head;
|
|
667
|
+
|
|
668
|
+
// === LATE PHASE (after async work) ===
|
|
669
|
+
|
|
670
|
+
// 4. Rest of head content (title, meta, links from loaders)
|
|
671
|
+
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
672
|
+
controller.enqueue(slots.headClose);
|
|
673
|
+
|
|
674
|
+
// 5. <body ...> with merged bodyAttributes
|
|
675
|
+
controller.enqueue(slots.bodyOpen);
|
|
676
|
+
controller.enqueue(
|
|
677
|
+
encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
|
|
678
|
+
);
|
|
679
|
+
controller.enqueue(slots.bodyClose);
|
|
680
|
+
|
|
681
|
+
// 6. Content before root (if any)
|
|
682
|
+
if (slots.beforeRoot) {
|
|
683
|
+
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// 7. <div id="root">
|
|
687
|
+
controller.enqueue(slots.rootOpen);
|
|
688
|
+
|
|
689
|
+
// 8. Stream React content
|
|
690
|
+
const reader = reactStream.getReader();
|
|
691
|
+
try {
|
|
692
|
+
while (true) {
|
|
693
|
+
const { done, value } = await reader.read();
|
|
694
|
+
if (done) break;
|
|
695
|
+
controller.enqueue(value);
|
|
696
|
+
}
|
|
697
|
+
} finally {
|
|
698
|
+
reader.releaseLock();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 9. </div>
|
|
702
|
+
controller.enqueue(slots.rootClose);
|
|
703
|
+
|
|
704
|
+
// 10. Content after root (if any)
|
|
705
|
+
if (slots.afterRoot) {
|
|
706
|
+
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 11. Hydration script
|
|
710
|
+
if (hydration) {
|
|
711
|
+
const hydrationData = this.buildHydrationData(state);
|
|
712
|
+
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
713
|
+
controller.enqueue(
|
|
714
|
+
encoder.encode(this.safeJsonSerialize(hydrationData)),
|
|
715
|
+
);
|
|
716
|
+
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// 12. </body></html>
|
|
720
|
+
controller.enqueue(slots.scriptClose);
|
|
721
|
+
|
|
722
|
+
controller.close();
|
|
723
|
+
} catch (error) {
|
|
724
|
+
onError?.(error);
|
|
725
|
+
controller.error(error);
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Template slots - the template split into logical parts for efficient streaming.
|
|
737
|
+
*
|
|
738
|
+
* Static parts are pre-encoded as Uint8Array for zero-copy streaming.
|
|
739
|
+
* Dynamic parts (attributes, head content) are kept as strings/objects for merging.
|
|
740
|
+
*/
|
|
741
|
+
export interface TemplateSlots {
|
|
742
|
+
// Pre-encoded static parts
|
|
743
|
+
doctype: Uint8Array;
|
|
744
|
+
htmlOpen: Uint8Array; // "<html"
|
|
745
|
+
htmlClose: Uint8Array; // ">"
|
|
746
|
+
headOpen: Uint8Array; // "<head>"
|
|
747
|
+
headClose: Uint8Array; // "</head>"
|
|
748
|
+
bodyOpen: Uint8Array; // "<body"
|
|
749
|
+
bodyClose: Uint8Array; // ">"
|
|
750
|
+
rootOpen: Uint8Array; // '<div id="root">'
|
|
751
|
+
rootClose: Uint8Array; // "</div>"
|
|
752
|
+
scriptClose: Uint8Array; // "</body></html>"
|
|
753
|
+
|
|
754
|
+
// Original content (kept for merging)
|
|
755
|
+
htmlOriginalAttrs: Record<string, string>;
|
|
756
|
+
bodyOriginalAttrs: Record<string, string>;
|
|
757
|
+
headOriginalContent: string;
|
|
758
|
+
beforeRoot: string; // content between <body> and root div
|
|
759
|
+
afterRoot: string; // content between root div and </body>
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Hydration state that gets serialized to window.__ssr
|
|
764
|
+
*/
|
|
765
|
+
export interface HydrationData {
|
|
766
|
+
layers: Array<{
|
|
767
|
+
data?: unknown;
|
|
768
|
+
error?: {
|
|
769
|
+
name: string;
|
|
770
|
+
message: string;
|
|
771
|
+
stack?: string;
|
|
772
|
+
};
|
|
773
|
+
}>;
|
|
774
|
+
[key: string]: unknown;
|
|
775
|
+
}
|