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 =
/`;
}
/**
* 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);
}