fzkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/dist/create-http-client-BoM9-_Ty.d.cts +265 -0
- package/dist/create-http-client-BoM9-_Ty.d.cts.map +1 -0
- package/dist/create-http-client-BoM9-_Ty.d.mts +265 -0
- package/dist/create-http-client-BoM9-_Ty.d.mts.map +1 -0
- package/dist/create-http-client-DzgZA6VA.cjs +734 -0
- package/dist/create-http-client-DzgZA6VA.cjs.map +1 -0
- package/dist/create-http-client-pzpOS1bC.mjs +700 -0
- package/dist/create-http-client-pzpOS1bC.mjs.map +1 -0
- package/dist/http-client/index.cjs +4 -0
- package/dist/http-client/index.d.cts +2 -0
- package/dist/http-client/index.d.mts +2 -0
- package/dist/http-client/index.mjs +2 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fengzai6
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# fzkit / HTTP Client
|
|
2
|
+
|
|
3
|
+
基于 `axios` 的 HTTP 客户端工厂。帮你统一处理:
|
|
4
|
+
|
|
5
|
+
- access token 注入
|
|
6
|
+
- 401 / 业务失效时的自动刷新
|
|
7
|
+
- 并发刷新合并与冷却
|
|
8
|
+
- 通用重试(网络错误 / 5xx)
|
|
9
|
+
- in-flight GET 请求合并
|
|
10
|
+
- 业务响应与错误钩子
|
|
11
|
+
|
|
12
|
+
> 当前版本 `0.1.0`,API 仍可能调整,适合早期接入与验证。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# npm
|
|
18
|
+
npm install fzkit axios
|
|
19
|
+
|
|
20
|
+
# yarn
|
|
21
|
+
yarn add fzkit axios
|
|
22
|
+
|
|
23
|
+
# pnpm
|
|
24
|
+
pnpm add fzkit axios
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`axios` 是 peer dependency,需要由业务项目自行安装(`^1.7.0`)。
|
|
28
|
+
|
|
29
|
+
## 快速开始
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import axios from "axios";
|
|
33
|
+
import { createHttpClient } from "fzkit";
|
|
34
|
+
|
|
35
|
+
const http = createHttpClient({
|
|
36
|
+
axiosConfig: {
|
|
37
|
+
baseURL: "/api",
|
|
38
|
+
timeout: 15_000,
|
|
39
|
+
},
|
|
40
|
+
getAccessToken: () => localStorage.getItem("access_token"),
|
|
41
|
+
refreshAccessToken: async () => {
|
|
42
|
+
const refreshToken = localStorage.getItem("refresh_token");
|
|
43
|
+
if (!refreshToken) {
|
|
44
|
+
throw new Error("refreshToken missing");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { data } = await axios.post("/auth/refresh", { refreshToken });
|
|
48
|
+
if (!data?.accessToken) {
|
|
49
|
+
throw new Error("refresh response missing accessToken");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
localStorage.setItem("access_token", data.accessToken);
|
|
53
|
+
if (data.refreshToken) {
|
|
54
|
+
localStorage.setItem("refresh_token", data.refreshToken);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return data.accessToken;
|
|
58
|
+
},
|
|
59
|
+
onAuthFailure: async () => {
|
|
60
|
+
localStorage.removeItem("access_token");
|
|
61
|
+
localStorage.removeItem("refresh_token");
|
|
62
|
+
// 跳转登录页
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const profile = await http.get("/account/profile");
|
|
67
|
+
console.log(profile.data);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
返回值是标准 `AxiosInstance`,可继续使用 `http.get/post/request` 等 axios API。
|
|
71
|
+
|
|
72
|
+
## 设计原则
|
|
73
|
+
|
|
74
|
+
1. **token 存储完全交给业务侧**
|
|
75
|
+
库不内置 storage,只通过 `getAccessToken` / `refreshAccessToken` 读写。
|
|
76
|
+
`refreshAccessToken` 返回新 token / `expiresAt` 时,业务必须写回 `getAccessToken` 使用的数据源。
|
|
77
|
+
|
|
78
|
+
2. **尽量保留 axios 语义**
|
|
79
|
+
成功返回 `AxiosResponse`;失败默认透传 `AxiosError`。
|
|
80
|
+
|
|
81
|
+
3. **鉴权失败与瞬时失败分离**
|
|
82
|
+
只有 refresh 鉴权失败、空 token、或登录过期才会走 `onAuthFailure`。
|
|
83
|
+
网络错误 / 5xx 默认不登出。
|
|
84
|
+
|
|
85
|
+
## Public API
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import {
|
|
89
|
+
createHttpClient,
|
|
90
|
+
TokenRefreshManager,
|
|
91
|
+
type HttpClientOptions,
|
|
92
|
+
type AccessTokenResult,
|
|
93
|
+
type AccessTokenDetail,
|
|
94
|
+
type RetryPolicy,
|
|
95
|
+
type DedupePolicy,
|
|
96
|
+
type RequestDedupePolicy,
|
|
97
|
+
type BusinessResponseResult,
|
|
98
|
+
type ErrorContext,
|
|
99
|
+
type ErrorMessages,
|
|
100
|
+
} from "fzkit/http-client";
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| 导出 | 说明 |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `createHttpClient(options)` | 创建带鉴权/刷新/重试/合并能力的 axios 实例 |
|
|
106
|
+
| `TokenRefreshManager` | 可选,多客户端共享同一套刷新与冷却状态 |
|
|
107
|
+
| 相关类型 | 配置与回调类型定义 |
|
|
108
|
+
|
|
109
|
+
## 配置项
|
|
110
|
+
|
|
111
|
+
### 最小可用
|
|
112
|
+
|
|
113
|
+
| 字段 | 必填 | 说明 |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `axiosConfig` | 是 | 透传给 `axios.create` |
|
|
116
|
+
| `getAccessToken` | 是 | 读取当前 access token |
|
|
117
|
+
| `refreshAccessToken` | 否 | 自定义刷新逻辑;不传则 401 直接走 `onAuthFailure` |
|
|
118
|
+
| `onAuthFailure` | 否 | 登录失效收尾(清 token、跳登录) |
|
|
119
|
+
| `skipRefreshUrls` | 否 | 不触发 refresh 的路径(exact / prefix) |
|
|
120
|
+
|
|
121
|
+
### Token 与 Headers
|
|
122
|
+
|
|
123
|
+
| 字段 | 默认 | 说明 |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `accessTokenHeaderName` | `"Authorization"` | token 注入 header 名 |
|
|
126
|
+
| `accessTokenPrefix` | `"Bearer"` | token 前缀 |
|
|
127
|
+
| `headersProvider` | - | 每次请求动态注入 headers(可 async) |
|
|
128
|
+
| `refreshBufferMs` | `0` | 提前刷新窗口;`getAccessToken` 返回 `AccessTokenDetail` 时生效 |
|
|
129
|
+
|
|
130
|
+
说明:
|
|
131
|
+
|
|
132
|
+
- `getAccessToken` 返回空值 / 空字符串 / `{ token: null|undefined }` 时,不注入鉴权 header
|
|
133
|
+
- 调用方显式传入鉴权 header(含空字符串)时,工厂不覆盖
|
|
134
|
+
- `headersProvider` 返回的 headers 会覆盖同名默认注入项
|
|
135
|
+
- 鉴权刷新后的重试请求中,新 token 优先,不会被 `headersProvider` 的旧 Authorization 覆盖
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const http = createHttpClient({
|
|
139
|
+
axiosConfig: { baseURL: "/api" },
|
|
140
|
+
getAccessToken: () => ({
|
|
141
|
+
token: localStorage.getItem("access_token") ?? "",
|
|
142
|
+
expiresAt: localStorage.getItem("expires_at"),
|
|
143
|
+
}),
|
|
144
|
+
refreshBufferMs: 60_000,
|
|
145
|
+
headersProvider: () => ({
|
|
146
|
+
"x-request-id": crypto.randomUUID(),
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 刷新控制
|
|
152
|
+
|
|
153
|
+
| 字段 | 默认 | 说明 |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `refreshAccessToken` | - | 刷新逻辑,返回类型与 `getAccessToken` 一致 |
|
|
156
|
+
| `shouldRefreshByResponse` | `() => false` | 通过业务响应判断是否需要刷新 |
|
|
157
|
+
| `refreshCooldownMs` | `15000` | 刷新成功后的冷却期,冷却内 401 跳过刷新、直接用新 token 重试 |
|
|
158
|
+
| `refreshManager` | 内部实例 | 多客户端共享刷新状态 |
|
|
159
|
+
| `unauthorizedStatusCode` | `401` | 触发刷新流程的 HTTP 状态码 |
|
|
160
|
+
| `refreshFailureCodes` | `[]` | 仅用于 refresh 失败判定的业务 code |
|
|
161
|
+
| `isRefreshFailure` | 内置默认 | 判断 refresh 是否已到需要登出的程度 |
|
|
162
|
+
|
|
163
|
+
默认 `isRefreshFailure`:
|
|
164
|
+
|
|
165
|
+
- 非 `AxiosError` → 不视为鉴权失败
|
|
166
|
+
- 无 `response`(网络错误)或 `status >= 500` → 不视为鉴权失败
|
|
167
|
+
- `status === unauthorizedStatusCode` 或命中 `refreshFailureCodes` → 鉴权失败
|
|
168
|
+
- `refreshAccessToken` 返回空 token → 按鉴权失败处理(不进冷却、不重试原请求)
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { createHttpClient, TokenRefreshManager } from "fzkit/http-client";
|
|
172
|
+
|
|
173
|
+
const sharedManager = new TokenRefreshManager(15_000);
|
|
174
|
+
|
|
175
|
+
const http1 = createHttpClient({
|
|
176
|
+
axiosConfig: { baseURL: "/api1" },
|
|
177
|
+
getAccessToken: () => tokenStore.accessToken,
|
|
178
|
+
refreshAccessToken: () => tokenStore.refresh(),
|
|
179
|
+
refreshManager: sharedManager,
|
|
180
|
+
onAuthFailure: () => tokenStore.logout(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const http2 = createHttpClient({
|
|
184
|
+
axiosConfig: { baseURL: "/api2" },
|
|
185
|
+
getAccessToken: () => tokenStore.accessToken,
|
|
186
|
+
refreshAccessToken: () => tokenStore.refresh(),
|
|
187
|
+
refreshManager: sharedManager,
|
|
188
|
+
onAuthFailure: () => tokenStore.logout(),
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 重试与请求合并
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const http = createHttpClient({
|
|
196
|
+
axiosConfig: { baseURL: "/api" },
|
|
197
|
+
getAccessToken: async () => "",
|
|
198
|
+
retryPolicy: {
|
|
199
|
+
maxRetries: 2,
|
|
200
|
+
// shouldRetry / retryDelay 可自定义
|
|
201
|
+
},
|
|
202
|
+
dedupePolicy: {
|
|
203
|
+
enabled: true,
|
|
204
|
+
// generateKey 可自定义
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
| 字段 | 默认 | 说明 |
|
|
210
|
+
| --- | --- | --- |
|
|
211
|
+
| `retryPolicy.maxRetries` | `0` | 最大重试次数(不含首次) |
|
|
212
|
+
| `retryPolicy.shouldRetry` | 网络错误或 5xx(取消不重试) | 是否重试 |
|
|
213
|
+
| `retryPolicy.retryDelay` | 指数退避,上限 30s | 重试延迟;非有限/负数按 0ms;回调抛错停止重试并走 `onError` |
|
|
214
|
+
| `dedupePolicy.enabled` | `false` | 是否启用 in-flight GET 合并 |
|
|
215
|
+
| `dedupePolicy.generateKey` | `method:baseURL:url:stableParams:headersProvider` | 合并 key |
|
|
216
|
+
|
|
217
|
+
补充:
|
|
218
|
+
|
|
219
|
+
- 鉴权失败(`unauthorizedStatusCode`)优先于通用 `retryPolicy`
|
|
220
|
+
- dedupe 仅对 GET 生效
|
|
221
|
+
- 请求级可用 `{ dedupePolicy: { enabled: false } }` 覆盖客户端级开关
|
|
222
|
+
- 请求级只能覆盖 `enabled`,不能改 `generateKey`
|
|
223
|
+
- dedupe 在 adapter 层生效,覆盖 `http(url)` / `http.get` / `http.request` 等入口
|
|
224
|
+
- 内部 refresh / retry 在失败后 pending 已清理,可正常重放,不会自死锁
|
|
225
|
+
|
|
226
|
+
#### dedupe 语义边界
|
|
227
|
+
|
|
228
|
+
- **合并粒度**:仅 in-flight;请求结束后相同 key 会重新发起
|
|
229
|
+
- **默认 key**:`method + baseURL + url + stableParams + headersProvider 快照`
|
|
230
|
+
- 只纳入 `headersProvider()` 返回值,不纳入调用方 `config.headers` / 工厂注入的 token
|
|
231
|
+
- 未配置 `headersProvider` 时该段为空
|
|
232
|
+
- **取消**:
|
|
233
|
+
- 每个消费者的 `signal` / `cancelToken` 只取消自己
|
|
234
|
+
- 使用引用计数;全部消费者都离开后,才 abort 底层 shared 请求
|
|
235
|
+
- 已取消的请求不会创建 shared,避免孤儿请求
|
|
236
|
+
- **超时**:
|
|
237
|
+
- 每个消费者按自己的 `timeout` 做本地超时
|
|
238
|
+
- shared 物理超时取当前参与者中的 `max(timeout)`
|
|
239
|
+
- 任一参与者 `timeout === 0`(显式关闭)时,shared 不强制超时
|
|
240
|
+
- 客户端默认 `timeout: 15_000`,未单独配置时会按该值参与 max 计算
|
|
241
|
+
- **响应隔离**:
|
|
242
|
+
- 成功响应深拷贝 `data`
|
|
243
|
+
- 失败时 `error.response.data` 也会深拷贝隔离
|
|
244
|
+
- `onBusinessResponse` 返回完整响应替换时,也会深拷贝 `data`
|
|
245
|
+
- 共享 refresh 失败时,每个 waiter 拿到独立错误实例
|
|
246
|
+
- `headers/status/request` **不保证**引用共享(axios 管线可能重建);仅保证 `data` 隔离
|
|
247
|
+
- **params 序列化**:默认 key 稳定支持 plain object / array / `URLSearchParams` / `Date` / `Map` / `Set`;循环引用会抛出可控错误
|
|
248
|
+
- **网络侧 config**:底层请求以首个创建 shared 的 leader config 为准(除 cancel/timeout 外)
|
|
249
|
+
|
|
250
|
+
### 业务钩子
|
|
251
|
+
|
|
252
|
+
| 字段 | 说明 |
|
|
253
|
+
| --- | --- |
|
|
254
|
+
| `onBusinessResponse` | 成功响应拦截:`void` 继续,`Error` / throw 失败,完整 `AxiosResponse` 替换响应 |
|
|
255
|
+
| `onError` | 请求失败 / 刷新失败钩子,可替换错误;`return Error` 与 `throw` 都会进入该钩子 |
|
|
256
|
+
| `errorMessages` | 覆盖内部英文默认文案(`refreshTokenExpired` / `loginExpired`) |
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const http = createHttpClient({
|
|
260
|
+
axiosConfig: { baseURL: "/api" },
|
|
261
|
+
getAccessToken: () => localStorage.getItem("access_token"),
|
|
262
|
+
onBusinessResponse: (response) => {
|
|
263
|
+
const data = response.data as { code?: number; message?: string };
|
|
264
|
+
if (data.code !== 0) {
|
|
265
|
+
return new Error(data.message ?? "business error");
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
onError: (error, context) => {
|
|
269
|
+
console.error(`[${context.type}]`, error.message);
|
|
270
|
+
},
|
|
271
|
+
errorMessages: {
|
|
272
|
+
refreshTokenExpired: "登录已过期,请重新登录",
|
|
273
|
+
loginExpired: "登录已失效,请重新登录",
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## 默认请求流程
|
|
279
|
+
|
|
280
|
+
1. `axios.create(axiosConfig)`
|
|
281
|
+
2. 请求拦截:注入 token、合并 `headersProvider`
|
|
282
|
+
3. 成功响应:
|
|
283
|
+
- `shouldRefreshByResponse` 判断是否刷新
|
|
284
|
+
- `onBusinessResponse` 处理业务响应
|
|
285
|
+
- 返回原始 `AxiosResponse`
|
|
286
|
+
4. 失败响应:
|
|
287
|
+
- 命中 `unauthorizedStatusCode` 且启用刷新 → refresh 后重放
|
|
288
|
+
- 命中 `unauthorizedStatusCode` 且未启用刷新 → `onAuthFailure`
|
|
289
|
+
- 其他错误再走 `retryPolicy`
|
|
290
|
+
5. refresh 失败:
|
|
291
|
+
- `isRefreshFailure === true` → `onAuthFailure`
|
|
292
|
+
- 否则透传原始错误,并触发 `onError({ type: "refresh" })`
|
|
293
|
+
|
|
294
|
+
## FAQ
|
|
295
|
+
|
|
296
|
+
### 1. 和直接用 axios 有什么区别?
|
|
297
|
+
|
|
298
|
+
`createHttpClient` 仍然返回 axios 实例,只是预装了鉴权、刷新、重试、合并等拦截逻辑。你继续用 `get/post/request`,不必换请求写法。
|
|
299
|
+
|
|
300
|
+
### 2. refresh 返回 400 会自动登出吗?
|
|
301
|
+
|
|
302
|
+
默认不会。只有:
|
|
303
|
+
|
|
304
|
+
- refresh 返回 `unauthorizedStatusCode`(默认 401)
|
|
305
|
+
- 或命中 `refreshFailureCodes`
|
|
306
|
+
- 或 `refreshAccessToken` 返回空 token
|
|
307
|
+
- 或你自定义的 `isRefreshFailure` 返回 `true`
|
|
308
|
+
|
|
309
|
+
才会走 `onAuthFailure`。
|
|
310
|
+
|
|
311
|
+
如果你们后端用业务 code 表示 refresh token 失效,推荐:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
refreshFailureCodes: [1001002],
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### 3. 为什么网络错误 / 5xx 不登出?
|
|
318
|
+
|
|
319
|
+
因为这通常不代表 refresh token 失效。默认策略是“瞬时失败可重试,鉴权失败才登出”。
|
|
320
|
+
|
|
321
|
+
### 4. `skipRefreshUrls` 怎么匹配?
|
|
322
|
+
|
|
323
|
+
只做 exact / prefix 路径匹配,不是子串 includes:
|
|
324
|
+
|
|
325
|
+
- `/auth` 匹配 `/auth`、`/auth/login`
|
|
326
|
+
- 不匹配 `/user/auth-history`、`/authorization`、`/api/auth/login`
|
|
327
|
+
|
|
328
|
+
### 5. 多个 http 客户端如何共享一次刷新?
|
|
329
|
+
|
|
330
|
+
创建共享的 `TokenRefreshManager`,通过 `refreshManager` 注入给多个 `createHttpClient` 实例。
|
|
331
|
+
|
|
332
|
+
共享 manager 表示同一鉴权域:
|
|
333
|
+
|
|
334
|
+
- 一次合并 refresh 事务的鉴权失败只会触发一次 `onAuthFailure`
|
|
335
|
+
- 使用发起该次 refresh 的 client 回调
|
|
336
|
+
- 建议所有共享 client 使用同一 token 数据源与等价 logout 行为
|
|
337
|
+
- 外部 manager 的冷却由构造参数决定;client 的 `refreshCooldownMs` 不会回写已有 manager
|
|
338
|
+
|
|
339
|
+
### 6. 请求级如何临时关闭 dedupe?
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
await http.get("/profile", {
|
|
343
|
+
dedupePolicy: { enabled: false },
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### 7. 不需要自动刷新怎么办?
|
|
348
|
+
|
|
349
|
+
不传 `refreshAccessToken` 即可。此时遇到 `unauthorizedStatusCode` 会直接触发 `onAuthFailure`。
|
|
350
|
+
|
|
351
|
+
### 8. 某些接口完全不想走鉴权/刷新?
|
|
352
|
+
|
|
353
|
+
建议单独使用普通 `axios` 实例,而不是复用 `createHttpClient` 产物。
|
|
354
|
+
|
|
355
|
+
### 9. 类型上为什么会看到 `dedupePolicy`?
|
|
356
|
+
|
|
357
|
+
当前通过 `declare module "axios"` 扩展了请求配置,方便请求级覆盖。
|
|
358
|
+
该字段只对 `createHttpClient` 创建的实例有运行时效果。
|
|
359
|
+
|
|
360
|
+
## 测试
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
yarn workspace fzkit test
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
测试分层与覆盖说明见 [`tests/README.md`](./tests/README.md)。
|
|
367
|
+
|
|
368
|
+
## License
|
|
369
|
+
|
|
370
|
+
MIT
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
|
2
|
+
//#region src/http-client/types/token.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* token 详细信息,用于主动刷新判断。
|
|
5
|
+
*/
|
|
6
|
+
interface AccessTokenDetail {
|
|
7
|
+
token: string;
|
|
8
|
+
expiresAt: Date | string | number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* getAccessToken 的返回值类型,兼容旧版纯字符串返回。
|
|
12
|
+
*/
|
|
13
|
+
type AccessTokenResult = string | AccessTokenDetail | null;
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/http-client/token-refresh-manager.d.ts
|
|
16
|
+
/** @internal 冷却期内跳过刷新时的哨兵值,不对业务侧开放。 */
|
|
17
|
+
declare const REFRESH_SKIPPED: unique symbol;
|
|
18
|
+
declare class TokenRefreshManager {
|
|
19
|
+
private refreshingPromise;
|
|
20
|
+
private lastRefreshTime;
|
|
21
|
+
private readonly cooldownMs;
|
|
22
|
+
constructor(cooldownMs?: number);
|
|
23
|
+
/** @internal 供 createHttpClient 内部调用。 */
|
|
24
|
+
runRefresh(task: () => Promise<AccessTokenResult>): Promise<AccessTokenResult | typeof REFRESH_SKIPPED>;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/http-client/types/common.d.ts
|
|
28
|
+
/**
|
|
29
|
+
* onBusinessResponse 的返回值类型。
|
|
30
|
+
* - void:继续正常流程(表示成功)
|
|
31
|
+
* - Error:抛出错误(表示业务失败)
|
|
32
|
+
* - AxiosResponse:用完整响应形态(status/data/headers/config/statusText)替换原响应,不会二次触发 onBusinessResponse
|
|
33
|
+
*/
|
|
34
|
+
type BusinessResponseResult = void | Error | AxiosResponse;
|
|
35
|
+
/**
|
|
36
|
+
* 错误上下文,传递给 onError 钩子。
|
|
37
|
+
*/
|
|
38
|
+
interface ErrorContext {
|
|
39
|
+
/**
|
|
40
|
+
* 错误来源类型。
|
|
41
|
+
*/
|
|
42
|
+
type: 'request' | 'refresh';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 自定义错误消息。
|
|
46
|
+
*/
|
|
47
|
+
interface ErrorMessages {
|
|
48
|
+
refreshTokenExpired?: string;
|
|
49
|
+
loginExpired?: string;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/http-client/types/http-client-options.d.ts
|
|
53
|
+
/**
|
|
54
|
+
* 请求合并配置。
|
|
55
|
+
*/
|
|
56
|
+
interface DedupePolicy {
|
|
57
|
+
/** 是否启用请求合并。默认 false。仅合并 GET 请求。 */
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* 自定义合并 key 生成器。
|
|
61
|
+
* 默认:`method:baseURL:url:stableParams:headersProviderSnapshot`
|
|
62
|
+
*
|
|
63
|
+
* 默认实现仅纳入 `headersProvider()` 返回值快照(`config.__dedupeProviderHeaders`),
|
|
64
|
+
* 不纳入调用方 `config.headers` 或工厂注入的 token。
|
|
65
|
+
* params 稳定序列化支持 plain object / array / URLSearchParams / Date / Map / Set;
|
|
66
|
+
* 循环引用会抛错,避免误合并。
|
|
67
|
+
*/
|
|
68
|
+
generateKey?: (config: AxiosRequestConfig) => string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 请求级合并配置。
|
|
72
|
+
* 仅允许覆盖 enabled;generateKey 只能在客户端级配置。
|
|
73
|
+
*/
|
|
74
|
+
interface RequestDedupePolicy {
|
|
75
|
+
/** 是否启用请求合并。覆盖客户端级 enabled。 */
|
|
76
|
+
enabled?: boolean;
|
|
77
|
+
}
|
|
78
|
+
declare module 'axios' {
|
|
79
|
+
interface AxiosRequestConfig {
|
|
80
|
+
/**
|
|
81
|
+
* 请求级合并策略。
|
|
82
|
+
* 仅可覆盖 enabled,不能修改 generateKey。
|
|
83
|
+
* 仅 createHttpClient 创建的实例生效。
|
|
84
|
+
*/
|
|
85
|
+
dedupePolicy?: RequestDedupePolicy;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 重试策略配置。
|
|
90
|
+
*/
|
|
91
|
+
interface RetryPolicy {
|
|
92
|
+
/** 最大重试次数(不含首次请求)。默认 0(不重试)。 */
|
|
93
|
+
maxRetries?: number;
|
|
94
|
+
/**
|
|
95
|
+
* 判断是否需要重试。
|
|
96
|
+
* 默认:网络错误(无 response)或 5xx 状态码时重试;用户取消不重试。
|
|
97
|
+
*/
|
|
98
|
+
shouldRetry?: (error: unknown, retryCount: number) => boolean;
|
|
99
|
+
/**
|
|
100
|
+
* 计算重试延迟(毫秒)。
|
|
101
|
+
* 默认:指数退避,公式 `1000 * 2^retryCount`,上限 30 秒。
|
|
102
|
+
* 返回非有限数(NaN/Infinity)或负数时,按 0ms 处理。
|
|
103
|
+
* shouldRetry / retryDelay 抛错会停止重试,并进入 onError({ type: "request" })。
|
|
104
|
+
*/
|
|
105
|
+
retryDelay?: (retryCount: number) => number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 创建 HTTP 客户端时可传入的配置。
|
|
109
|
+
*
|
|
110
|
+
* @typeParam T - getAccessToken / refreshAccessToken 的统一返回类型,
|
|
111
|
+
* 默认为 `AccessTokenResult`。当 T 为 `AccessTokenDetail` 时启用主动刷新。
|
|
112
|
+
*/
|
|
113
|
+
interface HttpClientOptions<T extends AccessTokenResult = AccessTokenResult> {
|
|
114
|
+
/**
|
|
115
|
+
* 透传给 axios.create 的初始化配置。
|
|
116
|
+
*/
|
|
117
|
+
axiosConfig: AxiosRequestConfig;
|
|
118
|
+
/**
|
|
119
|
+
* 获取当前 access token。
|
|
120
|
+
*
|
|
121
|
+
* 返回 `string` 时仅注入 token,不做主动刷新判断。
|
|
122
|
+
* 返回 `AccessTokenDetail` 时,会在 token 即将过期前主动触发刷新。
|
|
123
|
+
*/
|
|
124
|
+
getAccessToken: () => T | Promise<T>;
|
|
125
|
+
/**
|
|
126
|
+
* access token 注入到请求头时使用的字段名。
|
|
127
|
+
* 默认 `Authorization`。
|
|
128
|
+
*/
|
|
129
|
+
accessTokenHeaderName?: string;
|
|
130
|
+
/**
|
|
131
|
+
* access token 的前缀。
|
|
132
|
+
* 默认 `Bearer`。
|
|
133
|
+
*/
|
|
134
|
+
accessTokenPrefix?: string;
|
|
135
|
+
/**
|
|
136
|
+
* 刷新失败判定的业务 code 列表。
|
|
137
|
+
*
|
|
138
|
+
* 仅用于 `defaultIsRefreshFailure`:当 refresh 请求响应体存在 `data.code`
|
|
139
|
+
* 且命中该列表时,视为刷新鉴权失败。
|
|
140
|
+
*
|
|
141
|
+
* 不会影响普通业务请求的鉴权识别;更通用的入口是 `isRefreshFailure`。
|
|
142
|
+
* 默认假设响应体形状为 `{ code?: number }`。
|
|
143
|
+
*/
|
|
144
|
+
refreshFailureCodes?: number[];
|
|
145
|
+
/**
|
|
146
|
+
* 通用重试策略。
|
|
147
|
+
* 默认不重试(maxRetries: 0)。
|
|
148
|
+
*/
|
|
149
|
+
retryPolicy?: RetryPolicy;
|
|
150
|
+
/**
|
|
151
|
+
* 请求合并策略。
|
|
152
|
+
* 默认关闭(enabled: false)。
|
|
153
|
+
*/
|
|
154
|
+
dedupePolicy?: DedupePolicy;
|
|
155
|
+
/**
|
|
156
|
+
* 运行时动态 headers 提供者。
|
|
157
|
+
*
|
|
158
|
+
* 每次请求时调用,返回的 headers 会合并到请求中。
|
|
159
|
+
* 支持同步或异步返回。
|
|
160
|
+
*
|
|
161
|
+
* 典型场景:
|
|
162
|
+
* - 请求追踪:`{ 'x-trace-id': crypto.randomUUID() }`
|
|
163
|
+
* - 业务标识:从 store 读取 `{ 'x-tenant-id': tenantId }`
|
|
164
|
+
*
|
|
165
|
+
* 注意:
|
|
166
|
+
* - 首次请求时,返回的 headers 会覆盖默认注入的 headers(如 Authorization)
|
|
167
|
+
* - 鉴权刷新后的重试请求中,新 token 优先,不会被 headersProvider 的旧 Authorization 覆盖
|
|
168
|
+
*/
|
|
169
|
+
headersProvider?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
170
|
+
/**
|
|
171
|
+
* 触发刷新流程的 HTTP 状态码。
|
|
172
|
+
* 默认 `401`。
|
|
173
|
+
*/
|
|
174
|
+
unauthorizedStatusCode?: number;
|
|
175
|
+
/**
|
|
176
|
+
* 自定义错误消息,覆盖内部默认值。
|
|
177
|
+
*/
|
|
178
|
+
errorMessages?: ErrorMessages;
|
|
179
|
+
/**
|
|
180
|
+
* 判断刷新 token 请求本身是否已经失败到需要退出登录。
|
|
181
|
+
*
|
|
182
|
+
* 默认行为:
|
|
183
|
+
* - 非 AxiosError(编程错误 / 业务自定义 Error)不视为刷新鉴权失败
|
|
184
|
+
* - AxiosError 无 response(网络错误)或状态码 >= 500 时不视为刷新失败
|
|
185
|
+
* - 状态码 === unauthorizedStatusCode 时视为刷新失败
|
|
186
|
+
* - 响应 data.code 在 refreshFailureCodes 列表中时视为刷新鉴权失败
|
|
187
|
+
*
|
|
188
|
+
* 若业务需要“refresh 抛 Error 即登出”,请自定义该函数。
|
|
189
|
+
*/
|
|
190
|
+
isRefreshFailure?: (error: unknown) => boolean;
|
|
191
|
+
/**
|
|
192
|
+
* 自定义刷新逻辑,返回类型必须与 getAccessToken 一致。
|
|
193
|
+
*
|
|
194
|
+
* 库不缓存 token。若返回 AccessTokenDetail,业务必须把新 token / expiresAt
|
|
195
|
+
* 写回 getAccessToken 使用的数据源,后续请求才会读到新过期时间。
|
|
196
|
+
*/
|
|
197
|
+
refreshAccessToken?: () => T | Promise<T>;
|
|
198
|
+
/**
|
|
199
|
+
* 不触发 refresh token 流程的请求路径列表。
|
|
200
|
+
*
|
|
201
|
+
* 仅 exact / prefix 匹配,不是任意子串 includes,也不做中间段滑动匹配。
|
|
202
|
+
* 例如配置 `/auth` 会匹配 `/auth`、`/auth/login`,
|
|
203
|
+
* 但不会匹配 `/user/auth-history`、`/authorization`、`/api/auth/login`。
|
|
204
|
+
*/
|
|
205
|
+
skipRefreshUrls?: string[];
|
|
206
|
+
/**
|
|
207
|
+
* 提前刷新的毫秒数,默认 0(不提前刷新)。
|
|
208
|
+
* 设置为大于 0 的值时,会在 token 即将过期前主动触发刷新。
|
|
209
|
+
*/
|
|
210
|
+
refreshBufferMs?: number;
|
|
211
|
+
/**
|
|
212
|
+
* 刷新 token 后的冷却期(毫秒)。
|
|
213
|
+
* 在冷却期内收到的 401 请求会跳过刷新,直接使用新 token 重试。
|
|
214
|
+
* 用于处理刷新完成后,旧请求陆续返回 401 的并发场景。
|
|
215
|
+
* 默认 15000(15 秒)。
|
|
216
|
+
*/
|
|
217
|
+
refreshCooldownMs?: number;
|
|
218
|
+
/**
|
|
219
|
+
* 外部传入的 TokenRefreshManager 实例。
|
|
220
|
+
* 用于多个客户端共享同一鉴权域的 refresh / 冷却状态。
|
|
221
|
+
* 不传则自动创建独立实例。
|
|
222
|
+
*
|
|
223
|
+
* 共享 manager 时:
|
|
224
|
+
* - 一次合并 refresh 事务的鉴权失败只会触发一次 onAuthFailure
|
|
225
|
+
* - 使用发起该次 refresh 的 client 回调
|
|
226
|
+
* - 建议共享 client 使用同一 token 数据源与等价 logout 行为
|
|
227
|
+
* - 外部 manager 的 cooldown 由构造参数决定;client 的 refreshCooldownMs 不会回写已有 manager
|
|
228
|
+
*/
|
|
229
|
+
refreshManager?: TokenRefreshManager;
|
|
230
|
+
/**
|
|
231
|
+
* 通过业务响应判断是否需要刷新 token。
|
|
232
|
+
*/
|
|
233
|
+
shouldRefreshByResponse?: (response: AxiosResponse<unknown>) => boolean;
|
|
234
|
+
/**
|
|
235
|
+
* 登录失效后的统一收尾回调。
|
|
236
|
+
*/
|
|
237
|
+
onAuthFailure?: (error?: unknown) => void | Promise<void>;
|
|
238
|
+
/**
|
|
239
|
+
* 业务响应拦截器。
|
|
240
|
+
* - 返回 void:继续正常流程(表示成功)
|
|
241
|
+
* - 返回 Error:抛出错误(表示业务失败)
|
|
242
|
+
* - throw Error / 非 Error:与返回 Error 一样,统一进入 onError({ type: "request" })
|
|
243
|
+
* - 返回完整 AxiosResponse 形态:用新响应替换原响应,不会二次触发 onBusinessResponse
|
|
244
|
+
* - 可以是 async
|
|
245
|
+
*/
|
|
246
|
+
onBusinessResponse?: (response: AxiosResponse<unknown>) => BusinessResponseResult | Promise<BusinessResponseResult>;
|
|
247
|
+
/**
|
|
248
|
+
* 全局错误钩子。
|
|
249
|
+
* - 返回 Error:用新错误替换原错误
|
|
250
|
+
* - 返回 void:继续抛出原错误
|
|
251
|
+
* - 可以是 async
|
|
252
|
+
*
|
|
253
|
+
* error 类型为 AxiosError | Error:
|
|
254
|
+
* - 请求失败时为 AxiosError,可通过 axios.isAxiosError(error) 收敛访问 response/config
|
|
255
|
+
* - 刷新失败时可能为 AxiosError 或业务侧抛出的任意 Error
|
|
256
|
+
* - 登录过期等内部构造的错误为普通 Error
|
|
257
|
+
*/
|
|
258
|
+
onError?: (error: AxiosError | Error, context: ErrorContext) => AxiosError | Error | void | Promise<AxiosError | Error | void>;
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/http-client/create-http-client.d.ts
|
|
262
|
+
declare const createHttpClient: <T extends AccessTokenResult = AccessTokenResult>(options: HttpClientOptions<T>) => AxiosInstance;
|
|
263
|
+
//#endregion
|
|
264
|
+
export { RetryPolicy as a, ErrorMessages as c, AccessTokenResult as d, RequestDedupePolicy as i, TokenRefreshManager as l, DedupePolicy as n, BusinessResponseResult as o, HttpClientOptions as r, ErrorContext as s, createHttpClient as t, AccessTokenDetail as u };
|
|
265
|
+
//# sourceMappingURL=create-http-client-BoM9-_Ty.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-http-client-BoM9-_Ty.d.cts","names":[],"sources":["../src/http-client/types/token.ts","../src/http-client/token-refresh-manager.ts","../src/http-client/types/common.ts","../src/http-client/types/http-client-options.ts","../src/http-client/create-http-client.ts"],"mappings":";;;;;UAGiB;EACf;EACA,WAAW;;;;;KAMD,6BAA6B;;;;cCR5B;cAEA;UACH;UACA;mBACS;cAEL;;EAKN,WACJ,YAAY,QAAQ,qBACnB,QAAQ,2BAA2B;;;;;;;;;;KCe5B,gCAAgC,QAAQ;;;;UAKnC;;;;EAIf;;;;;UAMe;EACf;EACA;;;;;;;UCzCe;;EAEf;;;;;;;;;;EAWA,eAAe,QAAQ;;;;;;UAOR;;EAEf;;;YAMU;;;;;;IAMR,eAAe;;;;;;UAOF;;EAEf;;;;;EAMA,eAAe,gBAAgB;;;;;;;EAQ/B,cAAc;;;;;;;;UASC,kBAAkB,UAAU,oBAAoB;;;;EAI/D,aAAa;;;;;;;EAUb,sBAAsB,IAAI,QAAQ;;;;;EAMlC;;;;;EAMA;;;;;;;;;;EAaA;;;;;EAMA,cAAc;;;;;EAMd,eAAe;;;;;;;;;;;;;;;EAgBf,wBAAwB,yBAAyB,QAAQ;;;;;EAMzD;;;;EAKA,gBAAgB;;;;;;;;;;;;EAahB,oBAAoB;;;;;;;EAUpB,2BAA2B,IAAI,QAAQ;;;;;;;;EASvC;;;;;EAMA;;;;;;;EAQA;;;;;;;;;;;;EAaA,iBAAiB;;;;EAKjB,2BAA2B,UAAU;;;;EAOrC,iBAAiB,2BAA2B;;;;;;;;;EAU5C,sBACE,UAAU,2BACP,yBAAyB,QAAQ;;;;;;;;;;;;EAatC,WACE,OAAO,aAAa,OACpB,SAAS,iBACN,aAAa,eAAe,QAAQ,aAAa;;;;cC7O3C,mBAAoB,UAAU,oBAAoB,mBAC7D,SAAS,kBAAkB,OAC1B"}
|