Spaces:
Build error
Build error
| import type { JsonValue } from "type-fest"; | |
| export type QueryParams = Record<string, string | number | boolean | undefined | string[]>; | |
| /** | |
| * Create a query string ("?param=value") from an object {[key]: value}. | |
| * Undefined valued are ignored, and an empty string is returned if all values are undefined. | |
| */ | |
| export function queryString(params: QueryParams): string { | |
| const searchParams = new URLSearchParams(); | |
| for (const [key, value] of Object.entries(params)) { | |
| if (value !== undefined) { | |
| if (Array.isArray(value)) { | |
| for (const val of value) { | |
| searchParams.append(key, String(val)); | |
| } | |
| } else { | |
| searchParams.set(key, String(value)); | |
| } | |
| } | |
| } | |
| const searchParamsStr = searchParams.toString(); | |
| return searchParamsStr ? `?${searchParamsStr}` : ""; | |
| } | |
| interface HttpResponseBase<T> { | |
| /** set to true if the call was aborted by the User */ | |
| aborted?: boolean; | |
| /** set to true if the call resulted in an error */ | |
| isError: boolean; | |
| /** the parsed server response, whether the call ended up in an error or not */ | |
| payload: T; | |
| /** a clone of the raw Response object returned by fetch, in case it is needed for some edge cases */ | |
| rawResponse: Response | undefined; | |
| /** the request status code */ | |
| statusCode: number; | |
| /** Parsed links in Link header */ | |
| links?: Record<string, string>; | |
| } | |
| interface HttpResponseError<T> extends HttpResponseBase<T> { | |
| aborted: boolean; | |
| error: string; | |
| isError: true; | |
| } | |
| interface HttpResponseSuccess<T> extends HttpResponseBase<T> { | |
| isError: false; | |
| payload: T; | |
| } | |
| export type HttpResponse<SuccessType, ErrorType = unknown> = | |
| | HttpResponseSuccess<SuccessType> | |
| | HttpResponseError<ErrorType>; | |
| export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; | |
| type ResponseType = "blob" | "json" | "text"; | |
| interface SendOptions<D> { | |
| /** the data sent to the server */ | |
| data?: D; | |
| /** the request headers */ | |
| headers?: Record<string, string>; | |
| /** | |
| * determines how the server response will be parsed (as JSON, text, or a blob) | |
| * @default "json" | |
| */ | |
| responseType?: ResponseType; | |
| /** | |
| * The AbortSignal interface represents a signal object that allows you to communicate with a | |
| * DOM request (such as a fetch request) and abort it if required via an AbortController object. | |
| * read more at: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | |
| */ | |
| signal?: AbortSignal; | |
| credentials?: RequestCredentials; | |
| } | |
| async function getResponseContent(res: Response, type?: ResponseType): Promise<Blob | JsonValue | undefined> { | |
| try { | |
| if (res.headers.get("content-type")?.includes("json")) { | |
| return (await res.json()) as JsonValue; | |
| } | |
| if (type === "blob") { | |
| return await res.blob(); | |
| } | |
| return await res.text(); | |
| } catch (err) { | |
| return undefined; | |
| } | |
| } | |
| /** | |
| * Handle fetch calls, parse the server response, capture every possible error, | |
| * and returns standardized {@link HttpResponse} objects in every scenario | |
| */ | |
| export async function httpSend<T, D = Record<string, unknown> | File>( | |
| method: HttpMethod, | |
| path: string, | |
| sendOptions: SendOptions<D> = {} | |
| ): Promise<HttpResponse<T>> { | |
| try { | |
| const headers = { | |
| ...sendOptions.headers, | |
| ...(sendOptions.responseType === "json" | |
| ? { Accept: "application/json" } | |
| : sendOptions.responseType === "text" | |
| ? { Accept: "text/plain" } | |
| : {}), | |
| }; | |
| const res = await fetch(path, { | |
| body: | |
| sendOptions.data instanceof File | |
| ? sendOptions.data | |
| : sendOptions.data | |
| ? JSON.stringify(sendOptions.data) | |
| : undefined, | |
| headers, | |
| method, | |
| ...(sendOptions.signal ? { signal: sendOptions.signal } : {}), | |
| ...(sendOptions.credentials ? { credentials: sendOptions.credentials } : {}), | |
| }); | |
| const rawResponse = res.clone(); | |
| if (!res.ok) { | |
| let error = `${res.status} ${res.statusText}`; | |
| const payload = await getResponseContent(res); | |
| // In case we get a detailed JSON error message from the backend - which we should in any of the following cases: | |
| // - When hitting /api/... endpoints | |
| // - When using header X-Requested-With: XMLHttpRequest | |
| // - When using header Content-Type: application/json | |
| if (typeof payload === "object" && payload) { | |
| if ("message" in payload && typeof payload.message === "string") { | |
| error = payload.message; | |
| } else if ("error" in payload && typeof payload.error === "string") { | |
| error = payload.error; | |
| } | |
| } | |
| return { | |
| aborted: false, | |
| error, | |
| isError: true, | |
| payload, | |
| rawResponse, | |
| statusCode: res.status, | |
| }; | |
| } | |
| const payload = await getResponseContent(res, sendOptions.responseType); | |
| const links = res.headers.get("Link") ? parseLinkHeader(res.headers.get("Link")!) : undefined; | |
| return payload !== undefined | |
| ? { | |
| isError: false, | |
| payload: payload as T, | |
| rawResponse, | |
| statusCode: res.status, | |
| links, | |
| } | |
| : { | |
| aborted: false, | |
| error: sendOptions.responseType === "json" ? "Error parsing JSON" : "Error parsing server response", | |
| isError: true, | |
| payload, | |
| rawResponse, | |
| statusCode: res.status, | |
| links, | |
| }; | |
| } catch (e) { | |
| return { | |
| aborted: e instanceof DOMException && e.name === "AbortError", | |
| error: (e instanceof TypeError || e instanceof DOMException) && e.message ? e.message : "Failed to fetch", | |
| isError: true, | |
| payload: undefined, | |
| rawResponse: undefined, | |
| statusCode: 0, | |
| }; | |
| } | |
| } | |
| type GetOptions = Omit<SendOptions<unknown>, "data">; | |
| /** | |
| * Helper function to easily and safely make GET calls | |
| */ | |
| export function httpGet<T>(path: string, opts: GetOptions = {}): Promise<HttpResponse<T>> { | |
| return httpSend<T>("GET", path, { ...opts }); | |
| } | |
| export function parseLinkHeader(header: string): Record<string, string> { | |
| const regex = /<(https?:[/][/][^>]+)>;\s+rel="([^"]+)"/g; | |
| return Object.fromEntries([...header.matchAll(regex)].map(([_, url, rel]) => [rel, url])); | |
| } | |
| /// A not-that-great throttling function | |
| export function throttle<T extends unknown[]>(callback: (...rest: T) => unknown, limit: number): (...rest: T) => void { | |
| let last: number; | |
| /// setTimeout can return different types on browser or node | |
| let deferTimer: ReturnType<typeof setTimeout>; | |
| return function (...rest) { | |
| const now = Date.now(); | |
| if (last && now < last + limit) { | |
| clearTimeout(deferTimer); | |
| deferTimer = setTimeout(function () { | |
| last = now; | |
| callback(...rest); | |
| }, limit); | |
| } else { | |
| last = now; | |
| callback(...rest); | |
| } | |
| }; | |
| } | |