@mandujs/core 0.9.18 → 0.9.19
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/package.json +1 -1
- package/src/runtime/streaming-ssr.ts +116 -90
package/package.json
CHANGED
|
@@ -504,16 +504,17 @@ function generateHMRScript(port: number): string {
|
|
|
504
504
|
* React 컴포넌트를 ReadableStream으로 렌더링
|
|
505
505
|
* Bun/Web Streams API 기반
|
|
506
506
|
*
|
|
507
|
-
*
|
|
508
|
-
* - Shell
|
|
509
|
-
* -
|
|
507
|
+
* 핵심 원칙:
|
|
508
|
+
* - Shell은 즉시 전송 (TTFB 최소화)
|
|
509
|
+
* - allReady는 메트릭용으로만 사용 (대기 안 함)
|
|
510
|
+
* - Shell 전 에러는 throw → Response 레이어에서 500 처리
|
|
511
|
+
* - Shell 후 에러는 에러 스크립트 삽입
|
|
510
512
|
*/
|
|
511
513
|
export async function renderToStream(
|
|
512
514
|
element: ReactElement,
|
|
513
515
|
options: StreamingSSROptions = {}
|
|
514
516
|
): Promise<ReadableStream<Uint8Array>> {
|
|
515
517
|
const {
|
|
516
|
-
streamTimeout = 10000,
|
|
517
518
|
onShellReady,
|
|
518
519
|
onAllReady,
|
|
519
520
|
onShellError,
|
|
@@ -534,7 +535,7 @@ export async function renderToStream(
|
|
|
534
535
|
startTime: Date.now(),
|
|
535
536
|
};
|
|
536
537
|
|
|
537
|
-
// criticalData 직렬화 검증
|
|
538
|
+
// criticalData 직렬화 검증 (dev에서는 throw)
|
|
538
539
|
validateCriticalData(criticalData, isDev);
|
|
539
540
|
|
|
540
541
|
// 스트리밍 주의사항 경고 (첫 요청 시 1회만)
|
|
@@ -548,65 +549,42 @@ export async function renderToStream(
|
|
|
548
549
|
const htmlTail = generateHTMLTail(options);
|
|
549
550
|
|
|
550
551
|
let shellSent = false;
|
|
551
|
-
let hasShellError = false;
|
|
552
552
|
|
|
553
553
|
// React renderToReadableStream 호출
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
console.error("[Mandu Streaming] React render error:", error);
|
|
567
|
-
|
|
568
|
-
if (!shellSent) {
|
|
569
|
-
hasShellError = true;
|
|
570
|
-
onShellError?.(streamingError);
|
|
571
|
-
} else {
|
|
572
|
-
onStreamError?.(streamingError);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// deprecated 콜백 호환
|
|
576
|
-
onError?.(error);
|
|
577
|
-
},
|
|
578
|
-
});
|
|
579
|
-
} catch (error) {
|
|
580
|
-
// renderToReadableStream 자체 실패 (Shell 전 에러)
|
|
581
|
-
metrics.hasError = true;
|
|
582
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
583
|
-
const streamingError: StreamingError = {
|
|
584
|
-
error: err,
|
|
585
|
-
isShellError: true,
|
|
586
|
-
recoverable: false,
|
|
587
|
-
timestamp: Date.now(),
|
|
588
|
-
};
|
|
554
|
+
// 실패 시 throw → renderStreamingResponse에서 500 처리
|
|
555
|
+
const reactStream = await renderToReadableStream(element, {
|
|
556
|
+
onError: (error: Error) => {
|
|
557
|
+
metrics.hasError = true;
|
|
558
|
+
const streamingError: StreamingError = {
|
|
559
|
+
error,
|
|
560
|
+
isShellError: !shellSent,
|
|
561
|
+
recoverable: shellSent,
|
|
562
|
+
timestamp: Date.now(),
|
|
563
|
+
};
|
|
589
564
|
|
|
590
|
-
|
|
591
|
-
onError?.(err);
|
|
565
|
+
console.error("[Mandu Streaming] React render error:", error);
|
|
592
566
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
567
|
+
if (!shellSent) {
|
|
568
|
+
// Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
|
|
569
|
+
onShellError?.(streamingError);
|
|
570
|
+
} else {
|
|
571
|
+
// Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
|
|
572
|
+
onStreamError?.(streamingError);
|
|
573
|
+
}
|
|
596
574
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
);
|
|
575
|
+
onError?.(error);
|
|
576
|
+
},
|
|
577
|
+
});
|
|
601
578
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// Timeout이나 에러 시에도 계속 진행 (이미 일부 렌더링된 경우)
|
|
579
|
+
// allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
|
|
580
|
+
reactStream.allReady.then(() => {
|
|
581
|
+
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
606
582
|
if (isDev) {
|
|
607
|
-
console.
|
|
583
|
+
console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
|
|
608
584
|
}
|
|
609
|
-
}
|
|
585
|
+
}).catch(() => {
|
|
586
|
+
// 에러는 onError에서 이미 처리됨
|
|
587
|
+
});
|
|
610
588
|
|
|
611
589
|
// Custom stream으로 래핑 (Shell + React Content + Tail)
|
|
612
590
|
let tailSent = false;
|
|
@@ -614,17 +592,7 @@ export async function renderToStream(
|
|
|
614
592
|
|
|
615
593
|
return new ReadableStream<Uint8Array>({
|
|
616
594
|
async start(controller) {
|
|
617
|
-
// Shell
|
|
618
|
-
if (hasShellError) {
|
|
619
|
-
const errorHtml = `<!DOCTYPE html>
|
|
620
|
-
<html><head><title>Error</title></head>
|
|
621
|
-
<body><h1>500 Server Error</h1><p>렌더링 중 오류가 발생했습니다.</p></body></html>`;
|
|
622
|
-
controller.enqueue(encoder.encode(errorHtml));
|
|
623
|
-
controller.close();
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// 1. HTML Shell 전송 (즉시 TTFB)
|
|
595
|
+
// Shell 즉시 전송 (TTFB 최소화의 핵심!)
|
|
628
596
|
controller.enqueue(encoder.encode(htmlShell));
|
|
629
597
|
shellSent = true;
|
|
630
598
|
metrics.shellReadyTime = Date.now() - metrics.startTime;
|
|
@@ -637,10 +605,12 @@ export async function renderToStream(
|
|
|
637
605
|
|
|
638
606
|
if (done) {
|
|
639
607
|
if (!tailSent) {
|
|
640
|
-
// 2. HTML Tail 전송 (스크립트, 데이터)
|
|
641
608
|
controller.enqueue(encoder.encode(htmlTail));
|
|
642
609
|
tailSent = true;
|
|
643
|
-
|
|
610
|
+
// allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록
|
|
611
|
+
if (metrics.allReadyTime === 0) {
|
|
612
|
+
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
613
|
+
}
|
|
644
614
|
onAllReady?.();
|
|
645
615
|
onMetrics?.(metrics);
|
|
646
616
|
}
|
|
@@ -648,7 +618,7 @@ export async function renderToStream(
|
|
|
648
618
|
return;
|
|
649
619
|
}
|
|
650
620
|
|
|
651
|
-
//
|
|
621
|
+
// React 컨텐츠를 그대로 스트리밍
|
|
652
622
|
controller.enqueue(value);
|
|
653
623
|
} catch (error) {
|
|
654
624
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -656,7 +626,7 @@ export async function renderToStream(
|
|
|
656
626
|
|
|
657
627
|
console.error("[Mandu Streaming] Pull error:", err);
|
|
658
628
|
|
|
659
|
-
//
|
|
629
|
+
// Shell 후 에러 - 에러 스크립트 삽입
|
|
660
630
|
const streamingError: StreamingError = {
|
|
661
631
|
error: err,
|
|
662
632
|
isShellError: false,
|
|
@@ -665,7 +635,6 @@ export async function renderToStream(
|
|
|
665
635
|
};
|
|
666
636
|
onStreamError?.(streamingError);
|
|
667
637
|
|
|
668
|
-
// 에러 스크립트 삽입
|
|
669
638
|
controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
|
|
670
639
|
|
|
671
640
|
if (!tailSent) {
|
|
@@ -688,9 +657,15 @@ export async function renderToStream(
|
|
|
688
657
|
* Streaming SSR Response 생성
|
|
689
658
|
*
|
|
690
659
|
* 헤더 설명:
|
|
691
|
-
* - Transfer-Encoding: chunked - 스트리밍 필수
|
|
692
660
|
* - X-Accel-Buffering: no - nginx 버퍼링 비활성화
|
|
693
661
|
* - Cache-Control: no-transform - 중간 프록시 변환 방지
|
|
662
|
+
*
|
|
663
|
+
* 주의: Transfer-Encoding은 설정하지 않음
|
|
664
|
+
* - WHATWG Response 환경에서 런타임이 자동 처리
|
|
665
|
+
* - 명시적 설정은 오히려 문제 될 수 있음
|
|
666
|
+
*
|
|
667
|
+
* 에러 처리:
|
|
668
|
+
* - renderToStream에서 throw → 여기서 500 Response 생성 (단일 책임)
|
|
694
669
|
*/
|
|
695
670
|
export async function renderStreamingResponse(
|
|
696
671
|
element: ReactElement,
|
|
@@ -703,25 +678,47 @@ export async function renderStreamingResponse(
|
|
|
703
678
|
status: 200,
|
|
704
679
|
headers: {
|
|
705
680
|
"Content-Type": "text/html; charset=utf-8",
|
|
706
|
-
|
|
707
|
-
// Streaming 관련 헤더
|
|
681
|
+
// Transfer-Encoding은 런타임이 자동 처리 (명시 안 함)
|
|
708
682
|
"X-Content-Type-Options": "nosniff",
|
|
709
683
|
// nginx 버퍼링 비활성화 힌트
|
|
710
684
|
"X-Accel-Buffering": "no",
|
|
711
685
|
// 캐시 및 변환 방지 (Streaming은 동적)
|
|
712
|
-
"Cache-Control": "no-
|
|
713
|
-
//
|
|
686
|
+
"Cache-Control": "no-store, no-transform",
|
|
687
|
+
// CDN 힌트
|
|
714
688
|
"CDN-Cache-Control": "no-store",
|
|
715
689
|
},
|
|
716
690
|
});
|
|
717
691
|
} catch (error) {
|
|
718
|
-
//
|
|
692
|
+
// renderToStream에서 throw된 에러 → 500 응답 (단일 책임)
|
|
719
693
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
720
|
-
console.error("[Mandu Streaming]
|
|
694
|
+
console.error("[Mandu Streaming] Render failed:", err);
|
|
695
|
+
|
|
696
|
+
// XSS 방지
|
|
697
|
+
const safeMessage = err.message
|
|
698
|
+
.replace(/</g, "<")
|
|
699
|
+
.replace(/>/g, ">");
|
|
721
700
|
|
|
722
701
|
return new Response(
|
|
723
|
-
`<!DOCTYPE html
|
|
724
|
-
<
|
|
702
|
+
`<!DOCTYPE html>
|
|
703
|
+
<html lang="ko">
|
|
704
|
+
<head>
|
|
705
|
+
<meta charset="UTF-8">
|
|
706
|
+
<title>500 Server Error</title>
|
|
707
|
+
<style>
|
|
708
|
+
body { font-family: system-ui, sans-serif; padding: 40px; background: #f5f5f5; }
|
|
709
|
+
.error { background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
710
|
+
h1 { color: #e53935; margin: 0 0 16px 0; }
|
|
711
|
+
pre { background: #f5f5f5; padding: 12px; overflow-x: auto; }
|
|
712
|
+
</style>
|
|
713
|
+
</head>
|
|
714
|
+
<body>
|
|
715
|
+
<div class="error">
|
|
716
|
+
<h1>500 Server Error</h1>
|
|
717
|
+
<p>렌더링 중 오류가 발생했습니다.</p>
|
|
718
|
+
${options.isDev ? `<pre>${safeMessage}</pre>` : ""}
|
|
719
|
+
</div>
|
|
720
|
+
</body>
|
|
721
|
+
</html>`,
|
|
725
722
|
{
|
|
726
723
|
status: 500,
|
|
727
724
|
headers: {
|
|
@@ -742,21 +739,32 @@ export async function renderWithDeferredData(
|
|
|
742
739
|
deferredPromises?: Record<string, Promise<unknown>>;
|
|
743
740
|
}
|
|
744
741
|
): Promise<Response> {
|
|
745
|
-
const {
|
|
742
|
+
const {
|
|
743
|
+
deferredPromises = {},
|
|
744
|
+
routeId = "default",
|
|
745
|
+
onMetrics,
|
|
746
|
+
isDev = false,
|
|
747
|
+
...restOptions
|
|
748
|
+
} = options;
|
|
746
749
|
|
|
747
|
-
// Deferred 데이터 스크립트를 추가할 TransformStream
|
|
748
750
|
const encoder = new TextEncoder();
|
|
749
751
|
const deferredScripts: string[] = [];
|
|
752
|
+
let deferredChunkCount = 0;
|
|
753
|
+
const startTime = Date.now();
|
|
750
754
|
|
|
751
|
-
// Deferred promises 처리
|
|
755
|
+
// Deferred promises 처리 (병렬)
|
|
752
756
|
const deferredEntries = Object.entries(deferredPromises);
|
|
753
757
|
if (deferredEntries.length > 0) {
|
|
754
|
-
|
|
755
|
-
Promise.all(
|
|
758
|
+
await Promise.all(
|
|
756
759
|
deferredEntries.map(async ([key, promise]) => {
|
|
757
760
|
try {
|
|
758
761
|
const data = await promise;
|
|
759
762
|
deferredScripts.push(generateDeferredDataScript(routeId, key, data));
|
|
763
|
+
deferredChunkCount++;
|
|
764
|
+
|
|
765
|
+
if (isDev) {
|
|
766
|
+
console.log(`[Mandu Streaming] Deferred chunk ready: ${key}`);
|
|
767
|
+
}
|
|
760
768
|
} catch (error) {
|
|
761
769
|
console.error(`[Mandu Streaming] Deferred data error for ${key}:`, error);
|
|
762
770
|
}
|
|
@@ -764,8 +772,16 @@ export async function renderWithDeferredData(
|
|
|
764
772
|
);
|
|
765
773
|
}
|
|
766
774
|
|
|
767
|
-
// 기본 스트림 생성
|
|
768
|
-
|
|
775
|
+
// 기본 스트림 생성 (메트릭 수집 포함)
|
|
776
|
+
let baseMetrics: StreamingMetrics | null = null;
|
|
777
|
+
const baseStream = await renderToStream(element, {
|
|
778
|
+
...restOptions,
|
|
779
|
+
routeId,
|
|
780
|
+
isDev,
|
|
781
|
+
onMetrics: (metrics) => {
|
|
782
|
+
baseMetrics = metrics;
|
|
783
|
+
},
|
|
784
|
+
});
|
|
769
785
|
|
|
770
786
|
// Deferred 스크립트 주입을 위한 Transform
|
|
771
787
|
const transformStream = new TransformStream<Uint8Array, Uint8Array>({
|
|
@@ -777,6 +793,15 @@ export async function renderWithDeferredData(
|
|
|
777
793
|
for (const script of deferredScripts) {
|
|
778
794
|
controller.enqueue(encoder.encode(script));
|
|
779
795
|
}
|
|
796
|
+
|
|
797
|
+
// 최종 메트릭 보고
|
|
798
|
+
if (onMetrics && baseMetrics) {
|
|
799
|
+
onMetrics({
|
|
800
|
+
...baseMetrics,
|
|
801
|
+
deferredChunkCount,
|
|
802
|
+
allReadyTime: Date.now() - startTime,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
780
805
|
},
|
|
781
806
|
});
|
|
782
807
|
|
|
@@ -786,9 +811,10 @@ export async function renderWithDeferredData(
|
|
|
786
811
|
status: 200,
|
|
787
812
|
headers: {
|
|
788
813
|
"Content-Type": "text/html; charset=utf-8",
|
|
789
|
-
"Transfer-Encoding": "chunked",
|
|
790
814
|
"X-Content-Type-Options": "nosniff",
|
|
791
|
-
"
|
|
815
|
+
"X-Accel-Buffering": "no",
|
|
816
|
+
"Cache-Control": "no-store, no-transform",
|
|
817
|
+
"CDN-Cache-Control": "no-store",
|
|
792
818
|
},
|
|
793
819
|
});
|
|
794
820
|
}
|