├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── LICENSE ├── README.md ├── config ├── eslint │ ├── config.json │ ├── src.json │ └── test.json ├── karma │ └── config-unit.js ├── lint-staged │ └── config.json ├── prettier │ └── config.json ├── rollup │ └── bundle.mjs └── tslint │ └── src.json ├── package-lock.json ├── package.json ├── src ├── error.d.ts ├── factories │ ├── event-target-constructor.ts │ ├── event-target-factory.ts │ ├── rtc-peer-connection-factory.ts │ ├── signaling-factory.ts │ ├── sort-by-hops-and-round-trip-time.ts │ ├── timing-provider-constructor.ts │ ├── update-timing-state-vector.ts │ └── window.ts ├── functions │ ├── compare-hops.ts │ ├── create-backoff.ts │ ├── find-send-peer-to-peer-message-function.ts │ ├── is-boolean-tuple.ts │ ├── is-false-tuple.ts │ ├── is-not-boolean-tuple.ts │ ├── is-peer-to-peer-message-tuple.ts │ ├── is-send-peer-to-peer-message-tuple.ts │ ├── is-true-tuple.ts │ └── wrap-event-listener.ts ├── interfaces │ ├── array-event.ts │ ├── candidate-event.ts │ ├── check-event.ts │ ├── closure-event.ts │ ├── description-event.ts │ ├── error-event.ts │ ├── event-target.ts │ ├── ice-candidate-error-event.ts │ ├── index.ts │ ├── init-event.ts │ ├── notice-event.ts │ ├── ping-event.ts │ ├── pong-event.ts │ ├── request-event.ts │ ├── summary-event.ts │ ├── termination-event.ts │ └── update-event.ts ├── module.ts ├── operators │ ├── combine-as-tuple.ts │ ├── compute-offset-and-round-trip-time.ts │ ├── demultiplex-messages.ts │ ├── echo.ts │ ├── enforce-order.ts │ ├── group-by-property.ts │ ├── ignore-late-result.ts │ ├── maintain-array.ts │ ├── match-pong-with-ping.ts │ ├── negotiate-data-channels.ts │ ├── retry-backoff.ts │ ├── select-most-likely-offset.ts │ ├── send-periodic-pings.ts │ ├── take-until-fatal-value.ts │ └── ultimately.ts ├── tsconfig.json └── types │ ├── data-channel-tuple.ts │ ├── event-handler.ts │ ├── event-target-constructor.ts │ ├── extended-timing-state-vector.ts │ ├── grouped-observable.ts │ ├── incoming-data-channel-event.ts │ ├── incoming-negotiation-event.ts │ ├── incoming-signaling-event.ts │ ├── index.ts │ ├── native-event-target.ts │ ├── outgoing-data-channel-event.ts │ ├── outgoing-signaling-event.ts │ ├── send-peer-to-peer-message-function.ts │ └── timing-provider-constructor.ts └── test └── unit ├── factories ├── sort-by-hops-and-round-trip-time.js └── update-timing-state-vector.js ├── functions └── compare-hops.js ├── operators ├── combine-as-tuple.js ├── compute-offset-and-round-trip-time.js ├── demultiplex-messages.js ├── echo.js ├── enforce-order.js ├── group-by-property.js ├── ignore-late-result.js ├── maintain-array.js ├── match-pong-with-ping.js ├── retry-backoff.js ├── select-most-likely-offset.js ├── send-periodic-pings.js ├── take-until-fatal-value.js └── ultimately.js └── timing-provider-factory.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [20.x] 18 | target: [chrome, firefox, safari] 19 | type: [unit] 20 | max-parallel: 3 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Install Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | 31 | - name: Cache node modules 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.npm 35 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 36 | restore-keys: | 37 | ${{ runner.os }}-node- 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - env: 43 | BROWSER_STACK_ACCESS_KEY: ${{ secrets.BROWSER_STACK_ACCESS_KEY }} 44 | BROWSER_STACK_USERNAME: ${{ secrets.BROWSER_STACK_USERNAME }} 45 | TARGET: ${{ matrix.target }} 46 | TYPE: ${{ matrix.type }} 47 | name: Run ${{ matrix.type }} tests 48 | run: npm test 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | node_modules/ 3 | /build/ 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit --extends @commitlint/config-angular 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged --config config/lint-staged/config.json && npm run lint 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christoph Guttandin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # timing-provider 2 | 3 | **An implementation of the timing provider specification.** 4 | 5 | [![version](https://img.shields.io/npm/v/timing-provider.svg?style=flat-square)](https://www.npmjs.com/package/timing-provider) 6 | 7 | This is an implementation of a TimingProvider as it is defined by the 8 | [Timing Object specification](https://webtiming.github.io/timingobject/). It uses 9 | WebRTC to communicate between the connected clients. 10 | 11 | ## Installation 12 | 13 | This package is available on 14 | [npm](https://www.npmjs.org/package/timing-provider) and can be installed by 15 | running npm's install command. 16 | 17 | ```shell 18 | npm install timing-provider 19 | ``` 20 | 21 | ## Usage 22 | 23 | This package exposes the `TimingProvider` class which can be used to instantiate 24 | a TimingProvider. 25 | 26 | ```js 27 | import { TimingProvider } from 'timing-provider'; 28 | 29 | const timingProvider = new TimingProvider('aSuperSecretClientId'); 30 | ``` 31 | 32 | The only constructor argument the TimingProvider expects is the clientId. This 33 | is unfornately necessary to do the signaling process which establishes the 34 | WebRTC connection. Currently there is no automated way to get a clientId. Please 35 | send a quick email to [info@media-codings.com](mailto:info@media-codings.com) if 36 | you like to have a clientId for your project. 37 | 38 | The TimingProvider can be used with the TimingObject of the 39 | [timing-object package](https://github.com/chrisguttandin/timing-object). 40 | -------------------------------------------------------------------------------- /config/eslint/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": "eslint-config-holy-grail", 6 | "rules": { 7 | "no-sync": "off", 8 | "node/no-missing-require": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /config/eslint/src.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "eslint-config-holy-grail" 6 | } 7 | -------------------------------------------------------------------------------- /config/eslint/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "mocha": true 5 | }, 6 | "extends": "eslint-config-holy-grail", 7 | "globals": { 8 | "expect": "readonly" 9 | }, 10 | "rules": { 11 | "no-unused-expressions": "off", 12 | "node/file-extension-in-import": "off", 13 | "node/no-missing-require": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/karma/config-unit.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const { DefinePlugin } = require('webpack'); 3 | 4 | module.exports = (config) => { 5 | config.set({ 6 | basePath: '../../', 7 | 8 | browserDisconnectTimeout: 100000, 9 | 10 | browserNoActivityTimeout: 100000, 11 | 12 | client: { 13 | mocha: { 14 | bail: true, 15 | timeout: 20000 16 | } 17 | }, 18 | 19 | concurrency: 1, 20 | 21 | files: [ 22 | { 23 | included: false, 24 | pattern: 'src/**', 25 | served: false, 26 | watched: true 27 | }, 28 | 'test/unit/**/*.js' 29 | ], 30 | 31 | frameworks: ['mocha', 'sinon-chai'], 32 | 33 | preprocessors: { 34 | 'test/unit/**/*.js': 'webpack' 35 | }, 36 | 37 | reporters: ['dots'], 38 | 39 | webpack: { 40 | mode: 'development', 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.ts?$/, 45 | use: { 46 | loader: 'ts-loader', 47 | options: { 48 | compilerOptions: { 49 | declaration: false, 50 | declarationMap: false 51 | } 52 | } 53 | } 54 | } 55 | ] 56 | }, 57 | plugins: [ 58 | new DefinePlugin({ 59 | 'process.env': { 60 | CI: JSON.stringify(env.CI) 61 | } 62 | }) 63 | ], 64 | resolve: { 65 | extensions: ['.js', '.ts'], 66 | fallback: { util: false } 67 | } 68 | }, 69 | 70 | webpackMiddleware: { 71 | noInfo: true 72 | } 73 | }); 74 | 75 | if (env.CI) { 76 | config.set({ 77 | browserStack: { 78 | accessKey: env.BROWSER_STACK_ACCESS_KEY, 79 | build: `${env.GITHUB_RUN_ID}/unit-${env.TARGET}`, 80 | forceLocal: true, 81 | localIdentifier: `${Math.floor(Math.random() * 1000000)}`, 82 | project: env.GITHUB_REPOSITORY, 83 | username: env.BROWSER_STACK_USERNAME, 84 | video: false 85 | }, 86 | 87 | browsers: 88 | env.TARGET === 'chrome' 89 | ? ['ChromeBrowserStack'] 90 | : env.TARGET === 'firefox' 91 | ? ['FirefoxBrowserStack'] 92 | : env.TARGET === 'safari' 93 | ? ['SafariBrowserStack'] 94 | : ['ChromeBrowserStack', 'FirefoxBrowserStack', 'SafariBrowserStack'], 95 | 96 | captureTimeout: 300000, 97 | 98 | customLaunchers: { 99 | ChromeBrowserStack: { 100 | base: 'BrowserStack', 101 | browser: 'chrome', 102 | captureTimeout: 300, 103 | os: 'OS X', 104 | os_version: 'Big Sur' // eslint-disable-line camelcase 105 | }, 106 | FirefoxBrowserStack: { 107 | base: 'BrowserStack', 108 | browser: 'firefox', 109 | captureTimeout: 300, 110 | os: 'Windows', 111 | os_version: '10' // eslint-disable-line camelcase 112 | }, 113 | SafariBrowserStack: { 114 | base: 'BrowserStack', 115 | browser: 'safari', 116 | captureTimeout: 300, 117 | os: 'OS X', 118 | os_version: 'Big Sur' // eslint-disable-line camelcase 119 | } 120 | } 121 | }); 122 | } else { 123 | config.set({ 124 | browsers: ['ChromeCanaryHeadless', 'ChromeHeadless', 'FirefoxDeveloperHeadless', 'FirefoxHeadless', 'Safari'] 125 | }); 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /config/lint-staged/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": "prettier --config config/prettier/config.json --ignore-unknown --write" 3 | } 4 | -------------------------------------------------------------------------------- /config/prettier/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 140, 4 | "quoteProps": "consistent", 5 | "singleQuote": true, 6 | "tabWidth": 4, 7 | "trailingComma": "none" 8 | } 9 | -------------------------------------------------------------------------------- /config/rollup/bundle.mjs: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default { 5 | input: 'build/es2019/module.js', 6 | output: { 7 | file: 'build/es5/bundle.js', 8 | format: 'umd', 9 | name: 'timingProvider' 10 | }, 11 | plugins: [ 12 | babel({ 13 | babelHelpers: 'runtime', 14 | exclude: 'node_modules/**', 15 | plugins: ['@babel/plugin-external-helpers', '@babel/plugin-transform-runtime'], 16 | presets: [ 17 | [ 18 | '@babel/preset-env', 19 | { 20 | modules: false 21 | } 22 | ] 23 | ] 24 | }) 25 | ] 26 | }; 27 | -------------------------------------------------------------------------------- /config/tslint/src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-holy-grail" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Christoph Guttandin", 3 | "bugs": { 4 | "url": "https://github.com/chrisguttandin/timing-provider/issues" 5 | }, 6 | "config": { 7 | "commitizen": { 8 | "path": "cz-conventional-changelog" 9 | } 10 | }, 11 | "dependencies": { 12 | "@babel/runtime": "^7.27.6", 13 | "rxjs": "^7.8.2", 14 | "rxjs-etc": "^10.6.2", 15 | "subscribable-things": "^2.1.53", 16 | "timing-object": "^3.1.92", 17 | "tslib": "^2.8.1" 18 | }, 19 | "description": "An implementation of the timing provider specification.", 20 | "devDependencies": { 21 | "@babel/core": "^7.27.4", 22 | "@babel/plugin-external-helpers": "^7.27.1", 23 | "@babel/plugin-transform-runtime": "^7.27.4", 24 | "@babel/preset-env": "^7.27.2", 25 | "@commitlint/cli": "^19.8.1", 26 | "@commitlint/config-angular": "^19.8.1", 27 | "@rollup/plugin-babel": "^6.0.4", 28 | "chai": "^4.3.10", 29 | "commitizen": "^4.3.1", 30 | "cz-conventional-changelog": "^3.3.0", 31 | "eslint": "^8.57.0", 32 | "eslint-config-holy-grail": "^60.0.35", 33 | "husky": "^9.1.7", 34 | "karma": "^6.4.4", 35 | "karma-browserstack-launcher": "^1.6.0", 36 | "karma-chrome-launcher": "^3.2.0", 37 | "karma-firefox-launcher": "^2.1.3", 38 | "karma-mocha": "^2.0.1", 39 | "karma-sinon-chai": "^2.0.2", 40 | "karma-webkit-launcher": "^2.6.0", 41 | "karma-webpack": "^5.0.1", 42 | "lint-staged": "^16.1.2", 43 | "mocha": "^11.6.0", 44 | "prettier": "^3.5.3", 45 | "rimraf": "^6.0.1", 46 | "rollup": "^4.43.0", 47 | "rxjs-marbles": "^7.0.1", 48 | "sinon": "^17.0.2", 49 | "sinon-chai": "^3.7.0", 50 | "ts-loader": "^9.5.2", 51 | "tsconfig-holy-grail": "^15.0.2", 52 | "tslint": "^6.1.3", 53 | "tslint-config-holy-grail": "^56.0.6", 54 | "typescript": "^5.8.3", 55 | "webpack": "^5.99.9" 56 | }, 57 | "files": [ 58 | "build/es2019/", 59 | "build/es5/", 60 | "src/" 61 | ], 62 | "homepage": "https://github.com/chrisguttandin/timing-provider", 63 | "keywords": [ 64 | "Timing Object", 65 | "Timing Provider", 66 | "synchronisation", 67 | "timing", 68 | "timingsrc", 69 | "webtiming" 70 | ], 71 | "license": "MIT", 72 | "main": "build/es5/bundle.js", 73 | "module": "build/es2019/module.js", 74 | "name": "timing-provider", 75 | "repository": { 76 | "type": "git", 77 | "url": "https://github.com/chrisguttandin/timing-provider.git" 78 | }, 79 | "scripts": { 80 | "build": "rimraf build/* && tsc --project src/tsconfig.json && rollup --config config/rollup/bundle.mjs", 81 | "lint": "npm run lint:config && npm run lint:src && npm run lint:test", 82 | "lint:config": "eslint --config config/eslint/config.json --ext .cjs --ext .js --ext .mjs --report-unused-disable-directives config/", 83 | "lint:src": "tslint --config config/tslint/src.json --project src/tsconfig.json src/*.ts src/**/*.ts", 84 | "lint:test": "eslint --config config/eslint/test.json --ext .js --report-unused-disable-directives test/", 85 | "prepare": "husky", 86 | "prepublishOnly": "npm run build", 87 | "test": "npm run lint && npm run build && npm run test:unit", 88 | "test:unit": "if [ \"$TYPE\" = \"\" -o \"$TYPE\" = \"unit\" ]; then karma start config/karma/config-unit.js --single-run; fi" 89 | }, 90 | "types": "build/es2019/module.d.ts", 91 | "version": "7.2.2" 92 | } 93 | -------------------------------------------------------------------------------- /src/error.d.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:interface-name 2 | interface ErrorOptions { 3 | cause?: unknown; 4 | } 5 | 6 | // tslint:disable-next-line:interface-name 7 | interface ErrorConstructor { 8 | // tslint:disable-next-line:callable-types 9 | new (message?: string, options?: ErrorOptions): Error; 10 | } 11 | -------------------------------------------------------------------------------- /src/factories/event-target-constructor.ts: -------------------------------------------------------------------------------- 1 | import type { wrapEventListener as wrapEventListenerFunction } from '../functions/wrap-event-listener'; 2 | import { IEventTarget } from '../interfaces'; 3 | import { TEventHandler, TEventTargetConstructor, TNativeEventTarget } from '../types'; 4 | import type { createEventTargetFactory } from './event-target-factory'; 5 | 6 | export const createEventTargetConstructor = ( 7 | createEventTarget: ReturnType, 8 | wrapEventListener: typeof wrapEventListenerFunction 9 | ): TEventTargetConstructor => { 10 | return class EventTarget> implements IEventTarget { 11 | private _listeners: WeakMap; 12 | 13 | private _nativeEventTarget: TNativeEventTarget; 14 | 15 | constructor() { 16 | this._listeners = new WeakMap(); 17 | this._nativeEventTarget = createEventTarget(); 18 | } 19 | 20 | public addEventListener( 21 | type: string, 22 | listener: null | TEventHandler | EventListenerOrEventListenerObject, 23 | options?: boolean | AddEventListenerOptions 24 | ): void { 25 | if (listener !== null) { 26 | let wrappedEventListener = this._listeners.get(listener); 27 | 28 | if (wrappedEventListener === undefined) { 29 | wrappedEventListener = wrapEventListener(this, listener); 30 | 31 | if (typeof listener === 'function') { 32 | this._listeners.set(listener, wrappedEventListener); 33 | } 34 | } 35 | 36 | this._nativeEventTarget.addEventListener(type, wrappedEventListener, options); 37 | } 38 | } 39 | 40 | public dispatchEvent(event: Event): boolean { 41 | return this._nativeEventTarget.dispatchEvent(event); 42 | } 43 | 44 | public removeEventListener( 45 | type: string, 46 | listener: null | TEventHandler | EventListenerOrEventListenerObject, 47 | options?: boolean | EventListenerOptions 48 | ): void { 49 | const wrappedEventListener = listener === null ? undefined : this._listeners.get(listener); 50 | 51 | this._nativeEventTarget.removeEventListener(type, wrappedEventListener === undefined ? null : wrappedEventListener, options); 52 | } 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/factories/event-target-factory.ts: -------------------------------------------------------------------------------- 1 | import { TNativeEventTarget } from '../types'; 2 | 3 | export const createEventTargetFactory = (window: null | Window): (() => TNativeEventTarget) => { 4 | return () => { 5 | if (window === null) { 6 | throw new Error('A native EventTarget could not be created.'); 7 | } 8 | 9 | return window.document.createElement('p'); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/factories/rtc-peer-connection-factory.ts: -------------------------------------------------------------------------------- 1 | export const createRTCPeerConnectionFactory = (window: null | (Window & typeof globalThis)) => () => { 2 | if (window === null) { 3 | throw new Error('A native EventTarget could not be created.'); 4 | } 5 | 6 | return new window.RTCPeerConnection({ 7 | iceCandidatePoolSize: 1, 8 | iceServers: [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }] 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/factories/signaling-factory.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subject, finalize, map, merge, mergeMap, throwError } from 'rxjs'; 2 | import { on } from 'subscribable-things'; 3 | import { TIncomingSignalingEvent, TOutgoingSignalingEvent } from '../types'; 4 | 5 | export const createSignalingFactory = 6 | (createWebSocket: (url: string) => WebSocket) => 7 | (url: string): readonly [Observable, (event: TOutgoingSignalingEvent) => void] => { 8 | const errorSubject = new Subject(); 9 | const webSocket = createWebSocket(url); 10 | const signalingEvent$ = merge( 11 | on(webSocket, 'message'), 12 | merge( 13 | merge(...['close', 'error'].map((type) => on(webSocket, type))).pipe( 14 | map(({ type }) => new Error(`WebSocket fired unexpected event of type "${type}".`)) 15 | ), 16 | errorSubject 17 | // tslint:disable-next-line:rxjs-throw-error 18 | ).pipe(mergeMap((err) => throwError(() => err))) 19 | ).pipe( 20 | finalize(() => webSocket.close()), 21 | map((event) => JSON.parse(event.data)) 22 | ); 23 | const sendSignalingEvent = (event: TOutgoingSignalingEvent) => { 24 | try { 25 | webSocket.send(JSON.stringify(event)); 26 | } catch (err) { 27 | errorSubject.next(err); 28 | } 29 | }; 30 | 31 | return [signalingEvent$, sendSignalingEvent] as const; 32 | }; 33 | -------------------------------------------------------------------------------- /src/factories/sort-by-hops-and-round-trip-time.ts: -------------------------------------------------------------------------------- 1 | import type { compareHops as compareHopsFunction } from '../functions/compare-hops'; 2 | 3 | export const createSortByHopsAndRoundTripTime = 4 | (compareHops: typeof compareHopsFunction, getHops: (value: Value) => number[], getRoundTripTime: (value: Value) => number) => 5 | (array: Value[]) => { 6 | array.sort((a, b) => { 7 | const result = compareHops(getHops(a), getHops(b)); 8 | 9 | if (result === 0) { 10 | return getRoundTripTime(a) - getRoundTripTime(b); 11 | } 12 | 13 | return result; 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/factories/timing-provider-constructor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BehaviorSubject, 3 | EMPTY, 4 | ReplaySubject, 5 | Subject, 6 | Subscription, 7 | catchError, 8 | concat, 9 | concatMap, 10 | connect, 11 | defer, 12 | distinctUntilChanged, 13 | endWith, 14 | filter, 15 | first, 16 | from, 17 | groupBy, 18 | ignoreElements, 19 | map, 20 | merge, 21 | mergeMap, 22 | of, 23 | scan, 24 | take, 25 | tap, 26 | timer 27 | } from 'rxjs'; 28 | import { equals } from 'rxjs-etc/operators'; 29 | import { online } from 'subscribable-things'; 30 | import { 31 | ITimingProvider, 32 | ITimingProviderEventMap, 33 | ITimingStateVector, 34 | TConnectionState, 35 | TEventHandler, 36 | TTimingStateVectorUpdate 37 | } from 'timing-object'; 38 | import { findSendPeerToPeerMessageFunction } from '../functions/find-send-peer-to-peer-message-function'; 39 | import { isBooleanTuple } from '../functions/is-boolean-tuple'; 40 | import { isFalseTuple } from '../functions/is-false-tuple'; 41 | import { isNotBooleanTuple } from '../functions/is-not-boolean-tuple'; 42 | import { isPeerToPeerMessageTuple } from '../functions/is-peer-to-peer-message-tuple'; 43 | import { isSendPeerToPeerMessageTuple } from '../functions/is-send-peer-to-peer-message-tuple'; 44 | import { isTrueTuple } from '../functions/is-true-tuple'; 45 | import { IClosureEvent, IInitEvent, IPingEvent, IPongEvent, IUpdateEvent } from '../interfaces'; 46 | import { combineAsTuple } from '../operators/combine-as-tuple'; 47 | import { computeOffsetAndRoundTripTime } from '../operators/compute-offset-and-round-trip-time'; 48 | import { demultiplexMessages } from '../operators/demultiplex-messages'; 49 | import { enforceOrder } from '../operators/enforce-order'; 50 | import { groupByProperty } from '../operators/group-by-property'; 51 | import { matchPongWithPing } from '../operators/match-pong-with-ping'; 52 | import { negotiateDataChannels } from '../operators/negotiate-data-channels'; 53 | import { retryBackoff } from '../operators/retry-backoff'; 54 | import { selectMostLikelyOffset } from '../operators/select-most-likely-offset'; 55 | import { sendPeriodicPings } from '../operators/send-periodic-pings'; 56 | import { takeUntilFatalValue } from '../operators/take-until-fatal-value'; 57 | import { 58 | TDataChannelTuple, 59 | TEventTargetConstructor, 60 | TExtendedTimingStateVector, 61 | TSendPeerToPeerMessageFunction, 62 | TTimingProviderConstructor 63 | } from '../types'; 64 | import type { createRTCPeerConnectionFactory } from './rtc-peer-connection-factory'; 65 | import type { createSignalingFactory } from './signaling-factory'; 66 | import type { createSortByHopsAndRoundTripTime } from './sort-by-hops-and-round-trip-time'; 67 | import type { createUpdateTimingStateVector } from './update-timing-state-vector'; 68 | 69 | const SUENC_URL = 'wss://matchmaker.suenc.io'; 70 | const PROVIDER_ID_REGEX = /^[\dA-Za-z]{20}$/; 71 | 72 | export const createTimingProviderConstructor = ( 73 | createRTCPeerConnection: ReturnType, 74 | createSignaling: ReturnType, 75 | eventTargetConstructor: TEventTargetConstructor, 76 | performance: Window['performance'], 77 | setTimeout: Window['setTimeout'], 78 | sortByHopsAndRoundTripTime: ReturnType>, 79 | updateTimingStateVector: ReturnType 80 | ): TTimingProviderConstructor => { 81 | return class TimingProvider extends eventTargetConstructor implements ITimingProvider { 82 | private _clientId: string; 83 | 84 | private _endPosition: number; 85 | 86 | private _error: null | Error; 87 | 88 | private _hops: number[]; 89 | 90 | private _onadjust: null | [TEventHandler, TEventHandler]; 91 | 92 | private _onchange: null | [TEventHandler, TEventHandler]; 93 | 94 | private _onreadystatechange: null | [TEventHandler, TEventHandler]; 95 | 96 | private _origin: number; 97 | 98 | private _providerIdOrUrl: string; 99 | 100 | private _readyState: TConnectionState; 101 | 102 | private _skew: number; 103 | 104 | private _startPosition: number; 105 | 106 | private _subscription: null | Subscription; 107 | 108 | // tslint:disable-next-line:rxjs-no-exposed-subjects 109 | private _updateRequestsSubject: Subject; 110 | 111 | private _vector: ITimingStateVector; 112 | 113 | private _version: number; 114 | 115 | constructor(providerIdOrUrl: string) { 116 | super(); 117 | 118 | const timestamp = performance.now() / 1000; 119 | 120 | this._clientId = ''; 121 | this._endPosition = Number.POSITIVE_INFINITY; 122 | this._error = null; 123 | this._hops = []; 124 | this._onadjust = null; 125 | this._onchange = null; 126 | this._onreadystatechange = null; 127 | this._origin = Number.MAX_SAFE_INTEGER; 128 | this._providerIdOrUrl = providerIdOrUrl; 129 | this._readyState = 'connecting'; 130 | this._skew = 0; 131 | this._startPosition = Number.NEGATIVE_INFINITY; 132 | this._subscription = null; 133 | this._updateRequestsSubject = new Subject(); 134 | this._vector = { acceleration: 0, position: 0, timestamp, velocity: 0 }; 135 | this._version = 0; 136 | 137 | this._createClient(); 138 | } 139 | 140 | get endPosition(): number { 141 | return this._endPosition; 142 | } 143 | 144 | get error(): null | Error { 145 | return this._error; 146 | } 147 | 148 | get onadjust(): null | TEventHandler { 149 | return this._onadjust === null ? this._onadjust : this._onadjust[0]; 150 | } 151 | 152 | set onadjust(value) { 153 | if (this._onadjust !== null) { 154 | this.removeEventListener('adjust', this._onadjust[1]); 155 | } 156 | 157 | if (typeof value === 'function') { 158 | const boundListener = value.bind(this); 159 | 160 | this.addEventListener('adjust', boundListener); 161 | 162 | this._onadjust = [value, boundListener]; 163 | } else { 164 | this._onadjust = null; 165 | } 166 | } 167 | 168 | get onchange(): null | TEventHandler { 169 | return this._onchange === null ? this._onchange : this._onchange[0]; 170 | } 171 | 172 | set onchange(value) { 173 | if (this._onchange !== null) { 174 | this.removeEventListener('change', this._onchange[1]); 175 | } 176 | 177 | if (typeof value === 'function') { 178 | const boundListener = value.bind(this); 179 | 180 | this.addEventListener('change', boundListener); 181 | 182 | this._onchange = [value, boundListener]; 183 | } else { 184 | this._onchange = null; 185 | } 186 | } 187 | 188 | get onreadystatechange(): null | TEventHandler { 189 | return this._onreadystatechange === null ? this._onreadystatechange : this._onreadystatechange[0]; 190 | } 191 | 192 | set onreadystatechange(value) { 193 | if (this._onreadystatechange !== null) { 194 | this.removeEventListener('readystatechange', this._onreadystatechange[1]); 195 | } 196 | 197 | if (typeof value === 'function') { 198 | const boundListener = value.bind(this); 199 | 200 | this.addEventListener('readystatechange', boundListener); 201 | 202 | this._onreadystatechange = [value, boundListener]; 203 | } else { 204 | this._onreadystatechange = null; 205 | } 206 | } 207 | 208 | get readyState(): TConnectionState { 209 | return this._readyState; 210 | } 211 | 212 | get skew(): number { 213 | return this._skew; 214 | } 215 | 216 | get startPosition(): number { 217 | return this._startPosition; 218 | } 219 | 220 | get vector(): ITimingStateVector { 221 | return this._vector; 222 | } 223 | 224 | public destroy(): void { 225 | if (this._subscription === null) { 226 | throw new Error('The timingProvider is already destroyed.'); 227 | } 228 | 229 | this._readyState = 'closed'; 230 | this._subscription.unsubscribe(); 231 | this._subscription = null; 232 | this._updateRequestsSubject.complete(); 233 | 234 | setTimeout(() => this.dispatchEvent(new Event('readystatechange'))); 235 | } 236 | 237 | public update(newVector: TTimingStateVectorUpdate): Promise { 238 | if (this._subscription === null) { 239 | return Promise.reject(new Error("The timingProvider is destroyed and can't be updated.")); 240 | } 241 | 242 | const updatedVector = updateTimingStateVector(this._vector, newVector); 243 | 244 | if (updatedVector !== null) { 245 | this._updateRequestsSubject.next([ 246 | { 247 | ...updatedVector, 248 | hops: [], 249 | version: this._version + 1 250 | }, 251 | null 252 | ]); 253 | } 254 | 255 | return Promise.resolve(); 256 | } 257 | 258 | private _createClient(): void { 259 | const url = PROVIDER_ID_REGEX.test(this._providerIdOrUrl) 260 | ? `${SUENC_URL}?providerId=${this._providerIdOrUrl}` 261 | : this._providerIdOrUrl; 262 | 263 | this._subscription = merge( 264 | concat( 265 | from(online()).pipe(equals(true), first(), ignoreElements()), 266 | defer(() => { 267 | const [signalingEvent$, sendSignalingEvent] = createSignaling(url); 268 | 269 | return signalingEvent$.pipe( 270 | takeUntilFatalValue( 271 | (event): event is IClosureEvent => event.type === 'closure', 272 | () => { 273 | const err = new Error('Your plan has exceeded its quota.'); 274 | 275 | this._error = err; 276 | this._readyState = 'closed'; 277 | this.dispatchEvent(new Event('readystatechange')); 278 | } 279 | ), 280 | enforceOrder((event): event is IInitEvent => event.type === 'init'), 281 | concatMap((event) => { 282 | if (event.type === 'array') { 283 | return from(event.events); 284 | } 285 | 286 | if (event.type === 'init') { 287 | const { 288 | client: { id: clientId }, 289 | events, 290 | origin 291 | } = event; 292 | 293 | this._clientId = clientId; 294 | this._origin = origin; 295 | 296 | if (events.length === 0 && this._readyState === 'connecting') { 297 | this._readyState = 'open'; 298 | this.dispatchEvent(new Event('readystatechange')); 299 | } 300 | 301 | return from(events); 302 | } 303 | 304 | return of(event); 305 | }), 306 | demultiplexMessages(() => this._clientId, timer(10_000)), 307 | negotiateDataChannels(createRTCPeerConnection, sendSignalingEvent) 308 | ); 309 | }) 310 | ).pipe( 311 | retryBackoff(), 312 | catchError((err) => { 313 | this._error = err; 314 | this._readyState = 'closed'; 315 | this.dispatchEvent(new Event('readystatechange')); 316 | 317 | return EMPTY; 318 | }), 319 | tap((dataChannelTuple) => { 320 | if (isSendPeerToPeerMessageTuple(dataChannelTuple) && this._readyState === 'connecting') { 321 | this._readyState = 'open'; 322 | this.dispatchEvent(new Event('readystatechange')); 323 | } 324 | }), 325 | scan< 326 | TDataChannelTuple, 327 | [TDataChannelTuple, [string, true | TSendPeerToPeerMessageFunction][]], 328 | [void, [string, true | TSendPeerToPeerMessageFunction][]] 329 | >( 330 | ([, dataChannelTuples], dataChannelTuple) => { 331 | const index = dataChannelTuples.findIndex(([clientId]) => clientId === dataChannelTuple[0]); 332 | 333 | if (index === -1) { 334 | if (isTrueTuple(dataChannelTuple) || isSendPeerToPeerMessageTuple(dataChannelTuple)) { 335 | dataChannelTuples.push(dataChannelTuple); 336 | } 337 | } else if (isFalseTuple(dataChannelTuple)) { 338 | dataChannelTuples.splice(index, 1); 339 | } else if (isTrueTuple(dataChannelTuple) || isSendPeerToPeerMessageTuple(dataChannelTuple)) { 340 | dataChannelTuples[index] = dataChannelTuple; 341 | } 342 | 343 | return [dataChannelTuple, dataChannelTuples]; 344 | }, 345 | [, []] // tslint:disable-line:no-sparse-arrays 346 | ), 347 | tap(([, dataChannelTuples]) => { 348 | if (dataChannelTuples.length === 0 && this._readyState === 'connecting') { 349 | this._readyState = 'open'; 350 | this.dispatchEvent(new Event('readystatechange')); 351 | } 352 | }), 353 | filter(([dataChannelTuple]) => { 354 | if (isSendPeerToPeerMessageTuple(dataChannelTuple)) { 355 | if ( 356 | !dataChannelTuple[1]({ message: this._createExtendedVector([...this._hops, this._origin]), type: 'update' }) 357 | ) { 358 | return false; 359 | } 360 | } 361 | 362 | return true; 363 | }), 364 | connect((dataChannelTuple$) => { 365 | const dataChannelTuplesSubject = new ReplaySubject<[string, true | TSendPeerToPeerMessageFunction][]>(1); 366 | 367 | return dataChannelTuple$.pipe( 368 | tap(([, dataChannelTuples]) => dataChannelTuplesSubject.next(dataChannelTuples)), 369 | map< 370 | [TDataChannelTuple, TDataChannelTuple[]], 371 | readonly [ 372 | string, 373 | ( 374 | | boolean 375 | | (IPingEvent & { reply: TSendPeerToPeerMessageFunction; timestamp: number }) 376 | | (IPongEvent & { timestamp: number }) 377 | | IUpdateEvent 378 | | TSendPeerToPeerMessageFunction 379 | ) 380 | ] 381 | >(([dataChannelTuple, dataChannelTuples]) => { 382 | if (isPeerToPeerMessageTuple(dataChannelTuple)) { 383 | const [clientId, event] = dataChannelTuple; 384 | 385 | if (event.type === 'ping') { 386 | return [ 387 | clientId, 388 | { 389 | ...event, 390 | reply: findSendPeerToPeerMessageFunction( 391 | clientId, 392 | dataChannelTuples.filter(isSendPeerToPeerMessageTuple) 393 | ) 394 | } 395 | ]; 396 | } 397 | } 398 | 399 | return < 400 | readonly [ 401 | string, 402 | boolean | (IPongEvent & { timestamp: number }) | IUpdateEvent | TSendPeerToPeerMessageFunction 403 | ] 404 | >dataChannelTuple; 405 | }), 406 | filter(isNotBooleanTuple), 407 | groupBy(([clientId]) => clientId, { 408 | duration: (group$) => 409 | dataChannelTuple$.pipe( 410 | map(([dataChannelTuple]) => dataChannelTuple), 411 | filter(isBooleanTuple), 412 | filter(([clientId]) => clientId === group$.key) 413 | ) 414 | }), 415 | mergeMap((messageOrFunctionTuple$) => { 416 | const localSentTimesSubject = new BehaviorSubject<[number, number[]]>([0, []]); 417 | 418 | return messageOrFunctionTuple$.pipe( 419 | connect((observable$) => 420 | merge( 421 | observable$.pipe( 422 | filter(isSendPeerToPeerMessageTuple), 423 | sendPeriodicPings(localSentTimesSubject, () => performance.now()) 424 | ), 425 | observable$.pipe( 426 | filter(isPeerToPeerMessageTuple), 427 | map(([, event]) => event), 428 | groupByProperty('type'), 429 | mergeMap((group$) => { 430 | if (group$.key === 'ping') { 431 | return group$.pipe( 432 | tap(({ index, timestamp, reply }) => 433 | reply({ 434 | index, 435 | remoteReceivedTime: timestamp, 436 | remoteSentTime: performance.now(), 437 | type: 'pong' 438 | }) 439 | ), 440 | ignoreElements() 441 | ); 442 | } 443 | 444 | if (group$.key === 'pong') { 445 | return group$.pipe( 446 | matchPongWithPing(localSentTimesSubject), 447 | computeOffsetAndRoundTripTime(), 448 | selectMostLikelyOffset(), 449 | map((offset) => [1, offset] as const) 450 | ); 451 | } 452 | 453 | return group$.pipe( 454 | map(({ message }) => message), 455 | map((extendedVector) => { 456 | if (this._version > extendedVector.version) { 457 | return null; 458 | } 459 | 460 | if (this._version === extendedVector.version) { 461 | const origin = this._hops.length === 0 ? this._origin : this._hops[0]; 462 | 463 | if ( 464 | origin < extendedVector.hops[0] || 465 | extendedVector.hops.includes(this._origin) 466 | ) { 467 | return null; 468 | } 469 | } 470 | 471 | return extendedVector; 472 | }), 473 | map((extendedVector) => [0, extendedVector] as const) 474 | ); 475 | }), 476 | combineAsTuple(), 477 | distinctUntilChanged( 478 | ([vectorA, [offsetA, roundTripTimeA]], [vectorB, [offsetB, roundTripTimeB]]) => 479 | vectorA === vectorB && offsetA === offsetB && roundTripTimeA === roundTripTimeB 480 | ), 481 | map( 482 | ([vector, [offset, roundTripTime]]) => 483 | [ 484 | messageOrFunctionTuple$.key, 485 | vector === null 486 | ? null 487 | : { ...vector, timestamp: vector.timestamp - offset / 1000 }, 488 | roundTripTime 489 | ] 490 | ), 491 | endWith([messageOrFunctionTuple$.key, null, null]) 492 | ) 493 | ) 494 | ) 495 | ); 496 | }), 497 | scan< 498 | readonly [string, null | TExtendedTimingStateVector, number] | readonly [string, null, null], 499 | [ 500 | readonly [string, null | TExtendedTimingStateVector, number] | readonly [string, null, null], 501 | (readonly [string, null | TExtendedTimingStateVector, number])[] 502 | ], 503 | [void, (readonly [string, null | TExtendedTimingStateVector, number])[]] 504 | >( 505 | ([, tuples], tuple) => { 506 | const index = tuples.findIndex(([clientId]) => tuple[0] === clientId); 507 | 508 | if (tuple[2] === null) { 509 | if (index > -1) { 510 | tuples.splice(index, 1); 511 | } 512 | } else { 513 | if (index > -1) { 514 | tuples[index] = tuple; 515 | } else { 516 | tuples.push(tuple); 517 | tuples.sort(([clientIdA], [clientIdB]) => 518 | clientIdA < clientIdB ? -1 : clientIdA > clientIdB ? 1 : 0 519 | ); 520 | } 521 | } 522 | 523 | return [tuple, tuples]; 524 | }, 525 | [, []] // tslint:disable-line:no-sparse-arrays 526 | ), 527 | mergeMap((tupleAndTuples) => 528 | dataChannelTuplesSubject.pipe( 529 | take(1), 530 | map((dataChannelTuples) => [tupleAndTuples, dataChannelTuples]) 531 | ) 532 | ), 533 | map(([[tuple], dataChannelTuples]) => [...tuple, dataChannelTuples]) 534 | ); 535 | }) 536 | ), 537 | this._updateRequestsSubject.pipe(map(([vector]) => [null, vector, 0, null])) 538 | ) 539 | .pipe( 540 | scan< 541 | | readonly [string, null | TExtendedTimingStateVector, number, TDataChannelTuple[]] 542 | | readonly [string, null, null, TDataChannelTuple[]] 543 | | readonly [null, TExtendedTimingStateVector, number, null], 544 | [[null | string, TExtendedTimingStateVector, number][], TDataChannelTuple[]], 545 | undefined 546 | >( 547 | ( 548 | [tuples, previousDataChannelTuples] = [[[null, this._createExtendedVector(this._hops), 0]], []], 549 | [clientId, extendedVector, roundTripTime, currentDataChannelTuples] 550 | ) => { 551 | const dataChannelTuples = currentDataChannelTuples ?? previousDataChannelTuples; 552 | const index = tuples.findIndex((tuple) => tuple[0] === clientId); 553 | 554 | if (extendedVector !== null) { 555 | if (this._version < extendedVector.version) { 556 | tuples.length = 0; 557 | tuples.push([clientId, extendedVector, roundTripTime]); 558 | 559 | return [tuples, dataChannelTuples]; 560 | } 561 | 562 | if (this._version === extendedVector.version) { 563 | const origin = this._hops.length === 0 ? this._origin : this._hops[0]; 564 | 565 | if (origin > extendedVector.hops[0]) { 566 | if (!extendedVector.hops.includes(this._origin)) { 567 | tuples.length = 0; 568 | tuples.push([clientId, extendedVector, roundTripTime]); 569 | 570 | return [tuples, dataChannelTuples]; 571 | } 572 | } 573 | 574 | if ( 575 | origin === extendedVector.hops[0] && 576 | !extendedVector.hops.includes(this._origin) && 577 | this._hops.length > 0 578 | ) { 579 | if (index > -1) { 580 | tuples[index] = [clientId, extendedVector, roundTripTime]; 581 | } else { 582 | tuples.push([clientId, extendedVector, roundTripTime]); 583 | } 584 | 585 | sortByHopsAndRoundTripTime(tuples); 586 | 587 | return [tuples, dataChannelTuples]; 588 | } 589 | } 590 | } 591 | 592 | if (index > -1) { 593 | if (tuples.length === 1) { 594 | tuples[0] = [ 595 | null, 596 | { 597 | ...tuples[0][1], 598 | hops: [tuples[0][1].hops[0], ...tuples[0][1].hops.map(() => this._origin)] 599 | }, 600 | 0 601 | ]; 602 | } else { 603 | tuples.splice(index, 1); 604 | } 605 | } 606 | 607 | return [tuples, dataChannelTuples]; 608 | }, 609 | undefined 610 | ), 611 | distinctUntilChanged( 612 | (extendedVectorA, extendedVectorB) => extendedVectorA === extendedVectorB, 613 | ([[[, extendedVector]]]) => extendedVector 614 | ) 615 | ) 616 | .subscribe(([[[clientId, extendedVector]], dataChannelTuples]) => { 617 | const externalVector = { ...extendedVector, hops: [...extendedVector.hops, this._origin] }; 618 | 619 | for (const [remoteClientId, send] of dataChannelTuples.filter(isSendPeerToPeerMessageTuple)) { 620 | if (!send({ message: externalVector, type: 'update' }) && clientId === remoteClientId) { 621 | return; 622 | } 623 | } 624 | 625 | this._setInternalVector(extendedVector); 626 | }); 627 | } 628 | 629 | private _createExtendedVector(hops: number[]): TExtendedTimingStateVector { 630 | return { ...this._vector, hops, version: this._version }; 631 | } 632 | 633 | private _setInternalVector({ hops, version, ...vector }: TExtendedTimingStateVector): void { 634 | this._hops = hops; 635 | this._vector = vector; 636 | this._version = version; 637 | 638 | this.dispatchEvent(new CustomEvent('change', { detail: vector })); 639 | } 640 | }; 641 | }; 642 | -------------------------------------------------------------------------------- /src/factories/update-timing-state-vector.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ITimingStateVector, 3 | TTimingStateVectorUpdate, 4 | filterTimingStateVectorUpdate as filterTimingStateVectorUpdateFunction, 5 | translateTimingStateVector as translateTimingStateVectorFunction 6 | } from 'timing-object'; 7 | 8 | export const createUpdateTimingStateVector = ( 9 | filterTimingStateVectorUpdate: typeof filterTimingStateVectorUpdateFunction, 10 | performance: Window['performance'], 11 | translateTimingStateVector: typeof translateTimingStateVectorFunction 12 | ) => { 13 | return (timingStateVector: ITimingStateVector, timingStateVectorUpdate: TTimingStateVectorUpdate) => { 14 | const filteredTimingStateVectorUpdate = filterTimingStateVectorUpdate(timingStateVectorUpdate); 15 | const translatedTimingStateVector = translateTimingStateVector( 16 | timingStateVector, 17 | performance.now() / 1000 - timingStateVector.timestamp 18 | ); 19 | 20 | for (const [key, value] of <[keyof ITimingStateVector, number][]>Object.entries(filteredTimingStateVectorUpdate)) { 21 | if (value !== translatedTimingStateVector[key]) { 22 | return { ...translatedTimingStateVector, ...filteredTimingStateVectorUpdate }; 23 | } 24 | } 25 | 26 | return null; 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/factories/window.ts: -------------------------------------------------------------------------------- 1 | export const createWindow = () => (typeof window === 'undefined' ? null : window); 2 | -------------------------------------------------------------------------------- /src/functions/compare-hops.ts: -------------------------------------------------------------------------------- 1 | export const compareHops = ([originA, ...hopsA]: number[], [originB, ...hopsB]: number[]): number => { 2 | if (originA === undefined || originB === undefined) { 3 | throw new Error('Every vector should have an origin.'); 4 | } 5 | 6 | if (originA === originB) { 7 | const duplicatedHopsA = hopsA.filter((hop) => hop === hopsA[0]).length; 8 | const duplicatedHopsB = hopsB.filter((hop) => hop === hopsB[0]).length; 9 | 10 | if (duplicatedHopsA === duplicatedHopsB) { 11 | if (duplicatedHopsA === 0) { 12 | throw new Error('At least one vector should have a hop if they have the same origin.'); 13 | } 14 | 15 | if (duplicatedHopsA === 1 || hopsA[0] === hopsB[0]) { 16 | if (hopsA.length === hopsB.length) { 17 | if (hopsA.every((hop, index) => hop === hopsB[index])) { 18 | throw new Error('Every vector should be unique.'); 19 | } 20 | 21 | return 0; 22 | } 23 | 24 | return hopsA.length - hopsB.length; 25 | } 26 | 27 | return hopsA[0] - hopsB[0]; 28 | } 29 | 30 | return duplicatedHopsA - duplicatedHopsB; 31 | } 32 | 33 | return originA - originB; 34 | }; 35 | -------------------------------------------------------------------------------- /src/functions/create-backoff.ts: -------------------------------------------------------------------------------- 1 | export const createBackoff = (base: number) => 2 | [ 3 | () => base ** 2, 4 | () => { 5 | // tslint:disable-next-line:no-parameter-reassignment 6 | base += 1; 7 | } 8 | ]; 9 | -------------------------------------------------------------------------------- /src/functions/find-send-peer-to-peer-message-function.ts: -------------------------------------------------------------------------------- 1 | import { TSendPeerToPeerMessageFunction } from '../types'; 2 | 3 | export const findSendPeerToPeerMessageFunction = (key: string, sendPeerToPeerMessageTuples: [string, TSendPeerToPeerMessageFunction][]) => { 4 | const sendPeerToPeerMessageTuple = sendPeerToPeerMessageTuples.find(([clientId]) => clientId === key); 5 | 6 | if (sendPeerToPeerMessageTuple === undefined) { 7 | throw new Error('There is no tuple with the given key.'); 8 | } 9 | 10 | return sendPeerToPeerMessageTuple[1]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/functions/is-boolean-tuple.ts: -------------------------------------------------------------------------------- 1 | export const isBooleanTuple = ( 2 | tuple: readonly [FirstValue, SecondValue] 3 | ): tuple is [FirstValue, SecondValue extends boolean ? SecondValue : never] => typeof tuple[1] === 'boolean'; 4 | -------------------------------------------------------------------------------- /src/functions/is-false-tuple.ts: -------------------------------------------------------------------------------- 1 | export const isFalseTuple = ( 2 | tuple: readonly [FirstValue, SecondValue] 3 | ): tuple is [FirstValue, SecondValue extends false ? SecondValue : never] => tuple[1] === false; 4 | -------------------------------------------------------------------------------- /src/functions/is-not-boolean-tuple.ts: -------------------------------------------------------------------------------- 1 | export const isNotBooleanTuple = ( 2 | tuple: readonly [FirstValue, SecondValue] 3 | ): tuple is [FirstValue, SecondValue extends boolean ? never : SecondValue] => typeof tuple[1] !== 'boolean'; 4 | -------------------------------------------------------------------------------- /src/functions/is-peer-to-peer-message-tuple.ts: -------------------------------------------------------------------------------- 1 | export const isPeerToPeerMessageTuple = ( 2 | tuple: readonly [FirstValue, SecondValue] 3 | ): tuple is [ 4 | FirstValue, 5 | SecondValue extends (...args: any[]) => any ? never : SecondValue extends Record ? SecondValue : never 6 | ] => tuple[1] !== null && typeof tuple[1] === 'object'; 7 | -------------------------------------------------------------------------------- /src/functions/is-send-peer-to-peer-message-tuple.ts: -------------------------------------------------------------------------------- 1 | export const isSendPeerToPeerMessageTuple = ( 2 | tuple: readonly [FirstValue, SecondValue] 3 | ): tuple is [FirstValue, SecondValue extends (...args: any[]) => any ? SecondValue : never] => typeof tuple[1] === 'function'; 4 | -------------------------------------------------------------------------------- /src/functions/is-true-tuple.ts: -------------------------------------------------------------------------------- 1 | export const isTrueTuple = ( 2 | tuple: readonly [FirstValue, SecondValue] 3 | ): tuple is [FirstValue, SecondValue extends true ? SecondValue : never] => tuple[1] === true; 4 | -------------------------------------------------------------------------------- /src/functions/wrap-event-listener.ts: -------------------------------------------------------------------------------- 1 | export const wrapEventListener = (target: T, eventListener: EventListenerOrEventListenerObject): EventListener => { 2 | return (event) => { 3 | const descriptor = { value: target }; 4 | 5 | Object.defineProperties(event, { 6 | currentTarget: descriptor, 7 | target: descriptor 8 | }); 9 | 10 | if (typeof eventListener === 'function') { 11 | return eventListener.call(target, event); 12 | } 13 | 14 | return eventListener.handleEvent.call(target, event); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/interfaces/array-event.ts: -------------------------------------------------------------------------------- 1 | import { INoticeEvent } from './notice-event'; 2 | import { IRequestEvent } from './request-event'; 3 | 4 | export interface IArrayEvent { 5 | events: (INoticeEvent | IRequestEvent)[]; 6 | 7 | type: 'array'; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/candidate-event.ts: -------------------------------------------------------------------------------- 1 | export interface ICandidateEvent extends RTCIceCandidateInit { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | type: 'candidate'; 7 | 8 | version: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/check-event.ts: -------------------------------------------------------------------------------- 1 | export interface ICheckEvent { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | type: 'check'; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/closure-event.ts: -------------------------------------------------------------------------------- 1 | export interface IClosureEvent { 2 | reason: 'quota-exceeded'; 3 | 4 | type: 'closure'; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/description-event.ts: -------------------------------------------------------------------------------- 1 | export interface IDescriptionEvent extends RTCSessionDescriptionInit { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | version: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/error-event.ts: -------------------------------------------------------------------------------- 1 | export interface IErrorEvent { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | type: 'error'; 7 | 8 | version: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/event-target.ts: -------------------------------------------------------------------------------- 1 | import { TNativeEventTarget } from '../types'; 2 | 3 | export interface IEventTarget> extends TNativeEventTarget { 4 | addEventListener( 5 | type: Type, 6 | listener: (this: this, event: EventMap[Type]) => void, 7 | options?: boolean | AddEventListenerOptions 8 | ): void; 9 | addEventListener(type: string, listener: null | EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; 10 | 11 | removeEventListener( 12 | type: Type, 13 | listener: (this: this, event: EventMap[Type]) => void, 14 | options?: boolean | EventListenerOptions 15 | ): void; 16 | removeEventListener(type: string, callback: null | EventListenerOrEventListenerObject, options?: EventListenerOptions | boolean): void; 17 | } 18 | -------------------------------------------------------------------------------- /src/interfaces/ice-candidate-error-event.ts: -------------------------------------------------------------------------------- 1 | export interface IIceCandidateErrorEvent { 2 | address: null | string; 3 | 4 | errorCode: number; 5 | 6 | errorText: string; 7 | 8 | port: null | number; 9 | 10 | type: 'icecandidateerror'; 11 | 12 | url: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './array-event'; 2 | export * from './candidate-event'; 3 | export * from './check-event'; 4 | export * from './closure-event'; 5 | export * from './description-event'; 6 | export * from './error-event'; 7 | export * from './event-target'; 8 | export * from './ice-candidate-error-event'; 9 | export * from './init-event'; 10 | export * from './notice-event'; 11 | export * from './ping-event'; 12 | export * from './pong-event'; 13 | export * from './request-event'; 14 | export * from './summary-event'; 15 | export * from './termination-event'; 16 | export * from './update-event'; 17 | -------------------------------------------------------------------------------- /src/interfaces/init-event.ts: -------------------------------------------------------------------------------- 1 | import { INoticeEvent } from './notice-event'; 2 | import { IRequestEvent } from './request-event'; 3 | 4 | export interface IInitEvent { 5 | client: { 6 | id: string; 7 | }; 8 | 9 | events: (INoticeEvent | IRequestEvent)[]; 10 | 11 | origin: number; 12 | 13 | type: 'init'; 14 | } 15 | -------------------------------------------------------------------------------- /src/interfaces/notice-event.ts: -------------------------------------------------------------------------------- 1 | export interface INoticeEvent { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | type: 'notice'; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/ping-event.ts: -------------------------------------------------------------------------------- 1 | export interface IPingEvent { 2 | index: number; 3 | 4 | type: 'ping'; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/pong-event.ts: -------------------------------------------------------------------------------- 1 | export interface IPongEvent { 2 | index: number; 3 | 4 | remoteReceivedTime: number; 5 | 6 | remoteSentTime: number; 7 | 8 | type: 'pong'; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/request-event.ts: -------------------------------------------------------------------------------- 1 | export interface IRequestEvent { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | label: string; 7 | 8 | type: 'request'; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/summary-event.ts: -------------------------------------------------------------------------------- 1 | export interface ISummaryEvent { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | numberOfGatheredCandidates: number; 7 | 8 | type: 'summary'; 9 | 10 | version: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces/termination-event.ts: -------------------------------------------------------------------------------- 1 | export interface ITerminationEvent { 2 | client: { 3 | id: string; 4 | }; 5 | 6 | type: 'termination'; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/update-event.ts: -------------------------------------------------------------------------------- 1 | import { TExtendedTimingStateVector } from '../types'; 2 | 3 | export interface IUpdateEvent { 4 | message: TExtendedTimingStateVector; 5 | 6 | type: 'update'; 7 | } 8 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { filterTimingStateVectorUpdate, translateTimingStateVector } from 'timing-object'; 2 | import { createEventTargetConstructor } from './factories/event-target-constructor'; 3 | import { createEventTargetFactory } from './factories/event-target-factory'; 4 | import { createRTCPeerConnectionFactory } from './factories/rtc-peer-connection-factory'; 5 | import { createSignalingFactory } from './factories/signaling-factory'; 6 | import { createSortByHopsAndRoundTripTime } from './factories/sort-by-hops-and-round-trip-time'; 7 | import { createTimingProviderConstructor } from './factories/timing-provider-constructor'; 8 | import { createUpdateTimingStateVector } from './factories/update-timing-state-vector'; 9 | import { createWindow } from './factories/window'; 10 | import { compareHops } from './functions/compare-hops'; 11 | import { wrapEventListener } from './functions/wrap-event-listener'; 12 | import { TTimingProviderConstructor } from './types'; 13 | 14 | /* 15 | * @todo Explicitly referencing the barrel file seems to be necessary when enabling the 16 | * isolatedModules compiler option. 17 | */ 18 | export * from './types/index'; 19 | 20 | const window = createWindow(); 21 | const timingProviderConstructor: TTimingProviderConstructor = createTimingProviderConstructor( 22 | createRTCPeerConnectionFactory(window), 23 | createSignalingFactory((url) => new WebSocket(url)), 24 | createEventTargetConstructor(createEventTargetFactory(window), wrapEventListener), 25 | performance, 26 | setTimeout, 27 | createSortByHopsAndRoundTripTime<[unknown, { hops: number[] }, number]>( 28 | compareHops, 29 | ([, { hops }]) => hops, 30 | ([, , roundTripTime]) => roundTripTime 31 | ), 32 | createUpdateTimingStateVector(filterTimingStateVectorUpdate, performance, translateTimingStateVector) 33 | ); 34 | 35 | export { timingProviderConstructor as TimingProvider }; 36 | 37 | // @todo Expose an isSupported flag which checks for fetch and performance.now() support. 38 | -------------------------------------------------------------------------------- /src/operators/combine-as-tuple.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction, filter, scan } from 'rxjs'; 2 | 3 | const INITIAL_VALUE = Symbol(); 4 | 5 | export const combineAsTuple = 6 | (): OperatorFunction< 7 | readonly [0, FirstElement] | readonly [1, SecondElement], 8 | readonly [FirstElement, SecondElement] 9 | > => 10 | (source) => 11 | source.pipe( 12 | scan< 13 | readonly [0, FirstElement] | readonly [1, SecondElement], 14 | readonly [typeof INITIAL_VALUE | FirstElement, typeof INITIAL_VALUE | SecondElement], 15 | readonly [typeof INITIAL_VALUE, typeof INITIAL_VALUE] 16 | >( 17 | (lastValue, [index, value]) => { 18 | if (index === 0) { 19 | return [value, lastValue[1]]; 20 | } 21 | 22 | return [lastValue[0], value]; 23 | }, 24 | [INITIAL_VALUE, INITIAL_VALUE] 25 | ), 26 | filter((tuple): tuple is readonly [FirstElement, SecondElement] => tuple.every((element) => element !== INITIAL_VALUE)) 27 | ); 28 | -------------------------------------------------------------------------------- /src/operators/compute-offset-and-round-trip-time.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction, map } from 'rxjs'; 2 | 3 | /* 4 | * This will compute the offset with the formula `remoteTime - localTime`. That means a positive offset indicates that `remoteTime` is 5 | * larger than `localTime` and viceversa. 6 | */ 7 | export const computeOffsetAndRoundTripTime = (): OperatorFunction => 8 | map(([localSentTime, remoteReceivedTime, remoteSentTime, localReceivedTime]) => [ 9 | (remoteReceivedTime + remoteSentTime - localSentTime - localReceivedTime) / 2, 10 | localReceivedTime - localSentTime + remoteReceivedTime - remoteSentTime 11 | ]); 12 | -------------------------------------------------------------------------------- /src/operators/demultiplex-messages.ts: -------------------------------------------------------------------------------- 1 | import { Observable, OperatorFunction, Subject, Subscription, skip, take } from 'rxjs'; 2 | import { ITerminationEvent } from '../interfaces'; 3 | import { TIncomingNegotiationEvent } from '../types'; 4 | import { ultimately } from './ultimately'; 5 | 6 | export const demultiplexMessages = 7 | ( 8 | getClientId: () => string, 9 | timer: Observable 10 | ): OperatorFunction]> => 11 | (source) => 12 | new Observable<[string, boolean, Observable]>((observer) => { 13 | const subjects = new Map, null] | [null, Subscription]>(); 14 | 15 | const completeAll = () => { 16 | subjects.forEach(([subject, subscription]) => { 17 | if (subject === null) { 18 | subscription.unsubscribe(); 19 | } else { 20 | subject.complete(); 21 | } 22 | }); 23 | }; 24 | 25 | const isActive = (remoteClientId: string) => getClientId() < remoteClientId; 26 | 27 | return source.pipe(ultimately(() => completeAll())).subscribe({ 28 | complete(): void { 29 | observer.complete(); 30 | }, 31 | error(err): void { 32 | observer.error(err); 33 | }, 34 | next(event): void { 35 | const remoteClientId = event.client.id; 36 | const [subject, subscription] = subjects.get(remoteClientId) ?? [null, null]; 37 | 38 | if (event.type === 'termination') { 39 | if (subscription !== null) { 40 | subscription.unsubscribe(); 41 | } 42 | 43 | if (subject !== null) { 44 | subject.complete(); 45 | } 46 | 47 | subjects.set(remoteClientId, [ 48 | null, 49 | timer.pipe(skip(isActive(remoteClientId) ? 1 : 0), take(1)).subscribe(() => subjects.delete(remoteClientId)) // tslint:disable-line:rxjs-no-nested-subscribe 50 | ]); 51 | } else if (subject === null && subscription === null) { 52 | const newSubject = new Subject(); 53 | 54 | subjects.set(remoteClientId, [newSubject, null]); 55 | observer.next([remoteClientId, isActive(remoteClientId), newSubject.asObservable()]); 56 | newSubject.next(event); 57 | } else if (subscription === null) { 58 | subject.next(event); 59 | } 60 | } 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/operators/echo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EMPTY, 3 | MonoTypeOperatorFunction, 4 | Observable, 5 | concat, 6 | dematerialize, 7 | filter, 8 | ignoreElements, 9 | materialize, 10 | of, 11 | startWith, 12 | switchMap, 13 | takeWhile, 14 | tap 15 | } from 'rxjs'; 16 | import { isNotNullish } from 'rxjs-etc'; 17 | 18 | export const echo = 19 | ( 20 | callback: (value: U) => void, 21 | predicate: (value: U, index: number) => boolean, 22 | timer: Observable 23 | ): MonoTypeOperatorFunction => 24 | (source) => 25 | source.pipe( 26 | materialize(), 27 | startWith(null), 28 | switchMap((notification) => 29 | concat( 30 | of(notification), 31 | notification === null || notification.kind === 'N' 32 | ? timer.pipe(takeWhile(predicate), tap(callback), ignoreElements()) 33 | : EMPTY 34 | ) 35 | ), 36 | filter(isNotNullish), 37 | dematerialize() 38 | ); 39 | -------------------------------------------------------------------------------- /src/operators/enforce-order.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, concatMap, from, scan } from 'rxjs'; 2 | 3 | export const enforceOrder = 4 | ( 5 | isFirstValue: (value: FirstValue | SubsequentValue) => value is FirstValue 6 | ): MonoTypeOperatorFunction => 7 | (source) => 8 | source.pipe( 9 | scan( 10 | ([values, bufferedValues], value) => { 11 | if (isFirstValue(value)) { 12 | if (bufferedValues === null) { 13 | throw new Error('Another value has been identified as the first value already.'); 14 | } 15 | 16 | return [[value, ...bufferedValues], null]; 17 | } 18 | 19 | if (bufferedValues === null) { 20 | return [[value], bufferedValues]; 21 | } 22 | 23 | return [values, [...bufferedValues, value]]; 24 | }, 25 | [[], []] 26 | ), 27 | concatMap(([values]) => from(values)) 28 | ); 29 | -------------------------------------------------------------------------------- /src/operators/group-by-property.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction, groupBy } from 'rxjs'; 2 | import { TGroupedObservable } from '../types'; 3 | 4 | export const groupByProperty = ( 5 | property: Property 6 | ): OperatorFunction> => 7 | >>groupBy((value: Value) => value[property]); 8 | -------------------------------------------------------------------------------- /src/operators/ignore-late-result.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export const ignoreLateResult = (promise: Promise) => 4 | new Observable((observer) => { 5 | let isActive = true; 6 | 7 | promise.then( 8 | (value) => { 9 | if (isActive) { 10 | observer.next(value); 11 | observer.complete(); 12 | } 13 | }, 14 | (err) => { 15 | if (isActive) { 16 | observer.error(err); 17 | } 18 | } 19 | ); 20 | 21 | return () => (isActive = false); 22 | }); 23 | -------------------------------------------------------------------------------- /src/operators/maintain-array.ts: -------------------------------------------------------------------------------- 1 | import { scan } from 'rxjs'; 2 | 3 | export const maintainArray = () => 4 | scan<[T, boolean], T[]>((array, [value, isNewValue]) => { 5 | const index = array.indexOf(value); 6 | 7 | if (index > -1) { 8 | if (isNewValue) { 9 | throw new Error('The array does already contain the value to be added.'); 10 | } 11 | 12 | return [...array.slice(0, index), ...array.slice(index + 1)]; 13 | } 14 | 15 | if (!isNewValue) { 16 | throw new Error("The array doesn't contain the value to be removed."); 17 | } 18 | 19 | return [...array, value]; 20 | }, []); 21 | -------------------------------------------------------------------------------- /src/operators/match-pong-with-ping.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction, Subject, filter, map, withLatestFrom } from 'rxjs'; 2 | import { isNotNullish } from 'rxjs-etc'; 3 | import { IPongEvent } from '../interfaces'; 4 | 5 | export const matchPongWithPing = 6 | ( 7 | localSentTimesSubject: Subject<[number, number[]]> 8 | ): OperatorFunction => 9 | (source) => 10 | source.pipe( 11 | withLatestFrom(localSentTimesSubject), 12 | map(([{ index, remoteReceivedTime, remoteSentTime, timestamp }, [startIndex, localSentTimes]]) => { 13 | if (index < startIndex) { 14 | return null; 15 | } 16 | 17 | const numberOfMissingPings = index - startIndex; 18 | const [localSentTime, ...unansweredPings] = localSentTimes.slice(numberOfMissingPings); 19 | 20 | localSentTimesSubject.next([startIndex + numberOfMissingPings + 1, unansweredPings]); 21 | 22 | return [localSentTime, remoteReceivedTime, remoteSentTime, timestamp]; 23 | }), 24 | filter(isNotNullish) 25 | ); 26 | -------------------------------------------------------------------------------- /src/operators/negotiate-data-channels.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EMPTY, 3 | Observable, 4 | OperatorFunction, 5 | Subject, 6 | concat, 7 | count, 8 | defer, 9 | finalize, 10 | from, 11 | ignoreElements, 12 | iif, 13 | interval, 14 | merge, 15 | mergeMap, 16 | of, 17 | retry, 18 | switchMap, 19 | take, 20 | takeUntil, 21 | tap, 22 | throwError, 23 | timer, 24 | zip 25 | } from 'rxjs'; 26 | import { inexorably } from 'rxjs-etc/operators'; 27 | import { on } from 'subscribable-things'; 28 | import { createBackoff } from '../functions/create-backoff'; 29 | import { IErrorEvent, IPingEvent, IPongEvent, IUpdateEvent } from '../interfaces'; 30 | import { TDataChannelTuple, TIncomingNegotiationEvent, TOutgoingSignalingEvent, TSendPeerToPeerMessageFunction } from '../types'; 31 | import { echo } from './echo'; 32 | import { ignoreLateResult } from './ignore-late-result'; 33 | 34 | export const negotiateDataChannels = 35 | ( 36 | createPeerConnection: () => RTCPeerConnection, 37 | sendSignalingEvent: (event: TOutgoingSignalingEvent) => void 38 | ): OperatorFunction<[string, boolean, Observable], TDataChannelTuple> => 39 | (source) => 40 | source.pipe( 41 | mergeMap( 42 | ([clientId, isActive, observable]: [string, boolean, Observable]) => 43 | new Observable((observer) => { 44 | const errorEvents: IErrorEvent[] = []; 45 | const errorSubject = new Subject(); 46 | const receivedCandidates: RTCIceCandidateInit[] = []; 47 | const resetSubject = new Subject(); 48 | const createAndSendOffer = () => { 49 | isFresh = false; 50 | 51 | return ignoreLateResult(peerConnection.setLocalDescription()).pipe( 52 | tap(() => { 53 | const { localDescription } = peerConnection; 54 | 55 | if (localDescription === null) { 56 | throw new Error('The local description is not set.'); 57 | } 58 | 59 | sendSignalingEvent({ 60 | ...jsonifyDescription(localDescription), 61 | client: { id: clientId }, 62 | version 63 | }); 64 | }) 65 | ); 66 | }; 67 | const subscribeToCandidates = () => 68 | on( 69 | peerConnection, 70 | 'icecandidate' 71 | )(({ candidate }) => { 72 | if (candidate === null) { 73 | sendSignalingEvent({ 74 | client: { id: clientId }, 75 | numberOfGatheredCandidates, 76 | type: 'summary', 77 | version 78 | }); 79 | } else if (candidate.port !== 9 && candidate.protocol !== 'tcp') { 80 | sendSignalingEvent({ 81 | ...candidate.toJSON(), 82 | client: { id: clientId }, 83 | type: 'candidate', 84 | version 85 | }); 86 | 87 | numberOfGatheredCandidates += 1; 88 | } 89 | }); 90 | const subscribeToDataChannels = () => { 91 | const subscriptions = [ 92 | zip([on(reliableDataChannel, 'open'), on(unreliableDataChannel, 'open')]) 93 | .pipe(take(1)) 94 | .subscribe(() => { 95 | send = (event) => { 96 | const dataChannel = event.type === 'update' ? reliableDataChannel : unreliableDataChannel; 97 | 98 | try { 99 | dataChannel.send(JSON.stringify(event)); 100 | } catch (err) { 101 | errorSubject.next(err); 102 | 103 | return false; 104 | } 105 | 106 | return true; 107 | }; 108 | 109 | observer.next([clientId, send]); 110 | }), 111 | merge( 112 | ...[reliableDataChannel, unreliableDataChannel] 113 | .map((dataChannel) => ['close', 'closing', 'error'].map((type) => on(dataChannel, type))) 114 | .flat() 115 | ).subscribe(({ type }) => 116 | errorSubject.next(new Error(`RTCDataChannel fired unexpected event of type "${type}".`)) 117 | ) 118 | ]; 119 | 120 | const unsubscribeFunctions = [ 121 | () => subscriptions.forEach((subscription) => subscription.unsubscribe()), 122 | on( 123 | reliableDataChannel, 124 | 'message' 125 | )(({ data }) => { 126 | const event: IUpdateEvent = JSON.parse(data); 127 | 128 | observer.next([clientId, event]); 129 | }), 130 | on( 131 | unreliableDataChannel, 132 | 'message' 133 | )(({ data, timeStamp }) => { 134 | const event: (IPingEvent | IPongEvent) & { timestamp: number } = { 135 | ...JSON.parse(data), 136 | timestamp: timeStamp ?? performance.now() 137 | }; 138 | 139 | observer.next([clientId, event]); 140 | }) 141 | ]; 142 | 143 | return () => unsubscribeFunctions.forEach((unsubscribeFunction) => unsubscribeFunction()); 144 | }; 145 | const [getBackoff, incrementBackoff] = createBackoff(1); 146 | const subscribeToPeerConnection = () => { 147 | const subscription = merge(on(peerConnection, 'icecandidate'), on(peerConnection, 'icegatheringstatechange')) 148 | .pipe( 149 | switchMap(() => 150 | iif( 151 | () => peerConnection.iceGatheringState === 'gathering', 152 | defer(() => timer(10_000 * getBackoff())), 153 | EMPTY 154 | ) 155 | ) 156 | ) 157 | .subscribe(() => { 158 | incrementBackoff(); 159 | errorSubject.next(new Error('RTCPeerConnection seems to be stuck at iceGatheringState "gathering".')); 160 | }); 161 | const unsubscribeFunctions = [ 162 | () => subscription.unsubscribe(), 163 | on( 164 | peerConnection, 165 | 'connectionstatechange' 166 | )(() => { 167 | const connectionState = peerConnection.connectionState; 168 | 169 | if (['closed', 'disconnected', 'failed'].includes(connectionState)) { 170 | errorSubject.next( 171 | new Error(`RTCPeerConnection transitioned to unexpected connectionState "${connectionState}".`) 172 | ); 173 | } 174 | }), 175 | on( 176 | peerConnection, 177 | 'icecandidateerror' 178 | )(({ address, errorCode, errorText, port, url }) => 179 | sendSignalingEvent({ 180 | address, 181 | errorCode, 182 | errorText, 183 | port, 184 | type: 'icecandidateerror', 185 | url 186 | }) 187 | ), 188 | on( 189 | peerConnection, 190 | 'iceconnectionstatechange' 191 | )(() => { 192 | const iceConnectionState = peerConnection.iceConnectionState; 193 | 194 | if (['closed', 'disconnected', 'failed'].includes(iceConnectionState)) { 195 | errorSubject.next( 196 | new Error( 197 | `RTCPeerConnection transitioned to unexpected iceConnectionState "${iceConnectionState}".` 198 | ) 199 | ); 200 | } 201 | }), 202 | on( 203 | peerConnection, 204 | 'signalingstatechange' 205 | )(() => { 206 | if (peerConnection.signalingState === 'closed') { 207 | errorSubject.next( 208 | new Error(`RTCPeerConnection transitioned to unexpected signalingState "closed".`) 209 | ); 210 | } 211 | }) 212 | ]; 213 | 214 | return () => unsubscribeFunctions.forEach((unsubscribeFunction) => unsubscribeFunction()); 215 | }; 216 | const resetState = (newVersion: number) => { 217 | resetSubject.next(null); 218 | 219 | unsubscribeFromCandidates(); 220 | unsubscribeFromDataChannels(); 221 | unsubscribeFromPeerConnection(); 222 | 223 | reliableDataChannel.close(); 224 | unreliableDataChannel.close(); 225 | peerConnection.close(); 226 | 227 | if (send !== null) { 228 | observer.next([clientId, true]); 229 | } 230 | 231 | isFresh = true; 232 | numberOfAppliedCandidates = 0; 233 | numberOfExpectedCandidates = version === newVersion ? numberOfExpectedCandidates : Infinity; 234 | numberOfGatheredCandidates = 0; 235 | peerConnection = createPeerConnection(); 236 | receivedCandidates.length = version === newVersion ? receivedCandidates.length : 0; 237 | reliableDataChannel = peerConnection.createDataChannel('', { id: 0, negotiated: true, ordered: true }); 238 | send = null; 239 | unreliableDataChannel = peerConnection.createDataChannel('', { 240 | id: 1, 241 | maxRetransmits: 0, 242 | negotiated: true, 243 | ordered: false 244 | }); 245 | unsubscribeFromCandidates = subscribeToCandidates(); 246 | unsubscribeFromDataChannels = subscribeToDataChannels(); 247 | unsubscribeFromPeerConnection = subscribeToPeerConnection(); 248 | version = newVersion; 249 | }; 250 | 251 | let isFresh = true; 252 | let numberOfAppliedCandidates = 0; 253 | let numberOfExpectedCandidates = Infinity; 254 | let numberOfGatheredCandidates = 0; 255 | let peerConnection = createPeerConnection(); 256 | let reliableDataChannel = peerConnection.createDataChannel('', { id: 0, negotiated: true, ordered: true }); 257 | let send: null | TSendPeerToPeerMessageFunction = null; 258 | let unrecoverableError: null | Error = null; 259 | let unreliableDataChannel = peerConnection.createDataChannel('', { 260 | id: 1, 261 | maxRetransmits: 0, 262 | negotiated: true, 263 | ordered: false 264 | }); 265 | let unsubscribeFromCandidates = subscribeToCandidates(); 266 | let unsubscribeFromDataChannels = subscribeToDataChannels(); 267 | let unsubscribeFromPeerConnection = subscribeToPeerConnection(); 268 | let version = 0; 269 | 270 | const addFinalCandidate = async (numberOfNewlyAppliedCandidates: number) => { 271 | numberOfAppliedCandidates += numberOfNewlyAppliedCandidates; 272 | 273 | if (numberOfAppliedCandidates === numberOfExpectedCandidates) { 274 | await peerConnection.addIceCandidate(); 275 | } 276 | }; 277 | 278 | const jsonifyDescription = ( 279 | description: RTCSessionDescription | RTCSessionDescriptionInit 280 | ): RTCSessionDescriptionInit => (description instanceof RTCSessionDescription ? description.toJSON() : description); 281 | 282 | const processEvent = (event: TIncomingNegotiationEvent): Observable => { 283 | const { type } = event; 284 | 285 | if (type === 'answer' && isActive) { 286 | if (version > event.version) { 287 | return EMPTY; 288 | } 289 | 290 | if (version === event.version && !isFresh) { 291 | return ignoreLateResult(peerConnection.setRemoteDescription(event)).pipe( 292 | mergeMap(() => from(receivedCandidates)), 293 | mergeMap((receivedCandidate) => 294 | ignoreLateResult(peerConnection.addIceCandidate(receivedCandidate)) 295 | ), 296 | count(), 297 | mergeMap((numberOfNewlyAppliedCandidates) => 298 | ignoreLateResult(addFinalCandidate(numberOfNewlyAppliedCandidates)) 299 | ) 300 | ); 301 | } 302 | } 303 | 304 | if (type === 'candidate') { 305 | if (version > event.version) { 306 | return EMPTY; 307 | } 308 | 309 | if (version < event.version && !isActive) { 310 | resetState(event.version); 311 | } 312 | 313 | if (version === event.version) { 314 | if (peerConnection.remoteDescription === null) { 315 | receivedCandidates.push(event); 316 | 317 | return EMPTY; 318 | } 319 | 320 | return ignoreLateResult(peerConnection.addIceCandidate(event)).pipe( 321 | mergeMap(() => ignoreLateResult(addFinalCandidate(1))) 322 | ); 323 | } 324 | } 325 | 326 | if (type === 'error' && isActive) { 327 | if (version > event.version) { 328 | return EMPTY; 329 | } 330 | 331 | resetState(event.version + 1); 332 | 333 | return createAndSendOffer(); 334 | } 335 | 336 | if (type === 'notice' && !isActive) { 337 | return EMPTY; 338 | } 339 | 340 | if (type === 'offer' && !isActive) { 341 | if (version > event.version) { 342 | return EMPTY; 343 | } 344 | 345 | if (version < event.version) { 346 | resetState(event.version); 347 | } 348 | 349 | isFresh = false; 350 | 351 | return ignoreLateResult(peerConnection.setRemoteDescription(event)).pipe( 352 | mergeMap(() => 353 | merge( 354 | ignoreLateResult(peerConnection.setLocalDescription()).pipe( 355 | tap(() => { 356 | const { localDescription } = peerConnection; 357 | 358 | if (localDescription === null) { 359 | throw new Error('The local description is not set.'); 360 | } 361 | 362 | sendSignalingEvent({ 363 | ...jsonifyDescription(localDescription), 364 | client: { id: clientId }, 365 | version 366 | }); 367 | }) 368 | ), 369 | from(receivedCandidates).pipe( 370 | mergeMap((receivedCandidate) => 371 | ignoreLateResult(peerConnection.addIceCandidate(receivedCandidate)) 372 | ), 373 | count(), 374 | mergeMap((numberOfNewlyAppliedCandidates) => 375 | ignoreLateResult(addFinalCandidate(numberOfNewlyAppliedCandidates)) 376 | ) 377 | ) 378 | ) 379 | ) 380 | ); 381 | } 382 | 383 | if (type === 'request' && isActive) { 384 | if (version === 0 && isFresh) { 385 | return createAndSendOffer(); 386 | } 387 | 388 | return EMPTY; 389 | } 390 | 391 | if (type === 'summary') { 392 | if (version > event.version) { 393 | return EMPTY; 394 | } 395 | 396 | if (version < event.version && !isActive) { 397 | resetState(event.version); 398 | } 399 | 400 | if (version === event.version) { 401 | numberOfExpectedCandidates = event.numberOfGatheredCandidates; 402 | 403 | return ignoreLateResult(addFinalCandidate(0)); 404 | } 405 | } 406 | 407 | unrecoverableError = new Error(`The current event of type "${type}" can't be processed.`); 408 | 409 | // tslint:disable-next-line:rxjs-throw-error 410 | return throwError(() => unrecoverableError); 411 | }; 412 | 413 | observer.next([clientId, true]); 414 | 415 | return merge( 416 | defer(() => from(errorEvents)), 417 | // tslint:disable-next-line:rxjs-throw-error 418 | errorSubject.pipe(mergeMap((err) => throwError(() => err))), 419 | observable.pipe( 420 | echo( 421 | () => 422 | sendSignalingEvent({ 423 | client: { id: clientId }, 424 | type: 'check' 425 | }), 426 | () => reliableDataChannel.readyState !== 'open' || unreliableDataChannel.readyState !== 'open', 427 | interval(5000) 428 | ), 429 | inexorably((notification) => { 430 | if (notification !== undefined) { 431 | errorSubject.complete(); 432 | } 433 | }) 434 | ) 435 | ) 436 | .pipe( 437 | mergeMap((event) => processEvent(event).pipe(takeUntil(resetSubject))), 438 | retry({ 439 | delay: (err) => { 440 | if (err === unrecoverableError) { 441 | // tslint:disable-next-line:rxjs-throw-error 442 | return throwError(() => err); 443 | } 444 | 445 | errorEvents.length = 0; 446 | 447 | if (isFresh) { 448 | resetState(version); 449 | } else { 450 | const errorEvent = { 451 | client: { id: clientId }, 452 | message: err.message, 453 | name: err.name, 454 | type: 'error', 455 | version 456 | }; 457 | 458 | if (isActive) { 459 | errorEvents.push(errorEvent); 460 | } else { 461 | resetState(version + 1); 462 | sendSignalingEvent(errorEvent); 463 | } 464 | } 465 | 466 | return of(null); 467 | } 468 | }), 469 | takeUntil(concat(observable.pipe(ignoreElements()), of(null))), 470 | finalize(() => { 471 | unsubscribeFromCandidates(); 472 | unsubscribeFromDataChannels(); 473 | unsubscribeFromPeerConnection(); 474 | 475 | reliableDataChannel.close(); 476 | unreliableDataChannel.close(); 477 | peerConnection.close(); 478 | }) 479 | ) 480 | .subscribe({ 481 | complete: () => { 482 | observer.next([clientId, false]); 483 | observer.complete(); 484 | }, 485 | error: (err) => observer.error(err) 486 | }); 487 | }) 488 | ) 489 | ); 490 | -------------------------------------------------------------------------------- /src/operators/retry-backoff.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, defer, iif, retry, tap, throwError, timer } from 'rxjs'; 2 | 3 | export const retryBackoff = 4 | (): MonoTypeOperatorFunction => 5 | (source) => 6 | defer(() => { 7 | const attempts = 4; 8 | const interval = 1000; 9 | 10 | let index = 0; 11 | 12 | return source.pipe( 13 | retry({ 14 | delay: (error) => { 15 | index += 1; 16 | 17 | return iif( 18 | () => index < attempts, 19 | timer(interval * index ** 2), 20 | throwError(() => error) // tslint:disable-line:rxjs-throw-error 21 | ); 22 | } 23 | }), 24 | tap(() => (index = 0)) 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /src/operators/select-most-likely-offset.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction, map, scan } from 'rxjs'; 2 | 3 | export const selectMostLikelyOffset = (): OperatorFunction<[number, number], [number, number]> => (source) => 4 | source.pipe( 5 | scan<[number, number], [number, number][]>((tuples, tuple) => [...tuples.slice(-59), tuple], []), 6 | map((tuples) => 7 | tuples 8 | .slice(1) 9 | .reduce( 10 | (tupleWithSmallestRoundTripTime, tuple) => 11 | tupleWithSmallestRoundTripTime[1] < tuple[1] ? tupleWithSmallestRoundTripTime : tuple, 12 | tuples[0] 13 | ) 14 | ) 15 | ); 16 | -------------------------------------------------------------------------------- /src/operators/send-periodic-pings.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY, OperatorFunction, Subject, endWith, ignoreElements, interval, map, startWith, switchAll, tap, withLatestFrom } from 'rxjs'; 2 | import { TSendPeerToPeerMessageFunction } from '../types'; 3 | 4 | export const sendPeriodicPings = 5 | ( 6 | localSentTimesSubject: Subject<[number, number[]]>, 7 | now: () => number 8 | ): OperatorFunction<[string, TSendPeerToPeerMessageFunction], never> => 9 | (source) => 10 | source.pipe( 11 | map(([, send]) => 12 | interval(1000).pipe( 13 | startWith(0), 14 | map((_, index) => { 15 | send({ index, type: 'ping' }); 16 | 17 | return now(); 18 | }), 19 | withLatestFrom(localSentTimesSubject), 20 | tap(([localSentTime, [startIndex, localSentTimes]]) => 21 | localSentTimesSubject.next([startIndex, [...localSentTimes, localSentTime]]) 22 | ), 23 | ignoreElements() 24 | ) 25 | ), 26 | endWith(EMPTY), 27 | switchAll() 28 | ); 29 | -------------------------------------------------------------------------------- /src/operators/take-until-fatal-value.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction, connect, partition, takeUntil, tap } from 'rxjs'; 2 | 3 | export const takeUntilFatalValue = ( 4 | isFatalValue: (value: OtherValue) => value is FatalValue, 5 | handleFatalValue: (value: FatalValue) => unknown 6 | ): OperatorFunction> => 7 | connect((values$) => { 8 | const [fatalEvent$, otherEvent$] = partition(values$, isFatalValue); 9 | 10 | return otherEvent$.pipe(takeUntil(fatalEvent$.pipe(tap(handleFatalValue)))); 11 | }); 12 | -------------------------------------------------------------------------------- /src/operators/ultimately.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, Observable } from 'rxjs'; 2 | 3 | export const ultimately = 4 | (callback: () => void): MonoTypeOperatorFunction => 5 | (source) => 6 | new Observable((observer) => { 7 | const subscription = source.subscribe({ 8 | complete: () => { 9 | callback(); 10 | observer.complete(); 11 | }, 12 | error: (err) => { 13 | callback(); 14 | observer.error(err); 15 | }, 16 | next: (value) => observer.next(value) 17 | }); 18 | 19 | return () => { 20 | callback(); 21 | subscription.unsubscribe(); 22 | }; 23 | }); 24 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true 4 | }, 5 | "extends": "tsconfig-holy-grail/src/tsconfig-browser" 6 | } 7 | -------------------------------------------------------------------------------- /src/types/data-channel-tuple.ts: -------------------------------------------------------------------------------- 1 | import { TIncomingDataChannelEvent } from './incoming-data-channel-event'; 2 | import { TSendPeerToPeerMessageFunction } from './send-peer-to-peer-message-function'; 3 | 4 | export type TDataChannelTuple = readonly [string, boolean | TIncomingDataChannelEvent | TSendPeerToPeerMessageFunction]; 5 | -------------------------------------------------------------------------------- /src/types/event-handler.ts: -------------------------------------------------------------------------------- 1 | export type TEventHandler = (ThisType & { handler(event: U): void })['handler']; 2 | -------------------------------------------------------------------------------- /src/types/event-target-constructor.ts: -------------------------------------------------------------------------------- 1 | import { IEventTarget } from '../interfaces'; 2 | 3 | export type TEventTargetConstructor = new >() => IEventTarget; 4 | -------------------------------------------------------------------------------- /src/types/extended-timing-state-vector.ts: -------------------------------------------------------------------------------- 1 | import type { ITimingStateVector } from 'timing-object'; 2 | 3 | export type TExtendedTimingStateVector = ITimingStateVector & { 4 | readonly hops: number[]; 5 | 6 | readonly version: number; 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/grouped-observable.ts: -------------------------------------------------------------------------------- 1 | import { GroupedObservable } from 'rxjs'; 2 | 3 | export type TGroupedObservable = PropertyValue extends any 4 | ? GroupedObservable>> 5 | : never; 6 | -------------------------------------------------------------------------------- /src/types/incoming-data-channel-event.ts: -------------------------------------------------------------------------------- 1 | import { IPingEvent, IPongEvent, IUpdateEvent } from '../interfaces'; 2 | 3 | export type TIncomingDataChannelEvent = ((IPingEvent | IPongEvent) & { timestamp: number }) | IUpdateEvent; 4 | -------------------------------------------------------------------------------- /src/types/incoming-negotiation-event.ts: -------------------------------------------------------------------------------- 1 | import { ICandidateEvent, IDescriptionEvent, IErrorEvent, INoticeEvent, IRequestEvent, ISummaryEvent } from '../interfaces'; 2 | 3 | export type TIncomingNegotiationEvent = ICandidateEvent | IDescriptionEvent | IErrorEvent | INoticeEvent | IRequestEvent | ISummaryEvent; 4 | -------------------------------------------------------------------------------- /src/types/incoming-signaling-event.ts: -------------------------------------------------------------------------------- 1 | import { IArrayEvent, IClosureEvent, IInitEvent, ITerminationEvent } from '../interfaces'; 2 | import { TIncomingNegotiationEvent } from './incoming-negotiation-event'; 3 | 4 | export type TIncomingSignalingEvent = IArrayEvent | IClosureEvent | TIncomingNegotiationEvent | IInitEvent | ITerminationEvent; 5 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data-channel-tuple'; 2 | export * from './event-handler'; 3 | export * from './event-target-constructor'; 4 | export * from './extended-timing-state-vector'; 5 | export * from './grouped-observable'; 6 | export * from './incoming-data-channel-event'; 7 | export * from './incoming-negotiation-event'; 8 | export * from './incoming-signaling-event'; 9 | export * from './native-event-target'; 10 | export * from './outgoing-data-channel-event'; 11 | export * from './outgoing-signaling-event'; 12 | export * from './send-peer-to-peer-message-function'; 13 | export * from './timing-provider-constructor'; 14 | -------------------------------------------------------------------------------- /src/types/native-event-target.ts: -------------------------------------------------------------------------------- 1 | export type TNativeEventTarget = EventTarget; 2 | -------------------------------------------------------------------------------- /src/types/outgoing-data-channel-event.ts: -------------------------------------------------------------------------------- 1 | import { IPingEvent, IPongEvent, IUpdateEvent } from '../interfaces'; 2 | 3 | export type TOutgoingDataChannelEvent = IPingEvent | IPongEvent | IUpdateEvent; 4 | -------------------------------------------------------------------------------- /src/types/outgoing-signaling-event.ts: -------------------------------------------------------------------------------- 1 | import { ICandidateEvent, ICheckEvent, IDescriptionEvent, IErrorEvent, IIceCandidateErrorEvent, ISummaryEvent } from '../interfaces'; 2 | 3 | export type TOutgoingSignalingEvent = 4 | | ICandidateEvent 5 | | ICheckEvent 6 | | IDescriptionEvent 7 | | IErrorEvent 8 | | IIceCandidateErrorEvent 9 | | ISummaryEvent; 10 | -------------------------------------------------------------------------------- /src/types/send-peer-to-peer-message-function.ts: -------------------------------------------------------------------------------- 1 | import { TOutgoingDataChannelEvent } from './outgoing-data-channel-event'; 2 | 3 | export type TSendPeerToPeerMessageFunction = (event: TOutgoingDataChannelEvent) => boolean; 4 | -------------------------------------------------------------------------------- /src/types/timing-provider-constructor.ts: -------------------------------------------------------------------------------- 1 | import { ITimingProvider } from 'timing-object'; 2 | 3 | export type TTimingProviderConstructor = new (providerId: string) => ITimingProvider; 4 | -------------------------------------------------------------------------------- /test/unit/factories/sort-by-hops-and-round-trip-time.js: -------------------------------------------------------------------------------- 1 | import { createSortByHopsAndRoundTripTime } from '../../../src/factories/sort-by-hops-and-round-trip-time'; 2 | import { stub } from 'sinon'; 3 | 4 | describe('sortByHopsAndRoundTripTime()', () => { 5 | let array; 6 | let compareHops; 7 | let hopsA; 8 | let hopsB; 9 | let sortByHopsAndRoundTripTime; 10 | let valueA; 11 | let valueB; 12 | 13 | beforeEach(() => { 14 | hopsA = ['an', 'array', 'of', 'fake', 'hops']; 15 | hopsB = ['another', 'array', 'of', 'fake', 'hops']; 16 | valueA = [hopsA]; 17 | valueB = [hopsB]; 18 | array = [valueA, valueB]; 19 | compareHops = stub(); 20 | 21 | sortByHopsAndRoundTripTime = createSortByHopsAndRoundTripTime(compareHops, ([hops]) => hops); 22 | 23 | compareHops.callsFake((hops) => (hops === hopsA ? 1 : -1)); 24 | }); 25 | 26 | it('should call compareHops()', () => { 27 | sortByHopsAndRoundTripTime(array); 28 | 29 | try { 30 | expect(compareHops).to.have.been.calledOnceWithExactly(hopsA, hopsB); 31 | } catch { 32 | expect(compareHops).to.have.been.calledOnceWithExactly(hopsB, hopsA); 33 | } 34 | }); 35 | 36 | it('should use compareHops() to sort the given array', () => { 37 | sortByHopsAndRoundTripTime(array); 38 | 39 | expect(array).to.deep.equal([valueB, valueA]); 40 | }); 41 | 42 | it('should return undefined', () => { 43 | expect(sortByHopsAndRoundTripTime(array)).to.be.undefined; 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/unit/factories/update-timing-state-vector.js: -------------------------------------------------------------------------------- 1 | import { createUpdateTimingStateVector } from '../../../src/factories/update-timing-state-vector'; 2 | import { stub } from 'sinon'; 3 | 4 | describe('updateTimingStateVector()', () => { 5 | let filterTimingStateVectorUpdate; 6 | let performance; 7 | let timingStateVector; 8 | let timingStateVectorUpdate; 9 | let translateTimingStateVector; 10 | let translatedTimingStateVector; 11 | let updateTimingStateVector; 12 | 13 | beforeEach(() => { 14 | filterTimingStateVectorUpdate = stub(); 15 | performance = { now: stub() }; 16 | timingStateVector = { acceleration: 0, position: 0, timestamp: 0, velocity: 0 }; 17 | timingStateVectorUpdate = Symbol('timingStateVectorUpdate'); 18 | translateTimingStateVector = stub(); 19 | translatedTimingStateVector = { acceleration: 0, position: 0, timestamp: 10, velocity: 0 }; 20 | 21 | updateTimingStateVector = createUpdateTimingStateVector(filterTimingStateVectorUpdate, performance, translateTimingStateVector); 22 | 23 | filterTimingStateVectorUpdate.returns({}); 24 | performance.now.returns(10000); 25 | translateTimingStateVector.returns(translatedTimingStateVector); 26 | }); 27 | 28 | it('should call filterTimingStateVectorUpdate()', () => { 29 | updateTimingStateVector(timingStateVector, timingStateVectorUpdate); 30 | 31 | expect(filterTimingStateVectorUpdate).to.have.been.calledOnceWithExactly(timingStateVectorUpdate); 32 | }); 33 | 34 | it('should call translateTimingStateVector()', () => { 35 | updateTimingStateVector(timingStateVector, timingStateVectorUpdate); 36 | 37 | expect(translateTimingStateVector).to.have.been.calledOnceWithExactly(timingStateVector, 10); 38 | }); 39 | 40 | describe('without any property', () => { 41 | it('should return null', () => { 42 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.equal(null); 43 | }); 44 | }); 45 | 46 | describe('with the same acceleration', () => { 47 | beforeEach(() => { 48 | filterTimingStateVectorUpdate.returns({ acceleration: 0 }); 49 | }); 50 | 51 | it('should return null', () => { 52 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.equal(null); 53 | }); 54 | }); 55 | 56 | describe('with a new acceleration', () => { 57 | beforeEach(() => { 58 | filterTimingStateVectorUpdate.returns({ acceleration: 1 }); 59 | }); 60 | 61 | it('should return an updated vector', () => { 62 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.deep.equal({ 63 | acceleration: 1, 64 | position: 0, 65 | timestamp: 10, 66 | velocity: 0 67 | }); 68 | }); 69 | }); 70 | 71 | describe('with the same position', () => { 72 | beforeEach(() => { 73 | filterTimingStateVectorUpdate.returns({ position: 0 }); 74 | }); 75 | 76 | it('should return null', () => { 77 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.equal(null); 78 | }); 79 | }); 80 | 81 | describe('with a new position', () => { 82 | beforeEach(() => { 83 | filterTimingStateVectorUpdate.returns({ position: 10 }); 84 | }); 85 | 86 | it('should return an updated vector', () => { 87 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.deep.equal({ 88 | acceleration: 0, 89 | position: 10, 90 | timestamp: 10, 91 | velocity: 0 92 | }); 93 | }); 94 | }); 95 | 96 | describe('with the same velocity', () => { 97 | beforeEach(() => { 98 | filterTimingStateVectorUpdate.returns({ velocity: 0 }); 99 | }); 100 | 101 | it('should return null', () => { 102 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.equal(null); 103 | }); 104 | }); 105 | 106 | describe('with a new velocity', () => { 107 | beforeEach(() => { 108 | filterTimingStateVectorUpdate.returns({ velocity: 1 }); 109 | }); 110 | 111 | it('should return an updated vector', () => { 112 | expect(updateTimingStateVector(timingStateVector, timingStateVectorUpdate)).to.deep.equal({ 113 | acceleration: 0, 114 | position: 0, 115 | timestamp: 10, 116 | velocity: 1 117 | }); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /test/unit/functions/compare-hops.js: -------------------------------------------------------------------------------- 1 | import { compareHops } from '../../../src/functions/compare-hops'; 2 | 3 | describe('compareHops()', () => { 4 | describe('without an origin in the first array', () => { 5 | it('should throw an error', () => { 6 | expect(() => compareHops([], [1])).to.throw(Error, 'Every vector should have an origin.'); 7 | }); 8 | }); 9 | 10 | describe('without an origin in the second array', () => { 11 | it('should throw an error', () => { 12 | expect(() => compareHops([1], [])).to.throw(Error, 'Every vector should have an origin.'); 13 | }); 14 | }); 15 | 16 | describe('with a different origin', () => { 17 | describe('with a lower origin in the first array', () => { 18 | describe('without any hops', () => { 19 | it('should return less than zero', () => { 20 | expect(compareHops([1], [2])).to.be.below(0); 21 | }); 22 | }); 23 | 24 | describe('with less hops in the first array', () => { 25 | it('should return less than zero', () => { 26 | expect(compareHops([1], [2, 3])).to.be.below(0); 27 | }); 28 | }); 29 | 30 | describe('with less hops in the second array', () => { 31 | it('should return less than zero', () => { 32 | expect(compareHops([1, 3], [2])).to.be.below(0); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('with a lower origin in the second array', () => { 38 | describe('without any hops', () => { 39 | it('should return more than zero', () => { 40 | expect(compareHops([2], [1])).to.be.above(0); 41 | }); 42 | }); 43 | 44 | describe('with less hops in the first array', () => { 45 | it('should return more than zero', () => { 46 | expect(compareHops([2], [1, 3])).to.be.above(0); 47 | }); 48 | }); 49 | 50 | describe('with less hops in the second array', () => { 51 | it('should return more than zero', () => { 52 | expect(compareHops([2, 3], [1])).to.be.above(0); 53 | }); 54 | }); 55 | }); 56 | }); 57 | 58 | describe('with the same origin', () => { 59 | describe('without any hops', () => { 60 | it('should throw an error', () => { 61 | expect(() => compareHops([1], [1])).to.throw(Error, 'At least one vector should have a hop if they have the same origin.'); 62 | }); 63 | }); 64 | 65 | describe('with the same number hops', () => { 66 | describe('without any duplicated hops', () => { 67 | it('should return zero', () => { 68 | expect(compareHops([1, 2], [1, 3])).to.equal(0); 69 | }); 70 | }); 71 | 72 | describe('with duplicated hops in the first array', () => { 73 | it('should return more than zero', () => { 74 | expect(compareHops([1, 2, 2], [1, 3, 4])).to.be.above(0); 75 | }); 76 | }); 77 | 78 | describe('with duplicated hops in the second array', () => { 79 | it('should return less than zero', () => { 80 | expect(compareHops([1, 3, 4], [1, 2, 2])).to.be.below(0); 81 | }); 82 | }); 83 | 84 | describe('with duplicated hops in both arrays', () => { 85 | describe('with the same number of duplicated hops', () => { 86 | describe('with a lower duplicated value in the first array', () => { 87 | describe('without any extra hops', () => { 88 | it('should return less than zero', () => { 89 | expect(compareHops([1, 2, 2], [1, 3, 3])).to.be.below(0); 90 | }); 91 | }); 92 | 93 | describe('without one extra hop', () => { 94 | it('should return less than zero', () => { 95 | expect(compareHops([1, 2, 2, 5], [1, 3, 3, 4])).to.be.below(0); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('with a lower duplicated value in the second array', () => { 101 | describe('without any extra hops', () => { 102 | it('should return more than zero', () => { 103 | expect(compareHops([1, 3, 3], [1, 2, 2])).to.be.above(0); 104 | }); 105 | }); 106 | 107 | describe('without one extra hop', () => { 108 | it('should return more than zero', () => { 109 | expect(compareHops([1, 3, 3, 4], [1, 2, 2, 5])).to.be.above(0); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('with the same duplicated value', () => { 115 | describe('without any extra hops', () => { 116 | it('should throw an error', () => { 117 | expect(() => compareHops([1, 2, 2], [1, 2, 2])).to.throw(Error, 'Every vector should be unique.'); 118 | }); 119 | }); 120 | 121 | describe('without one extra hop', () => { 122 | it('should return zero', () => { 123 | expect(compareHops([1, 2, 2, 3], [1, 2, 2, 4])).to.equal(0); 124 | }); 125 | }); 126 | }); 127 | }); 128 | 129 | describe('with less duplicated hops in the first array', () => { 130 | it('should return less than zero', () => { 131 | expect(compareHops([1, 3, 3, 4], [1, 2, 2, 2])).to.be.below(0); 132 | }); 133 | }); 134 | 135 | describe('with less duplicated hops in the second array', () => { 136 | it('should return more than zero', () => { 137 | expect(compareHops([1, 2, 2, 2], [1, 3, 3, 4])).to.be.above(0); 138 | }); 139 | }); 140 | }); 141 | }); 142 | 143 | describe('with less hops in the first array', () => { 144 | describe('without any hops in the first array', () => { 145 | it('should return less than zero', () => { 146 | expect(compareHops([1], [1, 2])).to.be.below(0); 147 | }); 148 | }); 149 | 150 | describe('without any duplicated hops', () => { 151 | it('should return less than zero', () => { 152 | expect(compareHops([1, 2], [1, 3, 4])).to.be.below(0); 153 | }); 154 | }); 155 | 156 | describe('with duplicated hops in the first array', () => { 157 | it('should return more than zero', () => { 158 | expect(compareHops([1, 2, 2], [1, 3, 4, 5])).to.be.above(0); 159 | }); 160 | }); 161 | 162 | describe('with duplicated hops in the second array', () => { 163 | it('should return less than zero', () => { 164 | expect(compareHops([1, 3], [1, 2, 2])).to.be.below(0); 165 | }); 166 | }); 167 | 168 | describe('with duplicated hops in both arrays', () => { 169 | describe('with a lower duplicated value in the first array', () => { 170 | it('should return less than zero', () => { 171 | expect(compareHops([1, 2, 2], [1, 3, 3, 4])).to.be.below(0); 172 | }); 173 | }); 174 | 175 | describe('with a lower duplicated value in the second array', () => { 176 | it('should return more than zero', () => { 177 | expect(compareHops([1, 3, 3], [1, 2, 2, 4])).to.be.above(0); 178 | }); 179 | }); 180 | 181 | describe('with the same duplicated value', () => { 182 | it('should return less than zero', () => { 183 | expect(compareHops([1, 2, 2], [1, 2, 2, 3])).to.be.below(0); 184 | expect(compareHops([1, 2, 2, 5], [1, 2, 2, 3, 4])).to.be.below(0); 185 | }); 186 | }); 187 | }); 188 | }); 189 | 190 | describe('with less hops in the second array', () => { 191 | describe('without any in the second array', () => { 192 | it('should return more than zero', () => { 193 | expect(compareHops([1, 2], [1])).to.be.above(0); 194 | }); 195 | }); 196 | 197 | describe('without any duplicated hops', () => { 198 | it('should return more than zero', () => { 199 | expect(compareHops([1, 2, 3], [1, 4])).to.be.above(0); 200 | }); 201 | }); 202 | 203 | describe('with duplicated hops in the first array', () => { 204 | it('should return more than zero', () => { 205 | expect(compareHops([1, 2, 2], [1, 3])).to.be.above(0); 206 | }); 207 | }); 208 | 209 | describe('with duplicated hops in the second array', () => { 210 | it('should return less than zero', () => { 211 | expect(compareHops([1, 3, 4, 5], [1, 2, 2])).to.be.below(0); 212 | }); 213 | }); 214 | 215 | describe('with duplicated hops in both arrays', () => { 216 | describe('with a lower duplicated value in the first array', () => { 217 | it('should return less than zero', () => { 218 | expect(compareHops([1, 2, 2, 4], [1, 3, 3])).to.be.below(0); 219 | }); 220 | }); 221 | 222 | describe('with a lower duplicated value in the second array', () => { 223 | it('should return more than zero', () => { 224 | expect(compareHops([1, 3, 3, 4], [1, 2, 2])).to.be.above(0); 225 | }); 226 | }); 227 | 228 | describe('with the same duplicated value', () => { 229 | it('should return more than zero', () => { 230 | expect(compareHops([1, 2, 2, 3], [1, 2, 2])).to.be.above(0); 231 | expect(compareHops([1, 2, 2, 3, 4], [1, 2, 2, 5])).to.be.above(0); 232 | }); 233 | }); 234 | }); 235 | }); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /test/unit/operators/combine-as-tuple.js: -------------------------------------------------------------------------------- 1 | import { combineAsTuple } from '../../../src/operators/combine-as-tuple'; 2 | import { marbles } from 'rxjs-marbles'; 3 | 4 | describe('combineAsTuple', () => { 5 | describe('without any value', () => { 6 | it( 7 | 'should mirror an empty observable', 8 | marbles((helpers) => { 9 | const destination = helpers.cold('|').pipe(combineAsTuple()); 10 | const expected = helpers.cold('(|)'); 11 | 12 | helpers.expect(destination).toBeObservable(expected); 13 | }) 14 | ); 15 | }); 16 | 17 | describe('with an error', () => { 18 | it( 19 | 'should mirror an error observable', 20 | marbles((helpers) => { 21 | const err = new Error('a fake error'); 22 | const destination = helpers.cold('#', null, err).pipe(combineAsTuple()); 23 | const expected = helpers.cold('(#)', null, err); 24 | 25 | helpers.expect(destination).toBeObservable(expected); 26 | }) 27 | ); 28 | }); 29 | 30 | describe('with a single value to replace the first element', () => { 31 | it( 32 | 'should emit an updated tuple', 33 | marbles((helpers) => { 34 | const firstElement = Symbol('firstElement'); 35 | const destination = helpers.cold('a---|', { a: [0, firstElement] }).pipe(combineAsTuple()); 36 | const expected = helpers.cold('----|'); 37 | 38 | helpers.expect(destination).toBeObservable(expected); 39 | }) 40 | ); 41 | }); 42 | 43 | describe('with a single value to replace the second element', () => { 44 | it( 45 | 'should emit an updated tuple', 46 | marbles((helpers) => { 47 | const secondElement = Symbol('secondElement'); 48 | const destination = helpers.cold('a---|', { a: [1, secondElement] }).pipe(combineAsTuple()); 49 | const expected = helpers.cold('----|'); 50 | 51 | helpers.expect(destination).toBeObservable(expected); 52 | }) 53 | ); 54 | }); 55 | 56 | describe('with a single value to replace the first element and the second element', () => { 57 | it( 58 | 'should emit an updated tuple', 59 | marbles((helpers) => { 60 | const firstElement = Symbol('firstElement'); 61 | const secondElement = Symbol('secondElement'); 62 | const destination = helpers.cold('a---b|', { a: [0, firstElement], b: [1, secondElement] }).pipe(combineAsTuple()); 63 | const expected = helpers.cold('----a|', { 64 | a: [firstElement, secondElement] 65 | }); 66 | 67 | helpers.expect(destination).toBeObservable(expected); 68 | }) 69 | ); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/unit/operators/compute-offset-and-round-trip-time.js: -------------------------------------------------------------------------------- 1 | import { computeOffsetAndRoundTripTime } from '../../../src/operators/compute-offset-and-round-trip-time'; 2 | import { marbles } from 'rxjs-marbles'; 3 | 4 | describe('computeOffsetAndRoundTripTime', () => { 5 | let localReceivedTime; 6 | let localSentTime; 7 | let remoteReceivedTime; 8 | let remoteSentTime; 9 | 10 | beforeEach(() => { 11 | localReceivedTime = 4; 12 | localSentTime = 1; 13 | remoteReceivedTime = 12; 14 | remoteSentTime = 13; 15 | }); 16 | 17 | it( 18 | 'should compute the expected offset', 19 | marbles((helpers) => { 20 | const destination = helpers 21 | .cold('a|', { a: [localSentTime, remoteReceivedTime, remoteSentTime, localReceivedTime] }) 22 | .pipe(computeOffsetAndRoundTripTime()); 23 | const expected = helpers.cold('a|', { a: [10, 2] }); 24 | 25 | helpers.expect(destination).toBeObservable(expected); 26 | }) 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /test/unit/operators/demultiplex-messages.js: -------------------------------------------------------------------------------- 1 | import { EMPTY, NEVER, Subject, concat, from, interval, mergeMap, of, takeUntil } from 'rxjs'; 2 | import { demultiplexMessages } from '../../../src/operators/demultiplex-messages'; 3 | import { marbles } from 'rxjs-marbles'; 4 | 5 | describe('demultiplexMessages', () => { 6 | let clientId; 7 | let getClientId; 8 | 9 | beforeEach(() => { 10 | clientId = 'a fake client id'; 11 | getClientId = () => clientId; 12 | }); 13 | 14 | describe('without any event', () => { 15 | it( 16 | 'should mirror an empty observable', 17 | marbles((helpers) => { 18 | const timer = helpers.cold('a|'); 19 | const destination = helpers.cold('|').pipe(demultiplexMessages(getClientId, timer)); 20 | const expected = helpers.cold('|'); 21 | 22 | helpers.expect(destination).toBeObservable(expected); 23 | }) 24 | ); 25 | }); 26 | 27 | describe('with an error', () => { 28 | it( 29 | 'should mirror an error observable', 30 | marbles((helpers) => { 31 | const err = new Error('a fake error'); 32 | const timer = helpers.cold('a|'); 33 | const destination = helpers.cold('#', null, err).pipe(demultiplexMessages(getClientId, timer)); 34 | const expected = helpers.cold('#', null, err); 35 | 36 | helpers.expect(destination).toBeObservable(expected); 37 | }) 38 | ); 39 | }); 40 | 41 | describe('with a single request event', () => { 42 | let event; 43 | 44 | beforeEach( 45 | () => 46 | (event = { 47 | client: { 48 | id: clientId 49 | }, 50 | type: 'request' 51 | }) 52 | ); 53 | 54 | it( 55 | 'should emit an observable with the client id and an observable', 56 | marbles((helpers) => { 57 | const timer = helpers.cold('a|'); 58 | const destination = helpers.cold('a|', { a: event }).pipe(demultiplexMessages(getClientId, timer)); 59 | const subject = new Subject(); 60 | const expected = helpers.cold('a|', { a: [clientId, false, subject.asObservable()] }); 61 | 62 | subject.next(event); 63 | subject.complete(); 64 | 65 | helpers.expect(destination).toBeObservable(expected); 66 | }) 67 | ); 68 | 69 | it( 70 | 'should emit an observable with an observable that emits the event', 71 | marbles((helpers) => { 72 | const timer = helpers.cold('a|'); 73 | const destination = helpers.cold('a|', { a: event }).pipe( 74 | demultiplexMessages(getClientId, timer), 75 | mergeMap(([, , observable]) => observable) 76 | ); 77 | const expected = helpers.hot('a|', { a: event }); 78 | 79 | helpers.expect(destination).toBeObservable(expected); 80 | }) 81 | ); 82 | 83 | it('should complete the observable when unsubscribing', (done) => { 84 | concat(of(event), NEVER) 85 | .pipe(demultiplexMessages(getClientId, EMPTY), takeUntil(interval(1))) 86 | .subscribe({ 87 | next: ([, , observable]) => { 88 | observable.subscribe({ 89 | complete: done 90 | }); 91 | } 92 | }); 93 | }); 94 | 95 | describe('with a subsequent termination event', () => { 96 | it('should complete the observable', (done) => { 97 | concat( 98 | from([ 99 | event, 100 | { 101 | client: { 102 | id: clientId 103 | }, 104 | type: 'termination' 105 | } 106 | ]), 107 | NEVER 108 | ) 109 | .pipe(demultiplexMessages(getClientId, EMPTY)) 110 | .subscribe({ 111 | next: ([, , observable]) => { 112 | observable.subscribe({ 113 | complete: done 114 | }); 115 | } 116 | }); 117 | }); 118 | }); 119 | }); 120 | 121 | describe('with a single candidate event', () => { 122 | let event; 123 | 124 | beforeEach( 125 | () => 126 | (event = { 127 | client: { 128 | id: clientId 129 | }, 130 | type: 'candidate', 131 | version: 17 132 | }) 133 | ); 134 | 135 | it( 136 | 'should emit an observable with the client id and an observable', 137 | marbles((helpers) => { 138 | const timer = helpers.cold('a|'); 139 | const destination = helpers.cold('a|', { a: event }).pipe(demultiplexMessages(getClientId, timer)); 140 | const subject = new Subject(); 141 | const expected = helpers.cold('a|', { a: [clientId, false, subject.asObservable()] }); 142 | 143 | subject.next(event); 144 | subject.complete(); 145 | 146 | helpers.expect(destination).toBeObservable(expected); 147 | }) 148 | ); 149 | 150 | it( 151 | 'should emit an observable with an observable that emits the event', 152 | marbles((helpers) => { 153 | const timer = helpers.cold('a|'); 154 | const destination = helpers.cold('a|', { a: event }).pipe( 155 | demultiplexMessages(getClientId, timer), 156 | mergeMap(([, , observable]) => observable) 157 | ); 158 | const expected = helpers.hot('a|', { a: event }); 159 | 160 | helpers.expect(destination).toBeObservable(expected); 161 | }) 162 | ); 163 | 164 | it('should complete the observable when unsubscribing', (done) => { 165 | concat(of(event), NEVER) 166 | .pipe(demultiplexMessages(getClientId, EMPTY), takeUntil(interval(1))) 167 | .subscribe({ 168 | next: ([, , observable]) => { 169 | observable.subscribe({ 170 | complete: done 171 | }); 172 | } 173 | }); 174 | }); 175 | 176 | describe('with a subsequent termination event', () => { 177 | it('should complete the observable', (done) => { 178 | concat( 179 | from([ 180 | event, 181 | { 182 | client: { 183 | id: clientId 184 | }, 185 | type: 'termination' 186 | } 187 | ]), 188 | NEVER 189 | ) 190 | .pipe(demultiplexMessages(getClientId, EMPTY)) 191 | .subscribe({ 192 | next: ([, , observable]) => { 193 | observable.subscribe({ 194 | complete: done 195 | }); 196 | } 197 | }); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('with a single description event', () => { 203 | let event; 204 | 205 | beforeEach( 206 | () => 207 | (event = { 208 | client: { 209 | id: clientId 210 | }, 211 | type: 'description', 212 | version: 18 213 | }) 214 | ); 215 | 216 | it( 217 | 'should emit an observable with the client id and an observable', 218 | marbles((helpers) => { 219 | const timer = helpers.cold('a|'); 220 | const destination = helpers.cold('a|', { a: event }).pipe(demultiplexMessages(getClientId, timer)); 221 | const subject = new Subject(); 222 | const expected = helpers.cold('a|', { a: [clientId, false, subject.asObservable()] }); 223 | 224 | subject.next(event); 225 | subject.complete(); 226 | 227 | helpers.expect(destination).toBeObservable(expected); 228 | }) 229 | ); 230 | 231 | it( 232 | 'should emit an observable with an observable that emits the event', 233 | marbles((helpers) => { 234 | const timer = helpers.cold('a|'); 235 | const destination = helpers.cold('a|', { a: event }).pipe( 236 | demultiplexMessages(getClientId, timer), 237 | mergeMap(([, , observable]) => observable) 238 | ); 239 | const expected = helpers.hot('a|', { a: event }); 240 | 241 | helpers.expect(destination).toBeObservable(expected); 242 | }) 243 | ); 244 | 245 | it('should complete the observable when unsubscribing', (done) => { 246 | concat(of(event), NEVER) 247 | .pipe(demultiplexMessages(getClientId, EMPTY), takeUntil(interval(1))) 248 | .subscribe({ 249 | next: ([, , observable]) => { 250 | observable.subscribe({ 251 | complete: done 252 | }); 253 | } 254 | }); 255 | }); 256 | 257 | describe('with a subsequent termination event', () => { 258 | it('should complete the observable', (done) => { 259 | concat( 260 | from([ 261 | event, 262 | { 263 | client: { 264 | id: clientId 265 | }, 266 | type: 'termination' 267 | } 268 | ]), 269 | NEVER 270 | ) 271 | .pipe(demultiplexMessages(getClientId, EMPTY)) 272 | .subscribe({ 273 | next: ([, , observable]) => { 274 | observable.subscribe({ 275 | complete: done 276 | }); 277 | } 278 | }); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('with a single summary event', () => { 284 | let event; 285 | 286 | beforeEach( 287 | () => 288 | (event = { 289 | client: { 290 | id: clientId 291 | }, 292 | type: 'summary', 293 | version: 19 294 | }) 295 | ); 296 | 297 | it( 298 | 'should emit an observable with the client id and an observable', 299 | marbles((helpers) => { 300 | const timer = helpers.cold('a|'); 301 | const destination = helpers.cold('a|', { a: event }).pipe(demultiplexMessages(getClientId, timer)); 302 | const subject = new Subject(); 303 | const expected = helpers.cold('a|', { a: [clientId, false, subject.asObservable()] }); 304 | 305 | subject.next(event); 306 | subject.complete(); 307 | 308 | helpers.expect(destination).toBeObservable(expected); 309 | }) 310 | ); 311 | 312 | it( 313 | 'should emit an observable with an observable that emits the event', 314 | marbles((helpers) => { 315 | const timer = helpers.cold('a|'); 316 | const destination = helpers.cold('a|', { a: event }).pipe( 317 | demultiplexMessages(getClientId, timer), 318 | mergeMap(([, , observable]) => observable) 319 | ); 320 | const expected = helpers.hot('a|', { a: event }); 321 | 322 | helpers.expect(destination).toBeObservable(expected); 323 | }) 324 | ); 325 | 326 | it('should complete the observable when unsubscribing', (done) => { 327 | concat(of(event), NEVER) 328 | .pipe(demultiplexMessages(getClientId, EMPTY), takeUntil(interval(1))) 329 | .subscribe({ 330 | next: ([, , observable]) => { 331 | observable.subscribe({ 332 | complete: done 333 | }); 334 | } 335 | }); 336 | }); 337 | 338 | describe('with a subsequent termination event', () => { 339 | it('should complete the observable', (done) => { 340 | concat( 341 | from([ 342 | event, 343 | { 344 | client: { 345 | id: clientId 346 | }, 347 | type: 'termination' 348 | } 349 | ]), 350 | NEVER 351 | ) 352 | .pipe(demultiplexMessages(getClientId, EMPTY)) 353 | .subscribe({ 354 | next: ([, , observable]) => { 355 | observable.subscribe({ 356 | complete: done 357 | }); 358 | } 359 | }); 360 | }); 361 | }); 362 | }); 363 | 364 | describe('with a single termination event', () => { 365 | let event; 366 | 367 | beforeEach( 368 | () => 369 | (event = { 370 | client: { 371 | id: clientId 372 | }, 373 | type: 'termination' 374 | }) 375 | ); 376 | 377 | it( 378 | 'should not emit any observable', 379 | marbles((helpers) => { 380 | const timer = helpers.cold('a|'); 381 | const destination = helpers.cold('a|', { a: event }).pipe(demultiplexMessages(getClientId, timer)); 382 | const expected = helpers.cold('-|'); 383 | 384 | helpers.expect(destination).toBeObservable(expected); 385 | }) 386 | ); 387 | }); 388 | }); 389 | -------------------------------------------------------------------------------- /test/unit/operators/echo.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from 'sinon'; 2 | import { echo } from '../../../src/operators/echo'; 3 | import { marbles } from 'rxjs-marbles'; 4 | 5 | describe('echo', () => { 6 | let callback; 7 | let predicate; 8 | 9 | beforeEach(() => { 10 | callback = spy(); 11 | predicate = stub(); 12 | }); 13 | 14 | describe('without any value', () => { 15 | describe('with a timer that emits immediately', () => { 16 | let createTimer; 17 | 18 | beforeEach(() => (createTimer = (helpers) => helpers.cold('a|', { a: 0 }))); 19 | 20 | it( 21 | 'should mirror an empty observable', 22 | marbles((helpers) => { 23 | const timer = createTimer(helpers); 24 | const destination = helpers.cold('|').pipe(echo(callback, predicate, timer)); 25 | const expected = helpers.cold('|'); 26 | 27 | helpers.expect(destination).toBeObservable(expected); 28 | }) 29 | ); 30 | 31 | it( 32 | 'should call the predicate', 33 | marbles((helpers) => { 34 | const timer = createTimer(helpers); 35 | 36 | helpers 37 | .cold('|') 38 | .pipe(echo(callback, predicate, timer)) 39 | .subscribe({ 40 | complete: () => { 41 | expect(predicate).to.have.been.calledOnceWithExactly(0, 0); 42 | } 43 | }); 44 | }) 45 | ); 46 | 47 | describe('with a predicate that returns true', () => { 48 | beforeEach(() => predicate.returns(true)); 49 | 50 | it( 51 | 'should call the callback', 52 | marbles((helpers) => { 53 | const timer = createTimer(helpers); 54 | 55 | helpers 56 | .cold('|') 57 | .pipe(echo(callback, predicate, timer)) 58 | .subscribe({ 59 | complete: () => { 60 | expect(callback).to.have.been.calledOnceWithExactly(0); 61 | } 62 | }); 63 | }) 64 | ); 65 | }); 66 | 67 | describe('with a predicate that returns false', () => { 68 | beforeEach(() => predicate.returns(false)); 69 | 70 | it( 71 | 'should not call the callback', 72 | marbles((helpers) => { 73 | const timer = createTimer(helpers); 74 | 75 | helpers 76 | .cold('|') 77 | .pipe(echo(callback, predicate, timer)) 78 | .subscribe({ 79 | complete: () => { 80 | expect(callback).to.have.not.been.called; 81 | } 82 | }); 83 | }) 84 | ); 85 | }); 86 | }); 87 | 88 | describe('with a timer that delays the emission', () => { 89 | let createTimer; 90 | 91 | beforeEach(() => (createTimer = (helpers) => helpers.cold('-a|', { a: 0 }))); 92 | 93 | it( 94 | 'should mirror an empty observable', 95 | marbles((helpers) => { 96 | const timer = createTimer(helpers); 97 | const destination = helpers.cold('|').pipe(echo(callback, predicate, timer)); 98 | const expected = helpers.cold('|'); 99 | 100 | helpers.expect(destination).toBeObservable(expected); 101 | }) 102 | ); 103 | 104 | it( 105 | 'should not call the predicate', 106 | marbles((helpers) => { 107 | const timer = createTimer(helpers); 108 | 109 | helpers 110 | .cold('|') 111 | .pipe(echo(callback, predicate, timer)) 112 | .subscribe({ 113 | complete: () => { 114 | expect(predicate).to.have.not.been.called; 115 | } 116 | }); 117 | }) 118 | ); 119 | 120 | it( 121 | 'should not call the callback', 122 | marbles((helpers) => { 123 | const timer = createTimer(helpers); 124 | 125 | helpers 126 | .cold('|') 127 | .pipe(echo(callback, predicate, timer)) 128 | .subscribe({ 129 | complete: () => { 130 | expect(predicate).to.have.not.been.called; 131 | } 132 | }); 133 | }) 134 | ); 135 | }); 136 | }); 137 | 138 | describe('with an error', () => { 139 | it( 140 | 'should mirror an error observable', 141 | marbles((helpers) => { 142 | const err = new Error('a fake error'); 143 | const timer = helpers.cold('a|'); 144 | const destination = helpers.cold('#', null, err).pipe(echo(callback, predicate, timer)); 145 | const expected = helpers.cold('#', null, err); 146 | 147 | helpers.expect(destination).toBeObservable(expected); 148 | }) 149 | ); 150 | }); 151 | 152 | describe('with a single value', () => { 153 | let value; 154 | 155 | beforeEach(() => (value = 'a fake value')); 156 | 157 | describe('with a timer that emits immediately', () => { 158 | let createTimer; 159 | 160 | beforeEach(() => (createTimer = (helpers) => helpers.cold('a|', { a: 0 }))); 161 | 162 | it( 163 | 'should emit the same value', 164 | marbles((helpers) => { 165 | const timer = createTimer(helpers); 166 | const destination = helpers.cold('a|', { a: value }).pipe(echo(callback, predicate, timer)); 167 | const expected = helpers.cold('a|', { a: value }); 168 | 169 | helpers.expect(destination).toBeObservable(expected); 170 | }) 171 | ); 172 | 173 | it( 174 | 'should call the predicate', 175 | marbles((helpers) => { 176 | const timer = createTimer(helpers); 177 | 178 | helpers 179 | .cold('a|', { a: value }) 180 | .pipe(echo(callback, predicate, timer)) 181 | .subscribe({ 182 | complete: () => { 183 | expect(predicate).to.have.been.calledTwice.and.calledWithExactly(0, 0); 184 | } 185 | }); 186 | }) 187 | ); 188 | 189 | describe('with a predicate that returns true', () => { 190 | beforeEach(() => predicate.returns(true)); 191 | 192 | it( 193 | 'should call the callback', 194 | marbles((helpers) => { 195 | const timer = createTimer(helpers); 196 | 197 | helpers 198 | .cold('a|', { a: value }) 199 | .pipe(echo(callback, predicate, timer)) 200 | .subscribe({ 201 | complete: () => { 202 | expect(callback).to.have.been.calledTwice.and.calledWithExactly(0); 203 | } 204 | }); 205 | }) 206 | ); 207 | }); 208 | 209 | describe('with a predicate that returns false', () => { 210 | beforeEach(() => predicate.returns(false)); 211 | 212 | it( 213 | 'should not call the callback', 214 | marbles((helpers) => { 215 | const timer = createTimer(helpers); 216 | 217 | helpers 218 | .cold('a|', { a: value }) 219 | .pipe(echo(callback, predicate, timer)) 220 | .subscribe({ 221 | complete: () => { 222 | expect(callback).to.have.not.been.called; 223 | } 224 | }); 225 | }) 226 | ); 227 | }); 228 | }); 229 | 230 | describe('with a timer that delays the emission', () => { 231 | let createTimer; 232 | 233 | beforeEach(() => (createTimer = (helpers) => helpers.cold('-a|', { a: 0 }))); 234 | 235 | it( 236 | 'should emit the same value', 237 | marbles((helpers) => { 238 | const timer = createTimer(helpers); 239 | const destination = helpers.cold('a|', { a: value }).pipe(echo(callback, predicate, timer)); 240 | const expected = helpers.cold('a|', { a: value }); 241 | 242 | helpers.expect(destination).toBeObservable(expected); 243 | }) 244 | ); 245 | 246 | it( 247 | 'should not call the predicate', 248 | marbles((helpers) => { 249 | const timer = createTimer(helpers); 250 | 251 | helpers 252 | .cold('a|', { a: value }) 253 | .pipe(echo(callback, predicate, timer)) 254 | .subscribe({ 255 | complete: () => { 256 | expect(predicate).to.have.not.been.called; 257 | } 258 | }); 259 | }) 260 | ); 261 | 262 | it( 263 | 'should not call the callback', 264 | marbles((helpers) => { 265 | const timer = createTimer(helpers); 266 | 267 | helpers 268 | .cold('a|', { a: value }) 269 | .pipe(echo(callback, predicate, timer)) 270 | .subscribe({ 271 | complete: () => { 272 | expect(predicate).to.have.not.been.called; 273 | } 274 | }); 275 | }) 276 | ); 277 | }); 278 | }); 279 | }); 280 | -------------------------------------------------------------------------------- /test/unit/operators/enforce-order.js: -------------------------------------------------------------------------------- 1 | import { enforceOrder } from '../../../src/operators/enforce-order'; 2 | import { marbles } from 'rxjs-marbles'; 3 | 4 | describe('enforceOrder', () => { 5 | let firstValue; 6 | let isFirstValue; 7 | let subsequentValue; 8 | 9 | beforeEach(() => { 10 | firstValue = 'a fake first value'; 11 | isFirstValue = (value) => value === firstValue; 12 | subsequentValue = 'a fake subsequent value'; 13 | }); 14 | 15 | it( 16 | 'should emit an ordered serious of values if they were ordered already', 17 | marbles((helpers) => { 18 | const destination = helpers.cold('ab|', { a: firstValue, b: subsequentValue }).pipe(enforceOrder(isFirstValue)); 19 | const expected = helpers.cold('ab|', { a: firstValue, b: subsequentValue }); 20 | 21 | helpers.expect(destination).toBeObservable(expected); 22 | }) 23 | ); 24 | 25 | it( 26 | 'should emit an ordered serious of values if they were unordered before', 27 | marbles((helpers) => { 28 | const destination = helpers.cold('ab---|', { a: subsequentValue, b: firstValue }).pipe(enforceOrder(isFirstValue)); 29 | const expected = helpers.cold('-(ab)|', { a: firstValue, b: subsequentValue }); 30 | 31 | helpers.expect(destination).toBeObservable(expected); 32 | }) 33 | ); 34 | 35 | it( 36 | 'should emit an error if two values get identified as the first value', 37 | marbles((helpers) => { 38 | const destination = helpers.cold('ab|', { a: firstValue, b: firstValue }).pipe(enforceOrder(isFirstValue)); 39 | const expected = helpers.cold( 40 | 'a#', 41 | { a: firstValue }, 42 | new Error('Another value has been identified as the first value already.') 43 | ); 44 | 45 | helpers.expect(destination).toBeObservable(expected); 46 | }) 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /test/unit/operators/group-by-property.js: -------------------------------------------------------------------------------- 1 | import { groupByProperty } from '../../../src/operators/group-by-property'; 2 | import { marbles } from 'rxjs-marbles'; 3 | 4 | describe('groupByProperty', () => { 5 | describe('without any value', () => { 6 | it( 7 | 'should mirror an empty observable', 8 | marbles((helpers) => { 9 | const destination = helpers.cold('|').pipe(groupByProperty('property')); 10 | const expected = helpers.cold('|'); 11 | 12 | helpers.expect(destination).toBeObservable(expected); 13 | }) 14 | ); 15 | }); 16 | 17 | describe('with an error', () => { 18 | it( 19 | 'should mirror an error observable', 20 | marbles((helpers) => { 21 | const err = new Error('a fake error'); 22 | const destination = helpers.cold('#', null, err).pipe(groupByProperty('property')); 23 | const expected = helpers.cold('#', null, err); 24 | 25 | helpers.expect(destination).toBeObservable(expected); 26 | }) 27 | ); 28 | }); 29 | 30 | describe('with a single value', () => { 31 | let value; 32 | 33 | beforeEach( 34 | () => 35 | (value = { 36 | a: 'first', 37 | property: 'a' 38 | }) 39 | ); 40 | 41 | it( 42 | 'should emit an observable with that value', 43 | marbles((helpers) => { 44 | const group = helpers.cold('a|', { a: value }); 45 | const destination = helpers.cold('a|', { a: value }).pipe(groupByProperty('property')); 46 | const expected = helpers.cold('a|', { a: group }); 47 | 48 | helpers.expect(destination).toBeObservable(expected); 49 | }) 50 | ); 51 | }); 52 | 53 | describe('with a two values with the same property value', () => { 54 | let values; 55 | 56 | beforeEach( 57 | () => 58 | (values = [ 59 | { 60 | a: 'first', 61 | property: 'a' 62 | }, 63 | { 64 | a: 'second', 65 | property: 'a' 66 | } 67 | ]) 68 | ); 69 | 70 | it( 71 | 'should emit an observable with those values', 72 | marbles((helpers) => { 73 | const group = helpers.cold('ab|', { a: values[0], b: values[1] }); 74 | const destination = helpers.cold('ab|', { a: values[0], b: values[1] }).pipe(groupByProperty('property')); 75 | const expected = helpers.cold('a-|', { a: group }); 76 | 77 | helpers.expect(destination).toBeObservable(expected); 78 | }) 79 | ); 80 | }); 81 | 82 | describe('with a two values with different property values', () => { 83 | let values; 84 | 85 | beforeEach( 86 | () => 87 | (values = [ 88 | { 89 | a: 'first', 90 | property: 'a' 91 | }, 92 | { 93 | b: 'first', 94 | property: 'b' 95 | } 96 | ]) 97 | ); 98 | 99 | it( 100 | 'should emit two observables with one of those values emitted by each', 101 | marbles((helpers) => { 102 | const groups = [helpers.cold('a-|', { a: values[0] }), helpers.cold('a|', { a: values[1] })]; 103 | const destination = helpers.cold('ab|', { a: values[0], b: values[1] }).pipe(groupByProperty('property')); 104 | const expected = helpers.cold('ab|', { a: groups[0], b: groups[1] }); 105 | 106 | helpers.expect(destination).toBeObservable(expected); 107 | }) 108 | ); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/unit/operators/ignore-late-result.js: -------------------------------------------------------------------------------- 1 | import { finalize } from 'rxjs'; 2 | import { ignoreLateResult } from '../../../src/operators/ignore-late-result'; 3 | import { spy } from 'sinon'; 4 | 5 | describe('ignoreLateResult', () => { 6 | let complete; 7 | let error; 8 | let next; 9 | 10 | beforeEach(() => { 11 | complete = spy(); 12 | error = spy(); 13 | next = spy(); 14 | }); 15 | 16 | describe('with a promise that resolves a value', () => { 17 | let promise; 18 | let value; 19 | 20 | beforeEach(() => { 21 | value = 'a fake value'; 22 | promise = Promise.resolve(value); 23 | }); 24 | 25 | describe('with a subscription that is not yet completed when the promise settles', () => { 26 | it('should emit the value', (done) => { 27 | ignoreLateResult(promise) 28 | .pipe( 29 | finalize(() => { 30 | expect(complete).to.have.been.calledOnceWithExactly(); 31 | expect(error).to.have.not.been.called; 32 | expect(next).to.have.been.calledOnceWithExactly(value); 33 | 34 | done(); 35 | }) 36 | ) 37 | .subscribe({ 38 | complete, 39 | error, 40 | next 41 | }); 42 | }); 43 | }); 44 | 45 | describe('with a subscription that is already completed when the promise settles', () => { 46 | it('should not emit or throw anything', (done) => { 47 | ignoreLateResult(promise) 48 | .pipe( 49 | finalize(() => { 50 | expect(complete).to.have.not.been.called; 51 | expect(error).to.have.not.been.called; 52 | expect(next).to.have.not.been.called; 53 | 54 | done(); 55 | }) 56 | ) 57 | .subscribe({ 58 | complete, 59 | error, 60 | next 61 | }) 62 | .unsubscribe(); 63 | }); 64 | }); 65 | }); 66 | 67 | describe('with a promise that rejects an error', () => { 68 | let err; 69 | let promise; 70 | 71 | beforeEach(() => { 72 | err = new Error('a fake error'); 73 | promise = Promise.reject(err); 74 | }); 75 | 76 | describe('with a subscription that is not yet completed when the promise settles', () => { 77 | it('should throw the error', (done) => { 78 | ignoreLateResult(promise) 79 | .pipe( 80 | finalize(() => { 81 | expect(complete).to.have.not.been.called; 82 | expect(error).to.have.been.calledOnceWithExactly(err); 83 | expect(next).to.have.not.been.called; 84 | 85 | done(); 86 | }) 87 | ) 88 | .subscribe({ 89 | complete, 90 | error, 91 | next 92 | }); 93 | }); 94 | }); 95 | 96 | describe('with a subscription that is already completed when the promise settles', () => { 97 | it('should not emit or throw anything', (done) => { 98 | ignoreLateResult(promise) 99 | .pipe( 100 | finalize(() => { 101 | expect(complete).to.have.not.been.called; 102 | expect(error).to.have.not.been.called; 103 | expect(next).to.have.not.been.called; 104 | 105 | done(); 106 | }) 107 | ) 108 | .subscribe({ 109 | complete, 110 | error, 111 | next 112 | }) 113 | .unsubscribe(); 114 | }); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/unit/operators/maintain-array.js: -------------------------------------------------------------------------------- 1 | import { maintainArray } from '../../../src/operators/maintain-array'; 2 | import { marbles } from 'rxjs-marbles'; 3 | 4 | describe('maintainArray', () => { 5 | let value; 6 | 7 | beforeEach(() => (value = { a: 'fake value' })); 8 | 9 | it( 10 | 'should add a new value to the array', 11 | marbles((helpers) => { 12 | const destination = helpers.cold('a|', { a: [value, true] }).pipe(maintainArray()); 13 | const expected = helpers.cold('a|', { a: [value] }); 14 | 15 | helpers.expect(destination).toBeObservable(expected); 16 | }) 17 | ); 18 | 19 | it( 20 | 'should not add a value that is already stored in the array', 21 | marbles((helpers) => { 22 | const destination = helpers.cold('ab|', { a: [value, true], b: [value, true] }).pipe(maintainArray()); 23 | const expected = helpers.cold('a#', { a: [value] }, new Error('The array does already contain the value to be added.')); 24 | 25 | helpers.expect(destination).toBeObservable(expected); 26 | }) 27 | ); 28 | 29 | it( 30 | 'should remove a new value from the array', 31 | marbles((helpers) => { 32 | const destination = helpers.cold('ab|', { a: [value, true], b: [value, false] }).pipe(maintainArray()); 33 | const expected = helpers.cold('ab|', { a: [value], b: [] }); 34 | 35 | helpers.expect(destination).toBeObservable(expected); 36 | }) 37 | ); 38 | 39 | it( 40 | 'should not remove a value that is not stored in the array', 41 | marbles((helpers) => { 42 | const destination = helpers.cold('a|', { a: [value, false] }).pipe(maintainArray()); 43 | const expected = helpers.cold('#', null, new Error("The array doesn't contain the value to be removed.")); 44 | 45 | helpers.expect(destination).toBeObservable(expected); 46 | }) 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /test/unit/operators/match-pong-with-ping.js: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, of } from 'rxjs'; 2 | import { marbles } from 'rxjs-marbles'; 3 | import { matchPongWithPing } from '../../../src/operators/match-pong-with-ping'; 4 | 5 | describe('matchPongWithPing', () => { 6 | let localSentTimesSubject; 7 | 8 | beforeEach(() => { 9 | localSentTimesSubject = new BehaviorSubject([0, []]); 10 | }); 11 | 12 | describe('without any event', () => { 13 | it( 14 | 'should mirror an empty observable', 15 | marbles((helpers) => { 16 | const destination = helpers.cold('|').pipe(matchPongWithPing(localSentTimesSubject)); 17 | const expected = helpers.cold('|'); 18 | 19 | helpers.expect(destination).toBeObservable(expected); 20 | }) 21 | ); 22 | }); 23 | 24 | describe('with an error', () => { 25 | it( 26 | 'should mirror an error observable', 27 | marbles((helpers) => { 28 | const err = new Error('a fake error'); 29 | const destination = helpers.cold('#', null, err).pipe(matchPongWithPing(localSentTimesSubject)); 30 | const expected = helpers.cold('#', null, err); 31 | 32 | helpers.expect(destination).toBeObservable(expected); 33 | }) 34 | ); 35 | }); 36 | 37 | describe('with an expected pong', () => { 38 | let pong; 39 | let remoteReceivedTime; 40 | let remoteSentTime; 41 | let timestamp; 42 | 43 | beforeEach(() => { 44 | localSentTimesSubject.next([0, [0.123456789, 1.23456789]]); 45 | remoteReceivedTime = 'a fake remoteReceivedTime'; 46 | remoteSentTime = 'a fake remoteSentTime'; 47 | timestamp = 'a fake timestamp'; 48 | pong = { index: 0, remoteReceivedTime, remoteSentTime, timestamp }; 49 | }); 50 | 51 | beforeEach(() => { 52 | localSentTimesSubject.next([0, [0.123456789]]); 53 | }); 54 | 55 | it( 56 | 'should emit the pong with the matching ping', 57 | marbles((helpers) => { 58 | const destination = helpers.cold('a|', { a: pong }).pipe(matchPongWithPing(localSentTimesSubject)); 59 | const expected = helpers.cold('a|', { a: [0.123456789, remoteReceivedTime, remoteSentTime, timestamp] }); 60 | 61 | helpers.expect(destination).toBeObservable(expected); 62 | }) 63 | ); 64 | 65 | it('should update the stored pings', (done) => { 66 | of(pong) 67 | .pipe(matchPongWithPing(localSentTimesSubject)) 68 | .subscribe({ 69 | complete() { 70 | expect(localSentTimesSubject.getValue()).to.deep.equal([1, []]); 71 | 72 | done(); 73 | } 74 | }); 75 | }); 76 | }); 77 | 78 | describe('with an early pong', () => { 79 | let pong; 80 | let remoteReceivedTime; 81 | let remoteSentTime; 82 | let timestamp; 83 | 84 | beforeEach(() => { 85 | localSentTimesSubject.next([0, [0.123456789, 1.23456789]]); 86 | remoteReceivedTime = 'a fake remoteReceivedTime'; 87 | remoteSentTime = 'a fake remoteSentTime'; 88 | timestamp = 'a fake timestamp'; 89 | pong = { index: 1, remoteReceivedTime, remoteSentTime, timestamp }; 90 | }); 91 | 92 | it( 93 | 'should emit the pong with the matching ping', 94 | marbles((helpers) => { 95 | const destination = helpers.cold('a|', { a: pong }).pipe(matchPongWithPing(localSentTimesSubject)); 96 | const expected = helpers.cold('a|', { a: [1.23456789, remoteReceivedTime, remoteSentTime, timestamp] }); 97 | 98 | helpers.expect(destination).toBeObservable(expected); 99 | }) 100 | ); 101 | 102 | it('should update the stored pings', (done) => { 103 | of(pong) 104 | .pipe(matchPongWithPing(localSentTimesSubject)) 105 | .subscribe({ 106 | complete() { 107 | expect(localSentTimesSubject.getValue()).to.deep.equal([2, []]); 108 | 109 | done(); 110 | } 111 | }); 112 | }); 113 | }); 114 | 115 | describe('with a late pong', () => { 116 | let pong; 117 | let remoteReceivedTime; 118 | let remoteSentTime; 119 | let timestamp; 120 | 121 | beforeEach(() => { 122 | localSentTimesSubject.next([1, [1.23456789]]); 123 | remoteReceivedTime = 'a fake remoteReceivedTime'; 124 | remoteSentTime = 'a fake remoteSentTime'; 125 | timestamp = 'a fake timestamp'; 126 | pong = { index: 0, remoteReceivedTime, remoteSentTime, timestamp }; 127 | }); 128 | 129 | it( 130 | 'should emit the pong with the matching ping', 131 | marbles((helpers) => { 132 | const destination = helpers.cold('a|', { a: pong }).pipe(matchPongWithPing(localSentTimesSubject)); 133 | const expected = helpers.cold('-|'); 134 | 135 | helpers.expect(destination).toBeObservable(expected); 136 | }) 137 | ); 138 | 139 | it('should not update the stored pings', (done) => { 140 | of(pong) 141 | .pipe(matchPongWithPing(localSentTimesSubject)) 142 | .subscribe({ 143 | complete() { 144 | expect(localSentTimesSubject.getValue()).to.deep.equal([1, [1.23456789]]); 145 | 146 | done(); 147 | } 148 | }); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/unit/operators/retry-backoff.js: -------------------------------------------------------------------------------- 1 | import { concat, iif, mergeMap, of, throwError } from 'rxjs'; 2 | import { marbles } from 'rxjs-marbles'; 3 | import { retryBackoff } from '../../../src/operators/retry-backoff'; 4 | 5 | describe('retryBackoff', () => { 6 | let error; 7 | 8 | beforeEach(() => (error = new Error('a fake error'))); 9 | 10 | it( 11 | 'should retry three times before it fails', 12 | marbles((helpers) => { 13 | const destination = helpers.cold('#').pipe(retryBackoff()); 14 | const expected = helpers.cold('- 999ms - 3999ms - 8999ms #'); 15 | 16 | helpers.expect(destination).toBeObservable(expected); 17 | }) 18 | ); 19 | 20 | it( 21 | 'should reset the counter on success', 22 | marbles((helpers) => { 23 | let attempt = 0; 24 | 25 | const destination = of(1).pipe( 26 | mergeMap((value) => 27 | iif( 28 | () => { 29 | attempt += 1; 30 | 31 | return attempt < 5; 32 | }, 33 | concat( 34 | of(value), 35 | throwError(() => error) 36 | ), 37 | throwError(() => error) 38 | ) 39 | ), 40 | retryBackoff() 41 | ); 42 | const expected = helpers.cold('a 999ms a 999ms a 999ms a 13999ms #', { a: 1 }, error); 43 | 44 | helpers.expect(destination).toBeObservable(expected); 45 | }) 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /test/unit/operators/select-most-likely-offset.js: -------------------------------------------------------------------------------- 1 | import { marbles } from 'rxjs-marbles'; 2 | import { selectMostLikelyOffset } from '../../../src/operators/select-most-likely-offset'; 3 | 4 | describe('selectMostLikelyOffset', () => { 5 | describe('without any tuple', () => { 6 | it( 7 | 'should mirror an empty observable', 8 | marbles((helpers) => { 9 | const destination = helpers.cold('|').pipe(selectMostLikelyOffset()); 10 | const expected = helpers.cold('|'); 11 | 12 | helpers.expect(destination).toBeObservable(expected); 13 | }) 14 | ); 15 | }); 16 | 17 | describe('with an error', () => { 18 | it( 19 | 'should mirror an error observable', 20 | marbles((helpers) => { 21 | const err = new Error('a fake error'); 22 | const destination = helpers.cold('#', null, err).pipe(selectMostLikelyOffset()); 23 | const expected = helpers.cold('#', null, err); 24 | 25 | helpers.expect(destination).toBeObservable(expected); 26 | }) 27 | ); 28 | }); 29 | 30 | describe('with one tuple', () => { 31 | it( 32 | 'should emit the tuple', 33 | marbles((helpers) => { 34 | const destination = helpers.cold('a|', { a: [1, 2] }).pipe(selectMostLikelyOffset()); 35 | const expected = helpers.cold('a|', { a: [1, 2] }); 36 | 37 | helpers.expect(destination).toBeObservable(expected); 38 | }) 39 | ); 40 | }); 41 | 42 | describe('with two tuples', () => { 43 | it( 44 | 'should emit the tuple with the smallest round trip time', 45 | marbles((helpers) => { 46 | const destination = helpers.cold('ab|', { a: [1, 2], b: [3, 4] }).pipe(selectMostLikelyOffset()); 47 | const expected = helpers.cold('ab|', { a: [1, 2], b: [1, 2] }); 48 | 49 | helpers.expect(destination).toBeObservable(expected); 50 | }) 51 | ); 52 | }); 53 | 54 | describe('with three tuples', () => { 55 | it( 56 | 'should emit the tuple with the smallest round trip time', 57 | marbles((helpers) => { 58 | const destination = helpers.cold('ab|', { a: [6, 5], b: [4, 3], c: [2, 1] }).pipe(selectMostLikelyOffset()); 59 | const expected = helpers.cold('ab|', { a: [6, 5], b: [4, 3], c: [2, 1] }); 60 | 61 | helpers.expect(destination).toBeObservable(expected); 62 | }) 63 | ); 64 | }); 65 | 66 | describe('with sixty tuples', () => { 67 | it( 68 | 'should emit the tuple with the smallest round trip time', 69 | marbles((helpers) => { 70 | const destination = helpers.cold(`a${'b'.repeat(59)}|`, { a: [1, 2], b: [3, 4] }).pipe(selectMostLikelyOffset()); 71 | const expected = helpers.cold(`${'a'.repeat(60)}|`, { a: [1, 2] }); 72 | 73 | helpers.expect(destination).toBeObservable(expected); 74 | }) 75 | ); 76 | }); 77 | 78 | describe('with sixty one tuples', () => { 79 | it( 80 | 'should emit the tuple with the smallest round trip time within the last sixty tuples', 81 | marbles((helpers) => { 82 | const destination = helpers.cold(`a${'b'.repeat(60)}|`, { a: [1, 2], b: [3, 4] }).pipe(selectMostLikelyOffset()); 83 | const expected = helpers.cold(`${'a'.repeat(60)}b|`, { a: [1, 2], b: [3, 4] }); 84 | 85 | helpers.expect(destination).toBeObservable(expected); 86 | }) 87 | ); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/unit/operators/send-periodic-pings.js: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, EMPTY, from, of } from 'rxjs'; 2 | import { spy, stub } from 'sinon'; 3 | import { sendPeriodicPings } from '../../../src/operators/send-periodic-pings'; 4 | 5 | describe('sendPeriodicPings', () => { 6 | let localSentTimesSubject; 7 | let now; 8 | let send; 9 | 10 | beforeEach(() => { 11 | localSentTimesSubject = new BehaviorSubject([0, []]); 12 | now = stub(); 13 | send = spy(); 14 | }); 15 | 16 | describe('with no value', () => { 17 | it('should not emit any value', (done) => { 18 | EMPTY.pipe(sendPeriodicPings(localSentTimesSubject, now)).subscribe({ 19 | complete() { 20 | done(); 21 | }, 22 | error() { 23 | done(new Error('This should never be called.')); 24 | }, 25 | next() { 26 | done(new Error('This should never be called.')); 27 | } 28 | }); 29 | }); 30 | 31 | it('should not call send()', (done) => { 32 | EMPTY.pipe(sendPeriodicPings(localSentTimesSubject, now)).subscribe({ 33 | complete() { 34 | expect(send).to.have.not.been.called; 35 | 36 | done(); 37 | } 38 | }); 39 | }); 40 | 41 | it('should not update the stored pings', (done) => { 42 | EMPTY.pipe(sendPeriodicPings(localSentTimesSubject, now)).subscribe({ 43 | complete() { 44 | expect(localSentTimesSubject.getValue()).to.deep.equal([0, []]); 45 | 46 | done(); 47 | } 48 | }); 49 | }); 50 | }); 51 | 52 | describe('with one value', () => { 53 | beforeEach(() => { 54 | now.returns(0.123456789); 55 | }); 56 | 57 | it('should not emit any value', (done) => { 58 | of([, send]) 59 | .pipe(sendPeriodicPings(localSentTimesSubject, now)) 60 | .subscribe({ 61 | complete() { 62 | done(); 63 | }, 64 | error() { 65 | done(new Error('This should never be called.')); 66 | }, 67 | next() { 68 | done(new Error('This should never be called.')); 69 | } 70 | }); 71 | }); 72 | 73 | it('should call send()', (done) => { 74 | of([, send]) 75 | .pipe(sendPeriodicPings(localSentTimesSubject, now)) 76 | .subscribe({ 77 | complete() { 78 | expect(send).to.have.been.calledOnce.and.calledWithExactly({ index: 0, type: 'ping' }); 79 | 80 | done(); 81 | } 82 | }); 83 | }); 84 | 85 | it('should update the stored pings', (done) => { 86 | of([, send]) 87 | .pipe(sendPeriodicPings(localSentTimesSubject, now)) 88 | .subscribe({ 89 | complete() { 90 | expect(localSentTimesSubject.getValue()).to.deep.equal([0, [0.123456789]]); 91 | 92 | done(); 93 | } 94 | }); 95 | }); 96 | }); 97 | 98 | describe('with two values', () => { 99 | beforeEach(() => { 100 | now.onFirstCall().returns(0.123456789).onSecondCall().returns(1.23456789); 101 | }); 102 | 103 | it('should not emit any value', (done) => { 104 | from([ 105 | [, send], 106 | [, send] 107 | ]) 108 | .pipe(sendPeriodicPings(localSentTimesSubject, now)) 109 | .subscribe({ 110 | complete() { 111 | done(); 112 | }, 113 | error() { 114 | done(new Error('This should never be called.')); 115 | }, 116 | next() { 117 | done(new Error('This should never be called.')); 118 | } 119 | }); 120 | }); 121 | 122 | it('should call send()', (done) => { 123 | from([ 124 | [, send], 125 | [, send] 126 | ]) 127 | .pipe(sendPeriodicPings(localSentTimesSubject, now)) 128 | .subscribe({ 129 | complete() { 130 | expect(send).to.have.been.calledTwice; 131 | expect(send.getCall(0)).to.have.been.calledWithExactly({ index: 0, type: 'ping' }); 132 | expect(send.getCall(1)).to.have.been.calledWithExactly({ index: 0, type: 'ping' }); 133 | 134 | done(); 135 | } 136 | }); 137 | }); 138 | 139 | it('should update the stored pings', (done) => { 140 | from([ 141 | [, send], 142 | [, send] 143 | ]) 144 | .pipe(sendPeriodicPings(localSentTimesSubject, now)) 145 | .subscribe({ 146 | complete() { 147 | expect(localSentTimesSubject.getValue()).to.deep.equal([0, [0.123456789, 1.23456789]]); 148 | 149 | done(); 150 | } 151 | }); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/unit/operators/take-until-fatal-value.js: -------------------------------------------------------------------------------- 1 | import { EMPTY, NEVER, concat, from, mergeMap, of, throwError } from 'rxjs'; 2 | import { spy, stub } from 'sinon'; 3 | import { marbles } from 'rxjs-marbles'; 4 | import { takeUntilFatalValue } from '../../../src/operators/take-until-fatal-value'; 5 | 6 | describe('takeUntilFatalValue', () => { 7 | let handleFatalValue; 8 | let isFatalValue; 9 | 10 | beforeEach(() => { 11 | handleFatalValue = spy(); 12 | isFatalValue = stub(); 13 | }); 14 | 15 | describe('without any value', () => { 16 | it( 17 | 'should mirror an empty observable', 18 | marbles((helpers) => { 19 | const destination = helpers.cold('|').pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 20 | const expected = helpers.cold('|'); 21 | 22 | helpers.expect(destination).toBeObservable(expected); 23 | }) 24 | ); 25 | 26 | it('should not call isFatalValue()', (done) => { 27 | const destination = EMPTY.pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 28 | 29 | destination.subscribe({ 30 | complete: () => { 31 | expect(isFatalValue).to.have.not.been.called; 32 | 33 | done(); 34 | } 35 | }); 36 | }); 37 | 38 | it('should not call handleFatalValue()', (done) => { 39 | const destination = EMPTY.pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 40 | 41 | destination.subscribe({ 42 | complete: () => { 43 | expect(handleFatalValue).to.have.not.been.called; 44 | 45 | done(); 46 | } 47 | }); 48 | }); 49 | }); 50 | 51 | describe('with an error', () => { 52 | let error; 53 | 54 | beforeEach(() => (error = new Error('a fake error'))); 55 | 56 | it( 57 | 'should mirror an error observable', 58 | marbles((helpers) => { 59 | const destination = helpers.cold('#', null, error).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 60 | const expected = helpers.cold('#', null, error); 61 | 62 | helpers.expect(destination).toBeObservable(expected); 63 | }) 64 | ); 65 | 66 | it('should not call isFatalValue()', (done) => { 67 | const destination = of(error).pipe( 68 | mergeMap((err) => throwError(() => err)), 69 | takeUntilFatalValue(isFatalValue, handleFatalValue) 70 | ); 71 | 72 | destination.subscribe({ 73 | error: (err) => { 74 | expect(err).to.equal(error); 75 | 76 | expect(isFatalValue).to.have.not.been.called; 77 | 78 | done(); 79 | } 80 | }); 81 | }); 82 | 83 | it('should not call handleFatalValue()', (done) => { 84 | const destination = of(error).pipe( 85 | mergeMap((err) => throwError(() => err)), 86 | takeUntilFatalValue(isFatalValue, handleFatalValue) 87 | ); 88 | 89 | destination.subscribe({ 90 | error: (err) => { 91 | expect(err).to.equal(error); 92 | 93 | expect(handleFatalValue).to.have.not.been.called; 94 | 95 | done(); 96 | } 97 | }); 98 | }); 99 | }); 100 | 101 | describe('with a regular value', () => { 102 | let regularValue; 103 | 104 | beforeEach(() => { 105 | regularValue = 'a regular value'; 106 | 107 | isFatalValue.returns(false); 108 | }); 109 | 110 | it( 111 | 'should emit an observable with the regular value', 112 | marbles((helpers) => { 113 | const destination = helpers.cold('a', { a: regularValue }).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 114 | const expected = helpers.cold('a', { a: regularValue }); 115 | 116 | helpers.expect(destination).toBeObservable(expected); 117 | }) 118 | ); 119 | 120 | it('should call isFatalValue() with the regular value', (done) => { 121 | const destination = concat(of(regularValue), NEVER).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 122 | 123 | destination.subscribe(() => { 124 | expect(isFatalValue).to.have.been.calledTwice; 125 | expect(isFatalValue).to.have.been.calledWithExactly(regularValue, 0); 126 | 127 | done(); 128 | }); 129 | }); 130 | 131 | it('should not call handleFatalValue()', (done) => { 132 | const destination = concat(of(regularValue), NEVER).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 133 | 134 | destination.subscribe(() => { 135 | expect(handleFatalValue).to.have.not.been.called; 136 | 137 | done(); 138 | }); 139 | }); 140 | 141 | describe('with a subsequent fatal value', () => { 142 | let fatalValue; 143 | 144 | beforeEach(() => { 145 | fatalValue = 'a fatal value'; 146 | 147 | isFatalValue.callsFake((value) => value === fatalValue); 148 | }); 149 | 150 | it( 151 | 'should emit an observable with the regular value', 152 | marbles((helpers) => { 153 | const destination = helpers 154 | .cold('ab', { a: regularValue, b: fatalValue }) 155 | .pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 156 | const expected = helpers.cold('a|', { a: regularValue }); 157 | 158 | helpers.expect(destination).toBeObservable(expected); 159 | }) 160 | ); 161 | 162 | it('should call isFatalValue() with the regular and and the fatal value', (done) => { 163 | const destination = concat(from([regularValue, fatalValue]), NEVER).pipe( 164 | takeUntilFatalValue(isFatalValue, handleFatalValue) 165 | ); 166 | 167 | destination.subscribe({ 168 | complete: () => { 169 | expect(isFatalValue).to.have.been.calledThrice; 170 | expect(isFatalValue).to.have.been.calledWithExactly(regularValue, 0); 171 | expect(isFatalValue).to.have.been.calledWithExactly(fatalValue, 1); 172 | 173 | done(); 174 | } 175 | }); 176 | }); 177 | 178 | it('should call handleFatalValue() with the fatal value', (done) => { 179 | const destination = concat(from([regularValue, fatalValue]), NEVER).pipe( 180 | takeUntilFatalValue(isFatalValue, handleFatalValue) 181 | ); 182 | 183 | destination.subscribe({ 184 | complete: () => { 185 | expect(handleFatalValue).to.have.been.calledOnce; 186 | expect(handleFatalValue).to.have.been.calledWithExactly(fatalValue); 187 | 188 | done(); 189 | } 190 | }); 191 | }); 192 | }); 193 | }); 194 | 195 | describe('with a fatal value', () => { 196 | let fatalValue; 197 | 198 | beforeEach(() => { 199 | fatalValue = 'a fatal value'; 200 | 201 | isFatalValue.returns(true); 202 | }); 203 | 204 | it( 205 | 'should emit an empty observable', 206 | marbles((helpers) => { 207 | const destination = helpers.cold('a', { a: fatalValue }).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 208 | const expected = helpers.cold('|'); 209 | 210 | helpers.expect(destination).toBeObservable(expected); 211 | }) 212 | ); 213 | 214 | it('should call isFatalValue() with the fatal value', (done) => { 215 | const destination = concat(of(fatalValue), NEVER).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 216 | 217 | destination.subscribe({ 218 | complete: () => { 219 | expect(isFatalValue).to.have.been.calledOnce; 220 | expect(isFatalValue).to.have.been.calledWithExactly(fatalValue, 0); 221 | 222 | done(); 223 | } 224 | }); 225 | }); 226 | 227 | it('should call handleFatalValue() with the fatal value', (done) => { 228 | const destination = concat(of(fatalValue), NEVER).pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 229 | 230 | destination.subscribe({ 231 | complete: () => { 232 | expect(handleFatalValue).to.have.been.calledOnce; 233 | expect(handleFatalValue).to.have.been.calledWithExactly(fatalValue); 234 | 235 | done(); 236 | } 237 | }); 238 | }); 239 | 240 | describe('with a subsequent regular value', () => { 241 | let regularValue; 242 | 243 | beforeEach(() => { 244 | regularValue = 'a regular value'; 245 | 246 | isFatalValue.callsFake((value) => value === fatalValue); 247 | }); 248 | 249 | it( 250 | 'should emit an empty observable', 251 | marbles((helpers) => { 252 | const destination = helpers 253 | .cold('ab', { a: fatalValue, b: regularValue }) 254 | .pipe(takeUntilFatalValue(isFatalValue, handleFatalValue)); 255 | const expected = helpers.cold('|'); 256 | 257 | helpers.expect(destination).toBeObservable(expected); 258 | }) 259 | ); 260 | 261 | it('should call isFatalValue() with the fatal value', (done) => { 262 | const destination = concat(from([fatalValue, regularValue]), NEVER).pipe( 263 | takeUntilFatalValue(isFatalValue, handleFatalValue) 264 | ); 265 | 266 | destination.subscribe({ 267 | complete: () => { 268 | expect(isFatalValue).to.have.been.calledOnce; 269 | expect(isFatalValue).to.have.been.calledWithExactly(fatalValue, 0); 270 | 271 | done(); 272 | } 273 | }); 274 | }); 275 | 276 | it('should call handleFatalValue() with the fatal value', (done) => { 277 | const destination = concat(from([fatalValue, regularValue]), NEVER).pipe( 278 | takeUntilFatalValue(isFatalValue, handleFatalValue) 279 | ); 280 | 281 | destination.subscribe({ 282 | complete: () => { 283 | expect(handleFatalValue).to.have.been.calledOnce; 284 | expect(handleFatalValue).to.have.been.calledWithExactly(fatalValue); 285 | 286 | done(); 287 | } 288 | }); 289 | }); 290 | }); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /test/unit/operators/ultimately.js: -------------------------------------------------------------------------------- 1 | import { EMPTY, of, throwError } from 'rxjs'; 2 | import { marbles } from 'rxjs-marbles'; 3 | import { spy } from 'sinon'; 4 | import { ultimately } from '../../../src/operators/ultimately'; 5 | 6 | describe('ultimately', () => { 7 | let callback; 8 | 9 | beforeEach(() => { 10 | callback = spy(); 11 | }); 12 | 13 | describe('without any value', () => { 14 | it( 15 | 'should mirror an empty observable', 16 | marbles((helpers) => { 17 | const destination = helpers.cold('|').pipe(ultimately(callback)); 18 | const expected = helpers.cold('|'); 19 | 20 | helpers.expect(destination).toBeObservable(expected); 21 | }) 22 | ); 23 | 24 | it('should call the callback', (done) => { 25 | EMPTY.pipe(ultimately(callback)).subscribe({ 26 | complete() { 27 | expect(callback).to.have.been.calledOnceWithExactly(); 28 | 29 | done(); 30 | } 31 | }); 32 | }); 33 | }); 34 | 35 | describe('with an error', () => { 36 | it( 37 | 'should mirror an error observable', 38 | marbles((helpers) => { 39 | const err = new Error('a fake error'); 40 | const destination = helpers.cold('#', null, err).pipe(ultimately(callback)); 41 | const expected = helpers.cold('#', null, err); 42 | 43 | helpers.expect(destination).toBeObservable(expected); 44 | }) 45 | ); 46 | 47 | it('should call the callback', (done) => { 48 | throwError(() => new Error('a fake error')) 49 | .pipe(ultimately(callback)) 50 | .subscribe({ 51 | error() { 52 | expect(callback).to.have.been.calledOnceWithExactly(); 53 | 54 | done(); 55 | } 56 | }); 57 | }); 58 | }); 59 | 60 | describe('with a single value', () => { 61 | let value; 62 | 63 | beforeEach(() => { 64 | value = 'a fake value'; 65 | }); 66 | 67 | it( 68 | 'should emit the same value', 69 | marbles((helpers) => { 70 | const destination = helpers.cold('a|', { a: value }).pipe(ultimately(callback)); 71 | const expected = helpers.cold('a|', { a: value }); 72 | 73 | helpers.expect(destination).toBeObservable(expected); 74 | }) 75 | ); 76 | 77 | it('should call the callback', (done) => { 78 | of(value) 79 | .pipe(ultimately(callback)) 80 | .subscribe({ 81 | complete() { 82 | expect(callback).to.have.been.calledOnceWithExactly(); 83 | 84 | done(); 85 | } 86 | }); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/unit/timing-provider-factory.js: -------------------------------------------------------------------------------- 1 | describe('TimingProvider', () => { 2 | it('should ...', () => { 3 | // @todo 4 | }); 5 | }); 6 | --------------------------------------------------------------------------------