Files
nuzlocke-tracker/frontend/src/api/client.ts
Julian Tabel 22dd569b75 fix: proactively refresh Supabase JWT before API calls
Adds token expiry checking and automatic refresh to prevent intermittent
401 errors when the cached session token expires between interactions.

- Check token expiry (60s buffer) before each API call
- Add 401 interceptor that retries once with refreshed token
- Explicitly enable autoRefreshToken in Supabase client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:01:38 +01:00

102 lines
2.5 KiB
TypeScript

import { supabase } from '../lib/supabase'
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
// Refresh token if it expires within this many seconds
const TOKEN_EXPIRY_BUFFER_SECONDS = 60
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
function isTokenExpiringSoon(expiresAt: number): boolean {
const nowSeconds = Math.floor(Date.now() / 1000)
return expiresAt - nowSeconds < TOKEN_EXPIRY_BUFFER_SECONDS
}
async function getValidAccessToken(): Promise<string | null> {
const { data } = await supabase.auth.getSession()
const session = data.session
if (!session) {
return null
}
// If token is expired or expiring soon, refresh it
if (isTokenExpiringSoon(session.expires_at ?? 0)) {
const { data: refreshed, error } = await supabase.auth.refreshSession()
if (error || !refreshed.session) {
return null
}
return refreshed.session.access_token
}
return session.access_token
}
async function getAuthHeaders(): Promise<Record<string, string>> {
const token = await getValidAccessToken()
if (token) {
return { Authorization: `Bearer ${token}` }
}
return {}
}
async function request<T>(path: string, options?: RequestInit, isRetry = false): Promise<T> {
const authHeaders = await getAuthHeaders()
const res = await fetch(`${API_BASE}/api/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders,
...options?.headers,
},
})
// On 401, try refreshing the token and retry once
if (res.status === 401 && !isRetry) {
const { data: refreshed, error } = await supabase.auth.refreshSession()
if (!error && refreshed.session) {
return request<T>(path, options, true)
}
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new ApiError(res.status, body.detail ?? res.statusText)
}
if (res.status === 204) return undefined as T
return res.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'POST',
body: JSON.stringify(body),
}),
patch: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'PATCH',
body: JSON.stringify(body),
}),
put: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
del: <T = void>(path: string) => request<T>(path, { method: 'DELETE' }),
}