timing-provider
Version:
An implementation of the timing provider specification.
346 lines • 18.8 kB
JavaScript
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