Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Move mail, compose, contacts, and accounts off mocks onto REST + WS. Add client, auth store, IDB-backed query cache, offline queue, and sync bar; hybrid Zustand for UI-only state. Settings still local until backend has preferences API.
152 lines
4.0 KiB
TypeScript
152 lines
4.0 KiB
TypeScript
import { useAuthStore } from "./auth-store"
|
|
import type { ApiError } from "./types"
|
|
|
|
export class OfflineError extends Error {
|
|
constructor() {
|
|
super("Device is offline")
|
|
this.name = "OfflineError"
|
|
}
|
|
}
|
|
|
|
export class ApiRequestError extends Error {
|
|
code: string
|
|
details?: unknown
|
|
status: number
|
|
|
|
constructor(status: number, code: string, message: string, details?: unknown) {
|
|
super(message)
|
|
this.name = "ApiRequestError"
|
|
this.status = status
|
|
this.code = code
|
|
this.details = details
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TIMEOUT = 30_000
|
|
const DEFAULT_RETRIES = 3
|
|
const BASE_DELAY = 1000
|
|
|
|
class ApiClient {
|
|
constructor(private baseUrl: string) {}
|
|
|
|
private getHeaders(): HeadersInit {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
const token = useAuthStore.getState().accessToken
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`
|
|
}
|
|
return headers
|
|
}
|
|
|
|
private async request<T>(
|
|
method: string,
|
|
path: string,
|
|
opts?: {
|
|
body?: unknown
|
|
params?: Record<string, string | undefined>
|
|
timeout?: number
|
|
retries?: number
|
|
}
|
|
): Promise<T> {
|
|
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
throw new OfflineError()
|
|
}
|
|
|
|
const url = new URL(path, this.baseUrl.startsWith("http") ? this.baseUrl : `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`)
|
|
if (opts?.params) {
|
|
for (const [key, value] of Object.entries(opts.params)) {
|
|
if (value !== undefined) {
|
|
url.searchParams.set(key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT
|
|
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
|
|
|
|
let lastError: Error | null = null
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
if (attempt > 0) {
|
|
const delay = BASE_DELAY * Math.pow(2, attempt - 1)
|
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeout)
|
|
|
|
try {
|
|
const response = await fetch(url.toString(), {
|
|
method,
|
|
headers: this.getHeaders(),
|
|
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
|
signal: controller.signal,
|
|
})
|
|
|
|
clearTimeout(timer)
|
|
|
|
if (!response.ok) {
|
|
let errorBody: ApiError | undefined
|
|
try {
|
|
errorBody = await response.json()
|
|
} catch {}
|
|
|
|
const err = new ApiRequestError(
|
|
response.status,
|
|
errorBody?.code ?? "UNKNOWN",
|
|
errorBody?.message ?? response.statusText,
|
|
errorBody?.details
|
|
)
|
|
|
|
if (response.status >= 400 && response.status < 500) {
|
|
throw err
|
|
}
|
|
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T
|
|
}
|
|
|
|
return await response.json()
|
|
} catch (err) {
|
|
clearTimeout(timer)
|
|
|
|
if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) {
|
|
throw err
|
|
}
|
|
|
|
lastError = err instanceof Error ? err : new Error(String(err))
|
|
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
lastError = new Error("Request timed out")
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error("Request failed")
|
|
}
|
|
|
|
async get<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
|
|
return this.request<T>("GET", path, { params })
|
|
}
|
|
|
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
|
return this.request<T>("POST", path, { body })
|
|
}
|
|
|
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
|
return this.request<T>("PUT", path, { body })
|
|
}
|
|
|
|
async delete(path: string): Promise<void> {
|
|
await this.request<void>("DELETE", path)
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL ?? "/api/v1")
|