HttpClient.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { API_BASE_URL } from '../api';
  2. import { getComParams } from './CommonParams';
  3. import {
  4. ApiEndpoint,
  5. ApiEnvelope,
  6. ApiParams,
  7. HttpAbortedError,
  8. HttpError,
  9. HttpFailedError,
  10. HttpMethod,
  11. HttpParseError,
  12. HttpTimeoutError,
  13. } from './RequestTypes';
  14. import LZString from 'lz-string';
  15. export interface ApiCodec {
  16. encode(params: ApiParams): string;
  17. decode(payload: string): string;
  18. }
  19. export interface HttpClientOptions {
  20. baseUrl: string;
  21. timeoutMs?: number;
  22. retryCount?: number;
  23. retryDelayMs?: number;
  24. compress?: boolean;
  25. codec?: ApiCodec;
  26. }
  27. export class HttpClient {
  28. private readonly timeoutMs: number;
  29. private readonly retryCount: number;
  30. private readonly retryDelayMs: number;
  31. constructor(private readonly options: HttpClientOptions) {
  32. this.timeoutMs = options.timeoutMs ?? 10_000;
  33. this.retryCount = options.retryCount ?? 2;
  34. this.retryDelayMs = options.retryDelayMs ?? 500;
  35. }
  36. get<T>(path: string, query?: ApiParams | any): Promise<ApiEnvelope<T>> {
  37. return this.request<T>('GET', path, query);
  38. }
  39. post<T>(path: string, body?: ApiParams | any): Promise<ApiEnvelope<T>> {
  40. return this.request<T>('POST', path, body);
  41. }
  42. call<T>(endpoint: ApiEndpoint, params?: ApiParams | any): Promise<ApiEnvelope<T>> {
  43. return this.request<T>(endpoint.method, endpoint.path, params);
  44. }
  45. async request<T>(method: HttpMethod, path: string, params: ApiParams = {}): Promise<ApiEnvelope<T>> {
  46. for (let attempt = 0; attempt <= this.retryCount; attempt += 1) {
  47. try {
  48. return await this.send<T>(method, path, params);
  49. } catch (error) {
  50. console.error(error);
  51. if (attempt >= this.retryCount) {
  52. return {
  53. code: -1,
  54. data: null,
  55. };
  56. }
  57. await this.delay(this.retryDelayMs);
  58. }
  59. }
  60. return null;
  61. }
  62. private send<T>(method: HttpMethod, path: string, params: ApiParams): Promise<ApiEnvelope<T>> {
  63. const comParams = getComParams();
  64. Object.assign(params, comParams);
  65. const url = this.resolveUrl(path);
  66. const useCompression = Boolean(this.options.compress && this.options.codec);
  67. const payload = useCompression ? { lz: this.options.codec!.encode(params) } : params;
  68. const requestUrl = method === 'GET' ? this.appendQuery(url, payload) : url;
  69. return new Promise((resolve, reject) => {
  70. const xhr = new XMLHttpRequest();
  71. xhr.open(method, requestUrl, true);
  72. xhr.timeout = this.timeoutMs;
  73. xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
  74. if (method === 'POST') {
  75. xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
  76. }
  77. xhr.onload = () => {
  78. if (xhr.status < 200 || xhr.status >= 300) {
  79. reject(new HttpError(`HTTP ${xhr.status}`, xhr.status, requestUrl));
  80. return;
  81. }
  82. try {
  83. let text = xhr.responseText;
  84. if (text && this.options.codec && !text.replace(/^\s+/, '').startsWith('{')) {
  85. text = this.options.codec.decode(text);
  86. }
  87. resolve(JSON.parse(text) as ApiEnvelope<T>);
  88. } catch (error) {
  89. reject(new HttpParseError(
  90. `Invalid API response: ${error instanceof Error ? error.message : String(error)}`,
  91. xhr.status,
  92. requestUrl,
  93. ));
  94. }
  95. };
  96. xhr.onerror = () => reject(new HttpFailedError('Network request failed', xhr.status, requestUrl));
  97. xhr.ontimeout = () => reject(new HttpTimeoutError('Network request timed out', 0, requestUrl));
  98. xhr.onabort = () => reject(new HttpAbortedError('Network request aborted', 0, requestUrl));
  99. xhr.send(method === 'POST' ? JSON.stringify(payload) : null);
  100. });
  101. }
  102. private resolveUrl(path: string): string {
  103. return `${this.options.baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
  104. }
  105. private appendQuery(url: string, params: ApiParams): string {
  106. const query: string[] = [];
  107. for (const key of Object.keys(params)) {
  108. const value = params[key];
  109. if (value === undefined || value === null || value === '') continue;
  110. const encoded = typeof value === 'object' ? JSON.stringify(value) : String(value);
  111. query.push(`${encodeURIComponent(key)}=${encodeURIComponent(encoded)}`);
  112. }
  113. return query.length > 0 ? `${url}?${query.join('&')}` : url;
  114. }
  115. private delay(ms: number): Promise<void> {
  116. return new Promise((resolve) => setTimeout(resolve, ms));
  117. }
  118. }
  119. /**
  120. * 全局 HTTP 请求对象
  121. */
  122. export const Http = new HttpClient({
  123. baseUrl: API_BASE_URL,
  124. timeoutMs: 10_000,
  125. retryCount: 2,
  126. retryDelayMs: 1_000,
  127. compress: false,
  128. codec: {
  129. encode: function (params: ApiParams): string {
  130. return LZString.compressToBase64(JSON.stringify(params));
  131. },
  132. decode: function (payload: string): string {
  133. try {
  134. return LZString.decompressFromBase64(payload);
  135. } catch (error) {
  136. console.error(`api response decompress error: `, error);
  137. return payload;
  138. }
  139. }
  140. }
  141. });