npm package: @i.un/api-client
Description: Universal API client for i.un services
Author: nova
Latest version: 1.3.6

# Api Client

一个基于 `ofetch` 的轻量级 HTTP 客户端，内置：

- 自动携带 `Authorization` token
- 自动刷新 token（可选，串行防抖）
- 统一成功/失败返回结构（可配置协议）
- 完整 TypeScript 类型支持

适用于浏览器、Node、浏览器扩展等多种环境。

---

## 安装

```bash
npm install @i.un/api-client
# or
pnpm add @i.un/api-client
# or
yarn add @i.un/api-client
```

> 包名：`@i.un/api-client`。

---

## 快速上手

```ts
import { createApiClient, type TokenStorage } from "@i.un/api-client";

// 1. 提供一个 TokenStorage（决定 token 从哪里来、存到哪里去）
const tokenStorage: TokenStorage = {
  async getAccessToken() {
    return localStorage.getItem("access_token") || "";
  },
  async setAccessToken(token: string) {
    localStorage.setItem("access_token", token);
  },
};

// 2. 创建 client
const client = createApiClient({
  baseURL: "https://api.example.com",
  tokenStorage,
  // 刷新 token 的接口（默认 ApiResult 协议：{ code, data: { access_token }, message }）
  refreshToken: "/auth/refresh",
});

// 3. 使用
const { get, post, put, patch, del, request } = client;

// 示例：GET
const user = await get<{ name: string }>("/user/profile");

// 示例：POST
const updated = await post<{ ok: boolean }>("/user/profile", { name: "foo" });
```

---

## 默认响应协议

库内置了一个默认响应协议类型：

```ts
export interface ApiResult<T> {
  code: number;
  data: T;
  message: string;
}
```

默认行为：

- 约定后端统一返回 `ApiResult<T>`。
- 调用 `request/get/post/...`：
  - 当 `code === 0`：
    - 默认返回 `data`（即 `T`）。
    - 如果传了 `returnFullResponse: true`，则返回完整的 `ApiResult<T>`。
  - 当 `code !== 0`：
    - 抛出 `ApiError`（自定义错误类型，见下文）。

你可以通过配置项覆盖这套协议（见「高级：自定义解包和错误映射」）。

---

## 错误类型

```ts
export interface ApiError extends Error {
  code: number; // 业务错误码
  data?: unknown; // 后端 data 字段
  status?: number; // HTTP 状态码（仅网络层错误时可能存在）
}

export const isApiError = (error: unknown): error is ApiError => {
  return error instanceof Error && "code" in error;
};
```

调用方使用示例：

```ts
try {
  const data = await get("/some/api");
} catch (e) {
  if (isApiError(e)) {
    console.log("business error", e.code, e.message, e.data);
  } else {
    console.error("unexpected error", e);
  }
}
```

---

## 配置项：`CreateApiClientOptions`

```ts
export interface CreateApiClientOptions {
  baseURL: string;
  tokenStorage: TokenStorage;

  // 自动刷新 token（可选）
  refreshToken?: (() => Promise<string>) | string | false;

  // 识别“需要刷新 / 认证失败”的业务错误（可选，默认 code === 401）
  isAuthError?: (code: number) => boolean;

  // 自定义成功响应解包逻辑（可选）
  unwrapResponse?<T>(result: unknown, returnFullResponse: boolean): T;

  // 自定义失败响应映射为 Error 的逻辑（可选）
  createErrorFromResult?(res: unknown): Error;
}
```

### `TokenStorage`

```ts
export interface TokenStorage {
  getAccessToken: () => Promise<string> | string;
  setAccessToken: (token: string) => Promise<void> | void;
}
```

你可以自由决定 token 存到哪里，比如：

- 浏览器：`localStorage` / `sessionStorage` / `cookie`
- Node：内存变量、Redis、数据库等
- 浏览器扩展：`chrome.storage`、`cookies` 等

---

## 自动刷新 Token

### 方式一：字符串 endpoint（推荐）

```ts
const client = createApiClient({
  baseURL,
  tokenStorage,
  refreshToken: "/auth/refresh",
});
```

内部行为：

- 业务请求返回 `isAuthError(code) === true`（默认 401）时：
  - 调用 `ofetch<ApiResult<{ access_token: string }>>(refreshToken, { baseURL, method: "POST" })`。
  - 如果 `code !== 0`，使用 `createErrorFromResult` 抛错。
  - 如果 `code === 0`，从 `data.access_token` 取出新 token，并调用 `tokenStorage.setAccessToken` 存储。
  - 然后自动使用新 token 重试原请求一次。
- 串行防抖：
  - 使用内部的 `refreshingPromise` 确保同一时刻只有一个刷新请求在进行；
  - 其它并发 401 请求会等待这次刷新完成，复用刷新结果。

### 方式二：自定义函数

```ts
const client = createApiClient({
  baseURL,
  tokenStorage,
  refreshToken: async () => {
    const res = await ofetch<{ accessToken: string }>("/auth/refresh", {
      baseURL,
      method: "POST",
    });
    return res.accessToken;
  },
});
```

> 建议在 `refreshToken` 内部不要再调用同一个带自动刷新的 `request`，以避免在刷新接口也返回认证错误时形成递归。

---

## 请求方法

```ts
const { rawRequest, request, get, post, put, patch, del } = createApiClient(...);
```

- `rawRequest`: 底层的 `$fetch` 实例（来自 `ofetch.create`），按原样返回结果，不做自动刷新和解包。
- `request<T>(url, options?)`: 核心方法，自动携带 token / 自动刷新 / 解包 / 抛 `Error`。
- `get<T>(url, params?, options?)`: `GET` 请求，`params` 会被放到 `query`。
- `post/put/patch<T>(url, body?, options?)`: `body` 默认 `{}`。
- `del<T>(url, params?, options?)`: `DELETE` 请求，`params` 放到 `query`。

示例：

```ts
// GET /users?id=1
const user = await get<User>("/users", { id: 1 });

// POST /users { name }
const created = await post<User>("/users", { name: "foo" });

// 直接用 request（更通用）
const data = await request<User>("/users/1", {
  method: "GET",
  returnFullResponse: false, // true 时返回 ApiResult<User>
});
```

---

## 高级：自定义解包和错误映射

如果你的后端协议不是 `{ code, data, message }`，可以通过配置覆盖：

```ts
const client = createApiClient({
  baseURL,
  tokenStorage,
  refreshToken: false,

  unwrapResponse<T>(result, returnFullResponse) {
    // 举例：后端协议是 { success, result, errorMsg }
    if (result && typeof result === "object" && "success" in result) {
      const body = result as any;
      if (body.success) {
        return returnFullResponse ? (body as T) : (body.result as T);
      }
    }
    return result as T;
  },

  createErrorFromResult(res): Error {
    const body = res as any;
    const err = new Error(body.errorMsg || "Request failed");
    // 可以按需挂一些自定义字段，比如 err.code / err.raw
    return err;
  },
});
```

---

## 请求链执行 (Request Chain)

请求链引擎 (`executeRequestChain`) 允许你编排多个相互依赖的 HTTP 请求。它管理一个共享的 **上下文 (Context)**，处理 **变量替换 (Variable Substitution)**，并支持通过策略模式实现的 **智能路由 (Smart Routing)**。

### 核心概念

- **规则 (`ChainRequestRule`)**: 定义链中的单个步骤。
- **上下文 (Context)**: 一个共享对象，用于累积结果。每个步骤都可以从上下文中读取数据或向其中写入结果。
- **处理器 (`NetworkHandler`)**: 根据 URL 或方法决定哪个 **适配器 (Adapter)** 应该执行特定的请求。

### 规则配置 (`ChainRequestRule`)

| 属性       | 类型              | 描述                                                         |
| :--------- | :---------------- | :----------------------------------------------------------- |
| `key`      | `string`          | 结果在上下文中的存储键名。                                   |
| `request`  | `HttpRequestSpec` | 包含方法、URL、Header 和初始 Body。                          |
| `selector` | `string`          | 可选。使用点分隔的路径（如 `user.id`）从响应中提取特定数据。 |
| `optional` | `boolean`         | 如果为 true，即使该请求失败，链式执行也会继续。              |

### 变量替换

你可以在 `url`、`headers` 和 `body` 中使用 `{{key.path}}`。引擎会自动将这些占位符替换为当前上下文中的值。

### 详细示例：三步请求编排

```ts
import { executeRequestChain } from "@i.un/api-client";

const rules = [
  // 步骤 1: 获取登录会话
  {
    key: "auth",
    request: { method: "POST", url: "/login", body: { user: "admin" } },
  },
  // 步骤 2: 在 URL 和 Header 中使用会话 Token
  {
    key: "profile",
    request: {
      method: "GET",
      url: "/users/{{auth.userId}}",
      headers: { "X-Session": "{{auth.token}}" },
    },
    selector: "data", // context.profile 将仅包含响应中的 'data' 字段
  },
  // 步骤 3: 结合之前的步骤数据发起新请求
  {
    key: "update",
    request: {
      method: "PUT",
      url: "/sync",
      body: {
        source: "web-client",
        profile: "{{profile}}", // 自动从上下文中注入 'profile' 对象/数组
      },
    },
  },
];

const result = await executeRequestChain(rules);
```

### 自定义路由 (策略模式)

你可以提供 `NetworkHandler` 列表，将请求路由到不同的环境（例如：将 `/ext/*` 重定向到浏览器扩展 API，而 `/api/*` 走标准 fetch）。

```ts
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 模块为 `ofetch` 提供了一层安全保障。它支持二进制序列化、XOR 混淆以及通过 Hook 实现的自动集成。

### 处理流程

1. **对象** -> **转换 (Transform, 可选)** -> **Protobuf 编码** -> **XOR 混淆** -> **二进制负载 (Binary Payload)**

### 集成 Hook

由于使用了 `createProtobufHooks`，你可以轻松地为 `api-client` 实例添加 Protobuf 支持。

```ts
import {
  createApiClient,
  createProtobufHooks,
  createXorObfuscator,
} from "@i.un/api-client";

const hooks = createProtobufHooks({
  // 1. 自定义混淆器 (可选)
  // obfuscator: createXorObfuscator("my-secret-key"),

  // 2. 自定义数据转换
  transform: {
    beforeEncode: (data) => ({ ...data, ts: Date.now() }),
    afterDecode: (res) => res.data,
  },
});

const client = createApiClient({
  baseURL: "/api",
  ...hooks,
});

// 使用方法：只需将 Content-Type 或 Accept 设置为 'application/x-protobuf'
const data = await client.post(
  "/secure-endpoint",
  { foo: "bar" },
  {
    headers: { "Content-Type": "application/x-protobuf" },
  },
);
```

### 手动配置请求头

如果你没有使用自动 Hook，或者需要为特定请求手动配置 Header，可以使用 `getProtobufHeaders` 辅助函数：

```ts
import { getProtobufHeaders } from "@i.un/api-client";

// 1. 同时发送和接收 Protobuf (默认)
const headers = {
  ...getProtobufHeaders({ send: true, receive: true }),
  "X-Custom": "val",
};
// { "X-Custom": "val", "Content-Type": "application/x-protobuf", "Accept": "application/x-protobuf" }

// 2. 仅接收 Protobuf
const receiveOnly = getProtobufHeaders({ send: false, receive: true });
// { "Accept": "application/x-protobuf" }

// 在 client 中使用
await client.get("/data", { headers: receiveOnly });
```

### 信封模式 vs 自定义类型模式

- **信封模式 (默认)**：如果不提供 `protoType`，库将使用内置的 `SecurePayload`（包含时间戳和经过 JSON 序列化的数据）。这是在不为每个接口定义 Schema 的情况下，向开发者工具隐藏数据的最简单方法。
- **自定义类型模式**：提供一个 `protoType`（从 `.proto` 文件编译出的对象），以获得极致的二进制性能和类型安全。

```ts
const hooks = createProtobufHooks({
  protoType: MyCompiledMessage, // 来自 protobufjs
});
```

### 自定义混淆逻辑 (Obfuscator)

默认不启用任何混淆。如果需要对二进制数据进行混淆（例如为了绕过某些网络审查或简单的防篡改），你可以提供 `obfuscator` 实现。

如果你想使用库内置的 XOR 混淆，可以使用 `createXorObfuscator`：

```ts
import { createProtobufHooks, createXorObfuscator } from "@i.un/api-client";

const hooks = createProtobufHooks({
  obfuscator: createXorObfuscator("your-secret-key"),
});
```

或者完全自定义混淆算法：

```ts
import { createProtobufHooks, type ProtobufObfuscator } from "@i.un/api-client";

const myObfuscator: ProtobufObfuscator = {
  encrypt: (data: Uint8Array) => {
    // 自定义加密逻辑，返回 Uint8Array
    return data;
  },
  decrypt: (data: Uint8Array) => {
    // 自定义解密逻辑
    return data;
  },
};

const hooks = createProtobufHooks({
  obfuscator: myObfuscator,
});
```

### 服务端/边缘计算使用 (Node/Workers)

本库提供了服务端工具，用于处理这些安全请求。

```ts
import {
  parseSecureRequest,
  secureResponse,
  createXorObfuscator,
} from "@i.un/api-client";

// 1. 解析进入的加密请求
const body = await parseSecureRequest(request, {
  obfuscator: createXorObfuscator("key"),
});

// 2. 发送加密响应
return await secureResponse(
  { success: true },
  {
    obfuscator: createXorObfuscator("key"),
  },
);
```

---

## 环境支持与注意事项

---

- 依赖 `ofetch`，可在浏览器、Node 18+、Nuxt 等环境使用。
- 需要环境有 `fetch` / `Headers`：
  - 浏览器：原生支持。
  - Node 18+：内置 `fetch`。
  - 更低版本 Node：需要自行 polyfill（例如 `undici` 或 `cross-fetch`）。
- `client.ts` 本身不依赖 `window`／`localStorage`／`chrome` 等对象，这些应在你实现 `TokenStorage` 时根据环境自行选择。

---

## License

MIT