import type { Server, ServerWebSocket } from "bun"; import type { RoutesManifest, RouteSpec, HydrationConfig, StaticParamSetSchema } from "../spec/schema"; import type { BundleManifest } from "../bundler/types"; import type { ManduFilling, RenderMode } from "../filling/filling"; import { ManduContext, CookieManager } from "../filling/context"; import { Router } from "./router"; import { renderSSR, resolveAsyncElement } from "./ssr"; import { resolveMetadata, renderMetadata, renderTitle, type Metadata, type MetadataItem, type GenerateMetadata, } from "../seo"; import { type ErrorFallbackProps } from "./boundary"; import React from "react"; import path from "path"; import fs from "fs/promises"; import { PORTS } from "../constants"; import { type CacheStore, type CacheStoreStats, type CacheConfig, lookupCache, createCacheEntry, createCachedResponse, computeCacheControl, getCacheStoreStats, setGlobalCache, setGlobalCacheDefaults, getGlobalCacheDefaults, createCacheStoreFromConfig, } from "./cache"; import { createNotFoundResponse, createHandlerNotFoundResponse, createPageLoadErrorResponse, createSSRErrorResponse, errorToResponse, err, ok, type Result, } from "../error"; import { type CorsOptions, isPreflightRequest, handlePreflightRequest, applyCorsToResponse, isCorsRequest, } from "./cors"; import { validateImportPath } from "./security"; import { createRuntimeDevtoolsAdapter, recordRuntimeRequest, shouldRecordRuntimeRequest, type RuntimeDevtoolsAdapter, type RuntimeKitchenHandler, } from "./devtools-adapter"; import { createRuntimeObservabilityLifecycle, type RuntimeObservabilityLifecycle, } from "./observability-lifecycle"; import { handleOpenAPIRequest, isOpenAPIEndpointEnabled, resolveOpenAPIEndpointSettings, type OpenAPIEndpointSettings, } from "./openapi-endpoint"; import { type MiddlewareFn, type MiddlewareConfig, loadMiddlewareSync, } from "./middleware"; import { buildRequestMiddlewareChain, runRequestMiddleware, type ComposedHandler, } from "./request-middleware"; import type { Middleware } from "../middleware/define"; import { startRuntimeSchedulerLifecycle, type RuntimeSchedulerOptions, } from "./scheduler-lifecycle"; import { createFetchHandler } from "./handler"; import { wrapBunWebSocket, type WSHandlers, type WSUpgradeData } from "../filling/ws"; import { maybeHandleImageFeatureRequest } from "./image-feature"; import { serveStaticFile, computeStrongEtag, computeStaticCacheControl, matchesEtag, } from "./static-files"; export { __clearStaticEtagCacheForTests } from "./static-files"; import { extractShellHtml, createPPRResponse } from "./ppr"; import { renderPageResponse, type InlineClientHydrationTarget } from "./page-render-response"; import { isRedirectResponse } from "./redirect"; import { isNotFoundResponse } from "./not-found"; import { newId } from "../id"; // Phase 18.κ — typed RPC dispatch (tRPC-like). See // `packages/core/src/contract/rpc.ts` + `docs/architect/typed-rpc.md`. import { matchRpcPath, dispatchRpc, registerRpc, clearRpcRegistry, type RpcDefinition, type RpcProcedureRecord, } from "../contract/rpc"; import type { GuardConfig } from "../guard/types"; import type { ManduHooks, ManduPlugin } from "../plugins/hooks"; import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes"; import { DEFAULT_PRERENDER_DIR, DEFAULT_PRERENDER_CACHE_CONTROL, loadPrerenderIndex, resolvePrerenderedFile, type PrerenderIndex, } from "../bundler/prerender"; import { buildOverlayErrorHtml, buildPayloadFromError, OVERLAY_CUSTOM_EVENT, shouldInjectOverlay, } from "../dev-error-overlay"; // Phase 18.μ — i18n dispatch. `resolveLocale()` is pure (no side effects), // `createTranslator()` binds a per-request `t()` to the registry. import { resolveLocale, createTranslator, isI18nDefinition, isMessageRegistry, } from "../i18n"; import type { I18nDefinition, ResolvedLocale, Translator, } from "../i18n/types"; import type { MessageRegistry } from "../i18n/message-registry"; import { appendRateLimitHeaders, createRateLimitResponse, MemoryRateLimiter, normalizeRateLimitOptions, type NormalizedRateLimitOptions, type RateLimitOptions, } from "./rate-limit"; export { createRateLimiter, type RateLimitDecision, type RateLimitOptions, } from "./rate-limit"; // ========== Server Options ========== export interface ServerOptions { port?: number; hostname?: string; /** 프로젝트 루트 디렉토리 */ rootDir?: string; /** 개발 모드 여부 */ isDev?: boolean; /** HMR 포트 (개발 모드에서 사용) */ hmrPort?: number; /** 번들 매니페스트 (Island hydration용) */ bundleManifest?: BundleManifest; /** Public 디렉토리 경로 (기본: 'public') */ publicDir?: string; /** * CORS 설정 * - true: 모든 Origin 허용 * - false: CORS 비활성화 (기본값) * - CorsOptions: 세부 설정 */ cors?: boolean | CorsOptions; /** * Streaming SSR 활성화 * - true: 모든 페이지에 Streaming SSR 적용 * - false: 기존 renderToString 사용 (기본값) */ streaming?: boolean; /** * API 라우트 Rate Limit 설정 */ rateLimit?: boolean | RateLimitOptions; /** * CSS 파일 경로 (SSR 링크 주입용) * - string: 해당 경로로 주입 (예: "/.mandu/client/globals.css") * - false: CSS 링크 주입 비활성화 (Tailwind 미사용 시) * - undefined: false로 처리 (404 방지, dev/build에서 명시적 전달 필요) */ cssPath?: string | false; /** * 커스텀 레지스트리 (핸들러/설정 분리) * - 제공하지 않으면 기본 전역 레지스트리 사용 * - 테스트나 멀티앱 시나리오에서 createServerRegistry()로 생성한 인스턴스 전달 */ registry?: ServerRegistry; /** * Guard config for Kitchen dev dashboard (dev mode only) */ guardConfig?: GuardConfig | null; /** * SSR 캐시 설정 (ISR/SWR 용). * * Phase 18.ζ — `CacheConfig` 객체를 추가로 지원한다. * - `true` : 기본 메모리 캐시 (LRU 1000 엔트리) * - `CacheStore` : 커스텀 캐시 구현체 (e.g. redis 어댑터) * - `CacheConfig` : `{ defaultMaxAge, defaultSwr, maxEntries, store }` * 전역 기본값을 설정 — loader 가 `_cache` 를 안 내도 * 자동으로 캐싱됨 (Next.js `export const revalidate` 등가). * - `false`/undefined : 캐시 비활성화 */ cache?: boolean | CacheStore | CacheConfig; /** * Internal management token for local CLI/runtime control endpoints. * When set, token-protected endpoints such as `/_mandu/cache` become available. */ managementToken?: string; /** * Phase 18.κ — override initial `.mandu/client/*` health state when * starting a dev server. */ clientBundleHealthy?: boolean; /** Optional failure reason surfaced by dev overlay when unhealthy. */ clientBundleFailureReason?: string; /** Optional route ID associated with the latest client-bundle failure. */ clientBundleFailureRouteId?: string; /** * Issue #192 — enable CSS View Transitions auto-inject (default `true`). * When `true`, every SSR response gets * `` in its ``, * giving supported browsers a default crossfade on cross-document * navigation. Pass `false` to suppress (typically wired from * `ManduConfig.transitions`). */ transitions?: boolean; /** * Issue #192 — enable the hover prefetch helper (default `true`). * When `true`, every SSR response gets a ~500-byte inline script that * prefetches same-origin links on hover. Pass `false` to suppress * (typically wired from `ManduConfig.prefetch`). Individual links can * also opt out via `data-no-prefetch`. */ prefetch?: boolean; /** * Issue #193 / #208 — enable opt-out SPA navigation (default `true`). * When `true`, every SSR response gets (a) the `window.__MANDU_SPA__` * global elided (the router's default) and (b) the inline SPA-nav * IIFE (~1.6 KB) that intercepts internal `` clicks with pushState * + fetch + View-Transitions DOM-swap, so `hydration: "none"` projects * still feel like a SPA. `false` reverts to legacy full-reload (and * the full router's opt-in `data-mandu-link` requirement). Wired from * `ManduConfig.spa`; per-link opt-out lives on `data-no-spa`. */ spa?: boolean; /** * Issue #191 — override dev-mode `_devtools.js` injection. * Wired from `ManduConfig.dev.devtools`. * - `true` → force inject on every page (SSR-only + Kitchen). * - `false` → force skip on every page. * - `undefined` → default. Inject iff the page's route has at least * one island. Pure-SSR pages download zero devtools. */ devtools?: boolean; /** * Phase 17 — observability endpoints. * - `heapEndpoint` → `/_mandu/heap` JSON dump of process.memoryUsage() * + registered cache sizes. In dev this is on by * default; in prod it requires `MANDU_DEBUG_HEAP=1` * OR this flag set to `true`. * - `metricsEndpoint` → `/_mandu/metrics` Prometheus text exposition. * Same gating as `heapEndpoint`. * Passing `false` for either in dev force-disables it (useful for * isolating test environments that count listeners). */ observability?: { heapEndpoint?: boolean; metricsEndpoint?: boolean; /** * Phase 18.θ — OpenTelemetry-compatible request tracing. See * `@mandujs/core/observability` {@link import("../observability/tracing").TracerConfig} * for field semantics. `undefined` leaves tracing disabled; * `{ enabled: true }` opens a root span for every request and * propagates the trace context across `await`s via * AsyncLocalStorage. The `MANDU_OTEL_ENDPOINT` env var forces * tracing on with the OTLP exporter when the config is omitted. */ tracing?: { enabled?: boolean; exporter?: "console" | "otlp"; endpoint?: string; headers?: Record; serviceName?: string; }; }; /** * Production-grade OpenAPI endpoint. * * When enabled, the runtime serves the contracts-derived OpenAPI 3.0.3 * document at `.json` / `.yaml` (default base path * `/__mandu/openapi`). The body is loaded once from the build-time * artifacts under `.mandu/openapi.{json,yaml}` (emitted by * `mandu build`) and cached for the lifetime of the server. * * - `enabled` — default `false`. Security-conscious: production * deployments must explicitly opt in, or set * `MANDU_OPENAPI_ENABLED=1` in the environment. * - `path` — base URL path (with or without `.json` suffix). * Default `/__mandu/openapi`. * * Wired from `ManduConfig.openapi`. The response carries * `Cache-Control: public, max-age=0, must-revalidate` + a SHA-256 * ETag so CDNs / browsers revalidate on every deploy without holding * stale specs. */ openapi?: { enabled?: boolean; path?: string; }; /** * Phase 18 — prerendered HTML pass-through (SSG). * * When enabled (default), the server looks for a prerender index * under `//_manifest.json` (written by `mandu build`) * and, for every request whose pathname maps to a prerendered file, * serves that HTML directly, bypassing SSR entirely, with a * conditional-GET friendly `Cache-Control` + strong `ETag` pair. * * - `true` enabled with defaults (dir `.mandu/prerendered`, * Cache-Control `public, max-age=0, must-revalidate` * — Issue #221; prerendered URLs are stable, so * `immutable` would pin stale HTML across deploys). * - `false` disabled. Every request goes through SSR. * - object overrides. `dir` chooses a different output; * `cacheControl` lets adapters tune the CDN hint. * * Wired from `ManduConfig.build.prerender`; see * `@mandujs/core/bundler/prerender` for the build-side contract. */ prerender?: | boolean | { dir?: string; cacheControl?: string; }; /** * Phase 18.ε — canonical request-level middleware chain. * * Middleware execute in declaration order (outermost first) BEFORE * route dispatch. Each layer may short-circuit by returning a * Response without calling `next()`, or wrap the downstream Response * after `next()` returns. Composed via `compose()`. * * Typically wired from `ManduConfig.middleware`. See * `@mandujs/core/middleware` for `defineMiddleware` / `compose` * helpers plus bridge wrappers (`csrfMiddleware`, `sessionMiddleware`, * `secureMiddleware`, `rateLimitMiddleware`). */ middleware?: Middleware[]; /** * Phase 18.τ — plugins contributing `defineMiddlewareChain()` + * lifecycle observers. Plugin middleware are PREPENDED to * `options.middleware` so they run BEFORE user-declared layers. Both * fields are optional; omission is a zero-overhead passthrough. */ plugins?: readonly ManduPlugin[]; configHooks?: Partial; /** * Phase 18.κ — tRPC-like typed RPC endpoints. * * Keys map to `/api/rpc//` routes. Each value is a * `defineRpc()` result (see `@mandujs/core/contract/rpc`). Populating * this field at `startServer()` time registers every endpoint with * the global RPC registry; the dispatcher runs BEFORE β's route * matcher so RPC routes never collide with file-system API routes. * * Typically threaded from `ManduConfig.rpc.endpoints`. */ rpc?: { endpoints?: Record>; }; /** * Phase 18.λ — declarative cron scheduler. * * When `jobs` is non-empty and `disabled !== true`, `startServer()` * instantiates a `CronRegistration` via `defineCron(jobs)`, calls * `.start()` after the HTTP listener is bound, and wires the handle * into `stop()` so the returned `ManduServer.stop()` also drains any * in-flight cron tick before returning. Jobs whose `runOn` omits * `"bun"` are registered but never fire on the local Bun host — they * still appear in `status()` so dashboards can render their existence. * * Typically threaded from `ManduConfig.scheduler`. */ scheduler?: RuntimeSchedulerOptions; /** * Phase 18.μ — first-class i18n. Threaded from `ManduConfig.i18n`. * * When populated, the runtime dispatcher: * 1. resolves the active locale BEFORE route dispatch (via * {@link resolveLocale}) using the configured strategy; * 2. attaches `ctx.locale` (ResolvedLocale) + `ctx.t` (typed * translator) to every loader / handler invocation; * 3. stamps `Vary: Accept-Language` + `Content-Language` on all * page responses so upstream caches key on locale; * 4. when `strategy === "path-prefix"` and the incoming URL * carries no locale prefix AND no override cookie, redirects * `/` → `/` so browsers reach a locale-scoped * URL (Next.js parity). * * Omitting this block keeps the runtime branch-free — the hot path * incurs zero overhead when i18n is disabled. */ i18n?: I18nDefinition; /** * Phase 18.μ — optional message registry bound to `ctx.t`. Created via * `defineMessages({ en: {...}, ko: {...} })`. When omitted but `i18n` * is set, `ctx.locale` is populated but `ctx.t` stays `undefined` — * projects that manage their own translation layer (e.g. react-intl) * use `ctx.locale.code` and ignore `ctx.t`. */ messages?: MessageRegistry; /** * Issue #217 — suppress the "🥟 Mandu server listening"/"🥟 Mandu Dev * Server listening" banner printed at boot. The HTTP listener still * binds and `ManduServer.server.port` still reports the chosen port; * only the stdout banner (plus its auxiliary lines — HMR hint, CORS * hint, static-file hint, streaming hint, Kitchen hint, "also * reachable at" hint) is gated. * * Intended for internal callers such as the build-time prerender * orchestrator that spin up a transient listener on an ephemeral * port (`port: 0`) and tear it down seconds later — the banner's * URL is always wrong by the time a human (or an LLM reading build * logs) tries to curl it, so printing it causes confusion. * * User-facing commands (`mandu dev`, `mandu start`) leave this * `undefined` / `false` to preserve the normal banner. Default: * `false`. */ silent?: boolean; } export interface ManduServer { server: Server; router: Router; /** 이 서버 인스턴스의 레지스트리 */ registry: ServerRegistry; /** Replace the live dispatch table after FS routes change in dev mode. */ updateManifest: (nextManifest: RoutesManifest) => void; stop: () => void; } export type ApiHandler = (req: Request, params: Record) => Response | Promise; export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record }> }>; /** * Layout 컴포넌트 타입 * children을 받아서 감싸는 구조 */ export type LayoutComponent = React.ComponentType<{ children: React.ReactNode; params?: Record; }>; /** * Layout 로더 타입 */ export type LayoutLoader = () => Promise<{ default: LayoutComponent }>; /** * Loading 컴포넌트 타입 */ export type LoadingComponent = React.ComponentType>; /** * Error 컴포넌트 타입 */ export type ErrorComponent = React.ComponentType; /** * Loading/Error 로더 타입 */ export type LoadingLoader = () => Promise<{ default: LoadingComponent }>; export type ErrorLoader = () => Promise<{ default: ErrorComponent }>; /** * Page 등록 정보 * - component: React 컴포넌트 * - filling: Slot의 ManduFilling 인스턴스 (loader 포함) */ export interface PageRegistration { component: React.ComponentType<{ params: Record; searchParams: Record; loaderData?: unknown; __manduHydration?: InlineClientHydrationTarget; }>; filling?: ManduFilling; /** #186: page 모듈의 static `metadata` export (선택) */ metadata?: Metadata; /** #186: page 모듈의 `generateMetadata` 함수 export (선택) */ generateMetadata?: GenerateMetadata; } /** * Page Handler - 컴포넌트와 filling을 함께 반환 */ export type PageHandler = () => Promise; /** * Issue #206 — Metadata route handler. A thunk that returns the * imported user module (or its `default` export) so the dispatcher * in `handleMetadataRoute` can invoke the user-supplied function with * the correct Content-Type + cache headers. The indirection keeps dev * HMR cheap: the handler only imports on the request that needs it. */ export type MetadataHandler = () => Promise; export interface AppContext { routeId: string; url: string; params: Record; /** 요청 URL의 쿼리스트링 (generateMetadata와 동일한 값) */ searchParams: Record; /** SSR loader에서 로드한 데이터 */ loaderData?: unknown; __manduHydration?: InlineClientHydrationTarget; } type RouteComponent = (props: { params: Record; searchParams: Record; loaderData?: unknown; __manduHydration?: InlineClientHydrationTarget; }) => React.ReactElement; type CreateAppFn = (context: AppContext) => React.ReactElement; // ========== Server Registry (인스턴스별 분리) ========== /** * 서버 인스턴스별 핸들러/설정 레지스트리 * 같은 프로세스에서 여러 서버를 띄울 때 핸들러가 섞이는 문제 방지 */ export interface ServerRegistrySettings { isDev: boolean; hmrPort?: number; bundleManifest?: BundleManifest; rootDir: string; publicDir: string; cors?: CorsOptions | false; streaming: boolean; rateLimit?: NormalizedRateLimitOptions | false; /** * Internal flag controlled by the CLI dev bundler lifecycle. * `false` disables serving `.mandu/client/*` while the latest * client bundle build is unhealthy. */ clientBundleHealthy?: boolean; /** Last client bundle failure message emitted by the dev bundler. */ clientBundleFailureReason?: string; /** Route ID associated with the last client bundle failure. */ clientBundleFailureRouteId?: string; /** * CSS 파일 경로 (SSR 링크 주입용) * - string: 해당 경로로 주입 * - false: CSS 링크 주입 비활성화 * - undefined: false로 처리 (404 방지) */ cssPath?: string | false; /** ISR/SWR 캐시 스토어 */ cacheStore?: CacheStore; /** Internal management token for local runtime control */ managementToken?: string; /** * Edge runtime flag — disables filesystem-dependent features (static file * serving, Kitchen dashboard, image optimization, SSG fallback loaders). * Set by `@mandujs/edge` adapters (Cloudflare Workers, Deno Deploy, Vercel Edge). * Default: false (Bun/Node runtime with full FS access). */ edge?: boolean; /** * Issue #192 — threaded from `ServerOptions.transitions`. * `undefined` is treated as `true` at the SSR call-site (enabled by * default); `false` suppresses the `