├── .editorconfig ├── .github └── workflows │ └── run-tests.yml ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── configs ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.es2015.json ├── tsconfig.esm.json ├── tsconfig.types.json ├── webpack.base.js ├── webpack.build.js ├── webpack.build.min.js └── webpack.dev.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── behavior.ts ├── core │ ├── proxy.ts │ ├── shared.ts │ └── types.ts ├── index.ts ├── observable.ts ├── proxify.ts ├── statify.ts └── subject.ts ├── tests ├── behavior.test.ts ├── helpers.ts ├── observable.test.ts ├── state.test.ts └── subject.test.ts ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 120 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Cache Node.js modules 26 | uses: actions/cache@v2 27 | with: 28 | # npm cache files are stored in `~/.npm` on Linux/macOS 29 | path: ~/.npm 30 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.OS }}-node- 33 | ${{ runner.OS }}- 34 | - run: npm ci 35 | - run: npm run format:check 36 | - run: npm test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Output 64 | dist 65 | 66 | # Editor settings 67 | .vscode 68 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "tsc --noEmit && lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.json": "prettier --write", 3 | "*.js": "prettier --write", 4 | "*.{js,tsx}": "prettier --write", 5 | "*.yaml": "prettier --write" 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /package.json 2 | /package-lock.json 3 | /examples 4 | /dist 5 | *.md 6 | types.ts 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "proseWrap": "always", 6 | "htmlWhitespaceSensitivity": "ignore" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 rxjs-proxify 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 |
2 |

3 |
4 | { 👓 } 5 |
6 | Turn a Stream of Objects into an Object of Streams 7 |
8 |
9 | NPM 10 | Bundlephobia 11 | MIT license 12 |
13 |
14 |
15 |

16 |
17 | 18 | Access values inside RxJS Observables as if they were directly available on the stream! 19 | 20 | ```ts 21 | stream.pipe(pluck('msg')).subscribe(…); 22 | // turn ↑ into ↓ 23 | stream.msg.subscribe(…); 24 | ``` 25 | 26 | With good TypeScript support! 😲 Roughly speaking: 27 | 28 | ```ts 29 | proxify( Observable<{ msg: string }> ) ≈ Observable<{ msg: string }> & { msg: Observable } 30 | ``` 31 | 32 | **But recursively.** So `stream.msg` is a Proxy itself, allowing you to `stream.msg.length.subscribe(…)`! 33 | 34 | Proxify lets you **access Observable API** as well as **pluck props** and **call methods** at any depth of an Observable, Subject, or BehaviorSubject! See the [API](#-api) and [Examples](#-examples) sections to learn more. 35 | 36 | 37 | ## 📦 Install 38 | 39 | ``` 40 | npm i rxjs-proxify 41 | ``` 42 | 43 | or [try it online](https://stackblitz.com/edit/rxjs-proxify-repl?file=index.ts)! 44 | 45 | ## 🛠 API 46 | 47 | There are two methods available to you: [`proxify`](#proxify) and [`statify`](#statify) 48 | 49 | ## Proxify 50 | 51 | `proxify(stream)` will wrap your Observable, Subject or BehaviorSubject in a Proxy: 52 | 53 | **Observable Proxy** 54 | subscribe at any depth 55 | 56 | ```ts 57 | const observable = proxify( of({ p: '🐑' }) ); 58 | observable.subscribe(console.log); // > { p: 🐑 } 59 | observable.p.subscribe(console.log); // > 🐑 60 | ``` 61 | 62 | **Subject Proxy** 63 | subscribe at any depth, push at the root 64 | 65 | ```ts 66 | const subject = proxify(new Subject<{ p: string }>()); 67 | subject.subscribe(console.log); 68 | subject.p.subscribe(console.log); 69 | subject.next({ p: '🐥' }); // > { p: 🐥 } // > 🐥 70 | ``` 71 | 72 | **BehaviorSubject Proxy** 73 | subscribe at any depth, push at any depth, synchronously read the current state 74 | 75 | ```ts 76 | const behavior = proxify(new BehaviorSubject({ p: '🐖' })); 77 | behavior.p.subscribe(console.log); // > 🐖 78 | behavior.p.next('🐇'); // > 🐇 79 | console.log(behavior.p.value) // > 🐇 80 | ``` 81 | 82 | ### Statify 83 | 84 | `statify(value)` will put the value in a BehaviorSubject Proxy and add a `distinctUntilChanged` operator on each property access. 85 | 86 | **State Proxy** 87 | subscribe to distinct updates at any depth, push at any depth, synchronously read the current state 88 | 89 | ```ts 90 | // create a state 91 | const state = statify({ a: '🐰', z: '🏡' }); 92 | 93 | // listen to & log root state changes 94 | state.subscribe(console.log); //> { a:🐰 z:🏡 } 95 | 96 | // update particular substate 97 | state.a.next('🐇'); //> { a:🐇 z:🏡 } 98 | 99 | // read current values 100 | console.log(state.z.value + state.a.value); //> 🏡🐇 101 | 102 | // update root state, still logging 103 | state.next({ a: '🐇', z: '☁️' }) //> { a:🐇 z:☁️ } 104 | 105 | // and then… 106 | state.z.next('🌙'); //> { a:🐇 z:🌙 } 107 | state.a.next('🐇👀'); //> { a:🐇👀 z:🌙 } 108 | state.z.next('🛸') //> { a:🐇👀 z:🛸 } 109 | state.a.next('💨'); //> { a:💨 z:🛸 } 110 | ``` 111 | 112 | See Examples section for more details. 113 | 114 | ## 📖 Examples 115 | 116 | ### Basic 117 | 118 | ```ts 119 | import { proxify } from "rxjs-proxify"; 120 | import { of } from "rxjs"; 121 | 122 | const o = of({ msg: 'Hello' }, { msg: 'World' }); 123 | const p = proxify(o); 124 | p.msg.subscribe(console.log); 125 | 126 | // equivalent to 127 | // o.pipe(pluck('msg')).subscribe(console.log); 128 | ``` 129 | 130 | ### With JS destructuring 131 | 132 | Convenient stream props splitting 133 | 134 | ```ts 135 | import { proxify } from "rxjs-proxify"; 136 | import { of } from "rxjs"; 137 | 138 | const o = of({ msg: 'Hello', status: 'ok' }, { msg: 'World', status: 'ok' }); 139 | const { msg, status } = proxify(o); 140 | msg.subscribe(console.log); 141 | status.subscribe(console.log); 142 | 143 | // equivalent to 144 | // const msg = o.pipe(pluck('msg')); 145 | // const status = o.pipe(pluck('status')); 146 | // msg.subscribe(console.log); 147 | // status.subscribe(console.log); 148 | ``` 149 | 150 | **⚠️ WARNING:** as shown in "equivalent" comment, this operation creates several Observables from the source Observable. Which means that if your source is _cold_ — then you might get undesired subscriptions. This is a well-known nuance of working with Observables. To avoid this, you can use a multicasting operator on source before applying `proxify`, e.g. with [`shareReplay`](https://rxjs.dev/api/operators/shareReplay): 151 | 152 | ```ts 153 | const { msg, status } = proxify(o.pipe(shareReplay(1))); 154 | ``` 155 | 156 | ### With pipe 157 | 158 | Concatenate all messages using `pipe` with `scan` operator: 159 | 160 | ```ts 161 | import { proxify } from "rxjs-proxify"; 162 | import { of } from "rxjs"; 163 | import { scan } from "rxjs/operators"; 164 | 165 | const o = of({ msg: 'Hello' }, { msg: 'World' }); 166 | const p = proxify(o); 167 | p.msg.pipe(scan((a,c)=> a + c)).subscribe(console.log); 168 | 169 | // equivalent to 170 | // o.pipe(pluck('msg'), scan((a,c)=> a + c)).subscribe(console.log); 171 | ``` 172 | 173 | ### Calling methods 174 | 175 | Pick a method and call it: 176 | 177 | ```ts 178 | import { proxify } from "rxjs-proxify"; 179 | import { of } from "rxjs"; 180 | 181 | const o = of({ msg: () => 'Hello' }, { msg: () => 'World' }); 182 | const p = proxify(o); 183 | p.msg().subscribe(console.log); 184 | 185 | // equivalent to 186 | // o.pipe(map(x => x?.map())).subscribe(console.log); 187 | ``` 188 | 189 | ### Accessing array values 190 | 191 | Proxify is recursive, so you can keep chaining props or indices 192 | 193 | ```ts 194 | import { proxify } from "rxjs-proxify"; 195 | import { of } from "rxjs"; 196 | 197 | const o = of({ msg: () => ['Hello'] }, { msg: () => ['World'] }); 198 | const p = proxify(o); 199 | p.msg()[0].subscribe(console.log); 200 | 201 | // equivalent to 202 | // o.pipe(map(x => x?.map()), pluck(0)).subscribe(console.log); 203 | ``` 204 | 205 | ## 🤝 Want to contribute to this project? 206 | 207 | That will be awesome! 208 | 209 | Please create an issue before submiting a PR — we'll be able to discuss it first! 210 | 211 | Thanks! 212 | 213 | ## Enjoy 🙂 214 | -------------------------------------------------------------------------------- /configs/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "outDir": "../dist/", 5 | "target": "es5", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "importHelpers": false, 9 | "downlevelIteration": false, 10 | "noImplicitAny": false, 11 | "noUnusedLocals": false, 12 | "lib": ["es5"], 13 | "types": ["node"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /configs/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/cjs", 5 | "module": "commonjs" 6 | }, 7 | "include": ["../src/index.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /configs/tsconfig.es2015.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/es2015", 5 | "target": "es2015" 6 | }, 7 | "include": ["../src/index.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /configs/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/esm" 5 | }, 6 | "include": ["../src/index.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /configs/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/types", 5 | "emitDeclarationOnly": true, 6 | "declaration": true, 7 | "declarationMap": true 8 | }, 9 | "include": ["../src/index.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /configs/webpack.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | output: { 6 | filename: 'index.js', 7 | path: path.resolve(__dirname, '../dist'), 8 | library: 'rxjs-proxify', 9 | libraryTarget: 'umd', 10 | publicPath: '/dist/', 11 | umdNamedDefine: true, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /.ts$/, 17 | exclude: /node_modules/, 18 | use: [ 19 | { 20 | loader: 'ts-loader', 21 | options: { 22 | configFile: 'configs/tsconfig.esm.json', 23 | }, 24 | }, 25 | ], 26 | }, 27 | ], 28 | }, 29 | resolve: { 30 | extensions: ['.ts'], 31 | }, 32 | externals: [ 33 | // externalisation of rxjs 34 | // copied from https://github.com/jayphelps/webpack-rxjs-externals/ 35 | function rxjsExternals(context, request, callback) { 36 | if (request.match(/^rxjs(\/|$)/)) { 37 | const parts = request.split('/'); 38 | 39 | return callback(null, { 40 | root: parts, 41 | commonjs: request, 42 | commonjs2: request, 43 | amd: request, 44 | }); 45 | } 46 | 47 | callback(); 48 | }, 49 | ], 50 | }; 51 | -------------------------------------------------------------------------------- /configs/webpack.build.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | module.exports = merge(baseConfig, { 5 | mode: 'development', 6 | }); 7 | -------------------------------------------------------------------------------- /configs/webpack.build.min.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | module.exports = merge(baseConfig, { 5 | mode: 'production', 6 | }); 7 | -------------------------------------------------------------------------------- /configs/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | module.exports = merge(baseConfig, { 6 | mode: 'development', 7 | watch: true, 8 | plugins: [new CleanWebpackPlugin()], 9 | }); 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-proxify", 3 | "version": "0.1.1", 4 | "description": "Turns a Stream of Objects into an Object of Streams", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.js", 7 | "es2015": "./dist/es2015/index.js", 8 | "types": "./dist/types/index.d.ts", 9 | "unpkg": "./dist/rxjs-proxify.min.js", 10 | "sideEffects": true, 11 | "scripts": { 12 | "start": "webpack --config configs/webpack.dev.js", 13 | "clean": "rimraf temp dist", 14 | "build": "npm run build:esm && npm run build:es2015 && npm run build:cjs && npm run build:types && npm run build:umd && npm run build:umd:min", 15 | "build:esm": "tsc -p configs/tsconfig.esm.json", 16 | "build:es2015": "tsc -p configs/tsconfig.es2015.json", 17 | "build:cjs": "tsc -p configs/tsconfig.cjs.json", 18 | "build:types": "tsc -p configs/tsconfig.types.json", 19 | "build:umd": "webpack --config configs/webpack.build.js -o dist/rxjs-proxify.js", 20 | "build:umd:min": "webpack --config configs/webpack.build.min.js -o dist/rxjs-proxify.min.js", 21 | "test": "jest", 22 | "test:watch": "jest --watch", 23 | "test:debug": "node --inspect node_modules/.bin/jest --watch --runInBand", 24 | "np": "npm run clean && npm run build && np", 25 | "format:check": "prettier -c .", 26 | "format:fix": "prettier --write .", 27 | "format": "npm run format:fix" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/kosich/rxjs-proxify.git" 32 | }, 33 | "keywords": [ 34 | "rxjs", 35 | "rp", 36 | "frp", 37 | "angular", 38 | "javascript", 39 | "typescript" 40 | ], 41 | "author": "Kostiantyn Palchyk", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/kosich/rxjs-proxify/issues" 45 | }, 46 | "homepage": "https://github.com/kosich/rxjs-proxify#readme", 47 | "devDependencies": { 48 | "@types/jest": "26.0.14", 49 | "clean-webpack-plugin": "3.0.0", 50 | "jest": "26.4.2", 51 | "np": "6.5.0", 52 | "prettier": "2.1.2", 53 | "rimraf": "3.0.2", 54 | "rxjs": "6.5.0", 55 | "ts-jest": "26.3.0", 56 | "ts-loader": "8.0.3", 57 | "typescript": "4.0.2", 58 | "webpack": "4.44.2", 59 | "webpack-cli": "3.3.12", 60 | "webpack-merge": "5.1.4" 61 | }, 62 | "peerDependencies": { 63 | "rxjs": "^6.5.0" 64 | }, 65 | "files": [ 66 | "dist" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /src/behavior.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs'; 2 | import { coreProxy } from './core/proxy'; 3 | import { BehaviorSubjectProxy, Key, Path } from './core/types'; 4 | 5 | export function behaviorSubject(source$: BehaviorSubject, distinct?: boolean): BehaviorSubjectProxy { 6 | const rootGetter = () => source$.value; 7 | 8 | const setter = deepSetter(rootGetter, ns => void source$.next(ns)); 9 | 10 | const getOverride = (ps: Path, p: Key) => { 11 | const readValue = () => deepGetter(rootGetter)(ps); 12 | 13 | const overrides = { 14 | value: readValue, 15 | getValue: () => readValue, 16 | next: () => value => { 17 | if (!distinct || value !== readValue()) { 18 | setter(ps, value); 19 | } 20 | }, 21 | error: () => e => source$.error(e), 22 | complete: () => () => source$.complete(), 23 | }; 24 | 25 | return overrides[p]; 26 | }; 27 | 28 | return coreProxy(source$, [], getOverride, distinct) as BehaviorSubjectProxy; 29 | } 30 | 31 | // poor man's getter and setter 32 | type Getter = (ps: Path) => any; 33 | export function deepGetter(getRoot: () => T): Getter { 34 | return (ps: Path) => { 35 | return ps.reduce((a, c) => (a ?? {})[c], getRoot()); 36 | }; 37 | } 38 | 39 | // TODO: cover arrays, nulls and other non-object values 40 | type Setter = (ps: Path, value: any) => void; 41 | export function deepSetter(getRoot: () => T, setRoot: (s: T) => void): Setter { 42 | return (ps: Path, v: any) => { 43 | if (ps.length == 0) { 44 | setRoot(v); 45 | return; 46 | } 47 | 48 | const s = getRoot(); 49 | let pps = ps.slice(0, ps.length - 1); 50 | let p = ps[ps.length - 1]; 51 | const ns = { ...s }; 52 | const np = pps.reduce((a, c) => { 53 | return (a[c] = { ...a[c] }); 54 | }, ns); 55 | 56 | np[p] = v; 57 | setRoot(ns); 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/core/proxy.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { distinctUntilChanged, map } from 'rxjs/operators'; 3 | import { noop, OBSERVABLE_INSTANCE_PROP_KEYS } from './shared'; 4 | import { Key, ObservableProxy, Path } from './types'; 5 | 6 | // core api proxy 7 | export function coreProxy( 8 | o: Observable, 9 | ps: Path = [], 10 | getOverride?: (ps: Path, p: Key) => (() => any) | null, 11 | distinct?: boolean, 12 | ): ObservableProxy { 13 | // we need to preserve property proxies, so that 14 | // ```ts 15 | // let o = of({ a: 42 }); 16 | // let p = proxify(o); 17 | // assert(p.a === p.a); 18 | // ``` 19 | const proxyForPropertyCache = new Map>(); 20 | 21 | return (new Proxy(noop, { 22 | getPrototypeOf: function () { 23 | return Observable.prototype; 24 | }, 25 | // call result = O.fn in Observable 26 | // and make it Observable 27 | apply(_, __, argumentsList) { 28 | return coreProxy( 29 | o.pipe( 30 | deepPluck(ps), 31 | map(f => { 32 | // apply function 33 | if (typeof f == 'function') { 34 | return Reflect.apply(f, void 0, argumentsList); 35 | } 36 | 37 | // non-function or null values are skipped 38 | return f; 39 | }), 40 | ), 41 | ); 42 | }, 43 | 44 | // get Observable from Observable 45 | get(_, p: keyof O & keyof Observable, receiver) { 46 | const override = getOverride && getOverride(ps, p); 47 | if (override) { 48 | return override(); 49 | } 50 | 51 | // pass through Observable methods/props 52 | const isPipe = p == 'pipe'; 53 | if (isPipe || OBSERVABLE_INSTANCE_PROP_KEYS.includes(p)) { 54 | const deepO = o.pipe(deepPluck(ps), maybeDistinct(distinct)); 55 | const builtIn = Reflect.get(deepO, p, receiver); 56 | 57 | if (!isPipe) { 58 | // NOTE: we're binding .pipe, .subscribe, .lift, etc to current Source, 59 | // so that inner calls to `this` wont go through proxy again. This is 60 | // not equal to raw Rx where these fns are not bound 61 | return typeof builtIn == 'function' ? builtIn.bind(deepO) : builtIn; 62 | } 63 | 64 | // we should wrap piped observable into another proxy 65 | return function () { 66 | const applied = Reflect.apply(builtIn, deepO, arguments); 67 | return coreProxy(applied); 68 | }; 69 | } 70 | 71 | if (proxyForPropertyCache.has(p)) { 72 | return proxyForPropertyCache.get(p); 73 | } 74 | 75 | // return proxified sub-property 76 | const subproxy = coreProxy(o as any, ps.concat(p), getOverride, distinct); 77 | 78 | // cache, so that o.a.b == o.a.b 79 | proxyForPropertyCache.set(p, subproxy); 80 | return subproxy; 81 | }, 82 | }) as unknown) as ObservableProxy; 83 | } 84 | 85 | // Helper Operators 86 | // distinct value if needed 87 | function maybeDistinct(distinct: boolean) { 88 | return (o: Observable) => { 89 | return distinct ? o.pipe(distinctUntilChanged()) : o; 90 | }; 91 | } 92 | 93 | // read deep value by path, bind if needed 94 | function deepPluck(ps: Path) { 95 | return (observable: Observable) => { 96 | if (!ps.length) { 97 | return observable; 98 | } 99 | 100 | return observable.pipe( 101 | map(v => { 102 | // keep ref to parent 103 | let k = void 0; 104 | 105 | // similar to pluck, we skip nullish values 106 | for (let p of ps) { 107 | if (v == null) { 108 | return v; 109 | } else { 110 | k = v; 111 | v = v[p]; 112 | } 113 | } 114 | 115 | // we should keep the context for methods 116 | // so if the last prop is function -- we bind it 117 | if (typeof v == 'function') { 118 | return v.bind(k); 119 | } 120 | 121 | return v; 122 | }), 123 | ); 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /src/core/shared.ts: -------------------------------------------------------------------------------- 1 | // keys to preserve for Observable to work 2 | // these were manually picked from Observable type 3 | // TODO: consider doing prototype chain check instead 4 | export const OBSERVABLE_INSTANCE_PROP_KEYS = [ 5 | '_isScalar', 6 | 'source', 7 | 'operator', 8 | 'lift', 9 | 'subscribe', 10 | '_trySubscribe', 11 | 'forEach', 12 | '_subscribe', 13 | 'pipe', 14 | 'toPromise', 15 | ]; 16 | 17 | // a fn that will be used as Proxy basis 18 | // so that we could use Proxy.apply override 19 | // for a.b.c().subscribe(…) scenarios 20 | export function noop() {} 21 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer, Operator, OperatorFunction } from 'rxjs'; 2 | 3 | // Proxy kinds {{{ 4 | export type ObservableProxy = 5 | ValueProxy 6 | & IProxiedObservable 7 | & TMaybeCallableProxy; 8 | 9 | export type SubjectProxy = 10 | ValueProxy 11 | & IProxiedSubject 12 | & TMaybeCallableProxy; 13 | 14 | export type BehaviorSubjectProxy = 15 | ValueProxy 16 | & IProxiedState 17 | & TMaybeCallableProxy; 18 | 19 | // helper to distinguish root types 20 | type TProxy = 21 | K extends ProxyKind.State 22 | ? BehaviorSubjectProxy 23 | : K extends ProxyKind.Subject 24 | ? SubjectProxy 25 | // T == ProxyType.Observable 26 | : ObservableProxy 27 | 28 | enum ProxyKind { 29 | Observable, 30 | Subject, 31 | State 32 | }; 33 | // }}} 34 | 35 | // Basic proxy with props as proxify 36 | type ValueProxy = 37 | O extends null 38 | ? {} 39 | : O extends boolean 40 | ? { [P in keyof Boolean]: ObservableProxy } 41 | : O extends string 42 | ? { [P in keyof String]: ObservableProxy } 43 | : O extends number 44 | ? { [P in keyof Number]: ObservableProxy } 45 | : O extends bigint 46 | ? { [P in keyof BigInt]: ObservableProxy } 47 | : O extends symbol 48 | ? { [P in keyof Symbol]: ObservableProxy } 49 | // special hack for array type 50 | : O extends (infer R)[] 51 | ? { [P in keyof R[]]: TProxy } 52 | // any object 53 | : { [P in keyof O]: TProxy }; 54 | 55 | // Callable Proxies 56 | type TMaybeCallableProxy = 57 | O extends BasicFn 58 | ? ICallableProxy 59 | : {}; 60 | 61 | type BasicFn = (...args: any[]) => any; 62 | 63 | interface ICallableProxy { 64 | (...args: Parameters): ObservableProxy>; 65 | } 66 | 67 | // State API 68 | interface IProxiedState extends IProxiedSubject { 69 | readonly value: O; 70 | getValue(): O; 71 | } 72 | 73 | // Subject API 74 | interface IProxiedSubject extends IProxiedObservable, Observer { 75 | next(value: O): void; 76 | error(err: any): void; 77 | complete(): void; 78 | } 79 | 80 | // Observable with pipe method, returning Proxify 81 | interface IProxiedObservable extends Observable { 82 | pipe(): ObservableProxy; 83 | pipe(op1: OperatorFunction): ObservableProxy; 84 | pipe( 85 | op1: OperatorFunction, 86 | op2: OperatorFunction, 87 | ): ObservableProxy; 88 | pipe( 89 | op1: OperatorFunction, 90 | op2: OperatorFunction, 91 | op3: OperatorFunction, 92 | ): ObservableProxy; 93 | pipe( 94 | op1: OperatorFunction, 95 | op2: OperatorFunction, 96 | op3: OperatorFunction, 97 | op4: OperatorFunction, 98 | ): ObservableProxy; 99 | pipe( 100 | op1: OperatorFunction, 101 | op2: OperatorFunction, 102 | op3: OperatorFunction, 103 | op4: OperatorFunction, 104 | op5: OperatorFunction, 105 | ): ObservableProxy; 106 | pipe( 107 | op1: OperatorFunction, 108 | op2: OperatorFunction, 109 | op3: OperatorFunction, 110 | op4: OperatorFunction, 111 | op5: OperatorFunction, 112 | op6: OperatorFunction, 113 | ): ObservableProxy; 114 | pipe( 115 | op1: OperatorFunction, 116 | op2: OperatorFunction, 117 | op3: OperatorFunction, 118 | op4: OperatorFunction, 119 | op5: OperatorFunction, 120 | op6: OperatorFunction, 121 | op7: OperatorFunction, 122 | ): ObservableProxy; 123 | pipe( 124 | op1: OperatorFunction, 125 | op2: OperatorFunction, 126 | op3: OperatorFunction, 127 | op4: OperatorFunction, 128 | op5: OperatorFunction, 129 | op6: OperatorFunction, 130 | op7: OperatorFunction, 131 | op8: OperatorFunction, 132 | ): ObservableProxy; 133 | pipe( 134 | op1: OperatorFunction, 135 | op2: OperatorFunction, 136 | op3: OperatorFunction, 137 | op4: OperatorFunction, 138 | op5: OperatorFunction, 139 | op6: OperatorFunction, 140 | op7: OperatorFunction, 141 | op8: OperatorFunction, 142 | op9: OperatorFunction, 143 | ): ObservableProxy; 144 | pipe( 145 | op1: OperatorFunction, 146 | op2: OperatorFunction, 147 | op3: OperatorFunction, 148 | op4: OperatorFunction, 149 | op5: OperatorFunction, 150 | op6: OperatorFunction, 151 | op7: OperatorFunction, 152 | op8: OperatorFunction, 153 | op9: OperatorFunction, 154 | ...operations: OperatorFunction[] 155 | ): ObservableProxy<{}>; 156 | }; 157 | 158 | export type Key = string | number | symbol; 159 | export type Path = Key[]; 160 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './proxify'; 2 | export * from './statify'; 3 | -------------------------------------------------------------------------------- /src/observable.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { coreProxy } from './core/proxy'; 3 | import { ObservableProxy } from './core/types'; 4 | 5 | export function observable(source$: Observable): ObservableProxy { 6 | return coreProxy(source$, []) as ObservableProxy; 7 | } 8 | -------------------------------------------------------------------------------- /src/proxify.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, isObservable, Observable, Subject } from 'rxjs'; 2 | import { behaviorSubject } from './behavior'; 3 | import { BehaviorSubjectProxy, ObservableProxy, SubjectProxy } from './core/types'; 4 | import { observable } from './observable'; 5 | import { subject } from './subject'; 6 | 7 | export { BehaviorSubjectProxy, ObservableProxy, SubjectProxy }; 8 | export function proxify(source: BehaviorSubject): BehaviorSubjectProxy; 9 | export function proxify(source: Subject): SubjectProxy; 10 | export function proxify(source: Observable): ObservableProxy; 11 | export function proxify(source: Observable) { 12 | if (source instanceof BehaviorSubject) { 13 | return behaviorSubject(source as BehaviorSubject); 14 | } 15 | 16 | if (source instanceof Subject) { 17 | return subject(source as Subject); 18 | } 19 | 20 | if (isObservable(source)) { 21 | return observable(source as Observable); 22 | } 23 | 24 | throw 'Source should be Observable, Subject, or BehaviorSubject'; 25 | } 26 | -------------------------------------------------------------------------------- /src/statify.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs'; 2 | import { behaviorSubject } from './behavior'; 3 | import { BehaviorSubjectProxy } from './core/types'; 4 | 5 | export function statify(o: T): BehaviorSubjectProxy { 6 | return behaviorSubject(new BehaviorSubject(o), true); 7 | } 8 | 9 | /** 10 | * TODO: implement and type direct access to values on state 11 | * 12 | * ```ts 13 | * const state = statify({ a: 2, b: 2 }); 14 | * state.a + state.b == 4; 15 | * ``` 16 | * 17 | * via Symbol.toPrimitive 18 | */ 19 | 20 | /** 21 | * TODO: consider implementing directly settings values 22 | * 23 | * ```ts 24 | * const state = statify({ a: 2 }); 25 | * state.a = 4; 26 | * ``` 27 | * via Proxy { set:… } 28 | */ 29 | -------------------------------------------------------------------------------- /src/subject.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { coreProxy } from './core/proxy'; 3 | import { Key, Path, SubjectProxy } from './core/types'; 4 | 5 | export function subject(source$: Subject): SubjectProxy { 6 | // overrides work only for root values 7 | const overrides = { 8 | next: () => v => source$.next(v), 9 | error: () => e => source$.error(e), 10 | complete: () => () => source$.complete(), 11 | }; 12 | 13 | const getOverride = (ps: Path, p: Key) => { 14 | if (ps.length) { 15 | return void 0; 16 | } 17 | 18 | return overrides[p]; 19 | }; 20 | 21 | return coreProxy(source$, [], getOverride) as SubjectProxy; 22 | } 23 | -------------------------------------------------------------------------------- /tests/behavior.test.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, Subscription } from 'rxjs'; 2 | import { BehaviorSubjectProxy, proxify } from '../src'; 3 | import { createTestObserver, resetTestObservers, TestObserver } from './helpers'; 4 | 5 | describe('Behavior', () => { 6 | let sub: Subscription; 7 | let observer: TestObserver; 8 | 9 | beforeEach(() => { 10 | observer = createTestObserver(); 11 | }); 12 | 13 | afterEach(() => { 14 | if (sub) { 15 | sub.unsubscribe(); 16 | } 17 | }); 18 | 19 | test('Atomic', () => { 20 | const state = proxify(new BehaviorSubject(0)); 21 | state.subscribe(observer); 22 | expect(observer.next).toHaveBeenCalledWith(0); 23 | state.next(1); 24 | expect(observer.next).toHaveBeenCalledWith(1); 25 | }); 26 | 27 | test('Simple object', () => { 28 | const state = proxify(new BehaviorSubject({ a: 0 })); 29 | sub = state.a.subscribe(observer); 30 | expect(observer.next).toHaveBeenCalledWith(0); 31 | state.a.next(1); 32 | expect(observer.next).toHaveBeenCalledWith(1); 33 | state.next({ a: 1 }); 34 | expect(observer.next).toHaveBeenCalledTimes(3); 35 | // repeated call 36 | state.a.next(1); 37 | expect(observer.next).toHaveBeenCalledTimes(4); 38 | }); 39 | 40 | describe('Compound object', () => { 41 | let state: BehaviorSubjectProxy<{ a: number; b: { c: string }; z: number[] }>; 42 | let ao: TestObserver; 43 | let bo: TestObserver; 44 | let co: TestObserver; 45 | let z1o: TestObserver; 46 | 47 | beforeEach(() => { 48 | state = proxify(new BehaviorSubject({ a: 0, b: { c: 'I' }, z: [0, 1, 2] })); 49 | ao = createTestObserver(); 50 | bo = createTestObserver(); 51 | co = createTestObserver(); 52 | z1o = createTestObserver(); 53 | sub = new Subscription(); 54 | sub.add(state.a.subscribe(ao)); 55 | sub.add(state.b.subscribe(bo)); 56 | sub.add(state.b.c.subscribe(co)); 57 | sub.add(state.z[1].subscribe(z1o)); 58 | }); 59 | 60 | it('initial values', () => { 61 | expect(ao.next).toHaveBeenCalledWith(0); 62 | expect(bo.next).toHaveBeenCalledWith({ c: 'I' }); 63 | expect(co.next).toHaveBeenCalledWith('I'); 64 | expect(z1o.next).toHaveBeenCalledWith(1); 65 | }); 66 | 67 | it('update substate', () => { 68 | resetTestObservers(ao, bo, co, z1o); 69 | state.b.c.next('II'); 70 | expect(bo.next).toHaveBeenCalledWith({ c: 'II' }); 71 | expect(co.next).toHaveBeenCalledWith('II'); 72 | }); 73 | 74 | // NOTE: state.z[1].next(…) will fail 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from 'rxjs'; 2 | 3 | export interface TestObserver extends Observer { 4 | mockReset: () => void; 5 | next: jest.Mock; 6 | error: jest.Mock; 7 | complete: jest.Mock; 8 | } 9 | 10 | export function createTestObserver(): TestObserver { 11 | const o = { 12 | mockReset() { 13 | o.next.mockReset(); 14 | o.error.mockReset(); 15 | o.complete.mockReset(); 16 | }, 17 | next: jest.fn(), 18 | error: jest.fn(), 19 | complete: jest.fn(), 20 | }; 21 | 22 | return o; 23 | } 24 | 25 | export function resetTestObservers(...ts: TestObserver[]) { 26 | ts.forEach(t => t.mockReset()); 27 | } 28 | -------------------------------------------------------------------------------- /tests/observable.test.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest, from, isObservable, Observable, of, Subscription } from 'rxjs'; 2 | import { filter, map, scan } from 'rxjs/operators'; 3 | import { proxify } from '../src'; 4 | import { createTestObserver, TestObserver } from './helpers'; 5 | 6 | describe('Proxify', () => { 7 | let sub: Subscription; 8 | let observer: TestObserver; 9 | 10 | beforeEach(() => { 11 | observer = createTestObserver(); 12 | }); 13 | 14 | afterEach(() => { 15 | if (sub) { 16 | sub.unsubscribe(); 17 | } 18 | }); 19 | 20 | describe('Observable API', () => { 21 | test('isObservable should be true', () => { 22 | const o = of(1); 23 | const p = proxify(o); 24 | expect(isObservable(p)).toBe(true); 25 | }); 26 | 27 | test('should be instance of Observable', () => { 28 | const o = of(1); 29 | const p = proxify(o); 30 | expect(p instanceof Observable).toBe(true); 31 | }); 32 | 33 | test('should still behave as an Observable even after applying from', () => { 34 | const o = of(1); 35 | const p = proxify(o); 36 | const o2 = from(p); 37 | sub = o2.subscribe(observer); 38 | expect(observer.next.mock.calls).toEqual([[1]]); 39 | }); 40 | 41 | test('should be combinable with other Observables', () => { 42 | const a = proxify(of('a')); 43 | const b = of('b'); 44 | sub = combineLatest([a, b]).subscribe(observer); 45 | expect(observer.next.mock.calls).toEqual([[['a', 'b']]]); 46 | }); 47 | 48 | test('directly applying operator', () => { 49 | const o = of(1, 2, 3); 50 | const p = proxify(o); 51 | const mapped = map((x: number) => x + '.')(p); 52 | expect(isObservable(mapped)).toBe(true); 53 | sub = mapped.subscribe(observer); 54 | expect(observer.next.mock.calls).toEqual([['1.'], ['2.'], ['3.']]); 55 | expect(observer.complete.mock.calls.length).toBe(1); 56 | }); 57 | 58 | test('piping operator', () => { 59 | const o = of(1, 2, 3); 60 | const p = proxify(o); 61 | const mapped = p.pipe(map(x => x + '.')); 62 | expect(isObservable(mapped)).toBe(true); 63 | sub = mapped.subscribe(observer); 64 | expect(observer.next.mock.calls).toEqual([['1.'], ['2.'], ['3.']]); 65 | expect(observer.complete.mock.calls.length).toBe(1); 66 | }); 67 | }); 68 | 69 | describe('Preserve values', () => { 70 | it('should return same Proxy for each property access', () => { 71 | const o = of({ a: 42 }); 72 | const p = proxify(o); 73 | expect(p.a === p.a).toBe(true); 74 | expect(p.pipe(map(x => x)) !== p.pipe(map(x => x))).toBe(true); 75 | expect(p.pipe(map(x => x)).a !== p.pipe(map(x => x)).a).toBe(true); 76 | expect(p.a.pipe(map(x => x)) !== p.a.pipe(map(x => x))).toBe(true); 77 | }); 78 | }); 79 | 80 | describe('Pluck', () => { 81 | test('One level', () => { 82 | const o = of({ a: 1 }, { a: 2 }, { a: 3 }); 83 | const p = proxify(o); 84 | sub = p.a.subscribe(observer); 85 | expect(observer.next.mock.calls).toEqual([[1], [2], [3]]); 86 | expect(observer.complete.mock.calls.length).toBe(1); 87 | }); 88 | 89 | test('One level w/ pipe', () => { 90 | const o = of({ a: 1 }, { a: 2 }, { a: 3 }); 91 | const p = proxify(o); 92 | sub = p.pipe(filter(x => x.a > 1)).a.subscribe(observer); 93 | expect(observer.next.mock.calls).toEqual([[2], [3]]); 94 | expect(observer.complete.mock.calls.length).toBe(1); 95 | }); 96 | 97 | test('Two levels', () => { 98 | const o = of({ a: { b: 1 } }, { a: { b: 2 } }, { a: { b: 3 } }); 99 | const p = proxify(o); 100 | sub = p.a.b.subscribe(observer); 101 | expect(observer.next.mock.calls).toEqual([[1], [2], [3]]); 102 | expect(observer.complete.mock.calls.length).toBe(1); 103 | }); 104 | 105 | test('Two levels w/ pipe', () => { 106 | const o = of({ a: { b: 1, ok: true } }, { a: { b: 2, ok: false } }, { a: { b: 3, ok: true } }); 107 | const p = proxify(o); 108 | sub = p.a.pipe(filter(x => x.ok)).b.subscribe(observer); 109 | expect(observer.next.mock.calls).toEqual([[1], [3]]); 110 | expect(observer.complete.mock.calls.length).toBe(1); 111 | }); 112 | 113 | test('Three levels w/ four observers', () => { 114 | const o = of({ a: { b: 1 } }, { a: { b: 2 } }, { a: { b: 3 } }); 115 | const p = proxify(o); 116 | sub = new Subscription(); 117 | let ob1 = createTestObserver(); 118 | let ob2 = createTestObserver(); 119 | let ob3 = createTestObserver(); 120 | let ob4 = createTestObserver(); 121 | sub.add(p.subscribe(ob1)); 122 | sub.add(p.a.subscribe(ob2)); 123 | sub.add(p.a.b.subscribe(ob3)); 124 | sub.add(p.a.b.subscribe(ob4)); 125 | 126 | expect(ob1.next.mock.calls).toEqual([[{ a: { b: 1 } }], [{ a: { b: 2 } }], [{ a: { b: 3 } }]]); 127 | expect(ob2.next.mock.calls).toEqual([[{ b: 1 }], [{ b: 2 }], [{ b: 3 }]]); 128 | expect(ob3.next.mock.calls).toEqual([[1], [2], [3]]); 129 | expect(ob4.next.mock.calls).toEqual([[1], [2], [3]]); 130 | 131 | expect(ob1.complete).toHaveBeenCalled(); 132 | expect(ob2.complete).toHaveBeenCalled(); 133 | expect(ob3.complete).toHaveBeenCalled(); 134 | expect(ob4.complete).toHaveBeenCalled(); 135 | }); 136 | }); 137 | 138 | describe('Types', () => { 139 | // testing for splitting true/false in booleans 140 | // see issue https://github.com/kosich/rxjs-proxify/issues/11 141 | test('Boolean, property', () => { 142 | let o = of({ a: true }, { a: false }, { a: true }, { a: false }); 143 | let p = proxify(o); 144 | p.a.pipe(map(x => !x)).subscribe(); 145 | p.a 146 | .pipe(map(x => !x)) 147 | .pipe(map(x => x)) 148 | .subscribe(); 149 | }); 150 | 151 | test('Boolean, callable', () => { 152 | let o = of({ a: () => true }, { a: () => false }, { a: () => true }); 153 | let p = proxify(o); 154 | p.a() 155 | .pipe(map(x => x)) 156 | .subscribe(); 157 | }); 158 | }); 159 | 160 | describe('Calls', () => { 161 | test('One level', () => { 162 | const o = of({ a: () => 1 }, { a: () => 2 }, { a: () => 3 }); 163 | const p = proxify(o); 164 | sub = p.a().subscribe(observer); 165 | expect(observer.next.mock.calls).toEqual([[1], [2], [3]]); 166 | expect(observer.complete.mock.calls.length).toBe(1); 167 | }); 168 | 169 | it('should keep the THIS context', () => { 170 | const a = function () { 171 | return this.b; 172 | }; 173 | const o = of({ a, b: 1 }, { a, b: 2 }, { a, b: 3 }); 174 | const p = proxify(o); 175 | sub = p.a().subscribe(observer); 176 | expect(observer.next.mock.calls).toEqual([[1], [2], [3]]); 177 | expect(observer.complete.mock.calls.length).toBe(1); 178 | }); 179 | 180 | it('should pass the args', () => { 181 | const a = (x: number, y: number) => x + y; 182 | const o = of({ a }, { a }, { a }); 183 | const p = proxify(o); 184 | sub = p.a(1, 1).subscribe(observer); 185 | expect(observer.next.mock.calls).toEqual([[2], [2], [2]]); 186 | expect(observer.complete.mock.calls.length).toBe(1); 187 | }); 188 | 189 | it('should call proxify on result', () => { 190 | const a = (x: number, y: number) => ({ b: x + y }); 191 | const o = of({ a }, { a }, { a }); 192 | const p = proxify(o); 193 | sub = p 194 | .a(1, 1) 195 | .b.pipe(scan((acc, curr) => acc + curr)) 196 | .subscribe(observer); 197 | expect(observer.next.mock.calls).toEqual([[2], [4], [6]]); 198 | expect(observer.complete.mock.calls.length).toBe(1); 199 | }); 200 | }); 201 | 202 | describe('Types', () => { 203 | // TS: 204 | // proxify(fn)() -- should be Proxify 205 | test('fn call result type', () => { 206 | const o = of( 207 | () => 'Hello', 208 | () => 'World', 209 | ); 210 | const p = proxify(o); 211 | // fn call 212 | p().subscribe((s: string) => observer.next(s)); 213 | expect(observer.next).toHaveBeenCalledWith('Hello'); 214 | expect(observer.next).toHaveBeenCalledWith('World'); 215 | observer.next.mockClear(); 216 | // mapped 217 | p.pipe(map(f => f())).subscribe(observer); 218 | expect(observer.next).toHaveBeenCalledWith('Hello'); 219 | expect(observer.next).toHaveBeenCalledWith('World'); 220 | }); 221 | 222 | // TYPES: proxify(of('a', 'b')).length -- should be Proxify 223 | test('atomic props should be of type Proxify', () => { 224 | const o = of('Hi', 'World'); 225 | const p = proxify(o); 226 | // crazy subtype 227 | p.length.toString()[0].subscribe(observer); 228 | expect(observer.next).toHaveBeenCalledWith('2'); 229 | expect(observer.next).toHaveBeenCalledWith('5'); 230 | }); 231 | 232 | test('Classes', () => { 233 | class A { 234 | constructor(protected one: number) {} 235 | 236 | add() { 237 | return new A(this.one + 1); 238 | } 239 | 240 | read() { 241 | return this.one; 242 | } 243 | } 244 | 245 | class B extends A { 246 | minus() { 247 | return new B(this.one - 1); 248 | } 249 | } 250 | 251 | const bs = of(new B(3), new B(7)); 252 | const p = proxify(bs); 253 | p.minus().minus().add().read().subscribe(observer); 254 | expect(observer.next.mock.calls).toEqual([[2], [6]]); 255 | expect(observer.complete.mock.calls.length).toBe(1); 256 | }); 257 | 258 | describe('Arrays', () => { 259 | it('should map', () => { 260 | proxify(of([1, 2, 3])) 261 | .map(x => x + 1) 262 | // NOTE: This currently returns Proxify 263 | // TODO: fix 264 | .subscribe(observer); 265 | 266 | expect(observer.next.mock.calls).toEqual([[[2, 3, 4]]]); 267 | expect(observer.complete.mock.calls.length).toBe(1); 268 | }); 269 | 270 | it('should filter', () => { 271 | proxify(of([1, 2, 3])) 272 | .filter(x => x != 2)[1] 273 | .subscribe(observer); 274 | 275 | expect(observer.next.mock.calls).toEqual([[3]]); 276 | expect(observer.complete.mock.calls.length).toBe(1); 277 | }); 278 | 279 | // TODO: TypeScript: typing doesn't handle methods with overloads atm 280 | // it('should type every', () => { 281 | // proxify(of([1, 2, 3])) 282 | // .every(x => x != 2) 283 | // .subscribe(observer) 284 | // expect(observer.next.mock.calls).toEqual([[3]]); 285 | // expect(observer.complete.mock.calls.length).toBe(1); 286 | // }) 287 | }); 288 | 289 | it('should apply type on result w/ any', () => { 290 | const a = (x: any, y: any) => ({ b: x + y }); 291 | const o = of({ a }, { a }, { a }); 292 | const p = proxify(o); 293 | p.a(1, 1).b.subscribe(observer); 294 | expect(observer.next.mock.calls).toEqual([[2], [2], [2]]); 295 | expect(observer.complete.mock.calls.length).toBe(1); 296 | }); 297 | }); 298 | }); 299 | -------------------------------------------------------------------------------- /tests/state.test.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from 'rxjs'; 2 | import { BehaviorSubjectProxy, statify } from '../src'; 3 | import { createTestObserver, TestObserver } from './helpers'; 4 | 5 | describe('State', () => { 6 | let sub: Subscription; 7 | let observer: TestObserver; 8 | 9 | beforeEach(() => { 10 | observer = createTestObserver(); 11 | }); 12 | 13 | afterEach(() => { 14 | if (sub) { 15 | sub.unsubscribe(); 16 | } 17 | }); 18 | 19 | test('Story', () => { 20 | // create a state 21 | const state = statify({ a: '🐰', z: '🏡' }); 22 | 23 | // listen to & log state changes 24 | sub = state.subscribe(observer); 25 | expect(observer.next).toHaveBeenCalledWith({ a: '🐰', z: '🏡' }); 26 | 27 | // update particular substate 28 | state.a.next('🐇'); 29 | expect(observer.next).toHaveBeenCalledWith({ a: '🐇', z: '🏡' }); 30 | 31 | // update root state 32 | state.next({ a: '🐇', z: '☁️' }); 33 | expect(observer.next).toHaveBeenCalledWith({ a: '🐇', z: '☁️' }); 34 | 35 | // and then… 36 | state.z.next('🌙'); //> { a:🐇 z:🌙 } 37 | // TODO: TS does not supported yet 38 | // state.a += '👀'; //> { a:🐇👀 z:🌙 } 39 | state.z.next('🛸'); //> { a:🐇👀 z:🛸 } 40 | state.a.next('💨'); //> { a:💨 z:🛸 } 41 | 42 | // read current values 43 | expect(state.a.value + state.z.getValue()).toBe('💨🛸'); 44 | }); 45 | 46 | describe('skip repeated updates', () => { 47 | let state: BehaviorSubjectProxy<{ a: number }>; 48 | 49 | beforeEach(() => { 50 | state = statify({ a: 0 }); 51 | }); 52 | 53 | it('skip deep repeated updates', () => { 54 | sub = state.subscribe(observer); 55 | expect(observer.next).toHaveBeenCalledWith({ a: 0 }); 56 | observer.mockReset(); 57 | state.a.next(0); 58 | expect(observer.next).not.toHaveBeenCalled(); 59 | }); 60 | 61 | it('skip repeated updates', () => { 62 | sub = state.a.subscribe(observer); 63 | expect(observer.next).toHaveBeenCalledWith(0); 64 | observer.mockReset(); 65 | state.a.next(0); 66 | expect(observer.next).not.toHaveBeenCalled(); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/subject.test.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Subscription } from 'rxjs'; 2 | import { proxify } from '../src'; 3 | import { createTestObserver, TestObserver } from './helpers'; 4 | 5 | describe('Subject', () => { 6 | let sub: Subscription; 7 | let observer: TestObserver; 8 | 9 | beforeEach(() => { 10 | observer = createTestObserver(); 11 | }); 12 | 13 | afterEach(() => { 14 | if (sub) { 15 | sub.unsubscribe(); 16 | } 17 | }); 18 | 19 | test('basic', () => { 20 | const state = proxify(new Subject()); 21 | state.subscribe(observer); 22 | expect(observer.next).not.toHaveBeenCalled(); 23 | state.next(0); 24 | expect(observer.next).toHaveBeenCalledWith(0); 25 | state.next(1); 26 | expect(observer.next).toHaveBeenCalledWith(1); 27 | }); 28 | 29 | test('disabled Subject api on deeper levels', () => { 30 | const state = proxify(new Subject<{ a: { next: number; error: () => void } }>()); 31 | state.a.next.subscribe(observer); 32 | expect(observer.next).not.toHaveBeenCalled(); 33 | state.next({ a: { next: 0, error: () => {} } }); 34 | expect(observer.next).toHaveBeenCalledWith(0); 35 | state.a.error(); 36 | expect(observer.error).not.toHaveBeenCalled(); 37 | state.next({ a: { next: 1, error: () => {} } }); 38 | expect(observer.next).toHaveBeenCalledWith(1); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "outDir": "./dist/", 5 | "noImplicitAny": false, 6 | "noUnusedLocals": false, 7 | "strictFunctionTypes": true, 8 | "moduleResolution": "node", 9 | "module": "commonjs", 10 | "target": "es2015", 11 | "sourceMap": true, 12 | "downlevelIteration": false, 13 | "lib": [] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./configs/webpack.base'); 2 | 3 | module.exports = config; 4 | --------------------------------------------------------------------------------