@alepha/react 0.15.0 → 0.15.1
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 +603 -242
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.d.ts +6 -6
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1296 -922
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +128 -128
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +20 -20
- package/dist/core/index.js.map +1 -1
- package/dist/form/index.d.ts +36 -36
- package/dist/form/index.d.ts.map +1 -1
- package/dist/form/index.js +15 -15
- package/dist/form/index.js.map +1 -1
- package/dist/head/index.browser.js +20 -0
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +73 -65
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +20 -0
- package/dist/head/index.js.map +1 -1
- package/dist/i18n/index.d.ts +37 -37
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js.map +1 -1
- package/dist/router/index.browser.js +605 -244
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +539 -550
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +1296 -922
- package/dist/router/index.js.map +1 -1
- package/dist/websocket/index.d.ts +38 -38
- package/dist/websocket/index.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/auth/__tests__/$auth.spec.ts +162 -147
- package/src/auth/index.ts +9 -3
- package/src/auth/services/ReactAuth.ts +15 -5
- package/src/core/hooks/useAction.ts +1 -2
- package/src/core/index.ts +4 -4
- package/src/form/errors/FormValidationError.ts +4 -6
- package/src/form/hooks/useFormState.ts +1 -1
- package/src/form/index.ts +1 -1
- package/src/form/services/FormModel.ts +31 -25
- package/src/head/helpers/SeoExpander.ts +2 -1
- package/src/head/hooks/useHead.spec.tsx +2 -2
- package/src/head/index.browser.ts +2 -2
- package/src/head/index.ts +4 -4
- package/src/head/interfaces/Head.ts +15 -3
- package/src/head/primitives/$head.ts +2 -5
- package/src/head/providers/BrowserHeadProvider.ts +55 -0
- package/src/head/providers/HeadProvider.ts +4 -1
- package/src/i18n/__tests__/integration.spec.tsx +1 -1
- package/src/i18n/components/Localize.spec.tsx +2 -2
- package/src/i18n/hooks/useI18n.browser.spec.tsx +2 -2
- package/src/i18n/index.ts +1 -1
- package/src/i18n/primitives/$dictionary.ts +1 -1
- package/src/i18n/providers/I18nProvider.spec.ts +1 -1
- package/src/i18n/providers/I18nProvider.ts +1 -1
- package/src/router/__tests__/page-head-browser.browser.spec.ts +5 -1
- package/src/router/__tests__/page-head.spec.ts +11 -7
- package/src/router/__tests__/seo-head.spec.ts +7 -3
- package/src/router/atoms/ssrManifestAtom.ts +2 -11
- package/src/router/components/ErrorViewer.tsx +626 -167
- package/src/router/components/Link.tsx +4 -2
- package/src/router/components/NestedView.tsx +7 -9
- package/src/router/components/NotFound.tsx +2 -2
- package/src/router/hooks/useQueryParams.ts +1 -1
- package/src/router/hooks/useRouter.ts +1 -1
- package/src/router/hooks/useRouterState.ts +1 -1
- package/src/router/index.browser.ts +10 -11
- package/src/router/index.shared.ts +7 -7
- package/src/router/index.ts +10 -7
- package/src/router/primitives/$page.browser.spec.tsx +6 -1
- package/src/router/primitives/$page.spec.tsx +7 -1
- package/src/router/primitives/$page.ts +5 -9
- package/src/router/providers/ReactBrowserProvider.ts +17 -6
- package/src/router/providers/ReactBrowserRouterProvider.ts +1 -1
- package/src/router/providers/ReactPageProvider.ts +4 -3
- package/src/router/providers/ReactServerProvider.ts +29 -37
- package/src/router/providers/ReactServerTemplateProvider.ts +300 -137
- package/src/router/providers/SSRManifestProvider.ts +17 -60
- package/src/router/services/ReactPageService.ts +4 -1
- package/src/router/services/ReactRouter.ts +6 -5
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { AlephaContext } from "@alepha/react";
|
|
2
|
+
import type { SimpleHead } from "@alepha/react/head";
|
|
1
3
|
import { $inject, Alepha, AlephaError } from "alepha";
|
|
2
4
|
import { $logger } from "alepha/logger";
|
|
3
|
-
import type
|
|
5
|
+
import { createElement, type ReactNode } from "react";
|
|
6
|
+
import { renderToString } from "react-dom/server";
|
|
7
|
+
import ErrorViewer from "../components/ErrorViewer.tsx";
|
|
8
|
+
import { Redirection } from "../errors/Redirection.ts";
|
|
4
9
|
import type { ReactRouterState } from "./ReactPageProvider.ts";
|
|
5
10
|
|
|
6
11
|
/**
|
|
@@ -39,7 +44,6 @@ export class ReactServerTemplateProvider {
|
|
|
39
44
|
*/
|
|
40
45
|
protected slots: TemplateSlots | null = null;
|
|
41
46
|
|
|
42
|
-
|
|
43
47
|
/**
|
|
44
48
|
* Root element ID for React mounting.
|
|
45
49
|
*/
|
|
@@ -93,8 +97,6 @@ export class ReactServerTemplateProvider {
|
|
|
93
97
|
*
|
|
94
98
|
* This should be called once during server startup/configuration.
|
|
95
99
|
* The parsed slots are cached and reused for all requests.
|
|
96
|
-
*
|
|
97
|
-
* @param template - The HTML template string (typically index.html)
|
|
98
100
|
*/
|
|
99
101
|
public parseTemplate(template: string): TemplateSlots {
|
|
100
102
|
this.log.debug("Parsing template into slots");
|
|
@@ -168,7 +170,7 @@ export class ReactServerTemplateProvider {
|
|
|
168
170
|
|
|
169
171
|
this.slots = {
|
|
170
172
|
// Pre-encoded static parts
|
|
171
|
-
doctype: this.encoder.encode(doctype
|
|
173
|
+
doctype: this.encoder.encode(`${doctype}\n`),
|
|
172
174
|
htmlOpen: this.encoder.encode("<html"),
|
|
173
175
|
htmlClose: this.encoder.encode(">\n"),
|
|
174
176
|
headOpen: this.encoder.encode("<head>"),
|
|
@@ -209,9 +211,8 @@ export class ReactServerTemplateProvider {
|
|
|
209
211
|
|
|
210
212
|
// Match: key="value", key='value', key=value, or just key (boolean)
|
|
211
213
|
const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
212
|
-
let match: RegExpExecArray | null;
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
for (const match of attrStr.matchAll(attrRegex)) {
|
|
215
216
|
const key = match[1];
|
|
216
217
|
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
217
218
|
attrs[key] = value;
|
|
@@ -331,10 +332,14 @@ export class ReactServerTemplateProvider {
|
|
|
331
332
|
protected renderLinkTag(link: {
|
|
332
333
|
rel: string;
|
|
333
334
|
href: string;
|
|
335
|
+
type?: string;
|
|
334
336
|
as?: string;
|
|
335
337
|
crossorigin?: string;
|
|
336
338
|
}): string {
|
|
337
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
|
+
}
|
|
338
343
|
if (link.as) {
|
|
339
344
|
tag += ` as="${this.escapeHtml(link.as)}"`;
|
|
340
345
|
}
|
|
@@ -348,14 +353,30 @@ export class ReactServerTemplateProvider {
|
|
|
348
353
|
/**
|
|
349
354
|
* Render a script tag.
|
|
350
355
|
*/
|
|
351
|
-
protected renderScriptTag(
|
|
352
|
-
|
|
353
|
-
|
|
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)
|
|
354
369
|
.map(([key, value]) => {
|
|
355
370
|
if (value === true) return key;
|
|
356
371
|
return `${key}="${this.escapeHtml(String(value))}"`;
|
|
357
372
|
})
|
|
358
373
|
.join(" ");
|
|
374
|
+
|
|
375
|
+
if (content) {
|
|
376
|
+
return attrs
|
|
377
|
+
? `<script ${attrs}>${content}</script>\n`
|
|
378
|
+
: `<script>${content}</script>\n`;
|
|
379
|
+
}
|
|
359
380
|
return `<script ${attrs}></script>\n`;
|
|
360
381
|
}
|
|
361
382
|
|
|
@@ -393,25 +414,30 @@ export class ReactServerTemplateProvider {
|
|
|
393
414
|
this.alepha.context.als?.getStore() ?? {};
|
|
394
415
|
|
|
395
416
|
const layers = state.layers.map((layer) => ({
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
config: layer.config,
|
|
417
|
+
part: layer.part, // mandatory for previous-checking
|
|
418
|
+
name: layer.name, // mandatory for previous-checking
|
|
419
|
+
config: layer.config, // mandatory for previous-checking (contains 'query' & 'params')
|
|
420
|
+
props: layer.props, // our not-so-secret data cache
|
|
399
421
|
error: layer.error
|
|
400
422
|
? {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
423
|
+
...layer.error,
|
|
424
|
+
name: layer.error.name,
|
|
425
|
+
message: layer.error.message,
|
|
426
|
+
stack: !this.alepha.isProduction() ? layer.error.stack : undefined,
|
|
427
|
+
}
|
|
406
428
|
: undefined,
|
|
407
429
|
}));
|
|
408
430
|
|
|
409
431
|
const hydrationData: HydrationData = {
|
|
410
432
|
layers,
|
|
411
|
-
}
|
|
433
|
+
};
|
|
412
434
|
|
|
413
435
|
for (const [key, value] of Object.entries(store)) {
|
|
414
|
-
if (
|
|
436
|
+
if (
|
|
437
|
+
key.charAt(0) !== "_" &&
|
|
438
|
+
key !== "alepha.react.router.state" &&
|
|
439
|
+
key !== "registry"
|
|
440
|
+
) {
|
|
415
441
|
hydrationData[key] = value;
|
|
416
442
|
}
|
|
417
443
|
}
|
|
@@ -420,24 +446,82 @@ export class ReactServerTemplateProvider {
|
|
|
420
446
|
}
|
|
421
447
|
|
|
422
448
|
/**
|
|
423
|
-
*
|
|
449
|
+
* Stream the body content: body tag, root div, React content, hydration, and closing tags.
|
|
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.
|
|
424
453
|
*/
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
454
|
+
protected async streamBodyContent(
|
|
455
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
456
|
+
reactStream: ReadableStream<Uint8Array>,
|
|
457
|
+
state: ReactRouterState,
|
|
458
|
+
hydration: boolean,
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
const slots = this.getSlots();
|
|
461
|
+
const encoder = this.encoder;
|
|
462
|
+
const head = state.head;
|
|
428
463
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
464
|
+
// <body ...>
|
|
465
|
+
controller.enqueue(slots.bodyOpen);
|
|
466
|
+
controller.enqueue(
|
|
467
|
+
encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
|
|
468
|
+
);
|
|
469
|
+
controller.enqueue(slots.bodyClose);
|
|
435
470
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
480
|
+
const reader = reactStream.getReader();
|
|
481
|
+
let streamError: unknown = null;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
while (true) {
|
|
485
|
+
const { done, value } = await reader.read();
|
|
486
|
+
if (done) break;
|
|
487
|
+
controller.enqueue(value);
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
// React stream errored - save for error HTML injection
|
|
491
|
+
streamError = error;
|
|
492
|
+
this.log.error("Error during React stream reading", error);
|
|
493
|
+
} finally {
|
|
494
|
+
reader.releaseLock();
|
|
495
|
+
}
|
|
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);
|
|
441
525
|
}
|
|
442
526
|
|
|
443
527
|
/**
|
|
@@ -468,72 +552,31 @@ export class ReactServerTemplateProvider {
|
|
|
468
552
|
return new ReadableStream<Uint8Array>({
|
|
469
553
|
start: async (controller) => {
|
|
470
554
|
try {
|
|
471
|
-
//
|
|
555
|
+
// DOCTYPE
|
|
472
556
|
controller.enqueue(slots.doctype);
|
|
473
557
|
|
|
474
|
-
//
|
|
558
|
+
// <html ...>
|
|
475
559
|
controller.enqueue(slots.htmlOpen);
|
|
476
560
|
controller.enqueue(
|
|
477
561
|
encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)),
|
|
478
562
|
);
|
|
479
563
|
controller.enqueue(slots.htmlClose);
|
|
480
564
|
|
|
481
|
-
//
|
|
565
|
+
// <head>...</head>
|
|
482
566
|
controller.enqueue(slots.headOpen);
|
|
483
|
-
// Include early head content (entry.js, CSS) if set
|
|
484
567
|
if (this.earlyHeadContent) {
|
|
485
568
|
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
486
569
|
}
|
|
487
570
|
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
488
571
|
controller.enqueue(slots.headClose);
|
|
489
572
|
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
573
|
+
// Body content (body, root, React, hydration, closing tags)
|
|
574
|
+
await this.streamBodyContent(
|
|
575
|
+
controller,
|
|
576
|
+
reactStream,
|
|
577
|
+
state,
|
|
578
|
+
hydration,
|
|
494
579
|
);
|
|
495
|
-
controller.enqueue(slots.bodyClose);
|
|
496
|
-
|
|
497
|
-
// 5. Content before root (if any)
|
|
498
|
-
if (slots.beforeRoot) {
|
|
499
|
-
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// 6. <div id="root">
|
|
503
|
-
controller.enqueue(slots.rootOpen);
|
|
504
|
-
|
|
505
|
-
// 7. Stream React content
|
|
506
|
-
const reader = reactStream.getReader();
|
|
507
|
-
try {
|
|
508
|
-
while (true) {
|
|
509
|
-
const { done, value } = await reader.read();
|
|
510
|
-
if (done) break;
|
|
511
|
-
controller.enqueue(value);
|
|
512
|
-
}
|
|
513
|
-
} finally {
|
|
514
|
-
reader.releaseLock();
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// 8. </div>
|
|
518
|
-
controller.enqueue(slots.rootClose);
|
|
519
|
-
|
|
520
|
-
// 9. Content after root (if any)
|
|
521
|
-
if (slots.afterRoot) {
|
|
522
|
-
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// 10. Hydration script
|
|
526
|
-
if (hydration) {
|
|
527
|
-
const hydrationData = this.buildHydrationData(state);
|
|
528
|
-
controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
|
|
529
|
-
controller.enqueue(
|
|
530
|
-
encoder.encode(this.safeJsonSerialize(hydrationData)),
|
|
531
|
-
);
|
|
532
|
-
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// 11. </body></html>
|
|
536
|
-
controller.enqueue(slots.scriptClose);
|
|
537
580
|
|
|
538
581
|
controller.close();
|
|
539
582
|
} catch (error) {
|
|
@@ -635,15 +678,20 @@ export class ReactServerTemplateProvider {
|
|
|
635
678
|
const slots = this.getSlots();
|
|
636
679
|
const encoder = this.encoder;
|
|
637
680
|
|
|
681
|
+
// Track streaming state for error recovery
|
|
682
|
+
let headClosed = false;
|
|
683
|
+
let bodyStarted = false;
|
|
684
|
+
let routerState: ReactRouterState | undefined;
|
|
685
|
+
|
|
638
686
|
return new ReadableStream<Uint8Array>({
|
|
639
687
|
start: async (controller) => {
|
|
640
688
|
try {
|
|
641
689
|
// === EARLY PHASE (before async work) ===
|
|
642
690
|
|
|
643
|
-
//
|
|
691
|
+
// DOCTYPE
|
|
644
692
|
controller.enqueue(slots.doctype);
|
|
645
693
|
|
|
646
|
-
//
|
|
694
|
+
// <html ...> with global htmlAttributes only
|
|
647
695
|
controller.enqueue(slots.htmlOpen);
|
|
648
696
|
controller.enqueue(
|
|
649
697
|
encoder.encode(
|
|
@@ -652,7 +700,7 @@ export class ReactServerTemplateProvider {
|
|
|
652
700
|
);
|
|
653
701
|
controller.enqueue(slots.htmlClose);
|
|
654
702
|
|
|
655
|
-
//
|
|
703
|
+
// <head> open + entry preloads
|
|
656
704
|
controller.enqueue(slots.headOpen);
|
|
657
705
|
if (this.earlyHeadContent) {
|
|
658
706
|
controller.enqueue(encoder.encode(this.earlyHeadContent));
|
|
@@ -681,73 +729,188 @@ export class ReactServerTemplateProvider {
|
|
|
681
729
|
}
|
|
682
730
|
|
|
683
731
|
const { state, reactStream } = result;
|
|
684
|
-
|
|
732
|
+
routerState = state;
|
|
685
733
|
|
|
686
734
|
// === LATE PHASE (after async work) ===
|
|
687
735
|
|
|
688
|
-
//
|
|
689
|
-
controller.enqueue(encoder.encode(this.renderHeadContent(head)));
|
|
690
|
-
controller.enqueue(slots.headClose);
|
|
691
|
-
|
|
692
|
-
// 5. <body ...> with merged bodyAttributes
|
|
693
|
-
controller.enqueue(slots.bodyOpen);
|
|
736
|
+
// Rest of head content (title, meta, links from loaders)
|
|
694
737
|
controller.enqueue(
|
|
695
|
-
encoder.encode(this.
|
|
738
|
+
encoder.encode(this.renderHeadContent(state.head)),
|
|
739
|
+
);
|
|
740
|
+
controller.enqueue(slots.headClose);
|
|
741
|
+
headClosed = true;
|
|
742
|
+
|
|
743
|
+
// Body content (body, root, React, hydration, closing tags)
|
|
744
|
+
bodyStarted = true;
|
|
745
|
+
await this.streamBodyContent(
|
|
746
|
+
controller,
|
|
747
|
+
reactStream,
|
|
748
|
+
state,
|
|
749
|
+
hydration,
|
|
696
750
|
);
|
|
697
|
-
controller.enqueue(slots.bodyClose);
|
|
698
|
-
|
|
699
|
-
// 6. Content before root (if any)
|
|
700
|
-
if (slots.beforeRoot) {
|
|
701
|
-
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
702
|
-
}
|
|
703
751
|
|
|
704
|
-
|
|
705
|
-
|
|
752
|
+
controller.close();
|
|
753
|
+
} catch (error) {
|
|
754
|
+
onError?.(error);
|
|
706
755
|
|
|
707
|
-
//
|
|
708
|
-
|
|
756
|
+
// Instead of aborting the stream, inject error HTML so user sees
|
|
757
|
+
// an error message instead of white screen.
|
|
758
|
+
// React 19 streaming SSR doesn't reliably trigger ErrorBoundary,
|
|
759
|
+
// so we must handle it at the stream level.
|
|
709
760
|
try {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
761
|
+
this.injectErrorHtml(
|
|
762
|
+
controller,
|
|
763
|
+
encoder,
|
|
764
|
+
slots,
|
|
765
|
+
error,
|
|
766
|
+
routerState,
|
|
767
|
+
{ headClosed, bodyStarted },
|
|
768
|
+
);
|
|
769
|
+
controller.close();
|
|
770
|
+
} catch {
|
|
771
|
+
// If error injection fails, abort as last resort
|
|
772
|
+
controller.error(error);
|
|
717
773
|
}
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}
|
|
718
778
|
|
|
719
|
-
|
|
720
|
-
|
|
779
|
+
/**
|
|
780
|
+
* Inject error HTML into the stream when an error occurs during streaming.
|
|
781
|
+
*
|
|
782
|
+
* Uses the router state's onError handler to render the error component,
|
|
783
|
+
* falling back to ErrorViewer if no custom handler is defined.
|
|
784
|
+
* Renders using renderToString to produce static HTML.
|
|
785
|
+
*
|
|
786
|
+
* Since we may have already sent partial HTML (DOCTYPE, <html>, <head>),
|
|
787
|
+
* we need to complete the document with an error message instead of aborting.
|
|
788
|
+
*
|
|
789
|
+
* Handles different states:
|
|
790
|
+
* - headClosed=false, bodyStarted=false: Need to add head content, close head, open body, add error, close all
|
|
791
|
+
* - headClosed=true, bodyStarted=false: Need to open body, add error, close all
|
|
792
|
+
* - headClosed=true, bodyStarted=true: Already inside root div, add error, close all
|
|
793
|
+
*/
|
|
794
|
+
protected injectErrorHtml(
|
|
795
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
796
|
+
encoder: TextEncoder,
|
|
797
|
+
slots: TemplateSlots,
|
|
798
|
+
error: unknown,
|
|
799
|
+
routerState: ReactRouterState | undefined,
|
|
800
|
+
streamState: { headClosed: boolean; bodyStarted: boolean },
|
|
801
|
+
): void {
|
|
802
|
+
// If head not closed, add remaining head content first
|
|
803
|
+
if (!streamState.headClosed) {
|
|
804
|
+
// Include original head content (CSS, scripts) and any head from router state
|
|
805
|
+
const headContent = this.renderHeadContent(routerState?.head);
|
|
806
|
+
if (headContent) {
|
|
807
|
+
controller.enqueue(encoder.encode(headContent));
|
|
808
|
+
}
|
|
809
|
+
controller.enqueue(slots.headClose);
|
|
810
|
+
}
|
|
721
811
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
812
|
+
// If body hasn't started, we need to open body and root div
|
|
813
|
+
if (!streamState.bodyStarted) {
|
|
814
|
+
// Open body with any body attributes from state
|
|
815
|
+
controller.enqueue(slots.bodyOpen);
|
|
816
|
+
controller.enqueue(
|
|
817
|
+
encoder.encode(
|
|
818
|
+
this.renderMergedBodyAttrs(routerState?.head?.bodyAttributes),
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
controller.enqueue(slots.bodyClose);
|
|
726
822
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
controller.enqueue(
|
|
732
|
-
encoder.encode(this.safeJsonSerialize(hydrationData)),
|
|
733
|
-
);
|
|
734
|
-
controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
|
|
735
|
-
}
|
|
823
|
+
// Content before root (if any)
|
|
824
|
+
if (slots.beforeRoot) {
|
|
825
|
+
controller.enqueue(encoder.encode(slots.beforeRoot));
|
|
826
|
+
}
|
|
736
827
|
|
|
737
|
-
|
|
738
|
-
|
|
828
|
+
controller.enqueue(slots.rootOpen);
|
|
829
|
+
}
|
|
739
830
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
831
|
+
// Try to render error using router state's error handler
|
|
832
|
+
const errorHtml = this.renderErrorToString(
|
|
833
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
834
|
+
routerState,
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
controller.enqueue(encoder.encode(errorHtml));
|
|
838
|
+
|
|
839
|
+
// Close root div
|
|
840
|
+
controller.enqueue(slots.rootClose);
|
|
841
|
+
|
|
842
|
+
// Content after root (if any)
|
|
843
|
+
if (!streamState.bodyStarted && slots.afterRoot) {
|
|
844
|
+
controller.enqueue(encoder.encode(slots.afterRoot));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Close document
|
|
848
|
+
controller.enqueue(slots.scriptClose);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Render an error to HTML string using the router's error handler.
|
|
853
|
+
*
|
|
854
|
+
* Falls back to ErrorViewer if:
|
|
855
|
+
* - No router state is available
|
|
856
|
+
* - The error handler returns null/undefined
|
|
857
|
+
* - The error handler itself throws
|
|
858
|
+
*/
|
|
859
|
+
protected renderErrorToString(
|
|
860
|
+
error: Error,
|
|
861
|
+
routerState: ReactRouterState | undefined,
|
|
862
|
+
): string {
|
|
863
|
+
// Log the error with stack trace for debugging
|
|
864
|
+
this.log.error("SSR rendering error", error);
|
|
865
|
+
|
|
866
|
+
let errorElement: ReactNode;
|
|
867
|
+
|
|
868
|
+
// Try to use the router state's error handler
|
|
869
|
+
if (routerState?.onError) {
|
|
870
|
+
try {
|
|
871
|
+
const result = routerState.onError(error, routerState);
|
|
872
|
+
|
|
873
|
+
// If handler returns a Redirection, we can't handle it (headers already sent)
|
|
874
|
+
// Log and fall through to default error viewer
|
|
875
|
+
if (result instanceof Redirection) {
|
|
876
|
+
this.log.warn(
|
|
877
|
+
"Error handler returned Redirection but headers already sent",
|
|
878
|
+
{ redirect: result.redirect },
|
|
879
|
+
);
|
|
880
|
+
} else if (result !== null && result !== undefined) {
|
|
881
|
+
errorElement = result;
|
|
744
882
|
}
|
|
745
|
-
}
|
|
746
|
-
|
|
883
|
+
} catch (handlerError) {
|
|
884
|
+
this.log.error("Error handler threw an exception", handlerError);
|
|
885
|
+
// Fall through to default error viewer
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Fall back to ErrorViewer if no element was produced
|
|
890
|
+
if (!errorElement) {
|
|
891
|
+
errorElement = createElement(ErrorViewer, {
|
|
892
|
+
error,
|
|
893
|
+
alepha: this.alepha,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Wrap in AlephaContext.Provider so any components that need it can access it
|
|
898
|
+
const wrappedElement = createElement(
|
|
899
|
+
AlephaContext.Provider,
|
|
900
|
+
{ value: this.alepha },
|
|
901
|
+
errorElement,
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
try {
|
|
905
|
+
return renderToString(wrappedElement);
|
|
906
|
+
} catch (renderError) {
|
|
907
|
+
// If renderToString fails, return minimal fallback HTML
|
|
908
|
+
this.log.error("Failed to render error component", renderError);
|
|
909
|
+
return error.message;
|
|
910
|
+
}
|
|
747
911
|
}
|
|
748
912
|
}
|
|
749
913
|
|
|
750
|
-
|
|
751
914
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
752
915
|
|
|
753
916
|
/**
|