File size: 2,696 Bytes
45a105b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import type { ApiError } from '../types'
import { getToken } from '../auth/session'

// Base URL from a Vite env var (never a secret). Default '' = same origin as the app.
const BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? ''
const TIMEOUT_MS = Number(import.meta.env.VITE_API_TIMEOUT ?? 15000)

export class ApiRequestError extends Error {
  status: number
  requestId?: string
  constructor(err: ApiError) {
    super(err.message)
    this.name = 'ApiRequestError'
    this.status = err.status
    this.requestId = err.requestId
  }
}

let lastRequestId: string | null = null
/** The X-Request-ID of the most recent response — shown only in a support/debug panel. */
export function getLastRequestId(): string | null {
  return lastRequestId
}

export interface RequestOptions {
  method?: string
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  body?: any
  headers?: Record<string, string>
  signal?: AbortSignal
}

export async function apiRequest<T>(path: string, opts: RequestOptions = {}): Promise<T> {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
  const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers }
  if (opts.body !== undefined) headers['Content-Type'] = 'application/json'
  const token = getToken()
  if (token) headers['Authorization'] = `Bearer ${token}`

  let res: Response
  try {
    res = await fetch(BASE + path, {
      method: opts.method ?? (opts.body !== undefined ? 'POST' : 'GET'),
      headers,
      body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
      credentials: 'include', // carry HTTP-only cookies if the backend uses them
      signal: opts.signal ?? controller.signal,
    })
  } catch (e) {
    clearTimeout(timer)
    const aborted = e instanceof DOMException && e.name === 'AbortError'
    throw new ApiRequestError({ status: 0, message: aborted ? 'request timed out' : 'network error' })
  }
  clearTimeout(timer)

  const requestId = res.headers.get('x-request-id') ?? undefined
  if (requestId) lastRequestId = requestId

  const text = await res.text()
  let data: unknown = null
  if (text) {
    try {
      data = JSON.parse(text)
    } catch {
      if (!res.ok) throw new ApiRequestError({ status: res.status, message: 'malformed response', requestId })
      data = null
    }
  }

  if (!res.ok) {
    const detail =
      data && typeof data === 'object' && 'detail' in data
        ? String((data as { detail: unknown }).detail)
        : `request failed (${res.status})`
    throw new ApiRequestError({ status: res.status, message: detail, requestId })
  }
  return data as T
}