├── .eslintrc ├── .github └── workflows │ ├── coverage.yml │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── benchmark ├── mem │ ├── index.ts │ ├── many-listeners.ts │ ├── simple.ts │ └── util │ │ ├── benchmark.ts │ │ ├── format.ts │ │ ├── stats.ts │ │ └── test.ts └── perf │ ├── broadcast.ts │ ├── index.ts │ ├── many-listeners.ts │ ├── simple.ts │ └── util │ └── benchmark.ts ├── chameleon.png ├── conf └── typescript │ ├── base.json │ ├── commonjs.json │ └── es.json ├── jest.config.ts ├── misc ├── dark.png ├── dark.svg ├── light.png └── light.svg ├── package-lock.json ├── package.json ├── sample ├── index.html └── index.js ├── src ├── disposable.ts ├── event.ts ├── from.ts ├── index.ts ├── input.ts ├── iterate.ts ├── noop.ts ├── observe.ts ├── source.ts ├── subject.ts ├── test │ ├── disposable.test.ts │ ├── event.test.tsx │ ├── exports.test.ts │ ├── from.test.tsx │ ├── input.test.tsx │ ├── iterate.test.ts │ ├── misc.test.ts │ ├── observe.test.ts │ ├── source.test.ts │ ├── subject.test.ts │ └── timer.test.ts ├── timer.ts ├── types.ts └── util │ └── dom-events.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ "@typescript-eslint"], 5 | "ignorePatterns": ["dist/**/*"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "env": { 11 | "browser": true, 12 | "node": true, 13 | "es6": true 14 | }, 15 | "rules": { 16 | "quotes": ["warn", "single", {"avoidEscape": true}], 17 | "curly": "warn", 18 | "no-unused-vars": "off", 19 | "no-unused-expressions": [ 20 | "error", { 21 | "allowShortCircuit": true, 22 | "allowTernary": true 23 | } 24 | ], 25 | "no-shadow": "warn", 26 | "prefer-const": "warn", 27 | "eqeqeq": "warn", 28 | "prefer-spread": "warn", 29 | "prefer-object-spread": "warn", 30 | "indent": ["warn", 2], 31 | "newline-before-return": "warn", 32 | "eol-last": "warn", 33 | "semi": ["warn", "never"], 34 | "no-trailing-spaces": "warn", 35 | "@typescript-eslint/no-explicit-any": "off", 36 | "@typescript-eslint/adjacent-overload-signatures": "warn", 37 | "@typescript-eslint/no-empty-function": "off", 38 | "@typescript-eslint/no-non-null-assertion": "off", 39 | "@typescript-eslint/no-empty-interface": "warn", 40 | "@typescript-eslint/explicit-module-boundary-types": "off", 41 | "@typescript-eslint/no-inferrable-types": "warn", 42 | "@typescript-eslint/restrict-plus-operands": "off", 43 | "@typescript-eslint/restrict-template-expressions": "off", 44 | "@typescript-eslint/no-this-alias": "off", 45 | "@typescript-eslint/no-unused-vars": ["warn", { 46 | "argsIgnorePattern": "^_|^renderer$", 47 | "varsIgnorePattern": "^_" 48 | }], 49 | "@typescript-eslint/ban-types": "off", 50 | "@typescript-eslint/no-var-requires": "off" 51 | }, 52 | "overrides": [ 53 | { 54 | "files": ["src/**/*.test.ts", "src/**/*.test.tsx"], 55 | "rules": { 56 | "no-unused-expressions": "off" 57 | } 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: 3 | push: 4 | branches: ['*'] 5 | pull_request: 6 | branches: ['*'] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - name: Install Dependencies 19 | run: npm ci 20 | 21 | - name: Check Coverage 22 | run: npm run coverage 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: ['*'] 5 | pull_request: 6 | branches: ['*'] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - name: Install Dependencies 19 | run: npm ci 20 | 21 | - name: Check Linting 22 | run: npm run lint 23 | 24 | - name: Check Typings 25 | run: npm run typecheck 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | run: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '16' 16 | 17 | - name: Install Dependencies 18 | run: npm ci 19 | 20 | - name: Check Coverage 21 | run: npm run coverage 22 | 23 | - name: Check Linting 24 | run: npm run lint 25 | 26 | - name: Fix README for NPM 27 | run: sed -i.tmp -e '10d' README.md && rm README.md.tmp 28 | 29 | - name: Publish 30 | uses: JS-DevTools/npm-publish@v1 31 | with: 32 | token: ${{ secrets.NPM_AUTH_TOKEN }} 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: ['*'] 5 | pull_request: 6 | branches: ['*'] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | 18 | - name: Install Dependencies 19 | run: npm ci 20 | 21 | - name: Run Tests 22 | run: npm test 23 | 24 | -------------------------------------------------------------------------------- /.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 | 106 | .DS_Store 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Eugene Ghanizadeh 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 | [![npm package minimized gzipped size)](https://img.shields.io/bundlejs/size/quel?style=flat-square&label=%20&color=black)](https://bundlejs.com/?q=quel) 4 | [![types](https://img.shields.io/npm/types/quel?label=&color=black&style=flat-square)](./src/types.ts) 5 | [![version](https://img.shields.io/npm/v/quel?label=&color=black&style=flat-square)](https://www.npmjs.com/package/quel) 6 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/loreanvictor/quel/coverage.yml?label=%20&style=flat-square)](https://github.com/loreanvictor/quel/actions/workflows/coverage.yml) 7 | 8 |
9 | 10 | 11 | 12 | 13 | _Reactive Expressions for JavaScript_ 14 | 15 | ```bash 16 | npm i quel 17 | ``` 18 | 19 | **quel** is a tiny library for reactive programming in JavaScript. Use it to write applications that handle user interactions, events, timers, web sockets, etc. using only simple functions. 20 | 21 | ```js 22 | import { from, observe } from 'quel' 23 | 24 | 25 | const div$ = document.querySelector('div') 26 | 27 | // 👇 encapsulate the value of the input 28 | const input = from(document.querySelector('textarea')) 29 | 30 | // 👇 compute some other values based on that (changing) value 31 | const chars = $ => $(input)?.length ?? 0 32 | const words = $ => $(input)?.split(' ').length ?? 0 33 | 34 | // 👇 use the calculated value in a side-effect 35 | observe($ => div$.textContent = `${$(chars)} chars, ${$(words)} words`) 36 | ``` 37 | 38 |
39 | 40 | [**▷ TRY IT**](https://stackblitz.com/edit/js-jh6zt2?file=index.html,index.js) 41 | 42 |
43 | 44 |
45 | 46 | **quel** focuses on simplicity and composability. Even complex scenarios (such as higher-order reactive sources, debouncing events, etc.) 47 | are implemented with plain JS functions combined with each other (instead of operators, hooks, or other custom abstractions). 48 | 49 | ```js 50 | // 51 | // this code creates a timer whose rate changes 52 | // based on the value of an input 53 | // 54 | 55 | import { from, observe, Timer } from 'quel' 56 | 57 | 58 | const div$ = document.querySelector('div') 59 | const input = from(document.querySelector('input')) 60 | const rate = $ => parseInt($(input) ?? 100) 61 | 62 | // 63 | // 👇 wait a little bit after the input value is changed (debounce), 64 | // then create a new timer with the new rate. 65 | // 66 | // `timer` is a "higher-order" source of change, because 67 | // its rate also changes based on the value of the input. 68 | // 69 | const timer = async $ => { 70 | await sleep(200) 71 | return $(rate) && new Timer($(rate)) 72 | } 73 | 74 | observe($ => { 75 | // 76 | // 👇 `$(timer)` would yield the latest timer, 77 | // and `$($(timer))` would yield the latest 78 | // value of that timer, which is what we want to display. 79 | // 80 | const elapsed = $($(timer)) ?? '-' 81 | div$.textContent = `elapsed: ${elapsed}` 82 | }) 83 | ``` 84 | 85 |
86 | 87 | [**▷ TRY IT**](https://stackblitz.com/edit/js-4wppcl?file=index.js) 88 | 89 |
90 | 91 |
92 | 93 | # Contents 94 | 95 | - [Installation](#installation) 96 | - [Usage](#usage) 97 | - [Sources](#sources) 98 | - [Expressions](#expressions) 99 | - [Observation](#observation) 100 | - [Iteration](#iteration) 101 | - [Cleanup](#cleanup) 102 | - [Typing](#typing) 103 | - [Custom Sources](#custom-sources) 104 | - [Features](#features) 105 | - [Related Work](#related-work) 106 | - [Contribution](#contribution) 107 | 108 |
109 | 110 | # Installation 111 | 112 | On [node](https://nodejs.org/en/): 113 | ```bash 114 | npm i quel 115 | ``` 116 | On browser (or [deno](https://deno.land)): 117 | ```js 118 | import { from, observe } from 'https://esm.sh/quel' 119 | ``` 120 | 121 |
122 | 123 | # Usage 124 | 125 | 1. Encapsulate (or create) [sources of change](#sources), 126 | ```js 127 | const timer = new Timer(1000) 128 | const input = from(document.querySelector('#my-input')) 129 | ``` 130 | 2. Process and combine these changing values using [simple functions](#expressions), 131 | ```js 132 | const chars = $ => $(input).length 133 | ``` 134 | 3. [Observe](#observation) these changing values and react to them 135 | (or [iterate](#iteration) over them), 136 | ```js 137 | const obs = observe($ => console.log($(timer) + ' : ' + $(chars))) 138 | ``` 139 | 4. [Clean up](#cleanup) the sources, releasing resources (e.g. stop a timer, remove an event listener, cloe a socket, etc.). 140 | ```js 141 | obs.stop() 142 | timer.stop() 143 | ``` 144 | 145 |
146 | 147 | ## Sources 148 | 149 | 📝 Create a subject (whose value you can manually set at any time): 150 | ```js 151 | import { Subject } from 'quel' 152 | 153 | const a = new Subject() 154 | a.set(2) 155 | ``` 156 | 🕑 Create a timer: 157 | ```js 158 | import { Timer } from 'quel' 159 | 160 | const timer = new Timer(1000) 161 | ``` 162 | ⌨️ Create an event source: 163 | ```js 164 | import { from } from 'quel' 165 | 166 | const click = from(document.querySelector('button')) 167 | const hover = from(document.querySelector('button'), 'hover') 168 | const input = from(document.querySelector('input')) 169 | ``` 170 | 👀 Read latest value of a source: 171 | ```js 172 | src.get() 173 | ``` 174 | ✋ Stop a source: 175 | ```js 176 | src.stop() 177 | ``` 178 | 💁‍♂️ Wait for a source to be stopped: 179 | ```js 180 | await src.stops() 181 | ``` 182 | 183 |
184 | 185 | > In runtimes supporting `using` keyword ([see proposal](https://github.com/tc39/proposal-explicit-resource-management)), you can 186 | > subscribe to a source: 187 | > ```js 188 | > using sub = src.subscribe(value => ...) 189 | > ``` 190 | > Currently [TypeScript 5.2](https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-beta/#using-declarations-and-explicit-resource-management) or later supports `using` keyword. 191 | 192 |
193 | 194 | ## Expressions 195 | 196 | ⛓️ Combine two sources using simple _expression_ functions: 197 | ```js 198 | const sum = $ => $(a) + $(b) 199 | ``` 200 | 🔍 Filter values: 201 | ```js 202 | import { SKIP } from 'quel' 203 | 204 | const odd = $ => $(a) % 2 === 0 ? SKIP : $(a) 205 | ``` 206 | 🔃 Expressions can be async: 207 | ```js 208 | const response = async $ => { 209 | // a debounce to avoid overwhelming the 210 | // server with requests. 211 | await sleep(200) 212 | 213 | if ($(query)) { 214 | try { 215 | const res = await fetch('https://pokeapi.co/api/v2/pokemon/' + $(query)) 216 | const json = await res.json() 217 | 218 | return JSON.stringify(json, null, 2) 219 | } catch { 220 | return 'Could not find Pokemon' 221 | } 222 | } 223 | } 224 | ``` 225 | 226 |
227 | 228 | [**▷ TRY IT**](https://stackblitz.com/edit/js-3jpams?file=index.js) 229 | 230 |
231 | 232 | 🫳 Flatten higher-order sources: 233 | ```js 234 | const variableTimer = $ => new Timer($(input)) 235 | const message = $ => 'elapsed: ' + $($(timer)) 236 | ``` 237 | ✋ Stop the expression: 238 | ```js 239 | import { STOP } from 'quel' 240 | 241 | let count = 0 242 | const take5 = $ => { 243 | if (count++ > 5) return STOP 244 | 245 | return $(src) 246 | } 247 | ``` 248 | 249 |
250 | 251 | > ℹ️ **IMPORTANT** 252 | > 253 | > The `$` function, passed to expressions, _tracks_ and returns the latest value of a given source. Expressions 254 | > are then re-run every time a tracked source has a new value. Make sure you track the same sources everytime 255 | > the expression runs. 256 | > 257 | > **DO NOT** create sources you want to track inside an expression: 258 | > 259 | > ```js 260 | > // 👇 this is WRONG ❌ 261 | > const computed = $ => $(new Timer(1000)) * 2 262 | > ``` 263 | > ```js 264 | > // 👇 this is CORRECT ✅ 265 | > const timer = new Timer(1000) 266 | > const computed = $ => $(timer) * 2 267 | > ``` 268 | > 269 | >
270 | > 271 | > You _CAN_ create new sources inside an expression and return them (without tracking) them, creating a higher-order source: 272 | > ```js 273 | > // 274 | > // this is OK ✅ 275 | > // `timer` is a source of changing timers, 276 | > // who themselves are a source of changing numbers. 277 | > // 278 | > const timer = $ => new Timer($(rate)) 279 | > ``` 280 | > ```js 281 | > // 282 | > // this is OK ✅ 283 | > // `$(timer)` returns the latest timer as long as a new timer 284 | > // is not created (in response to a change in `rate`), so this 285 | > // expression is re-evaluated only when it needs to. 286 | > // 287 | > const msg = $ => 'elapsed: ' + $($(timer)) 288 | > ``` 289 |
290 | 291 | ## Observation 292 | 293 | 🚀 Run side effects: 294 | ```js 295 | import { observe } from 'quel' 296 | 297 | observe($ => console.log($(message))) 298 | ``` 299 | 300 | 💡 Observations are sources themselves: 301 | ```js 302 | const y = observe($ => $(x) * 2) 303 | console.log(y.get()) 304 | ``` 305 | 306 | ✋ Don't forget to stop observations: 307 | 308 | ```js 309 | const obs = observe($ => ...) 310 | obs.stop() 311 | ``` 312 | 313 |
314 | 315 | > In runtimes supporting `using` keyword ([see proposal](https://github.com/tc39/proposal-explicit-resource-management)), you don't need to manually stop observations: 316 | > ```js 317 | > using obs = observe($ => ...) 318 | > ``` 319 | > Currently [TypeScript 5.2](https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-beta/#using-declarations-and-explicit-resource-management) or later supports `using` keyword. 320 | 321 |
322 | 323 | Async expressions might get aborted mid-execution. You can handle those events by passing a second argument to `observe()`: 324 | ```js 325 | let ctrl = new AbortController() 326 | 327 | const data = observe(async $ => { 328 | await sleep(200) 329 | 330 | // 👇 pass abort controller signal to fetch to cancel mid-flight requests 331 | const res = await fetch('https://my.api/?q=' + $(input), { 332 | signal: ctrl.signal 333 | }) 334 | 335 | return await res.json() 336 | }, () => { 337 | ctrl.abort() 338 | ctrl = new AbortController() 339 | }) 340 | ``` 341 | 342 |
343 | 344 | ## Iteration 345 | 346 | Iterate on values of a source using `iterate()`: 347 | ```js 348 | import { iterate } from 'quel' 349 | 350 | for await (const i of iterate(src)) { 351 | // do something with it 352 | } 353 | ``` 354 | If the source emits values faster than you consume them, you are going to miss out on them: 355 | ```js 356 | const timer = new Timer(500) 357 | 358 | // 👇 loop body is slower than the source. values will be lost! 359 | for await (const i of iterate(timer)) { 360 | await sleep(1000) 361 | console.log(i) 362 | } 363 | ``` 364 | 365 |
366 | 367 | [**▷ TRY IT**](https://codepen.io/lorean_victor/pen/abKxbNw?editors=1010) 368 | 369 |
370 | 371 | ## Cleanup 372 | 373 | 🧹 You need to manually clean up sources you create: 374 | 375 | ```js 376 | const timer = new Timer(1000) 377 | 378 | // ... whatever ... 379 | 380 | timer.stop() 381 | ``` 382 | 383 | ✨ Observations cleanup automatically when all their tracked sources 384 | stop. YOU DONT NEED TO CLEANUP OBSERVATIONS. 385 | 386 | If you want to stop an observation earlier, call `stop()` on it: 387 | 388 | ```js 389 | const obs = observe($ => $(src)) 390 | 391 | // ... whatever ... 392 | 393 | obs.stop() 394 | ``` 395 | 396 |
397 | 398 | > In runtimes supporting `using` keyword ([see proposal](https://github.com/tc39/proposal-explicit-resource-management)), you can safely 399 | > create sources without manually cleaning them up: 400 | > ```js 401 | > using timer = new Timer(1000) 402 | > ``` 403 | > Currently [TypeScript 5.2](https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-beta/#using-declarations-and-explicit-resource-management) or later supports `using` keyword. 404 | 405 | 406 |
407 | 408 | ## Typing 409 | 410 | TypeScript wouldn't be able to infer proper types for expressions. To resolve this issue, use `Track` type: 411 | 412 | ```ts 413 | import { Track } from 'quel' 414 | 415 | const expr = ($: Track) => $(a) * 2 416 | ``` 417 | 418 | 👉 [Check this](src/types.ts) for more useful types. 419 | 420 |
421 | 422 | ## Custom Sources 423 | 424 | Create your own sources using `Source` class: 425 | 426 | ```js 427 | const src = new Source(async emit => { 428 | await sleep(1000) 429 | emit('Hellow World!') 430 | }) 431 | ``` 432 | 433 | If cleanup is needed, and your producer is sync, return a cleanup function: 434 | ```js 435 | const myTimer = new Source(emit => { 436 | let i = 0 437 | const interval = setInterval(() => emit(++i), 1000) 438 | 439 | // 👇 clear the interval when the source is stopped 440 | return () => clearInterval(interval) 441 | }) 442 | ``` 443 | 444 | If your producer is async, register the cleanup using `finalize` callback: 445 | ```js 446 | // 👇 with async producers, use a callback to specify cleanup code 447 | const asyncTimer = new Source(async (emit, finalize) => { 448 | let i = 0 449 | let stopped = false 450 | 451 | finalize(() => stopped = true) 452 | 453 | while (!stopped) { 454 | emit(++i) 455 | await sleep(1000) 456 | } 457 | }) 458 | ``` 459 | 460 | You can also extend the `Source` class: 461 | 462 | ```js 463 | class MyTimer extends Source { 464 | constructor(rate = 200) { 465 | super() 466 | this.rate = rate 467 | this.count = 0 468 | } 469 | 470 | toggle() { 471 | if (this.interval) { 472 | this.interval = clearInterval(this.interval) 473 | } else { 474 | this.interval = setInterval( 475 | // call this.emit() to emit values 476 | () => this.emit(++this.count), 477 | this.rate 478 | ) 479 | } 480 | } 481 | 482 | // override stop() to clean up 483 | stop() { 484 | clearInterval(this.interval) 485 | super.stop() 486 | } 487 | } 488 | ``` 489 | 490 |
491 | 492 | [**▷ TRY IT**](https://codepen.io/lorean_victor/pen/WNPdBdx?editors=0011) 493 | 494 |
495 | 496 | # Features 497 | 498 | 🧩 [**quel**](.) has a minimal API surface (the whole package [is ~1.3KB](https://bundlephobia.com/package/quel@0.1.5)), and relies on composability instead of providng tons of operators / helper methods: 499 | 500 | ```js 501 | // combine two sources: 502 | $ => $(a) + $(b) 503 | ``` 504 | ```js 505 | // debounce: 506 | async $ => { 507 | await sleep(1000) 508 | return $(src) 509 | } 510 | ``` 511 | ```js 512 | // flatten (e.g. switchMap): 513 | $ => $($(src)) 514 | ``` 515 | ```js 516 | // filter a source 517 | $ => $(src) % 2 === 0 ? $(src) : SKIP 518 | ``` 519 | ```js 520 | // take until other source emits a value 521 | $ => !$(notifier) ? $(src) : STOP 522 | ``` 523 | ```js 524 | // batch emissions 525 | async $ => (await Promise.resolve(), $(src)) 526 | ``` 527 | ```js 528 | // batch with animation frames 529 | async $ => { 530 | await Promise(resolve => requestAnimationFrame(resolve)) 531 | return $(src) 532 | } 533 | ``` 534 | ```js 535 | // merge sources 536 | new Source(emit => { 537 | const obs = sources.map(src => observe($ => emit($(src)))) 538 | return () => obs.forEach(ob => ob.stop()) 539 | }) 540 | ``` 541 | ```js 542 | // throttle 543 | let timeout = null 544 | 545 | $ => { 546 | const value = $(src) 547 | if (timeout === null) { 548 | timeout = setTimeout(() => timeout = null, 1000) 549 | return value 550 | } else { 551 | return SKIP 552 | } 553 | } 554 | ``` 555 | 556 | 557 |
558 | 559 | 🛂 [**quel**](.) is imperative (unlike most other general-purpose reactive programming libraries such as [RxJS](https://rxjs.dev), which are functional), resulting in code that is easier to read, write and debug: 560 | 561 | ```js 562 | import { interval, map, filter } from 'rxjs' 563 | 564 | const a = interval(1000) 565 | const b = interval(500) 566 | 567 | combineLatest(a, b).pipe( 568 | map(([x, y]) => x + y), 569 | filter(x => x % 2 === 0), 570 | ).subscribe(console.log) 571 | ``` 572 | ```js 573 | import { Timer, observe } from 'quel' 574 | 575 | const a = new Timer(1000) 576 | const b = new Timer(500) 577 | 578 | observe($ => { 579 | const sum = $(a) + $(b) 580 | if (sum % 2 === 0) { 581 | console.log(sum) 582 | } 583 | }) 584 | ``` 585 | 586 |
587 | 588 | ⚡ [**quel**](.) is as fast as [RxJS](https://rxjs.dev). Note that in most cases performance is not the primary concern when programming reactive applications (since you are handling async events). If performance is critical for your use case, I'd recommend using likes of [xstream](http://staltz.github.io/xstream/) or [streamlets](https://github.com/loreanvictor/streamlet), as the imperative style of [**quel**](.) does tax a performance penalty inevitably compared to the fastest possible implementation. 589 | 590 |
591 | 592 | 🧠 [**quel**](.) is more memory-intensive than [RxJS](https://rxjs.dev). Similar to the unavoidable performance tax, tracking sources of an expression will use more memory compared to explicitly tracking and specifying them. 593 | 594 |
595 | 596 | ☕ [**quel**](.) only supports [hot](https://rxjs.dev/guide/glossary-and-semantics#hot) [listenables](https://rxjs.dev/guide/glossary-and-semantics#push). Certain use cases would benefit (for example, in terms of performance) from using cold listenables, or from having hybrid pull-push primitives. However, most common event sources (user events, timers, Web Sockets, etc.) are hot listenables, and [**quel**](.) does indeed use the limited scope for simplification and optimization of its code. 597 | 598 |
599 | 600 | # Related Work 601 | 602 | - [**quel**](.) is inspired by [rxjs-autorun](https://github.com/kosich/rxjs-autorun) by [@kosich](https://github.com/kosich). 603 | - [**quel**](.) is basically an in-field experiment on ideas discussed in detail [here](https://github.com/loreanvictor/reactive-javascript). 604 | - [**quel**](.)'s focus on hot listenables was inspired by [xstream](https://github.com/staltz/xstream). 605 | 606 |
607 | 608 | # Contribution 609 | 610 | You need [node](https://nodejs.org/en/), [NPM](https://www.npmjs.com) to start and [git](https://git-scm.com) to start. 611 | 612 | ```bash 613 | # clone the code 614 | git clone git@github.com:loreanvictor/quel.git 615 | ``` 616 | ```bash 617 | # install stuff 618 | npm i 619 | ``` 620 | 621 | Make sure all checks are successful on your PRs. This includes all tests passing, high code coverage, correct typings and abiding all [the linting rules](https://github.com/loreanvictor/quel/blob/main/.eslintrc). The code is typed with [TypeScript](https://www.typescriptlang.org), [Jest](https://jestjs.io) is used for testing and coverage reports, [ESLint](https://eslint.org) and [TypeScript ESLint](https://typescript-eslint.io) are used for linting. Subsequently, IDE integrations for TypeScript and ESLint would make your life much easier (for example, [VSCode](https://code.visualstudio.com) supports TypeScript out of the box and has [this nice ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)), but you could also use the following commands: 622 | 623 | ```bash 624 | # run tests 625 | npm test 626 | ``` 627 | ```bash 628 | # check code coverage 629 | npm run coverage 630 | ``` 631 | ```bash 632 | # run linter 633 | npm run lint 634 | ``` 635 | ```bash 636 | # run type checker 637 | npm run typecheck 638 | ``` 639 | 640 | You can also use the following commands to run performance benchmarks: 641 | 642 | ```bash 643 | # run all benchmarks 644 | npm run bench 645 | ``` 646 | ```bash 647 | # run performance benchmarks 648 | npm run bench:perf 649 | ``` 650 | ```bash 651 | # run memory benchmarks 652 | npm run bench:mem 653 | ``` 654 | 655 |

656 | 657 |
658 | 659 |
660 | 661 |

662 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /benchmark/mem/index.ts: -------------------------------------------------------------------------------- 1 | import './simple' 2 | import './many-listeners' 3 | -------------------------------------------------------------------------------- /benchmark/mem/many-listeners.ts: -------------------------------------------------------------------------------- 1 | import { benchmark } from './util/benchmark' 2 | 3 | import { pipe as spipe, Subject as sSubject, map as smap, filter as sfilter, observe as sobserve } from 'streamlets' 4 | import { Subject as rSubject, map as rmap, filter as rfilter } from 'rxjs' 5 | import { Subject, observe, SKIP, Track } from '../../src' 6 | 7 | 8 | const data = [...Array(50_000).keys()] 9 | const listeners = [...Array(20).keys()] 10 | 11 | benchmark('many listeners', { 12 | RxJS: () => { 13 | const a = new rSubject() 14 | 15 | const o = a.pipe( 16 | rmap(x => x * 3), 17 | rfilter(x => x % 2 === 0) 18 | ) 19 | 20 | const subs = listeners.map(() => o.subscribe()) 21 | data.forEach(x => a.next(x)) 22 | 23 | return () => subs.forEach(s => s.unsubscribe()) 24 | }, 25 | 26 | Streamlets: () => { 27 | const a = new sSubject() 28 | 29 | const s = spipe( 30 | a, 31 | smap(x => x * 3), 32 | sfilter(x => x % 2 === 0), 33 | ) 34 | 35 | const obs = listeners.map(() => sobserve(s)) 36 | data.forEach(x => a.receive(x)) 37 | 38 | return () => obs.forEach(ob => ob.stop()) 39 | }, 40 | 41 | Quel: () => { 42 | const a = new Subject() 43 | const e = ($: Track) => { 44 | const b = $(a)! * 3 45 | 46 | return b % 2 === 0 ? b : SKIP 47 | } 48 | 49 | const obs = listeners.map(() => observe(e)) 50 | data.forEach(x => a.set(x)) 51 | 52 | return () => obs.forEach(ob => ob.stop()) 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /benchmark/mem/simple.ts: -------------------------------------------------------------------------------- 1 | import { benchmark } from './util/benchmark' 2 | 3 | import { pipe as spipe, Subject as sSubject, map as smap, filter as sfilter, observe as sobserve } from 'streamlets' 4 | import { Subject as rSubject, map as rmap, filter as rfilter } from 'rxjs' 5 | import { Subject, observe, SKIP } from '../../src' 6 | 7 | 8 | const data = [...Array(3_000_000).keys()] 9 | 10 | benchmark('simple', { 11 | RxJS: () => { 12 | const a = new rSubject() 13 | 14 | const s = a.pipe( 15 | rmap(x => x * 3), 16 | rfilter(x => x % 2 === 0) 17 | ).subscribe() 18 | 19 | data.forEach(x => a.next(x)) 20 | 21 | return () => s.unsubscribe() 22 | }, 23 | 24 | Streamlets: () => { 25 | const a = new sSubject() 26 | 27 | const o = spipe( 28 | a, 29 | smap(x => x * 3), 30 | sfilter(x => x % 2 === 0), 31 | sobserve, 32 | ) 33 | 34 | data.forEach(x => a.receive(x)) 35 | 36 | return () => o.stop() 37 | }, 38 | 39 | Quel: () => { 40 | const a = new Subject() 41 | const o = observe($ => { 42 | const b = $(a)! * 3 43 | 44 | return b % 2 === 0 ? b : SKIP 45 | }) 46 | 47 | data.forEach(x => a.set(x)) 48 | 49 | return () => o.stop() 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /benchmark/mem/util/benchmark.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { table, getBorderCharacters } from 'table' 3 | 4 | import { test } from './test' 5 | import { format } from './format' 6 | 7 | 8 | export function benchmark(name: string, libs: { [lib: string]: () => () => void}) { 9 | const suites = Object.entries(libs).sort(() => Math.random() > .5 ? 1 : -1) 10 | const results: [string, number, number][] = [] 11 | 12 | console.log(chalk`{yellowBright mem}: {bold ${name}}`) 13 | 14 | suites.forEach(([lib, fn]) => { 15 | const res = test(fn) 16 | results.push([lib, res.heap, res.rme]) 17 | console.log(chalk` {green ✔} ${lib}` 18 | + chalk` {gray ${Array(32 - lib.length).join('.')} ${res.samples.length} runs}` 19 | ) 20 | }) 21 | 22 | console.log() 23 | console.log(table( 24 | results 25 | .sort((a, b) => a[1] - b[1]) 26 | .map(([lib, heap, rme]) => ([ 27 | chalk`{bold ${lib}}`, 28 | chalk`{green.bold ${format(heap)}}`, 29 | chalk`{gray ±${rme.toFixed(2)}%}`, 30 | ])), 31 | { 32 | columns: { 33 | 0: { width: 20 }, 34 | 1: { width: 30 }, 35 | 2: { width: 10 } 36 | }, 37 | border: getBorderCharacters('norc') 38 | } 39 | )) 40 | } 41 | -------------------------------------------------------------------------------- /benchmark/mem/util/format.ts: -------------------------------------------------------------------------------- 1 | export function format(num: number) { 2 | const kb = num / 1024 3 | const mb = num / 1024 / 1024 4 | 5 | if (mb < 1) { 6 | return `${kb.toFixed(2)}KB` 7 | } else { 8 | return `${mb.toFixed(2)}MB` 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /benchmark/mem/util/stats.ts: -------------------------------------------------------------------------------- 1 | const tTable: any = { 2 | '1': 12.706, '2': 4.303, '3': 3.182, '4': 2.776, '5': 2.571, '6': 2.447, 3 | '7': 2.365, '8': 2.306, '9': 2.262, '10': 2.228, '11': 2.201, '12': 2.179, 4 | '13': 2.16, '14': 2.145, '15': 2.131, '16': 2.12, '17': 2.11, '18': 2.101, 5 | '19': 2.093, '20': 2.086, '21': 2.08, '22': 2.074, '23': 2.069, '24': 2.064, 6 | '25': 2.06, '26': 2.056, '27': 2.052, '28': 2.048, '29': 2.045, '30': 2.042, 7 | 'infinity': 1.96 8 | } 9 | 10 | export function rme(arr: number[]) { 11 | const avg = average(arr) 12 | const variance = arr.reduce((acc, num) => acc + Math.pow(num - avg, 2), 0) / arr.length 13 | const sd = Math.sqrt(variance) 14 | const sem = sd / Math.sqrt(arr.length) 15 | const df = arr.length - 1 16 | const critical = tTable[df as any] || tTable['infinity'] 17 | const moe = critical * sem 18 | 19 | return moe / avg * 100 20 | } 21 | 22 | 23 | export function average(arr: number[]) { 24 | return arr.reduce((acc, val) => acc + val) / arr.length 25 | } 26 | -------------------------------------------------------------------------------- /benchmark/mem/util/test.ts: -------------------------------------------------------------------------------- 1 | import { average, rme } from './stats' 2 | 3 | 4 | type Data = { 5 | heapTotal: number, 6 | heapUsed: number, 7 | } 8 | 9 | function sample(fn: () => () => void) { 10 | const initial = process.memoryUsage() 11 | const dispose = fn() 12 | const final = process.memoryUsage() 13 | 14 | dispose() 15 | // eslint-disable-next-line no-unused-expressions 16 | gc && gc() 17 | 18 | return { 19 | heapTotal: final.heapTotal - initial.heapTotal, 20 | heapUsed: final.heapUsed - initial.heapUsed, 21 | } 22 | } 23 | 24 | export function test(fn: () => () => void, N = 64, warmup = 16) { 25 | const samples: Data[] = [] 26 | 27 | for (let i = 0; i < warmup; i++) { 28 | sample(fn) 29 | } 30 | 31 | for (let i = 0; i < N; i++) { 32 | samples.push(sample(fn)) 33 | } 34 | 35 | return { 36 | samples, 37 | warmup, 38 | heap: average(samples.map(s => s.heapUsed)), 39 | rme: rme(samples.map(s => s.heapUsed)), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /benchmark/perf/broadcast.ts: -------------------------------------------------------------------------------- 1 | import { benchmark } from './util/benchmark' 2 | 3 | import { Subject as sSubject, observe as sobserve } from 'streamlets' 4 | import { Subject as rSubject } from 'rxjs' 5 | import xs from 'xstream' 6 | import { Subject } from '../../src' 7 | 8 | 9 | const data = [...Array(10_000).keys()] 10 | const listeners = [...Array(1_00).keys()] 11 | 12 | benchmark('broadcast', { 13 | RxJS: () => { 14 | const a = new rSubject() 15 | 16 | listeners.forEach(() => a.subscribe()) 17 | data.forEach(x => a.next(x)) 18 | }, 19 | 20 | Streamlets: () => { 21 | const a = new sSubject() 22 | 23 | listeners.forEach(() => sobserve(a)) 24 | data.forEach(x => a.receive(x)) 25 | }, 26 | 27 | Quel: () => { 28 | const a = new Subject() 29 | 30 | listeners.forEach(() => a.get(() => {})) 31 | data.forEach(x => a.set(x)) 32 | }, 33 | 34 | XStream: () => { 35 | const a = xs.create() 36 | 37 | listeners.forEach(() => a.subscribe({ 38 | next: () => {}, 39 | error: () => {}, 40 | complete: () => {}, 41 | })) 42 | 43 | data.forEach(x => a.shamefullySendNext(x)) 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /benchmark/perf/index.ts: -------------------------------------------------------------------------------- 1 | import './simple' 2 | import './many-listeners' 3 | import './broadcast' 4 | -------------------------------------------------------------------------------- /benchmark/perf/many-listeners.ts: -------------------------------------------------------------------------------- 1 | import { benchmark } from './util/benchmark' 2 | 3 | import { pipe as spipe, Subject as sSubject, map as smap, filter as sfilter, observe as sobserve } from 'streamlets' 4 | import { Subject as rSubject, map as rmap, filter as rfilter } from 'rxjs' 5 | import xs from 'xstream' 6 | import { Subject, observe, SKIP, Track } from '../../src' 7 | 8 | 9 | const data = [...Array(1_000).keys()] 10 | const listeners = [...Array(1_00).keys()] 11 | 12 | benchmark('many listeners', { 13 | RxJS: () => { 14 | const a = new rSubject() 15 | 16 | const o = a.pipe( 17 | rmap(x => x * 3), 18 | rfilter(x => x % 2 === 0) 19 | ) 20 | 21 | listeners.forEach(() => o.subscribe()) 22 | data.forEach(x => a.next(x)) 23 | }, 24 | 25 | Streamlets: () => { 26 | const a = new sSubject() 27 | 28 | const s = spipe( 29 | a, 30 | smap(x => x * 3), 31 | sfilter(x => x % 2 === 0), 32 | ) 33 | 34 | listeners.forEach(() => sobserve(s)) 35 | data.forEach(x => a.receive(x)) 36 | }, 37 | 38 | Quel: () => { 39 | const a = new Subject() 40 | const e = ($: Track) => { 41 | const b = $(a)! * 3 42 | 43 | return b % 2 === 0 ? b : SKIP 44 | } 45 | 46 | listeners.forEach(() => observe(e)) 47 | data.forEach(x => a.set(x)) 48 | }, 49 | 50 | XStream: () => { 51 | const a = xs.create() 52 | 53 | const s = a.map(x => x * 3) 54 | .filter(x => x % 2 === 0) 55 | 56 | listeners.forEach(() => s.subscribe({ 57 | next: () => {}, 58 | error: () => {}, 59 | complete: () => {}, 60 | })) 61 | data.forEach(x => a.shamefullySendNext(x)) 62 | }, 63 | }) 64 | -------------------------------------------------------------------------------- /benchmark/perf/simple.ts: -------------------------------------------------------------------------------- 1 | import { benchmark } from './util/benchmark' 2 | 3 | import { pipe as spipe, Subject as sSubject, map as smap, filter as sfilter, observe as sobserve } from 'streamlets' 4 | import { Subject as rSubject, map as rmap, filter as rfilter } from 'rxjs' 5 | import xs from 'xstream' 6 | import { Subject, observe, SKIP } from '../../src' 7 | 8 | 9 | const data = [...Array(1_000).keys()] 10 | 11 | benchmark('simple', { 12 | RxJS: () => { 13 | const a = new rSubject() 14 | 15 | a.pipe( 16 | rmap(x => x * 3), 17 | rfilter(x => x % 2 === 0) 18 | ).subscribe() 19 | 20 | data.forEach(x => a.next(x)) 21 | }, 22 | 23 | Streamlets: () => { 24 | const a = new sSubject() 25 | 26 | spipe( 27 | a, 28 | smap(x => x * 3), 29 | sfilter(x => x % 2 === 0), 30 | sobserve, 31 | ) 32 | 33 | data.forEach(x => a.receive(x)) 34 | }, 35 | 36 | Quel: () => { 37 | const a = new Subject() 38 | observe($ => { 39 | const b = $(a)! * 3 40 | 41 | return b % 2 === 0 ? b : SKIP 42 | }) 43 | 44 | data.forEach(x => a.set(x)) 45 | }, 46 | 47 | XStream: () => { 48 | const a = xs.create() 49 | 50 | a.map(x => x * 3) 51 | .filter(x => x % 2 === 0) 52 | .subscribe({ 53 | next: () => {}, 54 | error: () => {}, 55 | complete: () => {}, 56 | }) 57 | 58 | data.forEach(x => a.shamefullySendNext(x)) 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /benchmark/perf/util/benchmark.ts: -------------------------------------------------------------------------------- 1 | import Benchmark, { Event } from 'benchmark' 2 | import { table, getBorderCharacters } from 'table' 3 | import chalk from 'chalk' 4 | 5 | 6 | export function benchmark(name: string, libs: { [lib: string]: () => void}) { 7 | const suite = new Benchmark.Suite(name) 8 | const results: [string, number, number][] = [] 9 | 10 | Object.entries(libs).sort(() => Math.random() > .5 ? 1 : -1).forEach(([lib, impl]) => suite.add(lib, impl)) 11 | console.log(chalk`{blue perf}: {bold ${name}}`) 12 | 13 | suite 14 | .on('cycle', function(event: Event) { 15 | console.log( 16 | chalk` {green ✔} ${event.target.name}` 17 | + chalk` {gray ${Array(32 - event.target.name!.length).join('.')} ${event.target.stats?.sample.length} runs}` 18 | ) 19 | results.push([event.target.name!, event.target.hz!, event.target.stats!.rme]) 20 | }) 21 | .on('complete', function(this: any) { 22 | console.log() 23 | console.log(table( 24 | results 25 | .sort((a, b) => b[1] - a[1]) 26 | .map(([lib, ops, rme]) => ([ 27 | chalk`{bold ${lib}}`, 28 | chalk`{green.bold ${Benchmark.formatNumber(ops.toFixed(0) as any)}} ops/sec`, 29 | chalk`{gray ±${Benchmark.formatNumber(rme.toFixed(2) as any) + '%'}}`, 30 | ])), 31 | { 32 | columns: { 33 | 0: { width: 20 }, 34 | 1: { width: 30 }, 35 | 2: { width: 10 } 36 | }, 37 | border: getBorderCharacters('norc') 38 | } 39 | )) 40 | }) 41 | .run({ async: false }) 42 | } 43 | -------------------------------------------------------------------------------- /chameleon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loreanvictor/quel/9d067e7cbceb643708062cda4b2225a787299ac2/chameleon.png -------------------------------------------------------------------------------- /conf/typescript/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "esnext", 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "noImplicitAny": false, 9 | "lib": ["esnext"] 10 | }, 11 | "include": ["../../src"], 12 | "exclude": ["../../**/test/**/*"] 13 | } -------------------------------------------------------------------------------- /conf/typescript/commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./base.json", 3 | "compilerOptions": { 4 | "target": "es6", 5 | "module": "commonjs", 6 | "outDir": "../../dist/commonjs/" 7 | } 8 | } -------------------------------------------------------------------------------- /conf/typescript/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./base.json", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "module": "es2020", 6 | "outDir": "../../dist/es/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | verbose: true, 4 | clearMocks: true, 5 | testEnvironment: 'jsdom', 6 | testMatch: ['**/test/*.test.[jt]s?(x)'], 7 | collectCoverageFrom: [ 8 | 'src/**/*.{ts,tsx}', 9 | '!src/**/*.test.{ts,tsx}', 10 | ], 11 | coverageThreshold: { 12 | global: { 13 | branches: 100, 14 | functions: 90, 15 | lines: 100, 16 | statements: 100, 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /misc/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loreanvictor/quel/9d067e7cbceb643708062cda4b2225a787299ac2/misc/dark.png -------------------------------------------------------------------------------- /misc/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | dark 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /misc/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loreanvictor/quel/9d067e7cbceb643708062cda4b2225a787299ac2/misc/light.png -------------------------------------------------------------------------------- /misc/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | light 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quel", 3 | "version": "0.3.7", 4 | "description": "Expression-based reactive library for hot listenables", 5 | "main": "dist/commonjs/index.js", 6 | "module": "dist/es/index.js", 7 | "types": "dist/es/index.d.ts", 8 | "scripts": { 9 | "sample": "vite sample", 10 | "test": "jest", 11 | "lint": "eslint .", 12 | "typecheck": "tsc -p conf/typescript/es.json --noEmit", 13 | "coverage": "jest --coverage", 14 | "build-commonjs": "tsc -p conf/typescript/commonjs.json", 15 | "build-es": "tsc -p conf/typescript/es.json", 16 | "build": "npm run build-commonjs && npm run build-es", 17 | "prepack": "npm run build", 18 | "bench:perf": "ts-node ./benchmark/perf", 19 | "bench:mem": "node -r ts-node/register --expose-gc ./benchmark/mem", 20 | "bench": "npm run bench:perf && npm run bench:mem" 21 | }, 22 | "files": [ 23 | "dist/es", 24 | "dist/commonjs" 25 | ], 26 | "sideEffects": false, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/loreanvictor/quel.git" 30 | }, 31 | "keywords": [ 32 | "reactive", 33 | "expression", 34 | "stream", 35 | "observable" 36 | ], 37 | "author": "Eugene Ghanizadeh Khoub", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/loreanvictor/quel/issues" 41 | }, 42 | "homepage": "https://github.com/loreanvictor/quel#readme", 43 | "devDependencies": { 44 | "@babel/core": "^7.20.5", 45 | "@babel/preset-env": "^7.20.2", 46 | "@sindresorhus/tsconfig": "^3.0.1", 47 | "@types/benchmark": "^2.1.2", 48 | "@types/jest": "^29.2.3", 49 | "@types/node": "^18.11.10", 50 | "@typescript-eslint/eslint-plugin": "^6.11.0", 51 | "@typescript-eslint/parser": "^6.11.0", 52 | "babel-jest": "^29.3.1", 53 | "benchmark": "^2.1.4", 54 | "chalk": "^4.1.2", 55 | "eslint": "^8.28.0", 56 | "jest": "^29.3.1", 57 | "jest-environment-jsdom": "^29.3.1", 58 | "rxjs": "^7.5.7", 59 | "sleep-promise": "^9.1.0", 60 | "streamlets": "^0.5.1", 61 | "table": "^6.8.1", 62 | "test-callbag-jsx": "^0.4.1", 63 | "ts-jest": "^29.1.1", 64 | "ts-node": "^10.9.1", 65 | "tslib": "^2.4.1", 66 | "typescript": "^5.2.2", 67 | "vite": "^3.2.4", 68 | "xstream": "^11.14.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /sample/index.js: -------------------------------------------------------------------------------- 1 | import { from, observe, Timer, SKIP } from '../src' 2 | import sleep from 'sleep-promise' 3 | 4 | const div$ = document.querySelector('div') 5 | const input = from(document.querySelector('input')) 6 | const rate = $ => parseInt($(input) ?? 200) 7 | 8 | const timer = async $ => { 9 | await sleep(200) 10 | 11 | return $(rate) ? new Timer($(rate)) : SKIP 12 | } 13 | 14 | observe($ => { 15 | const elapsed = $($(timer)) ?? '-' 16 | div$.textContent = `elapsed: ${elapsed}` 17 | }) 18 | -------------------------------------------------------------------------------- /src/disposable.ts: -------------------------------------------------------------------------------- 1 | (Symbol as any).dispose ??= Symbol('dispose') 2 | 3 | 4 | export function dispose(target: Disposable) { 5 | if (target && typeof target[Symbol.dispose] === 'function') { 6 | target[Symbol.dispose]() 7 | } 8 | } 9 | 10 | 11 | export function disposable(fn: () => void): Disposable { 12 | return { 13 | [Symbol.dispose]: fn 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/event.ts: -------------------------------------------------------------------------------- 1 | import { Source } from './source' 2 | import { addListener, removeListener, EventMap } from './util/dom-events' 3 | 4 | 5 | export class EventSource extends Source { 6 | constructor( 7 | readonly node: EventTarget, 8 | readonly name: EventName, 9 | readonly options?: boolean | AddEventListenerOptions, 10 | ) { 11 | super(emit => { 12 | const handler = (evt: EventMap[EventName]) => emit(evt) 13 | addListener(node, name, handler, options) 14 | 15 | return () => removeListener(node, name, handler, options) 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/from.ts: -------------------------------------------------------------------------------- 1 | import { EventMap } from './util/dom-events' 2 | import { EventSource } from './event' 3 | import { InputSource } from './input' 4 | 5 | 6 | export function from(input: HTMLInputElement): InputSource 7 | export function from(node: EventTarget): EventSource<'click'> 8 | export function from( 9 | node: EventTarget, 10 | name: EventName, 11 | options?: boolean | AddEventListenerOptions 12 | ): EventSource 13 | export function from( 14 | node: EventTarget, 15 | name?: EventName, 16 | options?: boolean | AddEventListenerOptions, 17 | ): InputSource | EventSource { 18 | if (!name && (node as any).tagName && ( 19 | (node as any).tagName === 'INPUT' || (node as any).tagName === 'TEXTAREA' 20 | )) { 21 | return new InputSource(node as HTMLInputElement) 22 | } else { 23 | return new EventSource(node, name ?? 'click' as EventName, options) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './source' 2 | export * from './noop' 3 | export * from './timer' 4 | export * from './subject' 5 | export * from './observe' 6 | export * from './iterate' 7 | export * from './event' 8 | export * from './input' 9 | export * from './from' 10 | export * from './types' 11 | export * from './disposable' 12 | 13 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | import { addListener, removeListener } from './util/dom-events' 2 | import { Source } from './source' 3 | 4 | 5 | export class InputSource extends Source { 6 | constructor( 7 | readonly node: HTMLInputElement, 8 | ) { 9 | super(emit => { 10 | const handler = (evt: Event) => emit((evt.target as HTMLInputElement).value) 11 | addListener(node, 'input', handler) 12 | 13 | emit(node.value) 14 | 15 | return () => removeListener(node, 'input', handler) 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/iterate.ts: -------------------------------------------------------------------------------- 1 | import { SourceLike } from './types' 2 | 3 | 4 | export async function* iterate(src: SourceLike) { 5 | let resolve: ((pack: { val: T }) => void) | undefined = undefined 6 | let promise = new Promise<{val: T}>(res => resolve = res) 7 | 8 | src.get(t => { 9 | resolve!({val: t}) 10 | promise = new Promise<{val: T}>(res => resolve = res) 11 | }) 12 | 13 | while (true) { 14 | const pack = await Promise.race([promise, src.stops()]) 15 | if (pack) { 16 | yield pack.val 17 | } else { 18 | return 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/noop.ts: -------------------------------------------------------------------------------- 1 | export function noop() { } 2 | -------------------------------------------------------------------------------- /src/observe.ts: -------------------------------------------------------------------------------- 1 | import { Listener, SourceLike, isSourceLike, Observable, ExprFn, SKIP, STOP, ExprResultSync } from './types' 2 | import { Source } from './source' 3 | 4 | 5 | /** 6 | * Turns an object, that might be an expression function or a source, into a source. 7 | * Will attach the created source on the expression function and reuse it on subsequent calls. 8 | * 9 | * @param {Observable} fn the object to normalize 10 | * @returns {SourceLike} 11 | */ 12 | function normalize(fn: Observable): SourceLike { 13 | if (typeof fn === 'function') { 14 | (fn as any).__observed__ ??= observe(fn) 15 | 16 | return (fn as any).__observed__ 17 | } else { 18 | return fn 19 | } 20 | } 21 | 22 | 23 | /** 24 | * Represents an observation of an expression. An expression is a function that can track 25 | * some other sources and its return value depends on the values of those sources. This tracking 26 | * needs to be done explicitly via the _track function_ passed to the expression. 27 | * 28 | * Whenever a tracked source emits a value, the expression function is re-run, and its new value 29 | * is emitted. For each re-run of the expression function, the latest value emitted by each source 30 | * is used. An initial dry-run is performed upon construction to track necessary sources. 31 | * 32 | * @example 33 | * ```ts 34 | * const a = makeASource() 35 | * const b = makeAnotherSource() 36 | * 37 | * const expr = $ => $(a) + $(b) 38 | * const obs = new Observation(expr) 39 | * ``` 40 | */ 41 | export class Observation extends Source { 42 | /** 43 | * A mapping of all tracked sources. For receiving the values of tracked sources, 44 | * a handler is registered with them. This handler is stored in this map for cleanup. 45 | */ 46 | tracked: Map, Listener> = new Map() 47 | 48 | /** 49 | * A candidate tracked source for cleanup. If a tracked source initiates a rerun 50 | * by emitting, it is marked as a clean candidate. If the source is not re-tracked (i.e. used) 51 | * in the next run, it will be cleaned up. 52 | */ 53 | cleanCandidate: SourceLike | undefined 54 | 55 | /** 56 | * A token to keep track of the current run. If the expression is re-run 57 | * before a previous run is finished (which happens in case of async expressions), 58 | * then the value of the out-of-sync run is discarded. 59 | */ 60 | syncToken = 0 61 | 62 | /** 63 | * The last sync token. If this is different from the current sync token, 64 | * then the last started execution has not finished yet. 65 | */ 66 | lastSyncToken = 0 67 | 68 | /** 69 | * @param {ExprFn} fn the expression to observe 70 | * @param {Listener} abort a listener to call when async execution is aborted 71 | */ 72 | constructor( 73 | readonly fn: ExprFn, 74 | readonly abort?: Listener, 75 | ) { 76 | super(() => () => { 77 | this.tracked.forEach((h, t) => t.remove(h)) 78 | this.tracked.clear() 79 | }) 80 | 81 | // do a dry run on init, to track all sources 82 | this.run() 83 | } 84 | 85 | /** 86 | * Cleans the observation if necessary. The observation is "dirty" if 87 | * the last initiated run was initiated by a source that is no longer tracked 88 | * by the expression. The observation will always be clean after calling this method. 89 | * 90 | * @returns {boolean} true if the observation is clean (before cleaning up), false otherwise. 91 | */ 92 | protected clean() { 93 | if (this.cleanCandidate) { 94 | const handler = this.tracked.get(this.cleanCandidate)! 95 | this.cleanCandidate.remove(handler) 96 | this.tracked.delete(this.cleanCandidate) 97 | this.cleanCandidate = undefined 98 | 99 | return false 100 | } else { 101 | return true 102 | } 103 | } 104 | 105 | /** 106 | * creates a new sync token to distinguish async executions that should be aborted. 107 | * will call the abort listener if some execution is aborted. 108 | * @returns a new sync token. 109 | */ 110 | protected nextToken() { 111 | if (this.syncToken > 0) { 112 | // check if there is an unfinished run that needs to be aborted 113 | if (this.lastSyncToken !== this.syncToken) { 114 | this.abort && this.abort() 115 | } 116 | // if this is a higher-order observation, the last emitted source 117 | // should be stopped. 118 | isSourceLike(this.last) && this.last.stop() 119 | } 120 | 121 | /* istanbul ignore next */ 122 | return ++this.syncToken > 10e12 ? this.syncToken = 1 : this.syncToken 123 | } 124 | 125 | /** 126 | * Runs the expression function and emits its result. 127 | * @param {SourceLike} src the source that initiated the run, if any. 128 | */ 129 | protected run(src?: SourceLike) { 130 | this.cleanCandidate = src 131 | const syncToken = this.nextToken() 132 | 133 | const _res = this.fn(obs => obs ? this.track(normalize(obs), syncToken) : undefined) 134 | 135 | if (_res instanceof Promise) { 136 | _res.then(res => { 137 | if (this.syncToken !== syncToken) { 138 | return 139 | } 140 | 141 | this.emit(res) 142 | }) 143 | } else { 144 | this.emit(_res) 145 | } 146 | } 147 | 148 | /** 149 | * Emits the result of the expression function if the observation is clean. The observation 150 | * is "dirty" if the last initiated run was initiated by a source that is no longer tracked. This happens 151 | * when a source is conditionally tracked or when a higher-order tracked source emits a new inner-source or stops. 152 | * 153 | * This method will also skip the emission if the result is SKIP or STOP. In case of STOP, the observation 154 | * is stopped. This allows expressions to control flow of the observation in an imparative manner. 155 | * 156 | * @param {ExprResultSync} res the result to emit 157 | */ 158 | protected override emit(res: ExprResultSync) { 159 | // emission means last run is finished, 160 | // so sync tokens should be synced. 161 | this.lastSyncToken = this.syncToken 162 | 163 | if (this.clean() && res !== SKIP && res !== STOP) { 164 | super.emit(res) 165 | } else if (res === STOP) { 166 | this.stop() 167 | } 168 | } 169 | 170 | /** 171 | * Tracks a source and returns the latest value emitted by it. If the source is being tracked for the first time, 172 | * will register a listener with it to re-run the expression when it emits. 173 | * 174 | * @returns The latest value emitted by the source, or undefined if there was a subsequent run after the run 175 | * that initiated the tracking (so expression can realize mid-flight if they are aborted). 176 | */ 177 | protected track(src: SourceLike, syncToken: number) { 178 | if (syncToken !== this.syncToken) { 179 | return undefined 180 | } 181 | 182 | if (this.cleanCandidate === src) { 183 | this.cleanCandidate = undefined 184 | } 185 | 186 | if (!src.stopped && !this.tracked.has(src)) { 187 | const handler = () => this.run(src) 188 | this.tracked.set(src, handler) 189 | src.stops().then(() => this.checkStop(src)) 190 | 191 | return src.get(handler) 192 | } else { 193 | return src.get() 194 | } 195 | } 196 | 197 | /** 198 | * Removes a source from the tracked sources. If this was the last tracked source, 199 | * the observation is stopped. 200 | */ 201 | protected checkStop(src: SourceLike) { 202 | this.tracked.delete(src) 203 | if (this.tracked.size === 0) { 204 | this.stop() 205 | } 206 | } 207 | } 208 | 209 | 210 | export function observe(fn: ExprFn, abort?: Listener) { 211 | return new Observation(fn, abort) 212 | } 213 | -------------------------------------------------------------------------------- /src/source.ts: -------------------------------------------------------------------------------- 1 | import { disposable } from './disposable' 2 | import { noop } from './noop' 3 | import { Listener, Producer, Cleanup, SourceLike } from './types' 4 | 5 | 6 | export class Source implements SourceLike { 7 | subs: Listener[] | undefined = undefined 8 | last: T | undefined = undefined 9 | cleanup: Cleanup | undefined 10 | _stops: Promise | undefined 11 | _stopsResolve: (() => void) | undefined 12 | _stopped = false 13 | 14 | constructor( 15 | readonly producer: Producer = noop 16 | ) { 17 | const cl = producer(val => this.emit(val), cleanup => this.cleanup = cleanup) 18 | 19 | if (cl && typeof cl === 'function') { 20 | this.cleanup = cl 21 | } 22 | } 23 | 24 | protected emit(val: T) { 25 | this.last = val 26 | if (this.subs) { 27 | const cpy = this.subs.slice() 28 | for(let i = 0; i < cpy.length; i++) { 29 | cpy[i]!(val) 30 | } 31 | } 32 | } 33 | 34 | get(listener?: Listener) { 35 | if (listener) { 36 | this.subs ??= [] 37 | this.subs.push(listener) 38 | } 39 | 40 | return this.last 41 | } 42 | 43 | subscribe(listener: Listener) { 44 | // 45 | // can this be further optimised? 46 | // 47 | this.get(listener) 48 | 49 | return disposable(() => this.remove(listener)) 50 | } 51 | 52 | remove(listener: Listener) { 53 | if (this.subs) { 54 | const i = this.subs.indexOf(listener) 55 | if (i !== -1) { 56 | this.subs.splice(i, 1) 57 | } 58 | } 59 | } 60 | 61 | stop() { 62 | if (this.cleanup) { 63 | this.cleanup() 64 | } 65 | 66 | if (this.subs) { 67 | this.subs.length = 0 68 | } 69 | 70 | this._stopped = true 71 | 72 | if (this._stops) { 73 | this._stopsResolve!() 74 | } 75 | } 76 | 77 | stops() { 78 | this._stops ??= new Promise(resolve => this._stopsResolve = resolve) 79 | 80 | return this._stops 81 | } 82 | 83 | get stopped() { 84 | return this._stopped 85 | } 86 | 87 | [Symbol.dispose]() { 88 | this.stop() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/subject.ts: -------------------------------------------------------------------------------- 1 | import { Source } from './source' 2 | 3 | 4 | export class Subject extends Source { 5 | constructor() { 6 | super() 7 | } 8 | 9 | set(value: T) { 10 | this.emit(value) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/disposable.test.ts: -------------------------------------------------------------------------------- 1 | import { dispose, disposable } from '../disposable' 2 | 3 | 4 | describe(disposable, () => { 5 | test('disposes.', () => { 6 | const cb = jest.fn() 7 | 8 | { 9 | using _ = disposable(cb) 10 | } 11 | 12 | expect(cb).toHaveBeenCalled() 13 | }) 14 | }) 15 | 16 | 17 | describe(dispose, () => { 18 | test('disposes.', () => { 19 | const cb = jest.fn() 20 | const _ = disposable(cb) 21 | 22 | expect(cb).not.toHaveBeenCalled() 23 | 24 | dispose(_) 25 | 26 | expect(cb).toHaveBeenCalled() 27 | }) 28 | 29 | test('does nothing if not disposable.', () => { 30 | dispose(undefined as any) 31 | dispose(null as any) 32 | dispose({} as any) 33 | dispose([] as any) 34 | dispose(1 as any) 35 | dispose('1' as any) 36 | dispose(true as any) 37 | dispose(Symbol('1') as any) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/test/event.test.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx renderer.create */ 2 | /** @jsxFrag renderer.fragment */ 3 | 4 | import { testRender } from 'test-callbag-jsx' 5 | import { EventSource } from '../event' 6 | 7 | 8 | describe(EventSource, () => { 9 | test('captures events.', () => { 10 | testRender((renderer, {render, $}) => { 11 | render(