UNPKG

@prismicio/client

Version:

The official JavaScript + TypeScript client library for Prismic

331 lines (293 loc) 8.83 kB
import { type LimitFunction, pLimit } from "./lib/pLimit" import { PrismicError } from "./errors/PrismicError" /** * The default delay used with APIs not providing rate limit headers. * * @internal */ export const UNKNOWN_RATE_LIMIT_DELAY = 1500 /** * 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. // eslint-disable-next-line @typescript-eslint/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. // eslint-disable-next-line @typescript-eslint/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 // eslint-disable-next-line @typescript-eslint/no-explicit-any json(): Promise<any> text(): Promise<string> blob(): Promise<Blob> } /** * The minimum required properties from Headers. */ export interface HeadersLike { get(name: string): string | null } /** * The minimum required properties to treat as an HTTP Request for automatic * Prismic preview support. */ export type HttpRequestLike = | /** * Web API Request * * @see http://developer.mozilla.org/en-US/docs/Web/API/Request */ { headers?: { get(name: string): string | null } url?: string } /** * Express-style Request */ | { headers?: { cookie?: string } query?: Record<string, unknown> } /** * Configuration for clients that determine how APIs are queried. */ export type BaseClientConfig = { /** * The function used to make network requests to the Prismic REST API. In * environments where a global `fetch` function does not exist, such as * Node.js, this function must be provided. */ fetch?: FetchLike /** * Options provided to the client's `fetch()` on all network requests. These * options will be merged with internally required options. They can also be * overriden on a per-query basis using the query's `fetchOptions` parameter. */ fetchOptions?: RequestInitLike } /** * Parameters for any client method that use `fetch()`. */ export type FetchParams = { /** * Options provided to the client's `fetch()` on all network requests. These * options will be merged with internally required options. They can also be * overriden on a per-query basis using the query's `fetchOptions` parameter. */ fetchOptions?: RequestInitLike /** * An `AbortSignal` provided by an `AbortController`. This allows the network * request to be cancelled if necessary. * * @deprecated Move the `signal` parameter into `fetchOptions.signal`: * * @see \<https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal\> */ signal?: AbortSignalLike } /** * The result of a `fetch()` job. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any type FetchJobResult<TJSON = any> = { status: number headers: HeadersLike json: TJSON text?: string } export abstract class BaseClient { /** * The function used to make network requests to the Prismic REST API. In * environments where a global `fetch` function does not exist, such as * Node.js, this function must be provided. */ fetchFn: FetchLike fetchOptions?: RequestInitLike /** * Active queued `fetch()` jobs keyed by URL and AbortSignal (if it exists). */ private queuedFetchJobs: Record<string, LimitFunction> = {} /** * Active deduped `fetch()` jobs keyed by URL and AbortSignal (if it exists). */ private dedupedFetchJobs: Record< string, Map<AbortSignalLike | undefined, Promise<FetchJobResult>> > = {} constructor(options: BaseClientConfig) { this.fetchOptions = options.fetchOptions if (typeof options.fetch === "function") { this.fetchFn = options.fetch } else if (typeof globalThis.fetch === "function") { this.fetchFn = globalThis.fetch as FetchLike } else { throw new PrismicError( "A valid fetch implementation was not provided. In environments where fetch is not available (including Node.js), a fetch implementation must be provided via a polyfill or the `fetch` option.", undefined, undefined, ) } // If the global fetch function is used, we must bind it to the global scope. if (this.fetchFn === globalThis.fetch) { this.fetchFn = this.fetchFn.bind(globalThis) } } protected async fetch( url: string, params: FetchParams = {}, ): Promise<FetchJobResult> { const requestInit: RequestInitLike = { ...this.fetchOptions, ...params.fetchOptions, headers: { ...this.fetchOptions?.headers, ...params.fetchOptions?.headers, }, signal: params.fetchOptions?.signal || params.signal || this.fetchOptions?.signal, } // Request with a `body` are throttled, others are deduped. if (params.fetchOptions?.body) { return this.queueFetch(url, requestInit) } else { return this.dedupeFetch(url, requestInit) } } private queueFetch( url: string, requestInit: RequestInitLike = {}, ): Promise<FetchJobResult> { // Rate limiting is done per hostname. const hostname = new URL(url).hostname if (!this.queuedFetchJobs[hostname]) { this.queuedFetchJobs[hostname] = pLimit({ interval: UNKNOWN_RATE_LIMIT_DELAY, }) } return this.queuedFetchJobs[hostname](() => this.createFetchJob(url, requestInit), ) } private dedupeFetch( url: string, requestInit: RequestInitLike = {}, ): Promise<FetchJobResult> { let job: Promise<FetchJobResult> // `fetchJobs` is keyed twice: first by the URL and again by is // signal, if one exists. // // Using two keys allows us to reuse fetch requests for // equivalent URLs, but eject when we detect unique signals. if ( this.dedupedFetchJobs[url] && this.dedupedFetchJobs[url].has(requestInit.signal) ) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion job = this.dedupedFetchJobs[url].get(requestInit.signal)! } else { this.dedupedFetchJobs[url] = this.dedupedFetchJobs[url] || new Map() job = this.createFetchJob(url, requestInit).finally(() => { this.dedupedFetchJobs[url]?.delete(requestInit.signal) if (this.dedupedFetchJobs[url]?.size === 0) { delete this.dedupedFetchJobs[url] } }) this.dedupedFetchJobs[url].set(requestInit.signal, job) } return job } private createFetchJob( url: string, requestInit: RequestInitLike = {}, ): Promise<FetchJobResult> { return this.fetchFn(url, requestInit).then(async (res) => { // We can assume Prismic REST API responses // will have a `application/json` // Content Type. If not, this will // throw, signaling an invalid // response. // eslint-disable-next-line @typescript-eslint/no-explicit-any let json: any = undefined let text: string | undefined = undefined if (res.ok) { try { json = await res.json() } catch { // noop } } else { try { text = await res.text() json = JSON.parse(text) } catch { // noop } } return { status: res.status, headers: res.headers, json, text, } }) } }