UNPKG

@prismicio/client

Version:

The official JavaScript + TypeScript client library for Prismic

189 lines (165 loc) 5.37 kB
import { type LimitFunction, pLimit } from "./pLimit" /** * The default number of milliseconds to wait before retrying a rate-limited * `fetch()` request (429 response code). The default value is only used if the * response does not include a `retry-after` header. */ export const DEFAULT_RETRY_AFTER = 1500 // ms /** * A record of URLs mapped to throttled task runners. */ const THROTTLED_RUNNERS: Partial<Record<string, LimitFunction>> = {} /** * A record of URLs mapped to active deduplicated jobs. Jobs are keyed by their * optional signal. */ const DEDUPLICATED_JOBS: Partial< Record<string, Map<AbortSignalLike | undefined, Promise<ResponseLike>>> > = {} /** * A universal API to make network requests. A subset of the `fetch()` API. * * {@link https://developer.mozilla.org/en-US/docs/Web/API/fetch} */ export type FetchLike = ( input: string, init?: RequestInitLike, ) => Promise<ResponseLike> /** * An object that allows you to abort a `fetch()` request if needed via an * `AbortController` object * * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal} */ // `any` is used often here to ensure this type is universally valid among // different AbortSignal implementations. The types of each property are not // important to validate since it is blindly passed to a given `fetch()` // function. // oxlint-disable-next-line no-explicit-any export type AbortSignalLike = any /** * A subset of RequestInit properties to configure a `fetch()` request. */ // Only options relevant to the client are included. Extending from the full // RequestInit would cause issues, such as accepting Header objects. // // An interface is used to allow other libraries to augment the type with // environment-specific types. export interface RequestInitLike extends Pick<RequestInit, "cache"> { /** * The HTTP method to use for the request. */ method?: string /** * The request body to send with the request. */ // We want to keep the body type as compatible as possible, so // we only declare the type we need and accept anything else. // oxlint-disable-next-line no-explicit-any body?: any | FormData | string /** * An object literal to set the `fetch()` request's headers. */ headers?: Record<string, string> /** * An AbortSignal to set the `fetch()` request's signal. * * See: * [https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) */ // NOTE: `AbortSignalLike` is `any`! It is left as `AbortSignalLike` // for backwards compatibility (the type is exported) and to signal to // other readers that this should be an AbortSignal-like object. signal?: AbortSignalLike } /** * The minimum required properties from Response. */ export interface ResponseLike { ok: boolean status: number headers: HeadersLike url: string // oxlint-disable-next-line no-explicit-any json(): Promise<any> text(): Promise<string> blob(): Promise<Blob> clone(): ResponseLike } /** * The minimum required properties from Headers. */ export interface HeadersLike { get(name: string): string | null } async function memoizeResponse(response: ResponseLike): Promise<ResponseLike> { // Deduplicated responses are shared across multiple callers. Calling // response.clone() on a shared response can cause backpressure hangs // in Node.js, so we buffer the body as a blob upfront instead. const blob = await response.blob() const memoized: ResponseLike = { ok: response.ok, status: response.status, headers: response.headers, url: response.url, text: async () => blob.text(), json: async () => JSON.parse(await blob.text()), blob: async () => blob, clone: () => memoized, } return memoized } /** * Makes an HTTP request with automatic retry for rate limits and request * deduplication. * * @param url - The URL to request. * @param init - Fetch options. * @param fetchFn - The fetch function to use. * * @returns The response from the fetch request. */ export async function request( url: URL, init: RequestInitLike | undefined, fetchFn: FetchLike, ): Promise<ResponseLike> { const stringURL = url.toString() let job: Promise<ResponseLike> // Throttle requests with a body. if (init?.body) { // Rate limiting is done per hostname. const runner = (THROTTLED_RUNNERS[url.hostname] ||= pLimit({ interval: DEFAULT_RETRY_AFTER, })) job = runner(() => fetchFn(stringURL, init)) } else { // Deduplicate all other requests. const existingJob = DEDUPLICATED_JOBS[stringURL]?.get(init?.signal) if (existingJob) { job = existingJob } else { job = fetchFn(stringURL, init) .then(memoizeResponse) .finally(() => { DEDUPLICATED_JOBS[stringURL]?.delete(init?.signal) if (DEDUPLICATED_JOBS[stringURL]?.size === 0) { delete DEDUPLICATED_JOBS[stringURL] } }) const map = (DEDUPLICATED_JOBS[stringURL] ||= new Map()) map.set(init?.signal, job) } } const response = await job // Retry rate limited requests. if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after")) const resolvedRetryAfter = Number.isNaN(retryAfter) ? DEFAULT_RETRY_AFTER : retryAfter * 1000 await new Promise((resolve) => setTimeout(resolve, resolvedRetryAfter)) return request(url, init, fetchFn) } return response }