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:
@ -29,5 +29,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
79
packages/api-client/src/api.ts
Normal file
79
packages/api-client/src/api.ts
Normal 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);
|
||||
}
|
||||
113
packages/api-client/src/hooks.ts
Normal file
113
packages/api-client/src/hooks.ts
Normal 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 };
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Reference in New Issue
Block a user