@plott/webkit-utils0.4.0
프레임워크 없는 공통 도구 — 옵션 병합(프로토타입 오염 방어), 결과 값(Result), invariant, 디바운스·스로틀, 동시성 제한·재시도(AbortSignal), single-flight·latest-only, 안전한 JSON 읽기, id, cn(clsx + tailwind-merge)
설치
pnpm
pnpm add @plott/webkit-utils@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-utils | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-utils/cn | ./dist/cn.d.ts | ./dist/cn.js |
프레임워크 없이 쓰는 공통 도구 모음입니다. 읽는 사람은 앱·서버 액션·proxy 에서 같은 도우미를 레포마다 다시 쓰던 프론트엔드 개발자입니다. 훅은 @plott/webkit-react 에 있습니다.
React 도, DOM 전역(window·document)도 불러올 때 쓰지 않습니다. 서버 컴포넌트·Node·브라우저 어디서나 import 합니다.
진입점
| 진입점 | 어디서 | 내보내는 것 |
|---|---|---|
@plott/webkit-utils | 서버·브라우저·proxy | mergeOptions, Result·ok·err·isOk·isErr·unwrap·unwrapOr·fromPromise, invariant, debounce·throttle·debounceAsync, pool·pLimit, retry·sleep·withTimeout, singleFlight·latestOnly, parseJson·safeStringify, id, isServer·isBrowser, noop·once, 오류 클래스(AbortError·SupersededError·TimeoutError·ResultFailure), 목록 응답 어댑터(PageAdapter·PageView·springPage·itemsPage·PAGE_PRESETS·resolvePageAdapter), StandardSchemaV1 타입 |
@plott/webkit-utils/cn | 서버·브라우저 | cn, createCn, ClassValue. 이 경로는 clsx·tailwind-merge 가 설치돼 있어야 합니다(둘 다 peer 의존성) |
목록 응답 어댑터
백엔드마다 목록 응답 모양이 다릅니다. PageAdapter 는 응답을 PageView(items, hasMore?, total?, pageCount?, number? — 0부터 센 쪽 번호)로 바꾸는 함수이고, 모양이 아니면 undefined 를 돌려줍니다. 이름 있는 프리셋은 spring(content·totalElements·totalPages·number·last)과 items(items·total·pageCount·number·hasMore)이며 resolvePageAdapter 가 이름 또는 함수를 받아 함수로 돌려줍니다. 모르는 이름은 던집니다. 기본 프리셋은 없습니다 — 부르는 쪽이 고릅니다.
붙이기
ts
import { err, fromPromise, mergeOptions, ok, parseJson, retry, singleFlight, type Result } from "@plott/webkit-utils";
type Item = { id: string; name: string };type Cart = { items: string[] };declare const userOptions: { retries?: number } | undefined;declare const api: { createOrder(form: FormData): Promise<void> };declare const form: FormData;declare const url: string;declare const signal: AbortSignal;declare const check: (response: Response) => Response;
const opts = mergeOptions(userOptions, { retries: 3, tags: ["web"] });
async function load(id: string): Promise<Result<Item>> { const r = await fromPromise(fetch(`/items/${id}`).then((x) => x.json() as Promise<Item>)); return r.ok ? ok(r.data) : err({ code: "room_load_failed", message: r.error.message });}
const submit = singleFlight(() => api.createOrder(form));
const data = await retry((attempt, signal) => fetch(url, { signal }).then(check), { retries: 4, signal });
const cart = parseJson<Cart>(localStorage.getItem("cart"), { fallback: { items: [] } });ts
import { cn } from "@plott/webkit-utils/cn";
declare const active: boolean;declare const props: { className?: string };
export const className = cn("px-2 py-1", active && "bg-blue-500", props.className);| 호출 | 동작 |
|---|---|
mergeOptions | undefined 는 기본값을 쓰고, 배열은 통째로 바꾸며, __proto__ 는 무시합니다. |
load 의 Result | 예상한 실패를 던지지 않고 값으로 돌려줍니다. 서버 액션 결과(ActionResult<T>)와 같은 모양입니다. |
singleFlight | 제출 버튼을 두 번 눌러도 진행 중이면 같은 프라미스를 같이 기다립니다. |
retry | 지수 백오프와 완전 지터로 다시 시도하고, 취소하면 더 시도하지 않습니다. |
parseJson | localStorage 의 깨진 값에도 던지지 않고 fallback 을 돌려줍니다. |
타이밍과 동시성
ts
import { debounce, debounceAsync, latestOnly, pLimit, pool, throttle } from "@plott/webkit-utils";
declare const api: { save(text: string): Promise<void>; search(q: string): Promise<string[]>; get(id: string): Promise<unknown> };declare const measure: () => void;declare const urls: string[];declare const ids: string[];
const save = debounce((text: string) => api.save(text), { wait: 300, maxWait: 2000 });save.flush(); save.cancel(); save.pending();
const search = debounceAsync((q: string) => api.search(q), 250);const onScroll = throttle(measure, 100);
const results = await pool(urls, (url, _i, signal) => fetch(url, { signal }), { concurrency: 4, stopOnError: false });const limit = pLimit(3);await Promise.all(ids.map((id) => limit(() => api.get(id))));
const latest = latestOnly((signal, q: string) => fetch(`/s?q=${q}`, { signal }).then((r) => r.json()));debounceAsync 는 한 묶음의 호출자 모두에게 마지막 결과를 돌려줍니다.
기본으로 지키는 것
- 옵션 병합이 기본값을 지웁니다 — 막습니다.
mergeOptions는undefined·null을 «기본값 사용» 으로 읽어,{ timeout: undefined }가 기본 timeout 을 지우지 않습니다({...defaults, ...opts}는 지웁니다). 0·false·빈 문자열은 값입니다. 입력 객체는 바꾸지 않습니다. - 배열이 이어 붙지 않습니다. defu 기본은
["a"]를 기본["x","y"]뒤에 붙입니다.mergeOptions는 바꿉니다. - 결과를 고쳐도 기본값이 바뀌지 않습니다.
mergeOptions의 결과는 일반 객체·배열을 깊게 복제하므로, 모듈 상수인 기본값의 배열에push해도 다음 호출에 새지 않습니다. 클래스 인스턴스·Date·Map·Set·함수는 복제하지 않고 같은 참조입니다. 기본값을 일부러 지우려면{ timeout: mergeOptions.unset }(undefined·null은 «기본값 사용» 이라 지우지 못합니다). __proto__·constructor키로 오염되지 않습니다. 입력에 이 키가 있어도 무시하고,Object.prototype은 바뀌지 않습니다.Result는 일반 객체입니다. 클래스 인스턴스는 서버 컴포넌트 → 클라이언트 경계를 건너지 못합니다.ok/err는{ ok, data }/{ ok, error }뿐이고, 기본 실패 모양(ResultError)은{ code, message? }뿐입니다.@plott/webkit-next의ActionResult<T>는Result<T, ActionFailure>이고ActionFailure가ResultError를 넓힙니다.unwrap이 던지는ResultFailure는 원래 실패 값을error에 담습니다.- 디바운스·스로틀이 마지막 호출을 잃지 않습니다. 모든 실행은 마지막 호출 정확히
wait뒤이고(디바운스), 실행 간격은 늘wait이상이며 마지막 호출은 실행됩니다(스로틀).maxWait는 호출이 이어져도 굶기지 않습니다.signal이 취소되면 예약을 버립니다. - 콜백 안의 재호출이 사라지거나 간격을 어기지 않습니다. 디바운스 콜백이 스스로를 다시 부르면 그 호출은 새 묶음으로 예약되고, 스로틀은 콜백을 실행하기 전에 간격 타이머를 열어 안쪽 호출도 간격을 지키며 타이머는 한 번에 하나뿐이라
cancel()이 모두 멈춥니다. - 시간 값이 조용히 바뀌지 않습니다.
debounce·throttle·sleep·withTimeout은 NaN·음수·2^31-1(약 24.8일)을 넘는 값·Infinity 를RangeError로 거절합니다(타이머는 큰 값을 1ms 로 바꿔 즉시 실행하고, NaN 을 0 으로 읽으면 «기다리지 않는» 버그가 숨습니다).sleep·withTimeout은 던지지 않고 거절된 프라미스를 돌려줍니다.retry의 기다림은 이 범위로 묶습니다. - 기다리는 사람이 없는 취소가
unhandledrejection이 되지 않습니다.latestOnly의 밀려난 호출,debounceAsync의cancel()·signal취소는 프라미스를 «처리됨» 으로 표시합니다(await하면 거절을 평소처럼 받습니다.fn이 던진 진짜 오류는 표시하지 않습니다). 거절 대신latestOnly(fn, { onSuperseded: "pending" | "resolve-undefined" })로 끝낼 수도 있습니다. - 동시성 제한이 넘치지 않습니다.
pool·pLimit은concurrency를 넘기지 않고, 결과는 입력 순서입니다. 실패는 항목마다{ status: "rejected" }로 남고pool자체는 던지지 않습니다.stopOnError·signal로 멈추면 시작 못 한 항목은AbortError로 기록합니다. - 재시도가 취소 뒤에 돌지 않습니다. 신호가 취소되면 기다림을 끊고 다시 시도하지 않으며, 기본
shouldRetry는AbortError를 재시도하지 않습니다. 기다림은0 ~ maxDelayMs로 묶입니다. 시도 수는1 + retries입니다. - 늦게 온 응답이 새 응답을 덮어쓰지 않습니다.
latestOnly는 앞선 호출의 신호를 취소하고 그 프라미스를SupersededError(AbortError의 하위,name은"AbortError")로 거절합니다 — «취소됨» 으로 거르는 기존 코드가 그대로 잡습니다. - 이중 제출이 요청을 두 번 만들지 않습니다.
singleFlight는 진행 중이면 같은 프라미스를 돌려주고, 끝난 뒤(성공·실패 모두)에는 새로 시작합니다. 동기로 던지는 함수도 거절로 바뀝니다.singleFlight.keyed는 키별입니다. - JSON 읽기가 던지지 않고 오염시키지 않습니다.
parseJson은 문자열이 아닌 입력(localStorage.getItem의null)·깨진 문자열을Result실패(invalid_json/invalid_shape)로 돌려주고,__proto__키와prototype을 가진constructor값을 지웁니다.safeStringify는 순환([Circular])·BigInt(십진 문자열)·너무 깊은 곳([MaxDepth])·던지는 getter 에서도 던지지 않습니다. id()가 약한 난수로 물러서지 않습니다.crypto.randomUUID, 없으면crypto.getRandomValues로 v4 UUID 를 만들고, 둘 다 없으면Math.random대신 던집니다(비보안 컨텍스트에서randomUUID가 없는 경우를 흡수).isServer()·isBrowser()는 부를 때 판정합니다. 모듈 상수로 굳히지 않으므로 번들 시점 값이 섞이지 않습니다.cn은 충돌하는 Tailwind 클래스에서 뒤의 것을 남깁니다. 디자인 시스템의 커스텀 클래스는createCn({ extend: { classGroups: … } })로 알려 줍니다./cn은clsx와tailwind-merge를 앱이 직접 설치해야 씁니다. 둘은 선택 peer 의존성이라 패키지 관리자가 대신 설치하지도, 없다고 알리지도 않습니다. 둘 중 하나라도 없으면/cnimport 가ERR_MODULE_NOT_FOUND로 멈춥니다. 루트 경로(@plott/webkit-utils)는 둘에 기대지 않으므로, 루트만 쓰는 앱에는 두 패키지가 설치되지 않습니다.
그 밖
- shadcn 4 계열 앱은
@plott/webkit-utils/cn이 아니라cn패키지를 씁니다. 그 앱의 stock 컴포넌트가import { cn } from "cn"으로 부르기 때문입니다. - 옵션과 기본값(숫자 하나를 주면
wait·retries·concurrency·ms로 읽습니다).debounce:wait(마지막 호출 뒤 기다림 ms),leading(기본 false),trailing(기본 true),maxWait(기본 없음),signal(취소되면 예약을 버리고 이후 호출을 무시).throttle:wait,leading(기본 true),trailing(기본 true),signal.debounceAsync:wait,maxWait,signal.cancel()은 기다리던 호출을AbortError로 거절합니다.retry:retries(기본 3),minDelayMs(100),maxDelayMs(10000),factor(2),jitter("full"),shouldRetry,signal,onRetry(던져도 재시도는 이어집니다),random.pool:concurrency(1 이상의 정수 또는 Infinity),signal,stopOnError(기본 false).withTimeout:ms,signal,message. 함수를 넘기면 시간이 지났을 때 그 신호도 취소됩니다.parseJson:fallback(키가 있으면undefined도 값),reviver(위험한 키를 지운 뒤에 불립니다),schema(함수 또는parse를 가진 객체, 던지면invalid_shape).safeStringify:space,maxDepth(기본 64).latestOnly:onSuperseded("reject"기본,"pending","resolve-undefined").
MAX_DELAY(2^31-1)와checkDelay(ms, name)로 같은 시간 검사를 직접 쓸 수 있습니다.- 프로그래밍 실수로 던지는 오류는
@plott/webkit-utils:로 시작합니다(invariant, 잘못된wait·concurrency·retries). sideEffects: false이며 전역을 바꾸지 않습니다.- 타이머 함수(
debounce·throttle·sleep·retry)는setTimeout만 씁니다 — 가짜 타이머로 시험할 수 있습니다.