import { getRenderToString } from "./react-renderer"; import { serializeProps } from "../client/serialize"; import { createRequire } from "module"; import React, { type ReactElement, type ReactNode } from "react"; import type { BundleManifest } from "../bundler/types"; import { isSafeManduUrl } from "../bundler/manifest-schema"; import type { HydrationConfig, HydrationPriority } from "../spec/schema"; import { PORTS, TIMEOUTS } from "../constants"; import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape"; import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims"; 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 { maybeInjectDevOverlay } from "../dev-error-overlay"; import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary"; /** * Issue #192 — `@view-transition` at-rule block. * Inert in browsers without CSS View Transitions (Firefox, Safari < 18.0): * the at-rule is simply ignored, so there is no regression. Supporting * browsers (Chrome/Edge ≥ 111, Safari 18.2+) play the default crossfade * between cross-document navigations. * * `navigation: auto` is the only value we need — selective transitions * are a per-route concern reserved for a future `transitions` config * sub-block. */ const VIEW_TRANSITION_STYLE_TAG = ""; // Re-export streaming SSR utilities export { renderToStream, renderStreamingResponse, renderWithDeferredData, SuspenseIsland, DeferredData, createStreamingLoader, defer, type StreamingSSROptions, type StreamingLoaderResult, type StreamingError, type StreamingMetrics, } from "./streaming-ssr"; export interface SSROptions { title?: string; lang?: string; /** 서버에서 로드한 데이터 (클라이언트로 전달) */ serverData?: unknown; /** Hydration 설정 */ hydration?: HydrationConfig; /** 번들 매니페스트 */ bundleManifest?: BundleManifest; /** 라우트 ID (island 식별용) */ routeId?: string; /** * Issue #233 — layout chain applied to this render, ordered outer → inner. * Serialized into `data-mandu-layout` on `
` so the SPA * navigation helper can detect cross-layout transitions and fall back * to a hard navigation instead of a soft `
` swap (which would * leave stale layout chrome like a sidebar from the source layout). * When omitted or empty the attribute is dropped — same-layout nav * remains cheap and visually crossfaded. */ layoutChain?: string[]; /** 추가 head 태그 */ headTags?: string; /** 추가 body 끝 태그 */ bodyEndTags?: string; /** 개발 모드 여부 */ isDev?: boolean; /** HMR 포트 (개발 모드에서 사용) */ hmrPort?: number; /** Client-side Routing 활성화 여부 */ enableClientRouter?: boolean; /** 라우트 패턴 (Client-side Routing용) */ routePattern?: string; /** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */ cssPath?: string | false; /** Island 래핑이 이미 React 엘리먼트 레벨에서 완료됨 (중복 래핑 방지) */ islandPreWrapped?: boolean; /** * Phase 7.2 R1 Agent C (H1) — Content-Security-Policy nonce for the * Fast Refresh inline preamble. Three accepted shapes: * - `true` → auto-generate a fresh 128-bit base64 nonce * per-render and insert it as the `nonce` * attribute on the preamble ` `; } /** * Import map 생성 (bare specifier 해결용) */ function generateImportMap(manifest: BundleManifest): string { if (!manifest.importMap || Object.keys(manifest.importMap.imports).length === 0) { return ""; } const importMapJson = escapeJsonForInlineScript(JSON.stringify(manifest.importMap, null, 2)); return ``; } /** * Hydration 스크립트 태그 생성 * v0.9.0: vendor, runtime 모두 modulepreload로 성능 최적화 */ function generateHydrationScripts( routeId: string, manifest: BundleManifest ): string { const scripts: string[] = []; // Import map 먼저 (반드시 module scripts 전에 위치해야 함) const importMap = generateImportMap(manifest); if (importMap) { scripts.push(importMap); } // Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화) if (manifest.shared.vendor) { scripts.push(``); } if (manifest.importMap?.imports) { const imports = manifest.importMap.imports; // react-dom, react-dom/client 등 추가 preload if (imports["react-dom"] && imports["react-dom"] !== manifest.shared.vendor) { scripts.push(``); } if (imports["react-dom/client"]) { scripts.push(``); } } // Runtime modulepreload (hydration 실행 전 미리 로드) if (manifest.shared.runtime) { scripts.push(``); } // Island 번들 modulepreload (성능 최적화 - prefetch only) // Per-island bundles take precedence when available const routeIslands = manifest.islands ? Object.values(manifest.islands).filter((ib) => ib.route === routeId) : []; if (routeIslands.length > 0) { for (const ib of routeIslands) { const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } else { // Fallback: route-level bundle (backward compat) const bundle = manifest.bundles[routeId]; if (bundle) { const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } if (manifest.partials) { for (const partial of Object.values(manifest.partials)) { const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } if (manifest.boundaries) { for (const boundary of Object.values(manifest.boundaries)) { if (boundary.route !== routeId) continue; const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`; scripts.push(``); } } // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용) if (manifest.shared.runtime) { scripts.push(``); } return scripts.join("\n"); } /** * Island 래퍼로 컨텐츠 감싸기 * v0.8.0: data-mandu-src 속성 추가 (Runtime이 dynamic import로 로드) * * Phase 18.δ — emit `data-hydrate` alongside legacy `data-mandu-priority`. * The new attribute is the canonical per-island hydration strategy spec * read by `packages/core/src/client/hydrate.ts::parseHydrateStrategy`. * * Mapping of legacy priorities → Astro-grade strategies: * - `immediate` → `load` (hydrate right away) * - `visible` → `visible` (IntersectionObserver, 200px rootMargin) * - `idle` → `idle` (requestIdleCallback) * - `interaction` → `interaction` (click/touchstart/keydown) * * `hydrate` (the formalized strategy spec, including `media()`) * takes precedence when provided; otherwise we derive `data-hydrate` from * `priority` for backward compatibility. `data-mandu-priority` stays so * existing bundler-generated runtimes and dev-tools keep working. */ export function wrapWithIsland( content: string, routeId: string, priority: HydrationPriority = "visible", bundleSrc?: string, hydrate?: string ): string { const cacheBustedSrc = bundleSrc ? `${bundleSrc}?t=${Date.now()}` : undefined; const srcAttr = cacheBustedSrc ? ` data-mandu-src="${escapeHtmlAttr(cacheBustedSrc)}"` : ""; const hydrateValue = hydrate ?? priorityToHydrateStrategy(priority); const hydrateAttr = ` data-hydrate="${escapeHtmlAttr(hydrateValue)}"`; return `
${content}
`; } /** * Legacy `HydrationPriority` → Phase 18.δ `data-hydrate` strategy name. * * Kept local to ssr.ts (no public export) because the mapping is purely an * attribute-emission concern; callers that know the new spec pass `hydrate` * directly to `wrapWithIsland`. */ function priorityToHydrateStrategy(priority: HydrationPriority): string { switch (priority) { case "immediate": return "load"; case "visible": return "visible"; case "idle": return "idle"; case "interaction": return "interaction"; default: return "load"; } } /** * Phase 7.1 R2 Agent D — Fast Refresh preamble emission helper. * Phase 7.2 R1 Agent C (H1) — optional CSP nonce injection. * * Emits the `` : ""; // #179: body 내 태그를 로 호이스팅 // React 컴포넌트(layout.tsx 등)에서 를 렌더링하면 body 안에 위치하게 되는데, // 폰트/스타일시트는 에 있어야 FOUT 없이 로드됨 const linkTagPattern = /]*(?:rel=["'](?:stylesheet|preconnect|preload|icon|dns-prefetch)["'][^>]*|href=["'][^"']+["'][^>]*)\/?\s*>/gi; const hoistedLinks: string[] = []; const bodyAfterLinks = content.replace(linkTagPattern, (match) => { hoistedLinks.push(match); return ""; }); const hoistedLinkTags = hoistedLinks.join("\n "); // #317: hoist body (og:*, twitter:*, description, …) and JSON-LD into // . The legacy renderToString path does not perform React 19's // document-metadata hoisting, so without this og/twitter cards land in the // body where crawlers ignore them. Mirrors React 19's rule: is // hoistable document metadata EXCEPT microdata (`itemprop`), which is // content and must stay where it was authored. const metaTagPattern = /]*?\/?>/gi; const hoistedMetas: string[] = []; const bodyAfterMeta = bodyAfterLinks.replace(metaTagPattern, (match) => { if (/\bitemprop[\s=]/i.test(match)) return match; hoistedMetas.push(match); return ""; }); const hoistedMetaTags = hoistedMetas.join("\n "); const ldJsonPattern = /]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi; const hoistedLdJson: string[] = []; const bodyContent = bodyAfterMeta.replace(ldJsonPattern, (match) => { hoistedLdJson.push(match); return ""; }); const hoistedLdJsonTags = hoistedLdJson.join("\n "); // #273 F15 — React 19 renders document metadata such as from a // page component into the body string in this SSR path. Hoist the first // body title into <head> when no metadata/generateMetadata title was // provided, and always strip body titles to avoid duplicate/invalid HTML. let effectiveTitle = title; const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i; const bodyTitleMatch = bodyContent.match(titleTagPattern); const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, ""); if (!hasExplicitTitle && bodyTitleMatch) { effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title); } // Phase 18.α — Dev Error Overlay injection. // Only emitted when `isDev` AND the user has not opted out (via // `ManduConfig.dev.errorOverlay: false` → `devErrorOverlay: false`). // The injector itself re-checks `NODE_ENV !== "production"` as a // belt-and-suspenders guard, so prod HTML is byte-identical // regardless of whether this option is wired. const devErrorOverlayTag = maybeInjectDevOverlay({ isDev, enabled: devErrorOverlay, }); return `<!doctype html> <html lang="${escapeHtmlAttr(lang)}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${escapeHtmlText(effectiveTitle)} ${cssLinkTag} ${viewTransitionTag} ${prefetchScriptTag} ${spaNavHelperTag} ${hoistedLinkTags} ${hoistedMetaTags} ${hoistedLdJsonTags} ${headTags} ${collectedHeadTags} ${fastRefreshPreamble} ${devErrorOverlayTag}
${bodyWithoutTitle}
${dataScript} ${routeScript} ${hydrationScripts} ${needsHydration ? REACT_INTERNALS_SHIM_SCRIPT : ""} ${spaFlagScript} ${routerScript} ${hmrScript} ${devtoolsScript} ${bodyEndTags} `; } /** * Client-side Routing: 현재 라우트 정보 스크립트 생성 */ function generateRouteScript( routeId: string, pattern: string, _serverData?: unknown ): string { const routeInfo = { id: routeId, pattern, params: extractParamsFromUrl(pattern), }; const json = escapeJsonForInlineScript(JSON.stringify(routeInfo)); return ``; } /** * URL 패턴에서 파라미터 추출 (클라이언트에서 사용) */ function extractParamsFromUrl(_pattern: string): Record { // 서버에서는 실제 params를 전달받으므로 빈 객체 반환 // 실제 params는 serverData나 별도 전달 return {}; } /** * Client-side Router 스크립트 로드 */ function generateClientRouterScript(manifest: BundleManifest): string { // Import map 먼저 (이미 hydration에서 추가되었을 수 있음) const scripts: string[] = []; // 라우터 번들이 있으면 로드 if (manifest.shared?.router) { scripts.push(``); } return scripts.join("\n"); } /** * HMR 스크립트 생성 * * Phase 7.2 Agent B — HDR (Hot Data Revalidation) extension. * When the CLI broadcasts `{type: "vite", payload: {type: "custom", * event: "mandu:slot-refetch", data: {routeId, slotPath, ...}}}` (via * `hmrServer.broadcastVite`) we handle it here without remounting * the React tree: fetch the current URL with `X-Mandu-HDR: 1`, * receive JSON loader data, and hand it to the router's * `applyHDRUpdate` hook (installed by `initializeRouter`). If the * route doesn't match, the router hook is missing, or the fetch * fails — we fall back to `location.reload()`. See the detailed * design notes in `bundler/dev.ts:generateHMRClientScript` docstring. */ function generateHMRScript(port: number): string { const hmrPort = port + PORTS.HMR_OFFSET; return ``; } /** * Issue #191 + #259 — Determine whether the dev-only `_devtools.js` * bundle (~1.15 MB React dev runtime + Kitchen panel) should be injected * into the HTML response. The caller gates with `isDev`, so this only * affects dev-mode responses (prod always skips, regardless of the * decision below). * * Decision table (`devtools` option × manifest shape): * * | `devtools` | hasIslands | inject? | rationale | * |-------------|------------|---------|------------------------------| * | `true` | any | YES | explicit opt-in | * | `false` | any | NO | explicit opt-out | * | `undefined` | true | YES | default, hydration runtime | * | `undefined` | false | YES | dev DX: Kitchen on SSR pages | * | `undefined` | no manifest| YES | dev DX: Kitchen pre-build | * * #259 reverted the original #191 default (skip when no islands): SSR-only * landing/marketing pages are the exact place where Kitchen network/error * panels are most needed, and the 1.15 MB cost only applies in dev — prod * builds never emit `_devtools.js`. Users who explicitly want the old * behavior on a per-app basis can still set `dev.devtools: false`. * * @internal Exported via `_testOnly_shouldInjectDevtools` below so * `tests/runtime/devtools-inject.test.ts` can table-test the matrix * without mounting React. */ function shouldInjectDevtools( devtools: boolean | undefined, _manifest: BundleManifest | undefined, ): boolean { if (devtools === true) return true; if (devtools === false) return false; return true; } /** * Issue #191 — DevTools 번들 로드 스크립트 생성 (개발 모드 전용). * * `_devtools.js` 번들이 자체적으로 `initManduKitchen()` 을 호출한다. * * Cache-bust: `?v=${manifest.buildTime}` 을 우선 사용하고, manifest 가 없으면 * `?t=${Date.now()}` 로 fallback. `buildTime` 은 빌드별로 고정이라 브라우저 * 캐시 효율이 좋지만, 동일 빌드 안에서 HMR 이 발생하더라도 devtools 번들은 * dev 서버의 static 응답이 `Cache-Control: no-cache, no-store, must-revalidate` * (server.ts:1104) 를 보내므로 stale 위험이 없다. * * @internal Exported via `_testOnly_generateDevtoolsScript` below so tests * can verify the URL shape without needing a full render. */ function generateDevtoolsScript(manifest?: BundleManifest): string { const cacheBust = manifest?.buildTime ? `?v=${encodeURIComponent(manifest.buildTime)}` : `?t=${Date.now()}`; return ``; } /** @internal test helper — exposed only so unit tests can inspect the decision. */ export const _testOnly_shouldInjectDevtools = shouldInjectDevtools; /** @internal test helper — exposed only so unit tests can inspect the script tag. */ export const _testOnly_generateDevtoolsScript = generateDevtoolsScript; export function createHTMLResponse( html: string, status: number = 200, /** * Phase 7.2 R1 Agent C (H1) — extra headers to merge in alongside * the default `Content-Type`. Used for the Fast Refresh CSP header * when a nonce was produced; can be repurposed for future * per-response metadata without changing callsites. */ extraHeaders?: Record, ): Response { const headers: Record = { "Content-Type": "text/html; charset=utf-8", }; if (extraHeaders) { for (const [k, v] of Object.entries(extraHeaders)) { headers[k] = v; } } return new Response(html, { status, headers }); } export function renderSSR(element: ReactElement, options: SSROptions = {}): Response { const html = renderToHTML(element, options); // Phase 7.2 R1 Agent C (H1): if a CSP nonce was produced during // rendering (dev + fast-refresh path), emit the matching header so // the inline preamble is authorized under strict CSP. const nonce = _testOnly_getAttachedCspNonce(options); const extra = nonce ? { "Content-Security-Policy": buildFastRefreshCspHeader(nonce) } : undefined; return createHTMLResponse(html, 200, extra); } /** * Hydration이 포함된 SSR 렌더링 * * @example * ```typescript * const response = await renderWithHydration( * , * { * title: "할일 목록", * routeId: "todos", * serverData: { todos }, * hydration: { strategy: "island", priority: "visible" }, * bundleManifest, * } * ); * ``` */ export async function renderWithHydration( element: ReactElement, options: SSROptions & { routeId: string; serverData: unknown; hydration: HydrationConfig; bundleManifest: BundleManifest; } ): Promise { const html = renderToHTML(element, options); // Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR. const nonce = _testOnly_getAttachedCspNonce(options); const extra = nonce ? { "Content-Security-Policy": buildFastRefreshCspHeader(nonce) } : undefined; return createHTMLResponse(html, 200, extra); }