Files
yakcloud-packages/packages/bff-kit/src/respond.ts
jungwoo choi 362e3168a7 feat(auth-adapter,bff-kit,client-shell): 마지막 추출 배치
@yakcloud/auth-adapter: core 인터페이스(프레임워크 무관, ds-sdk type-only)
  + dev-bypass + next-auth 격리 계층(peer). exports 서브패스(./core·./dev-bypass).
@yakcloud/bff-kit: respond(web 표준)·serialize(Prisma-free)·ratelimit·session(AuthAdapter DI).
  auth-adapter=devDependency(type-only), 죽은 next peer 제거.
@yakcloud/client-shell: ServiceClientShell 셸 크롬(헤더/탭/pane/가드)+패널 슬롯+BackupPanel 스텁.
  (FileBrowser 는 Phase 1)

FIX-FIRST 반영: client-shell tsconfig jsx:react-jsx + @types/react(-dom),
  bff-kit/auth-adapter += @types/node. 7개 패키지 전부 tsc --noEmit 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-25 13:12:34 +09:00

108 lines
4.2 KiB
TypeScript

// @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<ApiCode, string> = {
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<T>(data: T, init?: ResponseInit): Response {
return Response.json({ data }, init);
}
// 202 Accepted + (선택) Location 헤더 — 비동기 프로비저닝 응답.
export function accepted<T>(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<A extends unknown[]>(
fn: (...args: A) => Promise<Response> | Response,
): (...args: A) => Promise<Response> {
return async (...args: A): Promise<Response> => {
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<S extends z.ZodType>(
req: Request,
schema: S,
): Promise<z.infer<S>> {
let raw: unknown;
try {
raw = await req.json();
} catch {
throw new ApiError("VALIDATION_FAILED", "요청 본문이 JSON이 아닙니다.");
}
return schema.parse(raw) as z.infer<S>;
}