http-init.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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]) => append(`${key}[${k}]`, v));
  74. return;
  75. }
  76. parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
  77. };
  78. Object.entries(data as Record<string, unknown>).forEach(([k, v]) => append(k, v));
  79. return parts.join('&');
  80. }
  81. async function performFetch(
  82. method: string,
  83. url: string,
  84. data: unknown,
  85. options: HttpRequestOptions,
  86. csrfOverride?: string,
  87. ): Promise<Response> {
  88. const upper = method.toUpperCase();
  89. const headers = new Headers(options.headers);
  90. headers.set('X-Requested-With', 'XMLHttpRequest');
  91. let body: BodyInit | undefined;
  92. if (data instanceof FormData) {
  93. body = data;
  94. headers.delete('Content-Type');
  95. } else if (!SAFE_METHODS.has(upper)) {
  96. const declaredType = (headers.get('Content-Type') || '').toLowerCase();
  97. if (declaredType.startsWith('application/json')) {
  98. if (data !== undefined) {
  99. body = typeof data === 'string' ? data : JSON.stringify(data);
  100. }
  101. } else {
  102. headers.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
  103. body = encodeForm(data);
  104. }
  105. }
  106. if (!SAFE_METHODS.has(upper)) {
  107. const token = csrfOverride ?? (await ensureCsrfToken());
  108. if (token) headers.set('X-CSRF-Token', token);
  109. }
  110. const query = encodeForm(options.params);
  111. const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
  112. const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
  113. return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
  114. }
  115. async function parseBody(res: Response): Promise<unknown> {
  116. if (res.status === 204 || res.status === 205) return '';
  117. const text = await res.text();
  118. if (text === '') return '';
  119. const contentType = (res.headers.get('content-type') || '').toLowerCase();
  120. if (contentType.includes('application/json') || text[0] === '{' || text[0] === '[') {
  121. try {
  122. return JSON.parse(text);
  123. } catch {
  124. return text;
  125. }
  126. }
  127. return text;
  128. }
  129. export async function httpRequest(
  130. method: string,
  131. url: string,
  132. data?: unknown,
  133. options: HttpRequestOptions = {},
  134. ): Promise<HttpResponse> {
  135. let res = await performFetch(method, url, data, options);
  136. if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
  137. csrfToken = null;
  138. const fresh = await fetchCsrfToken();
  139. if (fresh) {
  140. csrfToken = fresh;
  141. res = await performFetch(method, url, data, options, fresh);
  142. }
  143. }
  144. if (res.status === 401) {
  145. if (!sessionExpired) {
  146. sessionExpired = true;
  147. window.location.replace(window.X_UI_BASE_PATH || basePathPrefix || '/');
  148. }
  149. return new Promise<HttpResponse>(() => {});
  150. }
  151. const parsed = await parseBody(res);
  152. if (!res.ok) throw new HttpError(res.status, res.statusText, parsed);
  153. return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
  154. }
  155. export function setupHttp(): void {
  156. let basePath: string | null | undefined = window.X_UI_BASE_PATH;
  157. if (!basePath) {
  158. const metaTag = document.querySelector('meta[name="base-path"]');
  159. basePath = metaTag ? metaTag.getAttribute('content') : null;
  160. }
  161. basePathPrefix =
  162. typeof basePath === 'string' && basePath !== '' && basePath !== '/'
  163. ? basePath.replace(/\/$/, '')
  164. : '';
  165. csrfToken = readMetaToken();
  166. }