@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -504,16 +504,17 @@ function generateHMRScript(port: number): string {
504
504
  * React 컴포넌트를 ReadableStream으로 렌더링
505
505
  * Bun/Web Streams API 기반
506
506
  *
507
- * 에러 처리:
508
- * - Shell 에러: onShellError 호출 → 500 응답 가능
509
- * - Shell 에러: onStreamError 호출 → 에러 스크립트 삽입
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
- 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
- };
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
- onShellError?.(streamingError);
591
- onError?.(err);
565
+ console.error("[Mandu Streaming] React render error:", error);
592
566
 
593
- // 에러 응답을 위한 빈 스트림 반환
594
- throw err;
595
- }
567
+ if (!shellSent) {
568
+ // Shell 전 에러 - 콜백만 호출 (throw 하지 않음, 이미 스트림 시작됨)
569
+ onShellError?.(streamingError);
570
+ } else {
571
+ // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨
572
+ onStreamError?.(streamingError);
573
+ }
596
574
 
597
- // Shell 준비 완료 대기 (타임아웃 포함)
598
- const timeoutPromise = new Promise<void>((_, reject) =>
599
- setTimeout(() => reject(new Error("Shell render timeout")), streamTimeout)
600
- );
575
+ onError?.(error);
576
+ },
577
+ });
601
578
 
602
- try {
603
- await Promise.race([reactStream.allReady, timeoutPromise]);
604
- } catch {
605
- // Timeout이나 에러 시에도 계속 진행 (이미 일부 렌더링된 경우)
579
+ // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!)
580
+ reactStream.allReady.then(() => {
581
+ metrics.allReadyTime = Date.now() - metrics.startTime;
606
582
  if (isDev) {
607
- console.warn("[Mandu Streaming] Shell render timed out or errored, continuing with partial content");
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 에러면 에러 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)
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
- metrics.allReadyTime = Date.now() - metrics.startTime;
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
- // 3. React 컨텐츠 스트리밍
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
- "Transfer-Encoding": "chunked",
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-cache, no-store, must-revalidate, no-transform",
713
- // Cloudflare 등 CDN 힌트
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
- // Shell 에러 500 응답
692
+ // renderToStream에서 throw된 에러 500 응답 (단일 책임)
719
693
  const err = error instanceof Error ? error : new Error(String(error));
720
- console.error("[Mandu Streaming] Response generation failed:", err);
694
+ console.error("[Mandu Streaming] Render failed:", err);
695
+
696
+ // XSS 방지
697
+ const safeMessage = err.message
698
+ .replace(/</g, "&lt;")
699
+ .replace(/>/g, "&gt;");
721
700
 
722
701
  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>`,
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 { deferredPromises = {}, routeId = "default", ...restOptions } = options;
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
- // 병렬로 모든 deferred 데이터 대기
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
- const baseStream = await renderToStream(element, { ...restOptions, routeId });
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
- "Cache-Control": "no-cache, no-store, must-revalidate",
815
+ "X-Accel-Buffering": "no",
816
+ "Cache-Control": "no-store, no-transform",
817
+ "CDN-Cache-Control": "no-store",
792
818
  },
793
819
  });
794
820
  }