UNPKG

timing-provider

Version:

An implementation of the timing provider specification.

346 lines 18.8 kB
import { BehaviorSubject, EMPTY, ReplaySubject, Subject, 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 { 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 { 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'; const SUENC_URL = 'wss://matchmaker.suenc.io'; const PROVIDER_ID_REGEX = /^[\dA-Za-z]{20}$/; export const createTimingProviderConstructor = (createRTCPeerConnection, createSignaling, eventTargetConstructor, performance, setTimeout, sortByHopsAndRoundTripTime, updateTimingStateVector) => { return class TimingProvider extends eventTargetConstructor { constructor(providerIdOrUrl) { 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() { return this._endPosition; } get error() { return this._error; } get onadjust() { 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() { 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() { 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() { return this._readyState; } get skew() { return this._skew; } get startPosition() { return this._startPosition; } get vector() { return this._vector; } destroy() { 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'))); } update(newVector) { 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(); } _createClient() { 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.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.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(10000)), 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(([, 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(1); return dataChannelTuple$.pipe(tap(([, dataChannelTuples]) => dataChannelTuplesSubject.next(dataChannelTuples)), map(([dataChannelTuple, dataChannelTuples]) => { if (isPeerToPeerMessageTuple(dataChannelTuple)) { const [clientId, event] = dataChannelTuple; if (event.type === 'ping') { return [ clientId, { ...event, reply: findSendPeerToPeerMessageFunction(clientId, dataChannelTuples.filter(isSendPeerToPeerMessageTuple)) } ]; } } return 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([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])); } 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])); }), combineAsTuple(), distinctUntilChanged(([vectorA, [offsetA, roundTripTimeA]], [vectorB, [offsetB, roundTripTimeB]]) => vectorA === vectorB && offsetA === offsetB && roundTripTimeA === roundTripTimeB), map(([vector, [offset, roundTripTime]]) => [ messageOrFunctionTuple$.key, vector === null ? null : { ...vector, timestamp: vector.timestamp - offset / 1000 }, roundTripTime ]), endWith([messageOrFunctionTuple$.key, null, null]))))); }), scan(([, 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) => [tupleAndTuples, dataChannelTuples]))), map(([[tuple], dataChannelTuples]) => [...tuple, dataChannelTuples])); })), this._updateRequestsSubject.pipe(map(([vector]) => [null, vector, 0, null]))) .pipe(scan(([tuples, previousDataChannelTuples] = [[[null, this._createExtendedVector(this._hops), 0]], []], [clientId, extendedVector, roundTripTime, currentDataChannelTuples]) => { const dataChannelTuples = currentDataChannelTuples !== null && currentDataChannelTuples !== void 0 ? 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); }); } _createExtendedVector(hops) { return { ...this._vector, hops, version: this._version }; } _setInternalVector({ hops, version, ...vector }) { this._hops = hops; this._vector = vector; this._version = version; this.dispatchEvent(new CustomEvent('change', { detail: vector })); } }; }; //# sourceMappingURL=timing-provider-constructor.js.map