@i.un/api-client
v2.0.2
Published
Universal API client for i.un services
Maintainers
Readme
Api Client
基于
ofetch的轻量 HTTP 客户端,可选用 OpenAPI spec 驱动端到端类型。 A lightweight HTTP client built onofetch, with optional end-to-end types driven by your OpenAPI spec.
- 自动注入
Authorizationtoken / AutomaticAuthorizationtoken injection - 单飞(single-flight)的可选 token 刷新 / Optional single-flight token refresh
- 统一的成功 / 错误处理,协议可配 / Unified success / error handling, configurable protocol
- 可选:用 openapi-typescript 的
paths做端到端类型 —— endpoint 补全、query / body / path 参数与响应全部从 spec 推导 / Optional: end-to-end types from an openapi-typescriptpaths— endpoint autocomplete, query / body / path params and responses all inferred - 还内置:请求链编排(chain)、Protobuf 安全传输 / Also bundled: request-chain orchestration, Protobuf secure transport
浏览器、Node、浏览器扩展、Cloudflare Workers 均可用。 Works in browsers, Node, browser extensions, and Cloudflare Workers.
安装 / Installation
npm install @i.un/api-client
# or
pnpm add @i.un/api-client
# or
yarn add @i.un/api-client包名 / Package name:
@i.un/api-client
快速上手 / Quick Start
import { createApiClient, type TokenStorage } from "@i.un/api-client";
// 1. 提供 TokenStorage(token 从哪读 / 存到哪)
// Provide a TokenStorage (where to read / store the token)
const tokenStorage: TokenStorage = {
async getAccessToken() {
return localStorage.getItem("access_token") || "";
},
async setAccessToken(token: string) {
localStorage.setItem("access_token", token);
},
};
// 2. 创建 client(不带 spec:响应为 unknown,用 <T> 指定,兼容旧用法)
// Create a client (no spec: responses are unknown; pass <T> — legacy-compatible)
const client = createApiClient({
baseURL: "https://api.example.com",
tokenStorage,
refreshToken: "/auth/refresh", // token 刷新端点 / token-refresh endpoint
});
// 3. 调用(两段式:url + options)/ Call it (two-arg: url + options)
const user = await client.get<{ name: string }>("/user/profile");
const updated = await client.post<{ ok: boolean }>("/user/profile", {
body: { name: "foo" },
});2.0 起接口为两段式
(url, options)——query/path/body都放进options。 Since 2.0 the shape is two-arg(url, options)—query/path/bodyall live inoptions. 见下方「迁移」/ see "Migration" below.
用 OpenAPI spec 强类型 / Typed against your OpenAPI spec
传入 openapi-typescript 生成的 paths,即可获得端到端类型:endpoint 自动补全、query / path / body 与响应全部从 spec 推导,无需手写类型或 <T>。
Pass the paths produced by openapi-typescript to get end-to-end types: endpoint autocomplete, plus query / path / body and responses inferred from the spec — no hand-written types or <T>.
import { createApiClient } from "@i.un/api-client";
import type { paths } from "./api"; // openapi-typescript 生成 / generated by openapi-typescript
// 默认:通用库,如实返回后端响应(不解包)
const api = createApiClient<paths, "/api">({
baseURL: "https://api.example.com/api",
tokenStorage,
});
const me = await api.get("/me"); // 响应 = 后端原样返回
const u = await api.get("/users/{id}", { path: { id } }); // path 参数类型安全 + 运行时拼接
await api.post("/users", { body: { name: "foo" } }); // body 类型来自 spec
await api.get("/users", { query: { page: 1 } }); // query 类型来自 spec
// 信封后端({ code, data, message }):显式 opt-in 解包,自动剥出 data
// Envelope backend: opt in to unwrapping, the data field is peeled automatically
const envApi = createApiClient<paths, "/api", "data">({
baseURL: "https://api.example.com/api",
tokenStorage,
dataKey: "data",
});
const user = await envApi.get("/me"); // 响应 = 解包后的 data三个泛型轴 / The three generic axes
createApiClient<Paths, Prefix, DataKey>
| 泛型 / Generic | 含义 / Meaning | 默认 / Default |
| --- | --- | --- |
| Paths | openapi-typescript 的 paths;unknown = 未类型化(回退到 <T>(url))/ the paths; unknown = untyped | unknown |
| Prefix | 从 spec key 剥掉的前缀,对应 baseURL 的路径段 / prefix stripped from spec keys, matching the baseURL path segment | '' |
| DataKey | never(默认)= 不解包,如实返回;传字段名(如 'data')= 解包该字段 / never (default) = no unwrap; a field name = unwrap it | never |
例:
baseURL为https://x.com/api、spec key 为/api/me,则Prefix='/api',调用传去前缀的/me。 e.g. withbaseURLhttps://x.com/apiand spec key/api/me, setPrefix='/api'and call/me.
默认不解包 —— api-client 是通用库,不假设后端协议。要解包
{ code, data, message }信封,显式传DataKey字段名 + 运行时dataKey(类型会强制二者一致:自定义字段名时dataKey必填且必须 ===DataKey)。 No unwrap by default — api-client is a universal client and assumes nothing about your backend. To unwrap a{ code, data, message }envelope, pass aDataKeyfield name plus the runtimedataKey(the type enforces they match: with a custom field namedataKeyis required and must ===DataKey).
生成 spec / Generate the spec
openapi-typescript https://api.example.com/api/docs-json -o ./api.ts在项目侧组装短别名 / Build project-side aliases
类型内核来自 @i.un/openapi-kit,已由本包原样再导出(ResponseFor / QueryFor / BodyFor / AllPaths / MethodsFor / DefaultMethod / ApiKit …)。你可以在项目里组装短别名:
The type core comes from @i.un/openapi-kit and is re-exported as-is here (ResponseFor / QueryFor / BodyFor / AllPaths / MethodsFor / DefaultMethod / ApiKit …). Assemble short aliases in your project:
import type {
ResponseFor, BodyFor, QueryFor, AllPaths, MethodsFor, DefaultMethod,
} from "@i.un/api-client";
import type { paths } from "./api";
type Pfx = "/api";
type DKey = "data";
export type ApiResponse<
P extends AllPaths<paths, Pfx>,
M extends MethodsFor<paths, Pfx, P> = DefaultMethod<paths, Pfx, P>,
> = ResponseFor<paths, Pfx, P, M, unknown, DKey>;
export type ApiBody<
P extends AllPaths<paths, Pfx>,
M extends MethodsFor<paths, Pfx, P> = DefaultMethod<paths, Pfx, P, "post">,
> = BodyFor<paths, Pfx, P, M>;
// 用法 / usage:
type Me = ApiResponse<"/me">;
type CreateUser = ApiBody<"/users", "post">;不传泛型时,
createApiClient(...)完全退回未类型化模式:get<T>(url)照旧可用。 Without generics,createApiClient(...)falls back to untyped mode:get<T>(url)works as before.
响应处理:默认不解包 / Response: no unwrap by default
默认,api-client 如实返回后端响应(裸数据、完整信封、null / 204 都原样返回) —— 它是通用库,不假设你的后端协议。
By default api-client returns the backend response as-is (raw data, full envelope, null / 204 alike) — it's a universal client and assumes nothing about your protocol.
信封解包是 opt-in。 如果后端用如下信封 / Unwrapping is opt-in. If your backend uses this envelope:
export interface ApiResult<T> {
code: number;
data: T;
message: string;
}传字段名开启解包 / pass the field name to enable unwrapping:
const api = createApiClient<paths, "/api", "data">({ baseURL, tokenStorage, dataKey: "data" });开启后 / Once enabled:
code === 0(成功)→ 返回data字段。/ on success → returns thedatafield.code !== 0(业务失败)→ 抛出ApiError(见下)。/ on business failure → throwsApiError(see below).- 字段名与成功码都可配:
codeKey/messageKey/dataKey/successCode(默认{ code, data, message }+0)。/ field names & success code are configurable.
要整个 client 都拿完整信封,就不开解包(默认);要换整套解包/错误逻辑见「高级」。 To get full envelopes everywhere, just don't enable unwrapping (the default); to replace the whole unwrap / error logic see "Advanced".
错误类型 / Error Type
export interface ApiError extends Error {
code: number; // 业务错误码 / business error code
data?: unknown; // 后端 data 字段 / backend data field
status?: number; // HTTP 状态码(网络层错误)/ HTTP status (network-level)
}
export const isApiError = (error: unknown): error is ApiError =>
error instanceof Error && "code" in error;try {
const data = await api.get("/some/api");
} catch (e) {
if (isApiError(e)) {
console.log("业务错误 / business error", e.code, e.message, e.data);
} else {
console.error("意外错误 / unexpected error", e);
}
}配置项 / Options: CreateApiClientOptions
export interface CreateApiClientOptions {
baseURL: string;
// 可选。不传则不自动注入 Authorization、也不自动刷新 —— 适合无状态 / token 由
// onRequest 自行注入的场景(如 Cloudflare Worker 透传用户 token)。
// Optional. Omit for stateless / token-injected-in-onRequest setups (e.g. a
// Cloudflare Worker forwarding the user's token): no auto-inject, no auto-refresh.
tokenStorage?: TokenStorage;
// 可选:自动 token 刷新 / optional automatic token refresh
refreshToken?: (() => Promise<string>) | string | false;
// 鉴权错误码,默认 isAuthError 据此判定(code 或 status 命中即触发刷新),默认 401
// auth code for the default isAuthError (code or status match → refresh), default 401
unauthorizedCode?: number;
// 如何判定鉴权错误(默认 code/status === unauthorizedCode)/ how to detect an auth error
isAuthError?: (error: { code: number; status?: number }) => boolean;
// 信封字段名与成功码(解包模式用)。dataKey 不在此 —— 由 DataKey 泛型 + DataKeyOption 控制
// envelope field names & success code (unwrap mode). dataKey lives on the DataKey generic.
codeKey?: string;
messageKey?: string;
successCode?: number;
// 自定义成功解包 / custom success unwrapping
unwrapResponse?<T>(result: unknown): T;
// 自定义错误映射 / custom error mapping
createErrorFromResult?(res: unknown): Error;
// 请求 / 响应钩子(如 Protobuf 编解码)/ request / response hooks (e.g. Protobuf)
onRequest?: (context: FetchContext) => void | Promise<void>;
onResponse?: (context: FetchContext) => void | Promise<void>;
}TokenStorage
export interface TokenStorage {
getAccessToken: () => Promise<string> | string;
setAccessToken: (token: string) => Promise<void> | void;
}token 存哪由你决定 / You decide where the token lives:
- 浏览器 / Browser:
localStorage/sessionStorage/ cookies - Node:内存 / Redis / 数据库 / in-memory, Redis, DB …
- 扩展 / Extension:
chrome.storage、cookies …
tokenStorage可选:无状态场景(如 Cloudflare Worker 代用户转发请求)可以不传,改在onRequest里自行注入 token —— 库就不碰 token、不刷新,适合「每请求透传当前用户 token」且不希望有任何跨请求 token 状态的用法。tokenStorageis optional: for stateless setups (e.g. a Worker forwarding a user's request), omit it and inject the token yourself inonRequest. The library then never touches/refreshes the token — ideal for per-request token forwarding with zero cross-request state.
自动刷新 Token / Automatic Token Refresh
方式一:字符串 endpoint(推荐)/ Option 1: String endpoint (recommended)
const api = createApiClient({ baseURL, tokenStorage, refreshToken: "/auth/refresh" });行为 / Behavior:
- 命中
isAuthError(默认 401)时,POST刷新端点,取data.access_token存回tokenStorage,再自动重试一次原请求。 On an auth error (401 by default),POSTs the refresh endpoint, storesdata.access_token, then retries the original request once. - 单飞 / Single-flight:内部
refreshingPromise保证同一时刻只有一个刷新请求,并发的 401 复用其结果。 An internalrefreshingPromiseensures only one in-flight refresh; concurrent 401s reuse it.
方式二:自定义函数 / Option 2: Custom function
const api = createApiClient({
baseURL,
tokenStorage,
refreshToken: async () => {
const res = await ofetch<{ accessToken: string }>("/auth/refresh", { baseURL, method: "POST" });
return res.accessToken;
},
});建议不要在
refreshToken里再调用会自动刷新的request,以免刷新端点本身返回鉴权错误时递归。 Prefer not to call the auto-refreshingrequestinsiderefreshToken, to avoid recursion if the refresh endpoint itself returns an auth error.
请求方法 / Request Methods
const { rawRequest, request, get, post, put, patch, del } = createApiClient(...);| 方法 / Method | 说明 / Description |
| --- | --- |
| rawRequest | 底层 $fetch 实例,原样返回,无刷新 / 解包 / underlying $fetch, raw, no refresh / unwrap |
| request<T>(url, options?) | 核心逃生舱:token 注入 + 刷新 + 解包 + 抛错 / core escape hatch |
| get / del (url, options?) | options.query 作查询参数 / options.query as query string |
| post / put / patch (url, options?) | options.body 作请求体 / options.body as body |
options 同时透传 ApiRequestOptions(headers / timeout / responseType …);带 {param} 的端点 options.path 必填。
options also passes through ApiRequestOptions (headers / timeout / responseType …); for endpoints with {param}, options.path is required.
// GET /users?page=1
await get("/users", { query: { page: 1 } });
// POST /users { name }
await post("/users", { body: { name: "foo" } });
// GET /users/123 —— 模板 + path,运行时拼接 / template + path, interpolated at runtime
await get("/users/{id}", { path: { id: "123" } });
// 直接用底层 request(更灵活)/ raw request (more flexible)
const data = await request<User>("/users/1", { method: "GET" });高级:自定义解包与错误映射 / Advanced: Custom unwrapping and error mapping
后端不是 { code, data, message } 协议时,可覆盖 / If your backend differs from { code, data, message }:
const api = createApiClient({
baseURL,
tokenStorage,
refreshToken: false,
unwrapResponse<T>(result) {
// 例:后端返回 { success, result, errorMsg } / e.g. backend returns { success, result, errorMsg }
if (result && typeof result === "object" && "success" in result) {
const body = result as any;
if (body.success) return body.result as T;
}
return result as T;
},
createErrorFromResult(res): Error {
const body = res as any;
return new Error(body.errorMsg || "Request failed");
},
});请求链执行 / Request Chain Execution
executeRequestChain 用于编排多个有依赖的 HTTP 请求:共享 Context、支持 变量替换、可按策略 智能路由。这是运行时动态能力,与上面的 spec 类型无关。
executeRequestChain orchestrates multiple dependent HTTP requests: a shared Context, variable substitution, and strategy-based smart routing. It is a runtime/dynamic feature, independent of the spec types above.
核心概念 / Core Concepts
- Rule (
ChainRequestRule):链中的一步 / a single step. - Context:累积结果的共享对象,每步可读写 / shared object accumulating results.
- Handler (
NetworkHandler):按 URL / Method 决定用哪个 Adapter 执行 / picks the Adapter per URL / Method.
规则配置 / Rule Configuration (ChainRequestRule)
| 字段 / Property | 类型 / Type | 说明 / Description |
| :-- | :-- | :-- |
| key | string | 结果存入 Context 的键 / key to store the result in Context |
| request | HttpRequestSpec | method / url / headers / body |
| selector | string | 可选,点号路径(如 user.id)提取数据 / optional dot-path to extract data |
| optional | boolean | 为真时该步失败也继续 / continue even if this step fails |
变量替换 / Variable Substitution
可在 url / headers / body 用 {{key.path}},引擎以 Context 中的值替换。
Use {{key.path}} in url / headers / body; the engine substitutes from the Context.
示例:三步编排 / Example: 3-step orchestration
import { executeRequestChain } from "@i.un/api-client";
const rules = [
{ key: "auth", request: { method: "POST", url: "/login", body: { user: "admin" } } },
{
key: "profile",
request: { method: "GET", url: "/users/{{auth.userId}}", headers: { "X-Session": "{{auth.token}}" } },
selector: "data",
},
{
key: "update",
request: { method: "PUT", url: "/sync", body: { source: "web", profile: "{{profile}}" } },
},
];
const result = await executeRequestChain(rules);自定义路由(策略模式)/ Custom Routing (Strategy Pattern)
提供一组 NetworkHandler 把请求路由到不同环境(如把 chrome-extension:// 走扩展 API,其余走标准 fetch)。
Provide NetworkHandlers to route requests to different environments (e.g. chrome-extension:// to an extension API, the rest to standard fetch).
const handlers = [
{
name: "extension-router",
shouldHandle: (url) => url.startsWith("chrome-extension://"),
adapter: {
post: (url, body) => chrome.runtime.sendMessage({ url, body }),
get: (url) => /* ... */,
},
},
];
await executeRequestChain(rules, handlers);Protobuf 与安全通信 / Protobuf & Security
Protobuf 模块在 ofetch 之上提供安全层:二进制序列化、XOR 混淆、通过 hooks 自动集成。
The Protobuf module adds a security layer over ofetch: binary serialization, XOR obfuscation, automatic integration via hooks.
处理流程 / Pipeline
Object → Transform(可选)→ Protobuf Encode → XOR Obfuscation → Binary Payload
集成 Hook / Integration Hooks
用 createProtobufHooks 给 client 加 Protobuf 支持 / Use createProtobufHooks to add Protobuf support:
import { createApiClient, createProtobufHooks, createXorObfuscator } from "@i.un/api-client";
const hooks = createProtobufHooks({
// obfuscator: createXorObfuscator("my-secret-key"), // 可选 / optional
transform: {
beforeEncode: (data) => ({ ...data, ts: Date.now() }),
afterDecode: (res) => res.data,
},
});
const api = createApiClient({ baseURL: "/api", tokenStorage, ...hooks });
// 用法:把 Content-Type / Accept 设为 application/x-protobuf
// usage: set Content-Type / Accept to application/x-protobuf
await api.post("/secure-endpoint", {
body: { foo: "bar" },
headers: { "Content-Type": "application/x-protobuf" },
});手动配置请求头 / Manual Header Configuration
import { getProtobufHeaders } from "@i.un/api-client";
// 收发都用 Protobuf(默认)/ send & receive Protobuf (default)
const headers = { ...getProtobufHeaders({ send: true, receive: true }), "X-Custom": "val" };
// 仅接收 / receive only
const receiveOnly = getProtobufHeaders({ send: false, receive: true });
await api.get("/data", { headers: receiveOnly });信封模式 vs 自定义类型 / Envelope Mode vs Custom Type Mode
- 信封模式(默认)/ Envelope (default):不提供
protoType时用内置SecurePayload(时间戳 + JSON 串)。无需为每个接口定义 schema 即可隐藏 DevTools 数据。 Without aprotoType, uses the built-inSecurePayload(timestamp + JSON). Hides data from DevTools without per-API schemas. - 自定义类型 / Custom Type:提供编译自
.proto的protoType,获得完整二进制性能与类型安全。 Provide aprotoTypecompiled from.protofor full binary performance and type safety.
const hooks = createProtobufHooks({ protoType: MyCompiledMessage });自定义混淆 / Custom Obfuscator
默认不混淆。用内置 XOR / Off by default. Built-in XOR:
import { createProtobufHooks, createXorObfuscator } from "@i.un/api-client";
const hooks = createProtobufHooks({ obfuscator: createXorObfuscator("your-secret-key") });或完全自定义 / or fully custom:
import { createProtobufHooks, type ProtobufObfuscator } from "@i.un/api-client";
const myObfuscator: ProtobufObfuscator = {
encrypt: (data: Uint8Array) => data, // 你的加密 / your encryption
decrypt: (data: Uint8Array) => data, // 你的解密 / your decryption
};
const hooks = createProtobufHooks({ obfuscator: myObfuscator });服务端 / 边缘 用法 / Server-Side Usage (Node / Workers)
import { parseSecureRequest, secureResponse, createXorObfuscator } from "@i.un/api-client";
// 1. 解析进来的请求 / parse incoming request
const body = await parseSecureRequest(request, { obfuscator: createXorObfuscator("key") });
// 2. 返回安全响应 / send secure response
return await secureResponse({ success: true }, { obfuscator: createXorObfuscator("key") });从 1.x 迁移 / Migration from 1.x
2.0 把接口从三段式改为两段式(query / body / path 进 options),以对齐 OpenAPI spec 类型。
2.0 moves from a three-arg to a two-arg shape (query / body / path go into options), to align with OpenAPI spec types.
// 1.x → 2.0
get("/list", { page: 1 }) → get("/list", { query: { page: 1 } })
post("/x", body) → post("/x", { body })
del("/x", { id }) → del("/x", { query: { id } })
get(`/u/${id}`) → get("/u/{id}", { path: { id } })
get<T>("/me") → get<T>("/me") // 单参调用不变 / unchanged仅传
url的调用(get<T>(url))无需改动;只有「第二参直接传 query / body 对象」的写法需要包成{ query }/{ body }。 Single-arg calls (get<T>(url)) are unchanged; only "second arg is a raw query / body object" needs wrapping in{ query }/{ body }.
另外两个破坏性变更 / Two more breaking changes:
- 默认不再解包。 1.x 默认解包
{ code, data, message }取data;2.0 默认如实返回。信封后端要显式开启:createApiClient<paths, '/api', 'data'>({ …, dataKey: 'data' })。 No unwrap by default. 1.x unwrappeddata; 2.0 returns as-is. Envelope backends opt in:createApiClient<paths, '/api', 'data'>({ …, dataKey: 'data' }). - 移除
returnFullResponse。 它改运行时返回却不改类型(类型对不上)。要完整信封就用默认(不解包)的 client;要解包后的 data 就开dataKey。returnFullResponseremoved. It changed the runtime return without changing the type. For full envelopes use a default (no-unwrap) client; for unwrapped data enabledataKey.
环境与注意事项 / Environment & Notes
- 基于
ofetch,可用于浏览器、Node 18+、Nuxt、Workers 等 / Built onofetch; browsers, Node 18+, Nuxt, Workers. - 依赖
fetch/Headers:浏览器与 Node 18+ 内置;更老的 Node 需undici/cross-fetchpolyfill。 Requiresfetch/Headers: built-in in browsers and Node 18+; polyfill older Node withundici/cross-fetch. - 核心 client 不依赖
window/localStorage/chrome;环境相关逻辑放进你的TokenStorage。 The core client does not depend onwindow/localStorage/chrome; keep env-specific logic in yourTokenStorage.
License
MIT
