// @yakcloud/bff-kit — respond // HTTP 응답 봉투 & 오류 처리. web 표준 Request/Response(Fetch API)만 사용 — ⛔ next 정적 import 금지. // ApiCode/CODE_STATUS 는 ds-sdk 리터럴 재사용(서버→클라 역참조 방지). yakconsole api/respond.ts 하강. import { ZodError, z } from "zod"; import { CODE_STATUS } from "@yakcloud/ds-sdk"; import type { ApiCode } from "@yakcloud/ds-sdk"; // ds-sdk 계약 재export — bff-kit 소비자가 한 곳에서 코드 맵을 얻도록. export { CODE_STATUS }; export type { ApiCode }; // 기본 한국어 사용자 메시지 (i18n 은 §6-4). detail 은 기계 판독용. const DEFAULT_MSG: Record = { UNAUTHORIZED: "로그인이 필요합니다.", FORBIDDEN: "권한이 없습니다.", NOT_FOUND: "찾을 수 없습니다.", VALIDATION_FAILED: "입력값이 올바르지 않습니다.", NAME_TAKEN: "이미 사용 중인 이름입니다.", CONFLICT: "다른 리소스가 사용 중이라 처리할 수 없습니다.", NAME_RESERVED: "시스템이 예약한 이름이라 사용할 수 없습니다.", QUOTA_EXCEEDED: "쿼터를 초과했습니다.", CAPACITY_UNAVAILABLE: "현재 용량이 부족합니다. 잠시 후 다시 시도해 주세요.", RANCHER_UPSTREAM_ERROR: "백엔드 통신 오류가 발생했습니다.", RATE_LIMITED: "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.", NOT_IMPLEMENTED: "아직 제공되지 않는 기능입니다.", INTERNAL: "서버 오류가 발생했습니다.", }; export class ApiError extends Error { readonly code: ApiCode; readonly status: number; readonly detail?: unknown; constructor(code: ApiCode, message?: string, detail?: unknown) { super(message ?? DEFAULT_MSG[code]); this.name = "ApiError"; this.code = code; this.status = CODE_STATUS[code]; this.detail = detail; } } // success 봉투: { data }. init 으로 상태·헤더 설정 가능. export function ok(data: T, init?: ResponseInit): Response { return Response.json({ data }, init); } // 202 Accepted + (선택) Location 헤더 — 비동기 프로비저닝 응답. export function accepted(data: T, location?: string): Response { const headers = location ? { Location: location } : undefined; return Response.json({ data }, { status: 202, headers }); } // error 봉투: { error: { code, message, detail? } } export function fail(err: ApiError): Response { const body = { error: { code: err.code, message: err.message, ...(err.detail !== undefined ? { detail: err.detail } : {}), }, }; return Response.json(body, { status: err.status }); } // handle(): 라우트 본문을 감싸 던진 ApiError/ZodError/Prisma P2002/미지 오류를 봉투로 직렬화. // ⚠️ Prisma 를 정적 import 하지 않는다 — 던져진 오류의 구조(code === "P2002")만 덕타이핑으로 검사. export function handle( fn: (...args: A) => Promise | Response, ): (...args: A) => Promise { return async (...args: A): Promise => { try { return await fn(...args); } catch (e) { if (e instanceof ApiError) return fail(e); if (e instanceof ZodError) { // zod v4: 인스턴스 .flatten() 제거됨 → z.flattenError 사용. return fail(new ApiError("VALIDATION_FAILED", undefined, z.flattenError(e))); } // Prisma 유니크 위반(P2002): TOCTOU 경합에서 발생 가능 → 409(재시도 가능)로 매핑. if ( e !== null && typeof e === "object" && "code" in e && (e as { code?: unknown }).code === "P2002" ) { return fail(new ApiError("NAME_TAKEN", "값이 충돌했습니다. 잠시 후 다시 시도해 주세요.")); } console.error("[bff] unhandled", e); return fail(new ApiError("INTERNAL")); } }; } // JSON 본문을 zod 스키마로 파싱·검증 (ZodError → 422 via handle). export async function parseBody( req: Request, schema: S, ): Promise> { let raw: unknown; try { raw = await req.json(); } catch { throw new ApiError("VALIDATION_FAILED", "요청 본문이 JSON이 아닙니다."); } return schema.parse(raw) as z.infer; }