├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── publish.yaml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── README.md ├── package-lock.json ├── package.json ├── src ├── defer.spec.ts ├── defer.ts ├── index.spec.ts ├── index.ts ├── otag.spec.ts └── otag.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "standard-with-typescript" 11 | ], 12 | "plugins": ["@typescript-eslint"], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "./tsconfig.json" 16 | }, 17 | "ignorePatterns": [ 18 | "node_modules", 19 | "dist", 20 | ".eslintrc.*" 21 | ], 22 | "rules": { 23 | "comma-dangle": "off", 24 | "@typescript-eslint/comma-dangle": ["error", "always-multiline"], 25 | 26 | "indent": "off", 27 | "@typescript-eslint/indent": ["error", 2, { 28 | "SwitchCase": 1, 29 | "flatTernaryExpressions": false, 30 | "ignoredNodes": [ 31 | "PropertyDefinition[decorators]", 32 | "TSUnionType", 33 | "FunctionExpression[params]:has(Identifier[decorators])" 34 | ] 35 | }], 36 | 37 | "@typescript-eslint/interface-name-prefix": "off", 38 | "@typescript-eslint/space-before-function-paren": "off", 39 | "@typescript-eslint/strict-boolean-expressions": "off", 40 | "@typescript-eslint/no-misused-promises": "off", 41 | "@typescript-eslint/restrict-template-expressions": "off", // FIXME: the rule seems to be broken 42 | "@typescript-eslint/method-signature-style": "off", 43 | 44 | "semi": "off", 45 | "space-before-function-paren": "off" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: 'github-actions' 14 | directory: '/' 15 | schedule: 16 | interval: 'weekly' 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish new release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | check-version-match: 10 | name: Check versions match in package* files 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version-file: '/.nvmrc' 17 | # versions match => equality is true => inequality is false => Number(false) is 0 => process.exit(0) 18 | # versions differ => equality is false => inequality is true => Number(true) is 1 => process.exit(1) 19 | - run: |2 20 | node -e "process.exit(require('./package.json').version !== require('./package-lock.json').version)" 21 | 22 | check-version-new: 23 | name: Check version is new 24 | runs-on: ubuntu-20.04 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: '/.nvmrc' 30 | - name: Assert version is not published 31 | run: |2 32 | set -e 33 | 34 | package_name=$(node -pe "require('./package.json').name;") 35 | incoming_version=$(node -pe "require('./package.json').version;") 36 | 37 | if [ ! -z "$(npm view $package_name@$incoming_version version)" ]; then 38 | echo "This version ($incoming_version) of $package_name is already published; please, update version by running 'npm version '" 39 | exit 1 40 | fi 41 | 42 | check-no-deps: 43 | name: Check no dependencies 44 | runs-on: ubuntu-20.04 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version-file: '/.nvmrc' 50 | - name: Assert 'dependencies' object in package.json is empty 51 | run: |2 52 | node -e "const deps = Object.keys(require('./package.json').dependencies || {}); assert.equal(deps.length, 0, 'Claimed to have no dependencies, instead found: ' + deps.join(', '))" 53 | 54 | # TODO: maybe also check that the branch is 'main' 55 | 56 | lint: 57 | needs: 58 | - check-version-match 59 | - check-version-new 60 | - check-no-deps 61 | runs-on: ubuntu-20.04 62 | steps: 63 | - uses: actions/checkout@v4 64 | - uses: actions/setup-node@v4 65 | with: 66 | node-version-file: '/.nvmrc' 67 | - run: npm ci 68 | - run: npm run lint 69 | 70 | test: 71 | name: Run tests on ${{ matrix.os }} 72 | runs-on: ${{ matrix.os }} 73 | strategy: 74 | matrix: 75 | os: 76 | - ubuntu-latest 77 | - windows-latest 78 | - macos-latest 79 | needs: 80 | - lint 81 | steps: 82 | - uses: actions/checkout@v4 83 | - uses: actions/setup-node@v4 84 | with: 85 | node-version-file: '/.nvmrc' 86 | - run: npm ci 87 | - run: npm t 88 | 89 | publish: 90 | name: Publish version 91 | runs-on: ubuntu-20.04 92 | needs: 93 | - test 94 | steps: 95 | - uses: actions/checkout@v4 96 | - uses: actions/setup-node@v4 97 | with: 98 | node-version-file: '/.nvmrc' 99 | - run: npm ci 100 | - run: npm run build 101 | - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_AUTH_TOKEN }}" > .npmrc 102 | - run: npm publish --tag latest 103 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test on any push 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version-file: '/.nvmrc' 13 | 14 | - run: npm ci 15 | - run: npm run lint 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": false, 3 | "typescript.tsdk": "node_modules\\typescript\\lib", 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `observable-to-async-generator` 2 | 3 | Convert an observable to ES6 async generator. 4 | 5 | ### Why `observable-to-async-generator`? 6 | 7 | - it has no dependencies; 8 | - it is always 100% covered with unit tests; 9 | - it is written in TypeScript; 10 | - it can be [extended with a PR](https://github.com/parzh/observable-to-async-generator/fork); 11 | 12 | # Import 13 | 14 | ```ts 15 | import otag from "observable-to-async-generator"; 16 | ``` 17 | 18 | … or: 19 | 20 | ```js 21 | const otag = require("observable-to-async-generator").default; 22 | ``` 23 | 24 | # Usage 25 | 26 | ```ts 27 | try { 28 | for await (const item of otag(observable)) { 29 | doSomethingWith(item); 30 | } 31 | } catch (error) { 32 | handle(error); 33 | } 34 | ``` 35 | 36 | # Notes 37 | 38 | - `rxjs` is a peer dependency for this package; it is primarily used to add types on the development stage. These type imports are then removed from the JavaScript output, but are still present in `*.d.ts` files. In case if the type information is needed to you (for example, if your package/application is also written in TypeScript), you should install `rxjs` manually; inspect the `peerDependencies` object inside of [`observable-to-async-generator`'s `package.json` file](https://github.com/parzh/observable-to-async-generator/blob/v1.0.1-rc/package.json) to find the appropriate version of `rxjs` to install. 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "observable-to-async-generator", 3 | "description": "Convert an observable to ES6 async generator.", 4 | "version": "1.0.5", 5 | "author": "Dima Parzhitsky ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/parzh/observable-to-async-generator.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/parzh/observable-to-async-generator/issues" 13 | }, 14 | "homepage": "https://github.com/parzh/observable-to-async-generator#readme", 15 | "keywords": [ 16 | "*", 17 | "async", 18 | "await", 19 | "convert", 20 | "create", 21 | "function*", 22 | "function *", 23 | "generator", 24 | "iterable", 25 | "iterator", 26 | "observable", 27 | "otag", 28 | "yield", 29 | "yield*", 30 | "yield *" 31 | ], 32 | "files": [ 33 | "/dist", 34 | "!/dist/**/*.{spec,test}.*", 35 | "/README.md" 36 | ], 37 | "main": "dist/index.js", 38 | "types": "dist/index.d.ts", 39 | "jest": { 40 | "preset": "ts-jest", 41 | "testEnvironment": "node", 42 | "verbose": true, 43 | "clearMocks": true, 44 | "rootDir": ".", 45 | "testMatch": [ 46 | "/src/**/*.{spec,test}.ts" 47 | ], 48 | "collectCoverage": true, 49 | "collectCoverageFrom": [ 50 | "/src/**/*.ts", 51 | "!/{src,test}/**/*.{spec,test}.ts" 52 | ], 53 | "coverageDirectory": "coverage", 54 | "coverageReporters": [ 55 | "text", 56 | "html" 57 | ], 58 | "coverageThreshold": { 59 | "global": { 60 | "branches": 100, 61 | "functions": 100, 62 | "lines": 100, 63 | "statements": 100 64 | } 65 | } 66 | }, 67 | "scripts": { 68 | "test": "jest", 69 | "lint": "eslint \"**/*.ts\"", 70 | "start": "node --require ts-node/register src", 71 | "prebuild": "rm -rf dist/*", 72 | "build": "tsc --project ./tsconfig.build.json" 73 | }, 74 | "devDependencies": { 75 | "@types/jest": "26.0.23", 76 | "@types/node": "22.8.7", 77 | "@typescript-eslint/eslint-plugin": "6.21.0", 78 | "@typescript-eslint/parser": "6.21.0", 79 | "eslint": "8.57.1", 80 | "eslint-config-standard-with-typescript": "43.0.1", 81 | "eslint-plugin-import": "2.31.0", 82 | "eslint-plugin-n": "16.6.2", 83 | "eslint-plugin-promise": "6.6.0", 84 | "jest": "27.0.3", 85 | "rxjs": "7.8.1", 86 | "ts-jest": "27.0.3", 87 | "ts-node": "10.9.2", 88 | "typescript": "4.9.5" 89 | }, 90 | "peerDependencies": { 91 | "rxjs": "6 || 7" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/defer.spec.ts: -------------------------------------------------------------------------------- 1 | import defer from './defer' 2 | 3 | describe(defer, () => { 4 | it('should create a Promise-like object with exposed `.resolve()` and `.reject()`', () => { 5 | const deferred = defer<42>() 6 | 7 | expect(deferred).toBeInstanceOf(Promise) 8 | expect(deferred).toHaveProperty('resolve', expect.any(Function)) 9 | expect(deferred).toHaveProperty('reject', expect.any(Function)) 10 | }) 11 | 12 | it('should allow manually resolving the promise', (done) => { 13 | const deferred = defer<42>() 14 | 15 | void deferred 16 | .then((value) => { 17 | expect(value).toBe(42) 18 | }) 19 | .finally(done) 20 | 21 | deferred.resolve(42) 22 | }) 23 | 24 | it('should allow manually rejecting the promise', (done) => { 25 | const error = new Error('Unexpected error') 26 | const deferred = defer() 27 | 28 | void deferred 29 | .catch((caught: Error) => { 30 | expect(caught).toBe(error) 31 | }) 32 | .finally(done) 33 | 34 | deferred.reject(error) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/defer.ts: -------------------------------------------------------------------------------- 1 | /** @internal */ 2 | export interface Deferred extends Promise { 3 | resolve(value?: Value): void 4 | reject(error: Error): void 5 | } 6 | 7 | /** @internal */ 8 | // eslint-disable-next-line @typescript-eslint/promise-function-async 9 | export default function defer(): Deferred { 10 | const transit = Object.create(null) as Deferred 11 | const promise = new Promise((resolve, reject) => { 12 | Object.assign(transit, { resolve, reject }) // eslint-disable-line @typescript-eslint/no-floating-promises 13 | }) 14 | 15 | return Object.assign(promise, transit) 16 | } 17 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { otag } from './otag' 2 | import * as indexfile from '.' 3 | 4 | describe('default namespace (indexfile)', () => { 5 | it('should contain the `otag` function as its default entity', () => { 6 | expect(indexfile.default).toBe(otag) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { otag as default } from './otag' 2 | -------------------------------------------------------------------------------- /src/otag.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subject, concat, from, throwError } from 'rxjs' 2 | import { otag } from './otag' 3 | 4 | /** @private */ 5 | function createSubject(): Observable<42> { 6 | const subject = new Subject<42>() 7 | 8 | void new Promise((resolve) => { 9 | const iterations = 3 10 | 11 | for (let i = 0; i < iterations; i++) { 12 | setTimeout(() => { 13 | subject.next(42) 14 | }, 200 * i) 15 | } 16 | 17 | setTimeout(() => { 18 | subject.complete() 19 | resolve() 20 | }, 200 * (iterations + 1)) 21 | }) 22 | 23 | return subject 24 | } 25 | 26 | /** @private */ 27 | function createObservable(): Observable<42> { 28 | return from([42, 42, 42] as const) 29 | } 30 | 31 | /** @private */ 32 | function createErroneousSubject(errorLike: unknown): Observable<42> { 33 | return concat( 34 | createSubject(), 35 | throwError(() => errorLike), 36 | ) 37 | } 38 | 39 | /** @private */ 40 | function createErroneousObservable(errorLike: unknown): Observable<42> { 41 | return concat( 42 | createObservable(), 43 | throwError(() => errorLike), 44 | ) 45 | } 46 | 47 | describe(otag, () => { 48 | it('should convert observables into async generators', () => { 49 | const generator = otag(new Observable()) 50 | 51 | expect(generator).toHaveProperty('next', expect.any(Function)) 52 | expect(generator).toHaveProperty('return', expect.any(Function)) 53 | expect(generator).toHaveProperty('throw', expect.any(Function)) 54 | expect(generator).toHaveProperty([Symbol.asyncIterator], expect.any(Function)) 55 | }) 56 | 57 | it('should allow iterating over items in observable, using `for await .. of` construct', async () => { 58 | for (const create of [createSubject, createObservable]) { 59 | const observable = create() 60 | const values: Array<42> = [] 61 | 62 | for await (const value of otag(observable)) { 63 | expect(value).toBe(42) 64 | values.push(value) 65 | } 66 | 67 | expect(values).toStrictEqual([42, 42, 42]) 68 | } 69 | }) 70 | 71 | test.each([ 72 | ['an error', new Error('Unexpected error'), new Error('Unexpected error')], 73 | ['a string', 'Unexpected error', new Error('Unexpected error')], 74 | ] as const)('should throw if observable emits %s', async (name, errorLike, caughtExpected) => { 75 | for (const createErroneous of [createErroneousSubject, createErroneousObservable]) { 76 | const observable = createErroneous(errorLike) 77 | const values: Array<42> = [] 78 | 79 | try { 80 | for await (const value of otag(observable)) { 81 | expect(value).toBe(42) 82 | values.push(value) 83 | } 84 | } catch (caught) { 85 | expect(caught).toEqual(caughtExpected) 86 | } 87 | 88 | expect(values).toStrictEqual([42, 42, 42]) 89 | } 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /src/otag.ts: -------------------------------------------------------------------------------- 1 | //! Credit goes to https://stackoverflow.com/a/44123368/4554883 2 | 3 | import { type Observable, type Observer } from 'rxjs' 4 | import defer, { type Deferred } from './defer' 5 | 6 | /** @private */ 7 | class Carrier implements Observer { 8 | protected deferred = defer() 9 | protected finished = false 10 | 11 | // eslint-disable-next-line @typescript-eslint/promise-function-async 12 | getValue(): Deferred { 13 | return this.deferred 14 | } 15 | 16 | isFinished(): boolean { 17 | return this.finished 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/promise-function-async 21 | protected spawnDeferred(): Deferred { 22 | const deferred = this.deferred 23 | this.deferred = defer() 24 | return deferred 25 | } 26 | 27 | next(value: Value): void { 28 | setImmediate(() => { 29 | this.spawnDeferred().resolve(value) 30 | }) 31 | } 32 | 33 | protected convertToError(value: unknown): Error { 34 | if (value instanceof Error) { 35 | return value 36 | } 37 | 38 | return new Error(String(value)) 39 | } 40 | 41 | error(value: unknown): void { 42 | const error = this.convertToError(value) 43 | 44 | setImmediate(() => { 45 | this.spawnDeferred().reject(error) 46 | }) 47 | } 48 | 49 | // has to be a function expression 50 | private readonly doComplete = (): void => { 51 | this.finished = true 52 | this.deferred.resolve() 53 | } 54 | 55 | complete(): void { 56 | setImmediate(this.doComplete) 57 | } 58 | } 59 | 60 | export async function * otag(observable: Observable): AsyncIterableIterator { 61 | const valueCarrier = new Carrier() 62 | const subscription = observable.subscribe(valueCarrier) 63 | 64 | try { 65 | while (true) { 66 | const value = await valueCarrier.getValue() 67 | 68 | if (valueCarrier.isFinished()) { 69 | break 70 | } 71 | 72 | yield value 73 | } 74 | } finally { 75 | subscription.unsubscribe() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src", 5 | ], 6 | "exclude": [ 7 | "**/*.spec.ts", 8 | ], 9 | "compilerOptions": { 10 | "noEmit": false, 11 | "outDir": "dist", 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Output 4 | "target": "es2020", 5 | "module": "commonjs", 6 | "useDefineForClassFields": false, 7 | "noEmit": true, // TODO: override in the derived configs if needed 8 | "sourceMap": true, 9 | "declaration": true, 10 | "declarationMap": true, 11 | 12 | // Development 13 | "strict": true, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitOverride": true, 17 | "resolveJsonModule": true, 18 | "experimentalDecorators": true, 19 | "emitDecoratorMetadata": true, 20 | "skipLibCheck": true, 21 | }, 22 | } 23 | --------------------------------------------------------------------------------