From 2690ce5f579492a5ccff3ddd809b4aee5579f76f Mon Sep 17 00:00:00 2001 From: Gil Pedersen Date: Thu, 30 Nov 2023 17:23:14 +0100 Subject: [PATCH 1/3] Support AbortSignal --- API.md | 14 +++- lib/index.js | 18 ++++- test/index.js | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index d1cc06c..f5c08a1 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 an `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,12 @@ 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 on an `AbortSignal` by `AbortController.abort()`. + +### `isTimeout(err)` + +Returns `true` when `err` is an `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. diff --git a/lib/index.js b/lib/index.js index 7854ba1..ce1d84f 100755 --- a/lib/index.js +++ b/lib/index.js @@ -43,6 +43,8 @@ exports.ignore = function (err, types, options = {}) { internals.catch = function (err, types, options, match) { + options.signal?.throwIfAborted(); + if (internals.match(err, types) !== match) { return; } @@ -115,9 +117,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..6850509 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,38 @@ 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('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 +393,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 +647,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(); + }); + }); }); From 3d130e77078ba71fd0a742170e632a5b6443b103 Mon Sep 17 00:00:00 2001 From: Gil Pedersen Date: Thu, 30 Nov 2023 18:27:39 +0100 Subject: [PATCH 2/3] Make signal rethrow more web-compatible --- lib/index.js | 4 +++- test/index.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index ce1d84f..f68b076 100755 --- a/lib/index.js +++ b/lib/index.js @@ -43,7 +43,9 @@ exports.ignore = function (err, types, options = {}) { internals.catch = function (err, types, options, match) { - options.signal?.throwIfAborted(); + if (options.signal?.aborted) { + throw options.signal.reason ?? new DOMException('This operation was aborted', 'AbortError'); + } if (internals.match(err, types) !== match) { return; diff --git a/test/index.js b/test/index.js index 6850509..7c5fa34 100755 --- a/test/index.js +++ b/test/index.js @@ -303,6 +303,23 @@ describe('Bounce', () => { 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'); From 189747a3879ec83faa62f185146ad60a4789da26 Mon Sep 17 00:00:00 2001 From: Gil Pedersen Date: Wed, 23 Oct 2024 16:14:02 +0200 Subject: [PATCH 3/3] Cleanup docs --- API.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index f5c08a1..41e421c 100644 --- a/API.md +++ b/API.md @@ -83,7 +83,7 @@ Throws the error passed if it matches any of the specified rules where: - `'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 an `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. + - `'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. @@ -131,8 +131,14 @@ Return `true` when `err` is one of: ### `isAbort(err)` -Returns `true` when `err` is an `AbortError`, as generated on an `AbortSignal` by `AbortController.abort()`. +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 an `TimeoutError`, as generated by `AbortSignal.timeout(delay)`. +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')`.