diff --git a/API.md b/API.md index d1cc06c..41e421c 100644 --- a/API.md +++ b/API.md @@ -79,9 +79,11 @@ async function register(address, name) { Throws the error passed if it matches any of the specified rules where: - `err` - the error. - `type` - a single item or an array of items of: - - An error constructor (e.g. `SyntaxError`). + - An error constructor (e.g. `SyntaxError`) - matches error created from constructor or any subclass. - `'system'` - matches any languange native error or node assertions. - `'boom'` - matches [**boom**](https://github.com/hapijs/boom) errors. + - `'abort'` - matches an `AbortError`, as generated on an `AbortSignal` by `AbortController.abort()`. + - `'timeout'` - matches a `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. - an object where each property is compared with the error and must match the error property value. All the properties in the object must match the error but do not need to include all the error properties. @@ -90,6 +92,7 @@ Throws the error passed if it matches any of the specified rules where: - `override` - an error used to override `err` when `err` matches. If used with `decorate`, the `override` object is modified. - `return` - if `true`, the error is returned instead of thrown. Defaults to `false`. + - `signal` - an `AbortSignal`. Throws `signal.reason` if it has already been aborted. ### `ignore(err, types, [options])` @@ -124,3 +127,18 @@ Return `true` when `err` is one of: - `TypeError` - `URIError` - Node's `AssertionError` +- Hoek's `AssertError` + +### `isAbort(err)` + +Returns `true` when `err` is an `AbortError`, as generated by `AbortSignal.abort()`. + +Note that unlike other errors, `AbortError` cannot be considered a class in itself. +The best way to create a custom `AbortError` is with `new DOMException(message, 'AbortError')`. + +### `isTimeout(err)` + +Returns `true` when `err` is a `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. + +Note that unlike other errors, `TimeoutError` cannot be considered a class in itself. +The best way to create a custom `TimeoutError` is with `new DOMException(message, 'TimeoutError')`. diff --git a/lib/index.js b/lib/index.js index 7854ba1..f68b076 100755 --- a/lib/index.js +++ b/lib/index.js @@ -43,6 +43,10 @@ exports.ignore = function (err, types, options = {}) { internals.catch = function (err, types, options, match) { + if (options.signal?.aborted) { + throw options.signal.reason ?? new DOMException('This operation was aborted', 'AbortError'); + } + if (internals.match(err, types) !== match) { return; } @@ -115,9 +119,23 @@ exports.isSystem = function (err) { }; +exports.isAbort = function (err) { + + return err instanceof Error && err.name === 'AbortError'; +}; + + +exports.isTimeout = function (err) { + + return err instanceof Error && err.name === 'TimeoutError'; +}; + + internals.rules = { system: exports.isSystem, - boom: exports.isBoom + boom: exports.isBoom, + abort: exports.isAbort, + timeout: exports.isTimeout }; diff --git a/test/index.js b/test/index.js index 3f28ea4..7c5fa34 100755 --- a/test/index.js +++ b/test/index.js @@ -107,6 +107,52 @@ describe('Bounce', () => { expect(error3).to.be.an.error('Something', SyntaxError); }); + it('rethrows only abort errors', () => { + + try { + Bounce.rethrow(new Error('Something'), 'abort'); + } + catch (err) { + var error1 = err; + } + + expect(error1).to.not.exist(); + + try { + Bounce.rethrow(AbortSignal.abort().reason, 'abort'); + } + catch (err) { + var error2 = err; + } + + expect(error2).to.be.an.error(DOMException); + expect(error2.name).to.equal('AbortError'); + }); + + it('rethrows only timeout errors', async () => { + + try { + Bounce.rethrow(new Error('Something'), 'timeout'); + } + catch (err) { + var error1 = err; + } + + expect(error1).to.not.exist(); + + try { + const signal = AbortSignal.timeout(0); + await Hoek.wait(1); + Bounce.rethrow(signal.reason, 'timeout'); + } + catch (err) { + var error2 = err; + } + + expect(error2).to.be.an.error(DOMException); + expect(error2.name).to.equal('TimeoutError'); + }); + it('rethrows only specified errors', () => { try { @@ -240,6 +286,55 @@ describe('Bounce', () => { expect(error).to.shallow.equal(orig); expect(error).to.be.an.error('Something'); }); + + it('rethrows already aborted signal reason', () => { + + const orig = new Error('Something'); + const signal = AbortSignal.abort(new Error('Fail')); + + try { + Bounce.rethrow(orig, null, { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(signal.reason); + expect(error).to.be.an.error('Fail'); + }); + + it('rethrows already aborted signal with no reason', () => { + + const orig = new Error('Something'); + const signal = AbortSignal.abort(); + + Object.defineProperty(signal, 'reason', { value: undefined }); // Simulate older API without the reason property + + try { + Bounce.rethrow(orig, null, { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.be.an.error(DOMException, 'This operation was aborted'); + }); + + it('ignores non-aborted signal', () => { + + const orig = new Error('Something'); + const signal = new AbortController().signal; + + try { + Bounce.rethrow(orig, null, { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(orig); + expect(error).to.be.an.error('Something'); + }); }); describe('ignore()', () => { @@ -315,6 +410,38 @@ describe('Bounce', () => { expect(error3).to.not.exist(); }); + + it('rethrows already aborted signal reason', () => { + + const orig = new Error('Something'); + const signal = AbortSignal.abort(new Error('Fail')); + + try { + Bounce.ignore(orig, 'system', { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(signal.reason); + expect(error).to.be.an.error('Fail'); + }); + + it('ignores non-aborted signal', () => { + + const orig = new Error('Something'); + const signal = new AbortController().signal; + + try { + Bounce.ignore(orig, 'system', { signal }); + } + catch (err) { + var error = err; + } + + expect(error).to.shallow.equal(orig); + expect(error).to.be.an.error('Something'); + }); }); describe('background()', () => { @@ -537,4 +664,87 @@ describe('Bounce', () => { expect(Bounce.isSystem(Boom.boomify(new TypeError()))).to.be.false(); }); }); + + describe('isAbort()', () => { + + it('identifies AbortSignal.abort() reason as abort', () => { + + expect(Bounce.isAbort(AbortSignal.abort().reason)).to.be.true(); + }); + + it('identifies DOMException AbortError as abort', () => { + + expect(Bounce.isAbort(new DOMException('aborted', 'AbortError'))).to.be.true(); + }); + + it('identifies Error with name "AbortError" as abort', () => { + + class MyAbort extends Error { + name = 'AbortError'; + } + + expect(Bounce.isAbort(new MyAbort())).to.be.true(); + }); + + it('identifies object as non-abort', () => { + + expect(Bounce.isAbort({})).to.be.false(); + }); + + it('identifies error as non-abort', () => { + + expect(Bounce.isAbort(new Error('failed'))).to.be.false(); + }); + + it('identifies object with name "AbortError" as non-abort', () => { + + expect(Bounce.isAbort({ name: 'AbortError' })).to.be.false(); + }); + + it('identifies AbortSignal.timeout() reason non-abort', async () => { + + const signal = AbortSignal.timeout(0); + await Hoek.wait(1); + expect(Bounce.isAbort(signal.reason)).to.be.false(); + }); + }); + + describe('isTimeout()', () => { + + it('identifies AbortSignal.timeout() reason as timeout', async () => { + + const signal = AbortSignal.timeout(0); + await Hoek.wait(1); + expect(Bounce.isTimeout(signal.reason)).to.be.true(); + }); + + it('identifies DOMException TimeoutError as timeout', () => { + + expect(Bounce.isTimeout(new DOMException('timed out', 'TimeoutError'))).to.be.true(); + }); + + it('identifies Error with name "TimeoutError" as timeout', () => { + + class MyTimeout extends Error { + name = 'TimeoutError'; + } + + expect(Bounce.isTimeout(new MyTimeout())).to.be.true(); + }); + + it('identifies object as non-timeout', () => { + + expect(Bounce.isTimeout({})).to.be.false(); + }); + + it('identifies error as non-timeout', () => { + + expect(Bounce.isTimeout(new Error('failed'))).to.be.false(); + }); + + it('identifies object with name "TimeoutError" as non-timeout', () => { + + expect(Bounce.isTimeout({ name: 'TimeoutError' })).to.be.false(); + }); + }); });