@stdex/request 0.0.0-stage → 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 安坚实
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 CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # 简洁、灵活的日志模块
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ - 支持浏览器、Node.js 环境
4
+ - 可扩展性强(可继承子类或添加自定义 handler)
5
+ - 代码简洁,结构清晰
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 实现一个功能完善、异常处理逻辑全面的请求发起器。
3
+ * 可通过继承子类来扩展其功能。
4
+ */
5
+ import { type Logger } from '@stdex/logging';
6
+ import { type Result, type Success, type Failure } from '@stdex/utils/result.js';
7
+ import { type RequestFailure } from './error.js';
8
+ import type { Options, PredefinedOptions, FormattedOptions } from './options.js';
9
+ /** 此基类不可直接使用,因其未对错误格式进行具体约定 */
10
+ export declare abstract class BaseRequestClient<FailureT extends Failure> {
11
+ logger: Logger;
12
+ prefefinedOptions: PredefinedOptions;
13
+ constructor(options?: PredefinedOptions & {
14
+ logger?: string | Logger;
15
+ });
16
+ /** 生成一个快捷方式函数,调用它相当于调用 client.request() */
17
+ asFunction(): <T>(inputUrl: string, inputOptions?: Options<T>) => Promise<Result<T, FailureT>>;
18
+ request<T>(inputUrl: string, inputOptions?: Options<T>): Promise<Result<T, FailureT>>;
19
+ protected realRequest<T>(options: FormattedOptions): Promise<Result<T, FailureT>>;
20
+ formatOptions<T>(input: Options<T>): Promise<FormattedOptions<T>>;
21
+ /** 请求发起前调用此方法补充 Headers 内容 */
22
+ protected getHeaders(options: FormattedOptions, inputOptions: Options): Record<string, string> | undefined | Promise<Record<string, string> | undefined>;
23
+ /**
24
+ * 解析响应内容
25
+ * - 若 options.binary 为 true,返回二进制结果
26
+ * - 若 response Content-Type 为 'text/' 开头,返回文本结果
27
+ * - 尝试解析 JSON,若成功,返回 JSON 结果;
28
+ * - 若 JSON 解析失败:
29
+ * - 若 response Content-Type 为 'application/json' 开头,返回解析失败信息
30
+ * - 其他情况,返回纯文本结果
31
+ */
32
+ protected parseResponse(options: FormattedOptions, response: Response): Promise<Result<unknown, FailureT>>;
33
+ protected handleError(result: RequestFailure): FailureT;
34
+ /** 生成符合 FailureT 约定的失败结果 */
35
+ protected abstract makeFailureResult(result: RequestFailure): FailureT;
36
+ /**
37
+ * 对请求成功得到的数据进行格式化。
38
+ *
39
+ * - 此步骤在 applyInputFormat() 之前运行,子类可在此进行一些通用的格式化工作,
40
+ * - 输出 Success 或 Failure 结果均可,但输出的 Failure 结果不会交给 makeFailureResult() 进行格式化,需自行保证符合 FailureT 约定。
41
+ * - 若此方法抛出异常,会生成 ResponseDataFormattingFailed,此错误会照常交给 makeFailureResult() 处理。
42
+ */
43
+ protected formatSuccessResult(result: Success<unknown>, options: FormattedOptions): Result<unknown, FailureT>;
44
+ }
45
+ /** 默认的 RequestClient 实现。出现错误时,会把错误对象原样放入错误信息中。 */
46
+ export declare class RequestClient extends BaseRequestClient<RequestFailure> {
47
+ protected makeFailureResult(result: RequestFailure): RequestFailure;
48
+ }
package/dist/client.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 实现一个功能完善、异常处理逻辑全面的请求发起器。
3
+ * 可通过继承子类来扩展其功能。
4
+ */
5
+ import { getLogger } from '@stdex/logging';
6
+ import { success, failure, formatFailure, exceptionToFailure, } from '@stdex/utils/result.js';
7
+ import { combineUrl } from '@stdex/utils/url.js';
8
+ import isPlainObject from 'lodash/isPlainObject.js';
9
+ import pick from 'lodash/pick.js';
10
+ /** 此基类不可直接使用,因其未对错误格式进行具体约定 */
11
+ export class BaseRequestClient {
12
+ logger;
13
+ prefefinedOptions;
14
+ constructor(options = {}) {
15
+ this.logger = !options.logger
16
+ ? getLogger('request')
17
+ : typeof options.logger === 'string'
18
+ ? getLogger(options.logger)
19
+ : options.logger;
20
+ this.prefefinedOptions = options;
21
+ }
22
+ /** 生成一个快捷方式函数,调用它相当于调用 client.request() */
23
+ asFunction() {
24
+ return async (inputUrl, inputOptions) => this.request(inputUrl, inputOptions);
25
+ }
26
+ // -------------------------------
27
+ // 发起请求
28
+ // -------------------------------
29
+ async request(inputUrl, inputOptions) {
30
+ const options = await this.formatOptions({
31
+ url: inputUrl,
32
+ ...(inputOptions ?? {}),
33
+ });
34
+ let result;
35
+ let tryCount = 0;
36
+ do {
37
+ tryCount += 1;
38
+ if (tryCount > 1) {
39
+ this.logger.warn('Retrying Request', { ...options, tryCount });
40
+ }
41
+ result = await this.realRequest(options);
42
+ } while (!result.success && tryCount < options.retry + 1);
43
+ return result;
44
+ }
45
+ async realRequest(options) {
46
+ const { url, method, headers, body, timeout, signal: manualSignal, format } = options;
47
+ const timeoutSignal = timeout ? AbortSignal.timeout(timeout) : undefined;
48
+ const signal = manualSignal || timeoutSignal
49
+ ? AbortSignal.any([manualSignal, timeoutSignal].filter(Boolean))
50
+ : undefined;
51
+ // 发起请求
52
+ let response;
53
+ try {
54
+ response = await fetch(url, { method, headers, body, signal });
55
+ }
56
+ catch (error) {
57
+ // 失败情形“手动取消”
58
+ if (manualSignal?.aborted && manualSignal.reason === error) {
59
+ const reason = error instanceof DOMException && error.name === 'AbortError' ? null : error;
60
+ return this.handleError(failure('Request Was Canceled', 'RequestAborted', { options, reason }));
61
+ }
62
+ // 失败情形“请求超时”
63
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
64
+ return this.handleError(failure('Request Timed Out', 'RequestTimeout', { options }));
65
+ }
66
+ // 失败情形“请求发起失败”
67
+ return this.handleError(failure("Couldn't Send Request", 'RequestSendFailed', { options, originalError: error }));
68
+ }
69
+ // 失败情形“失败状态码”
70
+ if (!response.status.toString().startsWith('2')) {
71
+ // 此时服务端仍可能输出一些内容,试着解析出来
72
+ // 但因此情况下后端输出数据往往和成功时不同,因此不调用用户提供的 format() 函数来格式化
73
+ const responseDataRes = await this.parseResponse(options, response);
74
+ const responseData = responseDataRes.success ? responseDataRes.data : undefined;
75
+ return this.handleError(failure('Unexpected Response HTTP Status', 'UnexpectedHttpStatus', {
76
+ options,
77
+ status: response.status,
78
+ response,
79
+ responseData,
80
+ }));
81
+ }
82
+ // 解析响应内容
83
+ let result = await this.parseResponse(options, response);
84
+ // 格式化响应数据
85
+ if (result.success) {
86
+ try {
87
+ result = this.formatSuccessResult(result, options);
88
+ }
89
+ catch (error) {
90
+ return this.handleError(failure('Failed To Format Response Data', 'ResponseDataFormattingFailed', {
91
+ options,
92
+ originalError: error,
93
+ response,
94
+ responseData: result.data,
95
+ }));
96
+ }
97
+ }
98
+ if (result.success && format) {
99
+ try {
100
+ const formattedData = format(result.data);
101
+ result = success(formattedData);
102
+ }
103
+ catch (error) {
104
+ return this.handleError(failure('Failed To Format Response Data Using options.format', 'ResponseDataFormattingFailed', {
105
+ options,
106
+ originalError: error,
107
+ response,
108
+ responseData: result.data,
109
+ }));
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+ // -------------------------------
115
+ // 请求预处理
116
+ // -------------------------------
117
+ async formatOptions(input) {
118
+ const predefined = this.prefefinedOptions;
119
+ const { urlPrefix = predefined.urlPrefix ?? '', url: rawUrl, query = {}, method = predefined.method ?? 'GET', headers: rawHeaders = {}, body: rawBody = null, data, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
120
+ binary = false, timeout = predefined.timeout ?? 0, signal, retry = predefined.retry ?? 0, format, } = input;
121
+ const headers = {
122
+ ...(predefined.headers ?? {}),
123
+ ...rawHeaders,
124
+ };
125
+ let body = rawBody;
126
+ if (data !== undefined) {
127
+ if (method === 'GET') {
128
+ if (isPlainObject(data)) {
129
+ Object.assign(query, data);
130
+ }
131
+ }
132
+ else {
133
+ body = data instanceof FormData ? data : JSON.stringify(data);
134
+ headers['Content-Type'] = 'application/json; charset=utf-8';
135
+ }
136
+ }
137
+ const url = combineUrl(urlPrefix + (rawUrl ?? ''), query);
138
+ const options = {
139
+ method,
140
+ url,
141
+ headers,
142
+ body,
143
+ timeout,
144
+ binary,
145
+ signal,
146
+ format,
147
+ retry,
148
+ };
149
+ Object.assign(options.headers, await this.getHeaders(options, input));
150
+ return options;
151
+ }
152
+ /** 请求发起前调用此方法补充 Headers 内容 */
153
+ getHeaders(
154
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
155
+ options,
156
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
157
+ inputOptions) {
158
+ return undefined;
159
+ }
160
+ // -------------------------------
161
+ // 请求结果、错误处理
162
+ // -------------------------------
163
+ /**
164
+ * 解析响应内容
165
+ * - 若 options.binary 为 true,返回二进制结果
166
+ * - 若 response Content-Type 为 'text/' 开头,返回文本结果
167
+ * - 尝试解析 JSON,若成功,返回 JSON 结果;
168
+ * - 若 JSON 解析失败:
169
+ * - 若 response Content-Type 为 'application/json' 开头,返回解析失败信息
170
+ * - 其他情况,返回纯文本结果
171
+ */
172
+ async parseResponse(options, response) {
173
+ const contentType = (response.headers.get('Content-Type') ?? '').toLowerCase().trim();
174
+ if (options.binary) {
175
+ const blobResult = await exceptionToFailure(response.blob());
176
+ return formatFailure(blobResult, result => this.handleError(failure('Blob Body Parsing Failed', 'ResponseBodyParseFailed', {
177
+ options,
178
+ status: response.status,
179
+ originalError: result.data,
180
+ response,
181
+ })));
182
+ }
183
+ const textResult = await exceptionToFailure(response.text());
184
+ if (contentType.startsWith('text/') || !textResult.success) {
185
+ return formatFailure(textResult, result => this.handleError(failure('Text Body Parsing Failed', 'ResponseBodyParseFailed', {
186
+ options,
187
+ status: response.status,
188
+ originalError: result.data,
189
+ response,
190
+ })));
191
+ }
192
+ let jsonParseError;
193
+ try {
194
+ return success(JSON.parse(textResult.data));
195
+ }
196
+ catch (error) {
197
+ jsonParseError = error;
198
+ }
199
+ if (contentType.startsWith('application/json')) {
200
+ return this.handleError(failure('JSON Body Parsing Failed', 'ResponseBodyParseFailed', {
201
+ options,
202
+ status: response.status,
203
+ originalError: jsonParseError,
204
+ response,
205
+ }));
206
+ }
207
+ return textResult;
208
+ }
209
+ handleError(result) {
210
+ const info = pick(result.data.options, ['url', 'method']);
211
+ for (const key of ['originalError', 'status', 'responseData']) {
212
+ if (key in result.data)
213
+ Object.assign(info, pick(result.data, [key]));
214
+ }
215
+ this.logger.error(result.code, info);
216
+ return this.makeFailureResult(result);
217
+ }
218
+ /**
219
+ * 对请求成功得到的数据进行格式化。
220
+ *
221
+ * - 此步骤在 applyInputFormat() 之前运行,子类可在此进行一些通用的格式化工作,
222
+ * - 输出 Success 或 Failure 结果均可,但输出的 Failure 结果不会交给 makeFailureResult() 进行格式化,需自行保证符合 FailureT 约定。
223
+ * - 若此方法抛出异常,会生成 ResponseDataFormattingFailed,此错误会照常交给 makeFailureResult() 处理。
224
+ */
225
+ formatSuccessResult(result,
226
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
227
+ options) {
228
+ return result;
229
+ }
230
+ }
231
+ /** 默认的 RequestClient 实现。出现错误时,会把错误对象原样放入错误信息中。 */
232
+ export class RequestClient extends BaseRequestClient {
233
+ makeFailureResult(result) {
234
+ return result;
235
+ }
236
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 定义请求失败类型。
3
+ *
4
+ * 有两类失败情形不在处理范围内:
5
+ * 1. client 或子类自身代码报错,例如 formatOptions() 方法意外报错
6
+ * 2. 请求本身成功,但相应内容表达了业务上的失败,例如登录接口返回密码错误。
7
+ * 如有需要,可继承子类来处理这些失败情形。
8
+ */
9
+ import { type Failure } from '@stdex/utils/result.js';
10
+ import { type FormattedOptions } from './options.js';
11
+ export type RequestFailure = RequestSendFailed | RequestAborted | RequestTimeout | UnexpectedHttpStatus | ResponseBodyParseFailed | ResponseDataFormattingFailed;
12
+ export interface RequestFailureInfo {
13
+ options: FormattedOptions;
14
+ }
15
+ /** 请求发起失败(如网络异常) */
16
+ export type RequestSendFailed = Failure<'RequestSendFailed', RequestSendFailedInfo>;
17
+ export interface RequestSendFailedInfo extends RequestFailureInfo {
18
+ /** 原始错误对象 */
19
+ originalError: unknown;
20
+ }
21
+ /** 手动取消 */
22
+ export type RequestAborted = Failure<'RequestAborted', RequestAbortedInfo>;
23
+ export interface RequestAbortedInfo extends RequestFailureInfo {
24
+ /** 取消原因,即调用 abortController.abort() 时传入的值,若未传值则为 null */
25
+ reason: unknown;
26
+ }
27
+ /** 请求超时 */
28
+ export type RequestTimeout = Failure<'RequestTimeout', RequestFailureInfo>;
29
+ /** 失败状态码(服务端响应了代表失败的状态码,如 500) */
30
+ export type UnexpectedHttpStatus = Failure<'UnexpectedHttpStatus', UnexpectedHttpStatusInfo>;
31
+ export interface UnexpectedHttpStatusInfo extends RequestFailureInfo {
32
+ /** HTTP Status */
33
+ status: number;
34
+ /** Response 对象 */
35
+ response: Response;
36
+ /** 尝试着从 response body 中解析出的数据 */
37
+ responseData: unknown;
38
+ }
39
+ /** 响应体解析失败 */
40
+ export type ResponseBodyParseFailed = Failure<'ResponseBodyParseFailed', ResponseBodyParseFailedInfo>;
41
+ export interface ResponseBodyParseFailedInfo extends RequestFailureInfo {
42
+ /** HTTP Status */
43
+ status: number;
44
+ /** 原始错误对象 */
45
+ originalError: unknown;
46
+ /** Response 对象 */
47
+ response: Response;
48
+ }
49
+ /** 响应体数据格式化失败 */
50
+ export type ResponseDataFormattingFailed = Failure<'ResponseDataFormattingFailed', ResponseDataFormattingFailedInfo>;
51
+ export interface ResponseDataFormattingFailedInfo extends RequestFailureInfo {
52
+ /** 原始错误对象 */
53
+ originalError: unknown;
54
+ /** Response 对象 */
55
+ response: Response;
56
+ /** 未经格式化的响应数据 */
57
+ responseData: unknown;
58
+ }
package/dist/error.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export type * from './options.js';
2
+ export type * from './error.js';
3
+ export type * as errors from './error.js';
4
+ export * from './client.js';
5
+ export * from './instance.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './client.js';
2
+ export * from './instance.js';
@@ -0,0 +1,4 @@
1
+ import { RequestClient } from './client.js';
2
+ /** 提供一个即取即用的 RequestClient 实例及其函数版本 */
3
+ export declare const client: RequestClient;
4
+ export declare const request: <T>(inputUrl: string, inputOptions?: import("./options.js").Options<T> | undefined) => Promise<import("@stdex/utils/result.js").Result<T, import("./error.js").RequestFailure>>;
@@ -0,0 +1,4 @@
1
+ import { RequestClient } from './client.js';
2
+ /** 提供一个即取即用的 RequestClient 实例及其函数版本 */
3
+ export const client = new RequestClient();
4
+ export const request = client.asFunction();
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 请求参数
3
+ */
4
+ export interface Options<T = unknown> {
5
+ urlPrefix?: string;
6
+ url?: string;
7
+ query?: Record<string, string | number | undefined>;
8
+ method?: string;
9
+ headers?: Record<string, string>;
10
+ body?: string | FormData | null;
11
+ /**
12
+ * 向后端传递的数据。
13
+ * 1. 对于 GET 请求:若 data 为 plain object 会合并到 query 中,否则会被忽略。
14
+ * 2. 对于 POST 请求:会作为 POST body,代替 body 参数。
15
+ * 其中若为 FormData,会原样交给 fetch();否则进行 JSON 化。
16
+ * 3. 为了支持传入 interface 类型的值,Record 只能定义成 Record<string, any>
17
+ */
18
+ data?: any;
19
+ /** 是否把响应内容作为二进制处理(结果是 blob) */
20
+ binary?: boolean;
21
+ /** 超时时间,不指定或设为 0 代表不限 */
22
+ timeout?: number;
23
+ /** 可通过此信号手动终止请求 */
24
+ signal?: AbortSignal;
25
+ /** 请求结果为失败(无论原因为何)时的重试次数,默认值为 0,表示不重试 */
26
+ retry?: number;
27
+ /** 对请求成功时得到的数据进行最终格式化 */
28
+ format?: (responseData: any) => T;
29
+ }
30
+ /** 可预指定的请求参数 */
31
+ export type PredefinedOptions = Pick<Options, 'urlPrefix' | 'method' | 'headers' | 'timeout' | 'retry'>;
32
+ /** 经过整理的请求参数(client 内部使用) */
33
+ export type FormattedOptions<T = unknown> = Required<Pick<Options, 'url' | 'method' | 'headers' | 'body' | 'binary' | 'timeout' | 'retry'>> & Pick<Options<T>, 'signal' | 'format'>;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@stdex/request",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "可靠、简洁的网络请求模块",
5
+ "homepage": "https://github.com/anjianshi/stdex-request",
6
+ "bugs": {
7
+ "url": "https://github.com/anjianshi/stdex-request/issues",
8
+ "email": "anjianshi@gmail.com"
9
+ },
10
+ "license": "MIT",
11
+ "author": "anjianshi <anjianshi@gmail.com>",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/anjianshi/stdex-request.git"
15
+ },
16
+ "publishConfig": {
17
+ "registry": "https://registry.npmjs.org/",
18
+ "access": "public"
19
+ },
20
+ "type": "module",
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
23
+ "files": [
24
+ "src",
25
+ "dist/"
26
+ ],
27
+ "dependencies": {
28
+ "@stdex/logging": "^1.1.0",
29
+ "@stdex/utils": "^1.0.2",
30
+ "lodash": "^4.18.1"
31
+ },
32
+ "devDependencies": {
33
+ "@stdex/presets-eslint-typescript": "^1.0.2",
34
+ "@stdex/presets-prettier": "^1.0.2",
35
+ "@stdex/presets-typescript": "^1.0.2",
36
+ "@types/lodash": "^4.17.27",
37
+ "eslint": "^10.6.0",
38
+ "typescript": "^6.0.3"
39
+ },
40
+ "prettier": "@stdex/presets-prettier/prettierrc",
41
+ "scripts": {
42
+ "build": "npm run clear && tsc",
43
+ "watch": "npm run clear && tsc --watch",
44
+ "clear": "rm -rf dist",
45
+ "lint": "tsc --noEmit && eslint './src/'"
46
+ }
6
47
  }
package/src/client.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * 实现一个功能完善、异常处理逻辑全面的请求发起器。
3
+ * 可通过继承子类来扩展其功能。
4
+ */
5
+ import { getLogger, type Logger } from '@stdex/logging'
6
+ import {
7
+ success,
8
+ failure,
9
+ formatFailure,
10
+ exceptionToFailure,
11
+ type Result,
12
+ type Success,
13
+ type Failure,
14
+ } from '@stdex/utils/result.js'
15
+ import { combineUrl } from '@stdex/utils/url.js'
16
+ import isPlainObject from 'lodash/isPlainObject.js'
17
+ import pick from 'lodash/pick.js'
18
+ import { type RequestFailure } from './error.js'
19
+ import type { Options, PredefinedOptions, FormattedOptions } from './options.js'
20
+
21
+ /** 此基类不可直接使用,因其未对错误格式进行具体约定 */
22
+ export abstract class BaseRequestClient<FailureT extends Failure> {
23
+ logger: Logger
24
+ prefefinedOptions: PredefinedOptions
25
+
26
+ constructor(options: PredefinedOptions & { logger?: string | Logger } = {}) {
27
+ this.logger = !options.logger
28
+ ? getLogger('request')
29
+ : typeof options.logger === 'string'
30
+ ? getLogger(options.logger)
31
+ : options.logger
32
+ this.prefefinedOptions = options
33
+ }
34
+
35
+ /** 生成一个快捷方式函数,调用它相当于调用 client.request() */
36
+ asFunction() {
37
+ return async <T>(inputUrl: string, inputOptions?: Options<T>) =>
38
+ this.request<T>(inputUrl, inputOptions)
39
+ }
40
+
41
+ // -------------------------------
42
+ // 发起请求
43
+ // -------------------------------
44
+
45
+ async request<T>(inputUrl: string, inputOptions?: Options<T>): Promise<Result<T, FailureT>> {
46
+ const options = await this.formatOptions<T>({
47
+ url: inputUrl,
48
+ ...(inputOptions ?? {}),
49
+ })
50
+
51
+ let result!: Result<T, FailureT>
52
+ let tryCount = 0
53
+ do {
54
+ tryCount += 1
55
+ if (tryCount > 1) {
56
+ this.logger.warn('Retrying Request', { ...options, tryCount })
57
+ }
58
+ result = await this.realRequest<T>(options)
59
+ } while (!result.success && tryCount < options.retry + 1)
60
+ return result
61
+ }
62
+
63
+ protected async realRequest<T>(options: FormattedOptions): Promise<Result<T, FailureT>> {
64
+ const { url, method, headers, body, timeout, signal: manualSignal, format } = options
65
+
66
+ const timeoutSignal = timeout ? AbortSignal.timeout(timeout) : undefined
67
+ const signal =
68
+ manualSignal || timeoutSignal
69
+ ? AbortSignal.any([manualSignal, timeoutSignal].filter(Boolean) as AbortSignal[])
70
+ : undefined
71
+
72
+ // 发起请求
73
+ let response: Response | undefined
74
+ try {
75
+ response = await fetch(url, { method, headers, body, signal })
76
+ } catch (error) {
77
+ // 失败情形“手动取消”
78
+ if (manualSignal?.aborted && manualSignal.reason === error) {
79
+ const reason = error instanceof DOMException && error.name === 'AbortError' ? null : error
80
+ return this.handleError(
81
+ failure('Request Was Canceled', 'RequestAborted', { options, reason }),
82
+ )
83
+ }
84
+ // 失败情形“请求超时”
85
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
86
+ return this.handleError(failure('Request Timed Out', 'RequestTimeout', { options }))
87
+ }
88
+ // 失败情形“请求发起失败”
89
+ return this.handleError(
90
+ failure("Couldn't Send Request", 'RequestSendFailed', { options, originalError: error }),
91
+ )
92
+ }
93
+
94
+ // 失败情形“失败状态码”
95
+ if (!response.status.toString().startsWith('2')) {
96
+ // 此时服务端仍可能输出一些内容,试着解析出来
97
+ // 但因此情况下后端输出数据往往和成功时不同,因此不调用用户提供的 format() 函数来格式化
98
+ const responseDataRes = await this.parseResponse(options, response)
99
+ const responseData = responseDataRes.success ? responseDataRes.data : undefined
100
+ return this.handleError(
101
+ failure('Unexpected Response HTTP Status', 'UnexpectedHttpStatus', {
102
+ options,
103
+ status: response.status,
104
+ response,
105
+ responseData,
106
+ }),
107
+ )
108
+ }
109
+
110
+ // 解析响应内容
111
+ let result = await this.parseResponse(options, response)
112
+
113
+ // 格式化响应数据
114
+ if (result.success) {
115
+ try {
116
+ result = this.formatSuccessResult(result, options)
117
+ } catch (error) {
118
+ return this.handleError(
119
+ failure('Failed To Format Response Data', 'ResponseDataFormattingFailed', {
120
+ options,
121
+ originalError: error,
122
+ response,
123
+ responseData: result.data,
124
+ }),
125
+ )
126
+ }
127
+ }
128
+ if (result.success && format) {
129
+ try {
130
+ const formattedData = format(result.data)
131
+ result = success(formattedData)
132
+ } catch (error) {
133
+ return this.handleError(
134
+ failure(
135
+ 'Failed To Format Response Data Using options.format',
136
+ 'ResponseDataFormattingFailed',
137
+ {
138
+ options,
139
+ originalError: error,
140
+ response,
141
+ responseData: result.data,
142
+ },
143
+ ),
144
+ )
145
+ }
146
+ }
147
+ return result as Result<T, FailureT>
148
+ }
149
+
150
+ // -------------------------------
151
+ // 请求预处理
152
+ // -------------------------------
153
+
154
+ async formatOptions<T>(input: Options<T>): Promise<FormattedOptions<T>> {
155
+ const predefined = this.prefefinedOptions
156
+ const {
157
+ urlPrefix = predefined.urlPrefix ?? '',
158
+ url: rawUrl,
159
+ query = {},
160
+ method = predefined.method ?? 'GET',
161
+ headers: rawHeaders = {},
162
+ body: rawBody = null,
163
+ data, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
164
+ binary = false,
165
+ timeout = predefined.timeout ?? 0,
166
+ signal,
167
+ retry = predefined.retry ?? 0,
168
+ format,
169
+ } = input
170
+
171
+ const headers = {
172
+ ...(predefined.headers ?? {}),
173
+ ...rawHeaders,
174
+ }
175
+
176
+ let body: string | FormData | null = rawBody
177
+ if (data !== undefined) {
178
+ if (method === 'GET') {
179
+ if (isPlainObject(data)) {
180
+ Object.assign(query, data)
181
+ }
182
+ } else {
183
+ body = data instanceof FormData ? data : JSON.stringify(data)
184
+ headers['Content-Type'] = 'application/json; charset=utf-8'
185
+ }
186
+ }
187
+
188
+ const url = combineUrl(urlPrefix + (rawUrl ?? ''), query)
189
+
190
+ const options: FormattedOptions<T> = {
191
+ method,
192
+ url,
193
+ headers,
194
+ body,
195
+ timeout,
196
+ binary,
197
+ signal,
198
+ format,
199
+ retry,
200
+ }
201
+ Object.assign(options.headers, await this.getHeaders(options, input))
202
+ return options
203
+ }
204
+
205
+ /** 请求发起前调用此方法补充 Headers 内容 */
206
+ protected getHeaders(
207
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
208
+ options: FormattedOptions,
209
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
210
+ inputOptions: Options,
211
+ ): Record<string, string> | undefined | Promise<Record<string, string> | undefined> {
212
+ return undefined
213
+ }
214
+
215
+ // -------------------------------
216
+ // 请求结果、错误处理
217
+ // -------------------------------
218
+
219
+ /**
220
+ * 解析响应内容
221
+ * - 若 options.binary 为 true,返回二进制结果
222
+ * - 若 response Content-Type 为 'text/' 开头,返回文本结果
223
+ * - 尝试解析 JSON,若成功,返回 JSON 结果;
224
+ * - 若 JSON 解析失败:
225
+ * - 若 response Content-Type 为 'application/json' 开头,返回解析失败信息
226
+ * - 其他情况,返回纯文本结果
227
+ */
228
+ protected async parseResponse(
229
+ options: FormattedOptions,
230
+ response: Response,
231
+ ): Promise<Result<unknown, FailureT>> {
232
+ const contentType = (response.headers.get('Content-Type') ?? '').toLowerCase().trim()
233
+
234
+ if (options.binary) {
235
+ const blobResult = await exceptionToFailure(response.blob())
236
+ return formatFailure(blobResult, result =>
237
+ this.handleError(
238
+ failure('Blob Body Parsing Failed', 'ResponseBodyParseFailed', {
239
+ options,
240
+ status: response.status,
241
+ originalError: result.data,
242
+ response,
243
+ }),
244
+ ),
245
+ )
246
+ }
247
+
248
+ const textResult = await exceptionToFailure(response.text())
249
+ if (contentType.startsWith('text/') || !textResult.success) {
250
+ return formatFailure(textResult, result =>
251
+ this.handleError(
252
+ failure('Text Body Parsing Failed', 'ResponseBodyParseFailed', {
253
+ options,
254
+ status: response.status,
255
+ originalError: result.data,
256
+ response,
257
+ }),
258
+ ),
259
+ )
260
+ }
261
+
262
+ let jsonParseError: unknown
263
+ try {
264
+ return success(JSON.parse(textResult.data))
265
+ } catch (error) {
266
+ jsonParseError = error
267
+ }
268
+ if (contentType.startsWith('application/json')) {
269
+ return this.handleError(
270
+ failure('JSON Body Parsing Failed', 'ResponseBodyParseFailed', {
271
+ options,
272
+ status: response.status,
273
+ originalError: jsonParseError,
274
+ response,
275
+ }),
276
+ )
277
+ }
278
+ return textResult
279
+ }
280
+
281
+ protected handleError(result: RequestFailure) {
282
+ const info: Record<string, unknown> = pick(result.data.options, ['url', 'method'])
283
+ for (const key of ['originalError', 'status', 'responseData'] as const) {
284
+ if (key in result.data) Object.assign(info, pick(result.data, [key]))
285
+ }
286
+ this.logger.error(result.code, info)
287
+ return this.makeFailureResult(result)
288
+ }
289
+
290
+ /** 生成符合 FailureT 约定的失败结果 */
291
+ protected abstract makeFailureResult(result: RequestFailure): FailureT
292
+
293
+ /**
294
+ * 对请求成功得到的数据进行格式化。
295
+ *
296
+ * - 此步骤在 applyInputFormat() 之前运行,子类可在此进行一些通用的格式化工作,
297
+ * - 输出 Success 或 Failure 结果均可,但输出的 Failure 结果不会交给 makeFailureResult() 进行格式化,需自行保证符合 FailureT 约定。
298
+ * - 若此方法抛出异常,会生成 ResponseDataFormattingFailed,此错误会照常交给 makeFailureResult() 处理。
299
+ */
300
+ protected formatSuccessResult(
301
+ result: Success<unknown>,
302
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
303
+ options: FormattedOptions,
304
+ ): Result<unknown, FailureT> {
305
+ return result
306
+ }
307
+ }
308
+
309
+ /** 默认的 RequestClient 实现。出现错误时,会把错误对象原样放入错误信息中。 */
310
+ export class RequestClient extends BaseRequestClient<RequestFailure> {
311
+ protected makeFailureResult(result: RequestFailure) {
312
+ return result
313
+ }
314
+ }
package/src/error.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 定义请求失败类型。
3
+ *
4
+ * 有两类失败情形不在处理范围内:
5
+ * 1. client 或子类自身代码报错,例如 formatOptions() 方法意外报错
6
+ * 2. 请求本身成功,但相应内容表达了业务上的失败,例如登录接口返回密码错误。
7
+ * 如有需要,可继承子类来处理这些失败情形。
8
+ */
9
+ import { type Failure } from '@stdex/utils/result.js'
10
+ import { type FormattedOptions } from './options.js'
11
+
12
+ export type RequestFailure =
13
+ | RequestSendFailed
14
+ | RequestAborted
15
+ | RequestTimeout
16
+ | UnexpectedHttpStatus
17
+ | ResponseBodyParseFailed
18
+ | ResponseDataFormattingFailed
19
+
20
+ export interface RequestFailureInfo {
21
+ options: FormattedOptions
22
+ }
23
+
24
+ /** 请求发起失败(如网络异常) */
25
+ export type RequestSendFailed = Failure<'RequestSendFailed', RequestSendFailedInfo>
26
+ export interface RequestSendFailedInfo extends RequestFailureInfo {
27
+ /** 原始错误对象 */
28
+ originalError: unknown
29
+ }
30
+
31
+ /** 手动取消 */
32
+ export type RequestAborted = Failure<'RequestAborted', RequestAbortedInfo>
33
+ export interface RequestAbortedInfo extends RequestFailureInfo {
34
+ /** 取消原因,即调用 abortController.abort() 时传入的值,若未传值则为 null */
35
+ reason: unknown
36
+ }
37
+
38
+ /** 请求超时 */
39
+ export type RequestTimeout = Failure<'RequestTimeout', RequestFailureInfo>
40
+
41
+ /** 失败状态码(服务端响应了代表失败的状态码,如 500) */
42
+ export type UnexpectedHttpStatus = Failure<'UnexpectedHttpStatus', UnexpectedHttpStatusInfo>
43
+ export interface UnexpectedHttpStatusInfo extends RequestFailureInfo {
44
+ /** HTTP Status */
45
+ status: number
46
+ /** Response 对象 */
47
+ response: Response
48
+ /** 尝试着从 response body 中解析出的数据 */
49
+ responseData: unknown
50
+ }
51
+
52
+ /** 响应体解析失败 */
53
+ export type ResponseBodyParseFailed = Failure<
54
+ 'ResponseBodyParseFailed',
55
+ ResponseBodyParseFailedInfo
56
+ >
57
+ export interface ResponseBodyParseFailedInfo extends RequestFailureInfo {
58
+ /** HTTP Status */
59
+ status: number
60
+ /** 原始错误对象 */
61
+ originalError: unknown
62
+ /** Response 对象 */
63
+ response: Response
64
+ }
65
+
66
+ /** 响应体数据格式化失败 */
67
+ export type ResponseDataFormattingFailed = Failure<
68
+ 'ResponseDataFormattingFailed',
69
+ ResponseDataFormattingFailedInfo
70
+ >
71
+ export interface ResponseDataFormattingFailedInfo extends RequestFailureInfo {
72
+ /** 原始错误对象 */
73
+ originalError: unknown
74
+ /** Response 对象 */
75
+ response: Response
76
+ /** 未经格式化的响应数据 */
77
+ responseData: unknown
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type * from './options.js'
2
+
3
+ export type * from './error.js'
4
+ export type * as errors from './error.js'
5
+
6
+ export * from './client.js'
7
+ export * from './instance.js'
@@ -0,0 +1,5 @@
1
+ import { RequestClient } from './client.js'
2
+
3
+ /** 提供一个即取即用的 RequestClient 实例及其函数版本 */
4
+ export const client = new RequestClient()
5
+ export const request = client.asFunction()
package/src/options.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 请求参数
3
+ */
4
+ export interface Options<T = unknown> {
5
+ urlPrefix?: string
6
+ url?: string
7
+ query?: Record<string, string | number | undefined>
8
+ method?: string
9
+ headers?: Record<string, string>
10
+ body?: string | FormData | null
11
+
12
+ /**
13
+ * 向后端传递的数据。
14
+ * 1. 对于 GET 请求:若 data 为 plain object 会合并到 query 中,否则会被忽略。
15
+ * 2. 对于 POST 请求:会作为 POST body,代替 body 参数。
16
+ * 其中若为 FormData,会原样交给 fetch();否则进行 JSON 化。
17
+ * 3. 为了支持传入 interface 类型的值,Record 只能定义成 Record<string, any>
18
+ */
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ data?: any
21
+
22
+ /** 是否把响应内容作为二进制处理(结果是 blob) */
23
+ binary?: boolean
24
+
25
+ /** 超时时间,不指定或设为 0 代表不限 */
26
+ timeout?: number
27
+
28
+ /** 可通过此信号手动终止请求 */
29
+ signal?: AbortSignal
30
+
31
+ /** 请求结果为失败(无论原因为何)时的重试次数,默认值为 0,表示不重试 */
32
+ retry?: number
33
+
34
+ /** 对请求成功时得到的数据进行最终格式化 */
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ format?: (responseData: any) => T
37
+ }
38
+
39
+ /** 可预指定的请求参数 */
40
+ export type PredefinedOptions = Pick<
41
+ Options,
42
+ 'urlPrefix' | 'method' | 'headers' | 'timeout' | 'retry'
43
+ >
44
+
45
+ /** 经过整理的请求参数(client 内部使用) */
46
+ export type FormattedOptions<T = unknown> = Required<
47
+ Pick<Options, 'url' | 'method' | 'headers' | 'body' | 'binary' | 'timeout' | 'retry'>
48
+ > &
49
+ Pick<Options<T>, 'signal' | 'format'>