UNPKG

@sanity/client

Version:

Client for retrieving, creating and patching data from Sanity.io

291 lines (278 loc) 11.2 kB
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>> >()