├── .github ├── dependabot.yml └── workflows │ ├── js-test-and-release.yml │ ├── semantic-pull-request.yml │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── package.json ├── src ├── abort-error.ts └── index.ts ├── test └── index.spec.ts ├── tsconfig.json └── typedoc.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 20 9 | commit-message: 10 | prefix: "deps" 11 | prefix-development: "deps(dev)" 12 | -------------------------------------------------------------------------------- /.github/workflows/js-test-and-release.yml: -------------------------------------------------------------------------------- 1 | name: test & maybe release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: write 12 | id-token: write 13 | packages: write 14 | pull-requests: write 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | js-test-and-release: 22 | uses: ipdxco/unified-github-workflows/.github/workflows/js-test-and-release.yml@v1.0 23 | secrets: 24 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 25 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | UCI_GITHUB_TOKEN: ${{ secrets.UCI_GITHUB_TOKEN }} 28 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Semantic PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | uses: pl-strflt/.github/.github/workflows/reusable-semantic-pull-request.yml@v0.3 13 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close and mark stale issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | stale: 13 | uses: pl-strflt/.github/.github/workflows/reusable-stale-issue.yml@v0.3 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | .docs 5 | .coverage 6 | node_modules 7 | package-lock.json 8 | yarn.lock 9 | .vscode 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [5.1.0](https://github.com/alanshaw/abortable-iterator/compare/v5.0.1...v5.1.0) (2024-08-07) 2 | 3 | ### Features 4 | 5 | * add `abortName` option to override thrown error name ([#98](https://github.com/alanshaw/abortable-iterator/issues/98)) ([e43efdf](https://github.com/alanshaw/abortable-iterator/commit/e43efdf2fc187d6a325f59c31d2348f1b1269ab0)) 6 | 7 | ### Bug Fixes 8 | 9 | * update project config ([#99](https://github.com/alanshaw/abortable-iterator/issues/99)) ([c6660f5](https://github.com/alanshaw/abortable-iterator/commit/c6660f53104c16cad6a608dc42d5a9e91443bcc9)) 10 | 11 | ### Trivial Changes 12 | 13 | * add or force update .github/workflows/js-test-and-release.yml ([#95](https://github.com/alanshaw/abortable-iterator/issues/95)) ([b4aeafa](https://github.com/alanshaw/abortable-iterator/commit/b4aeafa15d1b0bc22424f05f31691757c26ad178)) 14 | * delete templates [skip ci] ([#94](https://github.com/alanshaw/abortable-iterator/issues/94)) ([be4d6bb](https://github.com/alanshaw/abortable-iterator/commit/be4d6bb035e8e04f97f5d400ae730aee40037510)) 15 | 16 | ### Dependencies 17 | 18 | * **dev:** bump aegir from 38.1.8 to 44.1.0 ([#97](https://github.com/alanshaw/abortable-iterator/issues/97)) ([8dd8a87](https://github.com/alanshaw/abortable-iterator/commit/8dd8a8798d3ce753628e233ee7dd67f80dec6340)) 19 | * **dev:** bump delay from 5.0.0 to 6.0.0 ([#89](https://github.com/alanshaw/abortable-iterator/issues/89)) ([e06ec34](https://github.com/alanshaw/abortable-iterator/commit/e06ec34868f00bd46e40d38fee1b7814675ebe2f)) 20 | 21 | ## [5.0.1](https://github.com/alanshaw/abortable-iterator/compare/v5.0.0...v5.0.1) (2023-04-19) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * accept source as a sink input type ([#83](https://github.com/alanshaw/abortable-iterator/issues/83)) ([a75aaab](https://github.com/alanshaw/abortable-iterator/commit/a75aaab2bbf4705127ca9dc08a528ca6cb6058d7)) 27 | 28 | ## [5.0.0](https://github.com/alanshaw/abortable-iterator/compare/v4.0.3...v5.0.0) (2023-04-18) 29 | 30 | 31 | ### ⚠ BREAKING CHANGES 32 | 33 | * bump it-stream-types from 1.0.5 to 2.0.1 (#82) 34 | 35 | ### Trivial Changes 36 | 37 | * **deps-dev:** bump aegir from 36.2.3 to 38.1.7 ([#75](https://github.com/alanshaw/abortable-iterator/issues/75)) ([53030ba](https://github.com/alanshaw/abortable-iterator/commit/53030bae97c3fb32013a318c491e65c7801e395e)) 38 | 39 | 40 | ### Dependencies 41 | 42 | * bump it-stream-types from 1.0.5 to 2.0.1 ([#82](https://github.com/alanshaw/abortable-iterator/issues/82)) ([0862e0f](https://github.com/alanshaw/abortable-iterator/commit/0862e0fa25a9da781e6300b85bb5f69d8a375cec)) 43 | 44 | ### [4.0.3](https://github.com/alanshaw/abortable-iterator/compare/v4.0.2...v4.0.3) (2023-04-18) 45 | 46 | 47 | ### Trivial Changes 48 | 49 | * **deps-dev:** bump it-drain from 1.0.5 to 2.0.1 ([#76](https://github.com/alanshaw/abortable-iterator/issues/76)) ([f9fd360](https://github.com/alanshaw/abortable-iterator/commit/f9fd360774fb203647ca4ee2efb4f4024dc8e185)) 50 | 51 | ### [4.0.2](https://github.com/alanshaw/abortable-iterator/compare/v4.0.1...v4.0.2) (2022-01-14) 52 | 53 | 54 | ### Tests 55 | 56 | * adds test for synchronous generator ([#19](https://github.com/alanshaw/abortable-iterator/issues/19)) ([3524a3f](https://github.com/alanshaw/abortable-iterator/commit/3524a3fcbbc7b8192b2aeecfd9b484169c2a75a3)), closes [#18](https://github.com/alanshaw/abortable-iterator/issues/18) 57 | 58 | ### [4.0.1](https://github.com/alanshaw/abortable-iterator/compare/v4.0.0...v4.0.1) (2022-01-13) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * re-enable transform duplex test ([#17](https://github.com/alanshaw/abortable-iterator/issues/17)) ([fcbf06d](https://github.com/alanshaw/abortable-iterator/commit/fcbf06ddb1054ecc806fadc5ed632d2dc2601e76)) 64 | 65 | ## [4.0.0](https://github.com/alanshaw/abortable-iterator/compare/v3.0.1...v4.0.0) (2022-01-12) 66 | 67 | 68 | ### ⚠ BREAKING CHANGES 69 | 70 | * switch to named exports, ESM only 71 | 72 | ### Features 73 | 74 | * convert to typescript ([#16](https://github.com/alanshaw/abortable-iterator/issues/16)) ([7a36b81](https://github.com/alanshaw/abortable-iterator/commit/7a36b810e3956bdd3f27f40dc4c468dd74632c3f)) 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This project is dual licensed under MIT and Apache-2.0. 2 | 3 | MIT: https://www.opensource.org/licenses/mit 4 | Apache-2.0: https://www.apache.org/licenses/license-2.0 5 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 2 | 3 | http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 6 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # abortable-iterator 2 | 3 | [![codecov](https://img.shields.io/codecov/c/github/alanshaw/abortable-iterator.svg?style=flat-square)](https://codecov.io/gh/alanshaw/abortable-iterator) 4 | [![CI](https://img.shields.io/github/actions/workflow/status/alanshaw/abortable-iterator/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/alanshaw/abortable-iterator/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) 5 | 6 | > Make any iterator or iterable abortable via an AbortSignal 7 | 8 | # About 9 | 10 | 24 | 25 | ## Example 26 | 27 | ```js 28 | import { abortableSource } from 'abortable-iterator' 29 | 30 | async function main () { 31 | // An example function that creates an async iterator that yields an increasing 32 | // number every x milliseconds and NEVER ENDS! 33 | const asyncCounter = async function * (start, delay) { 34 | let i = start 35 | while (true) { 36 | yield new Promise(resolve => setTimeout(() => resolve(i++), delay)) 37 | } 38 | } 39 | 40 | // Create a counter that'll yield numbers from 0 upwards every second 41 | const everySecond = asyncCounter(0, 1000) 42 | 43 | // Make everySecond abortable! 44 | const controller = new AbortController() 45 | const abortableEverySecond = abortableSource(everySecond, controller.signal) 46 | 47 | // Abort after 5 seconds 48 | setTimeout(() => controller.abort(), 5000) 49 | 50 | try { 51 | // Start the iteration, which will throw after 5 seconds when it is aborted 52 | for await (const n of abortableEverySecond) { 53 | console.log(n) 54 | } 55 | } catch (err) { 56 | if (err.code === 'ERR_ABORTED') { 57 | // Expected - all ok :D 58 | } else { 59 | throw err 60 | } 61 | } 62 | } 63 | 64 | main() 65 | ``` 66 | 67 | # Install 68 | 69 | ```console 70 | $ npm i abortable-iterator 71 | ``` 72 | 73 | ## Browser ` 79 | ``` 80 | 81 | ## API 82 | 83 | ```js 84 | import { 85 | abortableSource, 86 | abortableSink, 87 | abortableTransform, 88 | abortableDuplex 89 | } from 'abortable-iterator' 90 | ``` 91 | 92 | - [`abortableSource(source, signal, [options])`](#abortablesource-signal-options) 93 | - [`abortableSink(sink, signal, [options])`](#abortablesinksink-signal-options) 94 | - [`abortableTransform(transform, signal, [options])`](#abortabletransformtransform-signal-options) 95 | - [`abortableDuplex(duplex, signal, [options])`](#abortableduplexduplex-signal-options) 96 | 97 | ### `abortableSource(source, signal, [options])` 98 | 99 | **(alias for `abortable.source(source, signal, [options])`)** 100 | 101 | Make any iterator or iterable abortable via an `AbortSignal`. 102 | 103 | #### Parameters 104 | 105 | | Name | Type | Description | 106 | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 107 | | source | [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#The_iterable_protocol)\|[`Iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#The_iterator_protocol) | The iterator or iterable object to make abortable | 108 | | signal | [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) | Signal obtained from `AbortController.signal` which is used to abort the iterator. | 109 | | options | `Object` | (optional) options | 110 | | options.onAbort | `Function` | An (async) function called when the iterator is being aborted, before the abort error is thrown. Default `null` | 111 | | options.abortMessage | `String` | The message that the error will have if the iterator is aborted. Default "The operation was aborted" | 112 | | options.abortCode | `String`\|`Number` | The value assigned to the `code` property of the error that is thrown if the iterator is aborted. Default "ABORT\_ERR" | 113 | | options.returnOnAbort | `Boolean` | Instead of throwing the abort error, just return from iterating over the source stream. | 114 | | options.onReturnError | `Function` | When a generator is aborted, we call `.return` on it - if this function errors the error value will be passed to the `.onReturnError` callback if passed. Default `null` | 115 | 116 | #### Returns 117 | 118 | | Type | Description | 119 | | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | 120 | | [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#The_iterator_protocol) | An iterator that wraps the passed `source` parameter that makes it abortable via the passed `signal` parameter. | 121 | 122 | The returned iterator will `throw` an `AbortError` when it is aborted that has a `type` with the value `aborted` and `code` property with the value `ABORT_ERR` by default. 123 | 124 | ### `abortableSink(sink, signal, [options])` 125 | 126 | The same as [`abortable.source`](#abortablesource-signal-options) except this makes the passed [`sink`](https://gist.github.com/alanshaw/591dc7dd54e4f99338a347ef568d6ee9#sink-it) abortable. Returns a new sink that wraps the passed `sink` and makes it abortable via the passed `signal` parameter. 127 | 128 | ### `abortableTransform(transform, signal, [options])` 129 | 130 | The same as [`abortable.source`](#abortablesource-signal-options) except this makes the passed [`transform`](https://gist.github.com/alanshaw/591dc7dd54e4f99338a347ef568d6ee9#transform-it) abortable. Returns a new transform that wraps the passed `transform` and makes it abortable via the passed `signal` parameter. 131 | 132 | ### `abortableDuplex(duplex, signal, [options])` 133 | 134 | The same as [`abortable.source`](#abortablesource-signal-options) except this makes the passed [`duplex`](https://gist.github.com/alanshaw/591dc7dd54e4f99338a347ef568d6ee9#duplex-it) abortable. Returns a new duplex that wraps the passed `duplex` and makes it abortable via the passed `signal` parameter. 135 | 136 | Note that this will abort *both* sides of the duplex. Use `duplex.sink = abortable.sink(duplex.sink)` or `duplex.source = abortable.source(duplex.source)` to abort just the sink or the source. 137 | 138 | ## Related 139 | 140 | - [`it-pipe`](https://www.npmjs.com/package/it-pipe) Utility to "pipe" async iterables together 141 | 142 | # API Docs 143 | 144 | - 145 | 146 | # License 147 | 148 | Licensed under either of 149 | 150 | - Apache 2.0, ([LICENSE-APACHE](https://github.com/alanshaw/abortable-iterator/LICENSE-APACHE) / ) 151 | - MIT ([LICENSE-MIT](https://github.com/alanshaw/abortable-iterator/LICENSE-MIT) / ) 152 | 153 | # Contribution 154 | 155 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 156 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abortable-iterator", 3 | "version": "5.1.0", 4 | "description": "Make any iterator or iterable abortable via an AbortSignal", 5 | "author": "Alan Shaw", 6 | "license": "Apache-2.0 OR MIT", 7 | "homepage": "https://github.com/alanshaw/abortable-iterator#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/alanshaw/abortable-iterator.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/alanshaw/abortable-iterator/issues" 14 | }, 15 | "publishConfig": { 16 | "access": "public", 17 | "provenance": true 18 | }, 19 | "keywords": [ 20 | "AbortController", 21 | "AbortSignal", 22 | "abort", 23 | "abortable", 24 | "async", 25 | "cancel", 26 | "iterator", 27 | "signal", 28 | "stop" 29 | ], 30 | "type": "module", 31 | "types": "./dist/src/index.d.ts", 32 | "typesVersions": { 33 | "*": { 34 | "*": [ 35 | "*", 36 | "dist/*", 37 | "dist/src/*", 38 | "dist/src/*/index" 39 | ], 40 | "src/*": [ 41 | "*", 42 | "dist/*", 43 | "dist/src/*", 44 | "dist/src/*/index" 45 | ] 46 | } 47 | }, 48 | "files": [ 49 | "src", 50 | "dist", 51 | "!dist/test", 52 | "!**/*.tsbuildinfo" 53 | ], 54 | "exports": { 55 | ".": { 56 | "types": "./dist/src/index.d.ts", 57 | "import": "./dist/src/index.js" 58 | }, 59 | "./duplex": { 60 | "types": "./dist/src/duplex.d.ts", 61 | "import": "./dist/src/duplex.js" 62 | } 63 | }, 64 | "eslintConfig": { 65 | "extends": "ipfs", 66 | "parserOptions": { 67 | "project": true, 68 | "sourceType": "module" 69 | } 70 | }, 71 | "release": { 72 | "branches": [ 73 | "master" 74 | ], 75 | "plugins": [ 76 | [ 77 | "@semantic-release/commit-analyzer", 78 | { 79 | "preset": "conventionalcommits", 80 | "releaseRules": [ 81 | { 82 | "breaking": true, 83 | "release": "major" 84 | }, 85 | { 86 | "revert": true, 87 | "release": "patch" 88 | }, 89 | { 90 | "type": "feat", 91 | "release": "minor" 92 | }, 93 | { 94 | "type": "fix", 95 | "release": "patch" 96 | }, 97 | { 98 | "type": "docs", 99 | "release": "patch" 100 | }, 101 | { 102 | "type": "test", 103 | "release": "patch" 104 | }, 105 | { 106 | "type": "deps", 107 | "release": "patch" 108 | }, 109 | { 110 | "scope": "no-release", 111 | "release": false 112 | } 113 | ] 114 | } 115 | ], 116 | [ 117 | "@semantic-release/release-notes-generator", 118 | { 119 | "preset": "conventionalcommits", 120 | "presetConfig": { 121 | "types": [ 122 | { 123 | "type": "feat", 124 | "section": "Features" 125 | }, 126 | { 127 | "type": "fix", 128 | "section": "Bug Fixes" 129 | }, 130 | { 131 | "type": "chore", 132 | "section": "Trivial Changes" 133 | }, 134 | { 135 | "type": "docs", 136 | "section": "Documentation" 137 | }, 138 | { 139 | "type": "deps", 140 | "section": "Dependencies" 141 | }, 142 | { 143 | "type": "test", 144 | "section": "Tests" 145 | } 146 | ] 147 | } 148 | } 149 | ], 150 | "@semantic-release/changelog", 151 | "@semantic-release/npm", 152 | "@semantic-release/github", 153 | "@semantic-release/git" 154 | ] 155 | }, 156 | "scripts": { 157 | "clean": "aegir clean", 158 | "lint": "aegir lint", 159 | "dep-check": "aegir dep-check", 160 | "build": "aegir build", 161 | "test": "aegir test", 162 | "test:chrome": "npm run test -- -t browser --cov", 163 | "test:chrome-webworker": "npm run test -- -t webworker", 164 | "test:firefox": "npm run test -- -t browser -- --browser firefox", 165 | "test:firefox-webworker": "npm run test -- -t webworker -- --browser firefox", 166 | "test:node": "npm run test -- -t node --cov", 167 | "test:electron-main": "npm run test -- -t electron-main", 168 | "release": "aegir release", 169 | "docs": "aegir docs" 170 | }, 171 | "dependencies": { 172 | "get-iterator": "^2.0.0", 173 | "it-stream-types": "^2.0.1" 174 | }, 175 | "devDependencies": { 176 | "aegir": "^44.1.0", 177 | "delay": "^6.0.0", 178 | "it-drain": "^3.0.1", 179 | "it-pipe": "^3.0.1" 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/abort-error.ts: -------------------------------------------------------------------------------- 1 | export class AbortError extends Error { 2 | type: string 3 | code: string 4 | 5 | constructor (message?: string, code?: string, name?: string) { 6 | super(message ?? 'The operation was aborted') 7 | this.type = 'aborted' 8 | this.code = code ?? 'ABORT_ERR' 9 | this.name = name ?? 'AbortError' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * 4 | * @example 5 | * 6 | * ```js 7 | * import { abortableSource } from 'abortable-iterator' 8 | * 9 | * async function main () { 10 | * // An example function that creates an async iterator that yields an increasing 11 | * // number every x milliseconds and NEVER ENDS! 12 | * const asyncCounter = async function * (start, delay) { 13 | * let i = start 14 | * while (true) { 15 | * yield new Promise(resolve => setTimeout(() => resolve(i++), delay)) 16 | * } 17 | * } 18 | * 19 | * // Create a counter that'll yield numbers from 0 upwards every second 20 | * const everySecond = asyncCounter(0, 1000) 21 | * 22 | * // Make everySecond abortable! 23 | * const controller = new AbortController() 24 | * const abortableEverySecond = abortableSource(everySecond, controller.signal) 25 | * 26 | * // Abort after 5 seconds 27 | * setTimeout(() => controller.abort(), 5000) 28 | * 29 | * try { 30 | * // Start the iteration, which will throw after 5 seconds when it is aborted 31 | * for await (const n of abortableEverySecond) { 32 | * console.log(n) 33 | * } 34 | * } catch (err) { 35 | * if (err.code === 'ERR_ABORTED') { 36 | * // Expected - all ok :D 37 | * } else { 38 | * throw err 39 | * } 40 | * } 41 | * } 42 | * 43 | * main() 44 | * ``` 45 | */ 46 | 47 | import { getIterator } from 'get-iterator' 48 | import { AbortError } from './abort-error.js' 49 | import type { Duplex, Source, Sink } from 'it-stream-types' 50 | 51 | export interface Options { 52 | onReturnError?(err: Error): void 53 | onAbort?(source: Source): void 54 | abortMessage?: string 55 | abortCode?: string 56 | abortName?: string 57 | returnOnAbort?: boolean 58 | } 59 | 60 | /** 61 | * Wrap an iterator to make it abortable, allow cleanup when aborted via onAbort 62 | */ 63 | export function abortableSource (source: Source, signal: AbortSignal, options?: Options): AsyncGenerator { 64 | const opts: Options = options ?? {} 65 | const iterator = getIterator(source) 66 | 67 | async function * abortable (): AsyncGenerator, void, unknown> { 68 | let nextAbortHandler: (() => void) | null 69 | const abortHandler = (): void => { 70 | if (nextAbortHandler != null) nextAbortHandler() 71 | } 72 | 73 | signal.addEventListener('abort', abortHandler) 74 | 75 | while (true) { 76 | let result: IteratorResult 77 | try { 78 | if (signal.aborted) { 79 | const { abortMessage, abortCode, abortName } = opts 80 | throw new AbortError(abortMessage, abortCode, abortName) 81 | } 82 | 83 | const abort = new Promise((resolve, reject) => { // eslint-disable-line no-loop-func 84 | nextAbortHandler = () => { 85 | const { abortMessage, abortCode, abortName } = opts 86 | reject(new AbortError(abortMessage, abortCode, abortName)) 87 | } 88 | }) 89 | 90 | // Race the iterator and the abort signals 91 | result = await Promise.race([abort, iterator.next()]) 92 | nextAbortHandler = null 93 | } catch (err: any) { 94 | signal.removeEventListener('abort', abortHandler) 95 | 96 | // Might not have been aborted by a known signal 97 | const isKnownAborter = err.type === 'aborted' && signal.aborted 98 | 99 | if (isKnownAborter && (opts.onAbort != null)) { 100 | // Do any custom abort handling for the iterator 101 | opts.onAbort(source) 102 | } 103 | 104 | // End the iterator if it is a generator 105 | if (typeof iterator.return === 'function') { 106 | try { 107 | const p = iterator.return() 108 | 109 | if (p instanceof Promise) { // eslint-disable-line max-depth 110 | p.catch(err => { 111 | if (opts.onReturnError != null) { 112 | opts.onReturnError(err) 113 | } 114 | }) 115 | } 116 | } catch (err: any) { 117 | if (opts.onReturnError != null) { // eslint-disable-line max-depth 118 | opts.onReturnError(err) 119 | } 120 | } 121 | } 122 | 123 | if (isKnownAborter && opts.returnOnAbort === true) { 124 | return 125 | } 126 | 127 | throw err 128 | } 129 | 130 | if (result.done === true) { 131 | break 132 | } 133 | 134 | yield result.value 135 | } 136 | 137 | signal.removeEventListener('abort', abortHandler) 138 | } 139 | 140 | return abortable() 141 | } 142 | 143 | export function abortableSink > (sink: Sink, R>, signal: AbortSignal, options?: Options): Sink, R> { 144 | return (source: Source) => sink(abortableSource(source, signal, options)) 145 | } 146 | 147 | export function abortableDuplex > (duplex: Duplex, Source, RSink>, signal: AbortSignal, options?: Options): Duplex, Source, RSink> { 148 | return { 149 | sink: abortableSink(duplex.sink, signal, { 150 | ...options, 151 | onAbort: undefined 152 | }), 153 | source: abortableSource(duplex.source, signal, options) 154 | } 155 | } 156 | 157 | export { AbortError } 158 | export { abortableSink as abortableTransform } 159 | -------------------------------------------------------------------------------- /test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'aegir/chai' 2 | import delay from 'delay' 3 | import drain from 'it-drain' 4 | import { pipe } from 'it-pipe' 5 | import { abortableDuplex, abortableSink, abortableSource, abortableTransform } from '../src/index.js' 6 | import type { Sink, Transform, Duplex, Source } from 'it-stream-types' 7 | 8 | async function * forever (interval = 1): AsyncGenerator { 9 | // Never ends! 10 | while (true) { 11 | if (interval > 0) { 12 | await delay(interval) 13 | } 14 | yield Math.random() 15 | } 16 | } 17 | 18 | describe('abortable-iterator', () => { 19 | it('should abort', async () => { 20 | const controller = new AbortController() 21 | 22 | // Abort after 10ms 23 | setTimeout(() => { controller.abort() }, 10) 24 | 25 | await expect(drain(abortableSource(forever(), controller.signal))) 26 | .to.eventually.be.rejected.with.property('type', 'aborted') 27 | }) 28 | 29 | it('should abort a slow iterator', async () => { 30 | const controller = new AbortController() 31 | 32 | // Abort after 10ms 33 | setTimeout(() => { controller.abort() }, 10) 34 | 35 | await expect(drain(abortableSource(forever(6000), controller.signal))) 36 | .to.eventually.be.rejected.with.property('type', 'aborted') 37 | }) 38 | 39 | it('should pass error to onReturnError', async () => { 40 | const throwErr = new Error('Kaboom!') 41 | const controller = new AbortController() 42 | const iterator = { 43 | next: async () => { 44 | // never ending read 45 | await new Promise(() => {}) 46 | return 'hello world' 47 | }, 48 | return: async () => { 49 | throw throwErr 50 | } 51 | } 52 | 53 | // Abort after 10ms 54 | setTimeout(() => { controller.abort() }, 10) 55 | let returnedErr 56 | 57 | // @ts-expect-error wat 58 | await expect(drain(abortableSource(iterator, controller.signal, { 59 | onReturnError: (e) => { 60 | returnedErr = e 61 | } 62 | }))) 63 | .to.eventually.be.rejected.with.property('type', 'aborted') 64 | 65 | expect(returnedErr).to.equal(throwErr) 66 | }) 67 | 68 | it('should swallow error when no onReturnError callback passed', async () => { 69 | let threw = false 70 | const controller = new AbortController() 71 | const iterator = { 72 | next: async () => { 73 | // never ending read 74 | await new Promise(() => {}) 75 | }, 76 | return: async () => { 77 | threw = true 78 | throw new Error('Kaboom!') 79 | } 80 | } 81 | 82 | // Abort after 10ms 83 | setTimeout(() => { controller.abort() }, 10) 84 | 85 | // @ts-expect-error wat 86 | await expect(drain(abortableSource(iterator, controller.signal))) 87 | .to.eventually.be.rejected.with.property('type', 'aborted') 88 | 89 | expect(threw).to.be.true() 90 | }) 91 | 92 | it('should abort with onAbort handler', async () => { 93 | const controller = new AbortController() 94 | 95 | // Ensure we allow async cleanup 96 | let onAbortCalled = false 97 | const onAbort = (): void => { 98 | onAbortCalled = true 99 | } 100 | 101 | // Abort after 10ms 102 | setTimeout(() => { controller.abort() }, 10) 103 | 104 | await expect(drain(abortableSource(forever(1000), controller.signal, { onAbort }))) 105 | .to.eventually.be.rejected.with.property('type', 'aborted') 106 | 107 | expect(onAbortCalled).to.be.true() 108 | }) 109 | 110 | it('should complete successfully when aborted after iterator finishes', async () => { 111 | const controller = new AbortController() 112 | const iterator = (async function * () { 113 | yield new Promise((resolve, reject) => { 114 | setTimeout(() => { resolve(Math.random()) }) 115 | }) 116 | })() 117 | 118 | // Abort after 10ms 119 | setTimeout(() => { controller.abort() }, 10) 120 | 121 | await expect(drain(abortableSource(iterator, controller.signal))) 122 | .to.eventually.be.undefined() 123 | }) 124 | 125 | it('should throw for non iterator/iterable', async () => { 126 | const controller = new AbortController() 127 | const nonIterator = {} 128 | 129 | // @ts-expect-error not an iterator 130 | expect(() => drain(abortableSource(nonIterator, controller.signal))) // eslint-disable-line @typescript-eslint/promise-function-async 131 | .to.throw().with.property('message', 'argument is not an iterator or iterable') 132 | }) 133 | 134 | it('should abort if already aborted', async () => { 135 | const controller = new AbortController() 136 | // Abort before we start consuming 137 | controller.abort() 138 | 139 | await expect(drain(abortableSource(forever(), controller.signal))) 140 | .to.eventually.be.rejected.with.property('type', 'aborted') 141 | }) 142 | 143 | it('should abort a sink', async () => { 144 | const controller = new AbortController() 145 | const sink: Sink, Promise> = async (source) => { 146 | await drain(source) 147 | } 148 | 149 | // Abort after 10ms 150 | setTimeout(() => { controller.abort() }, 10) 151 | 152 | await expect(pipe( 153 | forever(), 154 | async (source) => { await abortableSink(sink, controller.signal)(source) } 155 | )) 156 | .to.eventually.be.rejected.with.property('type', 'aborted') 157 | }) 158 | 159 | it('should abort a transform', async () => { 160 | const controller = new AbortController() 161 | const transform: Transform, Source> = async function * (source) { 162 | yield * source 163 | } 164 | 165 | // Abort after 10ms 166 | setTimeout(() => { controller.abort() }, 10) 167 | 168 | await expect(pipe( 169 | forever(), 170 | abortableTransform(transform, controller.signal), 171 | drain 172 | )) 173 | .to.eventually.be.rejected.with.property('type', 'aborted') 174 | }) 175 | 176 | it('should abort a duplex used as a source', async () => { 177 | const controller = new AbortController() 178 | const duplex: Duplex, Source> = { 179 | source: forever(), 180 | sink: async (source) => { await drain(source) } 181 | } 182 | 183 | // Abort after 10ms 184 | setTimeout(() => { controller.abort() }, 10) 185 | 186 | await expect(pipe( 187 | abortableDuplex(duplex, controller.signal), 188 | drain 189 | )) 190 | .to.eventually.be.rejected.with.property('type', 'aborted') 191 | }) 192 | 193 | it('should abort a duplex used as a transform', async () => { 194 | const controller = new AbortController() 195 | const duplex: Duplex, Source> = { 196 | source: forever(), 197 | sink: drain 198 | } 199 | 200 | // Abort after 10ms 201 | setTimeout(() => { controller.abort() }, 10) 202 | 203 | await expect(pipe( 204 | forever(), 205 | abortableDuplex(duplex, controller.signal), 206 | drain 207 | )) 208 | .to.eventually.be.rejected.with.property('type', 'aborted') 209 | }) 210 | 211 | it('should abort a duplex used as a sink', async () => { 212 | const controller = new AbortController() 213 | const duplex: Duplex, Source> = { 214 | source: forever(), 215 | sink: drain 216 | } 217 | 218 | // Abort after 10ms 219 | setTimeout(() => { controller.abort() }, 10) 220 | 221 | await expect(pipe( 222 | forever(), 223 | abortableDuplex(duplex, controller.signal) 224 | )) 225 | .to.eventually.be.rejected.with.property('type', 'aborted') 226 | }) 227 | 228 | it('should abort a synchronous generator', async () => { 229 | const controller = new AbortController() 230 | const iterator = abortableSource((function * () { 231 | while (true) { 232 | yield Math.random() 233 | } 234 | })(), controller.signal) 235 | 236 | await expect((async () => { 237 | for await (const _ of iterator) { // eslint-disable-line @typescript-eslint/no-unused-vars 238 | controller.abort() 239 | } 240 | })()) 241 | .to.eventually.be.rejected.with.property('type', 'aborted') 242 | }) 243 | 244 | it('should override abort error properties', async () => { 245 | const controller = new AbortController() 246 | controller.abort() 247 | 248 | const err = await drain(abortableSource(forever(), controller.signal, { 249 | abortCode: 'custom code', 250 | abortMessage: 'custom message', 251 | abortName: 'custom name' 252 | })).catch(err => err) 253 | 254 | expect(err).to.have.property('code', 'custom code') 255 | expect(err).to.have.property('message', 'custom message') 256 | expect(err).to.have.property('name', 'custom name') 257 | }) 258 | }) 259 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "aegir/src/config/tsconfig.aegir.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "emitDeclarationOnly": false, 6 | "module": "ES2020" 7 | }, 8 | "include": [ 9 | "src", 10 | "test" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "./src/index.ts", 4 | "./src/duplex.ts" 5 | ] 6 | } 7 | --------------------------------------------------------------------------------