├── .npmignore ├── .gitignore ├── .prettierignore ├── renovate.json ├── .editorconfig ├── .prettierrc ├── tsconfig.json ├── tsconfig.typedoc.json ├── .travis.yml ├── LICENSE ├── package.json ├── tslint.json ├── src ├── index.ts └── index.test.ts └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | *.test.* 2 | *.test.*.* 3 | *.test.*.*.* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib 3 | /coverage/ 4 | /.nyc_output/ 5 | /docs/ 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | .nyc_output/ 4 | lib/ 5 | docs/ 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", ":maintainLockFilesMonthly"], 3 | "rangeStrategy": "replace", 4 | "prCreation": "not-pending" 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*] 3 | insert_final_newline = true 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | indent_style = space 8 | indent_size = 4 9 | 10 | [*.{json,js,yml,md}] 11 | indent_size = 2 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 120, 4 | "proseWrap": "preserve", 5 | "semi": false, 6 | "trailingComma": "es5", 7 | "singleQuote": true, 8 | "overrides": [ 9 | { 10 | "files": "{*.js?(on),*.y?(a)ml,.*.js?(on),.*.y?(a)ml,*.md,.prettierrc,.stylelintrc,.babelrc}", 11 | "options": { 12 | "tabWidth": 2 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es2018", 5 | "module": "es2015", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "outDir": "lib", 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "noUnusedParameters": false, 14 | "lib": ["es2017", "dom", "dom.iterable"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es2018", 5 | "module": "es2015", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "declarationMap": false, 9 | "sourceMap": true, 10 | "outDir": "lib", 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "noUnusedParameters": false, 14 | "lib": ["es2017", "dom", "dom.iterable"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: '10' 4 | 5 | env: 6 | global: 7 | - FORCE_COLOR=1 8 | 9 | cache: 10 | directories: 11 | - ~/.npm 12 | 13 | script: 14 | - npm run prettier 15 | - npm run tslint 16 | - npm run build 17 | - npm test 18 | - ./node_modules/.bin/nyc report --reporter json 19 | - bash <(curl -s https://codecov.io/bash) 20 | 21 | deploy: 22 | skip_cleanup: true 23 | provider: script 24 | script: ./node_modules/.bin/semantic-release 25 | on: 26 | branch: master 27 | 28 | branches: 29 | only: 30 | - master 31 | - /renovate\/.+/ 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Felix Frederick Becker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abortable-rx", 3 | "description": "Drop-in replacements for RxJS Observable methods and operators that work with AbortSignal", 4 | "keywords": [ 5 | "rx", 6 | "rxjs", 7 | "reactive", 8 | "AbortSignal", 9 | "AbortController", 10 | "cancel", 11 | "Promise", 12 | "async", 13 | "fetch" 14 | ], 15 | "version": "0.0.0-development", 16 | "license": "MIT", 17 | "author": "Felix Becker ", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/felixfbecker/abortable-rx.git" 21 | }, 22 | "sideEffects": false, 23 | "main": "lib/index.js", 24 | "types": "lib/index.d.ts", 25 | "files": [ 26 | "lib", 27 | "src", 28 | "docs" 29 | ], 30 | "scripts": { 31 | "build": "tsc -p .", 32 | "test": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --require ts-node/register mocha \"src/*.test.ts\"", 33 | "prettier": "prettier '**/{*.{js?(on),ts,yml},.*.js?(on),.*.yml}' --write --list-different", 34 | "tslint": "tslint -t stylish -c tslint.json -p tsconfig.json './src/*.ts'", 35 | "typedoc": "typedoc --out docs --readme none --excludeExternals --tsconfig tsconfig.typedoc.json --mode file --theme minimal" 36 | }, 37 | "release": { 38 | "analyzeCommits": { 39 | "preset": "angular", 40 | "releaseRules": [ 41 | { 42 | "type": "docs", 43 | "release": "patch" 44 | } 45 | ] 46 | }, 47 | "prepare": { 48 | "path": "@semantic-release/exec", 49 | "cmd": "npm run typedoc" 50 | } 51 | }, 52 | "nyc": { 53 | "include": [ 54 | "src/**/*.ts" 55 | ], 56 | "exclude": [ 57 | "src/**/*.test.ts" 58 | ], 59 | "extension": [ 60 | ".ts" 61 | ] 62 | }, 63 | "dependencies": { 64 | "rxjs": "^6.0.0" 65 | }, 66 | "devDependencies": { 67 | "@commitlint/cli": "^7.0.0", 68 | "@commitlint/config-conventional": "^7.0.1", 69 | "@semantic-release/exec": "^3.1.1", 70 | "@types/chai": "^4.1.4", 71 | "@types/mocha": "^5.2.5", 72 | "@types/node": "^10.9.2", 73 | "@types/sinon": "^5.0.1", 74 | "abort-controller": "^1.0.2", 75 | "chai": "^4.1.2", 76 | "mocha": "^5.2.0", 77 | "nyc": "^13.0.0", 78 | "prettier": "^1.14.2", 79 | "semantic-release": "^15.9.9", 80 | "sinon": "^6.1.5", 81 | "ts-node": "^7.0.1", 82 | "tslint": "^5.11.0", 83 | "tslint-config-prettier": "^1.15.0", 84 | "typedoc": "^0.12.0", 85 | "typescript": "^3.0.1" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "adjacent-overload-signatures": true, 5 | "array-type": [true, "array"], 6 | "arrow-parens": [true, "ban-single-arg-parens"], 7 | "arrow-return-shorthand": [true, "multiline"], 8 | "await-promise": [true, "Bluebird"], 9 | "ban": [ 10 | true, 11 | { 12 | "name": ["*", "forEach"] 13 | }, 14 | ["describe", "only"], 15 | ["it", "only"] 16 | ], 17 | "callable-types": true, 18 | "class-name": true, 19 | "comment-format": [true, "check-space"], 20 | "curly": true, 21 | "deprecation": { 22 | "severity": "warning" 23 | }, 24 | "eofline": true, 25 | "import-spacing": true, 26 | "interface-name": [false], 27 | "interface-over-type-literal": true, 28 | "jsdoc-format": true, 29 | "max-classes-per-file": false, 30 | "member-access": [true, "check-accessor"], 31 | "member-ordering": [false], 32 | "new-parens": true, 33 | "no-angle-bracket-type-assertion": true, 34 | "no-arg": true, 35 | "no-bitwise": false, 36 | "no-boolean-literal-compare": true, 37 | "no-conditional-assignment": true, 38 | "no-consecutive-blank-lines": [true, 1], 39 | "no-console": [false], 40 | "no-construct": true, 41 | "no-debugger": true, 42 | "no-duplicate-super": true, 43 | "no-duplicate-variable": true, 44 | "no-empty": true, 45 | "no-empty-interface": false, 46 | "no-eval": true, 47 | "no-floating-promises": [true], 48 | "no-for-in-array": true, 49 | "no-inferrable-types": [true], 50 | "no-inferred-empty-object-type": true, 51 | "no-internal-module": true, 52 | "no-invalid-template-strings": true, 53 | "no-irregular-whitespace": true, 54 | "no-magic-numbers": false, 55 | "no-misused-new": true, 56 | "no-namespace": [false, "allow-declarations"], 57 | "no-reference-import": true, 58 | "no-shadowed-variable": false, 59 | "no-sparse-arrays": true, 60 | "no-string-literal": true, 61 | "no-string-throw": true, 62 | "no-trailing-whitespace": true, 63 | "no-unbound-method": true, 64 | "no-unnecessary-callback-wrapper": false, 65 | "no-unnecessary-qualifier": true, 66 | "no-unnecessary-type-assertion": false, 67 | "no-unsafe-any": false, 68 | "no-unsafe-finally": true, 69 | "no-unused-expression": true, 70 | "no-use-before-declare": true, 71 | "no-var-keyword": true, 72 | "no-var-requires": false, 73 | "no-void-expression": false, 74 | "number-literal-format": true, 75 | "object-literal-key-quotes": [true, "as-needed"], 76 | "object-literal-shorthand": true, 77 | "object-literal-sort-keys": false, 78 | "one-variable-per-declaration": [true, "ignore-for-loop"], 79 | "only-arrow-functions": [true, "allow-declarations", "allow-named-functions"], 80 | "prefer-const": [ 81 | true, 82 | { 83 | "destructuring": "all" 84 | } 85 | ], 86 | "prefer-for-of": true, 87 | "prefer-template": [false, "allow-single-concat"], 88 | "quotemark": [true, "single", "jsx-double", "avoid-escape"], 89 | "return-undefined": true, 90 | "space-before-function-paren": [ 91 | true, 92 | { 93 | "anonymous": "never", 94 | "asyncArrow": "always", 95 | "named": "never" 96 | } 97 | ], 98 | "triple-equals": [true], 99 | "typedef": [true, "call-signature"], 100 | "unified-signatures": true, 101 | "variable-name": [true, "ban-keywords"] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { from, Observable, ObservableInput, OperatorFunction, Subscriber, Subscription, TeardownLogic } from 'rxjs' 2 | import { concatMap as rxConcatMap, mergeMap as rxMergeMap, switchMap as rxSwitchMap } from 'rxjs/operators' 3 | 4 | const createAbortError = () => { 5 | const error = new Error('Aborted') 6 | error.name = 'AbortError' 7 | return error 8 | } 9 | 10 | /** 11 | * Creates an Observable just like RxJS `create`, but exposes an AbortSignal in addition to the subscriber 12 | */ 13 | export const create = ( 14 | subscribe?: (subscriber: Subscriber, signal: AbortSignal) => TeardownLogic 15 | ): Observable => 16 | new Observable(subscriber => { 17 | const abortController = new AbortController() 18 | const subscription = new Subscription() 19 | const teardown = subscribe && subscribe(subscriber, abortController.signal) 20 | subscription.add(teardown) 21 | subscription.add(() => abortController.abort()) 22 | return subscription 23 | }) 24 | 25 | /** 26 | * Easiest way to wrap an abortable async function into a Promise. 27 | * The factory is called every time the Observable is subscribed to, and the AbortSignal is aborted on unsubscription. 28 | */ 29 | export const defer = (factory: (signal: AbortSignal) => ObservableInput): Observable => 30 | create((subscriber, signal) => from(factory(signal)).subscribe(subscriber)) 31 | 32 | /** 33 | * Returns a Promise that resolves with the last emission of the given Observable, 34 | * rejects if the Observable errors or rejects with an `AbortError` when the AbortSignal is aborted. 35 | */ 36 | export const toPromise = (observable: Observable, signal?: AbortSignal): Promise => 37 | new Promise((resolve, reject) => { 38 | if (signal && signal.aborted) { 39 | reject(createAbortError()) 40 | return 41 | } 42 | let subscription: Subscription 43 | const listener = () => { 44 | subscription.unsubscribe() 45 | reject(createAbortError()) 46 | } 47 | const cleanup = () => { 48 | if (signal) { 49 | signal.removeEventListener('abort', listener) 50 | } 51 | } 52 | let value: T 53 | subscription = observable.subscribe( 54 | val => { 55 | value = val 56 | }, 57 | err => { 58 | cleanup() 59 | reject(err) 60 | }, 61 | () => { 62 | cleanup() 63 | resolve(value) 64 | } 65 | ) 66 | if (signal) { 67 | signal.addEventListener('abort', listener, { once: true }) 68 | } 69 | }) 70 | 71 | /** 72 | * Calls `next` for every emission and returns a Promise that resolves when the Observable completed, rejects if the 73 | * Observable errors or rejects with an `AbortError` when the AbortSignal is aborted. 74 | */ 75 | export const forEach = (source: Observable, next: (value: T) => void, signal?: AbortSignal): Promise => 76 | new Promise((resolve, reject) => { 77 | if (signal && signal.aborted) { 78 | reject(createAbortError()) 79 | return 80 | } 81 | let subscription: Subscription 82 | const listener = () => { 83 | subscription.unsubscribe() 84 | reject(createAbortError()) 85 | } 86 | const cleanup = () => { 87 | if (signal) { 88 | signal.removeEventListener('abort', listener) 89 | } 90 | } 91 | subscription = source.subscribe( 92 | value => { 93 | try { 94 | next(value) 95 | } catch (err) { 96 | reject(err) 97 | if (subscription) { 98 | subscription.unsubscribe() 99 | } 100 | } 101 | }, 102 | err => { 103 | cleanup() 104 | reject(err) 105 | }, 106 | () => { 107 | cleanup() 108 | resolve() 109 | } 110 | ) 111 | if (signal) { 112 | signal.addEventListener('abort', listener, { once: true }) 113 | } 114 | }) 115 | 116 | /** 117 | * Like RxJS `switchMap`, but passes an AbortSignal that is aborted when the source emits another element. 118 | */ 119 | export const switchMap = ( 120 | project: (value: T, index: number, abortSignal: AbortSignal) => ObservableInput 121 | ): OperatorFunction => source => 122 | source.pipe(rxSwitchMap((value, index) => defer(abortSignal => project(value, index, abortSignal)))) 123 | 124 | /** 125 | * Like RxJS `concatMap`, but passes an AbortSignal that is aborted when the returned Observable is unsubscribed from. 126 | */ 127 | export const concatMap = ( 128 | project: (value: T, index: number, abortSignal: AbortSignal) => ObservableInput 129 | ): OperatorFunction => source => 130 | source.pipe(rxConcatMap((value, index) => defer(abortSignal => project(value, index, abortSignal)))) 131 | 132 | /** 133 | * Like RxJS `mergeMap`, but passes an AbortSignal that is aborted when the returned Observable is unsubscribed from. 134 | */ 135 | export const mergeMap = ( 136 | project: (value: T, index: number, abortSignal: AbortSignal) => ObservableInput 137 | ): OperatorFunction => source => 138 | source.pipe(rxMergeMap((value, index) => defer(abortSignal => project(value, index, abortSignal)))) 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # abortable-rx 2 | 3 | [![npm](https://img.shields.io/npm/v/abortable-rx.svg)](https://www.npmjs.com/package/abortable-rx) 4 | [![build](https://travis-ci.org/felixfbecker/abortable-rx.svg?branch=master)](https://travis-ci.org/felixfbecker/abortable-rx) 5 | [![codecov](https://codecov.io/gh/felixfbecker/abortable-rx/branch/master/graph/badge.svg)](https://codecov.io/gh/felixfbecker/abortable-rx) 6 | [![dependencies](https://david-dm.org/felixfbecker/abortable-rx.svg)](https://david-dm.org/felixfbecker/abortable-rx) 7 | [![license](https://img.shields.io/npm/l/abortable-rx.svg)](https://github.com/felixfbecker/abortable-rx/blob/master/LICENSE.txt) 8 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 9 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 10 | 11 | Drop-in replacements for RxJS Observable methods and operators that work with [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). 12 | Enables easy interop between Observable code and Promise-returning functions, without losing the cancellation capabilities of RxJS. 13 | 14 | ## Why? 15 | 16 | Some operations are imperative by nature and easier to express in imperative code with async/await. 17 | Expressing these operations that need control flow with functional RxJS operators or Subjects results in unreadable and unmaintainable code. 18 | In addition, it is confusing to have async functions that only have one or no result, but return an Observable, as it is unclear how many times it will emit. 19 | RxJS has great interop with Promises, however, it doesn't provide an easy mechanism to propagate cancellation to promise-returning functions like the native `fetch` API. 20 | This micro library provides that mechanism. 21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install abortable-rx 26 | ``` 27 | 28 | ## Included 29 | 30 | ### Observable factories 31 | 32 | - `defer(factory: (signal: AbortSignal) => ObservableInput): Observable` 33 | Easiest way to wrap an abortable async function into a Promise. The factory is called every time the Observable is subscribed to, and the AbortSignal is aborted on unsubscription. 34 | - `create(subscribe?: (subscriber: Subscriber, signal: AbortSignal) => TeardownLogic): Observable` 35 | Creates an Observable just like RxJS `create`, but exposes an AbortSignal in addition to the subscriber 36 | 37 | ### Observable consumers 38 | 39 | - `toPromise(observable: Observable, signal?: AbortSignal): Promise` 40 | Returns a Promise that resolves with the last emission of the given Observable, rejects if the Observable errors or rejects with an `AbortError` when the AbortSignal is aborted. 41 | - `forEach(source: Observable, next: (value: T) => void, signal?: AbortSignal): Promise` 42 | Calls `next` for every emission and returns a Promise that resolves when the Observable completed, rejects if the Observable errors or rejects with an `AbortError` when the AbortSignal is aborted. 43 | 44 | ### Observable operators 45 | 46 | - `switchMap(project: (value: T, index: number, abortSignal: AbortSignal) => ObservableInput): OperatorFunction` 47 | Like RxJS `switchMap`, but passes an AbortSignal that is aborted when the source emits another element. 48 | - `concatMap(project: (value: T, index: number, abortSignal: AbortSignal) => ObservableInput): OperatorFunction` 49 | Like RxJS `concatMap`, but passes an AbortSignal that is aborted when the returned Observable is unsubscribed from. 50 | - `mergeMap(project: (value: T, index: number, abortSignal: AbortSignal) => ObservableInput): OperatorFunction` 51 | Like RxJS `mergeMap`, but passes an AbortSignal that is aborted when the returned Observable is unsubscribed from. 52 | 53 | 📖 [Full API documentation](https://unpkg.com/abortable-rx/docs/) 54 | 55 | ## Handling AbortError 56 | 57 | `forEach` and `toPromise` will reject the Promise with an `Error` if the signal is aborted. 58 | This is so calling code does not continue execution and gets a chance to cleanup with `finally`. 59 | You can handle this error (usually at the top level) by checking if `error.name === 'AbortError'` in a `catch` block. 60 | 61 | If the functions you pass to `defer`, `switchMap`, etc. throw `AbortError`, you don't have to worry about catching it. 62 | The Promises are always converted to Observables internally, and that Observable is always unsubscribed from _first_, _then_ the AbortSignal is aborted. 63 | After an Observable is unsubscribed from, all further emissions or errors are ignored, so you don't have to worry about the error terminating your Observable chain. 64 | 65 | ## Example 66 | 67 | ### Using `fetch` inside `switchMap` 68 | 69 | ```ts 70 | import { fromEvent } from 'rxjs' 71 | import { switchMap } from 'abortable-rx/operators' 72 | 73 | fromEvent(input, 'value') 74 | .pipe(switchMap(async (event, i, signal) => { 75 | const resp = await fetch(`api/suggestions?value=${event.target.value}`, { signal }) 76 | if (!resp.ok) { 77 | throw new Error(resp.statusText) 78 | } 79 | return await resp.json() 80 | }) 81 | .subscribe(displaySuggestions) 82 | ``` 83 | 84 | ### Using `toPromise` to wait for an event to happen once 85 | 86 | ```ts 87 | import { toPromise } from 'abortable-rx' 88 | 89 | class ClientConnection { 90 | private events: Observable 91 | 92 | async sync(signal?: AbortSignal): Promise { 93 | await this.scheduleSync('immediatly', signal) 94 | const stream = this.events.pipe( 95 | filter(event => event.type === 'SYNC_COMPLETED'), 96 | take(1) 97 | ) 98 | await toPromise(stream, signal) 99 | } 100 | } 101 | ``` 102 | 103 | ### Polling 104 | 105 | ```ts 106 | import { fromEvent } from 'rxjs' 107 | import { switchMap } from 'rxjs/operators' 108 | import { defer } from 'abortable-rx' 109 | 110 | fromEvent(repoDropdown, 'change') 111 | .pipe(switchMap(event => 112 | concat( 113 | ['Loading...'], 114 | defer(async signal => { 115 | while (true) { 116 | const resp = await fetch(`api/repo/${event.target.value}`, { signal }) 117 | if (!resp.ok) { 118 | throw new Error(resp.statusText) 119 | } 120 | const repo = await resp.json() 121 | if (repo.cloneInProgress) { 122 | await new Promise(resolve => setTimeout(resolve, 1000)) 123 | continue 124 | } 125 | return repo.filesCount 126 | } 127 | }) 128 | ) 129 | })) 130 | .subscribe(content => { 131 | fileCount.textContent = content 132 | }) 133 | ``` 134 | 135 | ## Support 136 | 137 | `AbortSignal` is supported by all modern browsers, but there is a [polyfill](https://www.npmjs.com/package/abort-controller) available if you need it. 138 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | Object.assign(global, require('abort-controller')) 2 | 3 | import { AssertionError } from 'assert' 4 | import { assert } from 'chai' 5 | import { Observable, of, Subject, Subscriber, throwError } from 'rxjs' 6 | import { delay } from 'rxjs/operators' 7 | import * as sinon from 'sinon' 8 | import { concatMap, create, defer, forEach, mergeMap, switchMap, toPromise } from '.' 9 | 10 | describe('Observable factories', () => { 11 | describe('create()', () => { 12 | it('should abort the passed AbortSignal when the returned Observable is unsubscribed from', () => { 13 | const subscribe = sinon.spy() 14 | const obs = create(subscribe) 15 | assert.instanceOf(obs, Observable) 16 | sinon.assert.notCalled(subscribe) 17 | const onnext = sinon.spy() 18 | const onerror = sinon.spy() 19 | const oncomplete = sinon.spy() 20 | const subscription = obs.subscribe(onnext, onerror, oncomplete) 21 | sinon.assert.calledOnce(subscribe) 22 | sinon.assert.notCalled(onnext) 23 | const [subscriber, signal] = subscribe.args[0] as [Subscriber, AbortSignal] 24 | assert.instanceOf(signal, AbortSignal) 25 | assert.isFalse(signal.aborted) 26 | const onabort = sinon.spy() 27 | signal.onabort = onabort 28 | subscriber.next(1) 29 | sinon.assert.calledOnce(onnext) 30 | sinon.assert.calledWith(onnext, 1) 31 | subscription.unsubscribe() 32 | sinon.assert.calledOnce(onabort) 33 | subscriber.error(new Error()) // Simulate error that might be thrown on abort 34 | sinon.assert.notCalled(onerror) 35 | sinon.assert.notCalled(oncomplete) 36 | assert.isTrue(signal.aborted) 37 | }) 38 | }) 39 | describe('defer()', () => { 40 | it('should abort the passed AbortSignal when the returned Observable is unsubscribed from', () => { 41 | const subject = new Subject() 42 | const factory = sinon.spy(() => subject) 43 | const obs = defer(factory) 44 | assert.instanceOf(obs, Observable) 45 | sinon.assert.notCalled(factory) 46 | const onnext = sinon.stub() 47 | const onerror = sinon.stub() 48 | const oncomplete = sinon.stub() 49 | const subscription = obs.subscribe(onnext, onerror, oncomplete) 50 | sinon.assert.calledOnce(factory) 51 | sinon.assert.notCalled(onnext) 52 | const [signal] = factory.args[0] as [AbortSignal] 53 | assert.instanceOf(signal, AbortSignal) 54 | assert.isFalse(signal.aborted) 55 | const onabort = sinon.stub() 56 | signal.onabort = onabort 57 | subject.next(1) 58 | sinon.assert.calledOnce(onnext) 59 | sinon.assert.calledWith(onnext, 1) 60 | subscription.unsubscribe() 61 | sinon.assert.calledOnce(onabort) 62 | subject.error(new Error()) // Simulate error that might be thrown on abort 63 | sinon.assert.notCalled(onerror) 64 | sinon.assert.notCalled(oncomplete) 65 | assert.isTrue(signal.aborted) 66 | }) 67 | }) 68 | }) 69 | 70 | describe('Observable consumers', () => { 71 | describe('toPromise()', () => { 72 | it('should unsubscribe from the given Observable when the AbortSignal is aborted', async () => { 73 | const teardown = sinon.spy() 74 | const subscribe = sinon.spy((subscriber: Subscriber) => teardown) 75 | const obs = new Observable(subscribe) 76 | const abortController = new AbortController() 77 | const promise = toPromise(obs, abortController.signal) 78 | sinon.assert.notCalled(teardown) 79 | abortController.abort() 80 | sinon.assert.calledOnce(teardown) 81 | try { 82 | await promise 83 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 84 | } catch (err) { 85 | assert.instanceOf(err, Error) 86 | assert.propertyVal(err, 'name', 'AbortError') 87 | } 88 | }) 89 | it('should never subscribe to the Observable if the AbortSignal is already aborted', async () => { 90 | const subscribe = sinon.spy() 91 | const obs = new Observable(subscribe) 92 | const abortController = new AbortController() 93 | abortController.abort() 94 | const promise = toPromise(obs, abortController.signal) 95 | sinon.assert.notCalled(subscribe) 96 | try { 97 | await promise 98 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 99 | } catch (err) { 100 | assert.instanceOf(err, Error) 101 | assert.propertyVal(err, 'name', 'AbortError') 102 | } 103 | }) 104 | it('should resolve with the last value emitted', async () => { 105 | const obs = of(1, 2, 3) 106 | const abortController = new AbortController() 107 | const value = await toPromise(obs, abortController.signal) 108 | assert.strictEqual(value, 3) 109 | }) 110 | it('should reject if the Observable errors', async () => { 111 | const obs = throwError(123) 112 | const abortController = new AbortController() 113 | const promise = toPromise(obs, abortController.signal) 114 | try { 115 | await promise 116 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 117 | } catch (err) { 118 | assert.strictEqual(err, 123) 119 | } 120 | }) 121 | }) 122 | describe('forEach()', () => { 123 | it('should unsubscribe from the given Observable when the AbortSignal is aborted', async () => { 124 | const teardown = sinon.spy() 125 | const subscribe = sinon.spy((subscriber: Subscriber) => teardown) 126 | const obs = new Observable(subscribe) 127 | const abortController = new AbortController() 128 | const onnext = sinon.spy() 129 | const promise = forEach(obs, onnext, abortController.signal) 130 | sinon.assert.notCalled(onnext) 131 | const [subscriber] = subscribe.args[0] as [Subscriber] 132 | subscriber.next(1) 133 | assert.deepStrictEqual(onnext.args[0], [1]) 134 | subscriber.next(2) 135 | assert.deepStrictEqual(onnext.args[1], [2]) 136 | sinon.assert.notCalled(teardown) 137 | abortController.abort() 138 | sinon.assert.calledOnce(teardown) 139 | try { 140 | await promise 141 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 142 | } catch (err) { 143 | assert.instanceOf(err, Error) 144 | assert.propertyVal(err, 'name', 'AbortError') 145 | } 146 | }) 147 | it('should never subscribe to the Observable when the AbortSignal is already aborted', async () => { 148 | const subscribe = sinon.spy() 149 | const obs = new Observable(subscribe) 150 | const abortController = new AbortController() 151 | abortController.abort() 152 | const onnext = sinon.spy() 153 | const promise = forEach(obs, onnext, abortController.signal) 154 | sinon.assert.notCalled(subscribe) 155 | sinon.assert.notCalled(onnext) 156 | try { 157 | await promise 158 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 159 | } catch (err) { 160 | assert.instanceOf(err, Error) 161 | assert.propertyVal(err, 'name', 'AbortError') 162 | } 163 | }) 164 | it('should resolve the Promise when the Observable completes', async () => { 165 | const obs = of(1, 2, 3) 166 | const abortController = new AbortController() 167 | const onnext = sinon.spy() 168 | await forEach(obs, onnext, abortController.signal) 169 | assert.deepStrictEqual(onnext.args, [[1], [2], [3]]) 170 | }) 171 | it('should reject the Promise when the Observable errors', async () => { 172 | const error = new Error() 173 | const obs = throwError(error) 174 | const abortController = new AbortController() 175 | try { 176 | await forEach(obs, () => undefined, abortController.signal) 177 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 178 | } catch (err) { 179 | assert.strictEqual(err, error) 180 | } 181 | }) 182 | it('should reject the Promise when the next function throws and unsubscribe the Observable', async () => { 183 | const error = new Error() 184 | const teardown = sinon.spy() 185 | const subscribe = sinon.spy((subscriber: Subscriber) => teardown) 186 | const obs = new Observable(subscribe).pipe(delay(1)) 187 | const abortController = new AbortController() 188 | const promise = forEach( 189 | obs, 190 | () => { 191 | throw error 192 | }, 193 | abortController.signal 194 | ) 195 | sinon.assert.notCalled(teardown) 196 | const [subscriber] = subscribe.args[0] as [Subscriber] 197 | subscriber.next(1) 198 | try { 199 | await promise 200 | throw new AssertionError({ message: 'Expected Promise to be rejected' }) 201 | } catch (err) { 202 | assert.strictEqual(err, error) 203 | } 204 | sinon.assert.calledOnce(teardown) 205 | }) 206 | }) 207 | }) 208 | 209 | describe('Observable operators', () => { 210 | describe('switchMap()', () => { 211 | it('should abort the passed AbortSignal when the source emits a new item', () => { 212 | const source = new Subject() 213 | const project = sinon.spy(() => new Subject()) 214 | const obs = source.pipe(switchMap(project)) 215 | sinon.assert.notCalled(project) 216 | const onnext = sinon.spy() 217 | const onerror = sinon.spy() 218 | const oncomplete = sinon.spy() 219 | obs.subscribe(onnext, onerror, oncomplete) 220 | source.next('a') 221 | sinon.assert.calledOnce(project) 222 | const [value, index, signal] = project.args[0] as [string, number, AbortSignal] 223 | assert.strictEqual(value, 'a') 224 | assert.strictEqual(index, 0) 225 | assert.instanceOf(signal, AbortSignal) 226 | assert.isFalse(signal.aborted) 227 | const onabort = sinon.spy() 228 | signal.onabort = onabort 229 | source.next('b') 230 | sinon.assert.calledOnce(onabort) 231 | assert.isTrue(signal.aborted) 232 | const returnedSubject = project.returnValues[0] as Subject 233 | returnedSubject.next('a1') 234 | sinon.assert.notCalled(onnext) 235 | returnedSubject.error(new Error()) // Simulate error that might be thrown on abort 236 | sinon.assert.notCalled(onerror) 237 | sinon.assert.notCalled(oncomplete) 238 | }) 239 | }) 240 | for (const [name, operator] of new Map([['concatMap()', concatMap], ['mergeMap()', mergeMap]])) { 241 | describe(name, () => { 242 | it('should abort the passed AbortSignal when the returned Observable is unsubscribed from', () => { 243 | const source = new Subject() 244 | const project = sinon.spy(() => new Subject()) 245 | const obs = source.pipe(operator(project)) 246 | sinon.assert.notCalled(project) 247 | const onnext = sinon.spy() 248 | const onerror = sinon.spy() 249 | const oncomplete = sinon.spy() 250 | const subscription = obs.subscribe(onnext, onerror, oncomplete) 251 | source.next('a') 252 | sinon.assert.calledOnce(project) 253 | const [value, index, signal] = project.args[0] as [string, number, AbortSignal] 254 | assert.strictEqual(value, 'a') 255 | assert.strictEqual(index, 0) 256 | assert.instanceOf(signal, AbortSignal) 257 | assert.isFalse(signal.aborted) 258 | const onabort = sinon.spy() 259 | signal.onabort = onabort 260 | subscription.unsubscribe() 261 | sinon.assert.calledOnce(onabort) 262 | assert.isTrue(signal.aborted) 263 | const returnedSubject = project.returnValues[0] as Subject 264 | returnedSubject.next('a1') 265 | sinon.assert.notCalled(onnext) 266 | returnedSubject.error(new Error()) // Simulate error that might be thrown on abort 267 | sinon.assert.notCalled(onerror) 268 | sinon.assert.notCalled(oncomplete) 269 | }) 270 | }) 271 | } 272 | }) 273 | --------------------------------------------------------------------------------