/** * Mandu Context - ๋งŒ๋‘ ์ ‘์‹œ ๐ŸฅŸ * Request/Response๋ฅผ ๋ž˜ํ•‘ํ•˜์—ฌ ํŽธ๋ฆฌํ•œ API ์ œ๊ณต * * DNA-002: ์˜์กด์„ฑ ์ฃผ์ž… ํŒจํ„ด ์ง€์› */ import type { ZodSchema } from "zod"; import type { ContractSchema, ContractMethod } from "../contract/schema"; import type { InferBody, InferHeaders, InferParams, InferQuery, InferResponse } from "../contract/types"; import { ContractValidator, type ContractValidatorOptions } from "../contract/validator"; import { getCookieCodec } from "./cookie-codec"; import { type FillingDeps, globalDeps } from "./deps"; import { createSSEConnection, type SSEOptions, type SSEConnection } from "./sse"; import { getActiveSpan, getTracer, runWithSpan, type Span, type SpanAttributes, type SpanOptions, } from "../observability/tracing"; // Phase 18.ฮผ โ€” i18n integration. import type { ResolvedLocale, Translator } from "../i18n/types"; type ContractInput< TContract extends ContractSchema, TMethod extends ContractMethod, > = { query: InferQuery; body: InferBody; params: InferParams; headers: InferHeaders; }; // ========== Cookie Types ========== export interface CookieOptions { /** ์ฟ ํ‚ค ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (Date ๊ฐ์ฒด ๋˜๋Š” ๋ฌธ์ž์—ด) */ expires?: Date | string; /** ์ฟ ํ‚ค ์œ ํšจ ๊ธฐ๊ฐ„ (์ดˆ) */ maxAge?: number; /** ์ฟ ํ‚ค ๋„๋ฉ”์ธ */ domain?: string; /** ์ฟ ํ‚ค ๊ฒฝ๋กœ */ path?: string; /** HTTPS์—์„œ๋งŒ ์ „์†ก */ secure?: boolean; /** JavaScript์—์„œ ์ ‘๊ทผ ๋ถˆ๊ฐ€ */ httpOnly?: boolean; /** Same-Site ์ •์ฑ… */ sameSite?: "strict" | "lax" | "none"; /** ํŒŒํ‹ฐ์…˜ ํ‚ค (CHIPS) */ partitioned?: boolean; } /** * Cookie Manager - ์ฟ ํ‚ค ์ฝ๊ธฐ/์“ฐ๊ธฐ ๊ด€๋ฆฌ * * Parse + serialize I/O is delegated to a {@link CookieCodec} selected at * module load time (Bun.CookieMap when available, pure-JS legacy codec * otherwise). The codec is a private implementation detail; the public API * on this class is unchanged. */ export class CookieManager { private requestCookies: Map; private responseCookies: Map; private deletedCookies: Set; /** * Pre-serialized Set-Cookie strings queued for the response. Bypasses the * codec serializer โ€” used when the Set-Cookie value was produced by an * upstream source (e.g. `SessionStorage.commitSession`) that already * emitted a fully-formed header. Advanced escape hatch; prefer `set()`. */ private extraSetCookie: string[] = []; constructor(request: Request) { this.requestCookies = getCookieCodec().parseRequestHeader( request.headers.get("cookie") ); this.responseCookies = new Map(); this.deletedCookies = new Set(); } /** * ์ฟ ํ‚ค ๊ฐ’ ์ฝ๊ธฐ * @example * const session = ctx.cookies.get('session'); * * Reads from the response-cookie buffer first so that a value set earlier * in the same request (e.g. by an upstream middleware) is visible to later * code. Falls back to the incoming request cookie. */ get(name: string): string | undefined { if (this.deletedCookies.has(name)) { const pending = this.responseCookies.get(name); // A pending write overrides a delete if (pending && pending.options.maxAge !== 0) return pending.value; return undefined; } const pending = this.responseCookies.get(name); if (pending) return pending.value; return this.requestCookies.get(name); } /** * ์ฟ ํ‚ค ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ โ€” ์‘๋‹ต ์ธก์— ๋ฐฉ๊ธˆ set ํ•œ ๊ฐ’๋„ ํฌํ•จ. */ has(name: string): boolean { if (this.deletedCookies.has(name)) { const pending = this.responseCookies.get(name); return pending !== undefined && pending.options.maxAge !== 0; } return this.responseCookies.has(name) || this.requestCookies.has(name); } /** * ๋ชจ๋“  ์ฟ ํ‚ค ๊ฐ€์ ธ์˜ค๊ธฐ โ€” ์‘๋‹ต ์ธก pending ๊ฐ’์ด ์š”์ฒญ ์ธก์„ override. */ getAll(): Record { const merged: Record = {}; for (const [name, value] of this.requestCookies) { merged[name] = value; } for (const [name, { value, options }] of this.responseCookies) { if (options.maxAge === 0) { delete merged[name]; } else { merged[name] = value; } } return merged; } /** * ์ฟ ํ‚ค ์„ค์ • * @example * ctx.cookies.set('session', 'abc123', { httpOnly: true, maxAge: 3600 }); */ set(name: string, value: string, options: CookieOptions = {}): void { this.responseCookies.set(name, { value, options }); this.deletedCookies.delete(name); } /** * ์ฟ ํ‚ค ์‚ญ์ œ * @example * ctx.cookies.delete('session'); */ delete(name: string, options: Pick = {}): void { this.responseCookies.delete(name); this.deletedCookies.add(name); // ์‚ญ์ œ์šฉ ์ฟ ํ‚ค ์„ค์ • (maxAge=0) this.responseCookies.set(name, { value: "", options: { ...options, maxAge: 0, expires: new Date(0), }, }); } /** * Append a pre-serialized Set-Cookie header value, bypassing the codec. * * Intended for integrations that produce their own fully-formed Set-Cookie * strings (e.g. `SessionStorage.commitSession()`). Coexists with `set()` โ€” * both land in the final header list emitted by {@link applyToResponse}. * * Empty / non-string inputs are silently ignored so callers can pass the * output of an async producer without null-guarding. */ appendRawSetCookie(setCookieString: string): void { if (typeof setCookieString === "string" && setCookieString.length > 0) { this.extraSetCookie.push(setCookieString); } } /** * Set-Cookie ํ—ค๋” ๊ฐ’๋“ค ์ƒ์„ฑ */ getSetCookieHeaders(): string[] { const codec = getCookieCodec(); const headers: string[] = []; for (const [name, { value, options }] of this.responseCookies) { headers.push(codec.serializeSetCookie(name, value, options)); } // Raw-appended strings emit after codec-serialized cookies so that order // in the final `Set-Cookie` header list mirrors call order: `set()` first, // then `appendRawSetCookie()`. Callers relying on "last write wins" for // a given cookie name should prefer `set()`. for (const raw of this.extraSetCookie) { headers.push(raw); } return headers; } /** * Response์— Set-Cookie ํ—ค๋”๋“ค ์ ์šฉ */ applyToResponse(response: Response): Response { const setCookieHeaders = this.getSetCookieHeaders(); if (setCookieHeaders.length === 0) { return response; } // Headers๋ฅผ ๋ณต์‚ฌํ•˜์—ฌ ์ˆ˜์ • const newHeaders = new Headers(response.headers); for (const setCookie of setCookieHeaders) { newHeaders.append("Set-Cookie", setCookie); } return new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders, }); } /** * ์‘๋‹ต์— ์ ์šฉํ•  ์ฟ ํ‚ค๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ */ hasPendingCookies(): boolean { return this.responseCookies.size > 0 || this.extraSetCookie.length > 0; } /** * ์„œ๋ช…๋œ ์ฟ ํ‚ค ์ฝ๊ธฐ (HMAC-SHA256 ๊ฒ€์ฆ) * @returns ๊ฐ’(๊ฒ€์ฆ ์„ฑ๊ณต), null(์ฟ ํ‚ค ์—†์Œ), false(์„œ๋ช… ๋ถˆ์ผ์น˜) * @example * const userId = await ctx.cookies.getSigned('session', SECRET); * if (userId === false) return ctx.unauthorized('Invalid session'); * if (userId === null) return ctx.unauthorized('No session'); */ async getSigned(name: string, secret: string): Promise { const raw = this.get(name); if (!raw) return null; const dotIndex = raw.lastIndexOf("."); if (dotIndex === -1) return false; const value = raw.slice(0, dotIndex); const signature = raw.slice(dotIndex + 1); if (!value || !signature) return false; const expected = await hmacSign(value, secret); return signature === expected ? decodeURIComponent(value) : false; } /** * ์„œ๋ช…๋œ ์ฟ ํ‚ค ์„ค์ • (HMAC-SHA256) * @example * await ctx.cookies.setSigned('session', userId, SECRET, { httpOnly: true }); */ async setSigned(name: string, value: string, secret: string, options?: CookieOptions): Promise { const encoded = encodeURIComponent(value); const signature = await hmacSign(encoded, secret); this.set(name, `${encoded}.${signature}`, options); } /** * JSON ์ฟ ํ‚ค๋ฅผ ์Šคํ‚ค๋งˆ๋กœ ํŒŒ์‹ฑ + ๊ฒ€์ฆ (Zod ํ˜ธํ™˜ duck typing) * @returns ํŒŒ์‹ฑ๋œ ๊ฐ’ ๋˜๋Š” null(์ฟ ํ‚ค ์—†์Œ/ํŒŒ์‹ฑ ์‹คํŒจ/๊ฒ€์ฆ ์‹คํŒจ) * @example * const prefs = ctx.cookies.getParsed('prefs', z.object({ theme: z.string() })); */ getParsed(name: string, schema: { parse: (v: unknown) => T }): T | null { const raw = this.get(name); if (raw == null) return null; try { const decoded = decodeURIComponent(raw); const parsed = JSON.parse(decoded); return schema.parse(parsed); } catch { return null; } } } /** * HMAC-SHA256 ์„œ๋ช… ์ƒ์„ฑ (WebCrypto API) */ async function hmacSign(data: string, secret: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); return btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=+$/, ""); } // ========== ManduContext ========== /** * Phase 18.ฮถ โ€” ๋กœ๋”๊ฐ€ ์บ์‹œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์„ ์–ธํ•˜๊ธฐ ์œ„ํ•œ ํ”Œ๋ฃจ์–ธํŠธ ํ—ฌํผ. * * ๋‚ด๋ถ€์ ์œผ๋กœ๋Š” `ctx._cacheMeta` ์— ๋ˆ„์ ํ•˜๋ฉฐ, ์„œ๋ฒ„ ๋Ÿฐํƒ€์ž„์ด loader ์‹คํ–‰ * ์งํ›„ ์ฝ์–ด `_cache` ๋ธ”๋ก๊ณผ ๋™์ผํ•˜๊ฒŒ ํ•ด์„ํ•œ๋‹ค. ๋ฐ˜ํ™˜ ๋ฐ์ดํ„ฐ์— `_cache` ๋ฅผ * ๋ผ์›Œ๋„ฃ๋Š” ๋ฐฉ์‹๊ณผ ๋™์ผํ•œ ์˜๋ฏธ์ง€๋งŒ, ํƒ€์ž… ์•ˆ์ •์„ฑ์ด ๋” ๋†’๋‹ค. * * @example * ```ts * .loader(async (ctx) => { * ctx.cache.tag("posts").tag("posts:42").maxAge(3600).swr(86400); * return { post: await db.getPost(42) }; * }) * ``` */ export interface CacheHelper { tag(...tags: string[]): CacheHelper; maxAge(seconds: number): CacheHelper; /** alias for maxAge โ€” Next.js parity */ revalidate(seconds: number): CacheHelper; swr(seconds: number): CacheHelper; /** alias for swr */ staleWhileRevalidate(seconds: number): CacheHelper; /** ํ˜„์žฌ๊นŒ์ง€ ๋ˆ„์ ๋œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์Šค๋ƒ…์ƒท (๋Ÿฐํƒ€์ž„ ๋‚ด๋ถ€์šฉ) */ readonly meta: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number; }; } export class ManduContext { private store: Map = new Map(); private _params: Record; private _query: Record; private _cookies: CookieManager; private _deps: FillingDeps; private _cacheMeta: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } = { tags: [] }; private _cacheHelper: CacheHelper | null = null; /** * Phase 18.ฮผ โ€” resolved active locale. `undefined` when the server has * no `ManduConfig.i18n` configured (zero-overhead โ€” the runtime never * attaches this field). Populated by `runtime/server.ts` ฮผ dispatch. */ private _locale: ResolvedLocale | undefined = undefined; /** * Phase 18.ฮผ โ€” typed translator bound to the active locale. `undefined` * until the runtime attaches one via `_setI18n()`. User code should * null-check via `ctx.t?.(...)` OR require i18n via a type predicate. */ private _t: Translator | undefined = undefined; constructor( public readonly request: Request, params: Record = {}, deps?: FillingDeps ) { this._params = params; this._query = this.parseQuery(); this._cookies = new CookieManager(request); this._deps = deps ?? globalDeps.get(); } /** * Phase 18.ฮถ โ€” ๋กœ๋” ๋‚ด๋ถ€์—์„œ ํ˜ธ์ถœํ•˜๋Š” ์บ์‹œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํ”Œ๋ฃจ์–ธํŠธ ํ—ฌํผ. * ๋ˆ„์  ํ˜ธ์ถœ ๊ฐ€๋Šฅ: `ctx.cache.tag("a", "b").maxAge(60).swr(300)` */ get cache(): CacheHelper { if (this._cacheHelper) return this._cacheHelper; const meta = this._cacheMeta; const helper: CacheHelper = { tag: (...tags: string[]) => { for (const t of tags) { if (typeof t === "string" && t.length > 0 && !meta.tags.includes(t)) { meta.tags.push(t); } } return helper; }, maxAge: (seconds: number) => { if (Number.isFinite(seconds) && seconds >= 0) meta.maxAge = seconds; return helper; }, revalidate: (seconds: number) => helper.maxAge(seconds), swr: (seconds: number) => { if (Number.isFinite(seconds) && seconds >= 0) meta.staleWhileRevalidate = seconds; return helper; }, staleWhileRevalidate: (seconds: number) => helper.swr(seconds), get meta() { return { tags: [...meta.tags], maxAge: meta.maxAge, staleWhileRevalidate: meta.staleWhileRevalidate, }; }, }; this._cacheHelper = helper; return helper; } /** * Phase 18.ฮถ โ€” ์„œ๋ฒ„ ๋Ÿฐํƒ€์ž„์ด loader ์ข…๋ฃŒ ํ›„ ๋ˆ„์ ๋œ ๋ฉ”ํƒ€ ์Šค๋ƒ…์ƒท์„ ์ฝ๋Š”๋‹ค. * ๋‚ด๋ถ€ ์ „์šฉ. ์œ ์ € ์ฝ”๋“œ๋Š” `ctx.cache` ๋ฅผ ์‚ฌ์šฉํ•  ๊ฒƒ. */ getCacheMetaSnapshot(): { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null { if (this._cacheMeta.tags.length === 0 && this._cacheMeta.maxAge === undefined && this._cacheMeta.staleWhileRevalidate === undefined) { return null; } return { tags: [...this._cacheMeta.tags], maxAge: this._cacheMeta.maxAge, staleWhileRevalidate: this._cacheMeta.staleWhileRevalidate, }; } /** * Phase 18.ฮผ โ€” active resolved locale. `undefined` when i18n is * disabled at server boot (`ManduConfig.i18n` omitted). Populated by * the runtime dispatcher BEFORE loader + render so downstream code * can branch on it without threading state manually. * * @example * ```ts * if (ctx.locale?.code === "ko") { * return ctx.redirect("/ko/welcome"); * } * ``` */ get locale(): ResolvedLocale | undefined { return this._locale; } /** * Phase 18.ฮผ โ€” typed translator bound to `ctx.locale`. `undefined` * when i18n is disabled OR no message registry was supplied to * `startServer()`. Unlike `ctx.locale`, this is safe to call with an * unknown key โ€” misses fall through `fallbackLocale` โ†’ `defaultLocale` * โ†’ raw key per {@link createTranslator}. * * @example * ```ts * return ctx.ok({ greeting: ctx.t?.("welcome", { name: "๋งŒ๋‘" }) }); * ``` */ get t(): Translator | undefined { return this._t; } /** * Phase 18.ฮผ โ€” runtime-internal: attach resolved locale + translator. * Exposed as a method (not a public setter) so user code can't mutate * mid-request. Called exactly once by the server dispatcher before * loader execution. * * @internal */ _setI18n(locale: ResolvedLocale, translator?: Translator): void { this._locale = locale; if (translator) this._t = translator; } /** * DNA-002: ์˜์กด์„ฑ ์ ‘๊ทผ * * @example * ```ts * // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ฟผ๋ฆฌ * const users = await ctx.deps.db?.query("SELECT * FROM users"); * * // ์บ์‹œ ์‚ฌ์šฉ * const cached = await ctx.deps.cache?.get("user:123"); * * // ๋กœ๊น… * ctx.deps.logger?.info("User logged in", { userId }); * * // ํ˜„์žฌ ์‹œ๊ฐ„ (ํ…Œ์ŠคํŠธ์—์„œ ๋ชฉํ‚น ๊ฐ€๋Šฅ) * const now = ctx.deps.now?.() ?? new Date(); * ``` */ get deps(): FillingDeps { return this._deps; } private parseQuery(): Record { const url = new URL(this.request.url); const query: Record = {}; url.searchParams.forEach((value, key) => { query[key] = value; }); return query; } // ============================================ // ๐ŸฅŸ Request ์ฝ๊ธฐ // ============================================ /** Path parameters (e.g., /users/:id โ†’ { id: '123' }) */ get params(): Record { return this._params; } /** Query parameters (e.g., ?name=mandu โ†’ { name: 'mandu' }) */ get query(): Record { return this._query; } /** Request headers */ get headers(): Headers { return this.request.headers; } /** HTTP method */ get method(): string { return this.request.method; } /** Request URL */ get url(): string { return this.request.url; } /** Shorthand for request */ get req(): Request { return this.request; } /** * Cookie Manager * @example * // ์ฟ ํ‚ค ์ฝ๊ธฐ * const session = ctx.cookies.get('session'); * * // ์ฟ ํ‚ค ์„ค์ • * ctx.cookies.set('session', 'abc123', { httpOnly: true, maxAge: 3600 }); * * // ์ฟ ํ‚ค ์‚ญ์ œ * ctx.cookies.delete('session'); */ get cookies(): CookieManager { return this._cookies; } /** * Parse request body with optional Zod validation * @example * const data = await ctx.body() // any * const data = await ctx.body(UserSchema) // typed & validated */ async body(schema?: ZodSchema): Promise { const contentType = this.request.headers.get("content-type") || ""; let data: unknown; if (contentType.includes("application/json")) { try { data = await this.request.json(); } catch (cause) { // Empty or malformed JSON body is a client error (400), // not a framework bug (500). See issue #318. throw new BadRequestError( "Invalid or empty JSON request body", cause ); } } else if (contentType.includes("application/x-www-form-urlencoded")) { const formData = await this.request.formData(); data = Object.fromEntries(formData.entries()); } else if (contentType.includes("multipart/form-data")) { const formData = await this.request.formData(); data = Object.fromEntries(formData.entries()); } else { data = await this.request.text(); } if (schema) { const result = schema.safeParse(data); if (!result.success) { throw new ValidationError(result.error.errors); } return result.data; } return data as T; } /** * Parse and validate request input via Contract * @example * const input = await ctx.input(userContract, "POST", { id: "123" }) */ async input< TContract extends ContractSchema, TMethod extends ContractMethod, >( contract: TContract, method: TMethod, pathParams: Record = {}, options: ContractValidatorOptions = {} ): Promise> { const validator = new ContractValidator(contract, options); const result = await validator.validateAndNormalizeRequest( this.request, method, pathParams ); if (!result.success) { throw new ValidationError(result.errors ?? []); } return (result.data ?? {}) as ContractInput; } // ============================================ // ๐ŸฅŸ Response ๋ณด๋‚ด๊ธฐ // ============================================ /** * Response์— ์ฟ ํ‚ค ํ—ค๋” ์ ์šฉ (๋‚ด๋ถ€ ์‚ฌ์šฉ) */ private withCookies(response: Response): Response { if (this._cookies.hasPendingCookies()) { return this._cookies.applyToResponse(response); } return response; } /** 200 OK */ ok(data: T): Response { return this.json(data, 200); } /** 201 Created */ created(data: T): Response { return this.json(data, 201); } /** 204 No Content */ noContent(): Response { return this.withCookies(new Response(null, { status: 204 })); } /** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */ error(message: string, details?: unknown): Response; error(status: number, message: string, details?: unknown): Response; error( statusOrMessage: number | string, messageOrDetails?: string | unknown, maybeDetails?: unknown ): Response { if (typeof statusOrMessage === "number") { const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599 ? statusOrMessage : 400; const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error"; return this.json({ status: "error", message, details: maybeDetails }, status); } return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400); } /** 401 Unauthorized */ unauthorized(message: string = "Unauthorized"): Response { return this.json({ status: "error", message }, 401); } /** 403 Forbidden */ forbidden(message: string = "Forbidden"): Response { return this.json({ status: "error", message }, 403); } /** 404 Not Found */ notFound(message: string = "Not Found"): Response { return this.json({ status: "error", message }, 404); } /** 500 Internal Server Error */ fail(message: string = "Internal Server Error"): Response { return this.json({ status: "error", message }, 500); } /** Custom JSON response */ json(data: T, status: number = 200): Response { const response = Response.json(data, { status }); return this.withCookies(response); } /** * Validate and send response via Contract * @example * return ctx.output(userContract, 200, { data: users }) */ output< TContract extends ContractSchema, TStatus extends keyof TContract["response"], >( contract: TContract, status: TStatus, data: InferResponse, options: ContractValidatorOptions = {} ): Response { const validator = new ContractValidator(contract, options); const result = validator.validateResponse(data, Number(status)); if (!result.success) { if (options.mode === "strict") { const errorResponse = Response.json( { errorType: "CONTRACT_VIOLATION", code: "MANDU_C001", message: "Response does not match contract schema", summary: "์‘๋‹ต์ด Contract ์Šคํ‚ค๋งˆ์™€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", statusCode: Number(status), violations: result.errors, timestamp: new Date().toISOString(), }, { status: 500 } ); return this.withCookies(errorResponse); } console.warn( "\x1b[33m[Mandu] Contract violation in response:\x1b[0m", result.errors ); } const payload = result.success ? result.data : data; return this.json(payload as InferResponse, Number(status)); } /** 200 OK with Contract validation */ okContract( contract: TContract, data: InferResponse, options: ContractValidatorOptions = {} ): Response { return this.output(contract, 200 as keyof TContract["response"], data, options); } /** 201 Created with Contract validation */ createdContract( contract: TContract, data: InferResponse, options: ContractValidatorOptions = {} ): Response { return this.output(contract, 201 as keyof TContract["response"], data, options); } /** Custom text response */ text(data: string, status: number = 200): Response { const response = new Response(data, { status, headers: { "Content-Type": "text/plain" }, }); return this.withCookies(response); } /** Custom HTML response */ html(data: string, status: number = 200): Response { const response = new Response(data, { status, headers: { "Content-Type": "text/html" }, }); return this.withCookies(response); } /** Redirect response */ redirect(url: string, status: 301 | 302 | 307 | 308 = 302): Response { const response = Response.redirect(url, status); return this.withCookies(response); } /** * Create a Server-Sent Events (SSE) response. * * @example * return ctx.sse((sse) => { * sse.event("ready", { ok: true }); * const stop = sse.heartbeat(15000); * sse.onClose(() => stop()); * }); */ sse(setup?: (connection: SSEConnection) => void | Promise, options: SSEOptions = {}): Response { const connection = createSSEConnection(this.request.signal, options); if (setup) { Promise.resolve(setup(connection)).catch(() => { void connection.close(); }); } return this.withCookies(connection.response); } // ============================================ // ๐ŸฅŸ ์ƒํƒœ ์ €์žฅ (Lifecycle โ†’ Handler ์ „๋‹ฌ) // ============================================ /** Store value for later use */ set(key: string, value: T): void { this.store.set(key, value); } /** Get stored value */ get(key: string): T | undefined { return this.store.get(key) as T | undefined; } /** Check if key exists */ has(key: string): boolean { return this.store.has(key); } // ============================================ // ๐ŸฅŸ Tracing (Phase 18.ฮธ) // ============================================ /** * The currently-active tracing span for this request, or `undefined` * when tracing is disabled. Looked up via AsyncLocalStorage so it * stays accurate across nested awaits โ€” a loader that opens a child * span before hitting an external API will see `ctx.span` return * that child span until the child's `end()` fires. * * When tracing is disabled this reads as `undefined` (the runtime * never opens a recording span), keeping the hot path free of * extra allocations. */ get span(): Span | undefined { const active = getActiveSpan(); return active && active.recording ? active : undefined; } /** * Open a child span scoped to the async context of `fn`. The span * auto-ends when the returned promise resolves; if `fn` throws, the * span is marked `status=error` with the thrown message, the span * is ended, and the error re-thrown. * * When tracing is disabled this degenerates to just `await fn(noopSpan)` * with no allocation in the hot path โ€” callers don't have to branch * on `ctx.span` themselves. * * @example * const users = await ctx.startSpan("db.users.findAll", async (span) => { * span.setAttribute("db.system", "postgres"); * return await sql\`SELECT * FROM users\`; * }); */ async startSpan( name: string, fn: (span: Span) => Promise | T, opts: SpanOptions = {} ): Promise { const tracer = getTracer(); if (!tracer.enabled) { // Fast path: invoke fn with a shared no-op span (type-shape // identical, zero allocations). const { startSpan } = tracer; const span = startSpan.call(tracer, name, opts); return await fn(span); } const span = tracer.startSpan(name, opts); try { const result = await runWithSpan(span, () => fn(span)); if (span.status === "unset") span.setStatus("ok"); return result; } catch (err) { const msg = err instanceof Error ? err.message : String(err); span.setStatus("error", msg); throw err; } finally { span.end(); } } /** * Set attributes on the active request span (if any). Convenience * wrapper so user code doesn't have to null-check `ctx.span` * themselves. */ setSpanAttributes(attrs: SpanAttributes): void { this.span?.setAttributes(attrs); } } /** Route context for error reporting */ export interface ValidationRouteContext { routeId: string; pattern: string; } /** Validation error with details */ export class ValidationError extends Error { constructor( public readonly errors: unknown[], public readonly routeContext?: ValidationRouteContext ) { super("Validation failed"); this.name = "ValidationError"; } } /** * Bad request error (400 Bad Request) * * Raised when client-supplied input cannot be processed, e.g. an empty or * malformed JSON request body in {@link ManduContext.body}. This is the * client's fault and must never be classified as a framework bug (500). */ export class BadRequestError extends Error { readonly statusCode = 400; constructor(message: string = "Bad request", cause?: unknown) { super(message, { cause }); this.name = "BadRequestError"; } }