-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapiClient.ts
More file actions
254 lines (219 loc) · 7.34 KB
/
apiClient.ts
File metadata and controls
254 lines (219 loc) · 7.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import { NextRequest } from "next/server";
import { AUTH_EXPIRED_EVENT } from "@/app/lib/constants";
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000";
export type UploadPhase = "uploading" | "processing" | "done";
/** Coalesces concurrent refresh calls into a single request. */
let refreshPromise: Promise<boolean> | null = null;
/**
* Forwards a request to the backend, relaying auth headers (X-API-KEY, Cookie).
* Returns raw { status, data, headers } so the route handler can relay the response.
*/
export async function apiClient(
request: NextRequest | Request,
endpoint: string,
options: RequestInit = {},
) {
const apiKey = request.headers.get("X-API-KEY") || "";
const cookie = request.headers.get("Cookie") || "";
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
headers.set("X-API-KEY", apiKey);
if (cookie) headers.set("Cookie", cookie);
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
...options,
headers,
credentials: "include",
});
const text = response.status === 204 ? "" : await response.text();
const data = text ? JSON.parse(text) : null;
return { status: response.status, data, headers: response.headers };
}
/** Parse an error body into a readable message string. */
function extractErrorMessage(
body: Record<string, unknown>,
fallback: string,
): string {
const msg =
(body.error as string) ||
(body.message as string) ||
(body.detail as string) ||
"";
return msg || fallback;
}
/** Dispatch the auth-expired event (client-side only). */
function dispatchAuthExpired() {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT));
}
}
/** Attempt a silent token refresh. Returns true if new cookies were set. */
async function tryRefreshToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const res = await fetch("/api/v1/auth/refresh", {
method: "POST",
credentials: "include",
});
return res.ok;
} catch {
return false;
}
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
/**
* Upload a file with real-time progress tracking.
*
* Works with a streaming proxy endpoint that sends newline-delimited JSON:
* { phase: "uploading", progress: 42 } — bytes consumed by backend
* { phase: "processing" } — backend is processing the file
* { done: true, status, data } — final response
* { error: "message" } — failure
*
* Returns `{ promise, abort }` so callers can cancel in-flight uploads.
*/
export function uploadWithProgress<T>(
url: string,
apiKey: string,
body: FormData,
onProgress: (percent: number, phase: UploadPhase) => void,
): { promise: Promise<T>; abort: () => void } {
const controller = new AbortController();
const promise = (async () => {
const res = await fetch(url, {
method: "POST",
headers: { "X-API-KEY": apiKey },
body,
signal: controller.signal,
});
if (!res.body) throw new Error("No response stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result: T | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.error) {
throw new Error(event.error);
}
if (event.phase === "uploading" && event.progress !== undefined) {
onProgress(event.progress, "uploading");
}
if (event.phase === "processing") {
onProgress(100, "processing");
}
if (event.done) {
onProgress(100, "done");
if (event.status >= 400) {
const msg =
event.data?.error ||
event.data?.message ||
event.data?.detail ||
`Upload failed: ${event.status}`;
throw new Error(msg);
}
result = event.data as T;
}
}
}
if (!result) throw new Error("No response received from server");
return result;
})();
return { promise, abort: () => controller.abort() };
}
/**
* Options accepted by `apiFetch`. Extends `RequestInit` with one extra knob:
*/
export type ApiFetchOptions = RequestInit & { acceptEmpty?: boolean };
/**
* Client-side fetch helper for Next.js route handlers (/api/*).
*
* - Attaches X-API-KEY header and `credentials: "include"` for cookie auth.
* - On **403 "revoked"**: forces immediate logout (no refresh).
* - On **401**: tries a silent token refresh, retries once, then logs out.
* - All other errors are thrown as-is.
* Default return type is `Promise<T>`. Pass `{ acceptEmpty: true }` to opt
* into `Promise<T | null>` (returns null on 204).
*/
export async function apiFetch<T>(
url: string,
apiKey: string,
options?: RequestInit,
): Promise<T>;
export async function apiFetch<T>(
url: string,
apiKey: string,
options: ApiFetchOptions & { acceptEmpty: true },
): Promise<T | null>;
export async function apiFetch<T>(
url: string,
apiKey: string,
options: ApiFetchOptions = {},
): Promise<T | null> {
const { acceptEmpty, ...fetchOptions } = options;
const buildHeaders = () => {
const headers = new Headers(fetchOptions.headers);
if (!(fetchOptions.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
headers.set("X-API-KEY", apiKey);
return headers;
};
const doFetch = () =>
fetch(url, {
...fetchOptions,
headers: buildHeaders(),
credentials: "include",
});
const res = await doFetch();
if (res.ok) {
if (acceptEmpty && res.status === 204) return null;
return (await res.json()) as T;
}
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
const message = extractErrorMessage(body, `Request failed: ${res.status}`);
// 403 with "revoked" → force logout, no refresh attempt
if (res.status === 403 && message.toLowerCase().includes("revoked")) {
dispatchAuthExpired();
throw new Error(message.trim() || "Access revoked. Please log in again.");
}
// Non-401 errors → throw immediately
if (res.status !== 401) {
throw new Error(message);
}
// Auth endpoints (login, register, etc.) — never auto-refresh, just throw
if (url.startsWith("/api/auth/")) {
throw new Error(message);
}
// 401 → attempt silent token refresh, then retry once
const refreshed = await tryRefreshToken();
if (refreshed) {
const retry = await doFetch();
if (retry.ok) {
if (acceptEmpty && retry.status === 204) return null;
return (await retry.json()) as T;
}
const retryBody = (await retry.json().catch(() => ({}))) as Record<
string,
unknown
>;
throw new Error(
extractErrorMessage(retryBody, `Request failed: ${retry.status}`),
);
}
// Refresh failed → both tokens expired, force logout
dispatchAuthExpired();
throw new Error("Session expired. Please log in again.");
}