@mandujs/core 0.9.19 → 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 +108 -33
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;
|
|
@@ -664,8 +697,12 @@ export async function renderToStream(
|
|
|
664
697
|
* - WHATWG Response 환경에서 런타임이 자동 처리
|
|
665
698
|
* - 명시적 설정은 오히려 문제 될 수 있음
|
|
666
699
|
*
|
|
667
|
-
* 에러
|
|
668
|
-
* -
|
|
700
|
+
* 에러 정책:
|
|
701
|
+
* - renderToReadableStream 자체가 throw (stream 생성 실패)
|
|
702
|
+
* → 여기서 catch → 500 Response 반환 (유일한 500 케이스)
|
|
703
|
+
* - React onError 콜백 호출 (렌더링 중 에러)
|
|
704
|
+
* → StreamingError로 래핑 → 콜백 호출
|
|
705
|
+
* → 스트림은 계속 진행 (부분 렌더링 or 에러 스크립트 삽입)
|
|
669
706
|
*/
|
|
670
707
|
export async function renderStreamingResponse(
|
|
671
708
|
element: ReactElement,
|
|
@@ -731,16 +768,23 @@ export async function renderStreamingResponse(
|
|
|
731
768
|
|
|
732
769
|
/**
|
|
733
770
|
* Deferred 데이터와 함께 Streaming SSR 렌더링
|
|
734
|
-
*
|
|
771
|
+
*
|
|
772
|
+
* 핵심 원칙:
|
|
773
|
+
* - base stream은 즉시 시작 (TTFB 최소화)
|
|
774
|
+
* - deferred는 병렬로 처리하되 스트림을 막지 않음
|
|
775
|
+
* - 준비된 deferred만 tail 이후에 스크립트로 주입
|
|
735
776
|
*/
|
|
736
777
|
export async function renderWithDeferredData(
|
|
737
778
|
element: ReactElement,
|
|
738
779
|
options: StreamingSSROptions & {
|
|
739
780
|
deferredPromises?: Record<string, Promise<unknown>>;
|
|
781
|
+
/** Deferred 타임아웃 (ms) - 이 시간 안에 resolve되지 않으면 포기 */
|
|
782
|
+
deferredTimeout?: number;
|
|
740
783
|
}
|
|
741
784
|
): Promise<Response> {
|
|
742
785
|
const {
|
|
743
786
|
deferredPromises = {},
|
|
787
|
+
deferredTimeout = 5000,
|
|
744
788
|
routeId = "default",
|
|
745
789
|
onMetrics,
|
|
746
790
|
isDev = false,
|
|
@@ -748,31 +792,43 @@ export async function renderWithDeferredData(
|
|
|
748
792
|
} = options;
|
|
749
793
|
|
|
750
794
|
const encoder = new TextEncoder();
|
|
751
|
-
const deferredScripts: string[] = [];
|
|
752
|
-
let deferredChunkCount = 0;
|
|
753
795
|
const startTime = Date.now();
|
|
754
796
|
|
|
755
|
-
//
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
deferredEntries.map(async ([key, promise]) => {
|
|
760
|
-
try {
|
|
761
|
-
const data = await promise;
|
|
762
|
-
deferredScripts.push(generateDeferredDataScript(routeId, key, data));
|
|
763
|
-
deferredChunkCount++;
|
|
797
|
+
// 준비된 deferred 스크립트를 담을 배열 (mutable)
|
|
798
|
+
const readyScripts: string[] = [];
|
|
799
|
+
let deferredChunkCount = 0;
|
|
800
|
+
let allDeferredSettled = false;
|
|
764
801
|
|
|
765
|
-
|
|
766
|
-
|
|
802
|
+
// 1. Deferred promises 병렬 시작 (막지 않음!)
|
|
803
|
+
const deferredEntries = Object.entries(deferredPromises);
|
|
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);
|
|
767
824
|
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
|
|
825
|
+
})
|
|
826
|
+
).then(() => {
|
|
827
|
+
allDeferredSettled = true;
|
|
771
828
|
})
|
|
772
|
-
);
|
|
773
|
-
}
|
|
829
|
+
: Promise.resolve().then(() => { allDeferredSettled = true; });
|
|
774
830
|
|
|
775
|
-
//
|
|
831
|
+
// 2. Base stream 즉시 시작 (TTFB 최소화의 핵심!)
|
|
776
832
|
let baseMetrics: StreamingMetrics | null = null;
|
|
777
833
|
const baseStream = await renderToStream(element, {
|
|
778
834
|
...restOptions,
|
|
@@ -783,22 +839,41 @@ export async function renderWithDeferredData(
|
|
|
783
839
|
},
|
|
784
840
|
});
|
|
785
841
|
|
|
786
|
-
//
|
|
842
|
+
// 3. TransformStream: base stream 통과 + tail 이후 deferred 스크립트 주입
|
|
787
843
|
const transformStream = new TransformStream<Uint8Array, Uint8Array>({
|
|
788
844
|
transform(chunk, controller) {
|
|
845
|
+
// base stream chunk 그대로 전달
|
|
789
846
|
controller.enqueue(chunk);
|
|
790
847
|
},
|
|
791
|
-
flush(controller) {
|
|
792
|
-
//
|
|
793
|
-
|
|
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) {
|
|
794
864
|
controller.enqueue(encoder.encode(script));
|
|
865
|
+
injectedCount++;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (isDev && injectedCount > 0) {
|
|
869
|
+
console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`);
|
|
795
870
|
}
|
|
796
871
|
|
|
797
|
-
// 최종 메트릭 보고
|
|
872
|
+
// 최종 메트릭 보고 (injectedCount가 실제 메트릭)
|
|
798
873
|
if (onMetrics && baseMetrics) {
|
|
799
874
|
onMetrics({
|
|
800
875
|
...baseMetrics,
|
|
801
|
-
deferredChunkCount,
|
|
876
|
+
deferredChunkCount: injectedCount,
|
|
802
877
|
allReadyTime: Date.now() - startTime,
|
|
803
878
|
});
|
|
804
879
|
}
|