@servora/client 0.0.2
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/dist/errors.d.ts +32 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/request.d.ts +43 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +146 -0
- package/dist/request.js.map +1 -0
- package/package.json +44 -0
- package/src/errors.ts +49 -0
- package/src/request.ts +227 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kratos 错误解析工具
|
|
3
|
+
*
|
|
4
|
+
* Kratos 统一错误格式:{ code, reason, message, metadata? }
|
|
5
|
+
* 所有使用 protoc-gen-go-errors 生成的服务均输出此格式。
|
|
6
|
+
*/
|
|
7
|
+
import type { ApiError } from './request.js';
|
|
8
|
+
export interface KratosErrorBody {
|
|
9
|
+
code: number;
|
|
10
|
+
reason: string;
|
|
11
|
+
message: string;
|
|
12
|
+
metadata?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 从 ApiError 中提取 Kratos 结构化错误体。
|
|
16
|
+
* 非 HTTP 错误、或响应体不符合格式时返回 null。
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseKratosError(err: ApiError): KratosErrorBody | null;
|
|
19
|
+
/**
|
|
20
|
+
* 判断 ApiError 是否为指定的 Kratos reason。
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* if (isKratosReason(err, 'EMAIL_NOT_VERIFIED')) { ... }
|
|
24
|
+
*/
|
|
25
|
+
export declare function isKratosReason(err: ApiError, reason: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* 提取用户可读的错误消息,含 network / timeout 降级处理。
|
|
28
|
+
*
|
|
29
|
+
* @param fallback 当无法提取有效消息时的兜底文案,默认 '操作失败'
|
|
30
|
+
*/
|
|
31
|
+
export declare function kratosMessage(err: ApiError, fallback?: string): string;
|
|
32
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAE5C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAClC;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,QAAQ,GAAG,eAAe,GAAG,IAAI,CAKtE;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAErE;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,QAAQ,EAAE,QAAQ,SAAS,GAAG,MAAM,CAOtE"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 ApiError 中提取 Kratos 结构化错误体。
|
|
3
|
+
* 非 HTTP 错误、或响应体不符合格式时返回 null。
|
|
4
|
+
*/
|
|
5
|
+
export function parseKratosError(err) {
|
|
6
|
+
if (err.kind !== 'http' || !err.responseBody)
|
|
7
|
+
return null;
|
|
8
|
+
const body = err.responseBody;
|
|
9
|
+
if (typeof body['reason'] !== 'string')
|
|
10
|
+
return null;
|
|
11
|
+
return body;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 判断 ApiError 是否为指定的 Kratos reason。
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* if (isKratosReason(err, 'EMAIL_NOT_VERIFIED')) { ... }
|
|
18
|
+
*/
|
|
19
|
+
export function isKratosReason(err, reason) {
|
|
20
|
+
return parseKratosError(err)?.reason === reason;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 提取用户可读的错误消息,含 network / timeout 降级处理。
|
|
24
|
+
*
|
|
25
|
+
* @param fallback 当无法提取有效消息时的兜底文案,默认 '操作失败'
|
|
26
|
+
*/
|
|
27
|
+
export function kratosMessage(err, fallback = '操作失败') {
|
|
28
|
+
if (err.kind === 'network')
|
|
29
|
+
return '网络连接失败,请检查网络设置';
|
|
30
|
+
if (err.kind === 'timeout')
|
|
31
|
+
return '请求超时,请稍后重试';
|
|
32
|
+
return (parseKratosError(err)?.message ??
|
|
33
|
+
(err.httpStatus ? `请求失败(HTTP ${err.httpStatus})` : fallback));
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAeA;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAa;IAC5C,IAAI,GAAG,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,GAAG,CAAC,YAAY;QAAE,OAAO,IAAI,CAAA;IACzD,MAAM,IAAI,GAAG,GAAG,CAAC,YAAuC,CAAA;IACxD,IAAI,OAAO,IAAI,CAAC,QAAQ,CAAC,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IACnD,OAAO,IAAkC,CAAA;AAC3C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,GAAa,EAAE,MAAc;IAC1D,OAAO,gBAAgB,CAAC,GAAG,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;AACjD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,GAAa,EAAE,QAAQ,GAAG,MAAM;IAC5D,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,gBAAgB,CAAA;IACnD,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,YAAY,CAAA;IAC/C,OAAO,CACL,gBAAgB,CAAC,GAAG,CAAC,EAAE,OAAO;QAC9B,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,aAAa,GAAG,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAC7D,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type RequestType = {
|
|
2
|
+
path: string;
|
|
3
|
+
method: string;
|
|
4
|
+
body: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type RequestMeta = {
|
|
7
|
+
service: string;
|
|
8
|
+
method: string;
|
|
9
|
+
};
|
|
10
|
+
export type RequestHandler = (request: RequestType, meta: RequestMeta) => Promise<unknown>;
|
|
11
|
+
export interface TokenStore {
|
|
12
|
+
getAccessToken: () => string | null;
|
|
13
|
+
getRefreshToken: () => string | null;
|
|
14
|
+
setTokens: (accessToken: string, refreshToken: string) => void;
|
|
15
|
+
clear: () => void;
|
|
16
|
+
}
|
|
17
|
+
export interface RequestHandlerOptions {
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
tokenStore?: TokenStore;
|
|
20
|
+
contextHeaders?: (meta: RequestMeta) => Record<string, string>;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
onError?: (error: ApiError, meta: RequestMeta) => void;
|
|
23
|
+
autoRefreshToken?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type ApiErrorKind = 'http' | 'network' | 'timeout';
|
|
26
|
+
export declare class ApiError extends Error {
|
|
27
|
+
readonly kind: ApiErrorKind;
|
|
28
|
+
readonly httpStatus?: number;
|
|
29
|
+
readonly responseBody?: unknown;
|
|
30
|
+
readonly service: string;
|
|
31
|
+
readonly method: string;
|
|
32
|
+
constructor(opts: {
|
|
33
|
+
kind: ApiErrorKind;
|
|
34
|
+
message: string;
|
|
35
|
+
httpStatus?: number;
|
|
36
|
+
responseBody?: unknown;
|
|
37
|
+
service: string;
|
|
38
|
+
method: string;
|
|
39
|
+
cause?: unknown;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export declare function createRequestHandler(options?: RequestHandlerOptions): RequestHandler;
|
|
43
|
+
//# sourceMappingURL=request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,MAAM,MAAM,cAAc,GAAG,CAC3B,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,WAAW,KACd,OAAO,CAAC,OAAO,CAAC,CAAA;AAErB,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACnC,eAAe,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACpC,SAAS,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9D,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,UAAU,CAAA;IACvB,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,KAAK,IAAI,CAAA;IACtD,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,CAAA;AAEzD,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAA;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;gBAEX,IAAI,EAAE;QAChB,IAAI,EAAE,YAAY,CAAA;QAClB,OAAO,EAAE,MAAM,CAAA;QACf,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,YAAY,CAAC,EAAE,OAAO,CAAA;QACtB,OAAO,EAAE,MAAM,CAAA;QACf,MAAM,EAAE,MAAM,CAAA;QACd,KAAK,CAAC,EAAE,OAAO,CAAA;KAChB;CASF;AASD,wBAAgB,oBAAoB,CAClC,OAAO,GAAE,qBAA0B,GAClC,cAAc,CA+IhB"}
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @servora/client — 通用 HTTP 请求处理器
|
|
3
|
+
*
|
|
4
|
+
* 为 proto 生成的 TypeScript client 提供标准 RequestHandler 实现,包含:
|
|
5
|
+
* - Bearer token 自动注入
|
|
6
|
+
* - Token 自动刷新(单例防重复)
|
|
7
|
+
* - 结构化 ApiError(区分 http / network / timeout)
|
|
8
|
+
* - 全局 onError 回调(延迟到宏任务,避免与 toast.promise 竞态)
|
|
9
|
+
*
|
|
10
|
+
* 消费方需自行安装 peer dependency: ofetch
|
|
11
|
+
*/
|
|
12
|
+
import { ofetch } from 'ofetch';
|
|
13
|
+
export class ApiError extends Error {
|
|
14
|
+
kind;
|
|
15
|
+
httpStatus;
|
|
16
|
+
responseBody;
|
|
17
|
+
service;
|
|
18
|
+
method;
|
|
19
|
+
constructor(opts) {
|
|
20
|
+
super(opts.message, { cause: opts.cause });
|
|
21
|
+
this.name = 'ApiError';
|
|
22
|
+
this.kind = opts.kind;
|
|
23
|
+
this.httpStatus = opts.httpStatus;
|
|
24
|
+
this.responseBody = opts.responseBody;
|
|
25
|
+
this.service = opts.service;
|
|
26
|
+
this.method = opts.method;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function ensureLeadingSlash(path) {
|
|
30
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
31
|
+
}
|
|
32
|
+
const REFRESH_PATH = '/v1/auth/refresh-token';
|
|
33
|
+
const AUTH_PATH_PREFIX = '/v1/auth/';
|
|
34
|
+
export function createRequestHandler(options = {}) {
|
|
35
|
+
const { baseUrl = '', tokenStore, contextHeaders, timeoutMs = 30_000, onError, autoRefreshToken = false, } = options;
|
|
36
|
+
let refreshPromise = null;
|
|
37
|
+
async function tryRefreshToken() {
|
|
38
|
+
if (!tokenStore)
|
|
39
|
+
return false;
|
|
40
|
+
const refreshToken = tokenStore.getRefreshToken();
|
|
41
|
+
if (!refreshToken)
|
|
42
|
+
return false;
|
|
43
|
+
if (refreshPromise)
|
|
44
|
+
return refreshPromise;
|
|
45
|
+
refreshPromise = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
const data = await ofetch(REFRESH_PATH, {
|
|
48
|
+
baseURL: baseUrl,
|
|
49
|
+
method: 'POST',
|
|
50
|
+
body: { refreshToken },
|
|
51
|
+
timeout: timeoutMs,
|
|
52
|
+
});
|
|
53
|
+
tokenStore.setTokens(data.accessToken, data.refreshToken);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
tokenStore.clear();
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
refreshPromise = null;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
return refreshPromise;
|
|
65
|
+
}
|
|
66
|
+
async function doRequest(request, meta, isRetry = false) {
|
|
67
|
+
const headers = {
|
|
68
|
+
Accept: 'application/json',
|
|
69
|
+
};
|
|
70
|
+
if (request.body) {
|
|
71
|
+
headers['Content-Type'] = 'application/json';
|
|
72
|
+
}
|
|
73
|
+
if (tokenStore) {
|
|
74
|
+
const token = tokenStore.getAccessToken();
|
|
75
|
+
if (token) {
|
|
76
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (contextHeaders) {
|
|
80
|
+
Object.assign(headers, contextHeaders(meta));
|
|
81
|
+
}
|
|
82
|
+
const fetchOptions = {
|
|
83
|
+
baseURL: baseUrl,
|
|
84
|
+
method: request.method,
|
|
85
|
+
headers,
|
|
86
|
+
timeout: timeoutMs,
|
|
87
|
+
};
|
|
88
|
+
if (request.body) {
|
|
89
|
+
fetchOptions.body = request.body;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return await ofetch(ensureLeadingSlash(request.path), fetchOptions);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const fetchError = err;
|
|
96
|
+
if (fetchError.response) {
|
|
97
|
+
const status = fetchError.response.status;
|
|
98
|
+
const body = fetchError.response._data;
|
|
99
|
+
const apiErr = new ApiError({
|
|
100
|
+
kind: 'http',
|
|
101
|
+
message: `HTTP ${status} on ${meta.service}.${meta.method}`,
|
|
102
|
+
httpStatus: status,
|
|
103
|
+
responseBody: body,
|
|
104
|
+
service: meta.service,
|
|
105
|
+
method: meta.method,
|
|
106
|
+
cause: err,
|
|
107
|
+
});
|
|
108
|
+
const normalizedPath = ensureLeadingSlash(request.path);
|
|
109
|
+
if (autoRefreshToken &&
|
|
110
|
+
status === 401 &&
|
|
111
|
+
!isRetry &&
|
|
112
|
+
!normalizedPath.startsWith(AUTH_PATH_PREFIX)) {
|
|
113
|
+
const refreshed = await tryRefreshToken();
|
|
114
|
+
if (refreshed) {
|
|
115
|
+
return doRequest(request, meta, true);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 延迟到宏任务再调 onError,让 promise 链先跑完(含 toast.promise 的 _mark),
|
|
119
|
+
// 避免全局 handler 与 sonner.promise error callback 同时弹出双重 toast。
|
|
120
|
+
if (onError) {
|
|
121
|
+
const _err = apiErr;
|
|
122
|
+
setTimeout(() => onError(_err, meta), 0);
|
|
123
|
+
}
|
|
124
|
+
throw apiErr;
|
|
125
|
+
}
|
|
126
|
+
const message = (fetchError.message ?? '').toLowerCase();
|
|
127
|
+
const kind = message.includes('timeout')
|
|
128
|
+
? 'timeout'
|
|
129
|
+
: 'network';
|
|
130
|
+
const apiErr = new ApiError({
|
|
131
|
+
kind,
|
|
132
|
+
message: `${kind} error on ${meta.service}.${meta.method}: ${fetchError.message}`,
|
|
133
|
+
service: meta.service,
|
|
134
|
+
method: meta.method,
|
|
135
|
+
cause: err,
|
|
136
|
+
});
|
|
137
|
+
if (onError) {
|
|
138
|
+
const _err = apiErr;
|
|
139
|
+
setTimeout(() => onError(_err, meta), 0);
|
|
140
|
+
}
|
|
141
|
+
throw apiErr;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return doRequest;
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=request.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@servora/client",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Shared frontend utilities for Servora web apps (request handler, token management, Kratos error parsing)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./request": {
|
|
8
|
+
"types": "./dist/request.d.ts",
|
|
9
|
+
"import": "./dist/request.js"
|
|
10
|
+
},
|
|
11
|
+
"./errors": {
|
|
12
|
+
"types": "./dist/errors.d.ts",
|
|
13
|
+
"import": "./dist/errors.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*_test.ts"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Servora-Kit/servora-web.git",
|
|
30
|
+
"directory": "packages/client"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Servora-Kit/servora-web/tree/main/packages/client",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/Servora-Kit/servora-web/issues"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"ofetch": "^1.5.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"typescript": "^5.7.2"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kratos 错误解析工具
|
|
3
|
+
*
|
|
4
|
+
* Kratos 统一错误格式:{ code, reason, message, metadata? }
|
|
5
|
+
* 所有使用 protoc-gen-go-errors 生成的服务均输出此格式。
|
|
6
|
+
*/
|
|
7
|
+
import type { ApiError } from './request.js'
|
|
8
|
+
|
|
9
|
+
export interface KratosErrorBody {
|
|
10
|
+
code: number
|
|
11
|
+
reason: string
|
|
12
|
+
message: string
|
|
13
|
+
metadata?: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 从 ApiError 中提取 Kratos 结构化错误体。
|
|
18
|
+
* 非 HTTP 错误、或响应体不符合格式时返回 null。
|
|
19
|
+
*/
|
|
20
|
+
export function parseKratosError(err: ApiError): KratosErrorBody | null {
|
|
21
|
+
if (err.kind !== 'http' || !err.responseBody) return null
|
|
22
|
+
const body = err.responseBody as Record<string, unknown>
|
|
23
|
+
if (typeof body['reason'] !== 'string') return null
|
|
24
|
+
return body as unknown as KratosErrorBody
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 判断 ApiError 是否为指定的 Kratos reason。
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* if (isKratosReason(err, 'EMAIL_NOT_VERIFIED')) { ... }
|
|
32
|
+
*/
|
|
33
|
+
export function isKratosReason(err: ApiError, reason: string): boolean {
|
|
34
|
+
return parseKratosError(err)?.reason === reason
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 提取用户可读的错误消息,含 network / timeout 降级处理。
|
|
39
|
+
*
|
|
40
|
+
* @param fallback 当无法提取有效消息时的兜底文案,默认 '操作失败'
|
|
41
|
+
*/
|
|
42
|
+
export function kratosMessage(err: ApiError, fallback = '操作失败'): string {
|
|
43
|
+
if (err.kind === 'network') return '网络连接失败,请检查网络设置'
|
|
44
|
+
if (err.kind === 'timeout') return '请求超时,请稍后重试'
|
|
45
|
+
return (
|
|
46
|
+
parseKratosError(err)?.message ??
|
|
47
|
+
(err.httpStatus ? `请求失败(HTTP ${err.httpStatus})` : fallback)
|
|
48
|
+
)
|
|
49
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @servora/client — 通用 HTTP 请求处理器
|
|
3
|
+
*
|
|
4
|
+
* 为 proto 生成的 TypeScript client 提供标准 RequestHandler 实现,包含:
|
|
5
|
+
* - Bearer token 自动注入
|
|
6
|
+
* - Token 自动刷新(单例防重复)
|
|
7
|
+
* - 结构化 ApiError(区分 http / network / timeout)
|
|
8
|
+
* - 全局 onError 回调(延迟到宏任务,避免与 toast.promise 竞态)
|
|
9
|
+
*
|
|
10
|
+
* 消费方需自行安装 peer dependency: ofetch
|
|
11
|
+
*/
|
|
12
|
+
import { ofetch } from 'ofetch'
|
|
13
|
+
import type { FetchOptions } from 'ofetch'
|
|
14
|
+
|
|
15
|
+
export type RequestType = {
|
|
16
|
+
path: string
|
|
17
|
+
method: string
|
|
18
|
+
body: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type RequestMeta = {
|
|
22
|
+
service: string
|
|
23
|
+
method: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RequestHandler = (
|
|
27
|
+
request: RequestType,
|
|
28
|
+
meta: RequestMeta,
|
|
29
|
+
) => Promise<unknown>
|
|
30
|
+
|
|
31
|
+
export interface TokenStore {
|
|
32
|
+
getAccessToken: () => string | null
|
|
33
|
+
getRefreshToken: () => string | null
|
|
34
|
+
setTokens: (accessToken: string, refreshToken: string) => void
|
|
35
|
+
clear: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RequestHandlerOptions {
|
|
39
|
+
baseUrl?: string
|
|
40
|
+
tokenStore?: TokenStore
|
|
41
|
+
contextHeaders?: (meta: RequestMeta) => Record<string, string>
|
|
42
|
+
timeoutMs?: number
|
|
43
|
+
onError?: (error: ApiError, meta: RequestMeta) => void
|
|
44
|
+
autoRefreshToken?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ApiErrorKind = 'http' | 'network' | 'timeout'
|
|
48
|
+
|
|
49
|
+
export class ApiError extends Error {
|
|
50
|
+
readonly kind: ApiErrorKind
|
|
51
|
+
readonly httpStatus?: number
|
|
52
|
+
readonly responseBody?: unknown
|
|
53
|
+
readonly service: string
|
|
54
|
+
readonly method: string
|
|
55
|
+
|
|
56
|
+
constructor(opts: {
|
|
57
|
+
kind: ApiErrorKind
|
|
58
|
+
message: string
|
|
59
|
+
httpStatus?: number
|
|
60
|
+
responseBody?: unknown
|
|
61
|
+
service: string
|
|
62
|
+
method: string
|
|
63
|
+
cause?: unknown
|
|
64
|
+
}) {
|
|
65
|
+
super(opts.message, { cause: opts.cause })
|
|
66
|
+
this.name = 'ApiError'
|
|
67
|
+
this.kind = opts.kind
|
|
68
|
+
this.httpStatus = opts.httpStatus
|
|
69
|
+
this.responseBody = opts.responseBody
|
|
70
|
+
this.service = opts.service
|
|
71
|
+
this.method = opts.method
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureLeadingSlash(path: string): string {
|
|
76
|
+
return path.startsWith('/') ? path : `/${path}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const REFRESH_PATH = '/v1/auth/refresh-token'
|
|
80
|
+
const AUTH_PATH_PREFIX = '/v1/auth/'
|
|
81
|
+
|
|
82
|
+
export function createRequestHandler(
|
|
83
|
+
options: RequestHandlerOptions = {},
|
|
84
|
+
): RequestHandler {
|
|
85
|
+
const {
|
|
86
|
+
baseUrl = '',
|
|
87
|
+
tokenStore,
|
|
88
|
+
contextHeaders,
|
|
89
|
+
timeoutMs = 30_000,
|
|
90
|
+
onError,
|
|
91
|
+
autoRefreshToken = false,
|
|
92
|
+
} = options
|
|
93
|
+
|
|
94
|
+
let refreshPromise: Promise<boolean> | null = null
|
|
95
|
+
|
|
96
|
+
async function tryRefreshToken(): Promise<boolean> {
|
|
97
|
+
if (!tokenStore) return false
|
|
98
|
+
const refreshToken = tokenStore.getRefreshToken()
|
|
99
|
+
if (!refreshToken) return false
|
|
100
|
+
|
|
101
|
+
if (refreshPromise) return refreshPromise
|
|
102
|
+
|
|
103
|
+
refreshPromise = (async () => {
|
|
104
|
+
try {
|
|
105
|
+
const data = await ofetch<{
|
|
106
|
+
accessToken: string
|
|
107
|
+
refreshToken: string
|
|
108
|
+
}>(REFRESH_PATH, {
|
|
109
|
+
baseURL: baseUrl,
|
|
110
|
+
method: 'POST',
|
|
111
|
+
body: { refreshToken },
|
|
112
|
+
timeout: timeoutMs,
|
|
113
|
+
})
|
|
114
|
+
tokenStore.setTokens(data.accessToken, data.refreshToken)
|
|
115
|
+
return true
|
|
116
|
+
} catch {
|
|
117
|
+
tokenStore.clear()
|
|
118
|
+
return false
|
|
119
|
+
} finally {
|
|
120
|
+
refreshPromise = null
|
|
121
|
+
}
|
|
122
|
+
})()
|
|
123
|
+
|
|
124
|
+
return refreshPromise
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function doRequest(
|
|
128
|
+
request: RequestType,
|
|
129
|
+
meta: RequestMeta,
|
|
130
|
+
isRetry = false,
|
|
131
|
+
): Promise<unknown> {
|
|
132
|
+
const headers: Record<string, string> = {
|
|
133
|
+
Accept: 'application/json',
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (request.body) {
|
|
137
|
+
headers['Content-Type'] = 'application/json'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (tokenStore) {
|
|
141
|
+
const token = tokenStore.getAccessToken()
|
|
142
|
+
if (token) {
|
|
143
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (contextHeaders) {
|
|
148
|
+
Object.assign(headers, contextHeaders(meta))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fetchOptions: FetchOptions = {
|
|
152
|
+
baseURL: baseUrl,
|
|
153
|
+
method: request.method as FetchOptions['method'],
|
|
154
|
+
headers,
|
|
155
|
+
timeout: timeoutMs,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (request.body) {
|
|
159
|
+
fetchOptions.body = request.body
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return await ofetch(ensureLeadingSlash(request.path), fetchOptions)
|
|
164
|
+
} catch (err: unknown) {
|
|
165
|
+
const fetchError = err as {
|
|
166
|
+
response?: { status: number; _data?: unknown }
|
|
167
|
+
message?: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (fetchError.response) {
|
|
171
|
+
const status = fetchError.response.status
|
|
172
|
+
const body = fetchError.response._data
|
|
173
|
+
|
|
174
|
+
const apiErr = new ApiError({
|
|
175
|
+
kind: 'http',
|
|
176
|
+
message: `HTTP ${status} on ${meta.service}.${meta.method}`,
|
|
177
|
+
httpStatus: status,
|
|
178
|
+
responseBody: body,
|
|
179
|
+
service: meta.service,
|
|
180
|
+
method: meta.method,
|
|
181
|
+
cause: err,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const normalizedPath = ensureLeadingSlash(request.path)
|
|
185
|
+
if (
|
|
186
|
+
autoRefreshToken &&
|
|
187
|
+
status === 401 &&
|
|
188
|
+
!isRetry &&
|
|
189
|
+
!normalizedPath.startsWith(AUTH_PATH_PREFIX)
|
|
190
|
+
) {
|
|
191
|
+
const refreshed = await tryRefreshToken()
|
|
192
|
+
if (refreshed) {
|
|
193
|
+
return doRequest(request, meta, true)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 延迟到宏任务再调 onError,让 promise 链先跑完(含 toast.promise 的 _mark),
|
|
198
|
+
// 避免全局 handler 与 sonner.promise error callback 同时弹出双重 toast。
|
|
199
|
+
if (onError) {
|
|
200
|
+
const _err = apiErr
|
|
201
|
+
setTimeout(() => onError(_err, meta), 0)
|
|
202
|
+
}
|
|
203
|
+
throw apiErr
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const message = (fetchError.message ?? '').toLowerCase()
|
|
207
|
+
const kind: ApiErrorKind = message.includes('timeout')
|
|
208
|
+
? 'timeout'
|
|
209
|
+
: 'network'
|
|
210
|
+
|
|
211
|
+
const apiErr = new ApiError({
|
|
212
|
+
kind,
|
|
213
|
+
message: `${kind} error on ${meta.service}.${meta.method}: ${fetchError.message}`,
|
|
214
|
+
service: meta.service,
|
|
215
|
+
method: meta.method,
|
|
216
|
+
cause: err,
|
|
217
|
+
})
|
|
218
|
+
if (onError) {
|
|
219
|
+
const _err = apiErr
|
|
220
|
+
setTimeout(() => onError(_err, meta), 0)
|
|
221
|
+
}
|
|
222
|
+
throw apiErr
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return doRequest
|
|
227
|
+
}
|