@sanity/client
Version:
Client for retrieving, creating and patching data from Sanity.io
328 lines (302 loc) • 11.4 kB
text/typescript
import {
createRequester,
type FetchFunction,
isHttpError as isGetItHttpError,
type RequestOptions as FetchRequestOptions,
type TransformMiddleware,
type WrappingMiddleware,
} from 'get-it'
import {isRetryableRequest, retry} from 'get-it/middleware'
import {from, Observable} from 'rxjs'
import type {Any} from '../types'
import {ClientError, httpResponseFromFetch, ServerError} from './errors'
/**
* Middleware accepted by the internal HTTP layer. Two flavors per get-it v9:
* a flat-pipeline transform (`beforeRequest`/`afterResponse`) or a wrapping
* middleware that surrounds the fetch chain.
*
* @internal
*/
export type LegacyMiddleware = TransformMiddleware | WrappingMiddleware
/**
* The shape this client's internal pipeline produces. Mirrors the historical
* `ResponseEvent` from the get-it v8 multi-event observable, so all the existing
* downstream code (`_requestObservable`, `_uploadObservable`,
* `defineCreateClient`) keeps working without churn.
*
* @internal
*/
export interface ResponseEvent {
type: 'response'
body: unknown
statusCode: number
statusMessage: string | null
headers: Record<string, string>
url: string
method: string
}
/**
* Legacy "requester" type - the observable half of `defineRequester`'s
* result. Returns a single-event Observable for compatibility with the rest
* of the codebase.
*
* @internal
*/
export type LegacyRequester = (options: Any) => Observable<ResponseEvent>
/**
* Promise-based sibling of {@link LegacyRequester}. Resolves directly to the
* single `ResponseEvent` the transport produces, skipping the RxJS wrapper.
* Used by the promise-based client surface so it never constructs an
* Observable.
*
* @internal
*/
export type PromiseRequester = (options: Any) => Promise<ResponseEvent>
/**
* Both forms of the transport, sharing a single underlying get-it requester
* (so retry state and the one-shot warning de-duplication are shared between
* the observable and promise paths).
*
* @internal
*/
export interface DualRequester {
observable: LegacyRequester
promise: PromiseRequester
}
/**
* Options for tuning the HTTP request pipeline per-client.
*
* @internal
*/
export interface HttpRequestConfig {
ignoreWarnings?: string | RegExp | Array<string | RegExp>
maxRetries?: number
retryDelay?: (attemptNumber: number) => number
}
/**
* Build both the observable and promise transport forms from a single get-it
* requester. The promise form is the primitive (`executeRequest` is already
* promise-based); the observable form wraps it lazily so each subscription
* starts its own request (cold), and unsubscribing aborts the in-flight
* fetch — the same contract as the get-it v8 observable adapter.
*
* @internal
*/
export function defineRequester(
envOptions: EnvironmentOptions,
config: HttpRequestConfig = {},
): DualRequester {
// Framework-patched fetch implementations read extra `RequestInit` fields
// for caching semantics — Next.js App Router's `cache` and `next` options in
// particular. Legacy callers pass those via an object-valued `fetch` request
// option (see `adaptToFetchOptions`, which stashes it in `meta.fetchInit`
// since get-it v9's own `fetch` option only accepts a function). Merge them
// into the init of whichever fetch implementation is effective for the
// request: per-request/test-override fetch, the environment default, or the
// global fetch.
const applyFetchInit: WrappingMiddleware = (opts, next) => {
const fetchInit = opts.meta?.fetchInit
if (typeof fetchInit !== 'object' || fetchInit === null) return next(opts)
const baseFetch: NonNullable<FetchRequestOptions['fetch']> =
opts.fetch ?? envOptions.fetch ?? globalThis.fetch
const fetchWithInit: typeof baseFetch = (input, init) =>
baseFetch(input, {...fetchInit, ...init})
return next({...opts, fetch: fetchWithInit})
}
const requester = createRequester({
...(envOptions.fetch ? {fetch: envOptions.fetch} : {}),
headers: envOptions.headers,
// Keep get-it's built-in 4xx/5xx → HttpError so the retry middleware can
// see them; we translate to ClientError/ServerError after the retry loop
// has exhausted in `executeRequest`.
httpErrors: true,
middleware: [
retry({
shouldRetry: shouldRetryRequest,
maxRetries: config.maxRetries ?? 5,
...(config.retryDelay ? {retryDelay: config.retryDelay} : {}),
}),
...envOptions.middleware,
applyFetchInit,
printWarnings(config),
],
})
const promise: PromiseRequester = (options: Any) => {
// Options arrive fetch-shaped from `requestOptions` — the single
// translation boundary between public option names and the transport.
if (typeof options.url !== 'string') {
throw new TypeError('Request options must include a `url`')
}
return executeRequest(requester, options)
}
// Same per-subscription AbortController pattern as `_observe` in
// dataMethods: a caller-supplied signal is combined in via
// `AbortSignal.any`, so the request aborts both on the caller's signal and
// on unsubscribe. `AbortSignal.any` (rather than `addEventListener`)
// because the caller's signal can be long-lived and reused — a manually
// added listener would accumulate there once per subscription, since
// `{once: true}` only cleans up if the signal actually fires.
const observable: LegacyRequester = (options: Any) =>
new Observable<ResponseEvent>((subscriber) => {
const controller = new AbortController()
const userSignal: AbortSignal | undefined = options.signal
const signal = userSignal
? AbortSignal.any([userSignal, controller.signal])
: controller.signal
const subscription = from(promise({...options, signal})).subscribe(subscriber)
return () => {
subscription.unsubscribe()
controller.abort()
}
})
return {promise, observable}
}
/**
* Options describing the environment-specific defaults (Node vs. browser).
*
* @internal
*/
export interface EnvironmentOptions {
fetch?: FetchRequestOptions['fetch']
headers?: Record<string, string>
middleware: LegacyMiddleware[]
/**
* Resolves the environment's fetch implementation — the same transport
* regular requests use (custom fetch variants, undici configuration,
* env-proxy support and all), optionally configured for an explicit proxy
* URL. Lets consumers of the resolved config (the EventSource fetch
* resolver) avoid falling back to whatever `globalThis.fetch` happens to
* be. The Node environment supplies get-it's undici-backed fetch; the
* browser environment leaves it unset (the global fetch IS the
* environment's fetch there).
*
* Looked up via the env rather than imported directly so that the Node-only
* `get-it/node` (which transitively pulls in `undici`) never ends up in the
* browser bundle, even via rollup's inlined dynamic imports.
*/
resolveFetch?: (proxyUrl?: string) => FetchFunction
}
async function executeRequest(
requester: ReturnType<typeof createRequester>,
fetchOptions: FetchRequestOptions,
): Promise<ResponseEvent> {
const url = fetchOptions.url
const method = (fetchOptions.method ?? 'GET').toUpperCase()
let response
try {
response = await requester(fetchOptions)
} catch (err) {
if (isGetItHttpError(err)) {
// `err.body` is the response body as a string (get-it v9 stores the
// already-decoded text), regardless of which response variant
// `err.response` is.
const errBodyText = typeof err.body === 'string' ? err.body : ''
const errBody = parseJsonText(errBodyText, err.headers)
const canonical = httpResponseFromFetch(
{
status: err.status,
statusText: err.statusText,
headers: err.headers,
body: errBody,
url: err.response.url ?? err.url,
},
url,
method,
)
const tag = extractRequestTag(fetchOptions.query)
if (canonical.statusCode >= 500) {
throw new ServerError(canonical)
}
throw new ClientError(canonical, tag)
}
throw err
}
const body = parseJsonBody(response)
return {
type: 'response',
body,
statusCode: response.status,
statusMessage: response.statusText || null,
headers: headersToRecord(response.headers),
url: response.url || url,
method,
}
}
/**
* Extract the GROQ request tag (used for error messages) from the query.
*/
function extractRequestTag(query: FetchRequestOptions['query']): string | undefined {
if (!query) return undefined
if (query instanceof URLSearchParams) return query.get('tag') ?? undefined
const tag = query.tag
return typeof tag === 'string' ? tag : undefined
}
function parseJsonBody(response: {headers: Headers; text(): string}): unknown {
return parseJsonText(response.text(), response.headers)
}
/**
* Parse a response body according to its `content-type`: JSON when the header
* says so (falling back to the raw text on malformed JSON), text otherwise.
* Shared with the browser XHR upload path so error bodies parse identically
* on both transports.
*
* @internal
*/
export function parseJsonText(text: string, headers: Headers): unknown {
const contentType = (headers.get('content-type') ?? '').toLowerCase()
if (!text) return undefined
if (contentType.includes('application/json')) {
try {
return JSON.parse(text)
} catch {
return text
}
}
return text
}
function headersToRecord(headers: Headers): Record<string, string> {
const out: Record<string, string> = {}
headers.forEach((value, key) => {
out[key] = value
})
return out
}
function shouldRetryRequest(err: unknown, attempt: number, options: FetchRequestOptions): boolean {
// HTTP errors aren't usually retryable, but Content Lake gives us a few
// status codes where retrying *is* the right move.
if (isGetItHttpError(err)) {
const isSafe = (options.method ?? 'GET') === 'GET' || options.method === 'HEAD'
const isQuery = (options.url ?? '').includes('/data/query')
const status = err.status
const retryableStatus = status === 429 || status === 502 || status === 503
if ((isSafe || isQuery) && retryableStatus) return true
return false
}
return isRetryableRequest(err, attempt, options)
}
function printWarnings(config: HttpRequestConfig): TransformMiddleware {
const seen: Record<string, boolean> = {}
const shouldIgnore = (message: string): boolean => {
if (config.ignoreWarnings === undefined) return false
const patterns = Array.isArray(config.ignoreWarnings)
? config.ignoreWarnings
: [config.ignoreWarnings]
return patterns.some((pattern) =>
typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message),
)
}
return {
afterResponse(response) {
const header = response.headers.get('x-sanity-warning')
if (!header) return response
// Multiple warnings can be comma-separated per HTTP header semantics.
for (const msg of header.split(',').map((m) => m.trim())) {
if (!msg || seen[msg] || shouldIgnore(msg)) continue
seen[msg] = true
// oxlint-disable-next-line no-console
console.warn(msg)
}
return response
},
}
}