├── .node-version ├── ConcurModes2.png ├── ConcurModes2-mini.png ├── src ├── index.ts ├── toggleMap.ts ├── EffectManager.ts ├── types.ts ├── utils.ts └── channel.ts ├── docs └── assets │ ├── images │ ├── icons.png │ ├── icons@2x.png │ ├── widgets.png │ └── widgets@2x.png │ └── js │ ├── search.json │ └── main.js ├── .gitignore ├── .travis.yml ├── examples └── deno-poly.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── test ├── perf.spec.ts ├── rhythm.test.ts ├── utils.test.ts └── channel.test.ts ├── CHANGELOG.md └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 10.15.1 2 | -------------------------------------------------------------------------------- /ConcurModes2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanrad/polyrhythm/HEAD/ConcurModes2.png -------------------------------------------------------------------------------- /ConcurModes2-mini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanrad/polyrhythm/HEAD/ConcurModes2-mini.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './channel'; 3 | export * from './utils'; 4 | -------------------------------------------------------------------------------- /docs/assets/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanrad/polyrhythm/HEAD/docs/assets/images/icons.png -------------------------------------------------------------------------------- /docs/assets/images/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanrad/polyrhythm/HEAD/docs/assets/images/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanrad/polyrhythm/HEAD/docs/assets/images/widgets.png -------------------------------------------------------------------------------- /docs/assets/images/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanrad/polyrhythm/HEAD/docs/assets/images/widgets@2x.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_esm 6 | .rts2_cache_umd 7 | .rts2_cache_system 8 | dist 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - main 5 | cache: 6 | yarn: true 7 | directories: 8 | - node_modules 9 | notifications: 10 | email: false 11 | node_js: 12 | - 11 13 | os: 14 | - linux 15 | script: 16 | - npm run test 17 | - npm run test:perf 18 | -------------------------------------------------------------------------------- /examples/deno-poly.ts: -------------------------------------------------------------------------------- 1 | /* 2 | A basic version showing how to execute evented polyrhythm-style 3 | code inside the new runtime DENO. 4 | 5 | > deno run --allow-net=s3.amazonaws.com examples/deno-poly.ts 6 | */ 7 | import { 8 | channel, 9 | after, 10 | } from 'https://s3.amazonaws.com/www.deanius.com/polyrhythm.1.0.0.development.js'; 11 | channel.listen('greet', () => console.log('World')); 12 | channel.filter(true, () => console.log('Hello')); 13 | channel.trigger('greet'); 14 | await after(1000, () => console.log('Goodbye!')); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "exclude": ["src/toggleMap.ts"], 4 | "compilerOptions": { 5 | "target": "es5", 6 | "module": "esnext", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "rootDir": "./", 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "strictPropertyInitialization": true, 17 | "noImplicitThis": true, 18 | "alwaysStrict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "moduleResolution": "node", 24 | "baseUrl": "./", 25 | "paths": { 26 | "*": ["src/*", "node_modules/*"] 27 | }, 28 | "jsx": "react", 29 | "esModuleInterop": true, 30 | "allowSyntheticDefaultImports": true, 31 | "downlevelIteration": true, 32 | "types": ["node"] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dean Radcliffe 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. -------------------------------------------------------------------------------- /src/toggleMap.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { Observable, Subscription } from 'rxjs'; 3 | 4 | export interface Spawner { 5 | (event: Event): Observable; 6 | } 7 | 8 | // The missing *Map operator. Acts like a pushbutton toggle 9 | // wrt to a returned Observable - one will be canceled if its running, 10 | // one will only be started if it wasn't running! 11 | //prettier-ignore 12 | export const toggleMap = ( 13 | spawner: Spawner, 14 | mapper = (_:any, inner:any) => inner 15 | ) => { 16 | return function(source: Observable) { 17 | return new Observable(observer => { 18 | let innerSub: Subscription; 19 | return source.subscribe({ 20 | next(trigger) { 21 | if (!innerSub || innerSub.closed) { 22 | innerSub = spawner(trigger).subscribe( 23 | inner => observer.next(mapper(trigger, inner)), 24 | e => observer.error(e) 25 | ); 26 | } else { 27 | innerSub.unsubscribe(); 28 | } 29 | }, 30 | error(e) { 31 | observer.error(e); 32 | }, 33 | complete() { 34 | observer.complete(); 35 | } 36 | }); 37 | }); 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "polyrhythm", 3 | "version": "1.3.0", 4 | "license": "MIT", 5 | "author": "Dean Radcliffe", 6 | "repository": "https://github.com/deanius/polyrhythm", 7 | "main": "dist/index.js", 8 | "module": "dist/polyrhythm.esm.js", 9 | "typings": "dist/index.d.ts", 10 | "sideEffects": false, 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "start": "tsdx watch", 16 | "build": "tsdx build && size-limit", 17 | "test": "NODE_ENV=test ts-mocha -r esm -p tsconfig.json test/*test*.ts ", 18 | "test:watch": "NODE_ENV=test yarn test --watch --watch-files 'test/*test*.ts' --watch-files 'src/*.ts'", 19 | "test:perf": "ts-mocha -r esm -p tsconfig.json test/perf*.ts ", 20 | "lint": "tsdx lint", 21 | "doc": "typedoc --theme minimal --out docs/ src/", 22 | "doctoc": "doctoc .", 23 | "prepublishOnly": "npm run lint && npm run build" 24 | }, 25 | "dependencies": { 26 | "lodash.ismatch": "^4.4.0", 27 | "rxjs": "^6.5.5" 28 | }, 29 | "devDependencies": { 30 | "@reduxjs/toolkit": "^1.5.1", 31 | "@size-limit/preset-small-lib": "^4.4.5", 32 | "@types/chai": "^4.2.11", 33 | "@types/expect": "^24.3.0", 34 | "@types/jest": "^24.0.21", 35 | "@types/lodash.ismatch": "^4.4.6", 36 | "@types/mocha": "^7.0.2", 37 | "@types/react": "^16.9.19", 38 | "@types/sinon": "^9.0.0", 39 | "chai": "^4.2.0", 40 | "clear": "^0.1.0", 41 | "esm": "^3.2.25", 42 | "husky": "^3.0.9", 43 | "mocha": "^7.1.1", 44 | "react": "^16.12.0", 45 | "rxjs-for-await": "^0.0.2", 46 | "rxjs-marbles": "^6.0.1", 47 | "sinon": "^9.2.3", 48 | "size-limit": "^4.4.5", 49 | "ts-mocha": "^7.0.0", 50 | "tsdx": "^0.11.0", 51 | "tslib": "^2.1.0", 52 | "typedoc": "^0.17.4", 53 | "typescript": "^3.8.3", 54 | "typescript-fsa": "^3.0.0" 55 | }, 56 | "husky": { 57 | "hooks": { 58 | "pre-commit": "tsdx lint" 59 | } 60 | }, 61 | "prettier": { 62 | "printWidth": 80, 63 | "semi": true, 64 | "singleQuote": true, 65 | "trailingComma": "es5" 66 | }, 67 | "size-limit": [ 68 | { 69 | "path": "dist/polyrhythm.cjs.production.min.js", 70 | "limit": "8 KB", 71 | "webpack": false 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /src/EffectManager.ts: -------------------------------------------------------------------------------- 1 | import { Observable, ObservableInput, Subscription, Observer } from 'rxjs'; 2 | import { switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators'; 3 | 4 | /** 5 | * A function returning an Observable representing an effect. 6 | * Also may return Promises, generators, per RxJS' ObservableInput type. 7 | */ 8 | interface EffectFactory { 9 | (item: T): ObservableInput; 10 | } 11 | 12 | /** 13 | * Defines an extended Observer whose next methods themselves return Observables. 14 | */ 15 | interface EffectFactoryObserver { 16 | next: EffectFactory; 17 | complete?: Observer['complete']; 18 | error?: Observer['error']; 19 | } 20 | 21 | /** Defines an Effect execution container, applies a given concurrency strategy 22 | * to the Observables yielded by a given EffectFactory Function. 23 | * @example: const serialAjaxSub = new QueuingEffectManager(from([1,2,3])).subscribe({ next(v){ return ajax(url, {v}) }}); 24 | 25 | */ 26 | interface EffectManager { 27 | subscribe(effectObserver: EffectFactoryObserver): Subscription; 28 | } 29 | 30 | class EffectManagerBase implements EffectManager { 31 | constructor( 32 | protected stream: Observable, 33 | protected combiner: typeof switchMap 34 | ) {} 35 | 36 | subscribe(effectObserver: EffectFactoryObserver) { 37 | //prettier-ignore 38 | return this.stream 39 | .pipe(this.combiner(effectObserver.next)) 40 | .subscribe({ 41 | complete: effectObserver.complete, 42 | error: effectObserver.error || (() => null), 43 | }); 44 | } 45 | } 46 | 47 | /** An EffectManager that replaces any existing effect execution by 48 | * canceling it, and starting a new one. switchMap. 49 | * */ 50 | export class ReplacingEffectManager extends EffectManagerBase { 51 | constructor(stream: Observable) { 52 | super(stream, switchMap); 53 | } 54 | } 55 | 56 | /** An EffectManager that begins effects ASAP with unbounded concurrency. mergeMap. 57 | * */ 58 | export class ASAPEffectManager extends EffectManagerBase { 59 | constructor(stream: Observable) { 60 | super(stream, mergeMap); 61 | } 62 | } 63 | 64 | /** An EffectManager that queues effects without bound. concatMap. 65 | * */ 66 | export class QueuingEffectManager extends EffectManagerBase { 67 | constructor(stream: Observable) { 68 | super(stream, concatMap); 69 | } 70 | } 71 | 72 | /** An EffectManager that does not begin effects if one was executing. exhaustMap. 73 | * */ 74 | export class ThrottlingEffectManager extends EffectManagerBase { 75 | constructor(stream: Observable) { 76 | super(stream, exhaustMap); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subscriber } from 'rxjs'; 2 | export { Subscriber, Subscription } from 'rxjs'; 3 | 4 | export interface Event { 5 | type: string; 6 | payload?: any; 7 | error?: boolean; 8 | meta?: Object; 9 | } 10 | export interface EventWithAnyFields extends Event { 11 | [others: string]: any; 12 | } 13 | export interface EventWithResult extends Event { 14 | result?: Promise; 15 | } 16 | /* A function that can be used as an EventMatcher. */ 17 | export interface Predicate { 18 | (item: Event): boolean; 19 | } 20 | 21 | export interface AwaitableObservable extends PromiseLike, Observable {} 22 | 23 | export type EventMatcher = string | string[] | RegExp | Predicate | boolean; 24 | 25 | interface PromiseFactory { 26 | (): Promise; 27 | } 28 | interface ObservableConstructorFn { 29 | (notify: Subscriber): void | Function; 30 | } 31 | 32 | export type EffectDescriptor = 33 | | Promise 34 | | PromiseFactory 35 | | AwaitableObservable 36 | | Generator 37 | | Observable 38 | | ObservableConstructorFn; 39 | 40 | /** 41 | * A Filter runs a synchronous function prior to any listeners 42 | * being invoked, and can cancel future filters, and all listeners 43 | * by throwing an exception, which must be caught by the caller of 44 | * `trigger`. It may cancel or replace the event by returning `null` or non-null. 45 | * 46 | * It does *not*, as its name might suggest, split off a slice of the 47 | * stream. To do that see `query`. 48 | * @see query 49 | */ 50 | export interface Filter { 51 | (item: T): void | null | EventWithAnyFields; 52 | } 53 | 54 | /** 55 | * The function you assign to `.on(eventType, fn)` 56 | * events is known as a Listener. It receives the event. 57 | */ 58 | export interface Listener { 59 | (item: T): void | any | EffectDescriptor; 60 | } 61 | 62 | /** 63 | * When a listener is async, returning an Observable, it's possible 64 | * that a previous Observable of that listener is running already. 65 | * Concurrency modes control how the new and old listener are affected 66 | * when when they overlap. 67 | * 68 | * ![concurrency modes](https://s3.amazonaws.com/www.deanius.com/ConcurModes2.png) 69 | */ 70 | export enum ConcurrencyMode { 71 | /** 72 | * Newly returned Observables are subscribed immediately, without regard to resource constraints, or the ordering of their completion. (ala mergeMap) */ 73 | parallel = 'parallel', 74 | /** 75 | * Observables are enqueued and always complete in the order they were triggered. (ala concatMap)*/ 76 | serial = 'serial', 77 | /** 78 | * Any existing Observable is canceled, and a new is begun (ala switchMap) */ 79 | replace = 'replace', 80 | /** 81 | * Any new Observable is not subscribed if another is running. (ala exhaustMap) */ 82 | ignore = 'ignore', 83 | /** 84 | * Any new Observable is not subscribed if another is running, and 85 | * the previous one is canceled. (ala switchMap with empty() aka toggleMap) */ 86 | toggle = 'toggle', 87 | } 88 | 89 | export interface TriggerConfig { 90 | next?: string; 91 | complete?: string; 92 | error?: string; 93 | start?: string; 94 | } 95 | 96 | export interface ListenerConfig { 97 | /** The concurrency mode to use. Governs what happens when another handling from this handler is already in progress. */ 98 | mode?: 99 | | ConcurrencyMode 100 | | 'serial' 101 | | 'parallel' 102 | | 'replace' 103 | | 'ignore' 104 | | 'toggle'; 105 | /** A declarative way to map the Observable returned from the listener onto new triggered events */ 106 | trigger?: TriggerConfig | true; 107 | takeUntil?: EventMatcher; 108 | } 109 | 110 | export interface Thunk { 111 | (): T; 112 | } 113 | -------------------------------------------------------------------------------- /test/perf.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { filter, listen, trigger, reset } from '../src/channel'; 3 | import { after } from '../src/utils'; 4 | import { range } from 'rxjs'; 5 | import { expect } from 'chai'; 6 | 7 | describe('Performance Testing', () => { 8 | beforeEach(() => reset()); 9 | 10 | describe('Cost to trigger/process', () => { 11 | const threshold1s = 10000; 12 | const timeScale = 0.1; 13 | const thresholdTime = 1000; // 1 second 14 | it(`Can process ${threshold1s} per second with no filters or listeners`, () => { 15 | expectToCompleteWithin(thresholdTime * timeScale, () => { 16 | range(0, threshold1s * timeScale).subscribe(i => { 17 | trigger('perf/test', { i }); 18 | }); 19 | }); 20 | }); 21 | it(`Can process ${threshold1s} per second with a filter and an sync listener`, () => { 22 | let counter = 0; 23 | filter(true, () => { 24 | counter += 1; 25 | }); 26 | listen(true, () => { 27 | counter += 1; 28 | }); 29 | expectToCompleteWithin(thresholdTime * timeScale, () => { 30 | range(0, threshold1s * timeScale).subscribe({ 31 | next(i) { 32 | trigger('perf/test', { i }); 33 | }, 34 | complete() { 35 | expect(counter).to.be.below(threshold1s * 2); 36 | }, 37 | }); 38 | }); 39 | }); 40 | it(`Can process ${threshold1s} per second with a filter and an async listener`, () => { 41 | let counter = 0; 42 | filter(true, () => { 43 | counter += 1; 44 | }); 45 | listen(true, () => 46 | after(100, () => { 47 | counter += 1; 48 | }) 49 | ); 50 | expectToCompleteWithin(thresholdTime * timeScale, () => { 51 | range(0, threshold1s * timeScale).subscribe(i => 52 | trigger('perf/test', { i }) 53 | ); 54 | }); 55 | }); 56 | 57 | const manyFilters = 500; 58 | const processNum = 1000; 59 | 60 | it(`Can process ${processNum} per second with ${manyFilters} filters`, done => { 61 | let counter = 0; 62 | range(0, manyFilters + 1).subscribe(i => { 63 | filter(true, () => { 64 | counter = i; 65 | }); 66 | }); 67 | 68 | range(0, processNum).subscribe({ 69 | next(i) { 70 | trigger('perf/test', { i }); 71 | }, 72 | complete() { 73 | expect(counter).to.eql(manyFilters); 74 | done(); 75 | }, 76 | }); 77 | }); 78 | 79 | const manylisteners = 40; 80 | it(`Can process ${processNum} per second with ${manylisteners} listeners`, () => { 81 | let counter = 0; 82 | range(0, manylisteners + 1).subscribe(() => { 83 | listen(true, () => { 84 | counter += 1; 85 | }); 86 | }); 87 | 88 | expectToCompleteWithin(thresholdTime, () => { 89 | range(0, processNum).subscribe(i => trigger('perf/test', { i })); 90 | }); 91 | }); 92 | 93 | it('Is affected by time spent in filters / event matchers', done => { 94 | let counter = 0; 95 | let startTime = Date.now(); 96 | filter(true, () => { 97 | block(20); 98 | counter += 1; 99 | }); 100 | range(0, 5).subscribe({ 101 | next(i) { 102 | trigger('perf/test', { i }); 103 | }, 104 | complete() { 105 | let endTime = Date.now(); 106 | expect(endTime - startTime).to.be.at.least(100); 107 | done(); 108 | }, 109 | }); 110 | }); 111 | }); 112 | }); 113 | 114 | function expectToCompleteWithin(ms, fn) { 115 | const beginTime = Date.now(); 116 | let result = fn.call(); 117 | 118 | if (result && result.then) { 119 | return Promise.race([ 120 | result, 121 | new Promise((_, reject) => 122 | setTimeout(() => reject(new Error('timeout')), ms) 123 | ), 124 | ]); 125 | } 126 | 127 | const endTime = Date.now(); 128 | expect(endTime - beginTime).to.be.below(ms); 129 | } 130 | 131 | function getTime() { 132 | return Date.now(); 133 | } 134 | function block(ms = 200) { 135 | const orig = getTime(); 136 | while (getTime() - orig < ms) {} 137 | } 138 | -------------------------------------------------------------------------------- /test/rhythm.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { concat, of, Subscription } from 'rxjs'; 3 | import sinon from 'sinon'; 4 | import { fakeSchedulers } from 'rxjs-marbles/mocha'; 5 | import { channel } from '../src/channel'; 6 | import { after } from '../src/utils'; 7 | import { Event } from '../src/types'; 8 | 9 | const mockUser = { id: 42, name: 'Joe' }; 10 | 11 | let DELAY = 5000; 12 | 13 | describe('User Rhythm', () => { 14 | let channelEvents: Event[]; 15 | let cleanup: Subscription; 16 | let clock: sinon.SinonFakeTimers; 17 | 18 | beforeEach(() => { 19 | channelEvents = new Array(); 20 | // tests can add cleanup functions to this object 21 | cleanup = new Subscription(); 22 | cleanup.add(channel.filter(true, e => channelEvents.push(e))); 23 | clock = sinon.useFakeTimers(); 24 | }); 25 | 26 | beforeEach(() => { 27 | // Our test subject is a channel set up to 28 | // respond to certain events. We Assert upon events 29 | // the channel sees, and Arrange and Act by triggering 30 | // events according to an Observable 31 | channel.listen('REQUEST_USER', () => of(mockUser), { 32 | trigger: { next: 'RECEIVE_USER' }, 33 | }); 34 | 35 | channel.listen('REQUEST_USER_DELAY', () => after(DELAY, mockUser), { 36 | trigger: { next: 'RECEIVE_USER' }, 37 | }); 38 | 39 | channel.listen('REQUEST_USER_DEBOUNCED', () => after(DELAY, mockUser), { 40 | trigger: { next: 'RECEIVE_USER' }, 41 | mode: 'replace', 42 | }); 43 | }); 44 | 45 | afterEach(() => { 46 | // run any unsubscribers - tests should do cleanup.add for cleanup 47 | cleanup.unsubscribe(); 48 | // and simply 49 | channel.reset(); 50 | clock?.restore(); 51 | }); 52 | 53 | it('maps REQUEST_USER to RECEIVE_USER', () => { 54 | // declare inputs 55 | const request = { type: 'REQUEST_USER' }; 56 | const input = of(request); 57 | 58 | // define outputs 59 | const output = [ 60 | request, 61 | { 62 | type: 'RECEIVE_USER', 63 | payload: mockUser, 64 | }, 65 | ]; 66 | 67 | // run the inputs 68 | input.subscribe(e => channel.trigger(e)); 69 | 70 | // assert the outputs 71 | expect(channelEvents).to.eql(output); 72 | }); 73 | 74 | it( 75 | 'maps REQUEST_USER_DELAY to RECEIVE_USER', 76 | fakeSchedulers(() => { 77 | // declare inputs 78 | const request = { type: 'REQUEST_USER_DELAY' }; 79 | const input = of(request); 80 | 81 | // run the inputs 82 | input.subscribe(e => channel.trigger(e)); 83 | 84 | // sync events are seen 85 | expect(channelEvents).to.eql([request]); 86 | 87 | // advance time virtually 88 | clock.tick(DELAY); 89 | 90 | // assert all events 91 | expect(channelEvents).to.eql([ 92 | request, 93 | { 94 | type: 'RECEIVE_USER', 95 | payload: mockUser, 96 | }, 97 | ]); 98 | }) 99 | ); 100 | 101 | it( 102 | 'maps debounced REQUEST_USER to RECEIVE_USER', 103 | fakeSchedulers(() => { 104 | // declare inputs 105 | const request = { type: 'REQUEST_USER_DEBOUNCED' }; 106 | const input = of(request, request); 107 | 108 | // run the inputs 109 | input.subscribe(e => channel.trigger(e)); 110 | 111 | // advance time virtually, with sufficient margin 112 | clock.tick(DELAY * 10); 113 | 114 | // assert the outputs - 2 requests, one output 115 | expect(channelEvents).to.eql([ 116 | request, 117 | request, 118 | { 119 | type: 'RECEIVE_USER', 120 | payload: mockUser, 121 | }, 122 | ]); 123 | }) 124 | ); 125 | 126 | it( 127 | 'maps debounced REQUEST_USER to RECEIVE_USER over time', 128 | fakeSchedulers(() => { 129 | // declare inputs 130 | const request = { type: 'REQUEST_USER_DEBOUNCED' }; 131 | const input = concat(after(0, request), after(DELAY * 0.5, request)); 132 | 133 | // run the inputs 134 | input.subscribe(e => channel.trigger(e)); 135 | 136 | // advance time virtually, with sufficient margin 137 | clock.tick(DELAY * 10); 138 | 139 | // assert the outputs - 2 requests, one output 140 | expect(channelEvents).to.eql([ 141 | request, 142 | request, 143 | { 144 | type: 'RECEIVE_USER', 145 | payload: mockUser, 146 | }, 147 | ]); 148 | }) 149 | ); 150 | }); 151 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | of, 3 | from, 4 | defer, 5 | NEVER, 6 | timer, 7 | Observable, 8 | throwError, 9 | empty, 10 | concat, 11 | } from 'rxjs'; 12 | import { 13 | map, 14 | mergeMap, 15 | concatMap, 16 | exhaustMap, 17 | switchMap, 18 | } from 'rxjs/operators'; 19 | import { AwaitableObservable, ConcurrencyMode, Listener, Thunk } from './types'; 20 | import { toggleMap } from './toggleMap'; 21 | export { toggleMap } from './toggleMap'; 22 | export { concat } from 'rxjs'; 23 | export { map, tap, scan } from 'rxjs/operators'; 24 | 25 | /** 26 | * Returns a random hex string, like a Git SHA. Not guaranteed to 27 | * be unique - just to within about 1 in 10,000. 28 | */ 29 | export const randomId = (length: number = 7) => { 30 | return Math.floor(Math.pow(2, length * 4) * Math.random()) 31 | .toString(16) 32 | .padStart(length, '0'); 33 | }; 34 | 35 | /** 36 | * Returns an Observable of the value, or result of the function call, after 37 | * the number of milliseconds given. After is lazy and cancelable! So nothing happens until .subscribe 38 | * is called explicitly (via subscribe) or implicitly (toPromise(), await). 39 | * For a delay of 0, the function is executed synchronously when .subscribe is called. 40 | * @returns An Observable of the object or thunk return value. It is 'thenable', so may also be awaited directly. 41 | * ``` 42 | * // Examples: 43 | * // awaited Promise 44 | * await after(100, () => new Date()) 45 | * // unawaited Promise 46 | * after(100, () => new Date()).toPromise() 47 | * // unresolving Promise 48 | * after(Infinity, () => new Date()).toPromise() 49 | * ``` 50 | */ 51 | export function after( 52 | ms: number, 53 | objOrFn?: T | Thunk | Observable 54 | ): AwaitableObservable { 55 | const delay = ms <= 0 ? of(0) : ms === Infinity ? NEVER : timer(ms); 56 | 57 | const resultMapper = 58 | typeof objOrFn === 'function' 59 | ? (objOrFn as (value: Number) => any) 60 | : () => objOrFn; 61 | 62 | // prettier-ignore 63 | const resultObs: Observable = delay.pipe( 64 | isObservable(objOrFn) 65 | ? mergeMap(() => objOrFn) 66 | : map(resultMapper) 67 | ); 68 | 69 | // after is a 'thenable, thus usable with await. 70 | // ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await 71 | // @ts-ignore 72 | resultObs.then = function(resolve, reject) { 73 | return resultObs.toPromise().then(resolve, reject); 74 | }; 75 | 76 | return resultObs as AwaitableObservable; 77 | } 78 | 79 | function isObservable(obj: any): obj is Observable { 80 | return obj?.subscribe !== undefined; 81 | } 82 | 83 | /** Executes the given function on the microtask queue. 84 | * The microtask queue flushes before the macrotask queue. 85 | * @returns A Promise for its return value 86 | * @see https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide 87 | * @see queueMicrotask, Promise.resolve 88 | */ 89 | export function microq(fn: Function) { 90 | // @ts-ignore 91 | return Promise.resolve().then(fn); 92 | } 93 | 94 | /** Executes the given function on the macrotask queue. 95 | * The macrotask queue flushes after the microstask queue. 96 | * @returns A Promise for its return value 97 | * @see https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide 98 | * @see setTimeout 99 | */ 100 | export function macroq(fn: Function) { 101 | return new Promise(resolve => { 102 | return setTimeout(() => resolve(fn()), 0); 103 | }); 104 | } 105 | 106 | const getTimestamp = () => new Date().getTime(); 107 | 108 | /** Returns a Promise for the point in time at which all existing queued microtasks (e.g. Promise.resolve()) have completed. */ 109 | export function microflush(): Promise { 110 | return Promise.resolve().then(() => getTimestamp()); 111 | } 112 | 113 | /** Returns a Promise for the point in time at which all existing queued macrotasks (e.g. setTimeout 0) have completed. */ 114 | export function macroflush(): Promise { 115 | return new Promise(resolve => { 116 | return setTimeout(() => resolve(getTimestamp()), 0); 117 | }); 118 | } 119 | 120 | /** Creates a derived Observable, running the listener in the given ConcurrencyMode 121 | * turning sync errors into Observable error notifications 122 | */ 123 | export function combineWithConcurrency( 124 | o: Observable, 125 | listener: Listener, 126 | mode: ConcurrencyMode, 127 | individualPipes = [], 128 | individualEnder = empty(), 129 | individualStarter = () => empty() 130 | ) { 131 | const combine = operatorForMode(mode); 132 | const mappedEvents = (e: T): Observable => { 133 | try { 134 | const _results = listener(e); 135 | // @ts-ignore 136 | return concat( 137 | // @ts-ignore 138 | individualStarter(e), 139 | // @ts-ignore 140 | toObservable(_results).pipe(...individualPipes), 141 | individualEnder 142 | ); 143 | } catch (ex) { 144 | return throwError(ex); 145 | } 146 | }; 147 | const combined = o.pipe( 148 | // @ts-ignore 149 | combine(mappedEvents) 150 | ); 151 | return combined; 152 | } 153 | 154 | /** Controls what types can be returned from an `on` handler: 155 | Primitive types: `of()` 156 | Promises: `from()` 157 | Observables: pass-through 158 | */ 159 | function toObservable(_results: any): Observable { 160 | if (typeof _results === 'undefined') return empty(); 161 | 162 | if (typeof _results === 'function') { 163 | return _results.length === 1 ? new Observable(_results) : defer(_results); 164 | } 165 | 166 | // An Observable is preferred 167 | if (_results.subscribe) return _results; 168 | 169 | // A Subscrition is ok - can be canceled but not awaited 170 | if (_results.unsubscribe) 171 | return new Observable(() => { 172 | // an Observable's return value is its cleanup function 173 | return () => _results.unsubscribe(); 174 | }); 175 | 176 | // A Promise is acceptable 177 | if (_results.then) return from(_results as Promise); 178 | 179 | // All but string iterables will be expanded (generators, arrays) 180 | if ( 181 | typeof _results[Symbol.iterator] === 'function' && 182 | typeof _results !== 'string' 183 | ) 184 | return from(_results as Generator); 185 | 186 | // otherwise we convert it to a single-item Observable 187 | return of(_results); 188 | } 189 | 190 | export function operatorForMode( 191 | mode: ConcurrencyMode = ConcurrencyMode.parallel 192 | ) { 193 | switch (mode) { 194 | case ConcurrencyMode.ignore: 195 | return exhaustMap; 196 | case ConcurrencyMode.parallel: 197 | return mergeMap; 198 | case ConcurrencyMode.serial: 199 | return concatMap; 200 | case ConcurrencyMode.toggle: 201 | return toggleMap; 202 | case ConcurrencyMode.replace: 203 | default: 204 | return switchMap; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/channel.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Observable, Subscription, empty, throwError } from 'rxjs'; 2 | import isMatch from 'lodash.ismatch'; 3 | import { catchError, filter as _filter, map, mergeMap } from 'rxjs/operators'; 4 | import { takeUntil, first } from 'rxjs/operators'; 5 | import { combineWithConcurrency, after } from './utils'; 6 | import { 7 | Predicate, 8 | Filter, 9 | Event, 10 | EventWithAnyFields, 11 | EventWithResult, 12 | EventMatcher, 13 | Listener, 14 | ListenerConfig, 15 | } from './types'; 16 | 17 | function isTestMode() { 18 | if (typeof process === 'undefined') return false; 19 | return process?.env?.NODE_ENV === 'test'; 20 | } 21 | const MSG_LISTENER_ERROR = `A listener function notified with an error and will be unsubscribed`; 22 | 23 | export class Channel { 24 | private eventChannel: Subject; 25 | private resets: Subject; 26 | private filters: Map>; 27 | public errors: Subject; 28 | 29 | constructor() { 30 | this.eventChannel = new Subject(); 31 | this.filters = new Map>(); 32 | this.resets = new Subject(); 33 | this.errors = new Subject(); 34 | if (!isTestMode()) { 35 | this.errors.subscribe(e => console.error(e)); 36 | } 37 | } 38 | 39 | public trigger( 40 | eventOrType: string | (EventWithAnyFields & T), 41 | payload?: T 42 | ): EventWithResult { 43 | let event: EventWithResult = 44 | typeof eventOrType === 'string' 45 | ? { type: eventOrType as string } 46 | : eventOrType; 47 | payload && Object.assign(event, { payload }); 48 | 49 | for (const [predicate, filter] of this.filters.entries()) { 50 | if (predicate(event)) { 51 | const filterResult = filter(event); 52 | if (filterResult === null) return event; 53 | if (filterResult && filterResult.type) { 54 | event = filterResult as EventWithResult; 55 | } 56 | } 57 | } 58 | 59 | Object.freeze(event); 60 | 61 | this.eventChannel.next(event); 62 | return event; 63 | } 64 | 65 | public filter( 66 | eventMatcher: EventMatcher, 67 | f: Filter 68 | ): Subscription { 69 | const predicate = getEventPredicate(eventMatcher); 70 | this.filters.set(predicate, f); 71 | return new Subscription(() => { 72 | this.filters.delete(predicate); 73 | }); 74 | } 75 | 76 | public listen( 77 | eventMatcher: EventMatcher, 78 | listener: Listener, 79 | config: ListenerConfig = {} 80 | ): Subscription { 81 | const userTriggers = config.trigger || {}; 82 | const individualPipes = []; 83 | 84 | const nextNotifier = (e: Event) => 85 | // @ts-ignore 86 | userTriggers.next ? this.trigger(userTriggers.next, e) : this.trigger(e); 87 | 88 | // @ts-ignore 89 | if (userTriggers.next || userTriggers === true) { 90 | individualPipes.push( 91 | mergeMap((e: Event) => { 92 | try { 93 | nextNotifier(e); 94 | return empty(); 95 | } catch (ex) { 96 | return throwError(new Error(MSG_LISTENER_ERROR)); 97 | } 98 | }) 99 | ); 100 | } 101 | // @ts-ignore 102 | if (userTriggers.error) { 103 | individualPipes.push( 104 | catchError( 105 | e => 106 | new Observable(notify => { 107 | // @ts-ignore 108 | this.trigger(userTriggers.error, e); 109 | notify.complete(); 110 | }) 111 | ) 112 | ); 113 | } 114 | // allow declarative termination 115 | if (config.takeUntil) { 116 | individualPipes.push(takeUntil(this.query(config.takeUntil))); 117 | } 118 | // @ts-ignore 119 | const individualEnder: Observable = userTriggers.complete 120 | ? new Observable(o => { 121 | // @ts-ignore 122 | this.trigger(userTriggers.complete); 123 | o.complete(); 124 | }) 125 | : empty(); 126 | 127 | const individualStarter = (e: Event) => 128 | // @ts-ignore 129 | userTriggers.start 130 | ? after(0, () => { 131 | // @ts-ignore 132 | this.trigger(userTriggers.start, e.payload); 133 | }) 134 | : empty(); 135 | 136 | const _combined = combineWithConcurrency( 137 | this.query(eventMatcher), 138 | listener, 139 | // @ts-ignore 140 | config.mode, 141 | individualPipes, 142 | individualEnder, 143 | individualStarter 144 | ); 145 | 146 | const listenerObserver = { 147 | error: (err: Error) => { 148 | this.errors.next(err); 149 | this.errors.next(MSG_LISTENER_ERROR); 150 | }, 151 | }; 152 | 153 | const combined = _combined.pipe(takeUntil(this.resets)); 154 | return combined.subscribe(listenerObserver); 155 | } 156 | 157 | /* An alias for listen, hat tip to JQuery. */ 158 | public on( 159 | eventMatcher: EventMatcher, 160 | listener: Listener, 161 | config: ListenerConfig = {} 162 | ): Subscription { 163 | return this.listen(eventMatcher, listener, config); 164 | } 165 | 166 | /** 167 | * Provides an Observable of matching events from the channel. 168 | */ 169 | public query( 170 | eventMatcher: EventMatcher, 171 | payloadMatcher?: Object 172 | ): Observable { 173 | const resultObs = this.eventChannel.asObservable().pipe( 174 | _filter(getEventPredicate(eventMatcher, payloadMatcher)), 175 | map(e => e as T) 176 | ); 177 | 178 | resultObs.toPromise = function() { 179 | return resultObs.pipe(first()).toPromise(); 180 | }; 181 | // @ts-ignore 182 | resultObs.then = function(resolve, reject) { 183 | return resultObs.toPromise().then(resolve, reject); 184 | }; 185 | return resultObs; 186 | } 187 | 188 | /** Runs a filter function (sync) for all events on a channel */ 189 | public spy(spyFn: Filter) { 190 | const sub = this.filter(true, (e: T) => { 191 | try { 192 | spyFn(e); 193 | } catch (err) { 194 | this.errors.next(err); 195 | this.errors.next(`A spy threw an exception and will be unsubscribed`); 196 | if (sub) { 197 | sub.unsubscribe(); 198 | } 199 | } 200 | }); 201 | return sub; 202 | } 203 | /** 204 | * Clears all filters and listeners, and cancels any in-flight 205 | * async operations by listeners. 206 | */ 207 | public reset() { 208 | this.filters.clear(); 209 | this.resets.next(); 210 | } 211 | } 212 | 213 | // Exports for a default Channel 214 | export const channel = new Channel(); 215 | export const trigger = channel.trigger.bind(channel); 216 | export const query = channel.query.bind(channel); 217 | export const filter = channel.filter.bind(channel); 218 | export const listen = channel.listen.bind(channel); 219 | export const on = channel.on.bind(channel); 220 | export const spy = channel.spy.bind(channel); 221 | export const reset = channel.reset.bind(channel); 222 | 223 | function getEventPredicate( 224 | eventMatcher: EventMatcher, 225 | payloadMatcher?: Object 226 | ) { 227 | let predicate: (event: Event) => boolean; 228 | 229 | if (eventMatcher instanceof RegExp) { 230 | predicate = (event: Event) => 231 | eventMatcher.test(event.type) && 232 | (!payloadMatcher || isMatch(event.payload, payloadMatcher)); 233 | } else if (eventMatcher instanceof Function) { 234 | predicate = eventMatcher; 235 | } else if (typeof eventMatcher === 'boolean') { 236 | predicate = () => eventMatcher; 237 | } else if (typeof eventMatcher === 'object') { 238 | predicate = (event: Event) => isMatch(event, eventMatcher); 239 | } else if (eventMatcher.constructor === Array) { 240 | predicate = (event: Event) => 241 | // @ts-ignore 242 | eventMatcher.includes(event.type) && 243 | (!payloadMatcher || isMatch(event.payload, payloadMatcher)); 244 | } else { 245 | predicate = (event: Event) => 246 | eventMatcher === event.type && 247 | (!payloadMatcher || isMatch(event.payload, payloadMatcher)); 248 | } 249 | return predicate; 250 | } 251 | 252 | /** Decorates a function so that its argument is the mutable array 253 | * of all events seen during its run. Useful for testing: 254 | * 255 | * it('does awesome', captureEvents(async seen => { 256 | * trigger('foo) 257 | * expect(seen).toEqual([{type: 'foo'}]) 258 | * })); 259 | */ 260 | export function captureEvents(testFn: (arg: T[]) => void | Promise) { 261 | return function() { 262 | const seen = new Array(); 263 | // @ts-ignore 264 | const sub = query(true).subscribe(event => seen.push(event)); 265 | const result: any = testFn(seen); 266 | if (result && result.then) { 267 | return result.finally(() => sub.unsubscribe()); 268 | } 269 | sub.unsubscribe(); 270 | return result; 271 | }; 272 | } 273 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import { after, microq, macroq, microflush, macroflush } from '../src/utils'; 4 | import { concat, timer, of } from 'rxjs'; 5 | import { fakeSchedulers } from 'rxjs-marbles/mocha'; 6 | 7 | describe('Utilities', () => { 8 | describe('after', () => { 9 | let counter = 0; 10 | // a function incrementing c 11 | const incrementCounter = () => { 12 | return ++counter; 13 | }; 14 | 15 | describe('First argument', () => { 16 | describe('If zero (0)', () => { 17 | it('executes synchronously when subscribed', async () => { 18 | const effects = new Array(); 19 | 20 | // sync 21 | after(0, () => effects.push(1)).subscribe(); 22 | // not subscribed 23 | const effect2 = after(0, () => effects.push(2)); 24 | 25 | expect(effects).to.eql([1]); 26 | 27 | effect2.subscribe(); 28 | expect(effects).to.eql([1, 2]); 29 | }); 30 | }); 31 | describe('Greater than 0', () => { 32 | it('defers the function till then', async () => { 33 | const effects = new Array(); 34 | after(5, () => { 35 | effects.push(1); 36 | }).subscribe(); 37 | 38 | expect(effects).to.eql([]); 39 | await after(10, () => { 40 | expect(effects).to.eql([1]); 41 | }); 42 | }); 43 | }); 44 | describe('Positive infinity', () => { 45 | it('is Observable.never', () => { 46 | const finished = sinon.spy(); 47 | // Never do this obviously! 48 | // await after(Infinity, () => {}) 49 | after(Infinity, () => {}).subscribe({ 50 | complete: finished, 51 | }); 52 | expect(finished.args.length).to.equal(0); 53 | }); 54 | }); 55 | }); 56 | describe('Second argument', () => { 57 | describe('when a function', () => { 58 | it('Schedules its execution later', async () => { 59 | let counter = 0; 60 | await after(1, () => counter++).toPromise(); 61 | expect(counter).to.eql(1); 62 | }); 63 | it('Returns its return value', async () => { 64 | let result = await after(1, () => 2.71).toPromise(); 65 | expect(result).to.eql(2.71); 66 | }); 67 | }); 68 | describe('when a value', () => { 69 | it('Becomes the value of the Observable', async () => { 70 | const result = await after(1, 2.718).toPromise(); 71 | expect(result).to.eql(2.718); 72 | }); 73 | }); 74 | describe('when an Observable', () => { 75 | it('delays the subscribe to the Observable without producing a value', async () => { 76 | // const subject = after(10, of(2.718)); 77 | const subject = after(10, of(2.718)); 78 | const seen: number[] = []; 79 | subject.subscribe(v => seen.push(v)); 80 | await after(11); 81 | expect(seen).to.eql([2.718]); 82 | }); 83 | }); 84 | describe('when not provided', () => { 85 | it('undefined becomes the value of the Observable', async () => { 86 | const result = await after(1).toPromise(); 87 | expect(result).to.eql(undefined); 88 | }); 89 | }); 90 | }); 91 | describe('Return Value', () => { 92 | it('Is unstarted/lazy/not running', async () => { 93 | after(1, incrementCounter); // no .subscribe() or .toPromise() 94 | 95 | // Wait long enough that we'd see a change if it was eager (but it's lazy) 96 | await timer(10).toPromise(); 97 | expect(counter).not.to.be.greaterThan(0); 98 | }); 99 | it('Can be obtained via subscribe', done => { 100 | after(10, 1.1).subscribe(n => { 101 | expect(n).to.eql(1.1); 102 | done(); 103 | }); 104 | }); 105 | it('Can be awaited directly', async () => { 106 | const result = await after(1, () => 2.718); 107 | expect(result).to.eql(2.718); 108 | }); 109 | }); 110 | describe('Cancelability', () => { 111 | it('Can be canceled', async () => { 112 | const effects = new Array(); 113 | 114 | const twoEvents = [1, 2].map(i => after(5, () => effects.push(i))); 115 | 116 | const sub = concat(...twoEvents).subscribe(); 117 | 118 | await after(5, () => { 119 | expect(effects).to.eql([1]); 120 | sub.unsubscribe(); 121 | }); 122 | await after(15, () => { 123 | expect(effects).to.eql([1]); 124 | }); 125 | }); 126 | }); 127 | 128 | describe('Typescript Inference', () => { 129 | interface FooPayload { 130 | fooId: string; 131 | } 132 | 133 | it('lets you type the deferred value', () => { 134 | const a = after(0, { fooId: 'abc' }); 135 | a.subscribe(n => { 136 | // got typescript support! 137 | // n.fooId; 138 | expect(n).to.eql({ fooId: 'abc' }); 139 | }); 140 | }); 141 | 142 | it('lets you type the deferred value producing function ', () => { 143 | const a = after(0, () => ({ fooId: 'abc' })); 144 | a.subscribe(n => { 145 | // got typescript support! 146 | // n.fooId; 147 | expect(n).to.eql({ fooId: 'abc' }); 148 | }); 149 | }); 150 | 151 | it('lets you see types through then', () => { 152 | const a = after(0, () => ({ fooId: 'abc' })); 153 | a.then(n => { 154 | // got typescript support! 155 | // n.fooId; 156 | expect(n).to.eql({ fooId: 'abc' }); 157 | }); 158 | }); 159 | }); 160 | }); 161 | 162 | describe('microq (microqueue)', () => { 163 | it('executes functions on the microtask queue', async () => { 164 | const seen: Array = []; 165 | microq(() => seen.push(1)); 166 | microq(() => seen.push(2)); 167 | 168 | setTimeout(() => seen.push(3), 0); 169 | await Promise.resolve(); 170 | expect(seen).to.eql([1, 2]); 171 | }); 172 | 173 | it('promises the function return value', async () => { 174 | expect(await microq(() => 2)).to.eq(2); 175 | }); 176 | }); 177 | 178 | describe('macroq (macroqueue)', () => { 179 | it('executes functions on the macrotask queue', async () => { 180 | const seen: Array = []; 181 | microq(() => seen.push(1)); 182 | setTimeout(() => seen.push(2), 0); 183 | const result = macroq(() => seen.push(3)); 184 | 185 | await result; 186 | expect(seen).to.eql([1, 2, 3]); 187 | }); 188 | 189 | it('promises the function return value', async () => { 190 | expect(await microq(() => 2)).to.eq(2); 191 | }); 192 | }); 193 | 194 | describe('microflush/macroflush (a flush of the respective queue)', () => { 195 | it('returns a Promise for a timestamp some time in the future', async () => { 196 | const now = new Date().getTime(); 197 | const [microTime, macroTime] = await Promise.all([ 198 | microflush(), 199 | macroflush(), 200 | ]); 201 | 202 | expect(microTime).to.be.at.least(now); 203 | expect(macroTime).to.be.at.least(microTime); 204 | }); 205 | 206 | it('resolves a microflush first', () => { 207 | return Promise.all([microflush(), macroflush()]).then( 208 | ([microTime, macroTime]) => { 209 | expect(macroTime).to.be.at.least(microTime); 210 | } 211 | ); 212 | }); 213 | 214 | describe('microflush', () => { 215 | it('ensures existing microtasks are flushed', () => { 216 | let counter = 0; 217 | microq(() => (counter += 1)); 218 | 219 | expect(counter).to.equal(0); 220 | 221 | return microflush().then(() => { 222 | expect(counter).to.equal(1); 223 | }); 224 | }); 225 | }); 226 | 227 | describe('macroflush', () => { 228 | it('ensures existing macrotasks are flushed', () => { 229 | let counter = 0; 230 | microq(() => (counter += 0.1)); 231 | macroq(() => (counter += 1)); 232 | 233 | expect(counter).to.equal(0); 234 | 235 | return microflush() 236 | .then(() => { 237 | expect(counter).to.equal(0.1); 238 | }) 239 | .then(() => { 240 | return macroflush(); 241 | }) 242 | .then(() => { 243 | expect(counter).to.equal(1.1); 244 | }); 245 | }); 246 | }); 247 | }); 248 | 249 | describe('Virtual time testing (mocha)', () => { 250 | let clock: sinon.SinonFakeTimers; 251 | 252 | beforeEach(() => { 253 | clock = sinon.useFakeTimers(); 254 | }); 255 | afterEach(() => { 256 | clock?.restore(); 257 | }); 258 | it( 259 | 'is faster than real elapsed time', 260 | fakeSchedulers(() => { 261 | let seen = 0, 262 | WAIT = 100; 263 | after(WAIT, 3.14).subscribe(v => { 264 | seen = v; 265 | }); 266 | clock.tick(WAIT); 267 | expect(seen).to.eq(3.14); 268 | }) 269 | ); 270 | }); 271 | 272 | describe('combineWithConcurrency', () => { 273 | describe( 274 | 'Lets you combine Observables without operators', 275 | fakeSchedulers(() => { 276 | it('in parallel'); 277 | it('in serial'); 278 | it('replacing any old with the new'); 279 | it('ignoring any new, if an old is present'); 280 | it('toggling any old off'); 281 | }) 282 | ); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Releases 2 | 3 | 4 | 5 | 6 | 7 | - [1.3.0 Filters can remove or mutate events](#130-filters-can-remove-or-mutate-events) 8 | - [1.2.9 Better tree-shakability via `sideEffects: false`](#129-better-tree-shakability-via-sideeffects-false) 9 | - [1.2.7 Better Typescript typings, docs.](#127-better-typescript-typings-docs) 10 | - [1.2.6 After can defer an Observable.](#126-after-can-defer-an-observable) 11 | - [1.2.5 Allow listener-returned bare values or generators](#125-allow-listener-returned-bare-values-or-generators) 12 | - [1.2.4 Can declaratively fire a 'start' event upon Observable subscription](#124-can-declaratively-fire-a-start-event-upon-observable-subscription) 13 | - [1.2.3 Important bug fix](#123-important-bug-fix) 14 | - [1.2 Smaller Bundle, More Robust](#12-smaller-bundle-more-robust) 15 | - [1.1.6 takeUntil](#116-takeuntil) 16 | - [1.1.3 await query(), and succint tests](#113-await-query-and-succint-tests) 17 | - [1.1.2 Support generators as listeners](#112-support-generators-as-listeners) 18 | - [1.1.1 Add optional TypeScript typings](#111-add-optional-typescript-typings) 19 | - [1.1.0 Remove React dependencies](#110-remove-react-dependencies) 20 | - [1.0.12 Trigger whole event objects](#1012-trigger-whole-event-objects) 21 | - [1.0.11 `query.toPromise()` returns the next matching event](#1011-querytopromise-returns-the-next-matching-event) 22 | - [1.0.8 `microq` and `macroq` functions](#108-microq-and-macroq-functions) 23 | - [1.0.7 TypeScript typings corrected for `after`](#107-typescript-typings-corrected-for-after) 24 | - [1.0.6 Handy RxJS exports](#106-handy-rxjs-exports) 25 | 26 | 27 | 28 | ### 1.3.0 Filters can remove or mutate events 29 | Solves the common use cases of filters which remove events from downstream listeners', or which replace one event with another. If a Filter function 30 | returns null, no further filters or listeners will see it. If a non-nullish object, that object replaces the event (make sure it duck-types an FSA or EventWithAnyFields) 31 | 32 | ### 1.2.9 Better tree-shakability via `sideEffects: false` 33 | Simple. Viewable in the stats on [Bundlephobia](https://bundlephobia.com/package/polyrhythm) 34 | 35 | ### 1.2.7 Better Typescript typings, docs. 36 | 37 | Won't show type errors when triggering typescript-fsa actions (they weren't runtime issues anyway). 4 fewer ts-ignores due to Type Guards 38 | `as`, and generally more type awareness. 39 | 40 | ### 1.2.6 After can defer an Observable. 41 | 42 | It'd be handy to have `after(100, ob$)` 43 | defer the Observable-now it does. Important to note: this is not 44 | the same as `obs.pipe(delay(N))`, which delays the notifications, but still eagerly subscribes. `after` defers 45 | the subscription itself, so if canceled early enough, the side effect does not 46 | have to occur. Perfect for debouncing, with `mode: replace`. 47 | 48 | usually (which is why you can call `toPromise` on it, or `await` it) 49 | 50 | ### 1.2.5 Allow listener-returned bare values or generators 51 | 52 | Listeners ought to return Observables, but when they return an iterable, which could be a generator, 53 | how should the values be provided? They generally become `next` notifications individually, to preserve cases where, like Websockets, many notifications come in incrementally. However, a String is iterable, and it seemed a bug to `next` each letter of the string. 54 | 55 | ### 1.2.4 Can declaratively fire a 'start' event upon Observable subscription 56 | 57 | For feature-parity with conventions like for Redux Query, and those 58 | that emit an event at the beginning of an async operation, a TriggerConfig may 59 | now admit a `start` event, which will be triggered. 60 | 61 | Also fixed an issue where trigger: true (the source of so many Typescript errors) wasn't actually triggering. 62 | 63 | ### 1.2.3 Important bug fix 64 | 65 | ### 1.2 Smaller Bundle, More Robust 66 | 67 | - Bundle size 2.23Kb (Down from 2.37Kb) 68 | - `after` is type-safe! 69 | - `channel.listen({ takeUntil: matcher })`: Now adds a takeUntil(query(matcher)) to each Observable returned from the listener. 70 | 71 | Possibly breaking: 72 | 73 | - `channel.trigger`: The 3rd argument resultSpec was removed 74 | - `channel.listen({ trigger: { error }})`: Now rescues the error and keeps the listener alive. 75 | 76 | Includes the `combineWithConcurrency` export to allow ConcurrencyMode/string declarative style Observable-combination (without using the less-mnemonic operators). 77 | 78 | ### 1.1.6 takeUntil 79 | 80 | `channel.listen({ takeUntil: pattern })`: Now adds a takeUntil(query(pattern)) to each Observable returned from the listener to allow for declarative cancelation. 81 | 82 | ### 1.1.3 await query(), and succint tests 83 | 84 | Similar to `after`, there is a `then` method exposed on the return value from `query()`, so it is await-able without explicitly calling `toPromise` on it. Also, found a really nice testing pattern that will work as well in a `this`-less test framework like Jest, as it does in mocha, and also has fewer moving parts overall. 85 | 86 | ### 1.1.2 Support generators as listeners 87 | 88 | For Redux Saga and generator fans, a listener can be a generator function— Observable-wrapping of generators is easily done. 89 | 90 | ### 1.1.1 Add optional TypeScript typings 91 | 92 | The primary functions you use to `trigger`, `filter`, `listen`, and `query` the event bus, as well as the `after` utility, all at least somewhat support adding Typescript for addtional editor awareness. 93 | 94 | ### 1.1.0 Remove React dependencies 95 | 96 | The convenience hooks have been moved to the [polyrhythm-react](https://github.com/deanius/polyrhythm-react), so as not to import React in Node environments, or express a framework preference. 97 | 98 | ### 1.0.12 Trigger whole event objects 99 | 100 | Inspired by JQuery, the polyrhythm API `trigger` took the name of the event and the payload separately. 101 | 102 | ```js 103 | const result = trigger('event/type', { id: 'foo' }); 104 | ``` 105 | 106 | A Flux Standard Action was created for you with `type`, and `payload` fields. This meant that in Listeners, the event object you'd get would have `id` nested under the `payload` field of the event. 107 | 108 | ```js 109 | listen('event/type', ({ payload: { id } }) => fetch(/* use the id */)); 110 | ``` 111 | 112 | But what if you have Action Creator functions returning objects, must you split them apart? And what if you dont' want to nest under `payload` for compatibility with some other parts of your system? Now, you can just trigger objects: 113 | 114 | ```js 115 | const result = trigger({ type: 'event/type', id: 'foo' }); 116 | listen('event/type', ({ id }) => fetch(/* use the id */)); 117 | ``` 118 | 119 | Remember to keep the `type` field populated with a string, all of polyrhtyhm keys off of that, but shape the rest of the event how you like it! 120 | 121 | --- 122 | 123 | ### 1.0.11 `query.toPromise()` returns the next matching event 124 | 125 | Commit: ([cb5a859](https://github.com/deanius/polyrhythm/commit/cb5a859)) 126 | 127 | A common thing to do is to trigger an event and await a promise for a response, for example with events `api/search` and `api/results`. 128 | 129 | The way to do this before was to set up a promise for the response event type, then trigger the event that does the query. With proper cleanup, it looked like this: 130 | 131 | ```js 132 | const result = new Promise(resolve => { 133 | const sub = query('api/results').subscribe(event => { 134 | sub.unsubscribe() 135 | resolve(event) 136 | }) 137 | } 138 | trigger('api/search', { q: 'query' }) 139 | result.then(/* do something with the response */) 140 | ``` 141 | 142 | To simplify this pattern, now you can do: 143 | 144 | ```js 145 | const result = query('api/results').toPromise(); 146 | 147 | trigger('api/search', { q: 'query' }); 148 | result.then(/* do something with the response */); 149 | ``` 150 | 151 | To do this polyrhythm redefines `toPromise()` on the 152 | Observable returned by `query` to be a Promise that resolves as of the first event. As noted by Georgi Parlakov [here](https://levelup.gitconnected.com/rxjs-operator-topromise-waits-for-your-observable-to-complete-e7a002f5dccb), `toPromise()` waits for your Observable to complete, so will never resolve if over a stream that doesn't complete, and polyrhythms event bus and queries over it do not complete by design! 153 | 154 | A couple of tips: 155 | 156 | - The call to `toPromise()` _must_ be done prior to calling `trigger`, or your result event may be missed. 157 | - Attaching the `then` handler to the Promise can be done before or after calling `trigger` - Promises are flexible like that. 158 | 159 | Keep in mind that using a listener is still supported, and is often preferred, since it allows you to limit the concern of some components to being `trigger`-ers of events, and allowing other components to respond by updating spinners, and displaying results. 160 | 161 | ```js 162 | listen('api/results', ({ type, payload }) => { 163 | /* do something with the response */ 164 | }); 165 | 166 | trigger('api/search', { q: 'query' }); 167 | ``` 168 | 169 | If there may be many different sources of `api/results` from `api/query` events, you can include an ID in the event. This code shows how to append a query identifier in each event type: 170 | 171 | ```js 172 | const queryId = 123; 173 | const result = query(`api/results/${queryId}`).toPromise(); 174 | 175 | trigger(`api/search/${queryId}`, { q: 'query' }); 176 | result.then(/* do something with the response */); 177 | ``` 178 | 179 | On a humorous note, it was funny because I'd published the package without building it twice, making builds 1.0.9 and 1.0.10 useless. At least I discovered the npm `prepublishOnly` hook to save me from that in the future. 180 | 181 | --- 182 | 183 | ### 1.0.8 `microq` and `macroq` functions 184 | 185 | Commit: ([bc583de](https://github.com/deanius/polyrhythm/commit/bc583de)) 186 | 187 | Here's a fun quiz: In what order are the functions `fn1`, `fn2`, `fn3`, `fn4` called? 188 | 189 | ```js 190 | /* A */ Promise.resolve(1).then(() => fn1()); 191 | 192 | /* B */ await 2; 193 | fn2(); 194 | 195 | /* C */ setTimeout(() => fn3(), 0); 196 | 197 | /* D */ fn4(); 198 | ``` 199 | 200 | Obviously the synchronous function `fn4` is called before async ones - but in **B**, is `fn3` delayed or sync, when awaiting a constant? And which completes first, `Promise.resolve(fn1)`, or `setTimeout(fn3, 0)`? I found this stuff hard to remember, different in Node vs Browsers, and the complicated explanations left me wanting simply more readable API calls. So polyrhythm now exports `microq` and `macroq` functions. 201 | 202 | In summary, you can use `macroq` to replace `setTimeout(fn, 0)` code with `macroq(fn)`. This provides equivalent behavior which does not block the event loop. And you can use `microq(fn)` for async behavior that is equivalent to resolved-Promise deferring, for cases like layout that must complete before the next turn of the event loop. 203 | 204 | Quiz Explanation: **B** is essentially converted to **A**— a deferred call— despite the value `2` being synchronously available, because that's what `await` does. And promise resolutions are processed before `setTimeout(0)` calls. This is because JS has basically two places asynchronous code can go, the microtask queue and the macrotask queue. They are detailed [on MDN here](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide), but to simplify, the quiz example code basically boils down to this. 205 | 206 | ```js 207 | /* A */ microq(fn1); 208 | 209 | /* B */ microq(fn2); 210 | 211 | /* C */ macroq(fn3); 212 | 213 | /* D */ fn4(); 214 | ``` 215 | 216 | And thus the answer is `fn4`, `fn1`, `fn2`, and `fn3`, or **D**,**A**,**B**,**C**. 217 | 218 | --- 219 | 220 | ### 1.0.7 TypeScript typings corrected for `after` 221 | 222 | Commit: ([defeeeb](https://github.com/deanius/polyrhythm/commit/defeeeb)) 223 | 224 | Aside from having an awesome SHA, 1.0.7 is a TypeScript-only enhancement to the `after` function. Remember `after` is the setTimeout you always wanted - a lazy, composable, subscribable, awaitable object: 225 | 226 | ```js 227 | async function ignitionSequence() { 228 | await after(1000, () => console.log('3')); 229 | await after(1000, () => console.log('2')); 230 | await after(1000, () => console.log('1')); 231 | await after(1000, () => console.log('blastoff!')); 232 | } 233 | ignitionSequence(); 234 | ``` 235 | 236 | So basically, `after` is a deferred, Observable value or a function call. I won't call it a Monadic lift, because I'm not sure, but I think that's what it is :) 237 | 238 | Anyway, now TypeScript/VSCode won't yell at you if you omit a 2nd argument. 239 | 240 | ```js 241 | async function ignitionSequence() { 242 | await after(1000, () => console.log('3')); 243 | await after(1000, () => console.log('2')); 244 | await after(1000, () => console.log('1')); 245 | await after(1000); // dramatic pause! 246 | await after(1000, () => console.log('blastoff!')); 247 | } 248 | ignitionSequence(); 249 | ``` 250 | 251 | And just to refresh your memory for the DRY-er, more readable way to do such a sequence: 252 | 253 | ```js 254 | const ignitionSequence = () => 255 | concat( 256 | after(1000, '3'), 257 | after(1000, '2'), 258 | after(1000, '1'), 259 | after(1000, 'blastoff!') 260 | ); 261 | 262 | ignitionSequence().subscribe(count => console.log(count)); 263 | ``` 264 | 265 | You can get imports `after` and `concat` directly from polyrhythm, as of 266 | 267 | `after` is so handy, there needs to be a full blog post devoted to it. 268 | 269 | --- 270 | 271 | ### 1.0.6 Handy RxJS exports 272 | 273 | Commit: ([ee38e6a](https://github.com/deanius/polyrhythm/commit/ee38e6a)) 274 | 275 | If you use polyrhythm, you already have certain components of RxJS in your app. If you need to use only those components, you shouldn't need to have an explicit dependency on RxJS as well. For the fundamental operators `map`, `tap`, and `scan` that polyrhythm relies upon, you can import these directly. Same with the `concat` function of RxJS. 276 | 277 | ```diff 278 | - import { tap } from 'rxjs/operators' 279 | - import { concat } from 'rxjs' 280 | + import { tap, concat } from 'polyrhythm' 281 | ``` 282 | 283 | Unfortunately it looks like I introduced a conflict where `filter` is exported both as an RxJS operator and as the `channel.filter` function - it might be a source of error for some situations, but I'll address it in a patch later. 284 | -------------------------------------------------------------------------------- /docs/assets/js/search.json: -------------------------------------------------------------------------------- 1 | {"kinds":{"1":"Module","4":"Enumeration","16":"Enumeration member","32":"Variable","64":"Function","128":"Class","256":"Interface","512":"Constructor","1024":"Property","2048":"Method","4194304":"Type alias","16777216":"Reference"},"rows":[{"id":0,"kind":1,"name":"\"toggleMap\"","url":"modules/_togglemap_.html","classes":"tsd-kind-module"},{"id":1,"kind":256,"name":"Spawner","url":"interfaces/_togglemap_.spawner.html","classes":"tsd-kind-interface tsd-parent-kind-module tsd-is-not-exported","parent":"\"toggleMap\""},{"id":2,"kind":64,"name":"toggleMap","url":"modules/_togglemap_.html#togglemap","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"toggleMap\""},{"id":3,"kind":1,"name":"\"channel\"","url":"modules/_channel_.html","classes":"tsd-kind-module"},{"id":4,"kind":256,"name":"Event","url":"interfaces/_channel_.event.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":5,"kind":1024,"name":"type","url":"interfaces/_channel_.event.html#type","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".Event"},{"id":6,"kind":1024,"name":"payload","url":"interfaces/_channel_.event.html#payload","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".Event"},{"id":7,"kind":1024,"name":"error","url":"interfaces/_channel_.event.html#error","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".Event"},{"id":8,"kind":1024,"name":"meta","url":"interfaces/_channel_.event.html#meta","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".Event"},{"id":9,"kind":256,"name":"EventWithResult","url":"interfaces/_channel_.eventwithresult.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":10,"kind":1024,"name":"result","url":"interfaces/_channel_.eventwithresult.html#result","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".EventWithResult"},{"id":11,"kind":1024,"name":"type","url":"interfaces/_channel_.eventwithresult.html#type","classes":"tsd-kind-property tsd-parent-kind-interface tsd-is-inherited","parent":"\"channel\".EventWithResult"},{"id":12,"kind":1024,"name":"payload","url":"interfaces/_channel_.eventwithresult.html#payload","classes":"tsd-kind-property tsd-parent-kind-interface tsd-is-inherited","parent":"\"channel\".EventWithResult"},{"id":13,"kind":1024,"name":"error","url":"interfaces/_channel_.eventwithresult.html#error","classes":"tsd-kind-property tsd-parent-kind-interface tsd-is-inherited","parent":"\"channel\".EventWithResult"},{"id":14,"kind":1024,"name":"meta","url":"interfaces/_channel_.eventwithresult.html#meta","classes":"tsd-kind-property tsd-parent-kind-interface tsd-is-inherited","parent":"\"channel\".EventWithResult"},{"id":15,"kind":256,"name":"Predicate","url":"interfaces/_channel_.predicate.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":16,"kind":256,"name":"Filter","url":"interfaces/_channel_.filter.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":17,"kind":256,"name":"Listener","url":"interfaces/_channel_.listener.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":18,"kind":4,"name":"ConcurrencyMode","url":"enums/_channel_.concurrencymode.html","classes":"tsd-kind-enum tsd-parent-kind-module","parent":"\"channel\""},{"id":19,"kind":16,"name":"parallel","url":"enums/_channel_.concurrencymode.html#parallel","classes":"tsd-kind-enum-member tsd-parent-kind-enum","parent":"\"channel\".ConcurrencyMode"},{"id":20,"kind":16,"name":"serial","url":"enums/_channel_.concurrencymode.html#serial","classes":"tsd-kind-enum-member tsd-parent-kind-enum","parent":"\"channel\".ConcurrencyMode"},{"id":21,"kind":16,"name":"replace","url":"enums/_channel_.concurrencymode.html#replace","classes":"tsd-kind-enum-member tsd-parent-kind-enum","parent":"\"channel\".ConcurrencyMode"},{"id":22,"kind":16,"name":"ignore","url":"enums/_channel_.concurrencymode.html#ignore","classes":"tsd-kind-enum-member tsd-parent-kind-enum","parent":"\"channel\".ConcurrencyMode"},{"id":23,"kind":16,"name":"toggle","url":"enums/_channel_.concurrencymode.html#toggle","classes":"tsd-kind-enum-member tsd-parent-kind-enum","parent":"\"channel\".ConcurrencyMode"},{"id":24,"kind":256,"name":"TriggerConfig","url":"interfaces/_channel_.triggerconfig.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":25,"kind":1024,"name":"next","url":"interfaces/_channel_.triggerconfig.html#next","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".TriggerConfig"},{"id":26,"kind":1024,"name":"complete","url":"interfaces/_channel_.triggerconfig.html#complete","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".TriggerConfig"},{"id":27,"kind":1024,"name":"error","url":"interfaces/_channel_.triggerconfig.html#error","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".TriggerConfig"},{"id":28,"kind":256,"name":"ListenerConfig","url":"interfaces/_channel_.listenerconfig.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":29,"kind":1024,"name":"mode","url":"interfaces/_channel_.listenerconfig.html#mode","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".ListenerConfig"},{"id":30,"kind":1024,"name":"trigger","url":"interfaces/_channel_.listenerconfig.html#trigger","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".ListenerConfig"},{"id":31,"kind":256,"name":"TriggererConfig","url":"interfaces/_channel_.triggererconfig.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"channel\""},{"id":32,"kind":1024,"name":"result","url":"interfaces/_channel_.triggererconfig.html#result","classes":"tsd-kind-property tsd-parent-kind-interface","parent":"\"channel\".TriggererConfig"},{"id":33,"kind":128,"name":"Channel","url":"classes/_channel_.channel.html","classes":"tsd-kind-class tsd-parent-kind-module","parent":"\"channel\""},{"id":34,"kind":1024,"name":"channel","url":"classes/_channel_.channel.html#channel","classes":"tsd-kind-property tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":35,"kind":1024,"name":"filters","url":"classes/_channel_.channel.html#filters","classes":"tsd-kind-property tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":36,"kind":1024,"name":"listeners","url":"classes/_channel_.channel.html#listeners","classes":"tsd-kind-property tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":37,"kind":1024,"name":"listenerEnders","url":"classes/_channel_.channel.html#listenerenders","classes":"tsd-kind-property tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":38,"kind":1024,"name":"listenerParts","url":"classes/_channel_.channel.html#listenerparts","classes":"tsd-kind-property tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":39,"kind":1024,"name":"errors","url":"classes/_channel_.channel.html#errors","classes":"tsd-kind-property tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":40,"kind":1024,"name":"logs","url":"classes/_channel_.channel.html#logs","classes":"tsd-kind-property tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":41,"kind":512,"name":"constructor","url":"classes/_channel_.channel.html#constructor","classes":"tsd-kind-constructor tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":42,"kind":2048,"name":"trigger","url":"classes/_channel_.channel.html#trigger","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":43,"kind":2048,"name":"query","url":"classes/_channel_.channel.html#query","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":44,"kind":2048,"name":"filter","url":"classes/_channel_.channel.html#filter","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":45,"kind":2048,"name":"listen","url":"classes/_channel_.channel.html#listen","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":46,"kind":2048,"name":"on","url":"classes/_channel_.channel.html#on","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":47,"kind":2048,"name":"spy","url":"classes/_channel_.channel.html#spy","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":48,"kind":2048,"name":"log","url":"classes/_channel_.channel.html#log","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":49,"kind":2048,"name":"reset","url":"classes/_channel_.channel.html#reset","classes":"tsd-kind-method tsd-parent-kind-class","parent":"\"channel\".Channel"},{"id":50,"kind":2048,"name":"deactivateListener","url":"classes/_channel_.channel.html#deactivatelistener","classes":"tsd-kind-method tsd-parent-kind-class tsd-is-private","parent":"\"channel\".Channel"},{"id":51,"kind":16777216,"name":"toggleMap","url":"modules/_channel_.html#togglemap","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"channel\""},{"id":52,"kind":16777216,"name":"Subscription","url":"modules/_channel_.html#subscription","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"channel\""},{"id":53,"kind":4194304,"name":"EventMatcher","url":"modules/_channel_.html#eventmatcher","classes":"tsd-kind-type-alias tsd-parent-kind-module","parent":"\"channel\""},{"id":54,"kind":32,"name":"channel","url":"modules/_channel_.html#channel-1","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":55,"kind":32,"name":"trigger","url":"modules/_channel_.html#trigger","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":56,"kind":32,"name":"query","url":"modules/_channel_.html#query","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":57,"kind":32,"name":"filter","url":"modules/_channel_.html#filter-1","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":58,"kind":32,"name":"listen","url":"modules/_channel_.html#listen","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":59,"kind":32,"name":"on","url":"modules/_channel_.html#on","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":60,"kind":32,"name":"spy","url":"modules/_channel_.html#spy","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":61,"kind":32,"name":"log","url":"modules/_channel_.html#log","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":62,"kind":32,"name":"reset","url":"modules/_channel_.html#reset","classes":"tsd-kind-variable tsd-parent-kind-module","parent":"\"channel\""},{"id":63,"kind":64,"name":"isTestMode","url":"modules/_channel_.html#istestmode","classes":"tsd-kind-function tsd-parent-kind-module tsd-is-not-exported","parent":"\"channel\""},{"id":64,"kind":64,"name":"getEventPredicate","url":"modules/_channel_.html#geteventpredicate","classes":"tsd-kind-function tsd-parent-kind-module tsd-is-not-exported","parent":"\"channel\""},{"id":65,"kind":64,"name":"toObservable","url":"modules/_channel_.html#toobservable","classes":"tsd-kind-function tsd-parent-kind-module tsd-is-not-exported","parent":"\"channel\""},{"id":66,"kind":64,"name":"operatorForMode","url":"modules/_channel_.html#operatorformode","classes":"tsd-kind-function tsd-parent-kind-module tsd-is-not-exported","parent":"\"channel\""},{"id":67,"kind":1,"name":"\"utils\"","url":"modules/_utils_.html","classes":"tsd-kind-module"},{"id":68,"kind":256,"name":"AwaitableObservable","url":"interfaces/_utils_.awaitableobservable.html","classes":"tsd-kind-interface tsd-parent-kind-module","parent":"\"utils\""},{"id":69,"kind":2048,"name":"toPromise","url":"interfaces/_utils_.awaitableobservable.html#topromise","classes":"tsd-kind-method tsd-parent-kind-interface","parent":"\"utils\".AwaitableObservable"},{"id":70,"kind":2048,"name":"then","url":"interfaces/_utils_.awaitableobservable.html#then","classes":"tsd-kind-method tsd-parent-kind-interface tsd-has-type-parameter tsd-is-inherited","parent":"\"utils\".AwaitableObservable"},{"id":71,"kind":2048,"name":"subscribe","url":"interfaces/_utils_.awaitableobservable.html#subscribe","classes":"tsd-kind-method tsd-parent-kind-interface tsd-is-inherited","parent":"\"utils\".AwaitableObservable"},{"id":72,"kind":16777216,"name":"concat","url":"modules/_utils_.html#concat","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"utils\""},{"id":73,"kind":16777216,"name":"map","url":"modules/_utils_.html#map","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"utils\""},{"id":74,"kind":16777216,"name":"tap","url":"modules/_utils_.html#tap","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"utils\""},{"id":75,"kind":16777216,"name":"scan","url":"modules/_utils_.html#scan","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"utils\""},{"id":76,"kind":64,"name":"randomId","url":"modules/_utils_.html#randomid","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"utils\""},{"id":77,"kind":64,"name":"after","url":"modules/_utils_.html#after","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"utils\""},{"id":78,"kind":64,"name":"microq","url":"modules/_utils_.html#microq","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"utils\""},{"id":79,"kind":64,"name":"macroq","url":"modules/_utils_.html#macroq","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"utils\""},{"id":80,"kind":64,"name":"getTimestamp","url":"modules/_utils_.html#gettimestamp","classes":"tsd-kind-function tsd-parent-kind-module tsd-is-not-exported","parent":"\"utils\""},{"id":81,"kind":64,"name":"microflush","url":"modules/_utils_.html#microflush","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"utils\""},{"id":82,"kind":64,"name":"macroflush","url":"modules/_utils_.html#macroflush","classes":"tsd-kind-function tsd-parent-kind-module","parent":"\"utils\""},{"id":83,"kind":1,"name":"\"index\"","url":"modules/_index_.html","classes":"tsd-kind-module"},{"id":84,"kind":16777216,"name":"toggleMap","url":"modules/_index_.html#togglemap","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":85,"kind":16777216,"name":"Subscription","url":"modules/_index_.html#subscription","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":86,"kind":16777216,"name":"Event","url":"modules/_index_.html#event","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":87,"kind":16777216,"name":"EventWithResult","url":"modules/_index_.html#eventwithresult","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":88,"kind":16777216,"name":"Predicate","url":"modules/_index_.html#predicate","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":89,"kind":16777216,"name":"EventMatcher","url":"modules/_index_.html#eventmatcher","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":90,"kind":16777216,"name":"Filter","url":"modules/_index_.html#filter","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":91,"kind":16777216,"name":"Listener","url":"modules/_index_.html#listener","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":92,"kind":16777216,"name":"ConcurrencyMode","url":"modules/_index_.html#concurrencymode","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":93,"kind":16777216,"name":"TriggerConfig","url":"modules/_index_.html#triggerconfig","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":94,"kind":16777216,"name":"ListenerConfig","url":"modules/_index_.html#listenerconfig","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":95,"kind":16777216,"name":"TriggererConfig","url":"modules/_index_.html#triggererconfig","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":96,"kind":16777216,"name":"Channel","url":"modules/_index_.html#channel","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":97,"kind":16777216,"name":"channel","url":"modules/_index_.html#channel-1","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":98,"kind":16777216,"name":"trigger","url":"modules/_index_.html#trigger","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":99,"kind":16777216,"name":"query","url":"modules/_index_.html#query","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":100,"kind":16777216,"name":"filter","url":"modules/_index_.html#filter-1","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":101,"kind":16777216,"name":"listen","url":"modules/_index_.html#listen","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":102,"kind":16777216,"name":"on","url":"modules/_index_.html#on","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":103,"kind":16777216,"name":"spy","url":"modules/_index_.html#spy","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":104,"kind":16777216,"name":"log","url":"modules/_index_.html#log","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":105,"kind":16777216,"name":"reset","url":"modules/_index_.html#reset","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":106,"kind":16777216,"name":"microq","url":"modules/_index_.html#microq","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":107,"kind":16777216,"name":"macroq","url":"modules/_index_.html#macroq","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":108,"kind":16777216,"name":"microflush","url":"modules/_index_.html#microflush","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":109,"kind":16777216,"name":"macroflush","url":"modules/_index_.html#macroflush","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":110,"kind":16777216,"name":"concat","url":"modules/_index_.html#concat","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":111,"kind":16777216,"name":"map","url":"modules/_index_.html#map","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":112,"kind":16777216,"name":"tap","url":"modules/_index_.html#tap","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":113,"kind":16777216,"name":"scan","url":"modules/_index_.html#scan","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":114,"kind":16777216,"name":"randomId","url":"modules/_index_.html#randomid","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":115,"kind":16777216,"name":"AwaitableObservable","url":"modules/_index_.html#awaitableobservable","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""},{"id":116,"kind":16777216,"name":"after","url":"modules/_index_.html#after","classes":"tsd-kind-reference tsd-parent-kind-module","parent":"\"index\""}],"index":{"version":"2.3.8","fields":["name","parent"],"fieldVectors":[["name/0",[0,28.989]],["parent/0",[]],["name/1",[1,43.652]],["parent/1",[0,2.858]],["name/2",[0,28.989]],["parent/2",[0,2.858]],["name/3",[2,12.894]],["parent/3",[]],["name/4",[3,38.544]],["parent/4",[2,1.271]],["name/5",[4,38.544]],["parent/5",[5,3.22]],["name/6",[6,38.544]],["parent/6",[5,3.22]],["name/7",[7,35.179]],["parent/7",[5,3.22]],["name/8",[8,38.544]],["parent/8",[5,3.22]],["name/9",[9,38.544]],["parent/9",[2,1.271]],["name/10",[10,38.544]],["parent/10",[11,3.022]],["name/11",[4,38.544]],["parent/11",[11,3.022]],["name/12",[6,38.544]],["parent/12",[11,3.022]],["name/13",[7,35.179]],["parent/13",[11,3.022]],["name/14",[8,38.544]],["parent/14",[11,3.022]],["name/15",[12,38.544]],["parent/15",[2,1.271]],["name/16",[13,30.659]],["parent/16",[2,1.271]],["name/17",[14,38.544]],["parent/17",[2,1.271]],["name/18",[15,38.544]],["parent/18",[2,1.271]],["name/19",[16,43.652]],["parent/19",[17,3.022]],["name/20",[18,43.652]],["parent/20",[17,3.022]],["name/21",[19,43.652]],["parent/21",[17,3.022]],["name/22",[20,43.652]],["parent/22",[17,3.022]],["name/23",[21,43.652]],["parent/23",[17,3.022]],["name/24",[22,38.544]],["parent/24",[2,1.271]],["name/25",[23,43.652]],["parent/25",[24,3.468]],["name/26",[25,43.652]],["parent/26",[24,3.468]],["name/27",[7,35.179]],["parent/27",[24,3.468]],["name/28",[26,38.544]],["parent/28",[2,1.271]],["name/29",[27,43.652]],["parent/29",[28,3.799]],["name/30",[29,32.666]],["parent/30",[28,3.799]],["name/31",[30,38.544]],["parent/31",[2,1.271]],["name/32",[10,38.544]],["parent/32",[31,4.303]],["name/33",[2,12.894]],["parent/33",[2,1.271]],["name/34",[2,12.894]],["parent/34",[32,1.881]],["name/35",[33,43.652]],["parent/35",[32,1.881]],["name/36",[34,43.652]],["parent/36",[32,1.881]],["name/37",[35,43.652]],["parent/37",[32,1.881]],["name/38",[36,43.652]],["parent/38",[32,1.881]],["name/39",[37,43.652]],["parent/39",[32,1.881]],["name/40",[38,43.652]],["parent/40",[32,1.881]],["name/41",[39,43.652]],["parent/41",[32,1.881]],["name/42",[29,32.666]],["parent/42",[32,1.881]],["name/43",[40,35.179]],["parent/43",[32,1.881]],["name/44",[13,30.659]],["parent/44",[32,1.881]],["name/45",[41,35.179]],["parent/45",[32,1.881]],["name/46",[42,35.179]],["parent/46",[32,1.881]],["name/47",[43,35.179]],["parent/47",[32,1.881]],["name/48",[44,35.179]],["parent/48",[32,1.881]],["name/49",[45,35.179]],["parent/49",[32,1.881]],["name/50",[46,43.652]],["parent/50",[32,1.881]],["name/51",[0,28.989]],["parent/51",[2,1.271]],["name/52",[47,38.544]],["parent/52",[2,1.271]],["name/53",[48,38.544]],["parent/53",[2,1.271]],["name/54",[2,12.894]],["parent/54",[2,1.271]],["name/55",[29,32.666]],["parent/55",[2,1.271]],["name/56",[40,35.179]],["parent/56",[2,1.271]],["name/57",[13,30.659]],["parent/57",[2,1.271]],["name/58",[41,35.179]],["parent/58",[2,1.271]],["name/59",[42,35.179]],["parent/59",[2,1.271]],["name/60",[43,35.179]],["parent/60",[2,1.271]],["name/61",[44,35.179]],["parent/61",[2,1.271]],["name/62",[45,35.179]],["parent/62",[2,1.271]],["name/63",[49,43.652]],["parent/63",[2,1.271]],["name/64",[50,43.652]],["parent/64",[2,1.271]],["name/65",[51,43.652]],["parent/65",[2,1.271]],["name/66",[52,43.652]],["parent/66",[2,1.271]],["name/67",[53,21.68]],["parent/67",[]],["name/68",[54,38.544]],["parent/68",[53,2.137]],["name/69",[55,43.652]],["parent/69",[56,3.468]],["name/70",[57,43.652]],["parent/70",[56,3.468]],["name/71",[58,43.652]],["parent/71",[56,3.468]],["name/72",[59,38.544]],["parent/72",[53,2.137]],["name/73",[60,38.544]],["parent/73",[53,2.137]],["name/74",[61,38.544]],["parent/74",[53,2.137]],["name/75",[62,38.544]],["parent/75",[53,2.137]],["name/76",[63,38.544]],["parent/76",[53,2.137]],["name/77",[64,38.544]],["parent/77",[53,2.137]],["name/78",[65,38.544]],["parent/78",[53,2.137]],["name/79",[66,38.544]],["parent/79",[53,2.137]],["name/80",[67,43.652]],["parent/80",[53,2.137]],["name/81",[68,38.544]],["parent/81",[53,2.137]],["name/82",[69,38.544]],["parent/82",[53,2.137]],["name/83",[70,12.297]],["parent/83",[]],["name/84",[0,28.989]],["parent/84",[70,1.212]],["name/85",[47,38.544]],["parent/85",[70,1.212]],["name/86",[3,38.544]],["parent/86",[70,1.212]],["name/87",[9,38.544]],["parent/87",[70,1.212]],["name/88",[12,38.544]],["parent/88",[70,1.212]],["name/89",[48,38.544]],["parent/89",[70,1.212]],["name/90",[13,30.659]],["parent/90",[70,1.212]],["name/91",[14,38.544]],["parent/91",[70,1.212]],["name/92",[15,38.544]],["parent/92",[70,1.212]],["name/93",[22,38.544]],["parent/93",[70,1.212]],["name/94",[26,38.544]],["parent/94",[70,1.212]],["name/95",[30,38.544]],["parent/95",[70,1.212]],["name/96",[2,12.894]],["parent/96",[70,1.212]],["name/97",[2,12.894]],["parent/97",[70,1.212]],["name/98",[29,32.666]],["parent/98",[70,1.212]],["name/99",[40,35.179]],["parent/99",[70,1.212]],["name/100",[13,30.659]],["parent/100",[70,1.212]],["name/101",[41,35.179]],["parent/101",[70,1.212]],["name/102",[42,35.179]],["parent/102",[70,1.212]],["name/103",[43,35.179]],["parent/103",[70,1.212]],["name/104",[44,35.179]],["parent/104",[70,1.212]],["name/105",[45,35.179]],["parent/105",[70,1.212]],["name/106",[65,38.544]],["parent/106",[70,1.212]],["name/107",[66,38.544]],["parent/107",[70,1.212]],["name/108",[68,38.544]],["parent/108",[70,1.212]],["name/109",[69,38.544]],["parent/109",[70,1.212]],["name/110",[59,38.544]],["parent/110",[70,1.212]],["name/111",[60,38.544]],["parent/111",[70,1.212]],["name/112",[61,38.544]],["parent/112",[70,1.212]],["name/113",[62,38.544]],["parent/113",[70,1.212]],["name/114",[63,38.544]],["parent/114",[70,1.212]],["name/115",[54,38.544]],["parent/115",[70,1.212]],["name/116",[64,38.544]],["parent/116",[70,1.212]]],"invertedIndex":[["after",{"_index":64,"name":{"77":{},"116":{}},"parent":{}}],["awaitableobservable",{"_index":54,"name":{"68":{},"115":{}},"parent":{}}],["channel",{"_index":2,"name":{"3":{},"33":{},"34":{},"54":{},"96":{},"97":{}},"parent":{"4":{},"9":{},"15":{},"16":{},"17":{},"18":{},"24":{},"28":{},"31":{},"33":{},"51":{},"52":{},"53":{},"54":{},"55":{},"56":{},"57":{},"58":{},"59":{},"60":{},"61":{},"62":{},"63":{},"64":{},"65":{},"66":{}}}],["channel\".channel",{"_index":32,"name":{},"parent":{"34":{},"35":{},"36":{},"37":{},"38":{},"39":{},"40":{},"41":{},"42":{},"43":{},"44":{},"45":{},"46":{},"47":{},"48":{},"49":{},"50":{}}}],["channel\".concurrencymode",{"_index":17,"name":{},"parent":{"19":{},"20":{},"21":{},"22":{},"23":{}}}],["channel\".event",{"_index":5,"name":{},"parent":{"5":{},"6":{},"7":{},"8":{}}}],["channel\".eventwithresult",{"_index":11,"name":{},"parent":{"10":{},"11":{},"12":{},"13":{},"14":{}}}],["channel\".listenerconfig",{"_index":28,"name":{},"parent":{"29":{},"30":{}}}],["channel\".triggerconfig",{"_index":24,"name":{},"parent":{"25":{},"26":{},"27":{}}}],["channel\".triggererconfig",{"_index":31,"name":{},"parent":{"32":{}}}],["complete",{"_index":25,"name":{"26":{}},"parent":{}}],["concat",{"_index":59,"name":{"72":{},"110":{}},"parent":{}}],["concurrencymode",{"_index":15,"name":{"18":{},"92":{}},"parent":{}}],["constructor",{"_index":39,"name":{"41":{}},"parent":{}}],["deactivatelistener",{"_index":46,"name":{"50":{}},"parent":{}}],["error",{"_index":7,"name":{"7":{},"13":{},"27":{}},"parent":{}}],["errors",{"_index":37,"name":{"39":{}},"parent":{}}],["event",{"_index":3,"name":{"4":{},"86":{}},"parent":{}}],["eventmatcher",{"_index":48,"name":{"53":{},"89":{}},"parent":{}}],["eventwithresult",{"_index":9,"name":{"9":{},"87":{}},"parent":{}}],["filter",{"_index":13,"name":{"16":{},"44":{},"57":{},"90":{},"100":{}},"parent":{}}],["filters",{"_index":33,"name":{"35":{}},"parent":{}}],["geteventpredicate",{"_index":50,"name":{"64":{}},"parent":{}}],["gettimestamp",{"_index":67,"name":{"80":{}},"parent":{}}],["ignore",{"_index":20,"name":{"22":{}},"parent":{}}],["index",{"_index":70,"name":{"83":{}},"parent":{"84":{},"85":{},"86":{},"87":{},"88":{},"89":{},"90":{},"91":{},"92":{},"93":{},"94":{},"95":{},"96":{},"97":{},"98":{},"99":{},"100":{},"101":{},"102":{},"103":{},"104":{},"105":{},"106":{},"107":{},"108":{},"109":{},"110":{},"111":{},"112":{},"113":{},"114":{},"115":{},"116":{}}}],["istestmode",{"_index":49,"name":{"63":{}},"parent":{}}],["listen",{"_index":41,"name":{"45":{},"58":{},"101":{}},"parent":{}}],["listener",{"_index":14,"name":{"17":{},"91":{}},"parent":{}}],["listenerconfig",{"_index":26,"name":{"28":{},"94":{}},"parent":{}}],["listenerenders",{"_index":35,"name":{"37":{}},"parent":{}}],["listenerparts",{"_index":36,"name":{"38":{}},"parent":{}}],["listeners",{"_index":34,"name":{"36":{}},"parent":{}}],["log",{"_index":44,"name":{"48":{},"61":{},"104":{}},"parent":{}}],["logs",{"_index":38,"name":{"40":{}},"parent":{}}],["macroflush",{"_index":69,"name":{"82":{},"109":{}},"parent":{}}],["macroq",{"_index":66,"name":{"79":{},"107":{}},"parent":{}}],["map",{"_index":60,"name":{"73":{},"111":{}},"parent":{}}],["meta",{"_index":8,"name":{"8":{},"14":{}},"parent":{}}],["microflush",{"_index":68,"name":{"81":{},"108":{}},"parent":{}}],["microq",{"_index":65,"name":{"78":{},"106":{}},"parent":{}}],["mode",{"_index":27,"name":{"29":{}},"parent":{}}],["next",{"_index":23,"name":{"25":{}},"parent":{}}],["on",{"_index":42,"name":{"46":{},"59":{},"102":{}},"parent":{}}],["operatorformode",{"_index":52,"name":{"66":{}},"parent":{}}],["parallel",{"_index":16,"name":{"19":{}},"parent":{}}],["payload",{"_index":6,"name":{"6":{},"12":{}},"parent":{}}],["predicate",{"_index":12,"name":{"15":{},"88":{}},"parent":{}}],["query",{"_index":40,"name":{"43":{},"56":{},"99":{}},"parent":{}}],["randomid",{"_index":63,"name":{"76":{},"114":{}},"parent":{}}],["replace",{"_index":19,"name":{"21":{}},"parent":{}}],["reset",{"_index":45,"name":{"49":{},"62":{},"105":{}},"parent":{}}],["result",{"_index":10,"name":{"10":{},"32":{}},"parent":{}}],["scan",{"_index":62,"name":{"75":{},"113":{}},"parent":{}}],["serial",{"_index":18,"name":{"20":{}},"parent":{}}],["spawner",{"_index":1,"name":{"1":{}},"parent":{}}],["spy",{"_index":43,"name":{"47":{},"60":{},"103":{}},"parent":{}}],["subscribe",{"_index":58,"name":{"71":{}},"parent":{}}],["subscription",{"_index":47,"name":{"52":{},"85":{}},"parent":{}}],["tap",{"_index":61,"name":{"74":{},"112":{}},"parent":{}}],["then",{"_index":57,"name":{"70":{}},"parent":{}}],["toggle",{"_index":21,"name":{"23":{}},"parent":{}}],["togglemap",{"_index":0,"name":{"0":{},"2":{},"51":{},"84":{}},"parent":{"1":{},"2":{}}}],["toobservable",{"_index":51,"name":{"65":{}},"parent":{}}],["topromise",{"_index":55,"name":{"69":{}},"parent":{}}],["trigger",{"_index":29,"name":{"30":{},"42":{},"55":{},"98":{}},"parent":{}}],["triggerconfig",{"_index":22,"name":{"24":{},"93":{}},"parent":{}}],["triggererconfig",{"_index":30,"name":{"31":{},"95":{}},"parent":{}}],["type",{"_index":4,"name":{"5":{},"11":{}},"parent":{}}],["utils",{"_index":53,"name":{"67":{}},"parent":{"68":{},"72":{},"73":{},"74":{},"75":{},"76":{},"77":{},"78":{},"79":{},"80":{},"81":{},"82":{}}}],["utils\".awaitableobservable",{"_index":56,"name":{},"parent":{"69":{},"70":{},"71":{}}}]],"pipeline":[]}} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/polyrhythm.svg)](https://badge.fury.io/js/polyrhythm)[![<4 Kb](https://img.shields.io/badge/gzip%20size-%3C4%20kB-brightgreen.svg)](https://www.npmjs.com/package/polyrhythm) 2 | [![Travis](https://img.shields.io/travis/deanrad/polyrhythm.svg)](https://travis-ci.org/deanrad/polyrhythm) 3 | [![Maintainability](https://api.codeclimate.com/v1/badges/a99a88d28ad37a79dbf6/maintainability)](https://codeclimate.com/github/deanius/polyrhythm/maintainability) 4 | [![TypeScript](https://camo.githubusercontent.com/832d01092b0e822178475741271b049a2e27df13/68747470733a2f2f62616467656e2e6e65742f62616467652f2d2f547970655363726970742f626c75653f69636f6e3d74797065736372697074266c6162656c)](https://github.com/ellerbrock/typescript-badges/)code style: prettier[![twitter link](https://img.shields.io/badge/twitter-@DevRadcliffe-55acee.svg)](https://twitter.com/DevRadcliffe) 5 | 6 | # polyrhythm 🎵🎶 7 | 8 | `polyrhythm` is a way to avoid async race conditions, particularly those that arise when building UIs, in JavaScript. It can replace Redux middleware like Redux Saga, and is a framework-free library that supercharges your timing and resource-management. And it's under 4Kb. 9 | 10 | Its API is a synthesis of ideas from: 11 | 12 | - 💜RxJS. Older than Promises, nearly as old as JQuery. 13 | - 💜Redux-Observable, Redux Saga, Redux Thunk. Async. 14 | - 💙Macromedia Flash. Mutliple timelines. 15 | - 💙JQuery. [#on](https://api.jquery.com/on/) and [#trigger](https://api.jquery.com/trigger/). 16 | 17 | For use in a React context, [polyrhythm-react](https://github.com/deanius/polyrhythm-react) exports all in this library, plus React hooks for interfacing with it. 18 | 19 | ## Installation 20 | 21 | ``` 22 | npm install polyrhythm 23 | ``` 24 | 25 | # What Is It? 26 | 27 | `polyrhythm` is a TypeScript library that is a framework-independent coordinator of multiple streams of async using RxJS Observables. 28 | 29 | The goal of `polyrhythm` is to be a centralized control of timing for sync or async operations in your app. 30 | 31 | Because of it's pub-sub/event-bus design, your app remains inherently scalable because originators of events don't know about consumers, or vice versa. If a single subscriber errs out, neither the publisher nor other subscribers are affected. Your UI layer remains simple— its only job is to trigger/originate events. All the logic remains separated from the UI layer by the event bus. Testing of most of your app's effects can be done without going through your UI layer. 32 | 33 | `polyrhythm` envisions a set of primitives can compose into beautiful Web Apps and User Experiences more simply and flexibly than current JavaScript tools allow for. All thie with a tiny bundle size, and an API that is delightfully simple. 34 | 35 | # Where Can I Use It? 36 | 37 | The framework-independent primitives of `polyrhythm` can be used anywhere. It adds only 3Kb to your bundle, so it's worth a try. It is test-covered, provides types, is production-tested and performance-tested. 38 | 39 | --- 40 | 41 | # Declare Your Timing, Don't Code It 42 | 43 | RxJS was written in 2010 to address the growing need for async management code in the world of AJAX. Yet in 2021, it can still be a large impact to the codebase to add `async` to a function declaration, or turn a function into a generator with `function*() {}`. That impact can 'hard-code' in unadaptable behaviors or latency. And relying on framework features (like the timing difference between `useEffect` and `useLayoutEffect`) can make code vulnerable to framework changes, and make it harder to test. 44 | 45 | `polyrhythm` gives you 5 concurrency modes you can plug in trivially as configuration parameters, to get the full power of RxJS elegantly. 46 | 47 | The listener option `mode` allows you to control the concurrency behavior of a listener declaratively, and is important for making polyrhythm so adaptable to desired timing outcomes. For an autocomplete or session timeout, the `replace` mode is appropriate. For other use cases, `serial`, `parallel` or `ignore` may be appropriate. 48 | 49 | If async effects like AJAX were represented as sounds, this diagram shows how they might overlap/queue/cancel each other. 50 | 51 | 52 | 53 | Being able to plug in a strategy ensures that the exact syntax of your code, and your timing information, are decoupled - the one is not expressed in terms of the other. This lets you write fewer lines, be more direct and declarative, and generally cut down on race conditions. 54 | 55 | Not only do these 5 modes handle not only what you'd want to do with RxJS, but they handle anything your users would expect code to do when async process overlap! You have the ease to change behavior to satisfy your pickiest users, without rewriting code - you only have to update your tests to match! 56 | 57 | ![](https://s3.amazonaws.com/www.deanius.com/async-mode-table.png) 58 | 59 | Now let's dig into some examples. 60 | 61 | ## Example 1: Auto-Complete Input (replace mode) 62 | 63 | Based on the original example at [LearnRxjs.io](https://www.learnrxjs.io/learn-rxjs/recipes/type-ahead)... 64 | 65 | **Set up an event handler to trigger `search/start` events from an onChange:** 66 | 67 | ```js 68 | trigger('search/start', e.target.value)} /> 69 | ``` 70 | 71 | **Listen for the `search/results` event and update component or global state:** 72 | 73 | ```js 74 | filter('search/results', ({ payload: results }) => { 75 | setResults(results); 76 | }); 77 | ``` 78 | 79 | **Respond to `search/start` events with an Observable, or Promise of the ajax request.** 80 | <<<<<<< HEAD 81 | 82 | Assign the output to the `search/results` event, and specify your `mode`, and you're done and race-condition-free! 83 | 84 | ```js 85 | on( 86 | 'search/start', 87 | ({ payload }) => { 88 | return fetch(URL + payload).then(res => res.json()); 89 | }, 90 | { 91 | mode: 'replace', 92 | trigger: { next: 'search/results' }, 93 | } 94 | ); 95 | ``` 96 | 97 | `mode:replace` does what `switchMap` does, but with readability foremost, and without requiring you to model your app as a chained Observable, or manage Subscription objects or call `.subscribe()` or `.unsubscribe()` explicitly. 98 | 99 | [Debounced Search CodeSandbox](https://codesandbox.io/s/debounced-search-polyrhythm-react-w1t8o?file=/src/App.js) 100 | 101 | ## Example 2: Ajax Cat Fetcher (multi-mode) 102 | 103 | Based on an [XState Example](https://dev.to/davidkpiano/no-disabling-a-button-is-not-app-logic-598i) showing the value of separating out effects from components, and how to be React Concurrent Mode (Suspense-Mode) safe, in XState or Polyrhythm. 104 | 105 | Try it out - play with it! Is the correct behavior to use `serial` mode to allow you to queue up cat fetches, or `ignore` to disable new cats while one is loading, as XState does? You choose! I find having these options easily pluggble enables the correct UX to be discovered through play, and tweaked with minimal effort. 106 | 107 | [Cat Fetcher AJAX CodeSandbox](https://codesandbox.io/s/cat-fetcher-with-polyrhythm-uzjln?file=/src/handlers.js) 108 | 109 | ## Example 3: Redux Toolkit Counter (multi-mode) 110 | 111 | All 5 modes can be tried in the polyrhythm version of the 112 | [Redux Counter Example Sandbox](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0) 113 | 114 | --- 115 | 116 | # Can I use Promises instead of Observables? 117 | 118 | Recall the auto-complete example, in which you could create a new `search/results` event from either a Promise or Observable: 119 | 120 | ```js 121 | on('search/start', ({ payload }) => { 122 | // return Observable 123 | return ajax.get(URL + payload).pipe( 124 | tap({ results } => results) 125 | ); 126 | // OR Promise 127 | return fetch(URL + payload).then(res => res.json()) 128 | }, { 129 | mode: 'replace', 130 | trigger: { next: 'search/results' } 131 | }); 132 | ``` 133 | 134 | With either the Promise, or Observable, the `mode: replace` guarantees your autocomplete never has the race-condition where an old result populates after new letters invalidate it. But with an Observable: 135 | 136 | - The AJAX can be canceled, freeing up bandwidth as well 137 | - The AJAX can be set to be canceled implicitly upon component unmount, channel reset, or by another event declaratively with `takeUntil`. And no Abort Controllers or `await` ever required! 138 | 139 | You have to return an Observable to get cancelation, and you only get all the overlap strategies and lean performance when you can cancel. So best practice is to use them - but they are not required. 140 | 141 | --- 142 | 143 | # UI Layer Bindings 144 | 145 | `trigger`, `filter` `listen` (aka `on`), and `query` are methods bound to an instance of a `Channel`. For convenience, and in many examples, these bound methods may be imported and used directly 146 | 147 | ```js 148 | import { trigger, on } from 'polyrhythm'; 149 | on(...) 150 | trigger(...) 151 | ``` 152 | 153 | These top-level imports are enough to get started, and one channel is usually enough per JS process. However you may want more than one channel, or have control over its creation: 154 | 155 | ```js 156 | import { Channel } from 'polyrhythm'; 157 | const channel = new Channel(); 158 | channel.trigger(...) 159 | ``` 160 | 161 | (In a React environment, a similar choice exists- a top-level `useListener` hook, or a listener bound to a channel via `useChannel`. React equivalents are discussed further in the [polyrhythm-react](https://github.com/deanius/polyrhythm-react) repo) 162 | 163 | To tie cancelation into your UI layer's component lifecycle (or server-side request fulfillment if in Node), call `.unsubscribe()` on the return value from `channel.listen` or `channel.filter` for any handlers the component set up: 164 | 165 | ```js 166 | // at mount 167 | const sub = channel.on(...).. 168 | // at unmount 169 | sub.unsubscribe() 170 | ``` 171 | 172 | Lastly in a hot-module-reloading environment, `channel.reset()` is handy to remove all listeners, canceling their effects. Include that call early in the loading process to avoid double-registration of listeners in an HMR environment. 173 | 174 | # API 175 | 176 | A polyrhythm app, sync or async, can be built out of 6 or fewer primitives: 177 | 178 | - `trigger` - Puts an event on the event bus, and should be called at least once in your app. Generally all a UI Event Handler needs to do is call `trigger` with an event type and a payload.
Example — `addEventListener('click', ()=>{ trigger('timer/start') })` 179 | 180 | - `filter` - Adds a function to be called on every matching `trigger`. The filter function will be called synchronously in the call-stack of `trigger`, can modify its events, and can prevent events from being further handled by throwing an Error.
For metadata — `filter('timer/start', event => { event.payload.startedAt = Date.now()) })`
Validation — `filter('form/submit', ({ payload }) => { isValid(payload) || throw new Error() })` 181 | 182 | - `listen` - Adds a function to be called on every matching `trigger`, once all filters have passed. Allows you to return an Observable of its side-effects, and/or future event triggerings, and configure its overlap behavior / concurrency declaratively.
AJAX: `listen('profile/fetch', ({ payload }) => get('/user/' + payload.id)).tap(user => trigger('profile/complete', user.profile))` 183 | 184 | - `query` - Provides an Observable of matching events from the event bus. Useful when you need to create a derived Observable for further processing, or for controlling/terminating another Observable. Example: `interval(1000).takeUntil(query('user/activity'))` 185 | 186 | ## Observable creators 187 | 188 | - `after` - Defers a function call into an Observable of that function call, after a delay. This is the simplest way to get a cancelable side-effect, and can be used in places that expect either a `Promise` or an `Observable`.
Promise — `await after(10000, () => modal('Your session has expired'))`
Observable — `interval(1000).takeUntil(after(10000))` 189 | ` 190 | - `concat` - Combines Observables by sequentially starting them as each previous one finishes. This only works on Observables which are deferred, not Promises which are begun at their time of creation.
Sequence — `login().then(() => concat(after(9000, 'Your session is about to expire'), after(1000, 'Your session has expired')).subscribe(modal))` 191 | 192 | You can use Observables from any source in `polyrhythm`, not just those created with `concat` and `after`. For maximum flexibility, use the `Observable` constructor to wrap any async operation - and use them anywhere you need more control over the Observables behavior. Be sure to return a cleanup function from the Observable constructor, as in this session-timeout example. 193 | 194 | ======= 195 | 196 | Assign the output to the `search/results` event, and specify your `mode`, and you're done and race-condition-free! 197 | 198 | ```js 199 | on( 200 | 'search/start', 201 | ({ payload }) => { 202 | return fetch(URL + payload).then(res => res.json()); 203 | }, 204 | { 205 | mode: 'replace', 206 | trigger: { next: 'search/results' }, 207 | } 208 | ); 209 | ``` 210 | 211 | `mode:replace` does what `switchMap` does, but with readability foremost, and without requiring you to model your app as a chained Observable, or manage Subscription objects or call `.subscribe()` or `.unsubscribe()` explicitly. 212 | 213 | [Debounced Search CodeSandbox](https://codesandbox.io/s/debounced-search-polyrhythm-react-w1t8o?file=/src/App.js) 214 | 215 | ## Example 2: Ajax Cat Fetcher (multi-mode) 216 | 217 | Based on an [XState Example](https://dev.to/davidkpiano/no-disabling-a-button-is-not-app-logic-598i) showing the value of separating out effects from components, and how to be React Concurrent Mode (Suspense-Mode) safe, in XState or Polyrhythm. 218 | 219 | Try it out - play with it! Is the correct behavior to use `serial` mode to allow you to queue up cat fetches, or `ignore` to disable new cats while one is loading, as XState does? You choose! I find having these options easily pluggble enables the correct UX to be discovered through play, and tweaked with minimal effort. 220 | 221 | [Cat Fetcher AJAX CodeSandbox](https://codesandbox.io/s/cat-fetcher-with-polyrhythm-uzjln?file=/src/handlers.js) 222 | 223 | ## Example 3: Redux Toolkit Counter (multi-mode) 224 | 225 | All 5 modes can be tried in the polyrhythm version of the 226 | [Redux Counter Example Sandbox](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0) 227 | 228 | --- 229 | 230 | # Can I use Promises instead of Observables? 231 | 232 | Recall the auto-complete example, in which you could create a new `search/results` event from either a Promise or Observable: 233 | 234 | ```js 235 | on('search/start', ({ payload }) => { 236 | // return Observable 237 | return ajax.get(URL + payload).pipe( 238 | tap({ results } => results) 239 | ); 240 | // OR Promise 241 | return fetch(URL + payload).then(res => res.json()) 242 | }, { 243 | mode: 'replace', 244 | trigger: { next: 'search/results' } 245 | }); 246 | ``` 247 | 248 | With either the Promise, or Observable, the `mode: replace` guarantees your autocomplete never has the race-condition where an old result populates after new letters invalidate it. But with an Observable: 249 | 250 | - The AJAX can be canceled, freeing up bandwidth as well 251 | - The AJAX can be set to be canceled implicitly upon component unmount, channel reset, or by another event declaratively with `takeUntil`. And no Abort Controllers or `await` ever required! 252 | 253 | You have to return an Observable to get cancelation, and you only get all the overlap strategies and lean performance when you can cancel. So best practice is to use them - but they are not required. 254 | 255 | --- 256 | 257 | # UI Layer Bindings 258 | 259 | `trigger`, `filter` `listen` (aka `on`), and `query` are methods bound to an instance of a `Channel`. For convenience, and in many examples, these bound methods may be imported and used directly 260 | 261 | ```js 262 | import { trigger, on } from 'polyrhythm'; 263 | on(...) 264 | trigger(...) 265 | ``` 266 | 267 | These top-level imports are enough to get started, and one channel is usually enough per JS process. However you may want more than one channel, or have control over its creation: 268 | 269 | ```js 270 | import { Channel } from 'polyrhythm'; 271 | const channel = new Channel(); 272 | channel.trigger(...) 273 | ``` 274 | 275 | (In a React environment, a similar choice exists- a top-level `useListener` hook, or a listener bound to a channel via `useChannel`. React equivalents are discussed further in the [polyrhythm-react](https://github.com/deanius/polyrhythm-react) repo) 276 | 277 | To tie cancelation into your UI layer's component lifecycle (or server-side request fulfillment if in Node), call `.unsubscribe()` on the return value from `channel.listen` or `channel.filter` for any handlers the component set up: 278 | 279 | ```js 280 | // at mount 281 | const sub = channel.on(...).. 282 | // at unmount 283 | sub.unsubscribe() 284 | ``` 285 | 286 | Lastly in a hot-module-reloading environment, `channel.reset()` is handy to remove all listeners, canceling their effects. Include that call early in the loading process to avoid double-registration of listeners in an HMR environment. 287 | 288 | # API 289 | 290 | A polyrhythm app, sync or async, can be built out of 6 or fewer primitives: 291 | 292 | - `trigger` - Puts an event on the event bus, and should be called at least once in your app. Generally all a UI Event Handler needs to do is call `trigger` with an event type and a payload.
Example — `addEventListener('click', ()=>{ trigger('timer/start') })` 293 | 294 | - `filter` - Adds a function to be called on every matching `trigger`. The filter function will be called synchronously in the call-stack of `trigger`, can modify its events, and can prevent events from being further handled by throwing an Error.
For metadata — `filter('timer/start', event => { event.payload.startedAt = Date.now()) })`
Validation — `filter('form/submit', ({ payload }) => { isValid(payload) || throw new Error() })` 295 | 296 | - `listen` - Adds a function to be called on every matching `trigger`, once all filters have passed. Allows you to return an Observable of its side-effects, and/or future event triggerings, and configure its overlap behavior / concurrency declaratively.
AJAX: `listen('profile/fetch', ({ payload }) => get('/user/' + payload.id)).tap(user => trigger('profile/complete', user.profile))` 297 | 298 | - `query` - Provides an Observable of matching events from the event bus. Useful when you need to create a derived Observable for further processing, or for controlling/terminating another Observable. Example: `interval(1000).takeUntil(query('user/activity'))` 299 | 300 | ## Observable creators 301 | 302 | - `after` - Defers a function call into an Observable of that function call, after a delay. This is the simplest way to get a cancelable side-effect, and can be used in places that expect either a `Promise` or an `Observable`.
Promise — `await after(10000, () => modal('Your session has expired'))`
Observable — `interval(1000).takeUntil(after(10000))` 303 | ` 304 | - `concat` - Combines Observables by sequentially starting them as each previous one finishes. This only works on Observables which are deferred, not Promises which are begun at their time of creation.
Sequence — `login().then(() => concat(after(9000, 'Your session is about to expire'), after(1000, 'Your session has expired')).subscribe(modal))` 305 | 306 | You can use Observables from any source in `polyrhythm`, not just those created with `concat` and `after`. For maximum flexibility, use the `Observable` constructor to wrap any async operation - and use them anywhere you need more control over the Observables behavior. Be sure to return a cleanup function from the Observable constructor, as in this session-timeout example. 307 | 308 | > > > > > > > 1.2.6 After can defer an Observable 309 | 310 | ```js 311 | listen('user/activity', () => { 312 | return concat( 313 | new Observable(notify => { // equivalent to after(9000, "Your session is about to expire") 314 | const id = setTimeout(() => { 315 | notify.next("Your session is about to expire"); 316 | notify.complete(); // tells `concat` we're done- Observables may call next() many times 317 | }, 9000); 318 | return () => clearTimeout(id); // a cancelation function allowing this timeout to be 'replaced' with a new one 319 | }), 320 | after(1000, () => "Your session has expired")); 321 | }, { mode: 'replace' }); 322 | }); 323 | ``` 324 | 325 | --- 326 | 327 | ## List Examples - What Can You Build With It? 328 | 329 | - The [Redux Counter Example](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0) 330 | - The [Redux Todos Example](https://codesandbox.io/s/polyrhythm-redux-todos-ltigo) 331 | - A `requestAnimationFrame`-based [Game Loop](https://codesandbox.io/s/poly-game-loop-xirgs?file=/src/index.js) 332 | - Seven GUIs Solutions [1-Counter](https://codesandbox.io/s/7guis-1-counter-17pxb) | [2-Temperature](https://codesandbox.io/s/7guis-2-temperature-bnjbf) | [3-Flight](https://codesandbox.io/s/7guis-3-flight-c6wre) | [4-CRUD](https://codesandbox.io/s/7guis-4-crud-7wjut) | [5-Timer](https://codesandbox.io/s/7guis-5-timer-xgop9) _(more info at [7 GUIs](https://eugenkiss.github.io/7guis/tasks))_ 333 | - The [Chat UI Example](https://codesandbox.io/s/poly-chat-imw2z) with TypingIndicator 334 | - See [All CodeSandbox Demos](https://codesandbox.io/search?refinementList%5Bnpm_dependencies.dependency%5D%5B0%5D=`polyrhythm`&page=1&configure%5BhitsPerPage%5D=12) 335 | 336 | # FAQ 337 | 338 | **Got TypeScript typings?** 339 | 340 | But of course! 341 | 342 | **How large?** 343 | 16Kb parsed size, 4Kb Gzipped 344 | 345 | **In Production Use?** 346 | Yes. 347 | 348 | **What does it do sync, async? With what Error-Propogation and Cancelability? How does it work?** 349 | 350 | See [The test suite](/test/channel.test.ts) for details. 351 | 352 | **How fast is it?** 353 | Nearly as fast as RxJS. The [Travis CI build output](https://travis-ci.org/github/deanius/polyrhythm) contains some benchmarks. 354 | 355 | --- 356 | 357 | # Tutorial: Ping Pong 🏓 358 | 359 |
360 | 361 | Let's incrementally build the ping pong example app with `polyrhythm`. 362 | 363 | 364 | [Finished version CodeSandbox](https://codesandbox.io/s/polyrhythm-ping-pong-r6zk5) 365 | 366 | ## 1) Log all events, and trigger **ping** 367 | 368 | ```js 369 | const { filter, trigger, log } = require(); 370 | 371 | // **** Behavior (criteria, fn) ****** // 372 | filter(true, log); 373 | 374 | // **** Events (type, payload) ****** // 375 | trigger('ping', 'Hello World'); 376 | 377 | // **** Effects ({ type, payload }) ****** // 378 | function log({ type, payload }) { 379 | console.log(type, payload ? payload : ''); 380 | } 381 | // Output: 382 | // ping Hello World 383 | ``` 384 | 385 | Here's an app where a `filter` (one of 2 kinds of listeners) logs all events to the console, and the app `trigger`s a single event of `type: 'ping'`. 386 | 387 | **Explained:** Filters are functions that run before events go on the event bus. This makes filters great for logging, as you typically need some log output to tell you what caused an error, if an error occurs later. This filter's criteria is simply `true`, so it runs for all events. Strings, arrays of strings, Regex and boolean-functions are also valid kinds of criteria. The filter handler `log` recieves the event as an argument, so we destructure the `type` and `payload` from it, and send it to the console. 388 | 389 | We `trigger` a `ping`, passing `type` and `payload` arguments. This reduces boilerplate a bit compared to Redux' `dispatch({ type: 'ping' })`. But `trigger` will work with a pre-assembled event too. Now let's play some pong.. 390 | 391 | ## 2) Respond to **ping** with **pong** 392 | 393 | If we just want to respond to a **ping** event with a **pong** event, we could do so in a filter. But filters should be reserved for synchronous side-effect functions like logging, changing state, or dispatching an event/action to a store. So let's instead use `listen` to create a **Listener**. 394 | 395 | ```js 396 | const { filter, listen, log, trigger } = require('polyrhythm'); 397 | 398 | filter(true, log); 399 | listen('ping', () => { 400 | trigger('pong'); 401 | }); 402 | 403 | trigger('ping'); 404 | // Output: 405 | // ping 406 | // pong 407 | ``` 408 | 409 | We now have a **ping** event, and a **pong** reply. Now that we have a game, let's make it last longer. 410 | 411 | ## 3) Return Async From an Event Handler 412 | 413 | Normally in JavaScript things go fine—until we make something async. But `polyrhythm` has a solution for that, a simple utility function called `after`. I like to call `after` _"the `setTimeout` you always wanted"_. 414 | 415 | Let's suppose we want to trigger a **pong** event, but only after 1 second. We need to define the **Task** that represents "a triggering of a `pong`, after 1 second". 416 | 417 | ```js 418 | const { filter, listen, log, after, trigger } = require('polyrhythm'); 419 | 420 | filter(true, log); 421 | listen('ping', () => { 422 | return after(1000, () => trigger('pong')); 423 | }); 424 | 425 | trigger('ping'); 426 | // Output: (1000 msec between) 427 | // ping 428 | // pong 429 | ``` 430 | 431 | In plain, readable code, `after` returns an Observable of the function call you pass as its 2nd argument, with the delay you specify as its 1st argument. Read aloud, it says exactly what it does: _"After 1000 milliseconds, trigger `pong`"_ 432 | 433 | **TIP:** An object returned by `after` can be directly `await`-ed inside an async functions, as shown here: 434 | 435 | ```js 436 | async function sneeze() { 437 | await after(1000, () => log('Ah..')); 438 | await after(1000, () => log('..ah..')); 439 | await after(1000, () => log('..choo!')); 440 | } 441 | ``` 442 | 443 | **IMPORTANT:** All Observables, including those returned by `after`, are lazy. If you fail to return them to `polyrhythm`, or call `toPromise()`, `then()`, or `subscribe()` on them, they will not run! 444 | 445 | But back to ping-pong, let's respond both to **ping** and **pong** now... 446 | 447 | ## 4) Ping-Pong forever! 448 | 449 | Following this pattern of adding listeners, we can enhance the behavior of the app by adding another listener to `ping` it right back: 450 | 451 | ```js 452 | const { filter, listen, log, after, trigger } = require('polyrhythm'); 453 | 454 | filter(true, log); 455 | listen('ping', () => { 456 | return after(1000, () => trigger('pong')); 457 | }); 458 | listen('pong', () => { 459 | return after(1000, () => trigger('ping')); 460 | }); 461 | 462 | trigger('ping'); 463 | // Output: (1000 msec between each) 464 | // ping 465 | // pong 466 | // ping 467 | // pong (etc...) 468 | ``` 469 | 470 | It works! But we can clean this code up. While we could use a Regex to match either **ping** or **pong**, a string array does the job just as well, and is more grep-pable. We'll write a `returnBall` function that can `trigger` either **ping** or **pong**, and wire it up. 471 | 472 | ```js 473 | filter(true, log); 474 | listen(['ping', 'pong'], returnBall); 475 | 476 | trigger('ping'); 477 | 478 | function returnBall({ type }) { 479 | return after(1000, () => trigger(type === 'ping' ? 'pong' : 'ping')); 480 | } 481 | ``` 482 | 483 | Now we have an infinite game, without even containing a loop in our app! Though we're dispensing with traditional control structures like loops, we're also not inheriting their inability to handle async, so our app's code will be more flexible and readable. 484 | 485 | In ping-pong, running forever may be what is desired. But when it's not, or when parts of the app are shutdown, we'll want to turn off listeners safely. 486 | 487 | ## 5) Shutdown Safely (Game Over!) 488 | 489 | While each listener can be individually shut down, when it's time to shut down the app (or in Hot Module Reloading scenarios), it's good to have a way to remove all listeners. The `reset` function does just this. Let's end the game after 4 seconds, then print "done". 490 | 491 | ```js 492 | const { filter, listen, log, after, reset, trigger } = require('polyrhythm'); 493 | 494 | filter(true, log); 495 | listen(['ping', 'pong'], returnBall); 496 | 497 | trigger('ping'); 498 | after(4000, reset).then(() => console.log('Done!')); 499 | 500 | //Output: 501 | // ping 502 | // pong 503 | // ping 504 | // pong 505 | // Done! 506 | ``` 507 | 508 | Now that's a concise and readable description!. 509 | 510 | The function `after` returned an Observable of calling `reset()` after 4 seconds. Then we called `then()` on it, which caused `toPromise()` to be invoked, which kicked off its subscription. And we're done! 511 | 512 | **TIP:** To shut down an individual listener, `listen` returns a **Subscription** that is disposable in the usual RxJS fashion: 513 | 514 | ```js 515 | filter(true, log); 516 | listen('ping', () => { 517 | return after(1000, () => trigger('pong')); 518 | }); 519 | const player2 = listen('pong', () => { 520 | return after(1000, () => trigger('ping')); 521 | }); 522 | 523 | trigger('ping'); 524 | after(4000, () => player2.unsubscribe()).then(() => console.log('Done!')); 525 | 526 | //Output: 527 | // ping 528 | // pong 529 | // ping 530 | // pong 531 | // Done! 532 | ``` 533 | 534 | Calling `unsubscribe()` causes the 2nd Actor/Player to leave the game, effectively ending the match, and completing the ping-pong example! 535 | 536 |
537 | 538 | --- 539 | 540 | # Further Reading 541 | 542 | The following were inspiring principles for developing `polyrhythm`, and are definitely worth reading up on in their own right: 543 | 544 | - [Command Object Pattern](https://en.wikipedia.org/wiki/Command_pattern#:~:text=In%20object%2Doriented%20programming%2C%20the,values%20for%20the%20method%20parameters.) 545 | - [Pub Sub Pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) 546 | - [Actor Model](https://en.wikipedia.org/wiki/Actor_model) 547 | - [Event Sourcing / CQRS](https://en.wikipedia.org/wiki/Command%E2%80%93query_separation#Command_query_responsibility_segregation) 548 | - [Flux Standard Action](https://github.com/redux-utilities/flux-standard-action) | [Redux](https://redux.js.org) 549 | - [TC39 Observable proposal](https://github.com/tc39/proposal-observable) 550 | - [ReactiveX](http://reactivex.io/) 551 | - [RxJS Concurrency operators](https://rxjs-dev.firebaseapp.com/) 552 | - [Turning the Database Inside Out (Samza/Kafka)](https://www.confluent.io/blog/turning-the-database-inside-out-with-apache-samza/) 553 | - [Svelte](https://svelte.dev/) 554 | - [Elm](https://elm-lang.org/) 555 | -------------------------------------------------------------------------------- /test/channel.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | import { 4 | Observable, 5 | Subscription, 6 | range, 7 | asyncScheduler, 8 | throwError, 9 | of, 10 | } from 'rxjs'; 11 | import { tap } from 'rxjs/operators'; 12 | import { Channel } from '../src/channel'; 13 | import { Event, ConcurrencyMode, Filter } from '../src/types'; 14 | import { randomId, after } from '../src/utils'; 15 | 16 | const channel = new Channel(); 17 | const trigger = channel.trigger.bind(channel); 18 | const query = channel.query.bind(channel); 19 | const filter = channel.filter.bind(channel); 20 | const listen = channel.listen.bind(channel); 21 | const on = channel.on.bind(channel); 22 | const spy = channel.spy.bind(channel); 23 | const reset = channel.reset.bind(channel); 24 | 25 | function captureEvents(testFn: (arg: T[]) => void | Promise) { 26 | return function() { 27 | const seen = new Array(); 28 | // @ts-ignore 29 | const sub = channel.query(true).subscribe(event => seen.push(event)); 30 | const result: any = testFn(seen); 31 | if (result && result.then) { 32 | return result.finally(() => sub.unsubscribe()); 33 | } 34 | sub.unsubscribe(); 35 | return result; 36 | }; 37 | } 38 | 39 | function it$(name: string, fn: (arg: Event[]) => void | Promise) { 40 | it(name, captureEvents(fn)); 41 | } 42 | 43 | require('clear')(); 44 | 45 | describe('Channel Behavior', () => { 46 | let callCount = 0; 47 | const thrower: Filter = (e: Event) => { 48 | callCount++; 49 | syncThrow(e); 50 | }; 51 | 52 | const ill = () => { 53 | callCount++; 54 | throw new Error('down wit da sickness'); 55 | }; 56 | 57 | const throwsError = () => { 58 | callCount++; 59 | return throwError(new Error('Oops')); 60 | }; 61 | 62 | beforeEach(() => { 63 | reset(); 64 | callCount = 0; 65 | }); 66 | afterEach(function() { 67 | if (this.subscription) { 68 | this.unsubscribe = () => this.subscription.unsubscribe(); 69 | } 70 | this.unsubscribe && this.unsubscribe(); 71 | }); 72 | 73 | describe('#trigger', () => { 74 | describe('string, payload', () => { 75 | it('processes and returns the event', () => { 76 | const result = trigger('etype', {}); 77 | const expected = { type: 'etype', payload: {} }; 78 | expect(result).to.eql(expected); 79 | }); 80 | }); 81 | 82 | describe('object with type field', () => { 83 | it('processes and returns the event', () => { 84 | const result = trigger({ type: 'etype', payload: {} }); 85 | const expected = { type: 'etype', payload: {} }; 86 | expect(result).to.eql(expected); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('#query', () => { 92 | it('returns an Observable of events', () => { 93 | const result = query(true); 94 | 95 | expect(result).to.be.instanceOf(Observable); 96 | }); 97 | 98 | describe('.toPromise()', () => { 99 | it('can be awaited; reply in same callstack', async () => { 100 | listen('data/query', () => { 101 | trigger('data/result', 2.5); 102 | }); 103 | 104 | // its important the query for the result be subscribed via toPromise() 105 | // before the trigger occurs, to acomodate the case of the same callstack 106 | // Wouldn't work for a sync-triggering listener: 107 | // const { payload } = trigger('data/query') && (await query('data/result').toPromise()); 108 | const resultEvent = query('data/result').toPromise(); 109 | trigger('data/query'); 110 | const { payload } = await resultEvent; 111 | expect(payload).to.equal(2.5); 112 | }); 113 | 114 | it('can be awaited; reply in later callstack', async () => { 115 | listen('auth/login', () => after(1, () => trigger('auth/token', 2.7))); 116 | 117 | const tokenEvent = query('auth/token').toPromise(); 118 | const { payload } = trigger('auth/login') && (await tokenEvent); 119 | expect(payload).to.equal(2.7); 120 | }); 121 | }); 122 | 123 | describe('inside of a #listen', () => { 124 | it('misses its own event, of course', async function() { 125 | let counter = 0; 126 | listen('count/start', () => { 127 | query('count/start').subscribe(() => { 128 | counter++; 129 | }); 130 | }); 131 | trigger('count/start'); 132 | await delay(10); 133 | expect(counter).to.equal(0); 134 | }); 135 | }); 136 | }); 137 | 138 | describe('#filter', () => { 139 | describe('Arguments', () => { 140 | function simpleTest() { 141 | filter('foo', () => { 142 | callCount++; 143 | }); 144 | trigger('foo'); 145 | expect(callCount).to.equal(1); 146 | } 147 | 148 | describe('eventMatcher', () => { 149 | it('defines events the filter will run upon', simpleTest); 150 | }); 151 | describe('filter function', () => { 152 | it('defines the function to be run on matching events', simpleTest); 153 | }); 154 | }); 155 | 156 | it('returns a subscription', () => { 157 | const result = filter(true, () => null); 158 | expect(result).to.be.instanceOf(Subscription); 159 | expect(result).to.haveOwnProperty('closed', false); 160 | }); 161 | it('is cancelable', () => { 162 | const subs = filter(true, () => { 163 | callCount++; 164 | }); 165 | subs.unsubscribe(); 166 | trigger('foo'); 167 | expect(callCount).to.equal(0); 168 | }); 169 | }); 170 | 171 | describe('#listen', () => { 172 | function simpleTest() { 173 | listen('foo', () => { 174 | callCount++; 175 | }); 176 | trigger('foo'); 177 | trigger('not-foo'); 178 | expect(callCount).to.equal(1); 179 | } 180 | describe('Arguments', () => { 181 | describe('eventMatcher', () => { 182 | it('defines the events the listener will run upon', simpleTest); 183 | }); 184 | 185 | describe('listener function', () => { 186 | it('defines the function to be run on matching events', simpleTest); 187 | it('recieves a frozen event', () => { 188 | listen('foo', e => { 189 | expect(Object.isFrozen(e)).to.equal(true); 190 | }); 191 | trigger('foo'); 192 | }); 193 | 194 | it('listener may return a function to defer and schedule evaluation', async () => { 195 | listen( 196 | 'known-event', 197 | () => 198 | function() { 199 | callCount++; 200 | return delay(10); 201 | }, 202 | { mode: 'serial' } 203 | ); 204 | 205 | trigger('known-event'); // listener evaluated synchronusly 206 | trigger('known-event'); // listener deferred (due to the mode) 207 | expect(callCount).to.equal(1); 208 | }); 209 | }); 210 | 211 | describe('config', () => { 212 | it('See #listen / #trigger specs'); 213 | }); 214 | }); 215 | 216 | it('returns a subscription', () => { 217 | const result = listen(true, () => {}); 218 | 219 | expect(result).to.be.instanceOf(Subscription); 220 | }); 221 | }); 222 | 223 | describe('#filter, #trigger', () => { 224 | it('runs synchronously', () => { 225 | filter(true, () => { 226 | callCount++; 227 | }); 228 | trigger('foo'); 229 | expect(callCount).to.equal(1); 230 | }); 231 | 232 | it('can modify the event', () => { 233 | filter(true, mutator); 234 | const result = trigger(event.type, event.payload); 235 | expect(result).to.have.property('mutantProp', ':)'); 236 | }); 237 | 238 | it('affects only specified events', () => { 239 | let bac = 0; 240 | filter('beer', () => { 241 | bac += 0.1; 242 | }); 243 | filter('wine', () => { 244 | bac += 0.2; 245 | }); 246 | trigger('wine'); 247 | expect(bac).to.equal(0.2); 248 | }); 249 | 250 | it('can throw for the triggerer', () => { 251 | filter(true, syncThrow); 252 | expect(triggerEvent).to.throw(); 253 | }); 254 | 255 | it('can throw and resume taking events', () => { 256 | filter(true, thrower); 257 | expect(triggerEvent).to.throw(); 258 | expect(callCount).to.equal(1); 259 | expect(triggerEvent).to.throw(); 260 | expect(callCount).to.equal(2); 261 | }); 262 | }); 263 | 264 | describe('#trigger, #query', () => { 265 | it('does not find events triggered before the query', function() { 266 | let counter = 0; 267 | trigger('count/start'); 268 | this.subscription = query('count/start').subscribe(() => { 269 | counter++; 270 | }); 271 | trigger('count/start'); 272 | expect(counter).to.equal(1); 273 | }); 274 | }); 275 | 276 | describe('#query, #trigger', () => { 277 | it$('finds events triggered after the query', async seen => { 278 | // trigger events 279 | const event2 = { type: 'e2', payload: randomId() }; 280 | 281 | trigger(event.type, event.payload); 282 | trigger(event2.type, event2.payload); 283 | 284 | expect(seen).to.eql([event, event2]); 285 | }); 286 | }); 287 | 288 | describe('#filter, #filter, #trigger', () => { 289 | it('calls filters in order added', () => { 290 | const doubler = (e: Event) => { 291 | e.payload *= 2; 292 | }; 293 | const speaker = (e: Event) => { 294 | e.payload = `The number is ${e.payload}`; 295 | }; 296 | 297 | filter(true, doubler); 298 | filter(true, speaker); 299 | const result = trigger('any', 7); 300 | expect(result.payload).to.equal('The number is 14'); 301 | }); 302 | 303 | it('aborts later filters if earlier ones throw', () => { 304 | const healthy = () => { 305 | callCount++; 306 | }; 307 | 308 | filter(true, ill); 309 | filter(true, healthy); 310 | expect(triggerEvent).to.throw(); 311 | expect(callCount).to.equal(1); 312 | }); 313 | 314 | it('runs no listeners if an exception thrown', () => { 315 | let listenerCallCount = 0; 316 | const healthy = () => { 317 | listenerCallCount++; 318 | }; 319 | 320 | filter(true, ill); 321 | listen(true, healthy); 322 | expect(triggerEvent).to.throw(); 323 | expect(listenerCallCount).to.equal(0); 324 | }); 325 | }); 326 | 327 | describe('#filter, #trigger, #filter.unsubscribe, #trigger', () => { 328 | it('stops filtering events', () => { 329 | let sub = filter(true, mutator); 330 | let result = trigger(event.type, event.payload); 331 | expect(result).to.have.property('mutantProp', ':)'); 332 | 333 | sub.unsubscribe(); 334 | result = trigger(event.type, event.payload); 335 | expect(result).not.to.have.property('mutantProp', ':)'); 336 | }); 337 | }); 338 | 339 | describe('#listen, #trigger', () => { 340 | it('listener is run only on matching events', () => { 341 | listen('known-event', () => { 342 | callCount++; 343 | }); 344 | trigger('unknown-event'); 345 | expect(callCount).to.equal(0); 346 | }); 347 | 348 | describe('Listener Evaluation and Returning', () => { 349 | it('listener is evaluated synchronously by default', () => { 350 | listen('known-event', () => { 351 | callCount++; 352 | }); 353 | trigger('known-event'); 354 | expect(callCount).to.equal(1); 355 | }); 356 | 357 | it$('listener may return a Promise-returning function', async seen => { 358 | listen( 359 | 'known-event', 360 | () => 361 | function() { 362 | callCount++; 363 | return Promise.resolve(1.007); 364 | }, 365 | { mode: 'serial', trigger: { next: 'result' } } 366 | ); 367 | 368 | trigger('known-event'); // listener evaluated synchronusly 369 | trigger('known-event'); // listener deferred (due to the mode) 370 | expect(callCount).to.equal(1); 371 | 372 | await after(10); 373 | expect(seen.map(e => e.type)).to.eql([ 374 | 'known-event', 375 | 'known-event', 376 | 'result', 377 | 'result', 378 | ]); 379 | }); 380 | 381 | it( 382 | 'can trigger `next` events via config', 383 | captureEvents(async seen => { 384 | listen('cause', () => after(1, () => '⚡️'), { 385 | trigger: { next: 'effect' }, 386 | }); 387 | trigger('cause'); 388 | expect(seen).to.eql([{ type: 'cause' }]); 389 | await delay(2); 390 | expect(seen).to.eql([ 391 | { type: 'cause' }, 392 | { type: 'effect', payload: '⚡️' }, 393 | ]); 394 | }) 395 | ); 396 | 397 | it( 398 | 'Does not exapand a string when returned bare from a handler', 399 | captureEvents(async seen => { 400 | listen('cause', () => 'abc', { 401 | trigger: { next: 'effect' }, 402 | }); 403 | trigger('cause'); 404 | expect(seen).to.eql([ 405 | { type: 'cause' }, 406 | { type: 'effect', payload: 'abc' }, 407 | ]); 408 | }) 409 | ); 410 | 411 | it( 412 | 'Expands an array/iterable when returned bare from a handler', 413 | captureEvents(async seen => { 414 | listen('cause', () => ['a', 'b'], { 415 | trigger: { next: 'effect' }, 416 | }); 417 | trigger('cause'); 418 | expect(seen).to.eql([ 419 | { type: 'cause' }, 420 | { type: 'effect', payload: 'a' }, 421 | { type: 'effect', payload: 'b' }, 422 | ]); 423 | }) 424 | ); 425 | 426 | it( 427 | 'Expands/runs a generator', 428 | captureEvents(async seen => { 429 | expect(1).to.eql(1); 430 | listen( 431 | 'seq', 432 | function*({ payload: count }) { 433 | for (let i = 1; i <= count; i++) { 434 | yield i; 435 | } 436 | }, 437 | { trigger: { next: 'seq-value' } } 438 | ); 439 | trigger('seq', 2); 440 | expect(seen).to.eql([ 441 | { type: 'seq', payload: 2 }, 442 | { type: 'seq-value', payload: 1 }, 443 | { type: 'seq-value', payload: 2 }, 444 | ]); 445 | }) 446 | ); 447 | }); 448 | 449 | it( 450 | 'can trigger `next` events via config - and errors kill', 451 | captureEvents(async seen => { 452 | // when the 'cause' listener triggers next, it'll throw 453 | filter('call-err', syncThrow); 454 | 455 | // This listener will be brought down by the exception 456 | const subs = listen('cause', () => after(1, () => '⚡️'), { 457 | trigger: { next: 'call-err' }, 458 | }); 459 | 460 | trigger('cause'); 461 | await delay(2); 462 | 463 | // Error killed it 464 | expect(subs).to.have.property('closed', true); 465 | 466 | expect(seen).to.eql([{ type: 'cause' }]); 467 | trigger('cause'); 468 | await delay(2); 469 | // No effect, no error 470 | expect(seen).to.eql([{ type: 'cause' }, { type: 'cause' }]); 471 | }) 472 | ); 473 | it( 474 | 'can terminate a listener via takeUntil', 475 | captureEvents(async seen => { 476 | listen( 477 | 'start', 478 | () => 479 | new Observable(() => { 480 | const subs = after(1, () => { 481 | trigger('⚡️'); 482 | }).subscribe(); 483 | return () => { 484 | trigger('unsub'); 485 | subs.unsubscribe(); 486 | }; 487 | }), 488 | { takeUntil: 'end' } 489 | ); 490 | 491 | trigger('start'); 492 | // @ts-ignore 493 | expect(seen.map(e => e.type)).to.eql(['start']); 494 | trigger('end'); 495 | await after(1); 496 | // @ts-ignore 497 | expect(seen.map(e => e.type)).to.eql(['start', 'end', 'unsub']); 498 | }) 499 | ); 500 | 501 | it$('can trigger `complete` events via config', seen => { 502 | listen('cause', () => of(2.718), { 503 | trigger: { next: 'effect', complete: 'cause/complete' }, 504 | }); 505 | trigger('cause'); 506 | 507 | expect(seen).to.eql([ 508 | { type: 'cause' }, 509 | { type: 'effect', payload: 2.718 }, 510 | { type: 'cause/complete' }, 511 | ]); 512 | }); 513 | 514 | it$('can rescue `error` events via config', seen => { 515 | listen('cause', throwsError, { trigger: { error: 'cause/error' } }); 516 | trigger('cause'); 517 | expect(seen.length).to.equal(2); 518 | expect(seen[0]).to.eql({ type: 'cause' }); 519 | expect(seen[1].type).to.eq('cause/error'); 520 | expect(seen[1].payload).to.be.instanceOf(Error); 521 | 522 | trigger('cause'); 523 | expect(seen[2]).to.eql({ type: 'cause' }); 524 | 525 | // rescued, so both causes and effects 526 | expect(seen).to.have.length(4); 527 | }); 528 | 529 | it( 530 | 'can trigger `start` events via config - parallel', 531 | captureEvents(async seen => { 532 | listen('cause', () => after(1, () => '⚡️'), { 533 | mode: 'parallel', 534 | trigger: { start: 'started', next: 'effect' }, 535 | }); 536 | trigger('cause', 'a'); 537 | trigger('cause', 'b'); 538 | 539 | await delay(5); 540 | expect(seen).to.eql([ 541 | { type: 'cause', payload: 'a' }, 542 | { type: 'started', payload: 'a' }, 543 | { type: 'cause', payload: 'b' }, 544 | { type: 'started', payload: 'b' }, 545 | { type: 'effect', payload: '⚡️' }, 546 | { type: 'effect', payload: '⚡️' }, 547 | ]); 548 | }) 549 | ); 550 | 551 | it( 552 | 'can trigger `start` events via config - serial', 553 | captureEvents(async seen => { 554 | listen('cause', () => after(1, () => '⚡️'), { 555 | mode: 'serial', 556 | trigger: { start: 'started', next: 'effect' }, 557 | }); 558 | 559 | trigger('cause', 'a'); 560 | trigger('cause', 'b'); 561 | 562 | await delay(5); 563 | expect(seen).to.eql([ 564 | { type: 'cause', payload: 'a' }, 565 | { type: 'started', payload: 'a' }, 566 | { type: 'cause', payload: 'b' }, 567 | { type: 'effect', payload: '⚡️' }, 568 | { type: 'started', payload: 'b' }, 569 | { type: 'effect', payload: '⚡️' }, 570 | ]); 571 | }) 572 | ); 573 | 574 | it( 575 | 'can trigger entire Observable events with trigger:true', 576 | captureEvents(async seen => { 577 | listen( 578 | 'cause', 579 | () => after(1, () => ({ type: 'effect', payload: '⚡️' })), 580 | { 581 | trigger: true, 582 | } 583 | ); 584 | 585 | trigger('cause', 'a'); 586 | 587 | await delay(5); 588 | expect(seen).to.eql([ 589 | { type: 'cause', payload: 'a' }, 590 | { type: 'effect', payload: '⚡️' }, 591 | ]); 592 | }) 593 | ); 594 | 595 | describe('Error Handling', () => { 596 | describe('Sync Errors', () => { 597 | it('does not throw for the triggerer', () => { 598 | listen(true, thrower); 599 | expect(triggerEvent).not.to.throw(); 600 | }); 601 | 602 | it('terminates the listener subscription', () => { 603 | const subs = listen(true, thrower); 604 | triggerEvent(); 605 | expect(callCount).to.equal(1); 606 | expect(subs).to.have.property('closed', true); 607 | triggerEvent(); 608 | expect(callCount).to.equal(1); 609 | }); 610 | }); 611 | 612 | describe('Observable Errors', () => { 613 | it('does not throw for the triggerer', () => { 614 | listen(true, throwsError); 615 | expect(triggerEvent).not.to.throw(); 616 | }); 617 | 618 | it('terminates the listener subscription', () => { 619 | const subs = listen(true, throwsError); 620 | triggerEvent(); 621 | expect(callCount).to.equal(1); 622 | expect(subs).to.have.property('closed', true); 623 | triggerEvent(); 624 | expect(callCount).to.equal(1); 625 | }); 626 | 627 | it('fails on downstream filter errors', () => { 628 | filter('throws-error', syncThrow); 629 | let cc = 0; 630 | listen('top-level', () => { 631 | cc++; 632 | trigger('throws-error'); 633 | }); 634 | trigger('top-level'); 635 | expect(cc).to.equal(1); 636 | trigger('top-level'); 637 | expect(cc).to.equal(1); 638 | }); 639 | 640 | it('survives downstream listener errors (spawned not forked)', () => { 641 | const errSub = listen('throws-error', throwsError); 642 | let cc = 0; 643 | const spawnerSub = listen('top-level', () => { 644 | cc++; 645 | trigger('throws-error'); 646 | }); 647 | trigger('top-level'); 648 | expect(cc).to.equal(1); 649 | expect(errSub).to.have.property('closed', true); 650 | expect(spawnerSub).to.have.property('closed', false); 651 | 652 | trigger('top-level'); 653 | expect(cc).to.equal(2); 654 | }); 655 | }); 656 | }); 657 | }); 658 | 659 | describe('Concurrency Modes: #listen, #trigger, #trigger', () => { 660 | it$('ignore (mute/exhaustMap)', async seen => { 661 | listen('tick/start', ({ payload }) => threeTicksTriggered(payload, 3)(), { 662 | mode: ignore, 663 | }); 664 | 665 | // 2 within a short time 666 | trigger('tick/start', 1); 667 | trigger('tick/start', 7); // ignored 668 | await delay(10); 669 | expect(seen).to.eql([ 670 | { type: 'tick/start', payload: 1 }, 671 | { type: 'tick/start', payload: 7 }, 672 | { type: 'tick', payload: 1 }, 673 | { type: 'tick', payload: 2 }, 674 | { type: 'tick', payload: 3 }, 675 | ]); 676 | }); 677 | 678 | it$('toggle (toggle/toggleMap)', async seen => { 679 | listen( 680 | 'tick/start', 681 | ({ payload }) => { 682 | return threeTicksTriggered(payload, 3)(); 683 | }, 684 | { 685 | mode: toggle, 686 | } 687 | ); 688 | 689 | // 2 within a short time 690 | trigger('tick/start', 1); 691 | trigger('tick/start', 2); 692 | trigger('tick/start', 3); 693 | 694 | await delay(10); 695 | expect(seen).to.eql([ 696 | { type: 'tick/start', payload: 1 }, 697 | // the async part was toggled off 698 | { type: 'tick/start', payload: 2 }, 699 | // a new run went to completion 700 | { type: 'tick/start', payload: 3 }, 701 | { type: 'tick', payload: 3 }, 702 | { type: 'tick', payload: 4 }, 703 | { type: 'tick', payload: 5 }, 704 | ]); 705 | }); 706 | 707 | it$('replace (cutoff/switchMap', async seen => { 708 | listen('tick/start', ({ payload }) => threeTicksTriggered(payload, 3)(), { 709 | mode: replace, 710 | }); 711 | 712 | // 2 within a short time 713 | const sub = query('tick').subscribe(() => { 714 | trigger('tick/start', 7); 715 | sub.unsubscribe(); 716 | }); 717 | trigger('tick/start', 1); 718 | 719 | await delay(20); 720 | expect(seen).to.eql([ 721 | { type: 'tick/start', payload: 1 }, 722 | { type: 'tick', payload: 1 }, 723 | { type: 'tick/start', payload: 7 }, 724 | { type: 'tick', payload: 7 }, 725 | { type: 'tick', payload: 8 }, 726 | { type: 'tick', payload: 9 }, 727 | ]); 728 | }); 729 | 730 | it$('start (parallel/mergeMap)', async seen => { 731 | listen('tick/start', ({ payload }) => threeTicksTriggered(payload, 3)(), { 732 | mode: parallel, 733 | }); 734 | 735 | // 2 within a short time 736 | trigger('tick/start', 1); 737 | trigger('tick/start', 7); 738 | await delay(20); 739 | expect(seen).to.eql([ 740 | { type: 'tick/start', payload: 1 }, 741 | { type: 'tick/start', payload: 7 }, 742 | { type: 'tick', payload: 1 }, 743 | { type: 'tick', payload: 7 }, 744 | { type: 'tick', payload: 2 }, 745 | { type: 'tick', payload: 8 }, 746 | { type: 'tick', payload: 3 }, 747 | { type: 'tick', payload: 9 }, 748 | ]); 749 | }); 750 | 751 | it$('enqueue (serial/concatMap)', async seen => { 752 | listen('tick/start', ({ payload }) => threeTicksTriggered(payload, 3)(), { 753 | mode: serial, 754 | }); 755 | 756 | // 2 within a short time 757 | trigger('tick/start', 1); 758 | trigger('tick/start', 7); 759 | await delay(20); 760 | expect(seen).to.eql([ 761 | { type: 'tick/start', payload: 1 }, 762 | { type: 'tick/start', payload: 7 }, 763 | { type: 'tick', payload: 1 }, 764 | { type: 'tick', payload: 2 }, 765 | { type: 'tick', payload: 3 }, 766 | { type: 'tick', payload: 7 }, 767 | { type: 'tick', payload: 8 }, 768 | { type: 'tick', payload: 9 }, 769 | ]); 770 | }); 771 | }); 772 | 773 | describe('#listen, #trigger, #listen.unsubscribe', () => { 774 | it$('cancels in-flight listeners', async seen => { 775 | const sub = listen('cause', () => 776 | after(1, () => { 777 | trigger('effect'); 778 | }) 779 | ); 780 | trigger('cause'); 781 | expect(seen).to.eql([{ type: 'cause' }]); 782 | sub.unsubscribe(); 783 | await delay(2); 784 | expect(seen).to.eql([{ type: 'cause' }]); 785 | }); 786 | }); 787 | 788 | describe('#spy', () => { 789 | it('runs on every event', () => { 790 | spy(() => callCount++); 791 | trigger('foo'); 792 | expect(callCount).to.equal(1); 793 | }); 794 | 795 | describe('When has an error', () => { 796 | it('is unsubscribed', () => { 797 | spy(ill); 798 | trigger('foo'); // errs here 799 | trigger('foo'); // not counted 800 | expect(callCount).to.equal(1); 801 | }); 802 | }); 803 | }); 804 | 805 | describe('#reset', () => { 806 | let bac = 0; 807 | beforeEach(() => { 808 | bac = 0; 809 | }); 810 | it('wont fire filters after reset', () => { 811 | filter('beer', () => { 812 | bac += 0.1; 813 | }); 814 | reset(); 815 | trigger('beer'); 816 | expect(bac).to.equal(0); 817 | }); 818 | 819 | it('wont fire listeners after reset', () => { 820 | listen('beer', () => { 821 | bac += 0.1; 822 | }); 823 | reset(); 824 | trigger('beer'); 825 | expect(bac).to.equal(0); 826 | }); 827 | 828 | it('terminates existing listeners', async () => { 829 | const subs = listen('beer', () => { 830 | return after(1, () => { 831 | bac += 0.1; 832 | }); 833 | }); 834 | trigger('beer'); 835 | reset(); 836 | expect(subs).to.have.property('closed', true); 837 | 838 | await after(2); 839 | expect(bac).to.equal(0); 840 | }); 841 | }); 842 | 843 | describe('TypeScript Type Inference', () => { 844 | interface FooPayload { 845 | fooId: string; 846 | } 847 | 848 | interface AtLeastFooPayload extends FooPayload { 849 | [others: string]: any; 850 | } 851 | 852 | interface FooEvent extends Event { 853 | type: 'foo'; 854 | bar: string; 855 | } 856 | 857 | interface FooPayloadEvent extends Event { 858 | type: 'foo'; 859 | payload: FooPayload; 860 | } 861 | 862 | describe('#trigger', () => { 863 | describe('1 argument version', () => { 864 | it('can strongly type the event', () => { 865 | trigger({ 866 | type: 'foo', 867 | bar: 'baz', 868 | }); 869 | }); 870 | 871 | it('can weakly type the event', () => { 872 | trigger({ 873 | type: 'foo', 874 | bam: 'bing', 875 | }); 876 | }); 877 | }); 878 | 879 | describe('2 argument version', () => { 880 | it('can strongly type the payload', () => { 881 | trigger('type', { 882 | fooId: 'bar', 883 | }); 884 | }); 885 | 886 | it('can weakly type the payload', () => { 887 | trigger('type', { 888 | fooId: 'bar', 889 | catId: 'Mona Lisa', 890 | dogId: 'Mr. Thompson Wooft', 891 | }); 892 | }); 893 | 894 | it('dont have to type the payload', () => { 895 | trigger('type', { anyField: true }); 896 | }); 897 | }); 898 | }); 899 | 900 | describe('#filter, #trigger', () => { 901 | it('can mutate the payload', () => { 902 | filter('foo', e => { 903 | // Typescript helps here 904 | e.payload.fooId = 'bar'; 905 | }); 906 | 907 | // mutates the payload 908 | const payload = { fooId: 'bazž' }; 909 | let result = trigger('foo', payload); 910 | expect(payload.fooId).to.eq('bar'); 911 | 912 | // returns mutated payload (no type safety) 913 | const e: FooPayloadEvent = { type: 'foo', payload: { fooId: 'moo' } }; 914 | result = trigger(e); 915 | expect(result.payload.fooId).to.eq('bar'); 916 | }); 917 | it('can return a new payload to sub out for listeners', () => { 918 | const seenFooIds: Array = []; 919 | filter('foo', e => { 920 | return { type: e.type, payload: { fooId: 'bar' } }; 921 | }); 922 | listen('foo', e => { 923 | seenFooIds.push(e.payload.fooId); 924 | }); 925 | 926 | const payload = { fooId: 'bazž' }; 927 | trigger('foo', payload); 928 | 929 | // the filter replaces the event 930 | expect(seenFooIds).to.include('bar'); 931 | }); 932 | 933 | it('can return null to hide from listeners', () => { 934 | const seenFooIds: Array = []; 935 | filter('foo', e => { 936 | return null; 937 | }); 938 | listen('foo', e => { 939 | seenFooIds.push(e.payload.fooId); 940 | }); 941 | 942 | const payload = { fooId: 'bazž' }; 943 | trigger('foo', payload); 944 | expect(seenFooIds.length).to.equal(0); 945 | }); 946 | }); 947 | 948 | describe('#listen, #trigger', () => { 949 | it('should type it up', () => { 950 | const seenFooIds: string[] = []; 951 | listen('foo', e => { 952 | // Typescript helps here 953 | seenFooIds.push(e.payload.fooId); 954 | }); 955 | 956 | const payload = { fooId: 'bazž' }; 957 | trigger('foo', payload); 958 | expect(seenFooIds).to.eql(['bazž']); 959 | }); 960 | }); 961 | }); 962 | 963 | describe('Aliases', () => { 964 | describe('#on', () => { 965 | it('is an alias for #listen', () => { 966 | const result = on(true, () => {}); 967 | expect(result).to.be.instanceOf(Subscription); 968 | }); 969 | }); 970 | }); 971 | }); 972 | 973 | const event = { type: 'anytype', payload: randomId() }; 974 | const ignore = ConcurrencyMode.ignore; 975 | const toggle = ConcurrencyMode.toggle; 976 | const replace = ConcurrencyMode.replace; 977 | const parallel = ConcurrencyMode.parallel; 978 | const serial = ConcurrencyMode.serial; 979 | 980 | const syncThrow: Filter = () => { 981 | throw new Error(`Error: ${randomId()}`); 982 | }; 983 | 984 | const mutator: Filter = (e: Event) => { 985 | // @ts-ignore 986 | e.mutantProp = ':)'; 987 | }; 988 | 989 | const triggerEvent = () => { 990 | return trigger(event.type, event.payload); 991 | }; 992 | 993 | const delay = (ms: number, fn?: any) => 994 | new Promise(resolve => { 995 | setTimeout(() => resolve(fn && fn()), ms); 996 | }); 997 | 998 | const threeTicksTriggered = ( 999 | from: number, 1000 | count: number, 1001 | type = 'tick' 1002 | ) => () => { 1003 | return range(from, count, asyncScheduler).pipe( 1004 | tap(n => { 1005 | trigger(type, n); 1006 | }) 1007 | ); 1008 | }; 1009 | -------------------------------------------------------------------------------- /docs/assets/js/main.js: -------------------------------------------------------------------------------- 1 | !function(){var e=function(t){var r=new e.Builder;return r.pipeline.add(e.trimmer,e.stopWordFilter,e.stemmer),r.searchPipeline.add(e.stemmer),t.call(r,r),r.build()};e.version="2.3.7",e.utils={},e.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),e.utils.asString=function(e){return null==e?"":e.toString()},e.utils.clone=function(e){if(null==e)return e;for(var t=Object.create(null),r=Object.keys(e),i=0;i=this.length)return e.QueryLexer.EOS;var t=this.str.charAt(this.pos);return this.pos+=1,t},e.QueryLexer.prototype.width=function(){return this.pos-this.start},e.QueryLexer.prototype.ignore=function(){this.start==this.pos&&(this.pos+=1),this.start=this.pos},e.QueryLexer.prototype.backup=function(){this.pos-=1},e.QueryLexer.prototype.acceptDigitRun=function(){for(var t,r;47<(r=(t=this.next()).charCodeAt(0))&&r<58;);t!=e.QueryLexer.EOS&&this.backup()},e.QueryLexer.prototype.more=function(){return this.pos=this.scrollTop||0===this.scrollTop,isShown!==this.showToolbar&&(this.toolbar.classList.toggle("tsd-page-toolbar--hide"),this.secondaryNav.classList.toggle("tsd-navigation--toolbar-hide")),this.lastY=this.scrollTop},Viewport}(typedoc.EventTarget);typedoc.Viewport=Viewport,typedoc.registerService(Viewport,"viewport")}(typedoc||(typedoc={})),function(typedoc){function Component(options){this.el=options.el}typedoc.Component=Component}(typedoc||(typedoc={})),function(typedoc){typedoc.pointerDown="mousedown",typedoc.pointerMove="mousemove",typedoc.pointerUp="mouseup",typedoc.pointerDownPosition={x:0,y:0},typedoc.preventNextClick=!1,typedoc.isPointerDown=!1,typedoc.isPointerTouch=!1,typedoc.hasPointerMoved=!1,typedoc.isMobile=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),document.documentElement.classList.add(typedoc.isMobile?"is-mobile":"not-mobile"),typedoc.isMobile&&"ontouchstart"in document.documentElement&&(typedoc.isPointerTouch=!0,typedoc.pointerDown="touchstart",typedoc.pointerMove="touchmove",typedoc.pointerUp="touchend"),document.addEventListener(typedoc.pointerDown,function(e){typedoc.isPointerDown=!0,typedoc.hasPointerMoved=!1;var t="touchstart"==typedoc.pointerDown?e.targetTouches[0]:e;typedoc.pointerDownPosition.y=t.pageY||0,typedoc.pointerDownPosition.x=t.pageX||0}),document.addEventListener(typedoc.pointerMove,function(e){if(typedoc.isPointerDown&&!typedoc.hasPointerMoved){var t="touchstart"==typedoc.pointerDown?e.targetTouches[0]:e,x=typedoc.pointerDownPosition.x-(t.pageX||0),y=typedoc.pointerDownPosition.y-(t.pageY||0);typedoc.hasPointerMoved=10scrollTop;)index-=1;for(;index"+match+""}),parent=row.parent||"";(parent=parent.replace(new RegExp(this.query,"i"),function(match){return""+match+""}))&&(name=''+parent+"."+name);var item=document.createElement("li");item.classList.value=row.classes,item.innerHTML='\n '+name+"\n ",this.results.appendChild(item)}}},Search.prototype.setLoadingState=function(value){this.loadingState!=value&&(this.el.classList.remove(SearchLoadingState[this.loadingState].toLowerCase()),this.loadingState=value,this.el.classList.add(SearchLoadingState[this.loadingState].toLowerCase()),this.updateResults())},Search.prototype.setHasFocus=function(value){this.hasFocus!=value&&(this.hasFocus=value,this.el.classList.toggle("has-focus"),value?(this.setQuery(""),this.field.value=""):this.field.value=this.query)},Search.prototype.setQuery=function(value){this.query=value.trim(),this.updateResults()},Search.prototype.setCurrentResult=function(dir){var current=this.results.querySelector(".current");if(current){var rel=1==dir?current.nextElementSibling:current.previousElementSibling;rel&&(current.classList.remove("current"),rel.classList.add("current"))}else(current=this.results.querySelector(1==dir?"li:first-child":"li:last-child"))&¤t.classList.add("current")},Search.prototype.gotoCurrentResult=function(){var current=this.results.querySelector(".current");if(current||(current=this.results.querySelector("li:first-child")),current){var link=current.querySelector("a");link&&(window.location.href=link.href),this.field.blur()}},Search.prototype.bindEvents=function(){var _this=this;this.results.addEventListener("mousedown",function(){_this.resultClicked=!0}),this.results.addEventListener("mouseup",function(){_this.resultClicked=!1,_this.setHasFocus(!1)}),this.field.addEventListener("focusin",function(){_this.setHasFocus(!0),_this.loadIndex()}),this.field.addEventListener("focusout",function(){_this.resultClicked?_this.resultClicked=!1:setTimeout(function(){return _this.setHasFocus(!1)},100)}),this.field.addEventListener("input",function(){_this.setQuery(_this.field.value)}),this.field.addEventListener("keydown",function(e){13==e.keyCode||27==e.keyCode||38==e.keyCode||40==e.keyCode?(_this.preventPress=!0,e.preventDefault(),13==e.keyCode?_this.gotoCurrentResult():27==e.keyCode?_this.field.blur():38==e.keyCode?_this.setCurrentResult(-1):40==e.keyCode&&_this.setCurrentResult(1)):_this.preventPress=!1}),this.field.addEventListener("keypress",function(e){_this.preventPress&&e.preventDefault()}),document.body.addEventListener("keydown",function(e){e.altKey||e.ctrlKey||e.metaKey||!_this.hasFocus&&47this.groups.length-1&&(index=this.groups.length-1),this.index!=index){var to=this.groups[index];if(-1