@plott/webkit-api0.4.0
HTTP API 클라이언트 — 서버마다 인스턴스를 만들고 플러그인(미들웨어)으로 요청 ID·로케일·추적 헤더·인증을 끼운다. Next 서버 연동은 @plott/webkit-api/next
설치
pnpm add @plott/webkit-api@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-api | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-api/next | ./dist/next/index.d.ts | ./dist/next/index.js |
@plott/webkit-api/swr | ./dist/swr.d.ts | ./dist/swr.js |
Next.js(App Router) 앱이 업스트림 HTTP API 를 부르는 클라이언트입니다. 읽는 사람은 앱에 API 클라이언트를 만드는 프론트엔드 개발자입니다. 업스트림 쪽에서 받는 헤더의 계약은 업스트림 헤더 계약에 있습니다.
서버(주소)마다 클라이언트를 하나씩 만들고, 그 서버에 실을 것(요청 ID·로케일·추적 식별자·인증)을 플러그인(미들웨어)으로 고릅니다. 인증은 @plott/webkit-auth 가 주는 플러그인(auth.bearer())입니다.
서드파티 런타임 의존성이 없습니다(같은 저장소의 @plott/webkit-utils 만 씁니다). @plott/webkit-api/next 를 쓸 때만 peer next >=16.4.1 <17, react ^19.3 이 필요합니다.
진입점
| import | 어디서 | 주요 export |
|---|---|---|
@plott/webkit-api | 어디서나(proxy·서버·브라우저) | createApiClient, jsonBody, resolveApiUrl, isWithinClient, 플러그인 모양(ApiPlugin·ApiRequest), headerPlugin, timeout, 오류(ApiError·ApiNetworkError·ForbiddenOriginError·MissingValueError), 경로·쿼리 path·toQueryString, 헤더 계약 defineHeaders·DEFAULT_HEADERS·HEADER_PRESETS·HEADER_KEYS, proxy 도구 prepareRequestHeaders·clientIpFrom·sanitizeRequestId, 응답 검증 validateResponse·unwrapEnvelope·resolveEnvelope, 오류 본문 파서 rfc9457Errors·fieldErrors·ERROR_PARSER_PRESETS·resolveErrorParser, 안전한 쓰기 idempotencyKey·retry·ifMatch·concurrency, 페이지 collectPages·paginate(어댑터는 @plott/webkit-utils 의 PageAdapter) |
@plott/webkit-api/swr | 브라우저(React 없이) | createFetcher, swrDefaults, isNotFound·isUnauthorized·isForbidden·isTransient |
@plott/webkit-api/next | 서버 컴포넌트·서버 액션·라우트 핸들러 | 들어온 요청 getIncoming·runWithIncoming, 플러그인 requestContext(requestId·locale·forwardHeaders), clientInfo, tracking(ga4·googleAds·meta)·serializeConsent, cookieHeader |
붙이기
클라이언트 만들기
앱 루트의 api.ts 에 둡니다(인증 설정 auth.ts 옆).
import { createApiClient, defineHeaders, type ApiPlugin } from "@plott/webkit-api";import { clientInfo, requestContext, tracking, type ConsentResolver } from "@plott/webkit-api/next";
declare const auth: { bearer(): ApiPlugin };declare const consent: ConsentResolver;
const headers = defineHeaders("full");
const common = [ requestContext({ forward: ["x-app-version"], localeFrom: "x-request-locale", headers }), clientInfo({ headers }), tracking({ consent, vendors: ["ga4", "googleAds", "meta"], measurementId: "G-XXXXXXX", origin: process.env.APP_ORIGIN, headers }),];
export const api = createApiClient({ name: "main", baseUrl: process.env.API_BASE!, use: [...common, auth.bearer()] });export const payments = createApiClient({ name: "payments", baseUrl: process.env.PAYMENTS_API!, use: [...common, auth.bearer()] });export const catalog = createApiClient({ name: "catalog", baseUrl: process.env.CATALOG_API!, use: [requestContext()] });import type { ApiClient } from "@plott/webkit-api";
declare const api: ApiClient;type Me = { id: string };
const me = await api.json<Me>("/me");const res = await api("/files/1", { method: "GET" });api.json 은 2xx 면 JSON, 그 밖은 오류입니다. api(...) 는 Response 를 그대로 돌려주며 상태 코드로 던지지 않습니다.
| 옵션 | 기본값 | 뜻 |
|---|---|---|
baseUrl | (필수) | 이 클라이언트의 서버. 상대 경로는 이 경로 아래로만 갑니다(../ 로 벗어나면 ForbiddenOriginError) |
allowedOrigins | 없음 | 절대 주소로 부를 수 있는 다른 오리진. baseUrl 의 오리진은 언제나 들어 있습니다 |
use | 없음 | 플러그인. 배열을 넣어도 되고, false·null 은 건너뜁니다 |
name | api | 로그와 ApiError.client 에 쓰는 이름 |
init | 없음 | 모든 요청의 기본값(헤더·cache 등). 호출자가 넣은 것으로 봅니다 |
fetch | 전역 fetch | 부를 때마다 찾습니다 |
readTimeoutMs | 30000 | 읽기(GET·HEAD)의 제한 시간. false 면 없습니다. 쓰기에는 기본 제한 시간이 없습니다 |
timeoutMs | 없음 | 쓰기를 포함한 모든 메서드의 기본 제한 시간. 정하면 readTimeoutMs 보다 앞서고 호출의 timeoutMs 가 다시 앞섭니다 |
refuseMissingLiterals | false | true 면 이어 붙여 만든 주소의 경로 세그먼트·쿼리 값이 글자 undefined·null·NaN 일 때 보내기 전에 TypeError 로 멈춥니다 |
arrayFormat | repeat | query 의 배열 모양. repeat 은 ids=1&ids=2, comma 는 ids=1,2 |
envelope | 없음 | 모든 json() 이 봉투를 풀게 합니다. 객체(codeKey·dataKey·isSuccess 필수) 또는 프리셋 이름 "code-body-msg" |
redactIssues | true | 스키마 검증 오류 issues 의 가림 |
errorBodyTimeoutMs | 1000 | 실패 응답 본문을 기다리는 최대 시간(ms) |
errorBodyMaxBytes | 262144 | 실패 응답 본문을 읽는 최대 바이트. 양의 정수 |
snippetMaxBytes | 4096 | JSON 이 아닌 응답에서 ApiError.body 로 남기는 조각을 읽는 최대 바이트. 양의 정수 |
parseError | rfc9457 | 실패 본문에서 problem·fieldErrors 를 읽는 파서. 프리셋 이름("rfc9457"·"field-errors") 또는 (body) => { problem?, fieldErrors? } 함수 |
encodedSeparators | "reject" | 경로 세그먼트 안의 인코드한 구분자(%2F·%5C)를 어떻게 할지. "allow" 는 그 세그먼트를 그대로 보냅니다(아래 «경로와 쿼리»). extend() 는 같은 서버일 때만 이 값을 잇습니다 |
호출마다 주는 옵션은 fetch 의 RequestInit 에 query, timeoutMs, schema, envelope 이 더해진 것입니다. timeoutMs 는 그 호출의 제한 시간(ms)으로 readTimeoutMs 를 덮습니다. 쓰기에도 걸 수 있고, false 면 읽기의 기본 제한 시간도 없앱니다. 숫자를 주면 await api(...) 가 돌려준 Response 의 본문을 읽는 동안에도 그 시간이 계속 걸립니다.
import type { ApiClient } from "@plott/webkit-api";
declare const api: ApiClient;
await api.json("/reports/1", { timeoutMs: 120_000 });await api("/export", { method: "POST", timeoutMs: 10_000 });짧은 이름 api.get(path, init?)·api.delete(path, init?)·api.post(path, body?, init?)·api.put·api.patch 는 json() 에 메서드를 붙인 것입니다. 본문이 평범한 객체·배열이면 JSON 으로 보내고 content-type: application/json 을 정합니다(호출이 정한 content-type 이 먼저). 문자열·FormData·Blob·URLSearchParams·바이트·스트림은 그대로 보냅니다.
post(path, null) 은 본문 없음이고 post(path, "str") 은 문자열 «그대로»(JSON 아님)입니다. JSON null·JSON 문자열을 보내려면 jsonBody(값) 으로 감쌉니다. 언제나 JSON 으로 인코드하고 content-type: application/json 을 정합니다. JSON 으로 만들 수 없는 값(undefined·함수)은 보내기 전에 TypeError 입니다.
import { ifMatch, jsonBody, path, type ApiClient } from "@plott/webkit-api";
declare const api: ApiClient;declare const id: string;type Item = { name: string; version: number };
await api.put("/flags/dark-mode", jsonBody(null));const item = await api.post<Item>("/items", { name: "A" });await api.put(path`/items/${id}`, { name: "B" }, ifMatch(item.version));api.extend({ name, baseUrl, use, without }) 는 이 클라이언트를 바탕으로 새 인스턴스를 만듭니다. 플러그인은 부모 것 뒤에 use 를 더하고, without 에 적은 이름은 뺍니다. 없는 이름이면 멈춥니다.
import type { ApiClient } from "@plott/webkit-api";
declare const api: ApiClient;
const publicApi = api.extend({ name: "public", without: ["auth"] });응답 검증과 봉투
schema 에 Standard Schema(zod·valibot·arktype)를 주면 json() 이 검증하고, 결과 타입은 스키마의 출력 타입입니다. 어긋나면 ApiError 의 kind 와 code 가 invalid_response 이고 issues(경로와 메시지), status, url 이 실립니다. 응답 값은 싣지 않고, ApiError.message 에는 개수만 있습니다(failed validation (2 issues)). issues 는 서버 로그용입니다. 브라우저로 내보내지 않습니다.
import type { ApiClient, StandardSchemaV1 } from "@plott/webkit-api";
declare const api: ApiClient;declare const MeSchema: StandardSchemaV1<unknown, { id: string }>;
const me = await api.get("/me", { schema: MeSchema });봉투는 envelope 로 풉니다(클라이언트 옵션 또는 호출 옵션). 봉투 모양은 호출하는 쪽이 이름을 붙입니다 — 기본 봉투는 없습니다. envelope: "code-body-msg" 는 { code, body, msg } 모양의 이름 붙은 프리셋이고, 호출의 envelope 이 클라이언트 설정보다 앞섭니다. 호출에서 false 를 주면 그 호출만 끕니다. 다른 모양은 객체로 적습니다(codeKey·dataKey·isSuccess 는 필수, messageKey 는 선택). 프리셋이 성공으로 보는 코드는 0·"0"·200·"200" 뿐이라 "0000"·"SUCCESS" 같은 코드를 쓰는 서비스는 isSuccess 를 줍니다. 204·205·HEAD 처럼 본문이 없는 응답은 봉투를 풀지 않고 null 입니다. 실패면 HTTP 200 이어도 kind: "envelope" 인 ApiError(code 는 봉투의 코드 문자열, body 는 봉투)입니다. 봉투를 푼 뒤의 데이터를 schema 가 검증합니다.
안전한 쓰기
| 쓰는 것 | 하는 일 |
|---|---|
idempotencyKey({ header?, methods?, generate? }) | POST·PATCH 에 Idempotency-Key 를 싣습니다. 논리적 호출 하나에 키 하나이며 클라이언트가 다시 보내도 같은 키입니다. 호출이 헤더로 키를 주면 그대로 씁니다. 더블클릭을 막으려면 폼마다 newIdempotencyKey() 를 한 번 만들어 모든 제출에 같은 값을 줍니다 |
retry({ retries?, methods?, statuses?, delayMs?, idempotencyHeader?, jitter?, random? }) | 전송 실패(제한 시간 포함)와 429·502·503·504 를 다시 보냅니다. GET·HEAD·OPTIONS·PUT·DELETE 와, 멱등 키가 실린 POST·PATCH 만 다시 보냅니다. 스트림 본문은 되풀이하지 않습니다. 시도마다 새 init·헤더 사본을 안쪽 플러그인에 넘기므로 안쪽의 timeout(ms) 는 시도마다 새 제한 시간이고(바깥에 두면 재시도 전체의 제한), 대기는 호출자의 signal 로 끊습니다. 지연은 백오프(200·400·800ms…, 최대 2초)에 «전체 지터»(0~백오프 사이 무작위, jitter: "none" 이면 끔)를 적용하고, Retry-After(초·날짜)는 최소 지연으로 따릅니다(0·과거 날짜는 백오프 아래로 내리지 못함, 최대 10초) |
ifMatch(version, init?) | If-Match 를 더한 호출 옵션을 돌려줍니다. 숫자·문자열은 "7" 처럼 감싸고, ETag·* 는 그대로 둡니다. 버전이 비면 MissingValueError, 따옴표·줄바꿈이 섞이면 TypeError |
concurrency() | 412 → code: "precondition_failed", 428 → precondition_required, 409 → conflict(서비스 코드가 없을 때만). isPreconditionFailed(e)·isConflict(e) |
import { concurrency, createApiClient, idempotencyKey, retry } from "@plott/webkit-api";
declare const baseUrl: string;
createApiClient({ baseUrl, use: [idempotencyKey(), retry(), concurrency()] });페이지 넘기기
collectPages(fetchPage, { adapter, maxPages = 50, signal, startPage = 0, keyOf? }) 는 { items, pages, truncated, error? } 를 돌려줍니다. adapter 는 필수이고 응답 모양을 고릅니다 — 프리셋 이름("spring": content·totalPages·number·last, "items": items·pageCount·number·hasMore) 또는 (raw) => PageView | undefined 함수(@plott/webkit-utils 의 PageAdapter)입니다. 어댑터가 undefined 를 주거나 items 가 배열이 아니면 TypeError 입니다. startPage 는 «이 API 의 첫 페이지 번호»(0부터 세는 API 는 0, 1부터 세는 API 는 1)이고, 마지막 판단(pageCount)은 서버가 돌려주는 number 가 아니라 가져온 페이지 수로 합니다. hasMore 가 있으면 그것이 먼저입니다. 서버가 페이지 번호를 무시해 같은 number 가 다시 오거나(keyOf 를 주면) 같은 첫 항목 키가 다시 오면 중복해 쌓지 않고 error 로 멈춥니다. 오류를 [] 로 삼키지 않고, 가져온 페이지의 항목과 끊은 error 가 같이 오며 truncated 가 true 입니다. 상한에 닿아도 truncated: true 입니다. paginate 는 같은 일을 페이지 단위 비동기 반복자(for await)로 하며 오류는 던집니다. maxPages 가 양의 정수가 아니면 RangeError 입니다. 어댑터와 프리셋 정의는 @plott/webkit-utils 가 정본입니다.
import { collectPages, type ApiClient, type SpringPage } from "@plott/webkit-api";
declare const api: ApiClient;type Item = { name: string };
const { items, truncated, error } = await collectPages<Item>((page) => api.get<SpringPage<Item>>("/items", { query: { page, size: 100 } }), { adapter: "spring",});브라우저의 SWR·React Query
React 를 import 하지 않습니다. createFetcher({ basePath?, baseUrl?, allowedOrigins?, token?, headers?, onUnauthorized?, timeoutMs?, fetch? }) 가 { fetcher, json, client } 를 돌려주고, 오류는 서버 쪽과 같은 ApiError·ApiNetworkError 입니다.
baseUrl은 브라우저에서location.origin이 기본이고, 브라우저 밖에서 주지 않으면 멈춥니다.basePath는 상대 키 앞에 한 번만 붙고(키가 이미/app/…이면 또 붙이지 않음), 이 앱의 오리진으로 가는 절대 주소 키도 같게 다룹니다.- 다른 오리진(파일 서버 등)의 절대 키는
allowedOrigins에 적어야 하고, 적지 않은 오리진은ForbiddenOriginError입니다. token()이 비어 있으면(null·빈 문자열·Bearer뿐) 또는 던지거나 거절하면Authorization을 보내지 않습니다.headers의 빈Authorization도 뺍니다.timeoutMs를 주면fetcher·json뿐 아니라client.get/post/put/patch/delete에도 같이 걸립니다. 주지 않으면 클라이언트 기본값(읽기 30초, 쓰기 제한 없음)을 따릅니다. 쓰기에 제한을 걸 때는idempotencyKey()를 함께 씁니다.- 세션 미들웨어가 API 요청을 로그인 화면으로 리다이렉트하면 브라우저(
redirect: "manual")는 불투명 리다이렉트(status 0)를 줍니다. 이를kind: "redirect"인ApiError로 바꾸고onUnauthorized도 부릅니다.onUnauthorized가 던져도 원래 오류가 호출자에게 갑니다. - 판별 함수
isNotFound·isUnauthorized·isForbidden·isTransient와,swrDefaults({ retry?, retryCount?, retryIntervalMs?, transientStatuses? })(SWR 설정 객체)가 있습니다. 일시 오류(isTransient: 연결 실패·제한 시간, 408·425·429, 501 을 뺀 5xx.transientStatuses를 주면 연결 실패·제한 시간과 그 목록의 상태만)만 SWR 의 지수 백오프로 다시 시도하고 그 밖의 4xx 는 다시 시도하지 않습니다. 기본 횟수는 3, 간격은 3000ms 입니다.
import { createFetcher, swrDefaults } from "@plott/webkit-api/swr";
declare const session: { accessToken: string };declare function signOut(): void;
const { fetcher } = createFetcher({ basePath: "/app", token: () => session.accessToken, onUnauthorized: () => signOut() });export const swrConfig = { fetcher, ...swrDefaults() };swrConfig 를 <SWRConfig value={swrConfig}> 에 넘깁니다.
경로와 쿼리
import { path, type ApiClient } from "@plott/webkit-api";
declare const api: ApiClient;declare const goodsId: string;declare const q: string;declare const page: number;
await api.json(path`/goods/${goodsId}/images`);await api.json("/search", { query: { q, page, region: undefined } });- «빠진 값»은 글자가 아니라 JS 값으로 판단합니다.
path는 값이undefined·null·NaN·빈 문자열이면MissingValueError(TypeError의 하위 클래스)를 던지고,query는undefined·null은 빼고NaN이면MissingValueError를 던집니다. 오류 종류는 메시지가 아니라isMissingValueError(e)로 확인합니다. - 문자열
"null"·"undefined"·"NaN"은 정상 값일 수 있습니다(?q=null,/tags/undefined). 그래서 이어 붙여 만든 주소의 글자는 기본으로 검사하지 않고 그대로 보냅니다. 검사하려면createApiClient({ refuseMissingLiterals: true })입니다. 이 옵션을 켠 클라이언트를 브라우저 쿼리를 그대로 넘기는 BFF 에 물리면 브라우저의?q=null이 거절됩니다. path는.·..를TypeError로 막습니다(/users/../orders가 되기 때문입니다). 값 안의/는%2F로 인코드됩니다. 클라이언트는 인코드한 경로 구분자(%2F·%5C)를 보내지 않습니다(디코드하는 서버에서..%2f가 위로 올라갑니다). 이 경로로 부르면ForbiddenOriginError로 멈추고, 메시지가 «encoded separator» 라고 이유를 말합니다./가 든 값은 쿼리나 본문으로 보냅니다.- 업스트림이
%2F를 세그먼트 안의 글자로 읽는 서버(예: Next.js App Router 의 동적 세그먼트)이고 이미 그런 경로를 쓰고 있으면createApiClient({ encodedSeparators: "allow" })로 그 클라이언트만 엽니다. 점 세그먼트(..;)·경로 밖·다른 오리진은 이 옵션과 상관없이 거절합니다.
import { createApiClient } from "@plott/webkit-api";
const internal = createApiClient({ name: "internal", baseUrl: "https://app.example.com/api", encodedSeparators: "allow" });await internal(`/memos/org/${encodeURIComponent("A/B 지점")}`);query의 값은 문자열·수·불리언·bigint·Date만 받습니다(객체·함수는TypeError).Date는toISOString()(UTC)으로 보내고, 잘못된Date와Infinity는TypeError입니다. 입력 주소에 이미 쿼리가 있으면&로 더하고,#조각이 있으면 쿼리를 조각 앞에 둡니다.arrayFormat: "comma"는 값 안의 쉼표를 구분하지 않습니다.["a,b"]와["a","b"]가 같은ids=a%2Cb입니다. 값에 쉼표가 들 수 있으면repeat을 씁니다.
플러그인
import type { ApiError, ApiNext, ApiPlugin, ApiRequest } from "@plott/webkit-api";
export const plugin: ApiPlugin = { name: "example", headers: ["x-example"], credential: false, repeatable: false, handle: (req: ApiRequest, next: ApiNext): Promise<Response> => next(req), mapError: (error: ApiError, req: ApiRequest): Error | undefined => (req.method === "GET" ? undefined : error),};| 필드 | 뜻 |
|---|---|
name | 한 클라이언트 안에서 겹치지 않는 이름. extend({ without }) 로 뺄 때 씁니다 |
headers | 싣는 헤더 이름(소문자) |
credential | 이 서버용 자격(토큰)을 싣는다는 표시 |
repeatable | next 를 여러 번 불러도 되는 플러그인(retry) |
handle | 요청을 고치고 next 를 부르고 응답을 봅니다. 던지면 호출이 실패합니다 |
mapError | json() 이 실패 응답을 오류로 바꿀 때 부릅니다 |
- 순서:
use의 앞의 것이 바깥입니다. 요청은 앞에서 뒤로, 응답은 뒤에서 앞으로 지나갑니다. - 헤더: 호출자가 넣은 헤더 > 뒤의 플러그인 > 앞의 플러그인. 플러그인은
req.callerSet(name)으로 호출자 헤더를 확인하고 덮지 않습니다. - 주소는 바꿀 수 없습니다. 허용 오리진·경로 검사는 플러그인보다 먼저 한 번 하고, 실제 요청도 그 주소로 갑니다.
mapError는 안쪽(뒤) 플러그인부터 묻고, 처음 돌려준 오류를 던집니다. 없으면ApiError(status, body)입니다.credential: true인 플러그인은extend()로 다른 서버의 클라이언트를 만들 때 물려주지 않습니다.- 플러그인마다
next는 한 번만 부릅니다. 두 번 부르면 오류입니다(요청이 두 번 나가고 스트림 본문은 두 번째에 깨집니다).repeatable: true인 플러그인은 여러 번 부를 수 있고, 쓰는 쪽이 버릴 응답의 본문을 기다리지 않고 취소합니다. - 두 플러그인이
headers에 같은 이름을 적으면 클라이언트를 만들 때 멈춥니다.forwardHeaders가 추적 헤더 자리를 차지하면 동의가 없어 추적 값이 빈 요청에 브라우저가 보낸 값이 실리기 때문입니다. 부모의 것을 바꾸려면extend({ without: [...], use: [...] })로 빼고 끼웁니다.
헤더만 더하는 플러그인은 headerPlugin(name, compute, { failOpen?, headers? }) 로 만듭니다. failOpen: true 면 계산이 실패해도 호출은 나가고 경고는 한 번만 남깁니다. 값이 undefined 인 이름과 호출자가 넣은 이름은 건너뜁니다.
import { headerPlugin } from "@plott/webkit-api";import { getIncoming } from "@plott/webkit-api/next";
export const appSession = headerPlugin("app-session", async () => { const { cookies } = await getIncoming(); return { "x-app-session": cookies.get("app_sid") };}, { failOpen: true, headers: ["x-app-session"] });timeout(ms) 는 지나가는 요청마다 제한 시간을 겁니다. 호출자가 signal 을 줬으면 둘 중 먼저 끝나는 쪽입니다. retry 보다 안쪽에 두면 시도마다 새 제한 시간이고, 바깥쪽에 두면 재시도를 합친 전체 제한입니다. 시간이 지나면 ApiNetworkError(kind: "timeout")입니다.
헤더 계약
기본 플러그인이 업스트림으로 보내는 헤더 이름은 앱이 api.ts 에 계약 하나(defineHeaders)를 두고 플러그인마다 headers 로 넘겨 정합니다. 기본(DEFAULT_HEADERS)은 requestId(x-request-id)와 locale(accept-language) 둘뿐이고, 나머지 키는 false(싣지 않음)입니다. 이름 있는 프리셋 "full" 은 아래 표의 모든 키에 이름을 줍니다(defineHeaders("full"), 값은 HEADER_PRESETS.full). 프리셋 위에 키 몇 개만 바꾸려면 defineHeaders({ ...HEADER_PRESETS.full, requestId: "x-correlation-id" }) 로 적습니다. 플러그인의 headers 옵션에는 같은 값(객체 또는 "full")을 줍니다.
import { defineHeaders, HEADER_PRESETS } from "@plott/webkit-api";
export const headers = defineHeaders({ ...HEADER_PRESETS.full, requestId: "x-correlation-id", gaClientId: "x-ga-cid", pageUrl: false });| 키 | "full" 프리셋의 이름 | 싣는 플러그인 |
|---|---|---|
requestId | x-request-id | requestId |
locale | accept-language | locale |
clientIp·clientUserAgent | x-client-ip·x-client-user-agent | clientInfo |
trackingConsent·pageUrl | x-tracking-consent·x-page-url | tracking |
gaClientId·gaSessionId·gaSessionNumber | x-ga-client-id·x-ga-session-id·x-ga-session-number | ga4(tracking) |
gclid·gbraid | x-gclid·x-gbraid | googleAds(tracking) |
fbp·fbc | x-fbp·x-fbc | meta(tracking) |
defineHeaders는 모르는 키(오타), 헤더 이름 모양이 아닌 값, 자격·연결 헤더(authorization·cookie·host·x-forwarded-*등), 두 키가 같은 이름이면 앱이 뜰 때 멈춥니다.- 토큰 헤더는 인증 플러그인의 옵션입니다:
auth.bearer({ header: "x-access-token", format: (t) => t })(기본Authorization: Bearer <토큰>). - 계약을 바꾸면 업스트림 쪽 업스트림 헤더 계약도 그 프로젝트의 이름으로 맞춥니다.
기본 플러그인(@plott/webkit-api/next)
모든 플러그인이 headers(헤더 계약)를 받습니다. 아래 «업스트림 헤더» 칸은 "full" 프리셋의 이름입니다. 기본 계약에서는 requestId·locale 만 켜져 있어 clientInfo·tracking 은 headers: "full" 이나 키별 이름을 적어야 헤더를 만듭니다.
| 플러그인 | 업스트림 헤더(계약 키) | 값 |
|---|---|---|
requestId({ from }) | x-request-id(requestId) | proxy 가 실은 요청 ID(읽는 헤더 from, 기본 x-request-id). 없거나 모양이 틀리면 새로 만들고, 한 렌더의 호출들은 같은 값 |
locale({ from }) | Accept-Language(locale) | 앱 proxy 가 로케일을 싣는 헤더(from, 기본 없음 — 이름은 @plott/webkit-i18n 의 LOCALE_HEADER 같은 앱 쪽 값), 없으면 브라우저 Accept-Language 첫 값. 로케일 모양이 아니면 싣지 않습니다 |
forwardHeaders([...]) | 적은 이름 그대로 | 브라우저 요청의 헤더 중 적은 것만. 하나면 문자열도 됩니다. 자격(authorization·cookie)·연결 헤더·계약의 이름은 적을 수 없습니다(시작할 때 멈춤) |
clientInfo({ trustedProxyHops, ipFrom }) | x-client-ip·x-client-user-agent(clientIp·clientUserAgent) | 사용자 IP(X-Forwarded-For 오른쪽에서 믿는 프록시 수번째(기본 1), 또는 ipFrom 헤더. IP 모양만)와 UA(512자까지). 추적 동의와 무관하게 실립니다 |
requestContext({ forward, localeFrom, requestIdFrom }) | 위 셋 | requestId() + locale() + forwardHeaders(forward) |
계약에서 모든 헤더를 끈 플러그인(예: requestId: false 인 requestId())은 null 이 되어 use 에서 건너뜁니다.
forwardHeaders 는 넘겨받은 계약의 이름을 넘기지 못하게 막습니다. 이름 모양(접두사)이 아니라 계약으로 정하므로, 계약에서 clientIp 를 x-user-ip 로 바꾸면 x-user-ip 는 막히고 x-client-version 같은 이름은 넘길 수 있습니다. 계약을 바꿨다면 requestContext 나 forwardHeaders 에도 같은 계약을 넘깁니다.
추적 식별자
GTM 이 심은 GA4·Google Ads·Meta 픽셀 쿠키에서 식별자를 꺼내 헤더로 넘깁니다. 업스트림은 이 값으로 서버 측 이벤트(GA4 Measurement Protocol, Meta Conversions API)를 브라우저 태그가 본 세션에 붙입니다.
import { tracking } from "@plott/webkit-api/next";
declare function readConsent(value: string | undefined): { analytics: boolean; ads: boolean };
export const plugins = tracking({ consent: (incoming) => readConsent(incoming.cookies.get("cookie_consent")), vendors: ["ga4", "googleAds", "meta"], measurementId: "G-XXXXXXX", origin: "https://www.example.com", headers: "full",});vendors는 필수이며 적은 벤더("ga4"·"googleAds"·"meta")의 쿠키만 읽습니다. 적지 않은 벤더는 쿠키가 있어도 읽지 않습니다. 모르는 이름은 던집니다. 기본 헤더 계약에는 추적 헤더가 없으므로headers: "full"(또는 키별 이름)도 함께 적습니다.consent는 필수이며{ analytics, ads }를 돌려줍니다. 동의를 거두어도 쿠키는 만료까지 남기 때문에, 쿠키가 있다는 것은 동의가 지금 유효하다는 뜻이 아닙니다. 동의 판정은 한 요청에 한 번 합니다.- GA4 는 분석(
analytics) 동의, Google Ads·Meta 는 광고(ads) 동의가 있을 때만 싣습니다. 동의 상태(기본analytics=1;ads=0,serializeConsent옵션으로 직렬화를 바꿉니다)와 페이지 주소(식별자가 아닙니다)는 언제나 넘깁니다. 페이지 주소는origin을 줄 때origin + 경로(쿼리 제외, 경로를 읽는 헤더는pathFrom, 기본x-request-path)이고 경로 모양이 아니면 싣지 않습니다. measurementId를 주면 세션 ID·번호도_ga_<ID에서 G- 를 뺀 것>쿠키에서 꺼냅니다(GS1·GS2 형식).- 모양이 틀린 쿠키는 넘기지 않고, 추적 플러그인이 실패해도 API 호출은 나갑니다.
- 픽셀과 서버 이벤트의 중복 제거용
event_id는 브라우저가 보내는 헤더라forwardHeaders(["x-tracking-event-id"])로 넘깁니다. - 헤더 이름은 헤더 계약(
headers)으로 바꾸고, 값 하나는 계약에서false로, 벤더는vendors에서 빼서 끕니다. - 쿠키 형식 도구:
gaClientIdOf,gaSessionOf,gclClickIdOf, 정규식FBP·FBC.
다른 광고 픽셀이나 앱이 심은 식별자 쿠키는 cookieHeader 로 더합니다. 쿠키 값이 pattern 에 맞지 않거나 maxLength(기본 256)를 넘거나 consent 가 거절하면 싣지 않습니다.
import { cookieHeader, type ConsentResolver } from "@plott/webkit-api/next";
declare const adsConsent: (incoming: Parameters<ConsentResolver>[0]) => boolean;
export const partnerPixel = cookieHeader({ name: "partner-pixel", cookie: "_pp", header: "x-partner-pixel", pattern: /^[\w-]{1,64}$/, consent: (i) => adsConsent(i) });들어온 요청
플러그인은 인자 없이 지금 브라우저 요청의 헤더·쿠키를 읽습니다(getIncoming()). Next 의 headers()·cookies() 에서 매번 읽고, 요청 밖(빌드·배치)이면 빈 요청입니다. 요청 밖 오류가 아닌 오류(동적 렌더 신호)는 그대로 던집니다.
runWithIncoming(값, fn, { mode }) 로 그 값을 덮어씁니다. 동시에 도는 덮어쓰기끼리는 섞이지 않습니다.
| mode | 쓰는 곳 |
|---|---|
merge(기본) | 지금 요청 위에 헤더·쿠키를 더한다. 예: 이 호출만 x-app-channel: batch |
replace | 지금 요청을 보지 않는다. 사용자 요청 안에서 도는 시스템·배치 호출에서 그 사용자의 요청 ID·추적 값·토큰이 실리지 않습니다 |
"use cache"안이나 서버 컴포넌트의after()안에서는 Next 가headers()를 막습니다. 미리getIncoming()으로 읽어 두고runWithIncoming(값, fn, { mode: "replace" })로 넘깁니다.- 들어온 요청을 읽는 플러그인을 쓰면 그 화면은 동적 렌더가 됩니다. 요청마다 다른 헤더(요청 ID·추적 값)는 Next fetch 캐시 키에 들어가므로,
force-cache·revalidate로 캐시할 공개 API 클라이언트에는 이 플러그인들을 끼우지 않습니다.
proxy
요청 ID 는 proxy 가 만들어 렌더로 넘깁니다. @plott/webkit-auth 의 auth.proxy 는 이것을 이미 합니다. 인증 없는 앱은 직접 부릅니다.
import { prepareRequestHeaders } from "@plott/webkit-api";import { NextResponse, type NextRequest } from "next/server";
export function proxy(req: NextRequest) { return NextResponse.next({ request: { headers: prepareRequestHeaders(req) } });}prepareRequestHeaders 는 x-request-id(앞단이 준 값이 모양에 맞으면 잇고 아니면 새로)와 x-request-path(브라우저 경로, 쿼리 제외)를 다시 씁니다. 이 둘은 proxy 에서 렌더로 가는 내부 통로라 기본 이름이 x-request-id·x-request-path 이고, requestIdHeader·pathHeader 옵션으로 바꿉니다(바꾸면 requestId({ from })·tracking({ pathFrom }) 도 같은 이름을 읽게 합니다). 업스트림으로 보내는 이름은 헤더 계약이 정합니다. 앞단이 요청 ID 를 다른 헤더로 주면 prepareRequestHeaders(req, { requestIdFrom: "x-correlation-id" }) 로 그 값을 잇습니다. auth.proxy 를 쓰는 앱은 auth.ts 의 requestIdFrom 에 적습니다.
기본으로 지키는 것
각 항목은 막는 실패와 함께 적습니다.
- 읽기(GET·HEAD)에 기본 제한 시간 30초가 있습니다. 멈춘 업스트림 하나에 화면이 매달리는 것을 막습니다. 늘리려면
readTimeoutMs, 없애려면readTimeoutMs: false, 한 호출만이면{ timeoutMs: false }입니다. 이 시간은 «응답 헤더가 올 때까지, 그리고json()이 본문을 읽는 동안» 입니다.await api(...)로Response를 직접 받으면 헤더가 온 순간 풀려 오래 걸리는 스트리밍 다운로드를 끊지 않습니다. 본문 읽기 중 시간이 다 되면json()은null이 아니라ApiNetworkError(kind: "timeout")를 던집니다. - 쓰기에는 기본 제한 시간이 없습니다. 오래 걸리는 일괄 작업을 서버가 끝내는 중에 끊으면 화면은 실패, 서버는 성공이 되기 때문입니다.
- 전송 실패만
ApiNetworkError입니다. Node «fetch failed»(cause있음), 본문 읽기 중 «terminated», Chrome «Failed to fetch», Safari «Load failed», Firefox «NetworkError when attempting to fetch resource.» 를 한 모양으로 바꿔 화면이 글자를 비교하지 않게 합니다. 그 밖의TypeError(예: GET 에body)는 프로그래밍 오류라 그대로 던집니다. 호출자가signal로 취소했으면 이유가 무엇이든 그 오류가 그대로 나옵니다. - 밑줄(
_)이 든 헤더 이름은 던집니다. nginx 는 기본 설정(underscores_in_headers off)에서 밑줄이 든 헤더를 말없이 버립니다.defineHeaders·forwardHeaders·cookieHeader·idempotencyKey와createApiClient의init.headers는 만들 때, 호출의headers와headerPlugin이 돌려준 이름은 부를 때 던집니다(failOpen도 삼키지 않습니다). 하이픈을 씁니다(x-gcl-aw-cookie).forwardHeaders는 적은 이름만 넘기므로 브라우저가 밑줄 이름을 보내도 넘기지 않을 뿐 오류가 나지 않습니다. - «빠진 값»은 만들 때 멈춥니다. 문자열을 이어 붙이면 빠진 값이
undefined·null글자로 나가PUT /goods/undefined가 다른 자원을 건드리거나?accessKey=null이 값을 덮어쓸 수 있습니다.path템플릿과query옵션이 JS 값으로 잡습니다. json()의 실패 응답 본문은 상한(256KiB)과 시간(errorBodyTimeoutMs, 1초) 안에서만 읽고, 나머지는 기다리지 않고 버립니다. 끝나지 않는 401 본문에 매달리지 않고ApiError가 바로 돌아옵니다. 버리는 본문은void res.body?.cancel().catch(() => {})로 취소합니다. Next 가 GET 본문을 tee 해서await cancel()은 끝나지 않을 수 있습니다. 응답을 받은 뒤 플러그인이 던지면 그 응답의 본문도 버립니다.- JSON 이 아닌 2xx 는
ApiError(kind: "non_json")입니다. HTTP 200 의 HTML 로그인·프록시 오류 페이지를 데이터로 읽다 터지는 것을 막습니다.status·contentType이 실리고body에는 제어 문자를 지운 120자 이내 조각만 있으며 메시지에는 본문이 없습니다. content-type 이 없거나text/plain이어도 본문이 JSON 이면 받습니다. - 리다이렉트(3xx)는 따라가지 않고 응답 그대로 돌려줍니다(
json()이면ApiError). 따라가면 오리진 검사를 거치지 않은 주소로 요청 ID·사용자 IP·추적 헤더와 본문이 넘어가기 때문입니다. 따라가야 하는 호출은{ redirect: "follow" }를 넘깁니다. - 서버 밖 주소로 자격을 싣지 않습니다.
baseUrl의 오리진이면 절대 주소도baseUrl경로 아래만 부릅니다.baseUrl에는 쿼리·조각·자격 정보를 넣을 수 없습니다. 경로 파라미터를 붙인 점 세그먼트(..;)와 인코드한 경로 구분자(%2f·%5c)는 일부 서버가 위로 올라가는 경로로 읽으므로 거절합니다. 인코드한 경로 구분자만encodedSeparators: "allow"로 클라이언트마다 열 수 있습니다. 거절 메시지에는 쿼리를 싣지 않습니다. extend()는 서버가 바뀌면 자격을 물려주지 않습니다. 다른 서버란 오리진이 다르거나, 같은 오리진에서 부모 경로 밖인 경우입니다(/orders-api→/billing-api./v1→/v1/admin은 같은 서버). 이때 자격 플러그인(credential: true)과 부모의 기본 헤더(init.headers)를 잇지 않고, 오리진이 바뀌면 부모의allowedOrigins도 잇지 않습니다. 새 서버가 이 세션의 토큰을 받아야 하면use로 다시 끼웁니다.- 자격 헤더가 실린 요청은
no-store입니다.Authorization·Cookie·Proxy-Authorization, 또는credential: true플러그인이headers에 적은 헤더가 실리면 호출자가cache나next(revalidate·tags)를 정하지 않은 한 사용자별 응답을 Next 데이터 캐시에 넣지 않습니다. - 요청 설정은
Object.prototype에서 읽지 않습니다. 오염된Object.prototype의method·headers·redirect·cache가 요청을 바꾸지 못합니다. 값이undefined·null인 헤더는 보내지 않습니다(fetch는 문자열"undefined"로 보냅니다). - 전역
fetch를 바꾸지 않습니다. 결제·OTA 같은 외부 호출은 그냥fetch를 쓰며, 그쪽에는 이 클라이언트의 헤더가 실리지 않습니다. - 키 없는 POST 는 다시 보내지 않습니다. 서버가 처리했을지 모르는데 다시 보내면 승인이 두 번 생깁니다.
- 응답 검증 오류의
issues는 기본으로 가립니다. 경로에는 레코드의 동적 키(사용자 ID)가, 검증기에 따라 메시지에는 받은 값이 들어갈 수 있어서redactIssues: true는 숫자만인 문자열·UUID 경로 조각을*로 바꾸고 200자를 넘는 메시지를 버립니다.redactIssues: false는 200자로 자르기만 하고, 함수를 주면 자른 이슈마다 적용합니다. ApiError.fieldErrors는 프로토타입 없는 객체입니다. 필드 이름이constructor·__proto__여도 그냥 키이며,Object.hasOwn(e.fieldErrors, 이름)으로 확인합니다.ApiError의url에는 쿼리를 싣지 않습니다. 코드·토큰이 실릴 수 있습니다.- 요청 ID 는 모양을 검사합니다. 영숫자·
_·-8~64자가 아닌 값(위조·CRLF)은 버리고 새로 만듭니다. IP 는 X-Forwarded-For 의 오른쪽에서 믿는 프록시 수번째 값만 쓰며, 값이 그보다 적으면(앞단을 거치지 않은 요청) 모른다고 둡니다.
그 밖
오류
| 오류 | 언제 |
|---|---|
ForbiddenOriginError | 이 클라이언트의 서버(허용 오리진·baseUrl 경로) 밖으로 보내려 했다 |
ApiError | json() 이 2xx 가 아닌 응답(또는 JSON 이 아닌 2xx)을 받았다. status·kind(http·invalid_response·non_json·envelope·redirect)·body(JSON 이면 값, 아니면 텍스트)·url·client·issues·contentType. 본문이 Problem Details(RFC 9457)면 problem 에 그 값, code 에 서비스 오류 코드(problem.code)입니다. fieldErrors 는 parseError: "field-errors" 프리셋일 때 problem.errors 를 { 필드: [메시지…] } 로 고른 것이고([{ field, message }]·pointer·defaultMessage 모양과 { 필드: 메시지 } 맵을 받습니다), 기본 파서(rfc9457)는 표준 칸만 읽어 빈 객체입니다. kind 가 http 가 아니면 code 는 invalid_response 또는 봉투의 코드입니다 |
ApiNetworkError | 응답을 받지 못했다(헤더 또는 본문 읽기 중). kind 는 network(연결 실패) 또는 timeout(제한 시간) |
MissingValueError | TypeError 의 하위 클래스. path 의 undefined·null·NaN·빈 문자열, query 의 NaN, ifMatch 의 빈 버전. isMissingValueError(e) 로 확인 |
TypeError | path 에 .·..·Infinity, query 에 객체·잘못된 Date·Infinity, JSON 으로 만들 수 없는 본문을 넣었다 |
RangeError | retry 의 retries·jitter, 페이지 넘기기의 maxPages 가 범위 밖이다 |
| 플러그인의 오류 | 예: auth.bearer() 는 그 세션으로 부른 호출의 401 을 AuthRequiredError 로 바꾼다 |
프로그래밍 실수로 던지는 오류의 메시지는 @plott/webkit-api: 로 시작합니다(createFetcher 는 @plott/webkit-api/swr: ).