import { decode } from '@cloudpss/ubjson'; import type { HttpClientConfig } from './options.js'; /** 请求发生错误 */ export class RequestError extends Error { constructor(url: string, init: RequestInit, cause?: Error, message?: string) { const opt = cause ? { cause } : undefined; const msg = message || cause?.message; super(msg, opt); this.name = 'RequestError'; this.requestUrl = url; this.requestInit = init; } /** 请求的 URL */ readonly requestUrl: string; /** 请求的信息 */ readonly requestInit: RequestInit; } /** 响应解析发生错误 */ export class RequestResponseError extends RequestError implements RequestResponse { constructor(data: RequestResponse, cause?: Error, message?: string) { const { requestUrl, requestInit, response, data: responseBody } = data; super( requestUrl, requestInit, cause, message ?? cause?.message ?? `Server Error ${response.status}: ${response.statusText}`, ); this.name = 'RequestResponseError'; this.response = response; this.data = responseBody; this.status = response.status; } /** 响应 */ readonly response: Response; /** 服务器响应代码 */ readonly status: number; /** 响应体 */ readonly data: unknown; } /** 完成的请求 */ export interface RequestResponse { /** 请求的 URL */ readonly requestUrl: string; /** 请求的信息 */ readonly requestInit: RequestInit; /** 响应 */ readonly response: Response; /** 服务器响应代码 */ readonly status: number; /** 响应体 */ readonly data: unknown; } const KNOWN_TEXT_MIMES = new Set([ 'application/javascript', 'application/x-ndjson', 'application/x-ldjson', 'application/jsonl', 'application/json-seq', 'application/xml', 'application/rtf', 'application/sgml', 'application/xml-dtd', ]); /** 检查是否为文本 */ function isText(contentType: string): boolean { return contentType.startsWith('text/') || KNOWN_TEXT_MIMES.has(contentType) || contentType.endsWith('+xml'); } /** 检查是否为 json */ function isJson(contentType: string): boolean { return contentType === 'application/json' || contentType.endsWith('+json'); } /** 检查是否为 ubjson */ function isUbjson(contentType: string): boolean { return contentType === 'application/ubjson' || contentType.endsWith('+ubjson'); } /** 解析响应体 */ async function parseBody(response: Response): Promise { const contentLength = response.headers.get('content-length'); if (response.status === 204 && !contentLength) { return undefined; } if (contentLength) { const l = Number(contentLength); if (Number.isNaN(l) || l < 0) return undefined; } const contentType = (response.headers.get('content-type') || 'application/octet-stream') .split(';')[0]! .trim() .toLowerCase(); if (isJson(contentType)) { return (await response.json()) as unknown; } if (isUbjson(contentType)) { return decode(await response.arrayBuffer()); } if (contentType === 'application/x-www-form-urlencoded') { return new URLSearchParams(await response.text()); } if (contentType === 'multipart/form-data') { return await response.formData(); } if (isText(contentType)) { return await response.text(); } return new Uint8Array(await response.arrayBuffer()); } /** 解析服务器通常格式的错误 */ function isCommonError(data: unknown): data is { statusCode: number; error: string; message: string } { if (data == null || typeof data != 'object') return false; const { statusCode, error, message } = data as { statusCode: number; error: string; message: string }; if (typeof statusCode != 'number' || typeof error != 'string' || typeof message != 'string') return false; return true; } /** 请求重试 */ async function fetchRetry(config: HttpClientConfig, url: string, init: RequestInit, count = 3): Promise { const { fetch } = config.fetcher; let timeout = 100; for (let i = 1; ; i++) { try { return await fetch(url, init); } catch (ex) { if (i === count) throw ex; } await new Promise((resolve) => setTimeout(resolve, timeout)); timeout *= 3; if (timeout > 10000) timeout = 10000; } } /** 发送请求 */ export async function request(config: HttpClientConfig, url: string, init: RequestInit): Promise { const { fetcher: { Headers }, } = config; const headers = new Headers(init.headers); const req: RequestInit = { ...init, headers }; // set token if (config.token) { headers.set('Authorization', `Bearer ${config.token}`); } if (config.altTokens.length) { for (const token of config.altTokens) headers.append('X-Authorization', `Bearer ${token}`); } let res; try { res = await fetchRetry(config, url, req, 3); } catch (ex) { throw new RequestError(url, req, ex as Error); } const data = { requestUrl: url, requestInit: req, status: res.status, response: res, data: undefined as unknown, } satisfies RequestResponse; try { data.data = await parseBody(res); } catch (ex) { throw new RequestResponseError(data, ex as Error); } if (!res.ok && isCommonError(data.data)) { const error = new RequestResponseError(data, undefined, data.data.message); error.name = `${data.data.error}(${data.data.statusCode})`; throw error; } if (res.status >= 500) { throw new RequestResponseError(data); } return Object.freeze(data); }