@sanity/client
Version:
Client for retrieving, creating and patching data from Sanity.io
291 lines (278 loc) • 11.2 kB
text/typescript
import {EventSource} from 'eventsource'
import type {FetchFunction} from 'get-it'
import {catchError, mergeMap, Observable, of, throwError} from 'rxjs'
import {finalize, map} from 'rxjs/operators'
import {CorsOriginError} from '../http/errors'
import type {ObservableSanityClient, SanityClient} from '../SanityClient'
import type {
InitializedClientConfig,
LiveEvent,
LiveEventGoAway,
LiveEventMessage,
LiveEventReconnect,
LiveEventRestart,
LiveEventWelcome,
SyncTag,
} from '../types'
import {isRecord} from '../util/isRecord'
import {shareReplayLatest} from '../util/shareReplayLatest'
import {_getDataUrl} from './dataMethods'
import {connectEventSource} from './eventsource'
import {reconnectOnConnectionFailure} from './reconnectOnConnectionFailure'
import {pickBaseFetch, resolveEventSourceFetch} from './resolveEventSourceFetch'
const requiredApiVersion = '2021-03-25'
/**
* @public
*/
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,
token,
withCredentials,
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.`,
)
}
if (includeDrafts && !token && !withCredentials) {
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)
}
const eventSourceHeaders: Record<string, string> = {}
if (includeDrafts && token) {
eventSourceHeaders.Authorization = `Bearer ${token}`
}
if (configHeaders) {
Object.assign(eventSourceHeaders, configHeaders)
}
const eventSourceWithCredentials = Boolean(includeDrafts && withCredentials)
let transportCache = eventsCache.get(config.resolveFetch)
if (!transportCache) {
transportCache = new Map()
eventsCache.set(config.resolveFetch, transportCache)
}
const cacheKey = JSON.stringify([
url.href,
typeof config.proxy === 'string' ? config.proxy : null,
eventSourceHeaders,
eventSourceWithCredentials,
])
const existing = transportCache.get(cacheKey)
if (existing) {
return existing
}
const initEventSource = () =>
new EventSource(url.href, {
fetch: resolveEventSourceFetch(config, {
headers: Object.keys(eventSourceHeaders).length ? eventSourceHeaders : undefined,
withCredentials: eventSourceWithCredentials,
}),
})
const events = connectEventSource(initEventSource, [
'message',
'restart',
'welcome',
'reconnect',
'goaway',
])
const checkCors = checkCorsObservable(
new URL(this.#client.getUrl('/check/cors', false)),
projectId,
eventSourceWithCredentials,
pickBaseFetch(config),
)
const observable = events
.pipe(
reconnectOnConnectionFailure(),
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) => {
// If a prior `reconnect` already ran the CORS probe and produced a
// `CorsOriginError`, just rethrow it instead of calling `/check/cors`
// a second time only to get the same answer.
if (err instanceof CorsOriginError) {
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(() => {
transportCache.delete(cacheKey)
if (transportCache.size === 0) eventsCache.delete(config.resolveFetch)
}),
shareReplayLatest({
predicate: (event) => event.type === 'welcome',
}),
)
transportCache.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,
requireCredentials: boolean,
fetcher: FetchFunction,
): Observable<void> {
return new Observable<void>((observer) => {
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 (`initEventSource` closes over
* `config.resolveFetch` and `config.proxy`), so the cache is scoped per
* resolver — `undefined` covers the `globalThis.fetch` fallback.
*/
const eventsCache = new Map<
InitializedClientConfig['resolveFetch'],
Map<string, Observable<LiveEvent>>
>()