Barazo default frontend
barazo.forum
1/**
2 * Auth-aware fetch wrapper with 401 interception and silent token refresh.
3 * Wraps apiFetch to automatically retry on 401 after refreshing the session.
4 * @see specs/prd-web.md Section M3 (Auth Flow)
5 */
6
7import { refreshSession } from './client'
8import type { AuthSession } from './types'
9
10/** Client: relative URLs (empty string). Server: internal Docker network URL. */
11const API_URL =
12 typeof window === 'undefined' ? (process.env.API_INTERNAL_URL ?? 'http://localhost:3000') : ''
13
14interface AuthFetchOptions {
15 method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
16 headers?: Record<string, string>
17 body?: unknown
18 signal?: AbortSignal
19}
20
21interface AuthFetchDeps {
22 getToken: () => string | null
23 setToken: (session: AuthSession) => void
24 onAuthFailure: () => void
25}
26
27class ApiError extends Error {
28 constructor(
29 public readonly status: number,
30 message: string
31 ) {
32 super(message)
33 this.name = 'ApiError'
34 }
35}
36
37async function rawFetch(
38 path: string,
39 accessToken: string | null,
40 options: AuthFetchOptions = {}
41): Promise<Response> {
42 const url = `${API_URL}${path}`
43 const headers: Record<string, string> = {
44 'Content-Type': 'application/json',
45 ...options.headers,
46 }
47 if (accessToken) {
48 headers['Authorization'] = `Bearer ${accessToken}`
49 }
50
51 return fetch(url, {
52 method: options.method ?? 'GET',
53 headers,
54 signal: options.signal,
55 ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
56 })
57}
58
59/**
60 * Creates an auth-aware fetch function that automatically handles 401 responses
61 * by refreshing the session token and retrying the request once.
62 */
63export function createAuthFetch(deps: AuthFetchDeps) {
64 let refreshPromise: Promise<AuthSession> | null = null
65
66 return async function authFetch<T>(path: string, options: AuthFetchOptions = {}): Promise<T> {
67 const token = deps.getToken()
68 const response = await rawFetch(path, token, options)
69
70 if (response.ok) {
71 if (response.status === 204) {
72 return undefined as T
73 }
74 return response.json() as Promise<T>
75 }
76
77 if (response.status !== 401 || !token) {
78 const body = await response.text().catch(() => 'Unknown error')
79 throw new ApiError(response.status, `API ${response.status}: ${body}`)
80 }
81
82 // 401 -- attempt refresh (deduplicate concurrent refreshes)
83 try {
84 if (!refreshPromise) {
85 refreshPromise = refreshSession()
86 }
87 const session = await refreshPromise
88 deps.setToken(session)
89 } catch {
90 deps.onAuthFailure()
91 throw new ApiError(401, 'Session expired')
92 } finally {
93 refreshPromise = null
94 }
95
96 // Retry with new token
97 const retryToken = deps.getToken()
98 const retryResponse = await rawFetch(path, retryToken, options)
99
100 if (retryResponse.ok) {
101 if (retryResponse.status === 204) {
102 return undefined as T
103 }
104 return retryResponse.json() as Promise<T>
105 }
106
107 const body = await retryResponse.text().catch(() => 'Unknown error')
108 throw new ApiError(retryResponse.status, `API ${retryResponse.status}: ${body}`)
109 }
110}
111
112export { ApiError }