@alepha/react 0.14.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/auth/index.browser.js +29 -14
  2. package/dist/auth/index.browser.js.map +1 -1
  3. package/dist/auth/index.js +960 -195
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/core/index.d.ts +4 -0
  6. package/dist/core/index.d.ts.map +1 -1
  7. package/dist/core/index.js +7 -4
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/head/index.browser.js +59 -19
  10. package/dist/head/index.browser.js.map +1 -1
  11. package/dist/head/index.d.ts +99 -560
  12. package/dist/head/index.d.ts.map +1 -1
  13. package/dist/head/index.js +92 -87
  14. package/dist/head/index.js.map +1 -1
  15. package/dist/router/index.browser.js +30 -15
  16. package/dist/router/index.browser.js.map +1 -1
  17. package/dist/router/index.d.ts +616 -192
  18. package/dist/router/index.d.ts.map +1 -1
  19. package/dist/router/index.js +961 -196
  20. package/dist/router/index.js.map +1 -1
  21. package/package.json +4 -4
  22. package/src/auth/__tests__/$auth.spec.ts +188 -0
  23. package/src/core/__tests__/Router.spec.tsx +169 -0
  24. package/src/core/hooks/useAction.browser.spec.tsx +569 -0
  25. package/src/core/hooks/useAction.ts +11 -0
  26. package/src/form/hooks/useForm.browser.spec.tsx +366 -0
  27. package/src/head/helpers/SeoExpander.spec.ts +203 -0
  28. package/src/head/hooks/useHead.spec.tsx +288 -0
  29. package/src/head/index.ts +11 -28
  30. package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
  31. package/src/head/providers/BrowserHeadProvider.ts +25 -19
  32. package/src/head/providers/HeadProvider.ts +76 -10
  33. package/src/head/providers/ServerHeadProvider.ts +22 -138
  34. package/src/i18n/__tests__/integration.spec.tsx +239 -0
  35. package/src/i18n/components/Localize.spec.tsx +357 -0
  36. package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
  37. package/src/i18n/providers/I18nProvider.spec.ts +389 -0
  38. package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
  39. package/src/router/__tests__/page-head.spec.ts +44 -0
  40. package/src/router/__tests__/seo-head.spec.ts +121 -0
  41. package/src/router/atoms/ssrManifestAtom.ts +60 -0
  42. package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
  43. package/src/router/errors/Redirection.ts +1 -1
  44. package/src/router/index.shared.ts +1 -0
  45. package/src/router/index.ts +16 -2
  46. package/src/router/primitives/$page.browser.spec.tsx +702 -0
  47. package/src/router/primitives/$page.spec.tsx +702 -0
  48. package/src/router/primitives/$page.ts +46 -10
  49. package/src/router/providers/ReactBrowserProvider.ts +14 -29
  50. package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
  51. package/src/router/providers/ReactPageProvider.ts +11 -4
  52. package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
  53. package/src/router/providers/ReactServerProvider.ts +331 -315
  54. package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
  55. package/src/router/providers/SSRManifestProvider.ts +365 -0
  56. package/src/router/services/ReactPageServerService.ts +5 -3
  57. package/src/router/services/ReactRouter.ts +3 -3
@@ -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, "&amp;")
368
+ .replace(/</g, "&lt;")
369
+ .replace(/>/g, "&gt;")
370
+ .replace(/"/g, "&quot;")
371
+ .replace(/'/g, "&#039;");
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
+ }