export type CustomFetchOptions = RequestInit & { responseType?: "json" | "text" | "blob" | "auto"; }; export type ErrorType = ApiError; export type BodyType = T; const NO_BODY_STATUS = new Set([204, 205, 304]); const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json"; function isRequest(input: RequestInfo | URL): input is Request { return typeof Request !== "undefined" && input instanceof Request; } function resolveMethod(input: RequestInfo | URL, explicitMethod?: string): string { if (explicitMethod) return explicitMethod.toUpperCase(); if (isRequest(input)) return input.method.toUpperCase(); return "GET"; } // Use loose check for URL — some runtimes (e.g. React Native) polyfill URL // differently, so `instanceof URL` can fail. function isUrl(input: RequestInfo | URL): input is URL { return typeof URL !== "undefined" && input instanceof URL; } function resolveUrl(input: RequestInfo | URL): string { if (typeof input === "string") return input; if (isUrl(input)) return input.toString(); return input.url; } function mergeHeaders(...sources: Array): Headers { const headers = new Headers(); for (const source of sources) { if (!source) continue; new Headers(source).forEach((value, key) => { headers.set(key, value); }); } return headers; } function getMediaType(headers: Headers): string | null { const value = headers.get("content-type"); return value ? value.split(";", 1)[0].trim().toLowerCase() : null; } function isJsonMediaType(mediaType: string | null): boolean { return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); } function isTextMediaType(mediaType: string | null): boolean { return Boolean( mediaType && (mediaType.startsWith("text/") || mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml") || mediaType === "application/x-www-form-urlencoded"), ); } // Loose equality (`== null`) handles both `null` (browser) and `undefined` // (React Native, which doesn't implement ReadableStream body). function hasNoBody(response: Response, method: string): boolean { if (method === "HEAD") return true; if (NO_BODY_STATUS.has(response.status)) return true; if (response.headers.get("content-length") === "0") return true; if (response.body == null) return true; return false; } function stripBom(text: string): string { return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; } function looksLikeJson(text: string): boolean { const trimmed = text.trimStart(); return trimmed.startsWith("{") || trimmed.startsWith("["); } function getStringField(value: unknown, key: string): string | undefined { if (!value || typeof value !== "object") return undefined; const candidate = (value as Record)[key]; if (typeof candidate !== "string") return undefined; const trimmed = candidate.trim(); return trimmed === "" ? undefined : trimmed; } function truncate(text: string, maxLength = 300): string { return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; } function buildErrorMessage(response: Response, data: unknown): string { const prefix = `HTTP ${response.status} ${response.statusText}`; if (typeof data === "string") { const text = data.trim(); return text ? `${prefix}: ${truncate(text)}` : prefix; } const title = getStringField(data, "title"); const detail = getStringField(data, "detail"); const message = getStringField(data, "message") ?? getStringField(data, "error_description") ?? getStringField(data, "error"); if (title && detail) return `${prefix}: ${title} — ${detail}`; if (detail) return `${prefix}: ${detail}`; if (message) return `${prefix}: ${message}`; if (title) return `${prefix}: ${title}`; return prefix; } export class ApiError extends Error { readonly name = "ApiError"; readonly status: number; readonly statusText: string; readonly data: T | null; readonly headers: Headers; readonly response: Response; readonly method: string; readonly url: string; constructor( response: Response, data: T | null, requestInfo: { method: string; url: string }, ) { super(buildErrorMessage(response, data)); Object.setPrototypeOf(this, new.target.prototype); this.status = response.status; this.statusText = response.statusText; this.data = data; this.headers = response.headers; this.response = response; this.method = requestInfo.method; this.url = response.url || requestInfo.url; } } export class ResponseParseError extends Error { readonly name = "ResponseParseError"; readonly status: number; readonly statusText: string; readonly headers: Headers; readonly response: Response; readonly method: string; readonly url: string; readonly rawBody: string; readonly cause: unknown; constructor( response: Response, rawBody: string, cause: unknown, requestInfo: { method: string; url: string }, ) { super( `Failed to parse response from ${requestInfo.method} ${response.url || requestInfo.url} ` + `(${response.status} ${response.statusText}) as JSON`, ); Object.setPrototypeOf(this, new.target.prototype); this.status = response.status; this.statusText = response.statusText; this.headers = response.headers; this.response = response; this.method = requestInfo.method; this.url = response.url || requestInfo.url; this.rawBody = rawBody; this.cause = cause; } } async function parseJsonBody( response: Response, requestInfo: { method: string; url: string }, ): Promise { const raw = await response.text(); const normalized = stripBom(raw); if (normalized.trim() === "") { return null; } try { return JSON.parse(normalized); } catch (cause) { throw new ResponseParseError(response, raw, cause, requestInfo); } } async function parseErrorBody(response: Response, method: string): Promise { if (hasNoBody(response, method)) { return null; } const mediaType = getMediaType(response.headers); // Fall back to text when blob() is unavailable (e.g. some React Native builds). if (mediaType && !isJsonMediaType(mediaType) && !isTextMediaType(mediaType)) { return typeof response.blob === "function" ? response.blob() : response.text(); } const raw = await response.text(); const normalized = stripBom(raw); const trimmed = normalized.trim(); if (trimmed === "") { return null; } if (isJsonMediaType(mediaType) || looksLikeJson(normalized)) { try { return JSON.parse(normalized); } catch { return raw; } } return raw; } function inferResponseType(response: Response): "json" | "text" | "blob" { const mediaType = getMediaType(response.headers); if (isJsonMediaType(mediaType)) return "json"; if (isTextMediaType(mediaType) || mediaType == null) return "text"; return "blob"; } async function parseSuccessBody( response: Response, responseType: "json" | "text" | "blob" | "auto", requestInfo: { method: string; url: string }, ): Promise { if (hasNoBody(response, requestInfo.method)) { return null; } const effectiveType = responseType === "auto" ? inferResponseType(response) : responseType; switch (effectiveType) { case "json": return parseJsonBody(response, requestInfo); case "text": { const text = await response.text(); return text === "" ? null : text; } case "blob": if (typeof response.blob !== "function") { throw new TypeError( "Blob responses are not supported in this runtime. " + "Use responseType \"json\" or \"text\" instead.", ); } return response.blob(); } } export async function customFetch( input: RequestInfo | URL, options: CustomFetchOptions = {}, ): Promise { const { responseType = "auto", headers: headersInit, ...init } = options; const method = resolveMethod(input, init.method); if (init.body != null && (method === "GET" || method === "HEAD")) { throw new TypeError(`customFetch: ${method} requests cannot have a body.`); } const headers = mergeHeaders(isRequest(input) ? input.headers : undefined, headersInit); if ( typeof init.body === "string" && !headers.has("content-type") && looksLikeJson(init.body) ) { headers.set("content-type", "application/json"); } if (responseType === "json" && !headers.has("accept")) { headers.set("accept", DEFAULT_JSON_ACCEPT); } const requestInfo = { method, url: resolveUrl(input) }; const response = await fetch(input, { ...init, method, headers }); if (!response.ok) { const errorData = await parseErrorBody(response, method); throw new ApiError(response, errorData, requestInfo); } return (await parseSuccessBody(response, responseType, requestInfo)) as T; }