@prismicio/client
Version:
The official JavaScript + TypeScript client library for Prismic
166 lines (142 loc) • 5.45 kB
text/typescript
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>
arrayBuffer(): Promise<ArrayBuffer>
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 an ArrayBuffer upfront instead.
const buffer = await response.arrayBuffer()
const memoized: ResponseLike = {
ok: response.ok,
status: response.status,
headers: response.headers,
url: response.url,
text: async () => new TextDecoder().decode(buffer),
json: async () => JSON.parse(new TextDecoder().decode(buffer)),
arrayBuffer: async () => buffer,
blob: async () => new Blob([buffer]),
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
}