@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.
@@ -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"}
@@ -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
+ }