Skip to content
File

Blob: src/client/lib/live-api-request.ts

typescript97 lines
1import { ApiError } from "@/client/lib/api-contract";
2import { getStoredBookmark, setStoredBookmark } from "@/client/lib/storage";
3 
4export const D1_BOOKMARK_HEADER = "x-anvil-d1-bookmark";
5export const SESSION_EXPIRED_EVENT = "anvil:session-expired";
6 
7interface ErrorEnvelope {
8 error?: {
9 code?: string;
10 message?: string;
11 details?: unknown;
12 };
13}
14 
15export interface RequestOptions<T> {
16 path: string;
17 method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
18 body?: unknown;
19 decode?: (value: unknown) => T;
20}
21 
22const parseErrorResponse = async (response: Response): Promise<ApiError> => {
23 let payload: ErrorEnvelope | null = null;
24 
25 try {
26 payload = (await response.json()) as ErrorEnvelope;
27 } catch {
28 // Keep the HTTP status fallback when the error body is empty or malformed.
29 }
30 
31 return new ApiError(
32 response.status,
33 payload?.error?.code ?? `http_${response.status}`,
34 payload?.error?.message ?? `Request failed with status ${response.status}.`,
35 payload?.error?.details,
36 );
37};
38 
39export const request = async <T>({ path, method, body, decode }: RequestOptions<T>): Promise<T> => {
40 const headers = new Headers({
41 accept: "application/json",
42 });
43 
44 const bookmark = getStoredBookmark();
45 if (bookmark) {
46 headers.set(D1_BOOKMARK_HEADER, bookmark);
47 }
48 
49 if (body !== undefined) {
50 headers.set("content-type", "application/json; charset=utf-8");
51 }
52 
53 let response: Response;
54 
55 try {
56 response = await fetch(path, {
57 method,
58 headers,
59 body: body === undefined ? undefined : JSON.stringify(body),
60 credentials: "same-origin",
61 });
62 } catch (error) {
63 throw new ApiError(503, "network_error", "Request failed. The backend may not be ready yet.", error);
64 }
65 
66 const nextBookmark = response.headers.get(D1_BOOKMARK_HEADER);
67 if (nextBookmark) {
68 setStoredBookmark(nextBookmark);
69 }
70 
71 if (!response.ok) {
72 const apiError = await parseErrorResponse(response);
73 if (apiError.code === "invalid_session") {
74 window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
75 }
76 throw apiError;
77 }
78 
79 if (!decode) {
80 return undefined as T;
81 }
82 
83 let payload: unknown;
84 
85 try {
86 payload = (await response.json()) as unknown;
87 } catch (error) {
88 throw new ApiError(500, "invalid_response", "Server returned malformed JSON.", error);
89 }
90 
91 try {
92 return decode(payload);
93 } catch (error) {
94 throw new ApiError(500, "invalid_response", "Server response failed contract validation.", error);
95 }
96};