/** * Mandu Streaming SSR * React 18 renderToReadableStream 기반 점진적 HTML 스트리밍 * * 특징: * - TTFB 최소화 (Shell 즉시 전송) * - Suspense 경계에서 fallback → 실제 컨텐츠 스트리밍 * - Critical/Deferred 데이터 분리 * - Island Architecture와 완벽 통합 */ import { getRenderToReadableStream } from "./react-renderer"; import type { ReactElement, ReactNode } from "react"; import React, { Suspense } from "react"; import type { BundleManifest } from "../bundler/types"; import { isSafeManduUrl } from "../bundler/manifest-schema"; import type { HydrationConfig, HydrationPriority } from "../spec/schema"; import { serializeProps } from "../client/serialize"; import type { Metadata, MetadataItem } from "../seo/types"; import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr"; import { PORTS, TIMEOUTS } from "../constants"; import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsString } from "./escape"; import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims"; import { getRenderToString } from "./react-renderer"; import { mark, measure } from "../perf"; import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble"; import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper"; import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper"; import { createManduClientBoundaryRenderScope, renderWithManduClientBoundaryManifest, } from "../internal/client-boundary"; /** * Issue #192 — `@view-transition` at-rule, mirror of the constant in * `./ssr.ts`. Duplicated here to keep the streaming path self-contained * without a cross-module runtime import cycle (ssr.ts already re-exports * streaming-ssr.ts). Both constants MUST stay byte-identical. */ const VIEW_TRANSITION_STYLE_TAG = ""; // ========== Types ========== /** * Streaming SSR 에러 타입 * * 에러 정책 (Error Policy): * 1. Stream 생성 실패 (renderToReadableStream throws) * → renderStreamingResponse에서 catch → 500 Response 반환 * → 이 경우 StreamingError는 생성되지 않음 * * 2. Shell 전 React 렌더링 에러 (onError called, shellSent=false) * → isShellError: true, recoverable: false * → onShellError 콜백 호출 * → 스트림은 계속 진행 (빈 컨텐츠 or 부분 렌더링) * * 3. Shell 후 스트리밍 에러 (onError called, shellSent=true) * → isShellError: false, recoverable: true * → onStreamError 콜백 호출 * → 에러 스크립트가 HTML에 삽입됨 */ export interface StreamingError { error: Error; /** * Shell 전송 전 에러인지 여부 * - true: React 초기 렌더링 중 에러 (Shell 전송 전) * - false: 스트리밍 중 에러 (Shell 이미 전송됨) */ isShellError: boolean; /** * 복구 가능 여부 * - true: Shell 이후 에러 - 에러 스크립트 삽입으로 클라이언트 알림 * - false: Shell 전 에러 - 사용자에게 불완전한 UI 표시될 수 있음 */ recoverable: boolean; /** 타임스탬프 */ timestamp: number; } /** * Streaming SSR 메트릭 */ export interface StreamingMetrics { /** Shell ready까지 걸린 시간 (ms) */ shellReadyTime: number; /** All ready까지 걸린 시간 (ms) */ allReadyTime: number; /** Deferred chunk 개수 */ deferredChunkCount: number; /** 에러 발생 여부 */ hasError: boolean; /** 시작 시간 */ startTime: number; } export interface StreamingSSROptions { /** 페이지 타이틀 (SEO metadata 사용 시 자동 설정됨) */ title?: string; /** HTML lang 속성 */ lang?: string; /** 라우트 ID */ routeId?: string; /** 라우트 패턴 */ routePattern?: string; /** * Issue #233 — layout chain (outer → inner). Emitted as * `data-mandu-layout=""` on `
` so the SPA * navigation helper can detect cross-layout transitions. */ layoutChain?: string[]; /** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */ criticalData?: Record; // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨 /** Hydration 설정 */ hydration?: HydrationConfig; /** 번들 매니페스트 */ bundleManifest?: BundleManifest; /** React element already contains its own island wrapper. */ islandPreWrapped?: boolean; /** 추가 head 태그 (SEO metadata와 병합됨) */ headTags?: string; /** * SEO 메타데이터 (Layout 체인 또는 단일 객체) * - 배열: [rootLayout, ...nestedLayouts, page] 순서로 병합 * - 객체: 단일 정적 메타데이터 */ metadata?: MetadataItem[] | Metadata; /** 라우트 파라미터 (동적 메타데이터용) */ routeParams?: Record; /** 쿼리 파라미터 (동적 메타데이터용) */ searchParams?: Record; /** 개발 모드 여부 */ isDev?: boolean; /** HMR 포트 */ hmrPort?: number; /** Client-side Router 활성화 */ enableClientRouter?: boolean; /** Streaming 타임아웃 (ms) - 전체 스트림 최대 시간 */ streamTimeout?: number; /** Shell 렌더링 후 콜백 (TTFB 측정 시점) */ onShellReady?: () => void; /** 모든 컨텐츠 렌더링 후 콜백 */ onAllReady?: () => void; /** * Shell 전 에러 콜백 * - React 초기 렌더링 중 에러 발생 시 호출 * - 이 시점에서는 이미 스트림이 시작됨 (500 반환 불가) * - 로깅/모니터링 용도 */ onShellError?: (error: StreamingError) => void; /** * 스트리밍 중 에러 콜백 * - Shell 전송 후 에러 발생 시 호출 * - 에러 스크립트가 HTML에 자동 삽입됨 * - 클라이언트에서 mandu:streaming-error 이벤트로 감지 가능 */ onStreamError?: (error: StreamingError) => void; /** 에러 콜백 (deprecated - onShellError/onStreamError 사용 권장) */ onError?: (error: Error) => void; /** 메트릭 콜백 (observability) */ onMetrics?: (metrics: StreamingMetrics) => void; /** * HTML 닫기 태그 생략 여부 (내부용) * true이면 을 생략하여 deferred 스크립트 삽입 지점 확보 */ _skipHtmlClose?: boolean; /** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */ cssPath?: string | false; /** * Phase 7.2 R1 Agent C (H1) — Content-Security-Policy nonce for the * Fast Refresh inline preamble. Mirrors `SSROptions.cspNonce`: * - `true` → auto-generate fresh 128-bit base64 nonce * - non-empty string → use caller-provided nonce verbatim * - `false` / unset → no nonce attribute / CSP header (legacy) * Also forced off when `MANDU_CSP_NONCE=0` env is set. * Dev + hydration + populated `shared.fastRefresh` only. */ cspNonce?: string | boolean; /** * Issue #192 — emit `` * into the streaming shell ``. Mirrors `SSROptions.transitions`. * Default: `true`. */ transitions?: boolean; /** * Issue #192 — emit the ~500-byte hover prefetch helper ``; } /** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */ export const _testOnly_shouldInjectDevtoolsStreaming = shouldInjectDevtoolsStreaming; /** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */ export const _testOnly_generateStreamingDevtoolsScript = generateStreamingDevtoolsScript; export interface StreamingLoaderResult { /** 즉시 로드할 Critical 데이터 */ critical?: T; /** 지연 로드할 Deferred 데이터 (Promise) */ deferred?: Promise; } // ========== Serialization Guards ========== /** * 값이 JSON-serializable인지 검증 * Date, Map, Set, BigInt 등은 serializeProps에서 처리되지만 * 함수, Symbol, undefined는 문제가 됨 */ function isJSONSerializable(value: unknown, path: string = "root", isDev: boolean = false): { valid: boolean; issues: string[] } { const issues: string[] = []; const seen = new WeakSet(); function check(val: unknown, currentPath: string): void { if (val === undefined) { issues.push(`${currentPath}: undefined는 JSON으로 직렬화할 수 없습니다`); return; } if (val === null) return; const type = typeof val; if (type === "function") { issues.push(`${currentPath}: function은 JSON으로 직렬화할 수 없습니다`); return; } if (type === "symbol") { issues.push(`${currentPath}: symbol은 JSON으로 직렬화할 수 없습니다`); return; } if (type === "bigint") { // serializeProps에서 처리됨 - 경고만 if (isDev) { console.warn(`[Mandu Streaming] ${currentPath}: BigInt가 감지됨 - 문자열로 변환됩니다`); } return; } if (val instanceof Date || val instanceof Map || val instanceof Set || val instanceof URL || val instanceof RegExp) { // serializeProps에서 처리됨 return; } if (Array.isArray(val)) { val.forEach((item, index) => check(item, `${currentPath}[${index}]`)); return; } if (type === "object") { // 순환 참조 감지 — 무한 재귀 방지 if (seen.has(val as object)) { issues.push(`${currentPath}: 순환 참조가 감지되었습니다 (JSON 직렬화 불가)`); return; } seen.add(val as object); for (const [key, v] of Object.entries(val as Record)) { check(v, `${currentPath}.${key}`); } return; } // string, number, boolean은 OK } check(value, path); return { valid: issues.length === 0, issues, }; } /** * criticalData 검증 및 경고 * 개발 모드에서는 throw, 프로덕션에서는 경고만 */ function validateCriticalData(data: Record | undefined, isDev: boolean): void { if (!data) return; const result = isJSONSerializable(data, "criticalData", isDev); if (!result.valid) { const message = `[Mandu Streaming] criticalData 직렬화 문제:\n${result.issues.join("\n")}`; if (isDev) { throw new Error(message); } else { console.error(message); } } } // ========== Streaming Warnings ========== /** * Streaming 경고 상태 (module-level, globalThis as any 제거) */ const streamingWarnings = { _warned: false, markWarned() { this._warned = true; }, hasWarned() { return this._warned; }, }; /** * 프록시/버퍼링 관련 경고 (개발 모드) */ function warnStreamingCaveats(isDev: boolean): void { if (!isDev) return; console.log(`[Mandu Streaming] 💡 Streaming SSR 주의사항: - nginx/cloudflare 등 reverse proxy 사용 시 버퍼링 비활성화 필요 (nginx: proxy_buffering off; X-Accel-Buffering: no) - compression 미들웨어가 chunk를 모으면 스트리밍 이점 사라짐 - Transfer-Encoding: chunked 헤더가 유지되어야 함`); } // ========== Error HTML Generation ========== /** * 스트리밍 중 에러 시 삽입할 에러 스크립트 생성 * Shell 이후 에러는 이 방식으로 클라이언트에 전달 */ function generateErrorScript(error: Error, routeId: string): string { const safeMessage = escapeJsString(error.message); const safeRouteId = escapeJsString(routeId); return ``; } // ========== Suspense Wrappers ========== /** * Island를 Suspense로 감싸는 래퍼 * Streaming SSR에서 Island별 점진적 렌더링 지원 */ export function SuspenseIsland({ children, fallback, routeId, priority = "visible", bundleSrc, }: { children: ReactNode; fallback?: ReactNode; routeId: string; priority?: HydrationPriority; bundleSrc?: string; }): ReactElement { const hydrate = priorityToHydrateStrategy(priority); const defaultFallback = React.createElement("div", { "data-mandu-island": routeId, "data-mandu-priority": priority, "data-hydrate": hydrate, "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc, "data-mandu-loading": "true", style: { display: "contents", minHeight: "50px" }, }, React.createElement("div", { className: "mandu-loading-skeleton", style: { background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)", backgroundSize: "200% 100%", animation: "mandu-shimmer 1.5s infinite", height: "100%", minHeight: "50px", borderRadius: "4px", }, })); return React.createElement( Suspense, { fallback: fallback || defaultFallback }, React.createElement("div", { "data-mandu-island": routeId, "data-mandu-priority": priority, "data-hydrate": hydrate, "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc, style: { display: "contents" }, }, children) ); } function priorityToHydrateStrategy(priority: HydrationPriority): string { return priority === "immediate" ? "load" : priority; } /** * Deferred 데이터를 위한 Suspense 컴포넌트 * 데이터가 준비되면 children 렌더링 */ export function DeferredData({ promise, children, fallback, }: { promise: Promise; children: (data: T) => ReactNode; fallback?: ReactNode; }): ReactElement { // React 18 use() 훅 대신 Suspense + throw promise 패턴 사용 const AsyncComponent = React.lazy(async () => { const data = await promise; return { default: () => React.createElement(React.Fragment, null, children(data)), }; }); return React.createElement( Suspense, { fallback: fallback || React.createElement("span", null, "Loading...") }, React.createElement(AsyncComponent, null) ); } // ========== HTML Generation ========== /** * Streaming용 HTML Shell 생성 ( ~
) */ // ============================================================================ // Phase 7.2 R1 Agent C (H1) — CSP nonce helpers. Mirror the ones in // ssr.ts but live locally here so Streaming SSR does not depend on // internals of the non-streaming path. Keep the implementations in // sync; the single source of truth is documented in the ssr.ts // equivalents. // ============================================================================ /** 16-byte (128-bit) base64 nonce, matching csp.ts#resolveNonce. */ function generateStreamingCspNonce(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } /** * Resolve the nonce option honoring `MANDU_CSP_NONCE=0` opt-out. See * `ssr.ts#resolveFastRefreshCspNonce` for the rationale. */ function resolveStreamingCspNonce( opt: StreamingSSROptions["cspNonce"], ): string | undefined { try { if (typeof process !== "undefined" && process.env && process.env.MANDU_CSP_NONCE === "0") { return undefined; } } catch { /* sandboxed process access — treat as unset */ } if (opt === false || opt === undefined) return undefined; if (typeof opt === "string" && opt.length > 0) return opt; if (opt === true) return generateStreamingCspNonce(); return undefined; } /** Mirror of ssr.ts#buildFastRefreshCspHeader. */ function buildStreamingCspHeader(nonce: string): string { const safe = nonce.replace(/["\\\r\n]/g, ""); return `script-src 'self' 'nonce-${safe}' 'strict-dynamic'`; } /** * WeakMap that ferries the nonce chosen inside `renderToStream` back * to `renderStreamingResponse` / `renderWithDeferredData` so they can * emit the matching CSP header on the Response. Keyed by the options * object identity — typical usage shares the same instance across * the streaming pipeline. WeakMap entries GC naturally. */ const STREAMING_OPTIONS_TO_NONCE = new WeakMap(); /** @internal test helper — use only from unit tests. */ export const _testOnly_generateStreamingCspNonce = generateStreamingCspNonce; /** @internal test helper — use only from unit tests. */ export const _testOnly_resolveStreamingCspNonce = resolveStreamingCspNonce; /** @internal test helper — use only from unit tests. */ export const _testOnly_buildStreamingCspHeader = buildStreamingCspHeader; /** @internal test helper — use only from unit tests. */ export function _testOnly_getStreamingAttachedCspNonce( options: StreamingSSROptions, ): string | undefined { return STREAMING_OPTIONS_TO_NONCE.get(options as object); } function generateHTMLShell(options: StreamingSSROptions): string { const { title = "Mandu App", lang = "ko", headTags = "", bundleManifest, routeId, hydration, cssPath, isDev = false, transitions = true, prefetch = true, spa = true, layoutChain, islandPreWrapped = false, } = options; // Issue #233 — layout-key for SPA cross-layout detection. Mirror of the // block in `ssr.ts::renderToHTML`; see that call-site for the full // rationale. Streaming path must stamp the same attribute so the SPA // helper's layout-mismatch heuristic is consistent across modes. let layoutKey = ""; if (layoutChain && layoutChain.length > 0) { let hash = 0; const joined = layoutChain.join("|"); for (let i = 0; i < joined.length; i++) { hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0; } layoutKey = (hash >>> 0).toString(16).padStart(8, "0"); } const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : ""; // CSS 링크 태그 생성 // - cssPath가 string이면 해당 경로 사용 // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지) const cssLinkTag = cssPath ? `` : ""; // Issue #192 — Smooth navigation primitives. Mirror of the block in // `ssr.ts::renderToHTML`; see that call-site for the full rationale. // Positioned right after the user stylesheet so the at-rule parses // alongside it, and before user `headTags` so users can override with // an inline style later in the document order. const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : ""; const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : ""; // Issue #208 — Inline SPA-nav IIFE, mirror of `ssr.ts::renderToHTML`. // See that call-site for the full rationale. Streaming SSR follows // the same opt-out contract: `spa !== false` injects, `spa: false` // omits the ``; } // Loading skeleton 애니메이션 스타일 (hydration 필요 시에만) const loadingStyles = !needsHydration ? "" : ` `; let islandOpenTag = ""; const hasRouteBundle = !!(needsHydration && bundleManifest.bundles[routeId]?.js); if (needsHydration) { const bundle = bundleManifest.bundles[routeId]; const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : ""; const priority = hydration.priority || "visible"; const hydrate = priorityToHydrateStrategy(priority); if (hasRouteBundle && !islandPreWrapped) { islandOpenTag = `
`; } } // Phase 7.1 R2 Agent D: Fast Refresh preamble. Must land in // BEFORE any island script evaluates — the stubs it installs for // $RefreshReg$ / $RefreshSig$ are required by every module the // bundler transformed with `reactFastRefresh: true`. Dev mode only; // prod manifests omit `shared.fastRefresh` so `fr` is undefined and // we emit no preamble (HTML stays byte-identical to pre-7.1 prod). // // Phase 7.2 R1 Agent C (H1): when `cspNonce` is enabled, resolve // the nonce up-front, ferry it to the response layer via the // WeakMap, and rewrite the preamble's ``); scripts.push(``); } // 2. 라우트 정보 스크립트 if (enableClientRouter && routeId) { const routeInfo = { id: routeId, pattern: routePattern || "", params: {}, streaming: true, }; const json = escapeJsonForInlineScript(JSON.stringify(routeInfo)); scripts.push(``); } // 3. Streaming 완료 마커 (클라이언트 hydration에서 감지용) scripts.push(``); // 4. Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화) if (bundleManifest.shared.vendor) { scripts.push(``); } if (bundleManifest.importMap?.imports) { const imports = bundleManifest.importMap.imports; if (imports["react-dom"] && imports["react-dom"] !== bundleManifest.shared.vendor) { scripts.push(``); } if (imports["react-dom/client"]) { scripts.push(``); } } // 5. Runtime modulepreload (hydration 실행 전 미리 로드) if (bundleManifest.shared.runtime) { scripts.push(``); } // 6. Island modulepreload const routeIslands = bundleManifest.islands ? Object.values(bundleManifest.islands).filter((island) => island.route === routeId) : []; if (routeIslands.length > 0) { for (const island of routeIslands) { const cacheBust = `${island.js}${island.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } else { const bundle = bundleManifest.bundles[routeId]; if (bundle) { const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } if (bundleManifest.partials) { for (const partial of Object.values(bundleManifest.partials)) { const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } if (bundleManifest.boundaries) { for (const boundary of Object.values(bundleManifest.boundaries)) { if (boundary.route !== routeId) continue; const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } // 7. Runtime 로드 if (bundleManifest.shared.runtime) { scripts.push(``); } // 7.5 React internals shim (must run before react-dom/client runs) scripts.push(REACT_INTERNALS_SHIM_SCRIPT); // 8. Router 스크립트 if (enableClientRouter && bundleManifest.shared?.router) { scripts.push(``); } } // 9. #179: body 내 태그를 로 호이스팅 (외부 폰트/스타일시트 지원) scripts.push(``); // 10. HMR 스크립트 (개발 모드 — Zero-JS 페이지에서도 CSS 핫리로드 지원) if (isDev && hmrPort) { scripts.push(generateHMRScript(hmrPort)); } // 11. Issue #191 + #259 — DevTools 번들 (~1.15 MB) 주입 결정. // - 기본 (dev only): 항상 주입. SSR-only 랜딩에서도 Kitchen 사용 가능. // - `devtools === false` → 강제 스킵 (필요 시 1.15 MB 절약). // - 프로덕션 빌드는 `isDev` 가드로 0 bytes 보장. // - Cache-bust (`?v=buildTime`) 로 HMR 후 stale 방지. if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) { scripts.push(generateStreamingDevtoolsScript(bundleManifest)); } // Island wrapper 닫기 (hydration이 필요한 경우) const islandCloseTag = needsHydration && !islandPreWrapped && bundleManifest.bundles[routeId]?.js ? "
" : ""; return `${islandCloseTag}
${scripts.join("\n ")}`; } /** * HTML 문서 닫기 태그 * Deferred 스크립트 삽입 후 호출 */ function generateHTMLClose(): string { return ` `; } /** * Streaming용 HTML Tail 생성 ( ~ ) * 하위 호환성 유지 - 내부적으로 generateHTMLTailContent + generateHTMLClose 사용 */ function generateHTMLTail(options: StreamingSSROptions): string { return generateHTMLTailContent(options) + generateHTMLClose(); } /** * Deferred 데이터 인라인 스크립트 생성 * Streaming 중에 데이터 도착 시 DOM에 주입 */ function generateDeferredDataScript(routeId: string, key: string, data: unknown): string { const json = escapeJsonForInlineScript(serializeProps({ [key]: data })); const safeRouteId = escapeJsString(routeId); const safeKey = escapeJsString(key); return ``; } /** * HMR 스크립트 생성 * ssr.ts의 generateHMRScript와 동일한 구현을 유지해야 함 (#114) * * Phase 7.2 — mirrors the HDR (Hot Data Revalidation) extension in * `ssr.ts:generateHMRScript`. Keep in sync. */ function generateHMRScript(port: number): string { const hmrPort = port + PORTS.HMR_OFFSET; return ``; } // ========== Main Streaming Functions ========== /** * React 컴포넌트를 ReadableStream으로 렌더링 * Bun/Web Streams API 기반 * * 핵심 원칙: * - Shell은 즉시 전송 (TTFB 최소화) * - allReady는 메트릭용으로만 사용 (대기 안 함) * - Shell 전 에러는 throw → Response 레이어에서 500 처리 * - Shell 후 에러는 에러 스크립트 삽입 */ export async function renderToStream( element: ReactElement, options: StreamingSSROptions = {} ): Promise> { mark("ssr:render"); const { onShellReady, onAllReady, onShellError, onStreamError, onError, onMetrics, isDev = false, routeId = "unknown", criticalData, streamTimeout, } = options; // 메트릭 수집 const metrics: StreamingMetrics = { shellReadyTime: 0, allReadyTime: 0, deferredChunkCount: 0, hasError: false, startTime: Date.now(), }; // criticalData 직렬화 검증 (dev에서는 throw) validateCriticalData(criticalData, isDev); // 스트리밍 주의사항 경고 (첫 요청 시 1회만) if (isDev && !streamingWarnings.hasWarned()) { warnStreamingCaveats(isDev); streamingWarnings.markWarned(); } const encoder = new TextEncoder(); const collectedHeadTags = renderWithManduClientBoundaryManifest( options.routeId, options.bundleManifest, () => collectStreamingHeadTags(element), ); const resolvedOptions = collectedHeadTags ? { ...options, headTags: [options.headTags, collectedHeadTags].filter(Boolean).join("\n") } : options; const htmlShell = generateHTMLShell(resolvedOptions); // Reset SSR head before real render so streaming components push fresh tags try { const headMod = require("../client/use-head") as { resetSSRHead?: () => void; getSSRHeadTags?: () => string }; headMod.resetSSRHead?.(); } catch {} // Lazy tail: collect late head tags injected during streaming render function buildHtmlTail(): string { const baseTail = resolvedOptions._skipHtmlClose ? generateHTMLTailContent(resolvedOptions) : generateHTMLTail(resolvedOptions); try { const headMod = require("../client/use-head") as { getSSRHeadTags?: () => string }; const lateHeadTags = headMod.getSSRHeadTags?.() ?? ""; if (lateHeadTags) { const escaped = escapeJsonForInlineScript(JSON.stringify(lateHeadTags)); const script = ``; return script + baseTail; } } catch {} return baseTail; } let shellSent = false; let timedOut = false; // Issue #198 — `renderToReadableStream` natively supports async server // components in React 19. If `export default async function Page()` // lands here directly (without going through `server.ts`'s // `resolveAsyncElement` pre-pass), React's streaming pipeline // suspends on the awaiting component and flushes a fallback until // the promise resolves. `collectStreamingHeadTags` above uses the // sync `renderToString` and will throw on async trees — its // try/catch safely returns an empty string in that case, and any // `useHead`-pushed tags from async components are instead picked // up by `buildHtmlTail` on the way out. No additional wiring is // needed on this code path. // 실패 시 throw → renderStreamingResponse에서 500 처리 const renderToReadableStream = getRenderToReadableStream(); const renderBoundaryScope = createManduClientBoundaryRenderScope( routeId, resolvedOptions.bundleManifest, ); const scopedElement = renderBoundaryScope.wrapElement(element); const reactStream = await renderBoundaryScope(() => renderToReadableStream(scopedElement, { onError: (error: Error) => { if (timedOut) return; metrics.hasError = true; const streamingError: StreamingError = { error, isShellError: !shellSent, recoverable: shellSent, timestamp: Date.now(), }; console.error("[Mandu Streaming] React render error:", error); if (!shellSent) { // Shell 전 에러 - 콜백만 호출 (throw는 하지 않음, 이미 스트림 시작됨) onShellError?.(streamingError); } else { // Shell 후 에러 - 스트림에 에러 스크립트 삽입됨 onStreamError?.(streamingError); } onError?.(error); }, }), ); // allReady는 백그라운드에서 메트릭용으로만 사용 (대기 안 함!) renderBoundaryScope(() => { reactStream.allReady.then(() => { metrics.allReadyTime = Date.now() - metrics.startTime; if (isDev) { console.log(`[Mandu Streaming] All ready: ${routeId} (${metrics.allReadyTime}ms)`); } }).catch(() => { // 에러는 onError에서 이미 처리됨 }); }); // Custom stream으로 래핑 (Shell + React Content + Tail) let tailSent = false; const reader = reactStream.getReader(); const deadline = streamTimeout && streamTimeout > 0 ? metrics.startTime + streamTimeout : null; async function readWithTimeout(): Promise | null> { if (!deadline) { return renderBoundaryScope(() => reader.read() as Promise> ); } const remaining = deadline - Date.now(); if (remaining <= 0) { return null; } let timeoutId: ReturnType | null = null; const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => { timeoutId = setTimeout(() => resolve({ kind: "timeout" }), remaining); }); const readPromise = renderBoundaryScope(() => reader .read() .then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult })) .catch((error: unknown) => ({ kind: "error" as const, error })) ); const result = await Promise.race([readPromise, timeoutPromise]); if (result.kind === "timeout") { return null; } if (timeoutId) clearTimeout(timeoutId); if (result.kind === "error") { throw result.error; } return result.result; } return new ReadableStream({ async start(controller) { // Shell 즉시 전송 (TTFB 최소화의 핵심!) controller.enqueue(encoder.encode(htmlShell)); shellSent = true; metrics.shellReadyTime = Date.now() - metrics.startTime; measure("ssr:render", "ssr:render"); onShellReady?.(); }, async pull(controller) { try { const readResult = await readWithTimeout(); // 타임아웃 발생 if (!readResult) { const timeoutError = new Error(`Stream timeout: exceeded ${streamTimeout}ms`); metrics.hasError = true; timedOut = true; if (isDev) { console.warn(`[Mandu Streaming] Stream timeout after ${streamTimeout}ms`); } const streamingError: StreamingError = { error: timeoutError, isShellError: false, recoverable: true, timestamp: Date.now(), }; onStreamError?.(streamingError); controller.enqueue(encoder.encode(generateErrorScript(timeoutError, routeId))); if (!tailSent) { controller.enqueue(encoder.encode(buildHtmlTail())); tailSent = true; metrics.allReadyTime = Date.now() - metrics.startTime; onMetrics?.(metrics); } controller.close(); try { const cancelPromise = reader.cancel(); if (cancelPromise) { cancelPromise.catch(() => {}); } } catch {} return; } const { done, value } = readResult; if (done) { if (!tailSent) { controller.enqueue(encoder.encode(buildHtmlTail())); tailSent = true; // allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록 if (metrics.allReadyTime === 0) { metrics.allReadyTime = Date.now() - metrics.startTime; } onAllReady?.(); onMetrics?.(metrics); } controller.close(); return; } // React 컨텐츠를 그대로 스트리밍 controller.enqueue(value); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); metrics.hasError = true; console.error("[Mandu Streaming] Pull error:", err); // Shell 후 에러 - 에러 스크립트 삽입 const streamingError: StreamingError = { error: err, isShellError: false, recoverable: true, timestamp: Date.now(), }; onStreamError?.(streamingError); controller.enqueue(encoder.encode(generateErrorScript(err, routeId))); if (!tailSent) { controller.enqueue(encoder.encode(buildHtmlTail())); tailSent = true; metrics.allReadyTime = Date.now() - metrics.startTime; onMetrics?.(metrics); } controller.close(); } }, cancel() { try { const cancelPromise = reader.cancel(); if (cancelPromise) { cancelPromise.catch(() => {}); } } catch {} }, }); } function collectStreamingHeadTags(element: ReactElement): string { try { const mod = require("../client/use-head") as { resetSSRHead?: () => void; getSSRHeadTags?: () => string; }; mod.resetSSRHead?.(); const renderToString = getRenderToString(); renderToString(element); return mod.getSSRHeadTags?.() ?? ""; } catch { return ""; } } /** * Streaming SSR Response 생성 * * 헤더 설명: * - X-Accel-Buffering: no - nginx 버퍼링 비활성화 * - Cache-Control: no-transform - 중간 프록시 변환 방지 * * 주의: Transfer-Encoding은 설정하지 않음 * - WHATWG Response 환경에서 런타임이 자동 처리 * - 명시적 설정은 오히려 문제 될 수 있음 * * 에러 정책: * - renderToReadableStream 자체가 throw (stream 생성 실패) * → 여기서 catch → 500 Response 반환 (유일한 500 케이스) * - React onError 콜백 호출 (렌더링 중 에러) * → StreamingError로 래핑 → 콜백 호출 * → 스트림은 계속 진행 (부분 렌더링 or 에러 스크립트 삽입) */ export async function renderStreamingResponse( element: ReactElement, options: StreamingSSROptions = {} ): Promise { try { const stream = await renderToStream(element, options); // Phase 7.2 R1 Agent C (H1): if a CSP nonce was produced during // shell generation, emit the matching header so the inline // preamble is authorized under strict CSP. const nonce = STREAMING_OPTIONS_TO_NONCE.get(options as object); const baseHeaders: Record = { "Content-Type": "text/html; charset=utf-8", // Transfer-Encoding은 런타임이 자동 처리 (명시 안 함) "X-Content-Type-Options": "nosniff", // nginx 버퍼링 비활성화 힌트 "X-Accel-Buffering": "no", // 캐시 및 변환 방지 (Streaming은 동적) "Cache-Control": "no-store, no-transform", // CDN 힌트 "CDN-Cache-Control": "no-store", }; if (nonce) { baseHeaders["Content-Security-Policy"] = buildStreamingCspHeader(nonce); } return new Response(stream, { status: 200, headers: baseHeaders, }); } catch (error) { // renderToStream에서 throw된 에러 → 500 응답 (단일 책임) const err = error instanceof Error ? error : new Error(String(error)); console.error("[Mandu Streaming] Render failed:", err); // XSS 방지 const safeMessage = err.message .replace(//g, ">"); return new Response( ` 500 Server Error

500 Server Error

렌더링 중 오류가 발생했습니다.

${options.isDev ? `
${safeMessage}
` : ""}
`, { status: 500, headers: { "Content-Type": "text/html; charset=utf-8", }, } ); } } /** * Deferred 데이터와 함께 Streaming SSR 렌더링 * * 핵심 원칙: * - base stream은 즉시 시작 (TTFB 최소화) * - deferred는 병렬로 처리하되 스트림을 막지 않음 * - 준비된 deferred만 tail 이후에 스크립트로 주입 */ export async function renderWithDeferredData( element: ReactElement, options: StreamingSSROptions & { deferredPromises?: Record>; /** Deferred 타임아웃 (ms) - 이 시간 안에 resolve되지 않으면 포기 */ deferredTimeout?: number; } ): Promise { const { deferredPromises = {}, deferredTimeout = 5000, routeId = "default", onMetrics, isDev = false, ...restOptions } = options; const streamTimeout = options.streamTimeout; const encoder = new TextEncoder(); const startTime = Date.now(); // 준비된 deferred 스크립트를 담을 배열 (mutable) const readyScripts: string[] = []; let allDeferredSettled = false; // 1. Deferred promises 병렬 시작 (막지 않음!) const deferredEntries = Object.entries(deferredPromises); const deferredSettledPromise = deferredEntries.length > 0 ? Promise.allSettled( deferredEntries.map(async ([key, promise]) => { try { // 타임아웃 적용 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Deferred timeout: ${key}`)), deferredTimeout) ); const data = await Promise.race([promise, timeoutPromise]); // 스크립트 생성 및 추가 const script = generateDeferredDataScript(routeId, key, data); readyScripts.push(script); if (isDev) { console.log(`[Mandu Streaming] Deferred ready: ${key} (${Date.now() - startTime}ms)`); } } catch (error) { console.error(`[Mandu Streaming] Deferred error for ${key}:`, error); } }) ).then(() => { allDeferredSettled = true; }) : Promise.resolve().then(() => { allDeferredSettled = true; }); // 2. Base stream 즉시 시작 (TTFB 최소화의 핵심!) // _skipHtmlClose: true로 생략 → deferred 스크립트 삽입 지점 확보 let baseMetrics: StreamingMetrics | null = null; const baseStream = await renderToStream(element, { ...restOptions, routeId, isDev, _skipHtmlClose: true, // deferred 스크립트를 전에 삽입하기 위해 onMetrics: (metrics) => { baseMetrics = metrics; }, }); // 3. 수동 스트림 파이프라인 (Bun pipeThrough 호환성 문제 해결) // base stream을 읽고 → 변환 후 → 새 스트림으로 출력 const reader = baseStream.getReader(); const finalStream = new ReadableStream({ async pull(controller) { try { const { done, value } = await reader.read(); if (!done && value) { // base stream chunk 그대로 전달 controller.enqueue(value); return; } // base stream 완료 → flush 로직 실행 // deferred가 아직 안 끝났으면 잠시 대기 (단, deferredTimeout 내에서만) if (!allDeferredSettled) { const elapsed = Date.now() - startTime; let remainingTime = deferredTimeout - elapsed; if (streamTimeout && streamTimeout > 0) { const remainingStream = streamTimeout - elapsed; remainingTime = Math.min(remainingTime, remainingStream); } remainingTime = Math.max(0, remainingTime); if (remainingTime > 0) { await Promise.race([ deferredSettledPromise, new Promise(resolve => setTimeout(resolve, remainingTime)), ]); } } // 준비된 deferred 스크립트만 주입 (실제 enqueue 기준 카운트) let injectedCount = 0; for (const script of readyScripts) { controller.enqueue(encoder.encode(script)); injectedCount++; } if (isDev && injectedCount > 0) { console.log(`[Mandu Streaming] Injected ${injectedCount} deferred scripts`); } // HTML 닫기 태그 추가 () controller.enqueue(encoder.encode(generateHTMLClose())); // 최종 메트릭 보고 (injectedCount가 실제 메트릭) if (onMetrics && baseMetrics) { onMetrics({ ...baseMetrics, deferredChunkCount: injectedCount, allReadyTime: Date.now() - startTime, }); } controller.close(); } catch (error) { controller.error(error); } }, cancel() { void reader.cancel(); }, }); // Phase 7.2 R1 Agent C (H1): if the base stream produced a CSP // nonce during shell emission, forward the matching header on the // deferred Response as well. const deferredNonce = STREAMING_OPTIONS_TO_NONCE.get(options as object); const deferredHeaders: Record = { "Content-Type": "text/html; charset=utf-8", "X-Content-Type-Options": "nosniff", "X-Accel-Buffering": "no", "Cache-Control": "no-store, no-transform", "CDN-Cache-Control": "no-store", }; if (deferredNonce) { deferredHeaders["Content-Security-Policy"] = buildStreamingCspHeader(deferredNonce); } return new Response(finalStream, { status: 200, headers: deferredHeaders, }); } // ========== Loader Helpers ========== /** * Streaming Loader 헬퍼 * Critical과 Deferred 데이터를 분리하여 반환 * * @example * ```typescript * export const loader = createStreamingLoader(async (ctx) => { * return { * critical: await getEssentialData(ctx), * deferred: fetchOptionalData(ctx), // Promise 그대로 전달 * }; * }); * ``` */ export function createStreamingLoader( loaderFn: (ctx: unknown) => Promise> ) { return async (ctx: unknown) => { const result = await loaderFn(ctx); return { critical: result.critical, deferred: result.deferred, }; }; } /** * Deferred 데이터 프라미스 래퍼 * Streaming 중 데이터 준비되면 클라이언트로 전송 */ export function defer(promise: Promise): Promise { return promise; } // ========== SEO Integration ========== /** * SEO 메타데이터와 함께 Streaming SSR 렌더링 * * Layout 체인에서 메타데이터를 자동으로 수집하고 병합하여 * HTML head에 삽입합니다. * * @example * ```typescript * // 정적 메타데이터 * const response = await renderWithSEO(, { * metadata: { * title: 'Home', * description: 'Welcome to my site', * openGraph: { type: 'website' }, * }, * }) * * // Layout 체인 메타데이터 * const response = await renderWithSEO(, { * metadata: [ * layoutMetadata, // { title: { template: '%s | Site' } } * pageMetadata, // { title: 'Blog Post' } * ], * routeParams: { slug: 'hello' }, * }) * // → title: "Blog Post | Site" * ``` */ export async function renderWithSEO( element: ReactElement, options: StreamingSSROptions = {} ): Promise { const { metadata, routeParams, searchParams, ...restOptions } = options; // SEO 메타데이터 처리 if (metadata) { const seoOptions: SEOOptions = { routeParams, searchParams, }; // 배열이면 Layout 체인, 아니면 단일 메타데이터 if (Array.isArray(metadata)) { seoOptions.metadata = metadata; } else { seoOptions.staticMetadata = metadata as Metadata; } // SEO를 옵션에 주입 const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions); return renderStreamingResponse(element, optionsWithSEO); } // SEO 없이 기본 렌더링 return renderStreamingResponse(element, restOptions); } /** * Deferred 데이터 + SEO 메타데이터와 함께 Streaming SSR 렌더링 * * @example * ```typescript * const response = await renderWithDeferredDataAndSEO(, { * metadata: { * title: post.title, * openGraph: { images: [post.image] }, * }, * deferredPromises: { * comments: fetchComments(postId), * related: fetchRelatedPosts(postId), * }, * }) * ``` */ export async function renderWithDeferredDataAndSEO( element: ReactElement, options: StreamingSSROptions & { deferredPromises?: Record>; deferredTimeout?: number; } = {} ): Promise { const { metadata, routeParams, searchParams, ...restOptions } = options; // SEO 메타데이터 처리 if (metadata) { const seoOptions: SEOOptions = { routeParams, searchParams, }; if (Array.isArray(metadata)) { seoOptions.metadata = metadata; } else { seoOptions.staticMetadata = metadata as Metadata; } const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions); return renderWithDeferredData(element, optionsWithSEO); } return renderWithDeferredData(element, restOptions); } // ========== Exports ========== export { generateHTMLShell, generateHTMLTail, generateDeferredDataScript, }; // Re-export SEO integration utilities export { resolveSEO, injectSEOIntoOptions } from "../seo/integration/ssr"; export type { SEOOptions, SEOResult } from "../seo/integration/ssr";