206 lines
5.5 KiB
TypeScript
206 lines
5.5 KiB
TypeScript
import { useAuthStore } from "./auth-store"
|
|
import type { ApiError } from "./types"
|
|
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
|
|
|
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 = 10_000
|
|
const DEFAULT_RETRIES = 3
|
|
const BASE_DELAY = 1000
|
|
|
|
async function tryRefreshSession(): Promise<boolean> {
|
|
try {
|
|
const res = await fetch("/api/auth/session", { credentials: "include" })
|
|
if (!res.ok) return false
|
|
const data = (await res.json()) as {
|
|
authenticated?: boolean
|
|
accessToken?: string
|
|
refreshToken?: string | null
|
|
expiresAt?: number
|
|
user?: unknown
|
|
}
|
|
if (data.authenticated && data.accessToken && data.expiresAt) {
|
|
useAuthStore.getState().login(
|
|
data.accessToken,
|
|
data.refreshToken ?? "",
|
|
data.expiresAt,
|
|
(data.user as PlatformUser | null | undefined) ?? null
|
|
)
|
|
return true
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return false
|
|
}
|
|
|
|
class ApiClient {
|
|
constructor(private baseUrl: string) {}
|
|
|
|
private resolveUrl(path: string): URL {
|
|
const base = this.baseUrl.startsWith("http")
|
|
? this.baseUrl
|
|
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`
|
|
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
|
|
const normalizedBase = base.endsWith("/") ? base : `${base}/`
|
|
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
|
|
return new URL(normalizedPath, normalizedBase)
|
|
}
|
|
|
|
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 = this.resolveUrl(path)
|
|
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
|
|
let authRetried = false
|
|
|
|
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 === 401 && !authRetried) {
|
|
authRetried = true
|
|
if (await tryRefreshSession()) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (response.status >= 400 && response.status < 500) {
|
|
throw err
|
|
}
|
|
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T
|
|
}
|
|
|
|
const text = await response.text()
|
|
if (!text.trim()) {
|
|
return undefined as T
|
|
}
|
|
|
|
return JSON.parse(text) as T
|
|
} 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 patch<T>(path: string, body?: unknown): Promise<T> {
|
|
return this.request<T>("PATCH", 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")
|