본문으로 건너뛰기
언어

@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서버·브라우저·proxymergeOptions, 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);
호출동작
mergeOptionsundefined 는 기본값을 쓰고, 배열은 통째로 바꾸며, __proto__ 는 무시합니다.
load 의 Result예상한 실패를 던지지 않고 값으로 돌려줍니다. 서버 액션 결과(ActionResult<T>)와 같은 모양입니다.
singleFlight제출 버튼을 두 번 눌러도 진행 중이면 같은 프라미스를 같이 기다립니다.
retry지수 백오프와 완전 지터로 다시 시도하고, 취소하면 더 시도하지 않습니다.
parseJsonlocalStorage 의 깨진 값에도 던지지 않고 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 의존성이라 패키지 관리자가 대신 설치하지도, 없다고 알리지도 않습니다. 둘 중 하나라도 없으면 /cn import 가 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 만 씁니다 — 가짜 타이머로 시험할 수 있습니다.