@sanity/client
Version:
Client for retrieving, creating and patching data from Sanity.io
300 lines (287 loc) • 12.3 kB
text/typescript
import type {FetchFunction} from 'get-it'
import {catchError, mergeMap, Observable, of, throwError} from 'rxjs'
import {finalize, map} from 'rxjs/operators'
import {getStaticAuth} from '../auth'
import {CorsOriginError} from '../http/errors'
import type {ObservableSanityClient, SanityClient} from '../SanityClient'
import type {
Auth,
InitializedClientConfig,
LiveEvent,
LiveEventGoAway,
LiveEventMessage,
LiveEventReconnect,
LiveEventRestart,
LiveEventWelcome,
SyncTag,
} from '../types'
import {isRecord} from '../util/isRecord'
import {shareReplayLatest} from '../util/shareReplayLatest'
import {connectAuthenticatedEventSource} from './authenticatedEventSource'
import {_getDataUrl} from './dataMethods'
import {isEventSourceError} from './eventsource'
import {pickBaseFetch} from './resolveEventSourceFetch'
const requiredApiVersion = '2021-03-25'
/**
* @public
* @inline
*/
export class LiveClient {
#client: SanityClient | ObservableSanityClient
constructor(client: SanityClient | ObservableSanityClient) {
this.#client = client
}
/**
* Requires `apiVersion` to be `2021-03-25` or later.
*/
events({
includeDrafts = false,
tag: _tag,
waitFor,
}: {
includeDrafts?: boolean
/**
* Optional request tag for the listener. Use to identify the request in logs.
*
* @defaultValue `undefined`
*/
tag?: string
/**
* Delays events until after a Sanity Function has processed them and called the callback endpoint.
* When omitted, events are delivered immediately.
*/
waitFor?: 'function'
} = {}): Observable<LiveEvent> {
const config = this.#client.config()
const {projectId, apiVersion: _apiVersion, requestTagPrefix, headers: configHeaders} = config
const apiVersion = _apiVersion.replace(/^v/, '')
if (apiVersion !== 'X' && apiVersion < requiredApiVersion) {
throw new Error(
`The live events API requires API version ${requiredApiVersion} or later. ` +
`The current API version is ${apiVersion}. ` +
`Please update your API version to use this feature.`,
)
}
// Only a static config can be checked up front; a reactive `auth` observable
// is unknown until subscribed, and an anonymous value there surfaces as
// the server's own rejection.
const staticAuth = getStaticAuth(config)
if (includeDrafts && staticAuth !== undefined && staticAuth.value === undefined) {
throw new Error(
`The live events API requires a token or withCredentials when 'includeDrafts: true'. Please update your client configuration. The token should have the lowest possible access role.`,
)
}
const path = _getDataUrl(this.#client, 'live/events')
const url = new URL(this.#client.getUrl(path, false))
const tag = _tag && requestTagPrefix ? [requestTagPrefix, _tag].join('.') : _tag
if (tag) {
url.searchParams.set('tag', tag)
}
if (includeDrafts) {
url.searchParams.set('includeDrafts', 'true')
}
if (waitFor) {
url.searchParams.set('waitFor', waitFor)
}
// Drafts are only visible to authenticated connections, so that is the
// one case the credential travels on the EventSource request itself.
const withAuth = Boolean(includeDrafts)
// Whether the connection being diagnosed sent cookies: the credential
// its latest attempt went out with, which under a reactive `auth`
// observable is only known once a connection has resolved it.
let lastSent: Auth
const sentCredentials = () => lastSent !== undefined && 'withCredentials' in lastSent
// Two clients whose auth is the same static value, or the same reactive
// observable (by reference), share one stream; a reactive observable is keyed by
// identity because its value is not knowable here. Anonymous connections
// (`includeDrafts: false`) share regardless of the client's credential.
const authKey: Observable<Promise<Auth>> | null = withAuth ? (config.auth ?? null) : null
const transportCache = getOrCreate(eventsCache, config.resolveFetch, () => new Map())
const authCache = getOrCreate(transportCache, authKey, () => new Map())
const cacheKey = JSON.stringify([
url.href,
typeof config.proxy === 'string' ? config.proxy : null,
configHeaders ?? null,
withAuth ? (staticAuth?.value ?? null) : null,
])
const existing = authCache.get(cacheKey)
if (existing) {
return existing
}
const events = connectAuthenticatedEventSource(
config,
url.href,
['message', 'restart', 'welcome', 'reconnect', 'goaway'],
{
headers: configHeaders,
withAuth,
onSend: (auth) => {
lastSent = auth
},
},
)
const checkCors = checkCorsObservable(
new URL(this.#client.getUrl('/check/cors', false)),
projectId,
sentCredentials,
pickBaseFetch(config),
)
const observable = events
.pipe(
mergeMap((event) => {
if (event.type === 'reconnect') {
// Check for CORS on reconnect events (which happen on 403s)
return checkCors.pipe(mergeMap(() => of(event)))
}
return of(event)
}),
catchError((err) => {
// Probe for CORS only on errors from the connection itself. Anything
// else ends the stream as is: an error from the `auth` source, or a
// `CorsOriginError` a prior `reconnect` probe already produced.
if (!isEventSourceError(err)) {
return throwError(() => err)
}
return checkCors.pipe(
mergeMap(() => {
// rethrow the original error if checkCors passed
throw err
}),
)
}),
map((event) => {
if (event.type === 'message') {
const {data, ...rest} = event
// Splat data properties from the eventsource message onto the returned event
return {...rest, tags: (data as {tags: SyncTag[]}).tags} as LiveEventMessage
}
return event as LiveEventRestart | LiveEventReconnect | LiveEventWelcome | LiveEventGoAway
}),
)
.pipe(
finalize(() => {
authCache.delete(cacheKey)
if (authCache.size === 0) transportCache.delete(authKey)
if (transportCache.size === 0) eventsCache.delete(config.resolveFetch)
}),
shareReplayLatest({
predicate: (event) => event.type === 'welcome',
}),
)
authCache.set(cacheKey, observable)
return observable
}
}
/**
* Probes the `/check/cors` endpoint to confirm whether the current origin is
* allowed by the project's CORS configuration. EventSource failures are opaque,
* so we use this side-channel purely to tell "the server actively rejected our
* origin" apart from every other class of failure.
*
* Errors with `CorsOriginError` when either:
*
* - `requireCredentials` is `true` (the EventSource was about to send
* credentials) and `/check/cors` reports `result.withCredentials === false`.
* The credentialed request would fail due to a missing
* `access-control-allow-credentials` header. The resulting error carries
* `credentials: true` so its `addOriginUrl` deep-link pre-selects the
* "Allow credentials" toggle in the Sanity management form.
* - `/check/cors` reports `result.allowed === false` (origin is not on the
* project's CORS allow-list). The error carries `credentials: requireCredentials`
* so the deep-link still pre-selects credentials when the caller needed them.
*
* Every other outcome is intentionally treated as "we don't know": the
* observable emits a single `void` value and then completes, so downstream
* `mergeMap(() => ...)` consumers can continue. No error is surfaced for any
* of these cases:
*
* - `allowed: true` (with credentials satisfied if required) or an
* unrecognised body shape: the server did not confirm a CORS rejection.
* - Non-2xx HTTP response from `/check/cors`: same - no signal either way, and
* a 5xx on the probe shouldn't poison the EventSource's original error.
* - `fetch` / network / JSON parse failures: indistinguishable from ordinary
* connectivity hiccups (offline, DNS, certs, transient outages). Reporting
* those as CORS errors is exactly the false-positive class this helper
* exists to prevent.
* - The subscription was aborted: nothing to emit and nothing to complete.
*
* In all of those cases the caller's original underlying error from the
* EventSource is allowed to propagate unchanged.
*/
function checkCorsObservable(
url: URL,
projectId: string | undefined,
sentCredentials: () => boolean,
fetcher: FetchFunction,
): Observable<void> {
return new Observable<void>((observer) => {
const requireCredentials = sentCredentials()
const controller = new AbortController()
const {signal} = controller
fetcher(url.href, {method: 'GET', credentials: 'omit', signal})
.then((response) => {
// Aborted or non-2xx: not a confirmed CORS rejection. Fall through with
// an undefined body so the next step takes the silent-completion path.
if (signal.aborted || !response.ok) return undefined
return response.text()
})
.then((text) => {
if (signal.aborted) return
// `get-it`'s `FetchResponse` only guarantees `.text()`/`.arrayBuffer()`,
// not `.json()`, so the body is parsed by hand here. An empty/aborted
// fall-through (`text === undefined`) and malformed JSON both leave
// `result` unresolved, taking the same "no signal either way" path.
const parsed: unknown = text === undefined ? undefined : JSON.parse(text)
const result = isRecord(parsed) ? parsed.result : undefined
// Check the credentialed case first: if the EventSource was about to
// send credentials but the project's CORS config doesn't permit them,
// the credentialed request would fail with a missing
// `access-control-allow-credentials` header. Surface this as a CORS
// rejection with `credentials: true` so the deep-link pre-selects the
// "Allow credentials" toggle.
if (requireCredentials && isRecord(result) && result.withCredentials === false) {
observer.error(new CorsOriginError({projectId, credentials: true}))
return
}
// Generic case: the server actively rejected this origin. Propagate
// `credentials: requireCredentials` so the deep-link still pre-selects
// credentials when the caller needed them.
if (isRecord(result) && result.allowed === false) {
observer.error(new CorsOriginError({projectId, credentials: requireCredentials}))
return
}
// Anything else (allowed + credentials satisfied, unrecognised body)
// is treated as "not a confirmed CORS rejection" - let the caller's
// original error surface instead.
observer.next()
observer.complete()
})
// Fetch/network/JSON parse errors are intentionally ignored - see the
// helper's docblock for the rationale. We still need to settle the
// observer so downstream `mergeMap(checkCors, ...)` consumers can proceed.
.catch(() => {
if (signal.aborted || observer.closed) return
observer.next()
observer.complete()
})
return () => controller.abort()
})
}
/**
* Cached observables capture their transport (`config.resolveFetch` and
* `config.proxy`) and, for draft streams, their credential source, so the
* cache is scoped per resolver (`undefined` covers the `globalThis.fetch`
* fallback), then per reactive `auth` observable (`null` for static and anonymous
* connections, whose credential is part of the string key instead).
*/
const eventsCache = new Map<
InitializedClientConfig['resolveFetch'],
Map<Observable<Promise<Auth>> | null, Map<string, Observable<LiveEvent>>>
>()
function getOrCreate<K, V>(cache: Map<K, V>, key: K, create: () => V): V {
const existing = cache.get(key)
if (existing) return existing
const created = create()
cache.set(key, created)
return created
}