http-init.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
  2. const CSRF_TOKEN_PATH = '/csrf-token';
  3. let csrfToken: string | null = null;
  4. let csrfFetchPromise: Promise<string | null> | null = null;
  5. let sessionExpired = false;
  6. let basePathPrefix = '';
  7. export interface HttpResponse {
  8. ok: boolean;
  9. status: number;
  10. statusText: string;
  11. data: unknown;
  12. }
  13. export class HttpError extends Error {
  14. status: number;
  15. response: { status: number; statusText: string; data: unknown };
  16. constructor(status: number, statusText: string, data: unknown) {
  17. super(`Request failed with status ${status}`);
  18. this.name = 'HttpError';
  19. this.status = status;
  20. this.response = { status, statusText, data };
  21. }
  22. }
  23. export interface HttpRequestOptions {
  24. headers?: Record<string, string> | Headers;
  25. params?: unknown;
  26. timeout?: number;
  27. signal?: AbortSignal;
  28. }
  29. function readMetaToken(): string | null {
  30. return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
  31. }
  32. async function fetchCsrfToken(): Promise<string | null> {
  33. try {
  34. const res = await fetch(basePathPrefix + CSRF_TOKEN_PATH, {
  35. method: 'GET',
  36. credentials: 'same-origin',
  37. headers: { 'X-Requested-With': 'XMLHttpRequest' },
  38. });
  39. if (!res.ok) return null;
  40. const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
  41. return json?.success && typeof json.obj === 'string' ? json.obj : null;
  42. } catch {
  43. return null;
  44. }
  45. }
  46. async function ensureCsrfToken(): Promise<string | null> {
  47. if (csrfToken) return csrfToken;
  48. const meta = readMetaToken();
  49. if (meta) {
  50. csrfToken = meta;
  51. return csrfToken;
  52. }
  53. if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
  54. const fetched = await csrfFetchPromise;
  55. csrfFetchPromise = null;
  56. if (fetched) csrfToken = fetched;
  57. return csrfToken;
  58. }
  59. function encodeForm(data: unknown): string {
  60. if (data == null || typeof data !== 'object') return '';
  61. const parts: string[] = [];
  62. const append = (key: string, value: unknown): void => {
  63. if (value === undefined) return;
  64. if (value === null) {
  65. parts.push(`${encodeURIComponent(key)}=`);
  66. return;
  67. }
  68. if (Array.isArray(value)) {
  69. value.forEach((item) => append(key, item));
  70. return;
  71. }
  72. if (typeof value === 'object') {
  73. Object.entries(value as Record<string, unknown>).forEach(([k, v]) =>
  74. append(`${key}[${k}]`, v),
  75. );
  76. return;
  77. }
  78. parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
  79. };
  80. Object.entries(data as Record<string, unknown>).forEach(([k, v]) => append(k, v));
  81. return parts.join('&');
  82. }
  83. function appendQuery(url: string, query: string): string {
  84. if (query === '') return url;
  85. const hashIndex = url.indexOf('#');
  86. const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
  87. const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
  88. const hasQuery = path.includes('?');
  89. const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
  90. return `${path}${separator}${query}${hash}`;
  91. }
  92. function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
  93. if (!options.timeout) return options.signal;
  94. const timeout = AbortSignal.timeout(options.timeout);
  95. if (!options.signal) return timeout;
  96. if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
  97. const controller = new AbortController();
  98. const abort = () => controller.abort();
  99. options.signal.addEventListener('abort', abort, { once: true });
  100. timeout.addEventListener('abort', abort, { once: true });
  101. return controller.signal;
  102. }
  103. async function performFetch(
  104. method: string,
  105. url: string,
  106. data: unknown,
  107. options: HttpRequestOptions,
  108. csrfOverride?: string,
  109. ): Promise<Response> {
  110. const upper = method.toUpperCase();
  111. const headers = new Headers(options.headers);
  112. headers.set('X-Requested-With', 'XMLHttpRequest');
  113. let body: BodyInit | undefined;
  114. if (data instanceof FormData) {
  115. body = data;
  116. headers.delete('Content-Type');
  117. } else if (!SAFE_METHODS.has(upper)) {
  118. const declaredType = (headers.get('Content-Type') || '').toLowerCase();
  119. if (declaredType.startsWith('application/json')) {
  120. if (data !== undefined) {
  121. body = typeof data === 'string' ? data : JSON.stringify(data);
  122. }
  123. } else {
  124. headers.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
  125. body = encodeForm(data);
  126. }
  127. }
  128. if (!SAFE_METHODS.has(upper)) {
  129. const token = csrfOverride ?? (await ensureCsrfToken());
  130. if (token) headers.set('X-CSRF-Token', token);
  131. }
  132. const query = encodeForm(options.params);
  133. const fullUrl = basePathPrefix + appendQuery(url, query);
  134. const signal = requestSignal(options);
  135. return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
  136. }
  137. async function parseBody(res: Response): Promise<unknown> {
  138. if (res.status === 204 || res.status === 205) return '';
  139. const text = await res.text();
  140. if (text === '') return '';
  141. const contentType = (res.headers.get('content-type') || '').toLowerCase();
  142. if (contentType.includes('application/json') || text[0] === '{' || text[0] === '[') {
  143. try {
  144. return JSON.parse(text);
  145. } catch {
  146. return text;
  147. }
  148. }
  149. return text;
  150. }
  151. export async function httpRequest(
  152. method: string,
  153. url: string,
  154. data?: unknown,
  155. options: HttpRequestOptions = {},
  156. ): Promise<HttpResponse> {
  157. let res = await performFetch(method, url, data, options);
  158. if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
  159. csrfToken = null;
  160. const fresh = await fetchCsrfToken();
  161. if (fresh) {
  162. csrfToken = fresh;
  163. res = await performFetch(method, url, data, options, fresh);
  164. }
  165. }
  166. if (res.status === 401) {
  167. if (!sessionExpired) {
  168. sessionExpired = true;
  169. window.location.replace(window.X_UI_BASE_PATH || basePathPrefix || '/');
  170. }
  171. return new Promise<HttpResponse>(() => {});
  172. }
  173. const parsed = await parseBody(res);
  174. if (!res.ok) throw new HttpError(res.status, res.statusText, parsed);
  175. return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
  176. }
  177. export function setupHttp(): void {
  178. let basePath: string | null | undefined = window.X_UI_BASE_PATH;
  179. if (!basePath) {
  180. const metaTag = document.querySelector('meta[name="base-path"]');
  181. basePath = metaTag ? metaTag.getAttribute('content') : null;
  182. }
  183. basePathPrefix =
  184. typeof basePath === 'string' && basePath !== '' && basePath !== '/'
  185. ? basePath.replace(/\/$/, '')
  186. : '';
  187. csrfToken = readMetaToken();
  188. }