UNPKG

timing-provider

Version:

An implementation of the timing provider specification.

642 lines (553 loc) 30.5 kB
import { BehaviorSubject, EMPTY, ReplaySubject, Subject, Subscription, catchError, concat, concatMap, connect, defer, distinctUntilChanged, endWith, filter, first, from, groupBy, ignoreElements, map, merge, mergeMap, of, scan, take, tap, timer } from 'rxjs'; import { equals } from 'rxjs-etc/operators'; import { online } from 'subscribable-things'; import { ITimingProvider, ITimingProviderEventMap, ITimingStateVector, TConnectionState, TEventHandler, TTimingStateVectorUpdate } from 'timing-object'; import { findSendPeerToPeerMessageFunction } from '../functions/find-send-peer-to-peer-message-function'; import { isBooleanTuple } from '../functions/is-boolean-tuple'; import { isFalseTuple } from '../functions/is-false-tuple'; import { isNotBooleanTuple } from '../functions/is-not-boolean-tuple'; import { isPeerToPeerMessageTuple } from '../functions/is-peer-to-peer-message-tuple'; import { isSendPeerToPeerMessageTuple } from '../functions/is-send-peer-to-peer-message-tuple'; import { isTrueTuple } from '../functions/is-true-tuple'; import { IClosureEvent, IInitEvent, IPingEvent, IPongEvent, IUpdateEvent } from '../interfaces'; import { combineAsTuple } from '../operators/combine-as-tuple'; import { computeOffsetAndRoundTripTime } from '../operators/compute-offset-and-round-trip-time'; import { demultiplexMessages } from '../operators/demultiplex-messages'; import { enforceOrder } from '../operators/enforce-order'; import { groupByProperty } from '../operators/group-by-property'; import { matchPongWithPing } from '../operators/match-pong-with-ping'; import { negotiateDataChannels } from '../operators/negotiate-data-channels'; import { retryBackoff } from '../operators/retry-backoff'; import { selectMostLikelyOffset } from '../operators/select-most-likely-offset'; import { sendPeriodicPings } from '../operators/send-periodic-pings'; import { takeUntilFatalValue } from '../operators/take-until-fatal-value'; import { TDataChannelTuple, TEventTargetConstructor, TExtendedTimingStateVector, TSendPeerToPeerMessageFunction, TTimingProviderConstructor } from '../types'; import type { createRTCPeerConnectionFactory } from './rtc-peer-connection-factory'; import type { createSignalingFactory } from './signaling-factory'; import type { createSortByHopsAndRoundTripTime } from './sort-by-hops-and-round-trip-time'; import type { createUpdateTimingStateVector } from './update-timing-state-vector'; const SUENC_URL = 'wss://matchmaker.suenc.io'; const PROVIDER_ID_REGEX = /^[\dA-Za-z]{20}$/; export const createTimingProviderConstructor = ( createRTCPeerConnection: ReturnType<typeof createRTCPeerConnectionFactory>, createSignaling: ReturnType<typeof createSignalingFactory>, eventTargetConstructor: TEventTargetConstructor, performance: Window['performance'], setTimeout: Window['setTimeout'], sortByHopsAndRoundTripTime: ReturnType<typeof createSortByHopsAndRoundTripTime<[unknown, { hops: number[] }, number]>>, updateTimingStateVector: ReturnType<typeof createUpdateTimingStateVector> ): TTimingProviderConstructor => { return class TimingProvider extends eventTargetConstructor<ITimingProviderEventMap> implements ITimingProvider { private _clientId: string; private _endPosition: number; private _error: null | Error; private _hops: number[]; private _onadjust: null | [TEventHandler<this>, TEventHandler<this>]; private _onchange: null | [TEventHandler<this>, TEventHandler<this>]; private _onreadystatechange: null | [TEventHandler<this>, TEventHandler<this>]; private _origin: number; private _providerIdOrUrl: string; private _readyState: TConnectionState; private _skew: number; private _startPosition: number; private _subscription: null | Subscription; // tslint:disable-next-line:rxjs-no-exposed-subjects private _updateRequestsSubject: Subject<readonly [TExtendedTimingStateVector, null]>; private _vector: ITimingStateVector; private _version: number; constructor(providerIdOrUrl: string) { super(); const timestamp = performance.now() / 1000; this._clientId = ''; this._endPosition = Number.POSITIVE_INFINITY; this._error = null; this._hops = []; this._onadjust = null; this._onchange = null; this._onreadystatechange = null; this._origin = Number.MAX_SAFE_INTEGER; this._providerIdOrUrl = providerIdOrUrl; this._readyState = 'connecting'; this._skew = 0; this._startPosition = Number.NEGATIVE_INFINITY; this._subscription = null; this._updateRequestsSubject = new Subject(); this._vector = { acceleration: 0, position: 0, timestamp, velocity: 0 }; this._version = 0; this._createClient(); } get endPosition(): number { return this._endPosition; } get error(): null | Error { return this._error; } get onadjust(): null | TEventHandler<this> { return this._onadjust === null ? this._onadjust : this._onadjust[0]; } set onadjust(value) { if (this._onadjust !== null) { this.removeEventListener('adjust', this._onadjust[1]); } if (typeof value === 'function') { const boundListener = value.bind(this); this.addEventListener('adjust', boundListener); this._onadjust = [value, boundListener]; } else { this._onadjust = null; } } get onchange(): null | TEventHandler<this> { return this._onchange === null ? this._onchange : this._onchange[0]; } set onchange(value) { if (this._onchange !== null) { this.removeEventListener('change', this._onchange[1]); } if (typeof value === 'function') { const boundListener = value.bind(this); this.addEventListener('change', boundListener); this._onchange = [value, boundListener]; } else { this._onchange = null; } } get onreadystatechange(): null | TEventHandler<this> { return this._onreadystatechange === null ? this._onreadystatechange : this._onreadystatechange[0]; } set onreadystatechange(value) { if (this._onreadystatechange !== null) { this.removeEventListener('readystatechange', this._onreadystatechange[1]); } if (typeof value === 'function') { const boundListener = value.bind(this); this.addEventListener('readystatechange', boundListener); this._onreadystatechange = [value, boundListener]; } else { this._onreadystatechange = null; } } get readyState(): TConnectionState { return this._readyState; } get skew(): number { return this._skew; } get startPosition(): number { return this._startPosition; } get vector(): ITimingStateVector { return this._vector; } public destroy(): void { if (this._subscription === null) { throw new Error('The timingProvider is already destroyed.'); } this._readyState = 'closed'; this._subscription.unsubscribe(); this._subscription = null; this._updateRequestsSubject.complete(); setTimeout(() => this.dispatchEvent(new Event('readystatechange'))); } public update(newVector: TTimingStateVectorUpdate): Promise<void> { if (this._subscription === null) { return Promise.reject(new Error("The timingProvider is destroyed and can't be updated.")); } const updatedVector = updateTimingStateVector(this._vector, newVector); if (updatedVector !== null) { this._updateRequestsSubject.next([ { ...updatedVector, hops: [], version: this._version + 1 }, null ]); } return Promise.resolve(); } private _createClient(): void { const url = PROVIDER_ID_REGEX.test(this._providerIdOrUrl) ? `${SUENC_URL}?providerId=${this._providerIdOrUrl}` : this._providerIdOrUrl; this._subscription = merge( concat( from(online()).pipe(equals(true), first(), ignoreElements()), defer(() => { const [signalingEvent$, sendSignalingEvent] = createSignaling(url); return signalingEvent$.pipe( takeUntilFatalValue( (event): event is IClosureEvent => event.type === 'closure', () => { const err = new Error('Your plan has exceeded its quota.'); this._error = err; this._readyState = 'closed'; this.dispatchEvent(new Event('readystatechange')); } ), enforceOrder((event): event is IInitEvent => event.type === 'init'), concatMap((event) => { if (event.type === 'array') { return from(event.events); } if (event.type === 'init') { const { client: { id: clientId }, events, origin } = event; this._clientId = clientId; this._origin = origin; if (events.length === 0 && this._readyState === 'connecting') { this._readyState = 'open'; this.dispatchEvent(new Event('readystatechange')); } return from(events); } return of(event); }), demultiplexMessages(() => this._clientId, timer(10_000)), negotiateDataChannels(createRTCPeerConnection, sendSignalingEvent) ); }) ).pipe( retryBackoff(), catchError((err) => { this._error = err; this._readyState = 'closed'; this.dispatchEvent(new Event('readystatechange')); return EMPTY; }), tap((dataChannelTuple) => { if (isSendPeerToPeerMessageTuple(dataChannelTuple) && this._readyState === 'connecting') { this._readyState = 'open'; this.dispatchEvent(new Event('readystatechange')); } }), scan< TDataChannelTuple, [TDataChannelTuple, [string, true | TSendPeerToPeerMessageFunction][]], [void, [string, true | TSendPeerToPeerMessageFunction][]] >( ([, dataChannelTuples], dataChannelTuple) => { const index = dataChannelTuples.findIndex(([clientId]) => clientId === dataChannelTuple[0]); if (index === -1) { if (isTrueTuple(dataChannelTuple) || isSendPeerToPeerMessageTuple(dataChannelTuple)) { dataChannelTuples.push(dataChannelTuple); } } else if (isFalseTuple(dataChannelTuple)) { dataChannelTuples.splice(index, 1); } else if (isTrueTuple(dataChannelTuple) || isSendPeerToPeerMessageTuple(dataChannelTuple)) { dataChannelTuples[index] = dataChannelTuple; } return [dataChannelTuple, dataChannelTuples]; }, [, []] // tslint:disable-line:no-sparse-arrays ), tap(([, dataChannelTuples]) => { if (dataChannelTuples.length === 0 && this._readyState === 'connecting') { this._readyState = 'open'; this.dispatchEvent(new Event('readystatechange')); } }), filter(([dataChannelTuple]) => { if (isSendPeerToPeerMessageTuple(dataChannelTuple)) { if ( !dataChannelTuple[1]({ message: this._createExtendedVector([...this._hops, this._origin]), type: 'update' }) ) { return false; } } return true; }), connect((dataChannelTuple$) => { const dataChannelTuplesSubject = new ReplaySubject<[string, true | TSendPeerToPeerMessageFunction][]>(1); return dataChannelTuple$.pipe( tap(([, dataChannelTuples]) => dataChannelTuplesSubject.next(dataChannelTuples)), map< [TDataChannelTuple, TDataChannelTuple[]], readonly [ string, ( | boolean | (IPingEvent & { reply: TSendPeerToPeerMessageFunction; timestamp: number }) | (IPongEvent & { timestamp: number }) | IUpdateEvent | TSendPeerToPeerMessageFunction ) ] >(([dataChannelTuple, dataChannelTuples]) => { if (isPeerToPeerMessageTuple(dataChannelTuple)) { const [clientId, event] = dataChannelTuple; if (event.type === 'ping') { return <const>[ clientId, { ...event, reply: findSendPeerToPeerMessageFunction( clientId, dataChannelTuples.filter(isSendPeerToPeerMessageTuple) ) } ]; } } return < readonly [ string, boolean | (IPongEvent & { timestamp: number }) | IUpdateEvent | TSendPeerToPeerMessageFunction ] >dataChannelTuple; }), filter(isNotBooleanTuple), groupBy(([clientId]) => clientId, { duration: (group$) => dataChannelTuple$.pipe( map(([dataChannelTuple]) => dataChannelTuple), filter(isBooleanTuple), filter(([clientId]) => clientId === group$.key) ) }), mergeMap((messageOrFunctionTuple$) => { const localSentTimesSubject = new BehaviorSubject<[number, number[]]>([0, []]); return messageOrFunctionTuple$.pipe( connect((observable$) => merge( observable$.pipe( filter(isSendPeerToPeerMessageTuple), sendPeriodicPings(localSentTimesSubject, () => performance.now()) ), observable$.pipe( filter(isPeerToPeerMessageTuple), map(([, event]) => event), groupByProperty('type'), mergeMap((group$) => { if (group$.key === 'ping') { return group$.pipe( tap(({ index, timestamp, reply }) => reply({ index, remoteReceivedTime: timestamp, remoteSentTime: performance.now(), type: 'pong' }) ), ignoreElements() ); } if (group$.key === 'pong') { return group$.pipe( matchPongWithPing(localSentTimesSubject), computeOffsetAndRoundTripTime(), selectMostLikelyOffset(), map((offset) => [1, offset] as const) ); } return group$.pipe( map(({ message }) => message), map((extendedVector) => { if (this._version > extendedVector.version) { return null; } if (this._version === extendedVector.version) { const origin = this._hops.length === 0 ? this._origin : this._hops[0]; if ( origin < extendedVector.hops[0] || extendedVector.hops.includes(this._origin) ) { return null; } } return extendedVector; }), map((extendedVector) => [0, extendedVector] as const) ); }), combineAsTuple<null | TExtendedTimingStateVector, [number, number]>(), distinctUntilChanged( ([vectorA, [offsetA, roundTripTimeA]], [vectorB, [offsetB, roundTripTimeB]]) => vectorA === vectorB && offsetA === offsetB && roundTripTimeA === roundTripTimeB ), map( ([vector, [offset, roundTripTime]]) => <const>[ messageOrFunctionTuple$.key, vector === null ? null : { ...vector, timestamp: vector.timestamp - offset / 1000 }, roundTripTime ] ), endWith(<const>[messageOrFunctionTuple$.key, null, null]) ) ) ) ); }), scan< readonly [string, null | TExtendedTimingStateVector, number] | readonly [string, null, null], [ readonly [string, null | TExtendedTimingStateVector, number] | readonly [string, null, null], (readonly [string, null | TExtendedTimingStateVector, number])[] ], [void, (readonly [string, null | TExtendedTimingStateVector, number])[]] >( ([, tuples], tuple) => { const index = tuples.findIndex(([clientId]) => tuple[0] === clientId); if (tuple[2] === null) { if (index > -1) { tuples.splice(index, 1); } } else { if (index > -1) { tuples[index] = tuple; } else { tuples.push(tuple); tuples.sort(([clientIdA], [clientIdB]) => clientIdA < clientIdB ? -1 : clientIdA > clientIdB ? 1 : 0 ); } } return [tuple, tuples]; }, [, []] // tslint:disable-line:no-sparse-arrays ), mergeMap((tupleAndTuples) => dataChannelTuplesSubject.pipe( take(1), map((dataChannelTuples) => <const>[tupleAndTuples, dataChannelTuples]) ) ), map(([[tuple], dataChannelTuples]) => <const>[...tuple, dataChannelTuples]) ); }) ), this._updateRequestsSubject.pipe(map(([vector]) => <const>[null, vector, 0, null])) ) .pipe( scan< | readonly [string, null | TExtendedTimingStateVector, number, TDataChannelTuple[]] | readonly [string, null, null, TDataChannelTuple[]] | readonly [null, TExtendedTimingStateVector, number, null], [[null | string, TExtendedTimingStateVector, number][], TDataChannelTuple[]], undefined >( ( [tuples, previousDataChannelTuples] = [[[null, this._createExtendedVector(this._hops), 0]], []], [clientId, extendedVector, roundTripTime, currentDataChannelTuples] ) => { const dataChannelTuples = currentDataChannelTuples ?? previousDataChannelTuples; const index = tuples.findIndex((tuple) => tuple[0] === clientId); if (extendedVector !== null) { if (this._version < extendedVector.version) { tuples.length = 0; tuples.push([clientId, extendedVector, roundTripTime]); return [tuples, dataChannelTuples]; } if (this._version === extendedVector.version) { const origin = this._hops.length === 0 ? this._origin : this._hops[0]; if (origin > extendedVector.hops[0]) { if (!extendedVector.hops.includes(this._origin)) { tuples.length = 0; tuples.push([clientId, extendedVector, roundTripTime]); return [tuples, dataChannelTuples]; } } if ( origin === extendedVector.hops[0] && !extendedVector.hops.includes(this._origin) && this._hops.length > 0 ) { if (index > -1) { tuples[index] = [clientId, extendedVector, roundTripTime]; } else { tuples.push([clientId, extendedVector, roundTripTime]); } sortByHopsAndRoundTripTime(tuples); return [tuples, dataChannelTuples]; } } } if (index > -1) { if (tuples.length === 1) { tuples[0] = [ null, { ...tuples[0][1], hops: [tuples[0][1].hops[0], ...tuples[0][1].hops.map(() => this._origin)] }, 0 ]; } else { tuples.splice(index, 1); } } return [tuples, dataChannelTuples]; }, undefined ), distinctUntilChanged( (extendedVectorA, extendedVectorB) => extendedVectorA === extendedVectorB, ([[[, extendedVector]]]) => extendedVector ) ) .subscribe(([[[clientId, extendedVector]], dataChannelTuples]) => { const externalVector = { ...extendedVector, hops: [...extendedVector.hops, this._origin] }; for (const [remoteClientId, send] of dataChannelTuples.filter(isSendPeerToPeerMessageTuple)) { if (!send({ message: externalVector, type: 'update' }) && clientId === remoteClientId) { return; } } this._setInternalVector(extendedVector); }); } private _createExtendedVector(hops: number[]): TExtendedTimingStateVector { return { ...this._vector, hops, version: this._version }; } private _setInternalVector({ hops, version, ...vector }: TExtendedTimingStateVector): void { this._hops = hops; this._vector = vector; this._version = version; this.dispatchEvent(new CustomEvent('change', { detail: vector })); } }; };