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 |
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 |
--------------------------------------------------------------------------------
/misc/light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loreanvictor/quel/9d067e7cbceb643708062cda4b2225a787299ac2/misc/light.png
--------------------------------------------------------------------------------
/misc/light.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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()
12 | const src = new EventSource($('button').resolveOne()!, 'click')
13 | const cb = jest.fn()
14 |
15 | src.get(cb)
16 |
17 | expect(cb).not.toHaveBeenCalled()
18 |
19 | $('button').click()
20 | expect(cb).toHaveBeenCalledTimes(1)
21 | })
22 | })
23 |
24 | test('stops listening.', () => {
25 | testRender((renderer, {render, $}) => {
26 | render()
27 | const src = new EventSource($('button').resolveOne()!, 'click')
28 | const cb = jest.fn()
29 |
30 | src.get(cb)
31 | $('button').click()
32 | expect(cb).toHaveBeenCalledTimes(1)
33 |
34 | src.stop()
35 | $('button').click()
36 | expect(cb).toHaveBeenCalledTimes(1)
37 | })
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/src/test/exports.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Source, Timer, Subject, EventSource, InputSource,
3 | noop, observe, iterate, from,
4 | Listener, Cleanup, Producer, SourceLike, ExprFn, Observable, Track, SKIP,
5 | } from '../'
6 |
7 |
8 | test('exports stuff.', () => {
9 | expect(Source).toBeDefined()
10 | expect(Timer).toBeDefined()
11 | expect(Subject).toBeDefined()
12 | expect(EventSource).toBeDefined()
13 | expect(InputSource).toBeDefined()
14 |
15 | expect(noop).toBeDefined()
16 | expect(observe).toBeDefined()
17 | expect(iterate).toBeDefined()
18 | expect(from).toBeDefined()
19 |
20 | expect(SKIP).toBeDefined()
21 | expect(>{}).toBeDefined()
22 | expect({}).toBeDefined()
23 | expect(>{}).toBeDefined()
24 | expect(>{}).toBeDefined()
25 | expect(>{}).toBeDefined()
26 | expect(>{}).toBeDefined()
27 | expect(