diff --git a/README.md b/README.md index b2764efd..79ff075a 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,49 @@ The following options are available for mutations, and can be applied either as - `dryRun` (`true|false`) - default `false`. If true, the mutation will be a dry run - the response will be identical to the one returned had this property been omitted or false (including error responses) but no documents will be affected. - `autoGenerateArrayKeys` (`true|false`) - default `false`. If true, the mutation API will automatically add `_key` attributes to objects in arrays that is missing them. This makes array operations more robust by having a unique key within the array available for selections, which helps prevent race conditions in real-time, collaborative editing. +### Aborting a request + +Requests can be aborted (or cancelled) in two ways: + +#### 1. Abort a request by passing an [AbortSignal] with the request options + +Sanity Client supports the [AbortController] API and supports receiving an abort signal that can be used to cancel the request. Here's an example that will abort the request if it takes more than 200ms to complete: + +```js +const abortController = new AbortController() + +// note the lack of await here +const request = getClient().fetch('*[_type == "movie"]', {}, {signal: abortController.signal}) + +// this will abort the request after 200ms +setTimeout(() => abortController.abort(), 200) + +try { + const response = await request + //… +} catch (error) { + if (error.name === 'AbortError') { + console.log('Request was aborted') + } else { + // rethrow in case of other errors + throw error + } +} +``` + +#### 2. Cancel a request by unsubscribing from the Observable + +When using the Observable API (e.g. `client.observable.fetch()`), you can cancel the request by simply `unsubscribe` from the returned observable: + +```js +const subscription = client.observable.fetch('*[_type == "movie"]').subscribe((result) => { + /* do something with the result */ +}) + +// this will cancel the request +subscription.unsubscribe() +``` + ### Get client configuration ```js @@ -1234,3 +1277,5 @@ await client.request({uri: '/auth/logout', method: 'POST'}) [api-versioning]: http://sanity.io/docs/api-versioning [zod]: https://zod.dev/ [groqd]: https://github.com/FormidableLabs/groqd#readme +[AbortSignal]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal +[AbortController]: https://developer.mozilla.org/en-US/docs/Web/API/AbortController diff --git a/src/data/dataMethods.ts b/src/data/dataMethods.ts index cdfc24e4..157b7cb0 100644 --- a/src/data/dataMethods.ts +++ b/src/data/dataMethods.ts @@ -1,4 +1,4 @@ -import {Observable} from 'rxjs' +import {type MonoTypeOperatorFunction, Observable} from 'rxjs' import {filter, map} from 'rxjs/operators' import getRequestOptions from '../http/requestOptions' @@ -224,6 +224,7 @@ export function _dataRequest( token, tag, canUseCdn: isQuery, + signal: options.signal, } return _requestObservable(client, httpRequest, reqOptions).pipe( @@ -307,10 +308,12 @@ export function _requestObservable( }) ) as RequestOptions - return new Observable>((subscriber) => + const request = new Observable>((subscriber) => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the typings thinks it's optional because it's not required to specify it when calling createClient, but it's always defined in practice since SanityClient provides a default httpRequest(reqOptions, config.requester!).subscribe(subscriber) ) + + return options.signal ? request.pipe(_withAbortSignal(options.signal)) : request } /** @@ -356,3 +359,50 @@ export function _getUrl( const base = canUseCdn ? cdnUrl : url return `${base}/${uri.replace(/^\//, '')}` } + +/** + * @internal + */ +function _withAbortSignal(signal: AbortSignal): MonoTypeOperatorFunction { + return (input) => { + return new Observable((observer) => { + const abort = () => observer.error(_createAbortError(signal)) + + if (signal && signal.aborted) { + abort() + return + } + const subscription = input.subscribe(observer) + signal.addEventListener('abort', abort) + return () => { + signal.removeEventListener('abort', abort) + subscription.unsubscribe() + } + }) + } +} +// DOMException is supported on most modern browsers and Node.js 18+. +// @see https://developer.mozilla.org/en-US/docs/Web/API/DOMException#browser_compatibility +const isDomExceptionSupported = Boolean(globalThis.DOMException) + +/** + * @internal + * @param signal + * Original source copied from https://github.com/sindresorhus/ky/blob/740732c78aad97e9aec199e9871bdbf0ae29b805/source/errors/DOMException.ts + * TODO: When targeting Node.js 18, use `signal.throwIfAborted()` (https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted) + */ +function _createAbortError(signal?: AbortSignal) { + /* + NOTE: Use DomException with AbortError name as specified in MDN docs (https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort) + > When abort() is called, the fetch() promise rejects with an Error of type DOMException, with name AbortError. + */ + if (isDomExceptionSupported) { + return new DOMException(signal?.reason ?? 'The operation was aborted.', 'AbortError') + } + + // DOMException not supported. Fall back to use of error and override name. + const error = new Error(signal?.reason ?? 'The operation was aborted.') + error.name = 'AbortError' + + return error +} diff --git a/src/types.ts b/src/types.ts index d8947be3..4ebaf5c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface RequestOptions { method?: string query?: FIXME body?: FIXME + signal?: AbortSignal } /** @public */ diff --git a/test/client.test.ts b/test/client.test.ts index 180c977f..d0f71b44 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -447,6 +447,31 @@ describe('client', async () => { } }) + test.skipIf(isEdge || typeof globalThis.AbortController === 'undefined')( + 'can cancel request with an abort controller signal', + async () => { + expect.assertions(2) + + nock(projectHost()).get(`/v1/data/query/foo?query=*`).delay(100).reply(200, { + ms: 123, + q: '*', + result: [], + }) + + const abortController = new AbortController() + const fetch = getClient().fetch('*', {}, {signal: abortController.signal}) + await new Promise((resolve) => setTimeout(resolve, 10)) + + try { + abortController.abort() + await fetch + } catch (err: any) { + expect(err).toBeInstanceOf(Error) + expect(err.name, 'should throw AbortError').toBe('AbortError') + } + } + ) + test.skipIf(isEdge)('can query for single document', async () => { nock(projectHost()) .get('/v1/data/doc/foo/abc123')