moar WIP
This commit is contained in:
parent
1f15ca6498
commit
8c5411cb7d
20 changed files with 538 additions and 230 deletions
|
@ -36,9 +36,9 @@ rooms should report how many messages they have queued up, and each time they se
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- finish (Base)ObservableValue
|
- DONE: finish (Base)ObservableValue
|
||||||
- put in own file
|
- put in own file
|
||||||
- add waitFor
|
- add waitFor (won't this leak if the promise never resolves?)
|
||||||
- decide whether we want to inherit (no?)
|
- decide whether we want to inherit (no?)
|
||||||
- cleanup Reconnector with recent changes, move generic code, make imports work
|
- cleanup Reconnector with recent changes, move generic code, make imports work
|
||||||
- add SyncStatus as ObservableValue of enum in Sync
|
- add SyncStatus as ObservableValue of enum in Sync
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cheerio": "^1.0.0-rc.3",
|
"cheerio": "^1.0.0-rc.3",
|
||||||
"finalhandler": "^1.1.1",
|
"finalhandler": "^1.1.1",
|
||||||
"impunity": "^0.0.10",
|
"impunity": "^0.0.11",
|
||||||
"postcss": "^7.0.18",
|
"postcss": "^7.0.18",
|
||||||
"postcss-import": "^12.0.1",
|
"postcss-import": "^12.0.1",
|
||||||
"rollup": "^1.15.6",
|
"rollup": "^1.15.6",
|
||||||
|
|
12
src/main.js
12
src/main.js
|
@ -1,11 +1,13 @@
|
||||||
import HomeServerApi from "./matrix/hs-api.js";
|
import HomeServerApi from "./matrix/net/HomeServerApi.js";
|
||||||
// import {RecordRequester, ReplayRequester} from "./matrix/net/replay.js";
|
// import {RecordRequester, ReplayRequester} from "./matrix/net/replay.js";
|
||||||
import fetchRequest from "./matrix/net/fetch.js";
|
import fetchRequest from "./matrix/net/fetch.js";
|
||||||
|
import {Reconnector} from "./matrix/net/connection/Reconnector.js";
|
||||||
import StorageFactory from "./matrix/storage/idb/create.js";
|
import StorageFactory from "./matrix/storage/idb/create.js";
|
||||||
import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js";
|
import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js";
|
||||||
import BrawlViewModel from "./domain/BrawlViewModel.js";
|
import BrawlViewModel from "./domain/BrawlViewModel.js";
|
||||||
import BrawlView from "./ui/web/BrawlView.js";
|
import BrawlView from "./ui/web/BrawlView.js";
|
||||||
import DOMClock from "./utils/DOMClock.js";
|
import DOMClock from "./ui/web/dom/Clock.js";
|
||||||
|
import OnlineStatus from "./ui/web/dom/OnlineStatus.js";
|
||||||
|
|
||||||
export default async function main(container) {
|
export default async function main(container) {
|
||||||
try {
|
try {
|
||||||
|
@ -22,12 +24,6 @@ export default async function main(container) {
|
||||||
const request = fetchRequest;
|
const request = fetchRequest;
|
||||||
const clock = new DOMClock();
|
const clock = new DOMClock();
|
||||||
|
|
||||||
const sessionContainer = new SessionContainer({
|
|
||||||
clock,
|
|
||||||
request,
|
|
||||||
storageFactory: new StorageFactory(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const vm = new BrawlViewModel({
|
const vm = new BrawlViewModel({
|
||||||
storageFactory: new StorageFactory(),
|
storageFactory: new StorageFactory(),
|
||||||
createHsApi: (homeServer, accessToken, reconnector) => new HomeServerApi({homeServer, accessToken, request, reconnector}),
|
createHsApi: (homeServer, accessToken, reconnector) => new HomeServerApi({homeServer, accessToken, request, reconnector}),
|
||||||
|
|
|
@ -1,169 +0,0 @@
|
||||||
export class ExponentialRetryDelay {
|
|
||||||
constructor(start = 2000, createTimeout) {
|
|
||||||
this._start = start;
|
|
||||||
this._current = start;
|
|
||||||
this._createTimeout = createTimeout;
|
|
||||||
this._max = 60 * 5 * 1000; //5 min
|
|
||||||
this._timeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForRetry() {
|
|
||||||
this._timeout = this._createTimeout(this._current);
|
|
||||||
try {
|
|
||||||
await this._timeout.elapsed();
|
|
||||||
// only increase delay if we didn't get interrupted
|
|
||||||
const seconds = this._current / 1000;
|
|
||||||
const powerOfTwo = (seconds * seconds) * 1000;
|
|
||||||
this._current = Math.max(this._max, powerOfTwo);
|
|
||||||
} catch(err) {
|
|
||||||
// swallow AbortError, means abort was called
|
|
||||||
if (!(err instanceof AbortError)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this._timeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abort() {
|
|
||||||
if (this._timeout) {
|
|
||||||
this._timeout.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this._current = this._start;
|
|
||||||
this.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
get nextValue() {
|
|
||||||
return this._current;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need a clock interface that gives us both timestamps and a timer that we can interrupt?
|
|
||||||
|
|
||||||
// state
|
|
||||||
// - offline
|
|
||||||
// - waiting to reconnect
|
|
||||||
// - reconnecting
|
|
||||||
// - online
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
function createEnum(...values) {
|
|
||||||
const obj = {};
|
|
||||||
for (const value of values) {
|
|
||||||
obj[value] = value;
|
|
||||||
}
|
|
||||||
return Object.freeze(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionState = createEnum(
|
|
||||||
"Offline",
|
|
||||||
"Waiting",
|
|
||||||
"Reconnecting",
|
|
||||||
"Online"
|
|
||||||
);
|
|
||||||
|
|
||||||
export class Reconnector {
|
|
||||||
constructor({retryDelay, createTimeMeasure, isOnline}) {
|
|
||||||
this._isOnline = isOnline;
|
|
||||||
this._retryDelay = retryDelay;
|
|
||||||
this._createTimeMeasure = createTimeMeasure;
|
|
||||||
// assume online, and do our thing when something fails
|
|
||||||
this._state = new ObservableValue(ConnectionState.Online);
|
|
||||||
this._isReconnecting = false;
|
|
||||||
this._versionsResponse = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get lastVersionsResponse() {
|
|
||||||
return this._versionsResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
get connectionState() {
|
|
||||||
return this._state;
|
|
||||||
}
|
|
||||||
|
|
||||||
get retryIn() {
|
|
||||||
if (this._state.get() === ConnectionState.Waiting) {
|
|
||||||
return this._retryDelay.nextValue - this._stateSince.measure();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onRequestFailed(hsApi) {
|
|
||||||
if (!this._isReconnecting) {
|
|
||||||
this._setState(ConnectionState.Offline);
|
|
||||||
|
|
||||||
const isOnlineSubscription = this._isOnline && this._isOnline.subscribe(online => {
|
|
||||||
if (online) {
|
|
||||||
this.tryNow();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this._reconnectLoop(hsApi);
|
|
||||||
} finally {
|
|
||||||
if (isOnlineSubscription) {
|
|
||||||
// unsubscribe from this._isOnline
|
|
||||||
isOnlineSubscription();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tryNow() {
|
|
||||||
if (this._retryDelay) {
|
|
||||||
// this will interrupt this._retryDelay.waitForRetry() in _reconnectLoop
|
|
||||||
this._retryDelay.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_setState(state) {
|
|
||||||
if (state !== this._state.get()) {
|
|
||||||
if (state === ConnectionState.Waiting) {
|
|
||||||
this._stateSince = this._createTimeMeasure();
|
|
||||||
} else {
|
|
||||||
this._stateSince = null;
|
|
||||||
}
|
|
||||||
this._state.set(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _reconnectLoop(hsApi) {
|
|
||||||
this._isReconnecting = true;
|
|
||||||
this._versionsResponse = null;
|
|
||||||
this._retryDelay.reset();
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!this._versionsResponse) {
|
|
||||||
try {
|
|
||||||
this._setState(ConnectionState.Reconnecting);
|
|
||||||
// use 10s timeout, because we don't want to be waiting for
|
|
||||||
// a stale connection when we just came online again
|
|
||||||
const versionsRequest = hsApi.versions({timeout: 10000});
|
|
||||||
this._versionsResponse = await versionsRequest.response();
|
|
||||||
this._setState(ConnectionState.Online);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof NetworkError) {
|
|
||||||
this._setState(ConnectionState.Waiting);
|
|
||||||
try {
|
|
||||||
await this._retryDelay.waitForRetry();
|
|
||||||
} catch (err) {
|
|
||||||
if (!(err instanceof AbortError)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// nothing is catching the error above us,
|
|
||||||
// so just log here
|
|
||||||
console.err(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import HomeServerApi from "./hs-api.js";
|
import HomeServerApi from "./net/HomeServerApi.js";
|
||||||
|
|
||||||
export const LoadStatus = createEnum(
|
export const LoadStatus = createEnum(
|
||||||
"NotLoading",
|
"NotLoading",
|
||||||
|
@ -131,7 +131,6 @@ export class SessionContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._sync = new Sync({hsApi, storage, session: this._session});
|
this._sync = new Sync({hsApi, storage, session: this._session});
|
||||||
|
|
||||||
// notify sync and session when back online
|
// notify sync and session when back online
|
||||||
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
|
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
|
||||||
if (state === ConnectionStatus.Online) {
|
if (state === ConnectionStatus.Online) {
|
||||||
|
@ -139,7 +138,15 @@ export class SessionContainer {
|
||||||
this._session.start(this._reconnector.lastVersionsResponse);
|
this._session.start(this._reconnector.lastVersionsResponse);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await this._waitForFirstSync();
|
||||||
|
this._status.set(LoadStatus.Ready);
|
||||||
|
|
||||||
|
// if this fails, the reconnector will start polling versions to reconnect
|
||||||
|
const lastVersionsResponse = await hsApi.versions({timeout: 10000}).response();
|
||||||
|
this._session.start(lastVersionsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _waitForFirstSync() {
|
||||||
try {
|
try {
|
||||||
await this._sync.start();
|
await this._sync.start();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -151,12 +158,18 @@ export class SessionContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// only transition into Ready once the first sync has succeeded
|
// only transition into Ready once the first sync has succeeded
|
||||||
await this._sync.status.waitFor(s => s === SyncStatus.Syncing);
|
this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing);
|
||||||
this._status.set(LoadStatus.Ready);
|
try {
|
||||||
|
await this._waitForFirstSyncHandle.promise;
|
||||||
// if this fails, the reconnector will start polling versions to reconnect
|
} catch (err) {
|
||||||
const lastVersionsResponse = await hsApi.versions({timeout: 10000}).response();
|
// if dispose is called from stop, bail out
|
||||||
this._session.start(lastVersionsResponse);
|
if (err instanceof AbortError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this._waitForFirstSyncHandle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -183,6 +196,10 @@ export class SessionContainer {
|
||||||
this._reconnectSubscription = null;
|
this._reconnectSubscription = null;
|
||||||
this._sync.stop();
|
this._sync.stop();
|
||||||
this._session.stop();
|
this._session.stop();
|
||||||
|
if (this._waitForFirstSyncHandle) {
|
||||||
|
this._waitForFirstSyncHandle.dispose();
|
||||||
|
this._waitForFirstSyncHandle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
107
src/matrix/net/ExponentialRetryDelay.js
Normal file
107
src/matrix/net/ExponentialRetryDelay.js
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import {AbortError} from "../../utils/error.js";
|
||||||
|
|
||||||
|
export default class ExponentialRetryDelay {
|
||||||
|
constructor(createTimeout, start = 2000) {
|
||||||
|
this._start = start;
|
||||||
|
this._current = start;
|
||||||
|
this._createTimeout = createTimeout;
|
||||||
|
this._max = 60 * 5 * 1000; //5 min
|
||||||
|
this._timeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForRetry() {
|
||||||
|
this._timeout = this._createTimeout(this._current);
|
||||||
|
try {
|
||||||
|
await this._timeout.elapsed();
|
||||||
|
// only increase delay if we didn't get interrupted
|
||||||
|
const next = 2 * this._current;
|
||||||
|
this._current = Math.min(this._max, next);
|
||||||
|
} catch(err) {
|
||||||
|
// swallow AbortError, means abort was called
|
||||||
|
if (!(err instanceof AbortError)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._timeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
if (this._timeout) {
|
||||||
|
this._timeout.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._current = this._start;
|
||||||
|
this.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
get nextValue() {
|
||||||
|
return this._current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
import MockClock from "../../../mocks/Clock.js";
|
||||||
|
|
||||||
|
export function tests() {
|
||||||
|
return {
|
||||||
|
"test sequence": async assert => {
|
||||||
|
const clock = new MockClock();
|
||||||
|
const retryDelay = new ExponentialRetryDelay(clock.createTimeout, 2000);
|
||||||
|
let promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 2000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(2000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 4000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(4000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 8000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(8000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 16000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(16000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 32000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(32000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 64000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(64000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 128000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(128000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 256000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(256000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 300000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(300000);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
assert.strictEqual(retryDelay.nextValue, 300000);
|
||||||
|
promise = retryDelay.waitForRetry();
|
||||||
|
clock.elapse(300000);
|
||||||
|
await promise;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
176
src/matrix/net/Reconnector.js
Normal file
176
src/matrix/net/Reconnector.js
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
import createEnum from "../../utils/enum.js";
|
||||||
|
import {AbortError} from "../../utils/error.js";
|
||||||
|
import {NetworkError} from "../error.js"
|
||||||
|
import ObservableValue from "../../observable/ObservableValue.js";
|
||||||
|
|
||||||
|
export const ConnectionStatus = createEnum(
|
||||||
|
"Offline",
|
||||||
|
"Waiting",
|
||||||
|
"Reconnecting",
|
||||||
|
"Online"
|
||||||
|
);
|
||||||
|
|
||||||
|
export class Reconnector {
|
||||||
|
constructor({retryDelay, createMeasure, onlineStatus}) {
|
||||||
|
this._onlineStatus = onlineStatus;
|
||||||
|
this._retryDelay = retryDelay;
|
||||||
|
this._createTimeMeasure = createMeasure;
|
||||||
|
// assume online, and do our thing when something fails
|
||||||
|
this._state = new ObservableValue(ConnectionStatus.Online);
|
||||||
|
this._isReconnecting = false;
|
||||||
|
this._versionsResponse = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastVersionsResponse() {
|
||||||
|
return this._versionsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectionStatus() {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
get retryIn() {
|
||||||
|
if (this._state.get() === ConnectionStatus.Waiting) {
|
||||||
|
return this._retryDelay.nextValue - this._stateSince.measure();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRequestFailed(hsApi) {
|
||||||
|
if (!this._isReconnecting) {
|
||||||
|
this._setState(ConnectionStatus.Offline);
|
||||||
|
|
||||||
|
const onlineStatusSubscription = this._onlineStatus && this._onlineStatus.subscribe(online => {
|
||||||
|
if (online) {
|
||||||
|
this.tryNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._reconnectLoop(hsApi);
|
||||||
|
} catch (err) {
|
||||||
|
// nothing is catching the error above us,
|
||||||
|
// so just log here
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
if (onlineStatusSubscription) {
|
||||||
|
// unsubscribe from this._onlineStatus
|
||||||
|
onlineStatusSubscription();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryNow() {
|
||||||
|
if (this._retryDelay) {
|
||||||
|
// this will interrupt this._retryDelay.waitForRetry() in _reconnectLoop
|
||||||
|
this._retryDelay.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setState(state) {
|
||||||
|
if (state !== this._state.get()) {
|
||||||
|
if (state === ConnectionStatus.Waiting) {
|
||||||
|
this._stateSince = this._createTimeMeasure();
|
||||||
|
} else {
|
||||||
|
this._stateSince = null;
|
||||||
|
}
|
||||||
|
this._state.set(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _reconnectLoop(hsApi) {
|
||||||
|
this._isReconnecting = true;
|
||||||
|
this._versionsResponse = null;
|
||||||
|
this._retryDelay.reset();
|
||||||
|
|
||||||
|
while (!this._versionsResponse) {
|
||||||
|
try {
|
||||||
|
this._setState(ConnectionStatus.Reconnecting);
|
||||||
|
// use 10s timeout, because we don't want to be waiting for
|
||||||
|
// a stale connection when we just came online again
|
||||||
|
const versionsRequest = hsApi.versions({timeout: 10000});
|
||||||
|
this._versionsResponse = await versionsRequest.response();
|
||||||
|
this._setState(ConnectionStatus.Online);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NetworkError) {
|
||||||
|
this._setState(ConnectionStatus.Waiting);
|
||||||
|
try {
|
||||||
|
await this._retryDelay.waitForRetry();
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof AbortError)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
import MockClock from "../../../mocks/Clock.js";
|
||||||
|
import ExponentialRetryDelay from "./ExponentialRetryDelay.js";
|
||||||
|
|
||||||
|
export function tests() {
|
||||||
|
function createHsApiMock(remainingFailures) {
|
||||||
|
return {
|
||||||
|
versions() {
|
||||||
|
return {
|
||||||
|
response() {
|
||||||
|
if (remainingFailures) {
|
||||||
|
remainingFailures -= 1;
|
||||||
|
return Promise.reject(new NetworkError());
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"test reconnecting with 1 failure": async assert => {
|
||||||
|
const clock = new MockClock();
|
||||||
|
const {createMeasure} = clock;
|
||||||
|
const onlineStatus = new ObservableValue(false);
|
||||||
|
const retryDelay = new ExponentialRetryDelay(clock.createTimeout, 2000);
|
||||||
|
const reconnector = new Reconnector({retryDelay, onlineStatus, createMeasure});
|
||||||
|
const {connectionStatus} = reconnector;
|
||||||
|
const statuses = [];
|
||||||
|
const subscription = reconnector.connectionStatus.subscribe(s => {
|
||||||
|
statuses.push(s);
|
||||||
|
});
|
||||||
|
reconnector.onRequestFailed(createHsApiMock(1));
|
||||||
|
await connectionStatus.waitFor(s => s === ConnectionStatus.Waiting).promise;
|
||||||
|
clock.elapse(2000);
|
||||||
|
await connectionStatus.waitFor(s => s === ConnectionStatus.Online).promise;
|
||||||
|
assert.deepEqual(statuses, [
|
||||||
|
ConnectionStatus.Offline,
|
||||||
|
ConnectionStatus.Reconnecting,
|
||||||
|
ConnectionStatus.Waiting,
|
||||||
|
ConnectionStatus.Reconnecting,
|
||||||
|
ConnectionStatus.Online
|
||||||
|
]);
|
||||||
|
assert.strictEqual(reconnector.lastVersionsResponse, 42);
|
||||||
|
subscription();
|
||||||
|
},
|
||||||
|
"test reconnecting with onlineStatus": async assert => {
|
||||||
|
const clock = new MockClock();
|
||||||
|
const {createMeasure} = clock;
|
||||||
|
const onlineStatus = new ObservableValue(false);
|
||||||
|
const retryDelay = new ExponentialRetryDelay(clock.createTimeout, 2000);
|
||||||
|
const reconnector = new Reconnector({retryDelay, onlineStatus, createMeasure});
|
||||||
|
const {connectionStatus} = reconnector;
|
||||||
|
reconnector.onRequestFailed(createHsApiMock(1));
|
||||||
|
await connectionStatus.waitFor(s => s === ConnectionStatus.Waiting).promise;
|
||||||
|
onlineStatus.set(true); //skip waiting
|
||||||
|
await connectionStatus.waitFor(s => s === ConnectionStatus.Online).promise;
|
||||||
|
assert.equal(connectionStatus.get(), ConnectionStatus.Online);
|
||||||
|
assert.strictEqual(reconnector.lastVersionsResponse, 42);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
77
src/mocks/Clock.js
Normal file
77
src/mocks/Clock.js
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import ObservableValue from "../observable/ObservableValue.js";
|
||||||
|
|
||||||
|
class Timeout {
|
||||||
|
constructor(elapsed, ms) {
|
||||||
|
this._reject = null;
|
||||||
|
this._handle = null;
|
||||||
|
const timeoutValue = elapsed.get() + ms;
|
||||||
|
this._waitHandle = elapsed.waitFor(t => t >= timeoutValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed() {
|
||||||
|
return this._waitHandle.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
// will reject with AbortError
|
||||||
|
this._waitHandle.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeMeasure {
|
||||||
|
constructor(elapsed) {
|
||||||
|
this._elapsed = elapsed;
|
||||||
|
this._start = elapsed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
measure() {
|
||||||
|
return this._elapsed.get() - this._start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Clock {
|
||||||
|
constructor(baseTimestamp = 0) {
|
||||||
|
this._baseTimestamp = baseTimestamp;
|
||||||
|
this._elapsed = new ObservableValue(0);
|
||||||
|
// should be callable as a function as well as a method
|
||||||
|
this.createMeasure = this.createMeasure.bind(this);
|
||||||
|
this.createTimeout = this.createTimeout.bind(this);
|
||||||
|
this.now = this.now.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMeasure() {
|
||||||
|
return new TimeMeasure(this._elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTimeout(ms) {
|
||||||
|
return new Timeout(this._elapsed, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
now() {
|
||||||
|
return this._baseTimestamp + this.elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
elapse(ms) {
|
||||||
|
this._elapsed.set(this._elapsed.get() + Math.max(0, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
get elapsed() {
|
||||||
|
return this._elapsed.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tests() {
|
||||||
|
return {
|
||||||
|
"test timeout": async assert => {
|
||||||
|
const clock = new Clock();
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
clock.elapse(500);
|
||||||
|
clock.elapse(500);
|
||||||
|
}).catch(assert.fail);
|
||||||
|
const timeout = clock.createTimeout(1000);
|
||||||
|
const promise = timeout.elapsed();
|
||||||
|
assert(promise instanceof Promise);
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
export default class BaseObservableCollection {
|
export default class BaseObservable {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._handlers = new Set();
|
this._handlers = new Set();
|
||||||
}
|
}
|
||||||
|
@ -31,33 +31,8 @@ export default class BaseObservableCollection {
|
||||||
// Add iterator over handlers here
|
// Add iterator over handlers here
|
||||||
}
|
}
|
||||||
|
|
||||||
// like an EventEmitter, but doesn't have an event type
|
|
||||||
export class BaseObservableValue extends BaseObservableCollection {
|
|
||||||
emit(argument) {
|
|
||||||
for (const h of this._handlers) {
|
|
||||||
h(argument);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ObservableValue extends BaseObservableValue {
|
|
||||||
constructor(initialValue) {
|
|
||||||
super();
|
|
||||||
this._value = initialValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return this._value;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(value) {
|
|
||||||
this._value = value;
|
|
||||||
this.emit(this._value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
class Collection extends BaseObservableCollection {
|
class Collection extends BaseObservable {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.firstSubscribeCalls = 0;
|
this.firstSubscribeCalls = 0;
|
120
src/observable/ObservableValue.js
Normal file
120
src/observable/ObservableValue.js
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import {AbortError} from "../utils/error.js";
|
||||||
|
import BaseObservable from "./BaseObservable.js";
|
||||||
|
|
||||||
|
// like an EventEmitter, but doesn't have an event type
|
||||||
|
export class BaseObservableValue extends BaseObservable {
|
||||||
|
emit(argument) {
|
||||||
|
for (const h of this._handlers) {
|
||||||
|
h(argument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class WaitForHandle {
|
||||||
|
constructor(observable, predicate) {
|
||||||
|
this._promise = new Promise((resolve, reject) => {
|
||||||
|
this._reject = reject;
|
||||||
|
this._subscription = observable.subscribe(v => {
|
||||||
|
if (predicate(v)) {
|
||||||
|
this._reject = null;
|
||||||
|
resolve(v);
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get promise() {
|
||||||
|
return this._promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._subscription) {
|
||||||
|
this._subscription();
|
||||||
|
this._subscription = null;
|
||||||
|
}
|
||||||
|
if (this._reject) {
|
||||||
|
this._reject(new AbortError());
|
||||||
|
this._reject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResolvedWaitForHandle {
|
||||||
|
constructor(promise) {
|
||||||
|
this.promise = promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ObservableValue extends BaseObservableValue {
|
||||||
|
constructor(initialValue) {
|
||||||
|
super();
|
||||||
|
this._value = initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(value) {
|
||||||
|
if (value !== this._value) {
|
||||||
|
this._value = value;
|
||||||
|
this.emit(this._value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor(predicate) {
|
||||||
|
if (predicate(this.get())) {
|
||||||
|
return new ResolvedWaitForHandle(Promise.resolve(this.get()));
|
||||||
|
} else {
|
||||||
|
return new WaitForHandle(this, predicate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tests() {
|
||||||
|
return {
|
||||||
|
"set emits an update": assert => {
|
||||||
|
const a = new ObservableValue();
|
||||||
|
let fired = false;
|
||||||
|
const subscription = a.subscribe(v => {
|
||||||
|
fired = true;
|
||||||
|
assert.strictEqual(v, 5);
|
||||||
|
});
|
||||||
|
a.set(5);
|
||||||
|
assert(fired);
|
||||||
|
subscription();
|
||||||
|
},
|
||||||
|
"set doesn't emit if value hasn't changed": assert => {
|
||||||
|
const a = new ObservableValue(5);
|
||||||
|
let fired = false;
|
||||||
|
const subscription = a.subscribe(() => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
|
a.set(5);
|
||||||
|
a.set(5);
|
||||||
|
assert(!fired);
|
||||||
|
subscription();
|
||||||
|
},
|
||||||
|
"waitFor promise resolves on matching update": async assert => {
|
||||||
|
const a = new ObservableValue(5);
|
||||||
|
const handle = a.waitFor(v => v === 6);
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
a.set(6);
|
||||||
|
});
|
||||||
|
await handle.promise;
|
||||||
|
assert.strictEqual(a.get(), 6);
|
||||||
|
},
|
||||||
|
"waitFor promise rejects when disposed": async assert => {
|
||||||
|
const a = new ObservableValue();
|
||||||
|
const handle = a.waitFor(() => false);
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
handle.dispose();
|
||||||
|
});
|
||||||
|
await assert.rejects(handle.promise, AbortError);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import BaseObservableCollection from "../BaseObservableCollection.js";
|
import BaseObservable from "../BaseObservable.js";
|
||||||
|
|
||||||
export default class BaseObservableList extends BaseObservableCollection {
|
export default class BaseObservableList extends BaseObservable {
|
||||||
emitReset() {
|
emitReset() {
|
||||||
for(let h of this._handlers) {
|
for(let h of this._handlers) {
|
||||||
h.onReset(this);
|
h.onReset(this);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import BaseObservableCollection from "../BaseObservableCollection.js";
|
import BaseObservable from "../BaseObservable.js";
|
||||||
|
|
||||||
export default class BaseObservableMap extends BaseObservableCollection {
|
export default class BaseObservableMap extends BaseObservable {
|
||||||
emitReset() {
|
emitReset() {
|
||||||
for(let h of this._handlers) {
|
for(let h of this._handlers) {
|
||||||
h.onReset();
|
h.onReset();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {AbortError} from "../utils/error.js";
|
import {AbortError} from "../../../utils/error.js";
|
||||||
|
|
||||||
class Timeout {
|
class Timeout {
|
||||||
constructor(ms) {
|
constructor(ms) {
|
||||||
|
@ -37,7 +37,7 @@ class TimeMeasure {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Clock {
|
export default class Clock {
|
||||||
createMeasure() {
|
createMeasure() {
|
||||||
return new TimeMeasure();
|
return new TimeMeasure();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
export class OnlineStatus extends ObservableValue {
|
import {BaseObservableValue} from "../../../observable/ObservableValue.js";
|
||||||
|
|
||||||
|
export default class OnlineStatus extends BaseObservableValue {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._onOffline = this._onOffline.bind(this);
|
this._onOffline = this._onOffline.bind(this);
|
||||||
|
|
|
@ -34,7 +34,7 @@ export default class SwitchView {
|
||||||
return this._childView;
|
return this._childView;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
// SessionLoadView
|
// SessionLoadView
|
||||||
// should this be the new switch view?
|
// should this be the new switch view?
|
||||||
// and the other one be the BasicSwitchView?
|
// and the other one be the BasicSwitchView?
|
||||||
|
@ -50,8 +50,8 @@ new BoundSwitchView(vm, vm => vm.isLoading, (loading, vm) => {
|
||||||
return new SessionView(vm.sessionViewModel);
|
return new SessionView(vm.sessionViewModel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
class BoundSwitchView extends SwitchView {
|
export class BoundSwitchView extends SwitchView {
|
||||||
constructor(value, mapper, viewCreator) {
|
constructor(value, mapper, viewCreator) {
|
||||||
super(viewCreator(mapper(value), value));
|
super(viewCreator(mapper(value), value));
|
||||||
this._mapper = mapper;
|
this._mapper = mapper;
|
||||||
|
|
7
src/utils/enum.js
Normal file
7
src/utils/enum.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export default function createEnum(...values) {
|
||||||
|
const obj = {};
|
||||||
|
for (const value of values) {
|
||||||
|
obj[value] = value;
|
||||||
|
}
|
||||||
|
return Object.freeze(obj);
|
||||||
|
}
|
|
@ -234,10 +234,10 @@ http-errors@~1.7.2:
|
||||||
statuses ">= 1.5.0 < 2"
|
statuses ">= 1.5.0 < 2"
|
||||||
toidentifier "1.0.0"
|
toidentifier "1.0.0"
|
||||||
|
|
||||||
impunity@^0.0.10:
|
impunity@^0.0.11:
|
||||||
version "0.0.10"
|
version "0.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/impunity/-/impunity-0.0.10.tgz#b4e47c85db53279ca7fcf2e07f7ffb111b050e49"
|
resolved "https://registry.yarnpkg.com/impunity/-/impunity-0.0.11.tgz#216da6860ad17dd360fdaa2b15d7006579b5dd8a"
|
||||||
integrity sha512-orL7IaDV//74U6GDyw7j7wcLwxhhLpXStyZ+Pz4O1UEYx1zlCojfpBNuq26Mzbaw0HMEwrMMi4JnLQ9lz3HVFg==
|
integrity sha512-EZUlc/Qx7oaRXZY+PtewrPby63sWZQsEtjGFB05XfbL/20SBkR8ksFnBahkeOD2/ErNkO3vh8AV0oDbdSSS8jQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
colors "^1.3.3"
|
colors "^1.3.3"
|
||||||
commander "^2.19.0"
|
commander "^2.19.0"
|
||||||
|
|
Reference in a new issue