| 63 | * and share across the application. |
| 64 | */ |
| 65 | export class ApiClient { |
| 66 | private _baseUrl: string; |
| 67 | private _defaultTimeoutMs: number; |
| 68 | |
| 69 | constructor(baseUrl?: string, defaultTimeoutMs = DEFAULT_TIMEOUT_MS) { |
| 70 | this._baseUrl = baseUrl !== undefined ? baseUrl : resolveApiBase(); |
| 71 | this._defaultTimeoutMs = defaultTimeoutMs; |
| 72 | } |
| 73 | |
| 74 | /** Current base URL (without trailing slash). */ |
| 75 | get baseUrl(): string { |
| 76 | return this._baseUrl; |
| 77 | } |
| 78 | |
| 79 | /** Update the base URL and persist it to localStorage if non-empty. */ |
| 80 | setBaseUrl(value: string): void { |
| 81 | const normalized = (value || '').trim().replace(/\/$/, ''); |
| 82 | this._baseUrl = normalized === window.location.origin ? '' : normalized; |
| 83 | if (this._baseUrl) { |
| 84 | localStorage.setItem('space_api_base', this._baseUrl); |
| 85 | } else { |
| 86 | localStorage.removeItem('space_api_base'); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /** Build a fully-qualified URL for the given API path. */ |
| 91 | url(path: string): string { |
| 92 | return `${this._baseUrl}${path}`; |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * Execute an HTTP request and return the parsed JSON response. |
| 97 | * |
| 98 | * - Injects `Authorization: Bearer <token>` when `smartnode_token` is set |
| 99 | * in localStorage and no `Authorization` header was provided. |
| 100 | * - Unwraps backend envelope `{ code: 0, data: T }` transparently. |
| 101 | * - Throws `ApiError` on non-2xx responses or timeout. |
| 102 | */ |
| 103 | async request<T>(path: string, options: RequestOptions = {}): Promise<T> { |
| 104 | const { timeoutMs = this._defaultTimeoutMs, ...fetchOptions } = options; |
| 105 | |
| 106 | const headers: Record<string, string> = { |
| 107 | 'Content-Type': 'application/json', |
| 108 | ...((fetchOptions.headers as Record<string, string>) || {}), |
| 109 | }; |
| 110 | |
| 111 | const token = localStorage.getItem('smartnode_token'); |
| 112 | if (token && !headers['Authorization']) { |
| 113 | headers['Authorization'] = `Bearer ${token}`; |
| 114 | } |
| 115 | |
| 116 | let abortController: AbortController | undefined; |
| 117 | let timeoutId: ReturnType<typeof setTimeout> | undefined; |
| 118 | |
| 119 | if (timeoutMs > 0) { |
| 120 | abortController = new AbortController(); |
| 121 | timeoutId = setTimeout(() => abortController!.abort(), timeoutMs); |
| 122 | } |
nothing calls this directly
no outgoing calls
no test coverage detected