├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── jest.tsc.config.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ └── index.spec.ts ├── index.ts └── util │ └── deferred.ts ├── tsconfig.esm.json ├── tsconfig.json └── tsconfig.types.json /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "singleQuote": false, 4 | "useTabs": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.0.0](https://github.com/benlesh/rxjs-for-await/compare/0.0.2...1.0.0) (2021-11-10) 2 | 3 | 4 | ### Features 5 | 6 | * now supports RxJS 7 ([76aa18b](https://github.com/benlesh/rxjs-for-await/commit/76aa18b1032a1fde06e41165afd1bb32c331356f)), closes [#10](https://github.com/benlesh/rxjs-for-await/issues/10) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * Requires RxJS 7 or higher. 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Lesh 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Testing CI](https://github.com/benlesh/rxjs-for-await/actions/workflows/node.js.yml/badge.svg)](https://github.com/benlesh/rxjs-for-await/actions/workflows/node.js.yml) 2 | [![npm version](https://badge.fury.io/js/rxjs-for-await.svg)](https://www.npmjs.com/package/rxjs-for-await) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) 4 | 5 | # rxjs-for-await 6 | 7 | A library for making RxJS support async-await for-await loops via AsyncIterables 8 | 9 | ## Four Strategies 10 | 11 | This library exposes 4 different ways to consume an [RxJS](https://rxjs.dev) observable with an async/await `for await..of` loop using `AsyncIterable`. Each of these strategies has pros and cons, so be aware of those as you choose the one that suits your needs. 12 | 13 | ### eachValueFrom (lossless) 14 | 15 | ```ts 16 | import { interval } from "rxjs"; 17 | import { eachValueFrom } from "rxjs-for-await"; 18 | 19 | async function example() { 20 | const source$ = interval(100); 21 | 22 | for await (const value of eachValueFrom(source$)) { 23 | console.log(value); 24 | } 25 | } 26 | ``` 27 | 28 | This strategy will yield every single value the observable source emits, one at a time, until the observable completes or errors. 29 | 30 | #### Pros 31 | 32 | - All values are yielded 33 | - You get each value one at a time 34 | 35 | #### Cons 36 | 37 | - Creates more memory pressure if the body of the `for await` loop takes longer to come back around than the time between emissions from the observable source. If the observable emits faster than your loop can consume them, this may result in a memory leak. 38 | 39 | ### bufferedValuesFrom (lossless) 40 | 41 | ```ts 42 | import { interval } from "rxjs"; 43 | import { bufferedValuesFrom } from "rxjs-for-await"; 44 | 45 | async function example() { 46 | const source$ = interval(10); 47 | 48 | for await (const buffer of bufferedValuesFrom(source$)) { 49 | console.log(buffer); 50 | await wait(1000); 51 | } 52 | } 53 | ``` 54 | 55 | Keep an internal buffer of values emitted by the observable source, and yield the entire buffer to the `for await` loop. Continue this until the observable source completes or errors. 56 | 57 | #### Pros 58 | 59 | - All values are yielded 60 | - Lower memory pressure than `eachValueFrom` 61 | - Provides snapshots of what has happened since the last loop 62 | 63 | #### Cons 64 | 65 | - May still cause out of memory errors if the body of the `for await` loop is _extremely_ slow. 66 | - Perhaps less intuitive than `eachValueFrom`. 67 | 68 | ### latestValueFrom (lossy) 69 | 70 | ```ts 71 | import { interval } from "rxjs"; 72 | import { latestValueFrom } from "rxjs-for-await"; 73 | 74 | async function example() { 75 | const source$ = interval(100); 76 | 77 | for await (const value of latestValueFrom(source$)) { 78 | console.log(value); 79 | } 80 | } 81 | ``` 82 | 83 | This strategy will immediately yield the most recently arrived value, or the very next one, if the `for await` loop is waiting and one has not arrived yet. Will continue 84 | to do so until the source observable completes or errors. 85 | 86 | #### Pros 87 | 88 | - No chance of memory leaks 89 | - Quick entry to the loop if a value is already available 90 | 91 | #### Cons 92 | 93 | - Will lose values if more than one value arrives while the loop body is being processed. 94 | 95 | ### nextValueFrom (lossy) 96 | 97 | ```ts 98 | import { interval } from "rxjs"; 99 | import { nextValueFrom } from "rxjs-for-await"; 100 | 101 | async function example() { 102 | const source$ = interval(100); 103 | 104 | for await (const value of nextValueFrom(source$)) { 105 | console.log(value); 106 | } 107 | } 108 | ``` 109 | 110 | Will wait for the very next value to arrive, then yield it. Will continue to do so until the source observable completes or errors. 111 | 112 | #### Pros 113 | 114 | - No chance of memory leaks 115 | 116 | #### Cons 117 | 118 | - Loop must wait for the next value to arrive, perhaps slowing down the process 119 | - Will lose values if values arrive while the loop is being processed. 120 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/qs/qn55mp6108v77prvbk6y3hzm0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: undefined, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: undefined, 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | rootDir: './src', 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner-tsc", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [ 127 | // './test/test-helpers.js' 128 | // ], 129 | 130 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 131 | // setupFilesAfterEnv: [], 132 | 133 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 134 | // snapshotSerializers: [], 135 | 136 | // The test environment that will be used for testing 137 | testEnvironment: "node", 138 | 139 | // Options that will be passed to the testEnvironment 140 | // testEnvironmentOptions: {}, 141 | 142 | // Adds a location field to test results 143 | // testLocationInResults: false, 144 | 145 | // The glob patterns Jest uses to detect test files 146 | // testMatch: [ 147 | // "**/__tests__/**/*.[jt]s?(x)", 148 | // "**/?(*.)+(spec|test).[tj]s?(x)" 149 | // ], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | // testPathIgnorePatterns: [ 153 | // "/node_modules/" 154 | // ], 155 | 156 | // The regexp pattern or array of patterns that Jest uses to detect test files 157 | // testRegex: [], 158 | 159 | // This option allows the use of a custom results processor 160 | // testResultsProcessor: undefined, 161 | 162 | // This option allows use of a custom test runner 163 | // testRunner: "jasmine2", 164 | 165 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 166 | // testURL: "http://localhost", 167 | 168 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 169 | // timers: "real", 170 | 171 | // A map from regular expressions to paths to transformers 172 | transform: { 173 | '^.+\\.tsx?$': 'ts-jest' 174 | }, 175 | 176 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 177 | // transformIgnorePatterns: [ 178 | // "/node_modules/" 179 | // ], 180 | 181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 182 | // unmockedModulePathPatterns: undefined, 183 | 184 | // Indicates whether each individual test should be reported during the run 185 | // verbose: undefined, 186 | 187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 188 | // watchPathIgnorePatterns: [], 189 | 190 | // Whether to use watchman for file crawling 191 | // watchman: true, 192 | }; 193 | -------------------------------------------------------------------------------- /jest.tsc.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('./jest.config'); 2 | 3 | module.exports = { 4 | ...jestConfig, 5 | runner: 'jest-runner-tsc' 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-for-await", 3 | "version": "1.0.0", 4 | "description": "Add async-await for-await loop support to RxJS Observables", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.js", 7 | "es2015": "./dist/esm/index.js", 8 | "types": "./dist/types/src/index.d.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "build": "rm -rf ./dist && tsc -p tsconfig.esm.json && tsc && tsc -p tsconfig.types.json", 12 | "test": "jest", 13 | "test:jest": "jest", 14 | "test:tsc": "jest -c jest.tsc.config.js", 15 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 16 | "preversion": "git fetch --all", 17 | "version": "npm run build && npm run changelog && git add ./CHANGELOG.md" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/benlesh/rxjs-for-await.git" 22 | }, 23 | "keywords": [ 24 | "RxJS", 25 | "Observable", 26 | "async-await", 27 | "AsyncIterable", 28 | "for-await" 29 | ], 30 | "author": "Ben Lesh ", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/benlesh/rxjs-for-await/issues" 34 | }, 35 | "homepage": "https://github.com/benlesh/rxjs-for-await#readme", 36 | "devDependencies": { 37 | "@babel/core": "^7.8.7", 38 | "@babel/preset-typescript": "^7.8.3", 39 | "@types/jest": "^27.0.2", 40 | "@types/node": "^13.9.1", 41 | "conventional-changelog-cli": "^2.1.1", 42 | "jest": "^27.3.1", 43 | "jest-runner-tsc": "^1.6.0", 44 | "prettier": "^1.19.1", 45 | "rxjs": "7.4.0", 46 | "ts-jest": "^27.0.7", 47 | "typescript": "^4.4.4" 48 | }, 49 | "peerDependencies": { 50 | "rxjs": "^7.0.0" 51 | }, 52 | "files": [ 53 | "dist/", 54 | "README.md", 55 | "LICENSE" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { of, throwError, interval, Subject } from "rxjs"; 2 | import { 3 | eachValueFrom, 4 | bufferedValuesFrom, 5 | latestValueFrom, 6 | nextValueFrom, 7 | } from ".."; 8 | import { take, finalize } from "rxjs/operators"; 9 | 10 | describe("eachValueFrom", () => { 11 | test("should work for sync observables", async () => { 12 | const source = of(1, 2, 3); 13 | const results: number[] = []; 14 | for await (const value of eachValueFrom(source)) { 15 | results.push(value); 16 | } 17 | expect(results).toEqual([1, 2, 3]); 18 | }); 19 | 20 | test("should throw if the observable errors", async () => { 21 | const source = throwError(new Error("bad")); 22 | let error: any; 23 | try { 24 | for await (const _ of eachValueFrom(source)) { 25 | // do nothing 26 | } 27 | } catch (err) { 28 | error = err; 29 | } 30 | expect(error).toBeInstanceOf(Error); 31 | expect(error.message).toBe("bad"); 32 | }); 33 | 34 | test("should support async observables", async () => { 35 | const source = interval(1).pipe(take(3)); 36 | const results: number[] = []; 37 | for await (const value of eachValueFrom(source)) { 38 | results.push(value); 39 | } 40 | expect(results).toEqual([0, 1, 2]); 41 | }); 42 | 43 | test("should do something clever if the loop exits", async () => { 44 | let finalized = false; 45 | const source = interval(1).pipe( 46 | take(10), 47 | finalize(() => (finalized = true)) 48 | ); 49 | const results: number[] = []; 50 | try { 51 | for await (const value of eachValueFrom(source)) { 52 | results.push(value); 53 | if (value === 1) { 54 | throw new Error("bad"); 55 | } 56 | } 57 | } catch (err) { 58 | // ignore 59 | } 60 | expect(results).toEqual([0, 1]); 61 | expect(finalized).toBe(true); 62 | }); 63 | 64 | test("a more advanced test", async () => { 65 | const results: number[] = []; 66 | const source = new Subject(); 67 | const advancer = createAdvancer(); 68 | 69 | async function executeTest() { 70 | for await (const value of eachValueFrom(source)) { 71 | results.push(value); 72 | await advancer; 73 | } 74 | } 75 | 76 | const complete = executeTest(); 77 | 78 | source.next(0); 79 | source.next(1); 80 | source.next(2); 81 | await advancer.next(); 82 | // A loop was waiting by the time 0 was sent, so it 83 | // will resolve, then the advancer causes it to loop 84 | // again. 85 | expect(results).toEqual([0, 1]); 86 | await advancer.next(); 87 | expect(results).toEqual([0, 1, 2]); 88 | 89 | // Nothing arrived, start the loop waiting again. 90 | await advancer.next(); 91 | expect(results).toEqual([0, 1, 2]); 92 | 93 | source.next(3); 94 | source.next(4); 95 | source.next(5); 96 | await advancer.next(); 97 | // We were waiting for 3 already, so that was resolved, 98 | // then the advancer caused the loop back around to 99 | // get 4 100 | expect(results).toEqual([0, 1, 2, 3, 4]); 101 | 102 | await advancer.next(); 103 | expect(results).toEqual([0, 1, 2, 3, 4, 5]); 104 | 105 | // end the loop 106 | source.complete(); 107 | 108 | await complete; 109 | expect(results).toEqual([0, 1, 2, 3, 4, 5]); 110 | }); 111 | }); 112 | 113 | describe("bufferedValuesFrom", () => { 114 | test("should work for sync observables", async () => { 115 | const source = of(1, 2, 3); 116 | const results: number[][] = []; 117 | for await (const value of bufferedValuesFrom(source)) { 118 | results.push(value); 119 | } 120 | expect(results).toEqual([[1, 2, 3]]); 121 | }); 122 | 123 | test("should throw if the observable errors", async () => { 124 | const source = throwError(new Error("bad")); 125 | let error: any; 126 | try { 127 | for await (const _ of bufferedValuesFrom(source)) { 128 | // do nothing 129 | } 130 | } catch (err) { 131 | error = err; 132 | } 133 | expect(error).toBeInstanceOf(Error); 134 | expect(error.message).toBe("bad"); 135 | }); 136 | 137 | test("should support async observables", async () => { 138 | const source = interval(1).pipe(take(3)); 139 | const results: number[][] = []; 140 | for await (const value of bufferedValuesFrom(source)) { 141 | results.push(value); 142 | } 143 | expect(results).toEqual([[0], [1], [2]]); 144 | }); 145 | 146 | test("should do something clever if the loop exits", async () => { 147 | let finalized = false; 148 | const source = interval(1).pipe( 149 | take(10), 150 | finalize(() => (finalized = true)) 151 | ); 152 | const results: number[][] = []; 153 | try { 154 | for await (const value of bufferedValuesFrom(source)) { 155 | results.push(value); 156 | if (value[0] === 1) { 157 | throw new Error("bad"); 158 | } 159 | } 160 | } catch (err) { 161 | // ignore 162 | } 163 | expect(results).toEqual([[0], [1]]); 164 | expect(finalized).toBe(true); 165 | }); 166 | 167 | test("a more in-depth test", async () => { 168 | const results: number[][] = []; 169 | const source = new Subject(); 170 | const advancer = createAdvancer(); 171 | 172 | async function executeTest() { 173 | for await (let buffer of bufferedValuesFrom(source)) { 174 | results.push(buffer); 175 | await advancer; 176 | } 177 | } 178 | 179 | const complete = executeTest(); 180 | 181 | source.next(0); 182 | source.next(1); 183 | source.next(2); 184 | await advancer.next(); 185 | expect(results).toEqual([[0, 1, 2]]); 186 | 187 | // Next batch 188 | source.next(3); 189 | source.next(4); 190 | await advancer.next(); 191 | expect(results).toEqual([ 192 | [0, 1, 2], 193 | [3, 4], 194 | ]); 195 | 196 | // end the loop 197 | source.complete(); 198 | 199 | await complete; 200 | expect(results).toEqual([ 201 | [0, 1, 2], 202 | [3, 4], 203 | ]); 204 | }); 205 | }); 206 | 207 | describe("latestValueFrom", () => { 208 | test("should work for sync observables", async () => { 209 | const source = of(1, 2, 3); 210 | const results: number[] = []; 211 | for await (const value of latestValueFrom(source)) { 212 | results.push(value); 213 | } 214 | expect(results).toEqual([3]); 215 | }); 216 | 217 | test("should throw if the observable errors", async () => { 218 | const source = throwError(new Error("bad")); 219 | let error: any; 220 | try { 221 | for await (const _ of latestValueFrom(source)) { 222 | // do nothing 223 | } 224 | } catch (err) { 225 | error = err; 226 | } 227 | expect(error).toBeInstanceOf(Error); 228 | expect(error.message).toBe("bad"); 229 | }); 230 | 231 | test("should support async observables", async () => { 232 | const source = interval(1).pipe(take(3)); 233 | const results: number[] = []; 234 | for await (const value of latestValueFrom(source)) { 235 | results.push(value); 236 | } 237 | expect(results).toEqual([0, 1, 2]); 238 | }); 239 | 240 | test("a more in-depth test", async () => { 241 | const results: number[] = []; 242 | const source = new Subject(); 243 | const advancer = createAdvancer(); 244 | 245 | async function executeTest() { 246 | for await (let buffer of latestValueFrom(source)) { 247 | results.push(buffer); 248 | await advancer; 249 | } 250 | } 251 | 252 | const complete = executeTest(); 253 | 254 | source.next(0); 255 | source.next(1); 256 | source.next(2); 257 | await advancer.next(); 258 | expect(results).toEqual([2]); 259 | 260 | // Next batch 261 | source.next(3); 262 | source.next(4); 263 | await advancer.next(); 264 | expect(results).toEqual([2, 4]); 265 | 266 | source.next(5); 267 | source.next(6); 268 | 269 | // end the loop 270 | source.complete(); 271 | 272 | await complete; 273 | expect(results).toEqual([2, 4, 6]); 274 | }); 275 | 276 | test("a more in-depth with early exit", async () => { 277 | const results: number[] = []; 278 | const source = new Subject(); 279 | const advancer = createAdvancer(); 280 | 281 | async function executeTest() { 282 | let i = 0; 283 | for await (let buffer of latestValueFrom(source)) { 284 | if (i++ === 2) { 285 | // cause an early exit here. 286 | return; 287 | } 288 | results.push(buffer); 289 | await advancer; 290 | } 291 | } 292 | 293 | const complete = executeTest(); 294 | 295 | source.next(0); 296 | source.next(1); 297 | source.next(2); 298 | await advancer.next(); 299 | expect(results).toEqual([2]); 300 | 301 | // Next batch 302 | source.next(3); 303 | source.next(4); 304 | await advancer.next(); // exit 305 | expect(results).toEqual([2, 4]); 306 | 307 | // loop would have already exited here. 308 | source.next(5); 309 | source.next(6); 310 | await advancer.next(); 311 | expect(results).toEqual([2, 4]); 312 | 313 | await complete; 314 | 315 | expect(results).toEqual([2, 4]); 316 | }); 317 | }); 318 | 319 | describe("nextValueFrom", () => { 320 | test("should work for sync observables", async () => { 321 | const source = of(1, 2, 3); 322 | const results: number[] = []; 323 | for await (const value of nextValueFrom(source)) { 324 | results.push(value); 325 | } 326 | // sync observable would have already completed. 327 | expect(results).toEqual([]); 328 | }); 329 | 330 | test("should throw if the observable errors", async () => { 331 | const source = throwError(new Error("bad")); 332 | let error: any; 333 | try { 334 | for await (const _ of nextValueFrom(source)) { 335 | // do nothing 336 | } 337 | } catch (err) { 338 | error = err; 339 | } 340 | expect(error).toBeInstanceOf(Error); 341 | expect(error.message).toBe("bad"); 342 | }); 343 | 344 | test("should support async observables", async () => { 345 | const source = interval(1).pipe(take(3)); 346 | const results: number[] = []; 347 | for await (const value of nextValueFrom(source)) { 348 | results.push(value); 349 | } 350 | expect(results).toEqual([0, 1, 2]); 351 | }); 352 | 353 | test("a more in-depth test", async () => { 354 | const results: number[] = []; 355 | const source = new Subject(); 356 | const advancer = createAdvancer(); 357 | 358 | async function executeTest() { 359 | for await (let buffer of nextValueFrom(source)) { 360 | results.push(buffer); 361 | await advancer; 362 | } 363 | } 364 | 365 | const complete = executeTest(); 366 | 367 | source.next(0); 368 | source.next(1); 369 | source.next(2); 370 | await advancer.next(); 371 | expect(results).toEqual([0]); 372 | 373 | // Next batch 374 | source.next(3); 375 | source.next(4); 376 | await advancer.next(); 377 | expect(results).toEqual([0, 3]); 378 | 379 | source.next(5); 380 | source.next(6); 381 | 382 | // end the loop 383 | source.complete(); 384 | 385 | await complete; 386 | expect(results).toEqual([0, 3, 5]); 387 | }); 388 | 389 | test("a more in-depth with early exit", async () => { 390 | const results: number[] = []; 391 | const source = new Subject(); 392 | const advancer = createAdvancer(); 393 | 394 | async function executeTest() { 395 | let i = 0; 396 | for await (let buffer of nextValueFrom(source)) { 397 | if (i++ === 2) { 398 | // cause an early exit here. 399 | return; 400 | } 401 | results.push(buffer); 402 | await advancer; 403 | } 404 | } 405 | 406 | const complete = executeTest(); 407 | 408 | source.next(0); 409 | source.next(1); 410 | source.next(2); 411 | await advancer.next(); 412 | expect(results).toEqual([0]); 413 | 414 | // Next batch 415 | source.next(3); 416 | source.next(4); 417 | await advancer.next(); // exit 418 | expect(results).toEqual([0, 3]); 419 | 420 | // loop would have already exited here. 421 | source.next(5); 422 | source.next(6); 423 | await advancer.next(); 424 | expect(results).toEqual([0, 3]); 425 | 426 | await complete; 427 | 428 | expect(results).toEqual([0, 3]); 429 | }); 430 | }); 431 | 432 | /** 433 | * A little trick to get the test to manually advance from 434 | * one test to the next. 435 | */ 436 | function createAdvancer() { 437 | const factory = async function*(): AsyncGenerator { 438 | let prev: any; 439 | while (true) { 440 | prev = yield prev; 441 | } 442 | }; 443 | 444 | const advancer = factory(); 445 | // prime it 446 | advancer.next(); 447 | 448 | const _next = advancer.next.bind(advancer); 449 | advancer.next = async () => { 450 | return _next().then((x) => Promise.resolve(x)); 451 | }; 452 | return advancer; 453 | } 454 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | import { Deferred } from "./util/deferred"; 3 | 4 | const RESOLVED = Promise.resolve(); 5 | 6 | /** 7 | * Will subscribe to the `source` observable provided, 8 | * 9 | * Allowing a `for await..of` loop to iterate over every 10 | * value that the source emits. 11 | * 12 | * **WARNING**: If the async loop is slower than the observable 13 | * producing values, the values will build up in a buffer 14 | * and you could experience an out of memory error. 15 | * 16 | * This is a lossless subscription method. No value 17 | * will be missed or duplicated. 18 | * 19 | * Example usage: 20 | * 21 | * ```ts 22 | * async function test() { 23 | * const source$ = getSomeObservable(); 24 | * 25 | * for await(const value of eachValueFrom(source$)) { 26 | * console.log(value); 27 | * } 28 | * } 29 | * ``` 30 | * 31 | * @param source the Observable source to await values from 32 | */ 33 | export async function* eachValueFrom( 34 | source: Observable 35 | ): AsyncIterableIterator { 36 | const deferreds: Deferred>[] = []; 37 | const values: T[] = []; 38 | let hasError = false; 39 | let error: any = null; 40 | let completed = false; 41 | 42 | const subs = source.subscribe({ 43 | next: value => { 44 | if (deferreds.length > 0) { 45 | deferreds.shift()!.resolve({ value, done: false }); 46 | } else { 47 | values.push(value); 48 | } 49 | }, 50 | error: err => { 51 | hasError = true; 52 | error = err; 53 | while (deferreds.length > 0) { 54 | deferreds.shift()!.reject(err); 55 | } 56 | }, 57 | complete: () => { 58 | completed = true; 59 | while (deferreds.length > 0) { 60 | deferreds.shift()!.resolve({ value: undefined, done: true }); 61 | } 62 | }, 63 | }); 64 | 65 | try { 66 | while (true) { 67 | if (values.length > 0) { 68 | yield values.shift()!; 69 | } else if (completed) { 70 | return; 71 | } else if (hasError) { 72 | throw error; 73 | } else { 74 | const d = new Deferred>(); 75 | deferreds.push(d); 76 | const result = await d.promise; 77 | if (result.done) { 78 | return; 79 | } else { 80 | yield result.value; 81 | } 82 | } 83 | } 84 | } catch (err) { 85 | throw err; 86 | } finally { 87 | subs.unsubscribe(); 88 | } 89 | } 90 | 91 | /** 92 | * Will subscribe to the `source` observable provided 93 | * and build the emitted values up in a buffer. Allowing 94 | * `for await..of` loops to iterate and get the buffer 95 | * on each loop. 96 | * 97 | * This is a lossless subscription method. No value 98 | * will be missed or duplicated. 99 | * 100 | * Example usage: 101 | * 102 | * ```ts 103 | * async function test() { 104 | * const source$ = getSomeObservable(); 105 | * 106 | * for await(const buffer of bufferedValuesFrom(source$)) { 107 | * for (const value of buffer) { 108 | * console.log(value); 109 | * } 110 | * } 111 | * } 112 | * ``` 113 | * 114 | * @param source the Observable source to await values from 115 | */ 116 | export async function* bufferedValuesFrom(source: Observable) { 117 | let deferred: Deferred> | null = null; 118 | const buffer: T[] = []; 119 | let hasError = false; 120 | let error: any = null; 121 | let completed = false; 122 | 123 | const subs = source.subscribe({ 124 | next: value => { 125 | if (deferred) { 126 | deferred.resolve( 127 | RESOLVED.then(() => { 128 | const bufferCopy = buffer.slice(); 129 | buffer.length = 0; 130 | return { value: bufferCopy, done: false }; 131 | }) 132 | ); 133 | deferred = null; 134 | } 135 | buffer.push(value); 136 | }, 137 | error: err => { 138 | hasError = true; 139 | error = err; 140 | if (deferred) { 141 | deferred.reject(err); 142 | deferred = null; 143 | } 144 | }, 145 | complete: () => { 146 | completed = true; 147 | if (deferred) { 148 | deferred.resolve({ value: undefined, done: true }); 149 | deferred = null; 150 | } 151 | }, 152 | }); 153 | 154 | try { 155 | while (true) { 156 | if (buffer.length > 0) { 157 | const bufferCopy = buffer.slice(); 158 | buffer.length = 0; 159 | yield bufferCopy; 160 | } else if (completed) { 161 | return; 162 | } else if (hasError) { 163 | throw error; 164 | } else { 165 | deferred = new Deferred>(); 166 | const result = await deferred.promise; 167 | if (result.done) { 168 | return; 169 | } else { 170 | yield result.value; 171 | } 172 | } 173 | } 174 | } catch (err) { 175 | throw err; 176 | } finally { 177 | subs.unsubscribe(); 178 | } 179 | } 180 | 181 | /** 182 | * Will subscribe to the provided `source` observable, 183 | * allowing `for await..of` loops to iterate and get the 184 | * most recent value that was emitted. Will not iterate out 185 | * the same emission twice. 186 | * 187 | * This is a lossy subscription method. Do not use if 188 | * every value is important. 189 | * 190 | * Example usage: 191 | * 192 | * ```ts 193 | * async function test() { 194 | * const source$ = getSomeObservable(); 195 | * 196 | * for await(const value of latestValueFrom(source$)) { 197 | * console.log(value); 198 | * } 199 | * } 200 | * ``` 201 | * 202 | * @param source the Observable source to await values from 203 | */ 204 | export async function* latestValueFrom(source: Observable) { 205 | let deferred: Deferred> | undefined = undefined; 206 | let latestValue: T; 207 | let hasLatestValue = false; 208 | let hasError = false; 209 | let error: any = null; 210 | let completed = false; 211 | 212 | const subs = source.subscribe({ 213 | next: value => { 214 | hasLatestValue = true; 215 | latestValue = value; 216 | if (deferred) { 217 | deferred.resolve( 218 | RESOLVED.then(() => { 219 | hasLatestValue = false; 220 | return { value: latestValue, done: false }; 221 | }) 222 | ); 223 | } 224 | }, 225 | error: err => { 226 | hasError = true; 227 | error = err; 228 | if (deferred) { 229 | deferred.reject(err); 230 | } 231 | }, 232 | complete: () => { 233 | completed = true; 234 | if (deferred) { 235 | hasLatestValue = false; 236 | deferred.resolve({ value: undefined, done: true }); 237 | } 238 | }, 239 | }); 240 | 241 | try { 242 | while (true) { 243 | if (hasLatestValue) { 244 | await RESOLVED; 245 | const value = latestValue!; 246 | hasLatestValue = false; 247 | yield value; 248 | } else if (completed) { 249 | return; 250 | } else if (hasError) { 251 | throw error; 252 | } else { 253 | deferred = new Deferred>(); 254 | const result = await deferred.promise; 255 | if (result.done) { 256 | return; 257 | } else { 258 | yield result.value; 259 | } 260 | } 261 | } 262 | } catch (err) { 263 | throw err; 264 | } finally { 265 | subs.unsubscribe(); 266 | } 267 | } 268 | 269 | /** 270 | * Subscribes to the provided `source` observable and allows 271 | * `for await..of` loops to iterate over it, such that 272 | * all values are dropped until the iteration occurs, then 273 | * the very next value that arrives is provided to the 274 | * `for await` loop. 275 | * 276 | * This is a lossy subscription method. Do not use if 277 | * every value is important. 278 | * 279 | * Example usage: 280 | * 281 | * ```ts 282 | * async function test() { 283 | * const source$ = getSomeObservable(); 284 | * 285 | * for await(const value of nextValueFrom(source$)) { 286 | * console.log(value); 287 | * } 288 | * } 289 | * ``` 290 | * 291 | * @param source the Observable source to await values from 292 | */ 293 | export async function* nextValueFrom( 294 | source: Observable 295 | ): AsyncGenerator { 296 | let deferred: Deferred> | undefined = undefined; 297 | let hasError = false; 298 | let error: any = null; 299 | let completed = false; 300 | 301 | const subs = source.subscribe({ 302 | next: value => { 303 | if (deferred) { 304 | deferred.resolve({ value, done: false }); 305 | } 306 | }, 307 | error: err => { 308 | hasError = true; 309 | error = err; 310 | if (deferred) { 311 | deferred.reject(err); 312 | } 313 | }, 314 | complete: () => { 315 | completed = true; 316 | if (deferred) { 317 | deferred.resolve({ value: undefined, done: true }); 318 | } 319 | }, 320 | }); 321 | 322 | try { 323 | while (true) { 324 | if (completed) { 325 | return; 326 | } else if (hasError) { 327 | throw error; 328 | } else { 329 | deferred = new Deferred>(); 330 | const result = await deferred.promise; 331 | if (result.done) { 332 | return; 333 | } else { 334 | yield result.value; 335 | } 336 | } 337 | } 338 | } catch (err) { 339 | throw err; 340 | } finally { 341 | subs.unsubscribe(); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/util/deferred.ts: -------------------------------------------------------------------------------- 1 | export class Deferred { 2 | resolve: (value: T | PromiseLike) => void = null!; 3 | reject: (reason?: any) => void = null!; 4 | promise = new Promise((a, b) => { 5 | this.resolve = a; 6 | this.reject = b; 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "es2020", 6 | "outDir": "./dist/esm", 7 | "importHelpers": true 8 | } 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 7 | "lib": ["es2020"], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist/cjs", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | 63 | /* Advanced Options */ 64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "module": "es2015", 6 | "target": "esnext", 7 | "removeComments": false, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "declarationDir": "./dist/types", 11 | "emitDeclarationOnly": true 12 | } 13 | } --------------------------------------------------------------------------------