Skip to content

Commit

Permalink
feat: add support for AbortController (#92)
Browse files Browse the repository at this point in the history
* test: add test asserting behavior of AbortSignal

* feat: implement support for AbortSignal

* docs: add examples of how to abort/cancel a request
  • Loading branch information
bjoerge authored Feb 2, 2023
1 parent f2ae579 commit 775b5b5
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 2 deletions.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1234,3 +1277,5 @@ await client.request<void>({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
54 changes: 52 additions & 2 deletions src/data/dataMethods.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -224,6 +224,7 @@ export function _dataRequest(
token,
tag,
canUseCdn: isQuery,
signal: options.signal,
}

return _requestObservable(client, httpRequest, reqOptions).pipe(
Expand Down Expand Up @@ -307,10 +308,12 @@ export function _requestObservable<R>(
})
) as RequestOptions

return new Observable<HttpRequestEvent<R>>((subscriber) =>
const request = new Observable<HttpRequestEvent<R>>((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
}

/**
Expand Down Expand Up @@ -356,3 +359,50 @@ export function _getUrl(
const base = canUseCdn ? cdnUrl : url
return `${base}/${uri.replace(/^\//, '')}`
}

/**
* @internal
*/
function _withAbortSignal<T>(signal: AbortSignal): MonoTypeOperatorFunction<T> {
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
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface RequestOptions {
method?: string
query?: FIXME
body?: FIXME
signal?: AbortSignal
}

/** @public */
Expand Down
25 changes: 25 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit 775b5b5

Please sign in to comment.