├── .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 | [](https://codecov.io/gh/alanshaw/abortable-iterator)
4 | [](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 |
--------------------------------------------------------------------------------