Skip to content

Commit eb86c90

Browse files
authored
feat: Add auto-abort of HTTP requests (#29)
1 parent 1a0199f commit eb86c90

File tree

4 files changed

+255
-27
lines changed

4 files changed

+255
-27
lines changed

README.md

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ This package:
99
+ Does **not** throw on non-OK HTTP responses.
1010
+ **Can fully type all possible HTTP responses depending on the HTTP status code, even non-standard ones like 499.**
1111
+ **Supports abortable HTTP requests; no boilerplate.**
12+
+ **Can Auto-abort HTTP requests in favor of newer request versions.**
1213
+ Works in any runtime that implements `fetch()` (browsers, NodeJS, etc.).
13-
+ Probably the tiniest fetch wrapper you'll ever need.
14+
+ Is probably the tiniest fetch wrapper you'll ever need: **342 LOC** including typing (`npx cloc .\src --exclude-dir=tests`).
1415

1516
## Does a Non-OK Status Code Warrant an Error?
1617

@@ -262,7 +263,7 @@ export const rootFetcher new DrFetch(myFetch)
262263
export const abortableRootFetcher = rootFetcher.clone().abortable();
263264
```
264265

265-
We clone it because `abortable()` has permanent side effects on the object's state. Cloning also helps with other
266+
We clone it because `abortable()` has permanent side effects on the object's state. Cloning can also help with other
266267
scenarios, as explained next.
267268

268269
### Specializing the Root Fetcher
@@ -300,10 +301,88 @@ const specialFetcher = rootFetcher.clone({ preserveAbortable: false });
300301
> `preserveTyping` is a TypeScript trick and cannot be a variable of type `boolean`. Its value doesn't matter in
301302
> runtime because types are not a runtime thing, and TypeScript depends on knowing if the value is `true` or `false`.
302303
>
303-
> On the other hand, `preserveAbortable` is a hybrid: It uses the same TypeScript trick, but its value does matter in
304-
> runtime because an abortable fetcher object has different inner state than a stock fetcher object. In this sense,
305-
> supporting a variable would be ideal, but there's just no way to properly reconcile the TypeScript side with a
306-
> variable of type `boolean`. Therefore, try to always use constant values.
304+
> On the other hand, `preserveAbortable` (since **v0.9.0**) is a hybrid: It uses the same TypeScript trick, but its
305+
> value does matter in runtime because an abortable fetcher object has different inner state than a stock fetcher
306+
> object. In this sense, supporting a variable would be ideal, but there's just no way to properly reconcile the
307+
> TypeScript side with a variable of type `boolean`. Therefore, try to always use constant values.
308+
309+
## Auto-Abortable HTTP Requests
310+
311+
> Since **v0.9.0**
312+
313+
An HTTP request can automatically abort whenever a new version of the HTTP request is executed. This is useful in
314+
cases like server-sided autocomplete components, where an HTTP request is made every time a user stops typing in the
315+
search textbox. As soon as a new HTTP request is made, the previous has no value. With `dr-fetch`, this chore is
316+
fully automated.
317+
318+
To illustrate, this is how it would be done "by hand", as if auto-abortable wasn't a feature:
319+
320+
```typescript
321+
import { abortableRootFetcher } from './root-fetchers.js';
322+
import type { SimpleItem } from './my-types.js';
323+
324+
let ac: AbortController;
325+
326+
async function fetchAutocompleteList(searchTerm: string) {
327+
ac?.abort();
328+
ac = new AbortController();
329+
const response = await abortableRootFetcher
330+
.for<200, SimpleItem[]>()
331+
.get(`/my/data?s=${searchTerm}`, { signal: ac.signal });
332+
if (!response.aborted) {
333+
...
334+
}
335+
}
336+
```
337+
338+
While this is not too bad, it can actually be like this:
339+
340+
```typescript
341+
import { abortableRootFetcher } from './root-fetchers.js';
342+
import type { SimpleItem } from './my-types.js';
343+
344+
async function fetchAutocompleteList(searchTerm: string) {
345+
const response = await abortableRootFetcher
346+
.for<200, SimpleItem[]>()
347+
.get(`/my/data?s=${searchTerm}`, { autoAbort: 'my-key' });
348+
if (!response.aborted) {
349+
...
350+
}
351+
}
352+
```
353+
354+
> [!NOTE]
355+
> The key can be a string, a number or a unique symbol. Keys are not shared between fetcher instances, and cloning
356+
> does not clone any existing keys.
357+
358+
`DrFetch` instances create and keep track of abort controllers by key. All one must do is provide a key when starting
359+
the HTTP request. Furthermore, the abort controllers are disposed as soon as the HTTP request resolves or rejects.
360+
361+
### Delaying an Auto-Abortable HTTP Request
362+
363+
Aborting the HTTP request (the call to `fetch()`) is usually not the only thing that front-end developers do in cases
364+
like the autocomplete component. Developers usually also debounce the action of making the HTTP request for a short
365+
period of time (around 500 milliseconds).
366+
367+
You can do this very easily as well with `dr-fetch`. There is no need to program the debouncing externally.
368+
369+
This is the previous example, with a delay specified:
370+
371+
```typescript
372+
import { abortableRootFetcher } from './root-fetchers.js';
373+
import type { SimpleItem } from './my-types.js';
374+
375+
async function fetchAutocompleteList(searchTerm: string) {
376+
const response = await abortableRootFetcher
377+
.for<200, SimpleItem[]>()
378+
.get(`/my/data?s=${searchTerm}`, { autoAbort: { key: 'my-key', delay: 500 }});
379+
if (!response.aborted) {
380+
...
381+
}
382+
}
383+
```
384+
385+
By using the object form of `autoAbort`, one can specify the desired delay, in milliseconds.
307386

308387
## Shortcut Functions
309388

src/DrFetch.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { aborted } from "util";
2-
import type { BodyParserFn, CloneOptions, FetchFn, FetchFnInit, FetchFnUrl, FetchResult, StatusCode } from "./types.js";
2+
import type { AutoAbortKey, BodyParserFn, CloneOptions, FetchFn, FetchFnInit, FetchFnUrl, FetchResult, StatusCode } from "./types.js";
33
import { hasHeader, setHeaders } from "./headers.js";
44

55
/**
@@ -109,8 +109,8 @@ function textParser(response: Response) {
109109
export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abortable extends boolean = false> {
110110
#fetchFn: FetchFn;
111111
#customProcessors: [string | RegExp, (response: Response, stockParsers: { json: BodyParserFn<any>; text: BodyParserFn<string>; }) => Promise<any>][] = [];
112-
#fetchImpl: Function;
113-
#isAbortable: boolean = false;
112+
#fetchImpl: (url: FetchFnUrl, init?: FetchFnInit) => Promise<any>;
113+
#autoAbortMap: Map<AutoAbortKey, AbortController> | undefined;
114114

115115
async #abortableFetch(url: FetchFnUrl, init?: FetchFnInit) {
116116
try {
@@ -178,6 +178,15 @@ export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abort
178178
this.#fetchImpl = this.#simpleFetch.bind(this);
179179
}
180180

181+
/**
182+
* Gets a Boolean value indicating whether this fetcher object is in abortable mode or not.
183+
*
184+
* **NOTE**: Once in abortable mode, the fetcher object cannot be reverted to non-abortable mode.
185+
*/
186+
get isAbortable() {
187+
return !!this.#autoAbortMap;
188+
}
189+
181190
/**
182191
* Clones this fetcher object by creating a new fetcher object with the same data-fetching function, custom
183192
* body processors, and data typing unless specified otherwise via the options parameter.
@@ -198,7 +207,7 @@ export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abort
198207
if (opts.includeProcessors) {
199208
newClone.#customProcessors = [...this.#customProcessors];
200209
}
201-
if (opts.preserveAbortable && this.#isAbortable) {
210+
if (opts.preserveAbortable && this.isAbortable) {
202211
newClone.abortable();
203212
}
204213
return newClone as DrFetch<TStatusCode, TInherit extends true ? T : unknown, CloneAbortable>;
@@ -281,7 +290,7 @@ export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abort
281290

282291
abortable() {
283292
this.#fetchImpl = this.#abortableFetch.bind(this);
284-
this.#isAbortable = true;
293+
this.#autoAbortMap ??= new Map<AutoAbortKey, AbortController>();
285294
return this as DrFetch<TStatusCode, T, true>;
286295
}
287296

@@ -292,11 +301,38 @@ export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abort
292301
* @param init Options for the data-fetching function.
293302
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
294303
*/
295-
fetch(url: FetchFnUrl, init?: FetchFnInit) {
296-
return this.#fetchImpl(url, init) as (Abortable extends true ? Promise<{
297-
aborted: true;
298-
error: DOMException;
299-
} | T> : Promise<T>);
304+
async fetch(url: FetchFnUrl, init?: FetchFnInit): Promise<(Abortable extends true ? {
305+
aborted: true;
306+
error: DOMException;
307+
} | T : T)> {
308+
if (!this.#autoAbortMap && init?.autoAbort) {
309+
throw new Error('Cannot use autoAbort if the fetcher is not in abortable mode. Call "abortable()" first.');
310+
}
311+
const autoAbort = {
312+
key: typeof init?.autoAbort === 'object' ? init.autoAbort.key : init?.autoAbort,
313+
delay: typeof init?.autoAbort === 'object' ? init.autoAbort.delay : undefined,
314+
};
315+
if (autoAbort.key) {
316+
this.#autoAbortMap?.get(autoAbort.key)?.abort();
317+
const ac = new AbortController();
318+
this.#autoAbortMap!.set(autoAbort.key, ac);
319+
init ??= {};
320+
init.signal = ac.signal;
321+
if (autoAbort.delay !== undefined) {
322+
const aborted = await new Promise<boolean>((rs) => {
323+
setTimeout(() => rs(ac.signal.aborted), autoAbort.delay);
324+
});
325+
if (aborted) {
326+
// @ts-expect-error TS2322: A runtime check is in place to ensure that the type is correct.
327+
return {
328+
aborted: true,
329+
error: new DOMException('Aborted while delayed.', 'AbortError')
330+
};
331+
}
332+
}
333+
}
334+
return await this.#fetchImpl(url, init)
335+
.finally(() => autoAbort.key && this.#autoAbortMap?.delete(autoAbort.key));
300336
}
301337

302338
#createInit(body: BodyInit | null | Record<string, any> | undefined, init?: FetchFnInit) {
@@ -319,8 +355,7 @@ export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abort
319355
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
320356
*/
321357
get(url: URL | string, init?: Omit<FetchFnInit, 'method' | 'body'>) {
322-
init = { ...init, method: 'GET' };
323-
return this.fetch(url, init);
358+
return this.fetch(url, { ...init, method: 'GET' });
324359
}
325360

326361
/**
@@ -329,8 +364,7 @@ export class DrFetch<TStatusCode extends number = StatusCode, T = unknown, Abort
329364
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
330365
*/
331366
head(url: URL | string, init?: Omit<FetchFnInit, 'method' | 'body'>) {
332-
init = { ...init, method: 'HEAD' };
333-
return this.fetch(url, init);
367+
return this.fetch(url, { ...init, method: 'HEAD' });
334368
}
335369

336370
/**

src/tests/DrFetch.test.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect } from "chai";
22
import { describe, test } from "mocha";
33
import { fake } from 'sinon';
44
import { DrFetch } from "../DrFetch.js";
5-
import type { StatusCode } from "../types.js";
5+
import type { FetchFnInit, FetchFnUrl, StatusCode } from "../types.js";
66
import { getHeader } from "../headers.js";
77

88
const shortcutMethodsWithBody = [
@@ -261,7 +261,99 @@ describe('DrFetch', () => {
261261
// Assert.
262262
expect(processorFn.calledOnce).to.be.true;
263263
});
264-
})
264+
});
265+
test("Should throw and error if 'autoAbort' is used without calling 'abortable()'.", async () => {
266+
// Arrange.
267+
const fetchFn = fake.resolves(new Response(null));
268+
const fetcher = new DrFetch(fetchFn);
269+
let didThrow = false;
270+
271+
// Act.
272+
try {
273+
await fetcher.fetch('x', { autoAbort: 'abc' });
274+
}
275+
catch {
276+
didThrow = true;
277+
}
278+
279+
// Assert.
280+
expect(didThrow).to.be.true;
281+
});
282+
test("Should not throw an error if 'autoAbort' is used with 'abortable()'.", async () => {
283+
// Arrange.
284+
const fetchFn = fake.resolves(new Response(null));
285+
const fetcher = new DrFetch(fetchFn).abortable();
286+
let didThrow = false;
287+
288+
// Act.
289+
try {
290+
await fetcher.fetch('x', { autoAbort: 'abc' });
291+
}
292+
catch {
293+
didThrow = true;
294+
}
295+
296+
// Assert.
297+
expect(didThrow).to.be.false;
298+
});
299+
[
300+
{
301+
autoAbort: 'abc',
302+
text: 'a string',
303+
},
304+
{
305+
autoAbort: { key: 'abc' },
306+
text: 'an object',
307+
},
308+
].forEach(tc => {
309+
test(`Should abort the previous HTTP request whenever 'autoAbort' is used as ${tc.text}.`, async () => {
310+
// Arrange.
311+
const fetchFn = fake((url: FetchFnUrl, init?: FetchFnInit) => new Promise<Response>((rs, rj) => setTimeout(() => {
312+
if (init?.signal?.aborted) {
313+
rj(new DOMException('Test: Aborted.', 'AbortError'));
314+
}
315+
rs(new Response(null));
316+
}, 0)));
317+
const fetcher = new DrFetch(fetchFn).abortable().for<StatusCode, {}>();
318+
const request1 = fetcher.fetch('x', { autoAbort: tc.autoAbort });
319+
const request2 = fetcher.fetch('y', { autoAbort: tc.autoAbort });
320+
321+
// Act.
322+
const response = await request1;
323+
324+
// Assert.
325+
expect(fetchFn.calledTwice).to.be.true;
326+
expect(response.aborted).to.be.true;
327+
expect(response.aborted && response.error).to.be.instanceOf(DOMException);
328+
expect(response.aborted && response.error.name).to.equal('AbortError');
329+
330+
// Clean up.
331+
await request2;
332+
});
333+
});
334+
test("Should delay the HTTP request whenever a delay is specified.", async () => {
335+
// Arrange.
336+
const fetchFn = fake((url: FetchFnUrl, init?: FetchFnInit) => new Promise<Response>((rs, rj) => setTimeout(() => {
337+
if (init?.signal?.aborted) {
338+
rj(new DOMException('Test: Aborted.', 'AbortError'));
339+
}
340+
rs(new Response(null));
341+
}, 0)));
342+
const fetcher = new DrFetch(fetchFn).abortable().for<StatusCode, {}>();
343+
const request1 = fetcher.fetch('x', { autoAbort: { key: 'abc', delay: 0 } });
344+
const request2 = fetcher.fetch('y', { autoAbort: { key: 'abc', delay: 0 } });
345+
// Act.
346+
const response = await request1;
347+
348+
// Assert.
349+
expect(fetchFn.called, 'Fetch was called.').to.be.false;
350+
expect(response.aborted, 'Response is not aborted.').to.be.true;
351+
expect(response.aborted && response.error).to.be.instanceOf(DOMException);
352+
expect(response.aborted && response.error.name).to.equal('AbortError');
353+
354+
// Clean up.
355+
await request2;
356+
});
265357
});
266358
describe('Shortcut Functions', () => {
267359
allShortcutMethods.map(x => ({
@@ -377,11 +469,11 @@ describe('DrFetch', () => {
377469
const init = {
378470
headers: { 'x-test': 'abc' },
379471
signal: new AbortController().signal,
380-
mode: 'cors',
381-
credentials: 'include',
382-
redirect: 'follow',
472+
mode: 'cors' as const,
473+
credentials: 'include' as const,
474+
redirect: 'follow' as const,
383475
referrer: 'test',
384-
referrerPolicy: 'no-referrer',
476+
referrerPolicy: 'no-referrer' as const,
385477
integrity: 'sha256-abc'
386478
};
387479

src/types.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,33 @@ export type FetchFn = typeof fetch;
6161
*/
6262
export type FetchFnUrl = Parameters<FetchFn>[0];
6363

64+
/**
65+
* Possible types of keys accepted by the `autoAbort` option.
66+
*/
67+
export type AutoAbortKey = string | symbol | number;
68+
6469
/**
6570
* Type of the fetch function's init parameter.
6671
*/
67-
export type FetchFnInit = Parameters<FetchFn>[1];
72+
export type FetchFnInit = Parameters<FetchFn>[1] & {
73+
/**
74+
* Specifies the options for auto-aborting the HTTP request.
75+
*
76+
* If a string is provided, it will be used as the key for the abort signal; provide an object to specify further
77+
* options.
78+
*/
79+
autoAbort?: AutoAbortKey | {
80+
/**
81+
* The key used to identify the abort signal.
82+
*/
83+
key: AutoAbortKey;
84+
/**
85+
* The amount of time (in milliseconds) to wait before emitting the request. If not specified, then no delay
86+
* is applied.
87+
*/
88+
delay?: number;
89+
};
90+
};
6891

6992
/**
7093
* Fetcher cloning options.

0 commit comments

Comments
 (0)