@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- /** Shell 전송 전 에러인지 여부 */
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
- /** Shell 전 에러 콜백 (500 반환 가능) */
106
+ /**
107
+ * Shell 전 에러 콜백
108
+ * - React 초기 렌더링 중 에러 발생 시 호출
109
+ * - 이 시점에서는 이미 스트림이 시작됨 (500 반환 불가)
110
+ * - 로깅/모니터링 용도
111
+ */
84
112
  onShellError?: (error: StreamingError) => void;
85
- /** 스트리밍 중 에러 콜백 (이미 응답 시작됨 - fallback UI 삽입) */
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
- * - renderToStream에서 throw 여기서 500 Response 생성 (단일 책임)
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
- * Critical 데이터는 즉시, Deferred 데이터는 준비되면 스트리밍
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
- // Deferred promises 처리 (병렬)
756
- const deferredEntries = Object.entries(deferredPromises);
757
- if (deferredEntries.length > 0) {
758
- await Promise.all(
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
- if (isDev) {
766
- console.log(`[Mandu Streaming] Deferred chunk ready: ${key}`);
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
- } catch (error) {
769
- console.error(`[Mandu Streaming] Deferred data error for ${key}:`, error);
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
- // Deferred 스크립트 주입을 위한 Transform
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
- // 모든 deferred 스크립트 추가
793
- for (const script of deferredScripts) {
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
  }