@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.18",
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;
@@ -504,16 +537,17 @@ function generateHMRScript(port: number): string {
504
537
  * React 컴포넌트를 ReadableStream으로 렌더링
505
538
  * Bun/Web Streams API 기반
506
539
  *
507
- * 에러 처리:
508
- * - Shell 에러: onShellError 호출 → 500 응답 가능
509
- * - Shell 에러: onStreamError 호출 → 에러 스크립트 삽입
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
- let reactStream: ReadableStream<Uint8Array>;
555
- try {
556
- reactStream = await renderToReadableStream(element, {
557
- onError: (error: Error) => {
558
- metrics.hasError = true;
559
- const streamingError: StreamingError = {
560
- error,
561
- isShellError: !shellSent,
562
- recoverable: shellSent, // Shell 후면 복구 가능 (에러 스크립트 삽입)
563
- timestamp: Date.now(),
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
- onShellError?.(streamingError);
591
- onError?.(err);
598
+ console.error("[Mandu Streaming] React render error:", error);
592
599
 
593
- // 에러 응답을 위한 빈 스트림 반환
594
- throw err;
595
- }
600
+ if (!shellSent) {
601
+ // Shell 전 에러 - 콜백만 호출 (throw 하지 않음, 이미 스트림 시작됨)
602
+ onShellError?.(streamingError);
603
+ } else {
604
+ // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
605
+ onStreamError?.(streamingError);
606
+ }
596
607
 
597
- // Shell 준비 완료 대기 (타임아웃 포함)
598
- const timeoutPromise = new Promise<void>((_, reject) =>
599
- setTimeout(() => reject(new Error("Shell render timeout")), streamTimeout)
600
- );
608
+ onError?.(error);
609
+ },
610
+ });
601
611
 
602
- try {
603
- await Promise.race([reactStream.allReady, timeoutPromise]);
604
- } catch {
605
- // Timeout이나 에러 시에도 계속 진행 (이미 일부 렌더링된 경우)
612
+ // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
613
+ reactStream.allReady.then(() => {
614
+ metrics.allReadyTime = Date.now() - metrics.startTime;
606
615
  if (isDev) {
607
- console.warn("[Mandu Streaming] Shell render timed out or errored, continuing with partial content");
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 에러면 에러 HTML 반환
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
- metrics.allReadyTime = Date.now() - metrics.startTime;
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
- // 3. React 컨텐츠 스트리밍
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
- "Transfer-Encoding": "chunked",
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-cache, no-store, must-revalidate, no-transform",
713
- // Cloudflare 등 CDN 힌트
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
- // Shell 에러 500 응답
729
+ // renderToStream에서 throw된 에러 500 응답 (단일 책임)
719
730
  const err = error instanceof Error ? error : new Error(String(error));
720
- console.error("[Mandu Streaming] Response generation failed:", err);
731
+ console.error("[Mandu Streaming] Render failed:", err);
732
+
733
+ // XSS 방지
734
+ const safeMessage = err.message
735
+ .replace(/</g, "&lt;")
736
+ .replace(/>/g, "&gt;");
721
737
 
722
738
  return new Response(
723
- `<!DOCTYPE html><html><head><title>500 Error</title></head>
724
- <body><h1>500 Server Error</h1><p>${err.message}</p></body></html>`,
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
- * Critical 데이터는 즉시, Deferred 데이터는 준비되면 스트리밍
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 { deferredPromises = {}, routeId = "default", ...restOptions } = options;
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 deferredScripts: string[] = [];
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
- if (deferredEntries.length > 0) {
754
- // 병렬로 모든 deferred 데이터 대기
755
- Promise.all(
756
- deferredEntries.map(async ([key, promise]) => {
757
- try {
758
- const data = await promise;
759
- deferredScripts.push(generateDeferredDataScript(routeId, key, data));
760
- } catch (error) {
761
- console.error(`[Mandu Streaming] Deferred data error for ${key}:`, error);
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
- const baseStream = await renderToStream(element, { ...restOptions, routeId });
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
- // Deferred 스크립트 주입을 위한 Transform
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
- // 모든 deferred 스크립트 추가
777
- 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) {
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
- "Cache-Control": "no-cache, no-store, must-revalidate",
890
+ "X-Accel-Buffering": "no",
891
+ "Cache-Control": "no-store, no-transform",
892
+ "CDN-Cache-Control": "no-store",
792
893
  },
793
894
  });
794
895
  }