/** * Mandu Filling - ๋งŒ๋‘์†Œ ๐ŸฅŸ * ์ฒด์ด๋‹ API๋กœ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ •์˜ * * DNA-002: ์˜์กด์„ฑ ์ฃผ์ž… ํŒจํ„ด ์ง€์› */ import { ManduContext, ValidationError, BadRequestError } from "./context"; import { AuthenticationError, AuthorizationError } from "./auth"; import { type FillingDeps, globalDeps } from "./deps"; import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error"; import { TIMEOUTS } from "../constants"; import { createContract, type ContractDefinition, type ContractInstance } from "../contract"; import type { WSHandlers } from "./ws"; import { type Middleware as RuntimeMiddleware, type MiddlewareEntry, compose, } from "../runtime/compose"; import { type LifecycleStore, type OnRequestHandler, type OnParseHandler, type BeforeHandleHandler, type AfterHandleHandler, type MapResponseHandler, type OnErrorHandler, type AfterResponseHandler, createLifecycleStore, executeLifecycle, type ExecuteOptions, } from "../runtime/lifecycle"; import type { SlotMetadata, SlotConstraints } from "../guard/semantic-slots"; import { DeployIntentInput, type DeployIntentInput as DeployIntentInputType, } from "../deploy/intent"; /** Handler function type */ export type Handler = (ctx: ManduContext) => Response | Promise; /** Guard function type (alias of BeforeHandle) */ export type Guard = BeforeHandleHandler; /** HTTP methods */ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; /** ๋ฏธ๋“ค์›จ์–ด ํ”Œ๋Ÿฌ๊ทธ์ธ (์—ฌ๋Ÿฌ lifecycle ๋‹จ๊ณ„๋ฅผ ์กฐํ•ฉ) */ export interface MiddlewarePlugin { beforeHandle?: BeforeHandleHandler; afterHandle?: AfterHandleHandler; mapResponse?: MapResponseHandler; } /** * Loader function type โ€” SSR data loader. * * Returns the route's data object `T`, or a `Response` to short-circuit * the SSR pipeline (DX-3). A returned `Response` with a redirect-range * status code becomes the final response โ€” SSR is skipped and any pending * `ctx.cookies` are merged into the outgoing headers. Use the * `redirect(url)` helper for the common case; throwing a `Response` is * also accepted (Remix idiom). */ export type LoaderResult = T | Response; export type Loader = ( ctx: ManduContext ) => LoaderResult | Promise>; /** * Page-level SSR data reader. Prefer this name in public docs when the * distinction from mutation actions or schema contracts matters. */ export type RouteDataLoader = Loader; /** Loader ์‹คํ–‰ ์˜ต์…˜ */ export interface LoaderOptions { /** ํƒ€์ž„์•„์›ƒ (ms), ๊ธฐ๋ณธ๊ฐ’ 5000 */ timeout?: number; /** ํƒ€์ž„์•„์›ƒ ๋˜๋Š” ์—๋Ÿฌ ์‹œ ๋ฐ˜ํ™˜ํ•  fallback ๋ฐ์ดํ„ฐ */ fallback?: T; } /** Loader ์บ์‹œ/ISR ์˜ต์…˜ */ export interface LoaderCacheOptions { /** * Fresh ์บ์‹œ ์œ ์ง€ ์‹œ๊ฐ„ (์ดˆ). `maxAge` ์˜ ๋ณ„์นญ โ€” Next.js `revalidate` * API ์™€ ํ˜ธํ™˜์„ฑ์„ ์œ ์ง€ํ•œ๋‹ค. 0 ์ด๋ฉด ์บ์‹œ ์•ˆ ํ•จ. */ revalidate?: number; /** * Phase 18.ฮถ โ€” fresh ์ฐฝ์„ ์ง€๋‚œ ๋’ค ์บ์‹œ๋ฅผ ๊ณ„์† ์„œ๋น™ํ•  stale-while-revalidate * ์ฐฝ ๊ธธ์ด (์ดˆ). ์ง€์ • ์‹œ `revalidate` ๊ตฌ๊ฐ„ ํ›„ ์ด ๊ธฐ๊ฐ„๋งŒํผ STALE ์‘๋‹ต์„ * ์ฆ‰์‹œ ๋ฐ˜ํ™˜ํ•˜๊ณ  ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์žฌ์ƒ์„ฑํ•œ๋‹ค. */ staleWhileRevalidate?: number; /** ์˜จ๋””๋งจ๋“œ ๋ฌดํšจํ™” ํƒœ๊ทธ */ tags?: string[]; } /** ๋ Œ๋”๋ง ๋ชจ๋“œ */ export type RenderMode = "dynamic" | "isr" | "swr" | "ppr"; /** Loader ํƒ€์ž„์•„์›ƒ ์—๋Ÿฌ */ export class LoaderTimeoutError extends Error { constructor(timeout: number) { super(`Loader timed out after ${timeout}ms`); this.name = "LoaderTimeoutError"; } } /** Action handler type โ€” named mutation handler */ export type ActionHandler = (ctx: ManduContext) => Response | Promise; /** * Named mutation/interaction handler. Alias of `ActionHandler`, exported so * docs and generated examples can name the action responsibility directly. */ export type MutationAction = ActionHandler; /** * Executable route pipeline: handlers, loader, actions, middleware, cache, * render mode, and deploy intent. This is intentionally separate from the * API schema contract. */ export type RouteFilling = ManduFilling; interface FillingConfig { handlers: Map; actions: Map; loader?: Loader; loaderCache?: LoaderCacheOptions; renderMode?: RenderMode; wsHandlers?: WSHandlers; lifecycle: LifecycleStore; middleware: MiddlewareEntry[]; /** Semantic slot metadata */ semantic: SlotMetadata; /** * Issue #250 M5 โ€” explicit deploy intent override. When present, * `mandu deploy:plan` records this entry as `source: "explicit"`, * which the planner never overwrites via inference. The shape is * the partial-input form so users can declare just the fields they * care about (typically `runtime` + maybe `regions`). * * Use this to escape-hatch the heuristic / brain when you know * better than they do โ€” e.g. an API route that imports a DB driver * but is actually called from a build-only script and you want it * to ship as `static`. */ deploy?: DeployIntentInputType; } export class ManduFilling { private config: FillingConfig = { handlers: new Map(), actions: new Map(), lifecycle: createLifecycleStore(), middleware: [], semantic: {}, }; /** * Semantic Slot: ์Šฌ๋กฏ์˜ ๋ชฉ์  ์ •์˜ * AI๊ฐ€ ์ด ์Šฌ๋กฏ์˜ ์—ญํ• ์„ ์ดํ•ดํ•˜๊ณ  ์ ์ ˆํ•œ ๊ตฌํ˜„์„ ํ•˜๋„๋ก ์•ˆ๋‚ด * * @example * ```typescript * Mandu.filling() * .purpose("์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ API") * .get(async (ctx) => { ... }); * ``` */ purpose(purposeText: string): this { this.config.semantic.purpose = purposeText; return this; } /** * Semantic Slot: ์ƒ์„ธ ์„ค๋ช… ์ถ”๊ฐ€ * * @example * ```typescript * Mandu.filling() * .purpose("์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ API") * .description("ํŽ˜์ด์ง€๋„ค์ด์…˜๋œ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๋ฐ˜ํ™˜. ๊ด€๋ฆฌ์ž ์ „์šฉ.") * .get(async (ctx) => { ... }); * ``` */ description(descText: string): this { this.config.semantic.description = descText; return this; } /** * Semantic Slot: ์ œ์•ฝ ์กฐ๊ฑด ์ •์˜ * AI๊ฐ€ ์ด ๋ฒ”์œ„ ๋‚ด์—์„œ๋งŒ ๊ตฌํ˜„ํ•˜๋„๋ก ์ œํ•œ * * @example * ```typescript * Mandu.filling() * .purpose("์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ API") * .constraints({ * maxLines: 50, * maxCyclomaticComplexity: 10, * requiredPatterns: ["input-validation", "error-handling"], * forbiddenPatterns: ["direct-db-write"], * allowedImports: ["server/domain/user/*", "shared/utils/*"], * }) * .get(async (ctx) => { ... }); * ``` */ constraints(constraintsConfig: SlotConstraints): this { this.config.semantic.constraints = constraintsConfig; return this; } /** * Semantic Slot: ํƒœ๊ทธ ์ถ”๊ฐ€ (๊ฒ€์ƒ‰ ๋ฐ ๋ถ„๋ฅ˜์šฉ) */ tags(...tagList: string[]): this { this.config.semantic.tags = tagList; return this; } /** * Semantic Slot: ์†Œ์œ ์ž/๋‹ด๋‹น์ž ์ง€์ • */ owner(ownerName: string): this { this.config.semantic.owner = ownerName; return this; } /** * ์Šฌ๋กฏ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ */ getSemanticMetadata(): SlotMetadata { return { ...this.config.semantic }; } /** * Deploy intent override โ€” pin the runtime / cache / regions for this * route so `mandu deploy:plan` never overrides it via inference. * * Issue #250 M5. The shape mirrors the cache schema's partial-input * form: declare only the fields you care about; the rest fall to * the heuristic / brain. The `deploy:plan` command records the * entry as `source: "explicit"` and protects it from re-inference. * * @example Pin an API route to bun + Seoul region: * ```typescript * Mandu.filling() * .deploy({ runtime: "bun", regions: ["icn1"] }) * .post(async (ctx) => { ... }); * ``` * * @example Force a dynamic page to render statically (only valid when * `generateStaticParams` covers every parameter set): * ```typescript * Mandu.filling() * .deploy({ runtime: "static" }) * .loader(async () => ({ ... })); * ``` * * The intent is validated immediately so a typo (`runtime: "lambdda"`) * fails at module load instead of silently shipping the wrong shape. */ deploy(intent: DeployIntentInputType): this { this.config.deploy = DeployIntentInput.parse(intent); return this; } /** * Read the explicit deploy intent for this route, if any. * * Returns `undefined` when the user did not call `.deploy()`. The * build-time extractor consults this to decide whether to mark the * cache entry as `source: "explicit"` or pass through to inference. */ getDeployIntent(): DeployIntentInputType | undefined { return this.config.deploy; } /** * SSR ๋ฐ์ดํ„ฐ ๋กœ๋” ๋“ฑ๋ก * * @example * ```typescript * // ๊ธฐ๋ณธ (์บ์‹œ ์—†์Œ) * .loader(async (ctx) => ({ posts: await db.getPosts() })) * * // ISR: 60์ดˆ ์บ์‹œ ํ›„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์žฌ์ƒ์„ฑ * .loader(async (ctx) => ({ posts: await db.getPosts() }), { revalidate: 60 }) * * // ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ๋ฌดํšจํ™” * .loader(async (ctx) => ({ posts: await db.getPosts() }), { revalidate: 3600, tags: ["posts"] }) * ``` */ loader(loaderFn: Loader, cacheOptions?: LoaderCacheOptions): this { this.config.loader = loaderFn; if (cacheOptions) { this.config.loaderCache = cacheOptions; } return this; } /** * ๋ Œ๋”๋ง ๋ชจ๋“œ ์„ค์ • * * @example * ```typescript * .render("isr", { revalidate: 120 }) * .render("swr", { revalidate: 300, tags: ["blog"] }) * ``` */ render(mode: RenderMode, cacheOptions?: LoaderCacheOptions): this { this.config.renderMode = mode; if (cacheOptions) { this.config.loaderCache = { ...this.config.loaderCache, ...cacheOptions }; } return this; } /** ํ˜„์žฌ ์บ์‹œ/ISR ์„ค์ • ๋ฐ˜ํ™˜ */ getCacheOptions(): LoaderCacheOptions | undefined { return this.config.loaderCache; } /** ํ˜„์žฌ ๋ Œ๋”๋ง ๋ชจ๋“œ ๋ฐ˜ํ™˜ */ getRenderMode(): RenderMode { return this.config.renderMode ?? "dynamic"; } /** * Execute the registered loader. * * Return shape: * - `TLoaderData` โ€” normal loader data * - `Response` โ€” the loader returned a Response (e.g. `redirect("/login")`). * Callers must check `instanceof Response` and short-circuit their * pipeline instead of treating the value as page data. * - `undefined` โ€” no loader registered * * DX-3: the Response overload is explicit in the signature so downstream * TypeScript consumers are forced to narrow before consuming data โ€” * prevents accidentally passing a Response into renderPageSSR. */ async executeLoader( ctx: ManduContext, options: LoaderOptions = {} ): Promise { if (!this.config.loader) { return undefined; } const { timeout = TIMEOUTS.LOADER_DEFAULT, fallback } = options; try { const loaderPromise = Promise.resolve(this.config.loader(ctx)); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new LoaderTimeoutError(timeout)), timeout); }); return await Promise.race([loaderPromise, timeoutPromise]); } catch (error) { if (fallback !== undefined) { console.warn(`[Mandu] Loader failed, using fallback:`, error instanceof Error ? error.message : String(error)); return fallback; } throw error; } } hasLoader(): boolean { return !!this.config.loader; } get(handler: Handler): this { this.config.handlers.set("GET", handler); return this; } post(handler: Handler): this { this.config.handlers.set("POST", handler); return this; } put(handler: Handler): this { this.config.handlers.set("PUT", handler); return this; } patch(handler: Handler): this { this.config.handlers.set("PATCH", handler); return this; } delete(handler: Handler): this { this.config.handlers.set("DELETE", handler); return this; } head(handler: Handler): this { this.config.handlers.set("HEAD", handler); return this; } options(handler: Handler): this { this.config.handlers.set("OPTIONS", handler); return this; } all(handler: Handler): this { const methods: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; methods.forEach((method) => this.config.handlers.set(method, handler)); return this; } /** * Named action โ€” mutation ํ•ธ๋“ค๋Ÿฌ ๋“ฑ๋ก * POST ์š”์ฒญ์—์„œ _action ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋””์ŠคํŒจ์น˜๋จ * * action ์™„๋ฃŒ ํ›„ loader๊ฐ€ ์žˆ์œผ๋ฉด ์ž๋™ revalidation: * ์‘๋‹ต์— { _action, _revalidated, loaderData } ํฌํ•จ * * @example * ```typescript * Mandu.filling() * .loader(async (ctx) => ({ todos: await db.getTodos() })) * .action("create", async (ctx) => { * const { title } = await ctx.body<{ title: string }>(); * await db.createTodo(title); * return ctx.ok({ created: true }); * }) * .action("delete", async (ctx) => { * const { id } = await ctx.body<{ id: string }>(); * await db.deleteTodo(id); * return ctx.ok({ deleted: true }); * }); * ``` */ action(name: string, handler: ActionHandler): this { if (!name || name.trim().length === 0) { throw new Error("[Mandu] Action name must be a non-empty string"); } this.config.actions.set(name, handler); return this; } hasAction(name: string): boolean { return this.config.actions.has(name); } getActionNames(): string[] { return Array.from(this.config.actions.keys()); } /** * WebSocket ํ•ธ๋“ค๋Ÿฌ ๋“ฑ๋ก * * @example * ```typescript * Mandu.filling() * .ws({ * open(ws) { ws.subscribe("chat"); }, * message(ws, msg) { ws.publish("chat", msg); }, * close(ws) { console.log("Disconnected:", ws.id); }, * }); * ``` */ ws(handlers: WSHandlers): this { this.config.wsHandlers = handlers; return this; } getWSHandlers(): WSHandlers | undefined { return this.config.wsHandlers; } hasWS(): boolean { return !!this.config.wsHandlers; } /** * ์š”์ฒญ ์‹œ์ž‘ ํ›… */ onRequest(fn: OnRequestHandler): this { this.config.lifecycle.onRequest.push({ fn, scope: "local" }); return this; } /** * Compose-style middleware (Hono/Koa ์Šคํƒ€์ผ) * lifecycle์˜ handler ๋‹จ๊ณ„์—์„œ ์‹คํ–‰๋จ */ middleware(fn: RuntimeMiddleware, name?: string): this { this.config.middleware.push({ fn, name: name || fn.name || `middleware_${this.config.middleware.length}`, isAsync: fn.constructor.name === "AsyncFunction", }); return this; } /** * ๋ฐ”๋”” ํŒŒ์‹ฑ ํ›… * body๋ฅผ ์ฝ์„ ๋•Œ๋Š” req.clone() ์‚ฌ์šฉ ๊ถŒ์žฅ */ onParse(fn: OnParseHandler): this { this.config.lifecycle.onParse.push({ fn, scope: "local" }); return this; } beforeHandle(fn: BeforeHandleHandler): this { this.config.lifecycle.beforeHandle.push({ fn, scope: "local" }); return this; } /** * Guard alias (beforeHandle์™€ ๋™์ผ) * ์ธ์ฆ/์ธ๊ฐ€, ์š”์ฒญ ์ฐจ๋‹จ ๋“ฑ์— ์‚ฌ์šฉ */ guard(fn: Guard): this { return this.beforeHandle(fn); } /** * ๋ฏธ๋“ค์›จ์–ด ๋“ฑ๋ก (Guard ํ•จ์ˆ˜ ๋˜๋Š” lifecycle ๊ฐ์ฒด) * * @example * ```typescript * // ๋‹จ์ˆœ guard * .use(authGuard) * * // lifecycle ๊ฐ์ฒด (beforeHandle + afterHandle) * .use(compress()) * .use(cors({ origin: "https://example.com" })) * ``` */ use(fn: Guard | MiddlewarePlugin): this { if (typeof fn === "function") { return this.guard(fn); } // ๋ฏธ๋“ค์›จ์–ด ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ฐ์ฒด: ๊ฐ lifecycle ๋‹จ๊ณ„๋ฅผ ๊ฐœ๋ณ„ ๋“ฑ๋ก if (fn.beforeHandle) this.beforeHandle(fn.beforeHandle); if (fn.afterHandle) this.afterHandle(fn.afterHandle); if (fn.mapResponse) this.mapResponse(fn.mapResponse); return this; } /** * ํ•ธ๋“ค๋Ÿฌ ํ›„ ํ›… */ afterHandle(fn: AfterHandleHandler): this { this.config.lifecycle.afterHandle.push({ fn, scope: "local" }); return this; } /** * ์ตœ์ข… ์‘๋‹ต ๋งคํ•‘ ํ›… */ mapResponse(fn: MapResponseHandler): this { this.config.lifecycle.mapResponse.push({ fn, scope: "local" }); return this; } /** * ์—๋Ÿฌ ํ•ธ๋“ค๋ง ํ›… */ onError(fn: OnErrorHandler): this { this.config.lifecycle.onError.push({ fn, scope: "local" }); return this; } /** * ์‘๋‹ต ํ›„ ํ›… (๋น„๋™๊ธฐ) */ afterResponse(fn: AfterResponseHandler): this { this.config.lifecycle.afterResponse.push({ fn, scope: "local" }); return this; } async handle( request: Request, params: Record = {}, routeContext?: { routeId: string; pattern: string }, options?: ExecuteOptions & { deps?: FillingDeps } ): Promise { const deps = options?.deps ?? globalDeps.get(); const normalizedRequest = await applyMethodOverride(request); const ctx = new ManduContext(normalizedRequest, params, deps); const method = normalizedRequest.method.toUpperCase() as HttpMethod; // Action ๋””์ŠคํŒจ์น˜: POST/PUT/PATCH/DELETE + ๋“ฑ๋ก๋œ action์ด ์žˆ์„ ๋•Œ if (this.config.actions.size > 0 && method !== "GET" && method !== "HEAD" && method !== "OPTIONS") { const actionResult = await this.tryDispatchAction(ctx, routeContext, options); if (actionResult) return actionResult; } // RFC 7231 ยง4.3.2: HEAD is GET without a body. A route that only // registers a GET handler must still serve HEAD โ€” run GET, then strip // the body while preserving status + headers. An explicit HEAD handler // takes precedence when present. const stripBody = method === "HEAD" && !this.config.handlers.has("HEAD"); const handler = this.config.handlers.get(method) ?? (stripBody ? this.config.handlers.get("GET") : undefined); if (!handler) { const allowed = Array.from(this.config.handlers.keys()); if (this.config.handlers.has("GET") && !allowed.includes("HEAD")) { allowed.push("HEAD"); } // RFC 7231 ยง4.3.7: auto-respond to a plain OPTIONS request (no explicit // OPTIONS handler) with 204 + an `Allow` header. CORS preflights โ€” which // carry `Access-Control-Request-Method` โ€” are left to the normal flow so // CORS middleware can attach its Access-Control-* headers. if ( method === "OPTIONS" && !normalizedRequest.headers.get("access-control-request-method") ) { const optionsAllowed = allowed.includes("OPTIONS") ? allowed : [...allowed, "OPTIONS"]; return new Response(null, { status: 204, headers: { Allow: optionsAllowed.join(", ") }, }); } // RFC 7231 ยง6.5.5: a 405 response MUST include an `Allow` header listing // every supported method (not just the body field). ctx.json() cannot // attach headers, so build the response directly here. return Response.json( { status: "error", message: `Method ${method} not allowed`, allowed }, { status: 405, headers: { Allow: allowed.join(", ") } }, ); } const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext); const runHandler = async () => { if (this.config.middleware.length === 0) { return handler(ctx); } const chain: MiddlewareEntry[] = [ ...this.config.middleware, { fn: async (innerCtx) => handler(innerCtx), name: "handler", isAsync: true, }, ]; const composed = compose(chain); return composed(ctx); }; const response = await executeLifecycle(lifecycleWithDefaults, ctx, runHandler, options); if (stripBody) { // Preserve status + headers (incl. Content-Type / Content-Length the // GET handler set), drop the body. A null-body Response keeps headers. return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers, }); } return response; } /** * Action ๋””์ŠคํŒจ์น˜ ์‹œ๋„ * _action ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์žˆ๊ณ  ๋งค์นญ๋˜๋Š” action์ด ์žˆ์œผ๋ฉด ์‹คํ–‰ + revalidation * ๋งค์นญ ์•ˆ ๋˜๋ฉด null ๋ฐ˜ํ™˜ โ†’ ๊ธฐ์กด ํ•ธ๋“ค๋Ÿฌ๋กœ fallback */ private async tryDispatchAction( ctx: ManduContext, routeContext?: { routeId: string; pattern: string }, options?: ExecuteOptions & { deps?: FillingDeps } ): Promise { const actionName = await this.resolveActionName(ctx); if (!actionName) return null; const actionHandler = this.config.actions.get(actionName); if (!actionHandler) return null; // action ์ด๋ฆ„์„ ctx์— ์ €์žฅ (๋‹ค๋ฅธ lifecycle ํ›…์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅ) ctx.set("_actionName", actionName); const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext); const runAction = async () => { if (this.config.middleware.length === 0) { return actionHandler(ctx); } const chain: MiddlewareEntry[] = [ ...this.config.middleware, { fn: async (innerCtx) => actionHandler(innerCtx), name: `action:${actionName}`, isAsync: true }, ]; return compose(chain)(ctx); }; const actionResponse = await executeLifecycle(lifecycleWithDefaults, ctx, runAction, options); // Action ์„ฑ๊ณต + loader ์žˆ์œผ๋ฉด ์ž๋™ revalidation if (actionResponse.ok && this.config.loader) { // fetch ์š”์ฒญ(JS ํ™˜๊ฒฝ)๋งŒ revalidation JSON ๋ฐ˜ํ™˜ // HTML form ์ œ์ถœ(Accept: text/html)์€ action ์‘๋‹ต ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ const accept = ctx.headers.get("accept") ?? ""; const isFetchRequest = accept.includes("application/json") || ctx.headers.get("x-requested-with") === "ManduAction"; if (isFetchRequest) { try { const freshData = await this.executeLoader(ctx); // action ์‘๋‹ต ๋ณธ๋ฌธ ๋ณด์กด (actionData๋กœ ํฌํ•จ) let actionData: unknown = null; const actionContentType = actionResponse.headers.get("content-type") ?? ""; if (actionContentType.includes("application/json")) { actionData = await actionResponse.clone().json().catch(() => null); } const revalidatedResponse = ctx.json({ _action: actionName, _revalidated: true, actionData, loaderData: freshData, }); // action ์‘๋‹ต์˜ Set-Cookie ํ—ค๋” ๋ณด์กด const setCookies = actionResponse.headers.getSetCookie?.() ?? []; for (const cookie of setCookies) { revalidatedResponse.headers.append("Set-Cookie", cookie); } return revalidatedResponse; } catch { // Loader ์‹คํŒจ ์‹œ action ๊ฒฐ๊ณผ๋งŒ ๋ฐ˜ํ™˜ return actionResponse; } } } return actionResponse; } /** * ์š”์ฒญ์—์„œ action ์ด๋ฆ„ ์ถ”์ถœ * ์šฐ์„ ์ˆœ์œ„: body._action > URL ?_action= * (body๋ฅผ ์šฐ์„ ํ•˜์—ฌ URL query ์กฐ์ž‘์— ์˜ํ•œ action hijacking ๋ฐฉ์ง€) */ private async resolveActionName(ctx: ManduContext): Promise { // 1. Request body์—์„œ ๋จผ์ € ํ™•์ธ (form ์ œ์–ด ํ•˜์— ์žˆ์œผ๋ฏ€๋กœ ๋” ์•ˆ์ „) const contentType = ctx.headers.get("content-type") ?? ""; try { if (contentType.includes("application/json")) { const cloned = ctx.request.clone(); const body = await cloned.json() as Record; if (typeof body._action === "string") return body._action; } else if (contentType.includes("form")) { const cloned = ctx.request.clone(); const formData = await cloned.formData(); const action = formData.get("_action"); if (typeof action === "string") return action; } } catch { // ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ query fallback } // 2. URL query parameter (body์— ์—†์„ ๋•Œ๋งŒ) const fromQuery = ctx.query._action; if (fromQuery) return fromQuery; return null; } private createLifecycleWithDefaults(routeContext?: { routeId: string; pattern: string }): LifecycleStore { const lifecycle: LifecycleStore = { onRequest: [...this.config.lifecycle.onRequest], onParse: [...this.config.lifecycle.onParse], beforeHandle: [...this.config.lifecycle.beforeHandle], afterHandle: [...this.config.lifecycle.afterHandle], mapResponse: [...this.config.lifecycle.mapResponse], afterResponse: [...this.config.lifecycle.afterResponse], onError: [...this.config.lifecycle.onError], }; const defaultErrorHandler: OnErrorHandler = (ctx, error) => { if (error instanceof AuthenticationError) { return ctx.json({ errorType: "AUTH_ERROR", code: "AUTHENTICATION_REQUIRED", message: error.message, summary: "์ธ์ฆ ํ•„์š” - ๋กœ๊ทธ์ธ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”", timestamp: new Date().toISOString() }, 401); } if (error instanceof AuthorizationError) { return ctx.json({ errorType: "AUTH_ERROR", code: "ACCESS_DENIED", message: error.message, summary: "๊ถŒํ•œ ์—†์Œ - ์ ‘๊ทผ ๊ถŒํ•œ์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค", requiredRoles: error.requiredRoles, timestamp: new Date().toISOString() }, 403); } if (error instanceof ValidationError) { return ctx.json({ errorType: "LOGIC_ERROR", code: ErrorCode.SLOT_VALIDATION_ERROR, message: "Validation failed", summary: "์ž…๋ ฅ ๊ฒ€์ฆ ์‹คํŒจ - ์š”์ฒญ ๋ฐ์ดํ„ฐ ํ™•์ธ ํ•„์š”", fix: { file: routeContext ? `spec/slots/${routeContext.routeId}.slot.ts` : "spec/slots/", suggestion: "์š”์ฒญ ๋ฐ์ดํ„ฐ๊ฐ€ ์Šคํ‚ค๋งˆ์™€ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”" }, route: routeContext, errors: error.errors, timestamp: new Date().toISOString() }, 400); } if (error instanceof BadRequestError) { return ctx.json({ errorType: "CLIENT_ERROR", code: "MANDU_C400", message: error.message, summary: "์ž˜๋ชป๋œ ์š”์ฒญ ๋ณธ๋ฌธ - ํด๋ผ์ด์–ธํŠธ ์ž…๋ ฅ ํ™•์ธ ํ•„์š”", route: routeContext, timestamp: new Date().toISOString() }, 400); } const classifier = new ErrorClassifier(null, routeContext ? { id: routeContext.routeId, pattern: routeContext.pattern } : undefined); const manduError = classifier.classify(error); console.error(`[Mandu] ${manduError.errorType}:`, manduError.message); const response = formatErrorResponse(manduError, { isDev: process.env.NODE_ENV !== "production" }); return ctx.json(response, 500); }; lifecycle.onError.push({ fn: defaultErrorHandler, scope: "local" }); return lifecycle; } getMethods(): HttpMethod[] { return Array.from(this.config.handlers.keys()); } /** * Convert to named handler exports compatible with Mandu route.ts files. * Usage: export const { GET, POST } = filling.toHandlers(); */ toHandlers(): Partial Promise>> { const result: Partial Promise>> = {}; for (const method of this.config.handlers.keys()) { result[method] = (req: Request) => this.handle(req, {}, undefined); } return result; } hasMethod(method: HttpMethod): boolean { return this.config.handlers.has(method); } } const OVERRIDABLE_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); async function applyMethodOverride(request: Request): Promise { if (request.method.toUpperCase() !== "POST") { return request; } const override = await detectMethodOverride(request); if (!override || override === "POST") { return request; } return new Request(request, { method: override }); } async function detectMethodOverride(request: Request): Promise { const headerOverride = normalizeOverrideMethod(request.headers.get("X-HTTP-Method-Override")); if (headerOverride) return headerOverride; const url = new URL(request.url); const queryOverride = normalizeOverrideMethod(url.searchParams.get("_method")); if (queryOverride) return queryOverride; const contentType = request.headers.get("content-type") ?? ""; const cloned = request.clone(); try { if (contentType.includes("application/json")) { const body = await cloned.json() as { _method?: unknown }; return normalizeOverrideMethod(typeof body?._method === "string" ? body._method : null); } if ( contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data") ) { const form = await cloned.formData(); const override = form.get("_method"); return normalizeOverrideMethod(typeof override === "string" ? override : null); } } catch { return null; } return null; } function normalizeOverrideMethod(value: string | null): HttpMethod | null { if (!value) return null; const method = value.toUpperCase() as HttpMethod; return OVERRIDABLE_METHODS.has(method) ? method : null; } /** * Mandu Filling factory functions * Note: These are also available via the main `Mandu` namespace */ export const ManduFillingFactory = { filling(): ManduFilling { return new ManduFilling(); }, contract(definition: T): T & ContractInstance { return createContract(definition); }, context(request: Request, params?: Record): ManduContext { return new ManduContext(request, params); }, };