import { z } from "zod"; // ========== Hydration 설정 ========== export const SpecHydrationStrategy = z.enum(["none", "island", "full", "progressive"]); export type SpecHydrationStrategy = z.infer; export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]); export type HydrationPriority = z.infer; export const HydrationConfig = z.object({ /** * Hydration 전략 * - none: 순수 Static HTML (JS 없음) * - island: Slot 영역만 hydrate (기본값) * - full: 전체 페이지 hydrate * - progressive: 점진적 hydrate */ strategy: SpecHydrationStrategy, /** * Hydration 우선순위 * - immediate: 페이지 로드 즉시 * - visible: 뷰포트에 보일 때 (기본값) * - idle: 브라우저 idle 시 * - interaction: 사용자 상호작용 시 */ priority: HydrationPriority.default("visible"), /** * 번들 preload 여부 */ preload: z.boolean().default(false), }); export type HydrationConfig = z.infer; // ========== Client Boundary Metadata ========== export const RouteClientBoundarySource = z.object({ file: z.string().min(1), line: z.number().int().positive(), column: z.number().int().positive(), }); export type RouteClientBoundarySource = z.infer; export const RouteClientBoundary = z.object({ id: z.string().min(1), routeId: z.string().min(1), module: z.string().min(1), importSpecifier: z.string().min(1).optional(), exportName: z.string().min(1), localName: z.string().min(1), hydrate: z.string().min(1).default("visible"), ordinal: z.number().int().nonnegative(), propsSource: z.enum(["inline", "route-data", "data-props", "none", "unknown"]).default("inline"), propsKeys: z.array(z.string()).optional(), hasSpreadProps: z.boolean().optional(), source: RouteClientBoundarySource, }); export type RouteClientBoundary = z.infer; // ========== Loader 설정 ========== export const LoaderConfig = z.object({ /** * SSR 시 데이터 로딩 타임아웃 (ms) */ timeout: z.number().positive().default(5000), /** * 로딩 실패 시 fallback 데이터 */ fallback: z.record(z.unknown()).optional(), }); export type LoaderConfig = z.infer; // ========== Route 설정 ========== export const RouteKind = z.enum(["page", "api", "metadata"]); export type RouteKind = z.infer; /** * Metadata route kinds — one for each file-convention metadata file * detected under `app/`. Kept in sync with * `@mandujs/core/routes` → `MetadataRouteKind`. */ export const MetadataRouteKind = z.enum(["sitemap", "robots", "llms-txt", "manifest"]); export type MetadataRouteKind = z.infer; export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]); export type SpecHttpMethod = z.infer; // ---- 공통 필드 ---- const RouteSpecBase = { id: z.string().min(1, "id는 필수입니다"), pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"), module: z.string().min(1, "module 경로는 필수입니다"), slotModule: z.string().optional(), clientModule: z.string().optional(), clientExportName: z.string().optional(), boundaries: z.array(RouteClientBoundary).optional(), contractModule: z.string().optional(), hydration: HydrationConfig.optional(), loader: LoaderConfig.optional(), streaming: z.boolean().optional(), }; // ---- Static params (Issue #214) ---- // StaticParamSet values mirror `bundler/generate-static-params.ts` — // scalar params are strings, catch-all params are `string[]`. const StaticParamValue = z.union([z.string(), z.array(z.string())]); const StaticParamSet = z.record(StaticParamValue); export type StaticParamSetSchema = z.infer; // ---- Page 라우트 ---- export const PageRouteSpec = z .object({ ...RouteSpecBase, kind: z.literal("page"), // page 필수 componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"), // page 전용 optional methods: z.array(SpecHttpMethod).optional(), layoutChain: z.array(z.string()).optional(), loadingModule: z.string().optional(), errorModule: z.string().optional(), notFoundModule: z.string().optional(), /** * Issue #214 — when `false`, the runtime rejects dynamic URLs * whose params aren't in `staticParams` with a 404 instead of * falling through to SSR. Undefined or `true` preserves the * default "SSR on miss" behavior (Next.js parity). */ dynamicParams: z.boolean().optional(), /** * Issue #214 — populated at build time from `generateStaticParams`. * Consulted by the runtime #214 guard together with `dynamicParams` * to decide whether an incoming param set is allowed. Scalar values * are strings; catch-all values are string arrays. */ staticParams: z.array(StaticParamSet).optional(), }) .refine( (route) => { if (route.clientModule && route.hydration?.strategy === "none") { return false; } return true; }, { message: "clientModule이 있으면 hydration.strategy는 'none'이 아니어야 합니다", path: ["hydration"], } ); export type PageRouteSpec = z.infer; // ---- API 라우트 ---- export const ApiRouteSpec = z.object({ ...RouteSpecBase, kind: z.literal("api"), // api 전용 methods: z.array(SpecHttpMethod).optional(), // page 전용 필드도 optional로 허용 (호환성) componentModule: z.string().optional(), layoutChain: z.array(z.string()).optional(), loadingModule: z.string().optional(), errorModule: z.string().optional(), notFoundModule: z.string().optional(), }); export type ApiRouteSpec = z.infer; // ---- Metadata 라우트 (Issue #206) ---- // sitemap.ts / robots.ts / llms.txt.ts / manifest.ts — file-convention // metadata routes. Dispatched through `@mandujs/core/routes`. export const MetadataRouteSpec = z.object({ ...RouteSpecBase, kind: z.literal("metadata"), /** Which metadata file this entry represents. */ metadataKind: MetadataRouteKind, /** MIME type used on the served response. */ contentType: z.string().min(1), // Re-declare shared optional fields so downstream consumers that // switch on kind can still read them without type widening gymnastics. componentModule: z.string().optional(), methods: z.array(SpecHttpMethod).optional(), layoutChain: z.array(z.string()).optional(), loadingModule: z.string().optional(), errorModule: z.string().optional(), notFoundModule: z.string().optional(), }); export type MetadataRouteSpec = z.infer; // ---- discriminatedUnion ---- export const RouteSpec = z.discriminatedUnion("kind", [ // PageRouteSpec에 .refine()이 적용되어 있으므로 내부 shape를 직접 사용 z.object({ ...RouteSpecBase, kind: z.literal("page"), componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"), methods: z.array(SpecHttpMethod).optional(), layoutChain: z.array(z.string()).optional(), loadingModule: z.string().optional(), errorModule: z.string().optional(), notFoundModule: z.string().optional(), // Issue #214 — see PageRouteSpec for contract. Kept optional so // existing manifests load unchanged (default behavior: dynamic SSR). dynamicParams: z.boolean().optional(), staticParams: z.array(StaticParamSet).optional(), }), z.object({ ...RouteSpecBase, kind: z.literal("api"), methods: z.array(SpecHttpMethod).optional(), componentModule: z.string().optional(), layoutChain: z.array(z.string()).optional(), loadingModule: z.string().optional(), errorModule: z.string().optional(), notFoundModule: z.string().optional(), }), z.object({ ...RouteSpecBase, kind: z.literal("metadata"), metadataKind: MetadataRouteKind, contentType: z.string().min(1), componentModule: z.string().optional(), methods: z.array(SpecHttpMethod).optional(), layoutChain: z.array(z.string()).optional(), loadingModule: z.string().optional(), errorModule: z.string().optional(), notFoundModule: z.string().optional(), }), ]); export type RouteSpec = z.infer; // ========== Manifest ========== export const RoutesManifest = z .object({ version: z.number().int().positive(), routes: z.array(RouteSpec), }) .refine( (manifest) => { const ids = manifest.routes.map((r) => r.id); const uniqueIds = new Set(ids); return ids.length === uniqueIds.size; }, { message: "route id는 중복될 수 없습니다", path: ["routes"], } ) .refine( (manifest) => { const patterns = manifest.routes.map((r) => r.pattern); const uniquePatterns = new Set(patterns); return patterns.length === uniquePatterns.size; }, { message: "route pattern은 중복될 수 없습니다", path: ["routes"], } ); export type RoutesManifest = z.infer; // ========== Assertion Functions ========== /** * Asserts that the given route is a page route. * After this call, TypeScript narrows the type to PageRouteSpec. */ export function assertPageRoute(route: RouteSpec): asserts route is PageRouteSpec { if (route.kind !== "page") { throw new Error(`Expected page route, got "${route.kind}" (id: ${route.id})`); } } /** * Asserts that the given route is an API route. * After this call, TypeScript narrows the type to ApiRouteSpec. */ export function assertApiRoute(route: RouteSpec): asserts route is ApiRouteSpec { if (route.kind !== "api") { throw new Error(`Expected API route, got "${route.kind}" (id: ${route.id})`); } } /** * Asserts that the given route is a metadata route. * After this call, TypeScript narrows the type to MetadataRouteSpec. */ export function assertMetadataRoute(route: RouteSpec): asserts route is MetadataRouteSpec { if (route.kind !== "metadata") { throw new Error(`Expected metadata route, got "${route.kind}" (id: ${route.id})`); } } // ========== 유틸리티 함수 ========== /** * 기본 hydration 설정 반환 */ export function getDefaultHydration(route: RouteSpec): HydrationConfig { // clientModule이 있으면 island, 없으면 none if (route.clientModule) { return { strategy: "island", priority: "visible", preload: false, }; } return { strategy: "none", priority: "visible", preload: false, }; } /** * 라우트의 실제 hydration 설정 반환 (기본값 적용) */ export function getRouteHydration(route: RouteSpec): HydrationConfig { if (route.hydration) { return { strategy: route.hydration.strategy, priority: route.hydration.priority ?? "visible", preload: route.hydration.preload ?? false, }; } return getDefaultHydration(route); } /** * Hydration이 필요한 라우트인지 확인 */ export function needsHydration(route: RouteSpec): boolean { const hydration = getRouteHydration(route); // "none" 이외의 전략만 hydration 필요 (island의 "never"는 strategy가 "none"으로 매핑됨) return route.kind === "page" && hydration.strategy !== "none"; }