feat(ui,api-client): B4/B5 추출 — @yakcloud/ui + @yakcloud/api-client

@yakcloud/ui (15 모듈, react peer, ds-sdk type-only):
- globals.css 3분할(tokens/components/sheet.css; 680px→--sheet-body-max-height)
- icons·primitives·controls·states, LineChart 6색 CSS 토큰화(+peakColorToken)
- CardSheet/PanelSheets: apiGet 디커플(콘텐츠 prop 주입)·lockPageScroll(container?) 폴백
- decorators(statusMeta 등, ds-sdk enum 소비), getServiceMeta(type, logoBase) 로고 URL-only
- 수정: tsconfig jsx:react-jsx, @types/react(-dom), CSS exports 서브패스

@yakcloud/api-client (3 모듈, react peer, ds-sdk type-only):
- apiGet/apiSend/apiDownload + ApiClientError, usePoll(SWR·LRU·gen guard·401/403)
- /api/v1 고정 훅 제외, 서버코드 유입 0(ApiCode만 ds-sdk에서)

검증: DAG PASS(ui↛api-client, api-client↛서버), 디커플 PASS, tsc --noEmit 통과(양 패키지).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 11:41:00 +09:00
parent 7d1c92a406
commit e34a325b52
21 changed files with 1144 additions and 9 deletions

View File

@ -29,5 +29,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "^19.0.0"
}
}

View File

@ -0,0 +1,79 @@
// 클라이언트 fetch 래퍼 — 봉투 {data}/{error} 를 해제하고 오류는 ApiClientError 로 던진다.
// ⛔ 서버코드(respond/serialize)·prisma·next 유입 금지. web 표준 fetch 만 사용.
export class ApiClientError extends Error {
code: string;
status: number;
detail?: unknown;
constructor(code: string, message: string, status: number, detail?: unknown) {
super(message);
this.code = code;
this.status = status;
this.detail = detail;
}
}
async function unwrap<T>(res: Response): Promise<T> {
let body: unknown = null;
try {
body = await res.json();
} catch {
/* 비 JSON 응답 */
}
if (!res.ok) {
const e = (body as { error?: { code?: string; message?: string; detail?: unknown } })?.error;
throw new ApiClientError(
e?.code ?? "HTTP_ERROR",
e?.message ?? `요청이 실패했습니다 (${res.status}).`,
res.status,
e?.detail,
);
}
return (body as { data: T }).data;
}
export function apiGet<T>(path: string): Promise<T> {
return fetch(path, { headers: { accept: "application/json" }, cache: "no-store" }).then((r) =>
unwrap<T>(r),
);
}
export function apiSend<T>(
path: string,
method: "POST" | "PUT" | "PATCH" | "DELETE",
body?: unknown,
): Promise<T> {
return fetch(path, {
method,
headers: { "content-type": "application/json", accept: "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
cache: "no-store",
}).then((r) => unwrap<T>(r));
}
// kubeconfig 등 파일 다운로드 (봉투 아님) — Blob 을 받아 브라우저 저장.
export async function apiDownload(path: string, fallbackName: string, body?: unknown): Promise<void> {
const res = await fetch(path, {
method: "POST",
cache: "no-store",
...(body === undefined
? {}
: { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
});
if (!res.ok) {
await unwrap(res); // 오류 봉투를 ApiClientError 로 던짐
return;
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const m = /filename="?([^"]+)"?/.exec(cd);
const name = m?.[1] ?? fallbackName; // noUncheckedIndexedAccess: m[1] 은 string|undefined
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,113 @@
"use client";
// 폴링 + SWR 캐시 (클라 전용) — usePoll<T>(path, intervalMs).
// path-고정 편의 훅(useMe/useClusters/…)은 콘솔앱 전용이라 패키지에서 제외; 소비자가
// usePoll<DTO>(path, ms) 로 직접 감싼다. T 는 ds-sdk DTO(import type)로 소비 의도.
// ⛔ 서버코드(respond/serialize)·prisma·next 유입 금지. ds-sdk 는 type-only.
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet, ApiClientError } from "./api";
export interface PollState<T> {
data: T | undefined;
error: ApiClientError | null;
loading: boolean; // 최초 로딩 (아직 data 없음)
refresh: () => void;
}
function toErr(e: unknown): ApiClientError {
return e instanceof ApiClientError ? e : new ApiClientError("UNKNOWN", String(e), 0);
}
// path 별 마지막 성공 응답 캐시 — 탭 전환·페이지 재방문(remount) 시 이전 데이터를 즉시 렌더해
// 스켈레톤 깜빡임을 없애고, 백그라운드로 갱신한다(stale-while-revalidate). 캐시 키가 path(예:
// /clusters/<id>/usage) 이므로 클러스터/리소스별로 분리 보관되어 서로 섞이지 않는다.
// 클라 전용: SSR 중엔 effect 가 실행되지 않아 set 되지 않으므로 요청 간 오염이 없다.
const POLL_CACHE_MAX = 200; // 장기 세션에서 무한 증가 방지(간이 LRU).
const pollCache = new Map<string, unknown>();
function cacheSet(path: string, d: unknown): void {
pollCache.delete(path); // 재삽입으로 최근 사용 순서를 맨 뒤로(간이 LRU)
pollCache.set(path, d);
if (pollCache.size > POLL_CACHE_MAX) {
const oldest = pollCache.keys().next().value; // 가장 오래된 키 제거
if (oldest !== undefined) pollCache.delete(oldest);
}
}
export function usePoll<T>(path: string | null, intervalMs = 0): PollState<T> {
const [data, setData] = useState<T | undefined>(
() => (path ? (pollCache.get(path) as T | undefined) : undefined),
);
const [error, setError] = useState<ApiClientError | null>(null);
const [loading, setLoading] = useState(() => (path ? pollCache.get(path) === undefined : false));
const genRef = useRef(0); // 요청 세대 — 늦게 도착한 옛 응답이 최신 응답을 덮어쓰지 않게(순서 역전 방지)
const aliveRef = useRef(true);
useEffect(() => {
aliveRef.current = true;
return () => {
aliveRef.current = false;
};
}, []);
// 응답 반영/오류 처리 — 세대·마운트 가드로 stale·언마운트 후 커밋을 차단한다.
// 401/403 은 세션 무효로 보고 표시 데이터·캐시를 비운다(만료된 권한 데이터를 계속 노출하지 않도록).
const settle = useCallback(
(p: string, my: number) => ({
ok: (d: T) => {
if (!aliveRef.current || my !== genRef.current) return;
cacheSet(p, d);
setData(d);
setError(null);
setLoading(false);
},
fail: (e: unknown) => {
if (!aliveRef.current || my !== genRef.current) return;
const err = toErr(e);
setError(err);
if (err.status === 401 || err.status === 403) {
pollCache.delete(p);
setData(undefined);
}
setLoading(false);
},
}),
[],
);
// 수동 새로고침 (오류 재시도 버튼·변이 후 갱신 등). 세대 가드를 공유해 stale 커밋 차단.
const refresh = useCallback(() => {
if (!path) return;
const my = ++genRef.current;
const s = settle(path, my);
apiGet<T>(path).then(s.ok).catch(s.fail);
}, [path, settle]);
useEffect(() => {
if (!path) {
// 비활성화: 폴링만 멈추고 마지막 데이터는 유지(같은 리소스 재활성 시 즉시 렌더).
setLoading(false);
return;
}
const cached = pollCache.get(path) as T | undefined;
// 캐시가 있으면(같은 리소스 재방문) 즉시 stale 렌더 + 백그라운드 갱신,
// 없으면(첫 방문·다른 리소스) 로딩 스켈레톤부터 — 리소스 간 stale 오염 방지.
// path 가 바뀌었으니 이전 리소스의 error 는 초기화(다음 리소스 화면을 가리지 않게).
setData(cached);
setLoading(cached === undefined);
setError(null);
const run = () => {
const my = ++genRef.current;
const s = settle(path, my);
return apiGet<T>(path).then(s.ok).catch(s.fail);
};
run();
const t = intervalMs > 0 ? setInterval(run, intervalMs) : null;
return () => {
if (t) clearInterval(t);
};
}, [path, intervalMs, settle]);
return { data, error, loading, refresh };
}

View File

@ -1,4 +1,13 @@
// @yakcloud/api-client
// HTTP·폴링(클라 전용): apiGet/apiSend/apiDownload·usePoll. ⛔ 서버코드(respond) 유입 금지.
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조).
export const __package = "@yakcloud/api-client";
// [DAG] ⛔ @yakcloud/ui 참조 금지. ds-sdk 는 type-only. ⛔ prisma/next/next-auth 금지.
// ── fetch 래퍼 ──
export { apiGet, apiSend, apiDownload, ApiClientError } from "./api";
// ── 폴링 훅 ('use client') ──
export { usePoll } from "./hooks";
export type { PollState } from "./hooks";
// ── ApiCode 계약 (서버↔클라 공유, ds-sdk SSOT) ──
export type { ApiCode } from "@yakcloud/ds-sdk";