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
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import axios, { AxiosError, AxiosHeaders } from "axios";
|
|
2
|
+
//#region src/http-client/constants.ts
|
|
3
|
+
/**
|
|
4
|
+
* 包内默认常量。
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
/** Default error messages */
|
|
9
|
+
const DEFAULT_MESSAGES = {
|
|
10
|
+
refreshTokenExpired: "Refresh token is invalid or expired",
|
|
11
|
+
loginExpired: "Login session has expired",
|
|
12
|
+
refreshDisabled: "Refresh token flow is not enabled"
|
|
13
|
+
};
|
|
14
|
+
/** Default retry delay cap (ms) */
|
|
15
|
+
const DEFAULT_RETRY_DELAY_CAP = 3e4;
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/http-client/utils/common.ts
|
|
18
|
+
/**
|
|
19
|
+
* 判断 config 是否已通过 signal / cancelToken 取消。
|
|
20
|
+
*/
|
|
21
|
+
const isCanceled = (config) => {
|
|
22
|
+
if (config.signal?.aborted) return true;
|
|
23
|
+
try {
|
|
24
|
+
config.cancelToken?.throwIfRequested();
|
|
25
|
+
} catch {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* 归一化 timeout:仅正数生效,其余视为 0(无超时)。
|
|
32
|
+
*/
|
|
33
|
+
const normalizeTimeoutMs = (timeout) => typeof timeout === "number" && timeout > 0 ? timeout : 0;
|
|
34
|
+
/**
|
|
35
|
+
* 构造 axios CanceledError。
|
|
36
|
+
*/
|
|
37
|
+
const createCanceledError = (config) => new axios.CanceledError("canceled", config);
|
|
38
|
+
/**
|
|
39
|
+
* 构造 axios timeout 错误(ECONNABORTED)。
|
|
40
|
+
*/
|
|
41
|
+
const createTimeoutError = (config, timeoutMs) => new AxiosError(`timeout of ${timeoutMs}ms exceeded`, AxiosError.ECONNABORTED, config);
|
|
42
|
+
/**
|
|
43
|
+
* 深拷贝任意值;不可 clone 时回退浅拷贝。
|
|
44
|
+
*/
|
|
45
|
+
const cloneDeep = (value) => {
|
|
46
|
+
if (value == null || typeof value !== "object") return value;
|
|
47
|
+
try {
|
|
48
|
+
return structuredClone(value);
|
|
49
|
+
} catch {
|
|
50
|
+
if (Array.isArray(value)) return [...value];
|
|
51
|
+
return { ...value };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 订阅 config 的取消事件(signal / cancelToken),返回取消订阅函数。
|
|
56
|
+
*/
|
|
57
|
+
const subscribeCancel = (config, onCancel) => {
|
|
58
|
+
const signal = config.signal;
|
|
59
|
+
const onAbort = () => onCancel();
|
|
60
|
+
signal?.addEventListener?.("abort", onAbort);
|
|
61
|
+
const cancelToken = config.cancelToken;
|
|
62
|
+
let cancelListener;
|
|
63
|
+
if (cancelToken?.subscribe) {
|
|
64
|
+
cancelListener = () => onCancel();
|
|
65
|
+
cancelToken.subscribe(cancelListener);
|
|
66
|
+
} else if (cancelToken?.promise) cancelToken.promise.then(onCancel, () => {});
|
|
67
|
+
return () => {
|
|
68
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
69
|
+
if (cancelToken?.unsubscribe && cancelListener) cancelToken.unsubscribe(cancelListener);
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/http-client/create-dedupe-adapter.ts
|
|
74
|
+
/**
|
|
75
|
+
* 将 shared 错误分叉为绑定当前 consumer config 的独立错误实例。
|
|
76
|
+
* 仅 dedupe 合并路径使用。
|
|
77
|
+
*/
|
|
78
|
+
const forkError = (error, config) => {
|
|
79
|
+
if (axios.isAxiosError(error)) return new AxiosError(error.message, error.code, config, error.request, error.response ? {
|
|
80
|
+
...error.response,
|
|
81
|
+
data: cloneDeep(error.response.data),
|
|
82
|
+
config
|
|
83
|
+
} : void 0);
|
|
84
|
+
if (error instanceof Error) {
|
|
85
|
+
const forked = new Error(error.message);
|
|
86
|
+
forked.name = error.name;
|
|
87
|
+
forked.stack = error.stack;
|
|
88
|
+
if ("cause" in error) forked.cause = error.cause;
|
|
89
|
+
return forked;
|
|
90
|
+
}
|
|
91
|
+
return error;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* adapter 层 in-flight GET 合并。
|
|
95
|
+
*
|
|
96
|
+
* - 共享底层网络 Promise + 内部 AbortController
|
|
97
|
+
* - 每个消费者绑定自己的 config / cancel / timeout
|
|
98
|
+
* - 消费者取消仅为逻辑取消;refCount 归零才物理 abort
|
|
99
|
+
* - shared timeout 取参与者中的 max(timeout);任一无限则 shared 不强制超时
|
|
100
|
+
* - 成功响应只深拷贝 data,避免消费者互相污染
|
|
101
|
+
*
|
|
102
|
+
* @internal
|
|
103
|
+
*/
|
|
104
|
+
const createDedupeAdapter = ({ underlyingAdapter, dedupeManager, clientDedupeEnabled }) => {
|
|
105
|
+
const getAdapter = axios.getAdapter;
|
|
106
|
+
const dispatch = (config) => Promise.resolve(getAdapter(underlyingAdapter, config)(config));
|
|
107
|
+
const createSharedEntry = (config) => {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const { signal: _signal, cancelToken: _cancelToken, timeout: _timeout, ...rest } = config;
|
|
110
|
+
const sharedConfig = {
|
|
111
|
+
...rest,
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
timeout: 0
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
promise: dispatch(sharedConfig),
|
|
117
|
+
controller,
|
|
118
|
+
refCount: 0,
|
|
119
|
+
timeouts: [],
|
|
120
|
+
maxTimeoutMs: 0,
|
|
121
|
+
startedAt: Date.now(),
|
|
122
|
+
closed: false
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const bindConsumer = (entry, config) => new Promise((resolve, reject) => {
|
|
126
|
+
if (isCanceled(config)) {
|
|
127
|
+
reject(createCanceledError(config));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const timeoutMs = normalizeTimeoutMs(config.timeout);
|
|
131
|
+
dedupeManager.retain(entry, timeoutMs);
|
|
132
|
+
let settled = false;
|
|
133
|
+
let localTimeoutId;
|
|
134
|
+
const settle = (fn) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
if (localTimeoutId != null) clearTimeout(localTimeoutId);
|
|
138
|
+
cleanupCancel();
|
|
139
|
+
dedupeManager.release(entry, timeoutMs);
|
|
140
|
+
fn();
|
|
141
|
+
};
|
|
142
|
+
const onConsumerLeave = (error) => {
|
|
143
|
+
settle(() => reject(error));
|
|
144
|
+
};
|
|
145
|
+
const cleanupCancel = subscribeCancel(config, () => {
|
|
146
|
+
onConsumerLeave(createCanceledError(config));
|
|
147
|
+
});
|
|
148
|
+
if (timeoutMs > 0) localTimeoutId = setTimeout(() => {
|
|
149
|
+
onConsumerLeave(createTimeoutError(config, timeoutMs));
|
|
150
|
+
}, timeoutMs);
|
|
151
|
+
entry.promise.then((response) => {
|
|
152
|
+
if (isCanceled(config)) {
|
|
153
|
+
onConsumerLeave(createCanceledError(config));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
settle(() => resolve({
|
|
157
|
+
...response,
|
|
158
|
+
data: cloneDeep(response.data),
|
|
159
|
+
config
|
|
160
|
+
}));
|
|
161
|
+
}, (error) => {
|
|
162
|
+
if (isCanceled(config)) {
|
|
163
|
+
onConsumerLeave(createCanceledError(config));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (axios.isCancel(error) || axios.isAxiosError(error) && error.code === AxiosError.ERR_CANCELED) {
|
|
167
|
+
if (entry.abortReason === "timeout") {
|
|
168
|
+
onConsumerLeave(createTimeoutError(config, entry.maxTimeoutMs || timeoutMs || 0));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
onConsumerLeave(createCanceledError(config));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
settle(() => reject(forkError(error, config)));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
return (config) => {
|
|
178
|
+
const method = (config.method ?? "get").toLowerCase();
|
|
179
|
+
const enabled = config.dedupePolicy?.enabled ?? clientDedupeEnabled;
|
|
180
|
+
if (method !== "get" || !enabled) return dispatch(config);
|
|
181
|
+
if (isCanceled(config)) return Promise.reject(createCanceledError(config));
|
|
182
|
+
const key = dedupeManager.getKey(config);
|
|
183
|
+
let entry = dedupeManager.getPending(key);
|
|
184
|
+
if (!entry) {
|
|
185
|
+
entry = createSharedEntry(config);
|
|
186
|
+
dedupeManager.setPending(key, entry);
|
|
187
|
+
}
|
|
188
|
+
return bindConsumer(entry, config);
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/http-client/dedupe-manager.ts
|
|
193
|
+
/**
|
|
194
|
+
* 请求合并管理器。
|
|
195
|
+
* 仅合并仍在进行中的相同请求(in-flight coalesce)。
|
|
196
|
+
*
|
|
197
|
+
* @internal
|
|
198
|
+
*/
|
|
199
|
+
var DedupeManager = class {
|
|
200
|
+
pending = /* @__PURE__ */ new Map();
|
|
201
|
+
generateKey;
|
|
202
|
+
constructor(generateKey) {
|
|
203
|
+
this.generateKey = generateKey ?? this.defaultGenerateKey;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 默认的 key 生成器。
|
|
207
|
+
* 由 lower-case method + baseURL + url + 稳定序列化 params + headersProvider 快照组成。
|
|
208
|
+
*/
|
|
209
|
+
defaultGenerateKey(config) {
|
|
210
|
+
return `${(config.method ?? "get").toLowerCase()}:${config.baseURL ?? ""}:${config.url ?? ""}:${this.stableSerialize(config.params)}:${this.stableSerialize(config.__dedupeProviderHeaders ?? null)}`;
|
|
211
|
+
}
|
|
212
|
+
stableSerialize(value) {
|
|
213
|
+
if (value == null) return "";
|
|
214
|
+
return JSON.stringify(this.normalizeForSerialize(value, /* @__PURE__ */ new WeakSet()));
|
|
215
|
+
}
|
|
216
|
+
normalizeForSerialize(value, seen) {
|
|
217
|
+
if (value == null || typeof value !== "object") return value;
|
|
218
|
+
if (value instanceof Date) return {
|
|
219
|
+
__type: "Date",
|
|
220
|
+
value: value.toISOString()
|
|
221
|
+
};
|
|
222
|
+
if (value instanceof URLSearchParams) return {
|
|
223
|
+
__type: "URLSearchParams",
|
|
224
|
+
value: Array.from(value.entries()).sort(([a], [b]) => a.localeCompare(b))
|
|
225
|
+
};
|
|
226
|
+
if (value instanceof Set) return {
|
|
227
|
+
__type: "Set",
|
|
228
|
+
value: Array.from(value).map((item) => this.normalizeForSerialize(item, seen)).map((item) => JSON.stringify(item)).sort((a, b) => a.localeCompare(b)).map((item) => JSON.parse(item))
|
|
229
|
+
};
|
|
230
|
+
if (value instanceof Map) return {
|
|
231
|
+
__type: "Map",
|
|
232
|
+
value: Array.from(value.entries()).map(([key, nested]) => [this.normalizeForSerialize(key, seen), this.normalizeForSerialize(nested, seen)]).map(([key, nested]) => [
|
|
233
|
+
JSON.stringify(key),
|
|
234
|
+
key,
|
|
235
|
+
nested
|
|
236
|
+
]).sort(([a], [b]) => a.localeCompare(b)).map(([, key, nested]) => [key, nested])
|
|
237
|
+
};
|
|
238
|
+
if (seen.has(value)) throw new TypeError("Circular structure in dedupe key serialization");
|
|
239
|
+
seen.add(value);
|
|
240
|
+
if (Array.isArray(value)) {
|
|
241
|
+
const normalized = value.map((item) => this.normalizeForSerialize(item, seen));
|
|
242
|
+
seen.delete(value);
|
|
243
|
+
return normalized;
|
|
244
|
+
}
|
|
245
|
+
const normalizedObject = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, nested]) => [key, this.normalizeForSerialize(nested, seen)]));
|
|
246
|
+
seen.delete(value);
|
|
247
|
+
return normalizedObject;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 生成请求的唯一 key。
|
|
251
|
+
*/
|
|
252
|
+
getKey(config) {
|
|
253
|
+
return this.generateKey(config);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 读取可 join 的 pending entry。
|
|
257
|
+
* closed 的 entry 视为不可复用。
|
|
258
|
+
*/
|
|
259
|
+
getPending(key) {
|
|
260
|
+
const entry = this.pending.get(key);
|
|
261
|
+
if (!entry || entry.closed) return null;
|
|
262
|
+
return entry;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 注册新的 shared 请求。
|
|
266
|
+
*/
|
|
267
|
+
setPending(key, entry) {
|
|
268
|
+
this.pending.set(key, entry);
|
|
269
|
+
const cleanup = () => {
|
|
270
|
+
this.clearTimer(entry);
|
|
271
|
+
if (this.pending.get(key) === entry) this.pending.delete(key);
|
|
272
|
+
};
|
|
273
|
+
entry.promise.then(cleanup, cleanup);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* consumer 加入 shared。
|
|
277
|
+
* @param timeoutMs 0 表示无超时
|
|
278
|
+
*/
|
|
279
|
+
retain(entry, timeoutMs) {
|
|
280
|
+
entry.refCount += 1;
|
|
281
|
+
entry.timeouts.push(timeoutMs);
|
|
282
|
+
this.recomputeTimeout(entry);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* consumer 离开 shared。
|
|
286
|
+
* 当 refCount 归零时 abort 底层请求并标记 closed。
|
|
287
|
+
*/
|
|
288
|
+
release(entry, timeoutMs) {
|
|
289
|
+
const index = entry.timeouts.indexOf(timeoutMs);
|
|
290
|
+
if (index >= 0) entry.timeouts.splice(index, 1);
|
|
291
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
292
|
+
if (entry.closed) return;
|
|
293
|
+
if (entry.refCount === 0) {
|
|
294
|
+
this.abortEntry(entry, "release");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.recomputeTimeout(entry);
|
|
298
|
+
}
|
|
299
|
+
recomputeTimeout(entry) {
|
|
300
|
+
if (entry.closed) return;
|
|
301
|
+
if (entry.timeouts.some((item) => item <= 0)) {
|
|
302
|
+
entry.maxTimeoutMs = 0;
|
|
303
|
+
this.clearTimer(entry);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const nextMax = entry.timeouts.length > 0 ? Math.max(...entry.timeouts) : 0;
|
|
307
|
+
entry.maxTimeoutMs = nextMax;
|
|
308
|
+
if (nextMax <= 0) {
|
|
309
|
+
this.clearTimer(entry);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const remaining = nextMax - (Date.now() - entry.startedAt);
|
|
313
|
+
this.clearTimer(entry);
|
|
314
|
+
if (remaining <= 0) {
|
|
315
|
+
this.abortEntry(entry, "timeout");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
entry.timeoutId = setTimeout(() => {
|
|
319
|
+
this.abortEntry(entry, "timeout");
|
|
320
|
+
}, remaining);
|
|
321
|
+
}
|
|
322
|
+
abortEntry(entry, reason) {
|
|
323
|
+
if (entry.closed) return;
|
|
324
|
+
entry.closed = true;
|
|
325
|
+
entry.abortReason = reason;
|
|
326
|
+
this.clearTimer(entry);
|
|
327
|
+
if (!entry.controller.signal.aborted) entry.controller.abort();
|
|
328
|
+
}
|
|
329
|
+
clearTimer(entry) {
|
|
330
|
+
if (entry.timeoutId != null) {
|
|
331
|
+
clearTimeout(entry.timeoutId);
|
|
332
|
+
entry.timeoutId = void 0;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/http-client/token-refresh-manager.ts
|
|
338
|
+
/** @internal 冷却期内跳过刷新时的哨兵值,不对业务侧开放。 */
|
|
339
|
+
const REFRESH_SKIPPED = Symbol("REFRESH_SKIPPED");
|
|
340
|
+
var TokenRefreshManager = class {
|
|
341
|
+
refreshingPromise = null;
|
|
342
|
+
lastRefreshTime = null;
|
|
343
|
+
cooldownMs;
|
|
344
|
+
constructor(cooldownMs = 15e3) {
|
|
345
|
+
this.cooldownMs = cooldownMs;
|
|
346
|
+
}
|
|
347
|
+
/** @internal 供 createHttpClient 内部调用。 */
|
|
348
|
+
async runRefresh(task) {
|
|
349
|
+
if (this.lastRefreshTime !== null && Date.now() - this.lastRefreshTime < this.cooldownMs) return REFRESH_SKIPPED;
|
|
350
|
+
if (!this.refreshingPromise) this.refreshingPromise = (async () => {
|
|
351
|
+
try {
|
|
352
|
+
const result = await task();
|
|
353
|
+
this.lastRefreshTime = Date.now();
|
|
354
|
+
return result;
|
|
355
|
+
} finally {
|
|
356
|
+
this.refreshingPromise = null;
|
|
357
|
+
}
|
|
358
|
+
})();
|
|
359
|
+
return this.refreshingPromise;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/http-client/utils/error.ts
|
|
364
|
+
/**
|
|
365
|
+
* 错误归一化与 onError 调用辅助。
|
|
366
|
+
*
|
|
367
|
+
* @internal
|
|
368
|
+
*/
|
|
369
|
+
/**
|
|
370
|
+
* 将任意值标准化为 Error 实例。
|
|
371
|
+
*/
|
|
372
|
+
const normalizeError = (error) => {
|
|
373
|
+
if (error instanceof Error) return error;
|
|
374
|
+
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return new Error(error.message);
|
|
375
|
+
try {
|
|
376
|
+
return new Error(JSON.stringify(error));
|
|
377
|
+
} catch {
|
|
378
|
+
return new Error(String(error));
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* 分叉错误实例,避免共享 Promise rejection 让多个消费者拿到同一引用。
|
|
383
|
+
*/
|
|
384
|
+
const cloneError = (error) => {
|
|
385
|
+
if (axios.isAxiosError(error)) return new AxiosError(error.message, error.code, error.config, error.request, error.response ? {
|
|
386
|
+
...error.response,
|
|
387
|
+
data: cloneDeep(error.response.data)
|
|
388
|
+
} : void 0);
|
|
389
|
+
if (error instanceof Error) {
|
|
390
|
+
const forked = new Error(error.message);
|
|
391
|
+
forked.name = error.name;
|
|
392
|
+
forked.stack = error.stack;
|
|
393
|
+
if ("cause" in error) forked.cause = error.cause;
|
|
394
|
+
for (const key of Object.keys(error)) {
|
|
395
|
+
if (key === "name" || key === "message" || key === "stack" || key === "cause") continue;
|
|
396
|
+
try {
|
|
397
|
+
forked[key] = error[key];
|
|
398
|
+
} catch {}
|
|
399
|
+
}
|
|
400
|
+
return forked;
|
|
401
|
+
}
|
|
402
|
+
return error;
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* 调用错误回调,返回最终错误。
|
|
406
|
+
*/
|
|
407
|
+
const invokeOnError = async (error, context, onError) => {
|
|
408
|
+
if (!onError) return error;
|
|
409
|
+
try {
|
|
410
|
+
return await onError(error, context) ?? error;
|
|
411
|
+
} catch {
|
|
412
|
+
return error;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/http-client/utils/refresh.ts
|
|
417
|
+
/**
|
|
418
|
+
* 默认的重试判断逻辑。
|
|
419
|
+
* 网络错误(AxiosError 无 response)或 5xx 状态码时重试。
|
|
420
|
+
* 用户取消 / 非 AxiosError 不重试。
|
|
421
|
+
*/
|
|
422
|
+
const defaultShouldRetry = (error) => {
|
|
423
|
+
if (!axios.isAxiosError(error)) return false;
|
|
424
|
+
if (axios.isCancel(error) || error.code === AxiosError.ERR_CANCELED) return false;
|
|
425
|
+
if (!error.response) return true;
|
|
426
|
+
return error.response.status >= 500;
|
|
427
|
+
};
|
|
428
|
+
/**
|
|
429
|
+
* 默认的重试延迟计算。
|
|
430
|
+
* 指数退避:1000 * 2^retryCount,上限 30 秒。
|
|
431
|
+
*/
|
|
432
|
+
const defaultRetryDelay = (retryCount) => {
|
|
433
|
+
return Math.min(1e3 * Math.pow(2, retryCount), DEFAULT_RETRY_DELAY_CAP);
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* 解析重试策略,补齐默认值。
|
|
437
|
+
*/
|
|
438
|
+
const resolveRetryPolicy = (retryPolicy) => {
|
|
439
|
+
if (!retryPolicy) return null;
|
|
440
|
+
const maxRetries = retryPolicy.maxRetries ?? 0;
|
|
441
|
+
if (maxRetries <= 0) return null;
|
|
442
|
+
return {
|
|
443
|
+
maxRetries,
|
|
444
|
+
shouldRetry: retryPolicy.shouldRetry ?? defaultShouldRetry,
|
|
445
|
+
retryDelay: retryPolicy.retryDelay ?? defaultRetryDelay
|
|
446
|
+
};
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* 判断是否跳过刷新流程。
|
|
450
|
+
*
|
|
451
|
+
* 匹配规则:
|
|
452
|
+
* - 仅 exact / prefix 路径匹配
|
|
453
|
+
* - 不是任意子串 includes,也不做中间段滑动匹配
|
|
454
|
+
* - 例如 `/auth` 匹配 `/auth`、`/auth/login`
|
|
455
|
+
* - 不匹配 `/user/auth-history`、`/authorization`、`/api/auth/login`
|
|
456
|
+
*/
|
|
457
|
+
const shouldSkipRefresh = (skipRefreshUrls, config) => {
|
|
458
|
+
const requestUrl = config?.url;
|
|
459
|
+
if (!requestUrl) return false;
|
|
460
|
+
const normalizedRequestPath = normalizeRequestPath(requestUrl);
|
|
461
|
+
return skipRefreshUrls.some((skipUrl) => matchesSkipPath(normalizedRequestPath, skipUrl));
|
|
462
|
+
};
|
|
463
|
+
const normalizeRequestPath = (requestUrl) => {
|
|
464
|
+
try {
|
|
465
|
+
return new URL(requestUrl, "http://localhost").pathname || "/";
|
|
466
|
+
} catch {
|
|
467
|
+
const withoutQuery = requestUrl.split("?")[0]?.split("#")[0] ?? requestUrl;
|
|
468
|
+
return withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
const matchesSkipPath = (requestPath, skipUrl) => {
|
|
472
|
+
if (!skipUrl) return false;
|
|
473
|
+
const skipPath = (skipUrl.startsWith("/") ? skipUrl : `/${skipUrl}`).replace(/\/+$/, "") || "/";
|
|
474
|
+
const path = requestPath.replace(/\/+$/, "") || "/";
|
|
475
|
+
return path === skipPath || path.startsWith(`${skipPath}/`);
|
|
476
|
+
};
|
|
477
|
+
/**
|
|
478
|
+
* 默认的刷新失败判断逻辑。
|
|
479
|
+
*/
|
|
480
|
+
const defaultIsRefreshFailure = (error, options) => {
|
|
481
|
+
if (!axios.isAxiosError(error)) return false;
|
|
482
|
+
if (!error.response || error.response.status >= 500) return false;
|
|
483
|
+
const status = error.response.status;
|
|
484
|
+
const data = error.response.data;
|
|
485
|
+
return status === options.unauthorizedStatusCode || data?.code !== void 0 && options.refreshFailureCodes.includes(data.code);
|
|
486
|
+
};
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/http-client/utils/token.ts
|
|
489
|
+
/**
|
|
490
|
+
* 格式化 token(添加前缀)。
|
|
491
|
+
*/
|
|
492
|
+
const formatAccessToken = (prefix, token) => {
|
|
493
|
+
const normalizedPrefix = prefix.trim();
|
|
494
|
+
return normalizedPrefix ? `${normalizedPrefix} ${token}` : token;
|
|
495
|
+
};
|
|
496
|
+
/**
|
|
497
|
+
* 标准化 token 结果,提取 token 和 expiresAt。
|
|
498
|
+
*/
|
|
499
|
+
const normalizeTokenResult = (result) => {
|
|
500
|
+
if (!result || typeof result === "string") return {
|
|
501
|
+
token: result ?? "",
|
|
502
|
+
expiresAt: null
|
|
503
|
+
};
|
|
504
|
+
const token = result.token ?? "";
|
|
505
|
+
const expiresAtValue = result.expiresAt;
|
|
506
|
+
if (expiresAtValue == null || expiresAtValue === "" || expiresAtValue === 0) return {
|
|
507
|
+
token,
|
|
508
|
+
expiresAt: null
|
|
509
|
+
};
|
|
510
|
+
const expiresAtDate = new Date(expiresAtValue);
|
|
511
|
+
if (Number.isNaN(expiresAtDate.getTime())) return {
|
|
512
|
+
token,
|
|
513
|
+
expiresAt: null
|
|
514
|
+
};
|
|
515
|
+
return {
|
|
516
|
+
token,
|
|
517
|
+
expiresAt: expiresAtDate
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* 判断 token 是否即将过期。
|
|
522
|
+
*/
|
|
523
|
+
const isTokenExpiringSoon = (expiresAt, refreshBufferMs) => {
|
|
524
|
+
return Date.now() >= expiresAt.getTime() - refreshBufferMs;
|
|
525
|
+
};
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/http-client/create-http-client.ts
|
|
528
|
+
const createHttpClient = (options) => {
|
|
529
|
+
const resolvedOptions = {
|
|
530
|
+
accessTokenHeaderName: "Authorization",
|
|
531
|
+
accessTokenPrefix: "Bearer",
|
|
532
|
+
refreshFailureCodes: [],
|
|
533
|
+
unauthorizedStatusCode: 401,
|
|
534
|
+
errorMessages: {},
|
|
535
|
+
isRefreshFailure: (error) => defaultIsRefreshFailure(error, {
|
|
536
|
+
unauthorizedStatusCode: resolvedOptions.unauthorizedStatusCode,
|
|
537
|
+
refreshFailureCodes: resolvedOptions.refreshFailureCodes
|
|
538
|
+
}),
|
|
539
|
+
skipRefreshUrls: [],
|
|
540
|
+
refreshBufferMs: 0,
|
|
541
|
+
shouldRefreshByResponse: () => false,
|
|
542
|
+
...options
|
|
543
|
+
};
|
|
544
|
+
const refreshManager = options.refreshManager ?? new TokenRefreshManager(options.refreshCooldownMs);
|
|
545
|
+
const refreshEnabled = options.refreshAccessToken !== void 0;
|
|
546
|
+
const resolvedRetryPolicy = resolveRetryPolicy(options.retryPolicy);
|
|
547
|
+
const clientDedupePolicy = options.dedupePolicy;
|
|
548
|
+
const clientDedupeEnabled = clientDedupePolicy?.enabled === true;
|
|
549
|
+
const dedupeManager = new DedupeManager(clientDedupePolicy?.generateKey);
|
|
550
|
+
const instance = axios.create({
|
|
551
|
+
timeout: 15 * 1e3,
|
|
552
|
+
...resolvedOptions.axiosConfig
|
|
553
|
+
});
|
|
554
|
+
const originalRequest = instance.request.bind(instance);
|
|
555
|
+
const replayRequest = (config) => originalRequest(config);
|
|
556
|
+
instance.defaults.adapter = createDedupeAdapter({
|
|
557
|
+
underlyingAdapter: instance.defaults.adapter,
|
|
558
|
+
dedupeManager,
|
|
559
|
+
clientDedupeEnabled
|
|
560
|
+
});
|
|
561
|
+
const handleAuthFailure = async (error) => {
|
|
562
|
+
await resolvedOptions.onAuthFailure?.(error);
|
|
563
|
+
};
|
|
564
|
+
const rejectWithError = async (error, type) => {
|
|
565
|
+
const finalError = await invokeOnError(normalizeError(error), { type }, resolvedOptions.onError);
|
|
566
|
+
return Promise.reject(finalError);
|
|
567
|
+
};
|
|
568
|
+
const refreshAccessToken = async () => {
|
|
569
|
+
const requestRefreshAccessToken = resolvedOptions.refreshAccessToken;
|
|
570
|
+
if (!refreshEnabled || !requestRefreshAccessToken) throw new Error(DEFAULT_MESSAGES.refreshDisabled);
|
|
571
|
+
const rejectRefreshAuthFailure = async () => {
|
|
572
|
+
const authError = new Error(resolvedOptions.errorMessages?.refreshTokenExpired ?? DEFAULT_MESSAGES.refreshTokenExpired);
|
|
573
|
+
await handleAuthFailure(authError);
|
|
574
|
+
throw await invokeOnError(authError, { type: "refresh" }, resolvedOptions.onError);
|
|
575
|
+
};
|
|
576
|
+
try {
|
|
577
|
+
return await refreshManager.runRefresh(async () => {
|
|
578
|
+
let result;
|
|
579
|
+
try {
|
|
580
|
+
result = await requestRefreshAccessToken();
|
|
581
|
+
} catch (error) {
|
|
582
|
+
const normalizedError = normalizeError(error);
|
|
583
|
+
if (resolvedOptions.isRefreshFailure(error)) await rejectRefreshAuthFailure();
|
|
584
|
+
throw await invokeOnError(normalizedError, { type: "refresh" }, resolvedOptions.onError);
|
|
585
|
+
}
|
|
586
|
+
const { token } = normalizeTokenResult(result);
|
|
587
|
+
if (!token) await rejectRefreshAuthFailure();
|
|
588
|
+
return result;
|
|
589
|
+
});
|
|
590
|
+
} catch (error) {
|
|
591
|
+
throw cloneError(error);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const retryAfterRefresh = async (config) => {
|
|
595
|
+
if (config._retry) {
|
|
596
|
+
const authError = new Error(resolvedOptions.errorMessages?.loginExpired ?? DEFAULT_MESSAGES.loginExpired);
|
|
597
|
+
await handleAuthFailure(authError);
|
|
598
|
+
throw await invokeOnError(authError, { type: "refresh" }, resolvedOptions.onError);
|
|
599
|
+
}
|
|
600
|
+
config._retry = true;
|
|
601
|
+
const refreshResult = await refreshAccessToken();
|
|
602
|
+
const { token } = normalizeTokenResult(refreshResult === REFRESH_SKIPPED ? await resolvedOptions.getAccessToken() : refreshResult);
|
|
603
|
+
if (!token) {
|
|
604
|
+
const authError = new Error(resolvedOptions.errorMessages?.loginExpired ?? DEFAULT_MESSAGES.loginExpired);
|
|
605
|
+
await handleAuthFailure(authError);
|
|
606
|
+
throw await invokeOnError(authError, { type: "refresh" }, resolvedOptions.onError);
|
|
607
|
+
}
|
|
608
|
+
const headers = AxiosHeaders.from(config.headers ?? {});
|
|
609
|
+
headers.set(resolvedOptions.accessTokenHeaderName, formatAccessToken(resolvedOptions.accessTokenPrefix, token));
|
|
610
|
+
config.headers = headers;
|
|
611
|
+
config.__tokenInjected = true;
|
|
612
|
+
config.__headersPrepared = false;
|
|
613
|
+
return replayRequest(config);
|
|
614
|
+
};
|
|
615
|
+
const prepareRequestHeaders = async (config) => {
|
|
616
|
+
if (config.__headersPrepared) return config;
|
|
617
|
+
const headersProvider = resolvedOptions.headersProvider;
|
|
618
|
+
const runtimeHeaders = headersProvider ? await headersProvider() : void 0;
|
|
619
|
+
config.__dedupeProviderHeaders = runtimeHeaders ? { ...runtimeHeaders } : headersProvider ? {} : null;
|
|
620
|
+
const headers = AxiosHeaders.from(config.headers ?? {});
|
|
621
|
+
const refreshedAuth = config._retry ? headers.get(resolvedOptions.accessTokenHeaderName) : void 0;
|
|
622
|
+
if (runtimeHeaders) headers.set(runtimeHeaders);
|
|
623
|
+
if (refreshedAuth != null) headers.set(resolvedOptions.accessTokenHeaderName, refreshedAuth);
|
|
624
|
+
config.headers = headers;
|
|
625
|
+
if (headers.get(resolvedOptions.accessTokenHeaderName) == null) {
|
|
626
|
+
const { token, expiresAt } = normalizeTokenResult(await resolvedOptions.getAccessToken());
|
|
627
|
+
if (token) {
|
|
628
|
+
if (refreshEnabled && expiresAt && !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config)) {
|
|
629
|
+
const bufferMs = resolvedOptions.refreshBufferMs;
|
|
630
|
+
if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) refreshAccessToken().catch(() => {});
|
|
631
|
+
}
|
|
632
|
+
headers.set(resolvedOptions.accessTokenHeaderName, formatAccessToken(resolvedOptions.accessTokenPrefix, token));
|
|
633
|
+
config.__tokenInjected = true;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
config.__headersPrepared = true;
|
|
637
|
+
return config;
|
|
638
|
+
};
|
|
639
|
+
instance.interceptors.request.use(async (config) => {
|
|
640
|
+
return prepareRequestHeaders(config);
|
|
641
|
+
}, (error) => Promise.reject(error));
|
|
642
|
+
instance.interceptors.response.use(async (response) => {
|
|
643
|
+
const config = response.config;
|
|
644
|
+
if (resolvedOptions.shouldRefreshByResponse(response) && refreshEnabled && !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config)) return retryAfterRefresh(config);
|
|
645
|
+
if (resolvedOptions.onBusinessResponse) try {
|
|
646
|
+
const businessResult = await resolvedOptions.onBusinessResponse(response);
|
|
647
|
+
if (businessResult instanceof Error) return rejectWithError(businessResult, "request");
|
|
648
|
+
if (businessResult && typeof businessResult === "object" && "status" in businessResult && "data" in businessResult && "config" in businessResult && "headers" in businessResult && "statusText" in businessResult) return {
|
|
649
|
+
...businessResult,
|
|
650
|
+
data: cloneDeep(businessResult.data)
|
|
651
|
+
};
|
|
652
|
+
} catch (businessError) {
|
|
653
|
+
return rejectWithError(businessError, "request");
|
|
654
|
+
}
|
|
655
|
+
return response;
|
|
656
|
+
}, async (error) => {
|
|
657
|
+
const config = error.config;
|
|
658
|
+
const normalizedError = normalizeError(error);
|
|
659
|
+
if (!config) return rejectWithError(normalizedError, "request");
|
|
660
|
+
if (error.response?.status === resolvedOptions.unauthorizedStatusCode) {
|
|
661
|
+
if (!refreshEnabled || shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config)) {
|
|
662
|
+
await handleAuthFailure(normalizedError);
|
|
663
|
+
return rejectWithError(normalizedError, "request");
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
return await retryAfterRefresh(config);
|
|
667
|
+
} catch (refreshError) {
|
|
668
|
+
return Promise.reject(refreshError);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (resolvedRetryPolicy) {
|
|
672
|
+
const retryCount = config.__retryCount ?? 0;
|
|
673
|
+
try {
|
|
674
|
+
const shouldRetry = resolvedRetryPolicy.shouldRetry(error, retryCount);
|
|
675
|
+
if (retryCount < resolvedRetryPolicy.maxRetries && shouldRetry) {
|
|
676
|
+
const rawDelay = resolvedRetryPolicy.retryDelay(retryCount);
|
|
677
|
+
const delay = typeof rawDelay === "number" && Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 0;
|
|
678
|
+
config.__retryCount = retryCount + 1;
|
|
679
|
+
if (config.__tokenInjected) {
|
|
680
|
+
const headers = AxiosHeaders.from(config.headers ?? {});
|
|
681
|
+
headers.delete(resolvedOptions.accessTokenHeaderName);
|
|
682
|
+
config.headers = headers;
|
|
683
|
+
delete config.__tokenInjected;
|
|
684
|
+
}
|
|
685
|
+
config.__headersPrepared = false;
|
|
686
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
687
|
+
return replayRequest(config);
|
|
688
|
+
}
|
|
689
|
+
} catch (policyError) {
|
|
690
|
+
return rejectWithError(policyError, "request");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return rejectWithError(normalizedError, "request");
|
|
694
|
+
});
|
|
695
|
+
return instance;
|
|
696
|
+
};
|
|
697
|
+
//#endregion
|
|
698
|
+
export { TokenRefreshManager as n, createHttpClient as t };
|
|
699
|
+
|
|
700
|
+
//# sourceMappingURL=create-http-client-pzpOS1bC.mjs.map
|