@mandujs/core 0.9.18 → 0.9.20
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 +212 -111
package/package.json
CHANGED
|
@@ -22,12 +22,35 @@ import { serializeProps } from "../client/serialize";
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Streaming SSR 에러 타입
|
|
25
|
+
*
|
|
26
|
+
* 에러 정책 (Error Policy):
|
|
27
|
+
* 1. Stream 생성 실패 (renderToReadableStream throws)
|
|
28
|
+
* → renderStreamingResponse에서 catch → 500 Response 반환
|
|
29
|
+
* → 이 경우 StreamingError는 생성되지 않음
|
|
30
|
+
*
|
|
31
|
+
* 2. Shell 전 React 렌더링 에러 (onError called, shellSent=false)
|
|
32
|
+
* → isShellError: true, recoverable: false
|
|
33
|
+
* → onShellError 콜백 호출
|
|
34
|
+
* → 스트림은 계속 진행 (빈 컨텐츠 or 부분 렌더링)
|
|
35
|
+
*
|
|
36
|
+
* 3. Shell 후 스트리밍 에러 (onError called, shellSent=true)
|
|
37
|
+
* → isShellError: false, recoverable: true
|
|
38
|
+
* → onStreamError 콜백 호출
|
|
39
|
+
* → 에러 스크립트가 HTML에 삽입됨
|
|
25
40
|
*/
|
|
26
41
|
export interface StreamingError {
|
|
27
42
|
error: Error;
|
|
28
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Shell 전송 전 에러인지 여부
|
|
45
|
+
* - true: React 초기 렌더링 중 에러 (Shell 전송 전)
|
|
46
|
+
* - false: 스트리밍 중 에러 (Shell 이미 전송됨)
|
|
47
|
+
*/
|
|
29
48
|
isShellError: boolean;
|
|
30
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* 복구 가능 여부
|
|
51
|
+
* - true: Shell 이후 에러 - 에러 스크립트 삽입으로 클라이언트 알림
|
|
52
|
+
* - false: Shell 전 에러 - 사용자에게 불완전한 UI 표시될 수 있음
|
|
53
|
+
*/
|
|
31
54
|
recoverable: boolean;
|
|
32
55
|
/** 타임스탬프 */
|
|
33
56
|
timestamp: number;
|
|
@@ -76,13 +99,23 @@ export interface StreamingSSROptions {
|
|
|
76
99
|
enableClientRouter?: boolean;
|
|
77
100
|
/** Streaming 타임아웃 (ms) */
|
|
78
101
|
streamTimeout?: number;
|
|
79
|
-
/** Shell 렌더링 후 콜백 */
|
|
102
|
+
/** Shell 렌더링 후 콜백 (TTFB 측정 시점) */
|
|
80
103
|
onShellReady?: () => void;
|
|
81
104
|
/** 모든 컨텐츠 렌더링 후 콜백 */
|
|
82
105
|
onAllReady?: () => void;
|
|
83
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Shell 전 에러 콜백
|
|
108
|
+
* - React 초기 렌더링 중 에러 발생 시 호출
|
|
109
|
+
* - 이 시점에서는 이미 스트림이 시작됨 (500 반환 불가)
|
|
110
|
+
* - 로깅/모니터링 용도
|
|
111
|
+
*/
|
|
84
112
|
onShellError?: (error: StreamingError) => void;
|
|
85
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* 스트리밍 중 에러 콜백
|
|
115
|
+
* - Shell 전송 후 에러 발생 시 호출
|
|
116
|
+
* - 에러 스크립트가 HTML에 자동 삽입됨
|
|
117
|
+
* - 클라이언트에서 mandu:streaming-error 이벤트로 감지 가능
|
|
118
|
+
*/
|
|
86
119
|
onStreamError?: (error: StreamingError) => void;
|
|
87
120
|
/** 에러 콜백 (deprecated - onShellError/onStreamError 사용 권장) */
|
|
88
121
|
onError?: (error: Error) => void;
|
|
@@ -504,16 +537,17 @@ function generateHMRScript(port: number): string {
|
|
|
504
537
|
* React 컴포넌트를 ReadableStream으로 렌더링
|
|
505
538
|
* Bun/Web Streams API 기반
|
|
506
539
|
*
|
|
507
|
-
*
|
|
508
|
-
* - Shell
|
|
509
|
-
* -
|
|
540
|
+
* 핵심 원칙:
|
|
541
|
+
* - Shell은 즉시 전송 (TTFB 최소화)
|
|
542
|
+
* - allReady는 메트릭용으로만 사용 (대기 안 함)
|
|
543
|
+
* - Shell 전 에러는 throw → Response 레이어에서 500 처리
|
|
544
|
+
* - Shell 후 에러는 에러 스크립트 삽입
|
|
510
545
|
*/
|
|
511
546
|
export async function renderToStream(
|
|
512
547
|
element: ReactElement,
|
|
513
548
|
options: StreamingSSROptions = {}
|
|
514
549
|
): Promise<ReadableStream<Uint8Array>> {
|
|
515
550
|
const {
|
|
516
|
-
streamTimeout = 10000,
|
|
517
551
|
onShellReady,
|
|
518
552
|
onAllReady,
|
|
519
553
|
onShellError,
|
|
@@ -534,7 +568,7 @@ export async function renderToStream(
|
|
|
534
568
|
startTime: Date.now(),
|
|
535
569
|
};
|
|
536
570
|
|
|
537
|
-
// criticalData 직렬화 검증
|
|
571
|
+
// criticalData 직렬화 검증 (dev에서는 throw)
|
|
538
572
|
validateCriticalData(criticalData, isDev);
|
|
539
573
|
|
|
540
574
|
// 스트리밍 주의사항 경고 (첫 요청 시 1회만)
|
|
@@ -548,65 +582,42 @@ export async function renderToStream(
|
|
|
548
582
|
const htmlTail = generateHTMLTail(options);
|
|
549
583
|
|
|
550
584
|
let shellSent = false;
|
|
551
|
-
let hasShellError = false;
|
|
552
585
|
|
|
553
586
|
// 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
|
-
};
|
|
587
|
+
// 실패 시 throw → renderStreamingResponse에서 500 처리
|
|
588
|
+
const reactStream = await renderToReadableStream(element, {
|
|
589
|
+
onError: (error: Error) => {
|
|
590
|
+
metrics.hasError = true;
|
|
591
|
+
const streamingError: StreamingError = {
|
|
592
|
+
error,
|
|
593
|
+
isShellError: !shellSent,
|
|
594
|
+
recoverable: shellSent,
|
|
595
|
+
timestamp: Date.now(),
|
|
596
|
+
};
|
|
589
597
|
|
|
590
|
-
|
|
591
|
-
onError?.(err);
|
|
598
|
+
console.error("[Mandu Streaming] React render error:", error);
|
|
592
599
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
600
|
+
if (!shellSent) {
|
|
601
|
+
// Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨)
|
|
602
|
+
onShellError?.(streamingError);
|
|
603
|
+
} else {
|
|
604
|
+
// Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
|
|
605
|
+
onStreamError?.(streamingError);
|
|
606
|
+
}
|
|
596
607
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
);
|
|
608
|
+
onError?.(error);
|
|
609
|
+
},
|
|
610
|
+
});
|
|
601
611
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// Timeout이나 에러 시에도 계속 진행 (이미 일부 렌더링된 경우)
|
|
612
|
+
// allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
|
|
613
|
+
reactStream.allReady.then(() => {
|
|
614
|
+
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
606
615
|
if (isDev) {
|
|
607
|
-
console.
|
|
616
|
+
console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`);
|
|
608
617
|
}
|
|
609
|
-
}
|
|
618
|
+
}).catch(() => {
|
|
619
|
+
// 에러는 onError에서 이미 처리됨
|
|
620
|
+
});
|
|
610
621
|
|
|
611
622
|
// Custom stream으로 래핑 (Shell + React Content + Tail)
|
|
612
623
|
let tailSent = false;
|
|
@@ -614,17 +625,7 @@ export async function renderToStream(
|
|
|
614
625
|
|
|
615
626
|
return new ReadableStream<Uint8Array>({
|
|
616
627
|
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)
|
|
628
|
+
// Shell 즉시 전송 (TTFB 최소화의 핵심!)
|
|
628
629
|
controller.enqueue(encoder.encode(htmlShell));
|
|
629
630
|
shellSent = true;
|
|
630
631
|
metrics.shellReadyTime = Date.now() - metrics.startTime;
|
|
@@ -637,10 +638,12 @@ export async function renderToStream(
|
|
|
637
638
|
|
|
638
639
|
if (done) {
|
|
639
640
|
if (!tailSent) {
|
|
640
|
-
// 2. HTML Tail 전송 (스크립트, 데이터)
|
|
641
641
|
controller.enqueue(encoder.encode(htmlTail));
|
|
642
642
|
tailSent = true;
|
|
643
|
-
|
|
643
|
+
// allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록
|
|
644
|
+
if (metrics.allReadyTime === 0) {
|
|
645
|
+
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
646
|
+
}
|
|
644
647
|
onAllReady?.();
|
|
645
648
|
onMetrics?.(metrics);
|
|
646
649
|
}
|
|
@@ -648,7 +651,7 @@ export async function renderToStream(
|
|
|
648
651
|
return;
|
|
649
652
|
}
|
|
650
653
|
|
|
651
|
-
//
|
|
654
|
+
// React 컨텐츠를 그대로 스트리밍
|
|
652
655
|
controller.enqueue(value);
|
|
653
656
|
} catch (error) {
|
|
654
657
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -656,7 +659,7 @@ export async function renderToStream(
|
|
|
656
659
|
|
|
657
660
|
console.error("[Mandu Streaming] Pull error:", err);
|
|
658
661
|
|
|
659
|
-
//
|
|
662
|
+
// Shell 후 에러 - 에러 스크립트 삽입
|
|
660
663
|
const streamingError: StreamingError = {
|
|
661
664
|
error: err,
|
|
662
665
|
isShellError: false,
|
|
@@ -665,7 +668,6 @@ export async function renderToStream(
|
|
|
665
668
|
};
|
|
666
669
|
onStreamError?.(streamingError);
|
|
667
670
|
|
|
668
|
-
// 에러 스크립트 삽입
|
|
669
671
|
controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
|
|
670
672
|
|
|
671
673
|
if (!tailSent) {
|
|
@@ -688,9 +690,19 @@ export async function renderToStream(
|
|
|
688
690
|
* Streaming SSR Response 생성
|
|
689
691
|
*
|
|
690
692
|
* 헤더 설명:
|
|
691
|
-
* - Transfer-Encoding: chunked - 스트리밍 필수
|
|
692
693
|
* - X-Accel-Buffering: no - nginx 버퍼링 비활성화
|
|
693
694
|
* - Cache-Control: no-transform - 중간 프록시 변환 방지
|
|
695
|
+
*
|
|
696
|
+
* 주의: Transfer-Encoding은 설정하지 않음
|
|
697
|
+
* - WHATWG Response 환경에서 런타임이 자동 처리
|
|
698
|
+
* - 명시적 설정은 오히려 문제 될 수 있음
|
|
699
|
+
*
|
|
700
|
+
* 에러 정책:
|
|
701
|
+
* - renderToReadableStream 자체가 throw (stream 생성 실패)
|
|
702
|
+
* → 여기서 catch → 500 Response 반환 (유일한 500 케이스)
|
|
703
|
+
* - React onError 콜백 호출 (렌더링 중 에러)
|
|
704
|
+
* → StreamingError로 래핑 → 콜백 호출
|
|
705
|
+
* → 스트림은 계속 진행 (부분 렌더링 or 에러 스크립트 삽입)
|
|
694
706
|
*/
|
|
695
707
|
export async function renderStreamingResponse(
|
|
696
708
|
element: ReactElement,
|
|
@@ -703,25 +715,47 @@ export async function renderStreamingResponse(
|
|
|
703
715
|
status: 200,
|
|
704
716
|
headers: {
|
|
705
717
|
"Content-Type": "text/html; charset=utf-8",
|
|
706
|
-
|
|
707
|
-
// Streaming 관련 헤더
|
|
718
|
+
// Transfer-Encoding은 런타임이 자동 처리 (명시 안 함)
|
|
708
719
|
"X-Content-Type-Options": "nosniff",
|
|
709
720
|
// nginx 버퍼링 비활성화 힌트
|
|
710
721
|
"X-Accel-Buffering": "no",
|
|
711
722
|
// 캐시 및 변환 방지 (Streaming은 동적)
|
|
712
|
-
"Cache-Control": "no-
|
|
713
|
-
//
|
|
723
|
+
"Cache-Control": "no-store, no-transform",
|
|
724
|
+
// CDN 힌트
|
|
714
725
|
"CDN-Cache-Control": "no-store",
|
|
715
726
|
},
|
|
716
727
|
});
|
|
717
728
|
} catch (error) {
|
|
718
|
-
//
|
|
729
|
+
// renderToStream에서 throw된 에러 → 500 응답 (단일 책임)
|
|
719
730
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
720
|
-
console.error("[Mandu Streaming]
|
|
731
|
+
console.error("[Mandu Streaming] Render failed:", err);
|
|
732
|
+
|
|
733
|
+
// XSS 방지
|
|
734
|
+
const safeMessage = err.message
|
|
735
|
+
.replace(/</g, "<")
|
|
736
|
+
.replace(/>/g, ">");
|
|
721
737
|
|
|
722
738
|
return new Response(
|
|
723
|
-
`<!DOCTYPE html
|
|
724
|
-
<
|
|
739
|
+
`<!DOCTYPE html>
|
|
740
|
+
<html lang="ko">
|
|
741
|
+
<head>
|
|
742
|
+
<meta charset="UTF-8">
|
|
743
|
+
<title>500 Server Error</title>
|
|
744
|
+
<style>
|
|
745
|
+
body { font-family: system-ui, sans-serif; padding: 40px; background: #f5f5f5; }
|
|
746
|
+
.error { background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
747
|
+
h1 { color: #e53935; margin: 0 0 16px 0; }
|
|
748
|
+
pre { background: #f5f5f5; padding: 12px; overflow-x: auto; }
|
|
749
|
+
</style>
|
|
750
|
+
</head>
|
|
751
|
+
<body>
|
|
752
|
+
<div class="error">
|
|
753
|
+
<h1>500 Server Error</h1>
|
|
754
|
+
<p>렌더링 중 오류가 발생했습니다.</p>
|
|
755
|
+
${options.isDev ? `<pre>${safeMessage}</pre>` : ""}
|
|
756
|
+
</div>
|
|
757
|
+
</body>
|
|
758
|
+
</html>`,
|
|
725
759
|
{
|
|
726
760
|
status: 500,
|
|
727
761
|
headers: {
|
|
@@ -734,48 +768,114 @@ export async function renderStreamingResponse(
|
|
|
734
768
|
|
|
735
769
|
/**
|
|
736
770
|
* Deferred 데이터와 함께 Streaming SSR 렌더링
|
|
737
|
-
*
|
|
771
|
+
*
|
|
772
|
+
* 핵심 원칙:
|
|
773
|
+
* - base stream은 즉시 시작 (TTFB 최소화)
|
|
774
|
+
* - deferred는 병렬로 처리하되 스트림을 막지 않음
|
|
775
|
+
* - 준비된 deferred만 tail 이후에 스크립트로 주입
|
|
738
776
|
*/
|
|
739
777
|
export async function renderWithDeferredData(
|
|
740
778
|
element: ReactElement,
|
|
741
779
|
options: StreamingSSROptions & {
|
|
742
780
|
deferredPromises?: Record<string, Promise<unknown>>;
|
|
781
|
+
/** Deferred 타임아웃 (ms) - 이 시간 안에 resolve되지 않으면 포기 */
|
|
782
|
+
deferredTimeout?: number;
|
|
743
783
|
}
|
|
744
784
|
): Promise<Response> {
|
|
745
|
-
const {
|
|
785
|
+
const {
|
|
786
|
+
deferredPromises = {},
|
|
787
|
+
deferredTimeout = 5000,
|
|
788
|
+
routeId = "default",
|
|
789
|
+
onMetrics,
|
|
790
|
+
isDev = false,
|
|
791
|
+
...restOptions
|
|
792
|
+
} = options;
|
|
746
793
|
|
|
747
|
-
// Deferred 데이터 스크립트를 추가할 TransformStream
|
|
748
794
|
const encoder = new TextEncoder();
|
|
749
|
-
const
|
|
795
|
+
const startTime = Date.now();
|
|
796
|
+
|
|
797
|
+
// 준비된 deferred 스크립트를 담을 배열 (mutable)
|
|
798
|
+
const readyScripts: string[] = [];
|
|
799
|
+
let deferredChunkCount = 0;
|
|
800
|
+
let allDeferredSettled = false;
|
|
750
801
|
|
|
751
|
-
// Deferred promises
|
|
802
|
+
// 1. Deferred promises 병렬 시작 (막지 않음!)
|
|
752
803
|
const deferredEntries = Object.entries(deferredPromises);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
804
|
+
const deferredSettledPromise = deferredEntries.length > 0
|
|
805
|
+
? Promise.allSettled(
|
|
806
|
+
deferredEntries.map(async ([key, promise]) => {
|
|
807
|
+
try {
|
|
808
|
+
// 타임아웃 적용
|
|
809
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
810
|
+
setTimeout(() => reject(new Error(`Deferred timeout: ${key}`)), deferredTimeout)
|
|
811
|
+
);
|
|
812
|
+
const data = await Promise.race([promise, timeoutPromise]);
|
|
813
|
+
|
|
814
|
+
// 스크립트 생성 및 추가
|
|
815
|
+
const script = generateDeferredDataScript(routeId, key, data);
|
|
816
|
+
readyScripts.push(script);
|
|
817
|
+
deferredChunkCount++;
|
|
818
|
+
|
|
819
|
+
if (isDev) {
|
|
820
|
+
console.log(`[Mandu Streaming] Deferred ready: ${key} (${Date.now() - startTime}ms)`);
|
|
821
|
+
}
|
|
822
|
+
} catch (error) {
|
|
823
|
+
console.error(`[Mandu Streaming] Deferred error for ${key}:`, error);
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
).then(() => {
|
|
827
|
+
allDeferredSettled = true;
|
|
763
828
|
})
|
|
764
|
-
);
|
|
765
|
-
}
|
|
829
|
+
: Promise.resolve().then(() => { allDeferredSettled = true; });
|
|
766
830
|
|
|
767
|
-
//
|
|
768
|
-
|
|
831
|
+
// 2. Base stream 즉시 시작 (TTFB 최소화의 핵심!)
|
|
832
|
+
let baseMetrics: StreamingMetrics | null = null;
|
|
833
|
+
const baseStream = await renderToStream(element, {
|
|
834
|
+
...restOptions,
|
|
835
|
+
routeId,
|
|
836
|
+
isDev,
|
|
837
|
+
onMetrics: (metrics) => {
|
|
838
|
+
baseMetrics = metrics;
|
|
839
|
+
},
|
|
840
|
+
});
|
|
769
841
|
|
|
770
|
-
//
|
|
842
|
+
// 3. TransformStream: base stream 통과 + tail 이후 deferred 스크립트 주입
|
|
771
843
|
const transformStream = new TransformStream<Uint8Array, Uint8Array>({
|
|
772
844
|
transform(chunk, controller) {
|
|
845
|
+
// base stream chunk 그대로 전달
|
|
773
846
|
controller.enqueue(chunk);
|
|
774
847
|
},
|
|
775
|
-
flush(controller) {
|
|
776
|
-
//
|
|
777
|
-
|
|
848
|
+
async flush(controller) {
|
|
849
|
+
// base stream 완료 후, deferred가 아직 안 끝났으면 잠시 대기
|
|
850
|
+
// (단, deferredTimeout 내에서만)
|
|
851
|
+
if (!allDeferredSettled) {
|
|
852
|
+
const remainingTime = Math.max(0, deferredTimeout - (Date.now() - startTime));
|
|
853
|
+
if (remainingTime > 0) {
|
|
854
|
+
await Promise.race([
|
|
855
|
+
deferredSettledPromise,
|
|
856
|
+
new Promise(resolve => setTimeout(resolve, remainingTime)),
|
|
857
|
+
]);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 준비된 deferred 스크립트만 주입 (실제 enqueue 기준 카운트)
|
|
862
|
+
let injectedCount = 0;
|
|
863
|
+
for (const script of readyScripts) {
|
|
778
864
|
controller.enqueue(encoder.encode(script));
|
|
865
|
+
injectedCount++;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (isDev && injectedCount > 0) {
|
|
869
|
+
console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 최종 메트릭 보고 (injectedCount가 실제 메트릭)
|
|
873
|
+
if (onMetrics && baseMetrics) {
|
|
874
|
+
onMetrics({
|
|
875
|
+
...baseMetrics,
|
|
876
|
+
deferredChunkCount: injectedCount,
|
|
877
|
+
allReadyTime: Date.now() - startTime,
|
|
878
|
+
});
|
|
779
879
|
}
|
|
780
880
|
},
|
|
781
881
|
});
|
|
@@ -786,9 +886,10 @@ export async function renderWithDeferredData(
|
|
|
786
886
|
status: 200,
|
|
787
887
|
headers: {
|
|
788
888
|
"Content-Type": "text/html; charset=utf-8",
|
|
789
|
-
"Transfer-Encoding": "chunked",
|
|
790
889
|
"X-Content-Type-Options": "nosniff",
|
|
791
|
-
"
|
|
890
|
+
"X-Accel-Buffering": "no",
|
|
891
|
+
"Cache-Control": "no-store, no-transform",
|
|
892
|
+
"CDN-Cache-Control": "no-store",
|
|
792
893
|
},
|
|
793
894
|
});
|
|
794
895
|
}
|