diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61af41a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +yarn build # Transpile src/ to dist/ with Babel +yarn test # Run Jest test suite +yarn test -t "name" # Run a single test by name pattern +yarn test --watch # Run tests in watch mode +yarn lint # Run ESLint + Prettier format check +yarn format # Auto-format src/ with Prettier +``` + +Docker-based dev environment (requires Docker + make): +```bash +make dev-setup dev-build # Initial setup +make dev-test # Run tests in Docker +make dev-test-watch # Watch-mode tests in Docker +``` + +## Architecture + +`@hubble/request` is a lightweight, universal HTTP client wrapping the Fetch API. It targets both Node and browser environments via `isomorphic-unfetch`. + +**Request flow:** +1. `src/request.js` — core function; encodes the request body (JSON-stringifies objects, converts GET body to query params), calls `fetch`, then parses the response based on `Content-Type` +2. On non-2xx status → throws `HttpError` (with body, statusCode, response, extra) +3. On network failure → throws `NetworkError` (with request, exception, extra) +4. On abort → throws `AbortError` (with request, exception) +5. On success → returns `ValidResponse` (with body, statusCode, response) + +**Exports (`src/index.js`):** `request` (default), `AbortError`, `HttpError`, `NetworkError`, `ValidResponse`, `httpMethods`, `httpStatuses` + +**Error distinction:** `HttpError` = server responded with 4xx/5xx. `NetworkError` = no response (DNS failure, timeout, etc.). `AbortError` = request cancelled via `request().abort()`. + +**Aborting requests:** `request()` returns a promise with an `abort()` method. Calling it cancels the underlying fetch via an internal `AbortController` and rejects the promise with `AbortError`. diff --git a/src/AbortError.js b/src/AbortError.js new file mode 100644 index 0000000..d7844a9 --- /dev/null +++ b/src/AbortError.js @@ -0,0 +1,35 @@ +/** + * An error resulting from a request being aborted via `request().abort()`. + * @memberof module:request + */ +class AbortError extends Error { + /** + * @param {Error} exception - the original AbortError thrown by fetch + * @param {object} request - the request url along with any options passed along + * @param {...any} args - further arguments to pass to `Error` + */ + constructor(exception, request, ...args) { + super(...args); + + this.internalRequest = request; + this.internalException = exception; + } + + /** + * Information about the aborted request, like the URL and headers + * @type {object} + */ + get request() { + return this.internalRequest; + } + + /** + * The original exception that was thrown. + * @type {Error} + */ + get exception() { + return this.internalException; + } +} + +export default AbortError; diff --git a/src/__tests__/request.test.js b/src/__tests__/request.test.js index 364118d..5b12378 100644 --- a/src/__tests__/request.test.js +++ b/src/__tests__/request.test.js @@ -1,3 +1,4 @@ +import AbortError from '../AbortError'; import HttpError from '../HttpError'; import request from '../request'; import NetworkError from '../NetworkError'; @@ -443,4 +444,51 @@ describe('core : helpers : request : ', () => { await expect(request(url)).rejects.toBeInstanceOf(NetworkError); }); }); + + describe('abort : ', () => { + it('should expose an abort method on the returned promise', () => { + fetch.mockResponse('sample response - value is irrelevant'); + const req = request(url); + expect(typeof req.abort).toBe('function'); + }); + + it('should reject with an AbortError when aborted', async () => { + const abortError = new DOMException( + 'The user aborted a request.', + 'AbortError', + ); + fetch.mockReject(abortError); + + const req = request(url); + req.abort(); + + await expect(req).rejects.toBeInstanceOf(AbortError); + }); + + it('should include the exception on the AbortError', async () => { + const abortError = new DOMException( + 'The user aborted a request.', + 'AbortError', + ); + fetch.mockReject(abortError); + + const req = request(url); + req.abort(); + + await expect(req).rejects.toHaveProperty('exception', abortError); + }); + + it('should not throw a NetworkError when aborted', async () => { + const abortError = new DOMException( + 'The user aborted a request.', + 'AbortError', + ); + fetch.mockReject(abortError); + + const req = request(url); + req.abort(); + + await expect(req).rejects.not.toBeInstanceOf(NetworkError); + }); + }); }); diff --git a/src/index.js b/src/index.js index 53f77b3..59e37da 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ export { default } from './request'; +export { default as AbortError } from './AbortError'; export { default as HttpError } from './HttpError'; export { default as NetworkError } from './NetworkError'; export { default as method } from './httpMethods'; diff --git a/src/request.js b/src/request.js index 2c7082b..9bd2d40 100644 --- a/src/request.js +++ b/src/request.js @@ -1,5 +1,6 @@ import 'isomorphic-unfetch'; +import AbortError from './AbortError'; import HttpError from './HttpError'; import NetworkError from './NetworkError'; import ValidResponse from './ValidResponse'; @@ -137,10 +138,14 @@ const request = ( body = undefined, opts = {}, ) => { - const sendableOptions = getOptions(method, body, opts); + const controller = new AbortController(); + const sendableOptions = getOptions(method, body, { + ...opts, + signal: controller.signal, + }); const url = getUrl(urlArg, method, body); - return fetch(url, sendableOptions).then( + const promise = fetch(url, sendableOptions).then( (response) => { if (!response.ok) { return createError(response).then((error) => Promise.reject(error)); @@ -149,6 +154,16 @@ const request = ( } }, (error) => { + if (error.name === 'AbortError') { + return Promise.reject( + new AbortError( + error, + { url, ...sendableOptions }, + 'The request was aborted.', + ), + ); + } + return Promise.reject( new NetworkError( error, @@ -158,6 +173,10 @@ const request = ( ); }, ); + + promise.abort = () => controller.abort(); + + return promise; }; export default request;