@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>
108 lines
4.2 KiB
TypeScript
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>;
|
|
}
|