File
Blob: src/client/lib/live-api-request.ts
| 1 | import { ApiError } from "@/client/lib/api-contract"; |
| 2 | import { getStoredBookmark, setStoredBookmark } from "@/client/lib/storage"; |
| 3 | |
| 4 | export const D1_BOOKMARK_HEADER = "x-anvil-d1-bookmark"; |
| 5 | export const SESSION_EXPIRED_EVENT = "anvil:session-expired"; |
| 6 | |
| 7 | interface ErrorEnvelope { |
| 8 | error?: { |
| 9 | code?: string; |
| 10 | message?: string; |
| 11 | details?: unknown; |
| 12 | }; |
| 13 | } |
| 14 | |
| 15 | export interface RequestOptions<T> { |
| 16 | path: string; |
| 17 | method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; |
| 18 | body?: unknown; |
| 19 | decode?: (value: unknown) => T; |
| 20 | } |
| 21 | |
| 22 | const 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 | |
| 39 | export 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 | }; |