From 80f4bb3d9821482755c18fa65ad357b30565f2ed Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Tue, 28 Jun 2022 13:20:08 +0100 Subject: [PATCH] feat: build finite state machine for sessions --- src/controllers/client/ClientController.tsx | 49 ++++++ src/controllers/client/Session.tsx | 164 ++++++++++++++++++++ src/mobx/State.ts | 7 +- src/mobx/stores/Auth.ts | 37 ++--- src/mobx/stores/ServerConfig.ts | 4 +- src/types/revolt-api.d.ts | 2 + 6 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 src/controllers/client/ClientController.tsx create mode 100644 src/controllers/client/Session.tsx diff --git a/src/controllers/client/ClientController.tsx b/src/controllers/client/ClientController.tsx new file mode 100644 index 00000000..25fc536c --- /dev/null +++ b/src/controllers/client/ClientController.tsx @@ -0,0 +1,49 @@ +import { action, makeAutoObservable, ObservableMap } from "mobx"; +import type { Nullable } from "revolt.js"; + +import Auth from "../../mobx/stores/Auth"; + +import Session from "./Session"; + +class ClientController { + /** + * Map of user IDs to sessions + */ + private sessions: ObservableMap; + + /** + * User ID of active session + */ + private current: Nullable; + + constructor() { + this.sessions = new ObservableMap(); + this.current = null; + + makeAutoObservable(this); + } + + /** + * Hydrate sessions and start client lifecycles. + * @param auth Authentication store + */ + @action hydrate(auth: Auth) { + for (const entry of auth.getAccounts()) { + const session = new Session(); + session.emit({ + action: "LOGIN", + session: entry.session, + }); + } + } + + getActiveSession() { + return this.sessions; + } + + isLoggedIn() { + return this.current === null; + } +} + +export const clientController = new ClientController(); diff --git a/src/controllers/client/Session.tsx b/src/controllers/client/Session.tsx new file mode 100644 index 00000000..ec9c7e45 --- /dev/null +++ b/src/controllers/client/Session.tsx @@ -0,0 +1,164 @@ +import { action, makeAutoObservable } from "mobx"; +import { Client } from "revolt.js"; + +type State = "Ready" | "Connecting" | "Online" | "Disconnected" | "Offline"; + +type Transition = + | { + action: "LOGIN"; + session: SessionPrivate; + } + | { + action: + | "SUCCESS" + | "DISCONNECT" + | "RETRY" + | "LOGOUT" + | "ONLINE" + | "OFFLINE"; + }; + +export default class Session { + state: State = window.navigator.onLine ? "Ready" : "Offline"; + client: Client | null = null; + + constructor() { + makeAutoObservable(this); + + this.onDropped = this.onDropped.bind(this); + this.onReady = this.onReady.bind(this); + this.onOnline = this.onOnline.bind(this); + this.onOffline = this.onOffline.bind(this); + + window.addEventListener("online", this.onOnline); + window.addEventListener("offline", this.onOffline); + } + + private onOnline() { + this.emit({ + action: "ONLINE", + }); + } + + private onOffline() { + this.emit({ + action: "OFFLINE", + }); + } + + private onDropped() { + this.emit({ + action: "DISCONNECT", + }); + } + + private onReady() { + this.emit({ + action: "SUCCESS", + }); + } + + private createClient() { + this.client = new Client({ + unreads: true, + autoReconnect: false, + onPongTimeout: "EXIT", + apiURL: import.meta.env.VITE_API_URL, + }); + + this.client.addListener("dropped", this.onDropped); + this.client.addListener("ready", this.onReady); + } + + private destroyClient() { + this.client!.removeAllListeners(); + this.client = null; + } + + private assert(...state: State[]) { + let found = false; + for (const target of state) { + if (this.state === target) { + found = true; + break; + } + } + + if (!found) { + throw `State must be ${state} in order to transition! (currently ${this.state})`; + } + } + + @action async emit(data: Transition) { + switch (data.action) { + // Login with session + case "LOGIN": { + this.assert("Ready"); + this.state = "Connecting"; + this.createClient(); + + try { + await this.client!.useExistingSession(data.session); + } catch (err) { + this.state = "Ready"; + throw err; + } + + break; + } + // Ready successfully received + case "SUCCESS": { + this.assert("Connecting"); + this.state = "Online"; + break; + } + // Client got disconnected + case "DISCONNECT": { + if (navigator.onLine) { + this.assert("Online"); + this.state = "Disconnected"; + + setTimeout(() => { + this.emit({ + action: "RETRY", + }); + }, 1500); + } + + break; + } + // We should try reconnecting + case "RETRY": { + this.assert("Disconnected"); + this.client!.websocket.connect(); + this.state = "Connecting"; + break; + } + // User instructed logout + case "LOGOUT": { + this.assert("Connecting", "Online", "Disconnected"); + this.state = "Ready"; + this.destroyClient(); + break; + } + // Browser went offline + case "OFFLINE": { + this.state = "Offline"; + break; + } + // Browser went online + case "ONLINE": { + this.assert("Offline"); + if (this.client) { + this.state = "Disconnected"; + this.emit({ + action: "RETRY", + }); + } else { + this.state = "Ready"; + } + break; + } + } + } +} diff --git a/src/mobx/State.ts b/src/mobx/State.ts index e742ac44..97299b69 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -4,8 +4,7 @@ import localforage from "localforage"; import { makeAutoObservable, reaction, runInAction } from "mobx"; import { Client } from "revolt.js"; -import { reportError } from "../lib/ErrorBoundary"; - +import { clientController } from "../controllers/client/ClientController"; import Persistent from "./interfaces/Persistent"; import Syncable from "./interfaces/Syncable"; import Auth from "./stores/Auth"; @@ -24,6 +23,7 @@ import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync"; export const MIGRATIONS = { REDUX: 1640305719826, + MULTI_SERVER_CONFIG: 1656350006152, }; /** @@ -253,6 +253,9 @@ export default class State { // Post-hydration, init plugins. this.plugins.init(); + + // Push authentication information forwards to client controller. + clientController.hydrate(this.auth); } /** diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 71cea92c..1d63e7be 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -1,6 +1,4 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import { API } from "revolt.js"; -import { Nullable } from "revolt.js"; import { mapToRecord } from "../../lib/conversion"; @@ -13,7 +11,6 @@ interface Account { export interface Data { sessions: Record; - current?: string; } /** @@ -22,14 +19,12 @@ export interface Data { */ export default class Auth implements Store, Persistent { private sessions: ObservableMap; - private current: Nullable; /** * Construct new Auth store. */ constructor() { this.sessions = new ObservableMap(); - this.current = null; // Inject session token if it is provided. if (import.meta.env.VITE_SESSION_TOKEN) { @@ -40,8 +35,6 @@ export default class Auth implements Store, Persistent { token: import.meta.env.VITE_SESSION_TOKEN as string, }, }); - - this.current = "0"; } makeAutoObservable(this); @@ -54,7 +47,6 @@ export default class Auth implements Store, Persistent { @action toJSON() { return { sessions: JSON.parse(JSON.stringify(mapToRecord(this.sessions))), - current: this.current ?? undefined, }; } @@ -72,10 +64,6 @@ export default class Auth implements Store, Persistent { this.sessions.set(id, v[id]), ); } - - if (data.current && this.sessions.has(data.current)) { - this.current = data.current; - } } /** @@ -84,7 +72,6 @@ export default class Auth implements Store, Persistent { */ @action setSession(session: Session) { this.sessions.set(session.user_id, { session }); - this.current = session.user_id; } /** @@ -92,34 +79,38 @@ export default class Auth implements Store, Persistent { * @param user_id User ID tied to session */ @action removeSession(user_id: string) { - if (user_id == this.current) { - this.current = null; - } - this.sessions.delete(user_id); } + /** + * Get all known accounts. + * @returns Array of accounts + */ + @computed getAccounts() { + return [...this.sessions.values()]; + } + /** * Remove current session. */ - @action logout() { + /*@action logout() { this.current && this.removeSession(this.current); - } + }*/ /** * Get current session. * @returns Current session */ - @computed getSession() { + /*@computed getSession() { if (!this.current) return; return this.sessions.get(this.current)!.session; - } + }*/ /** * Check whether we are currently logged in. * @returns Whether we are logged in */ - @computed isLoggedIn() { + /*@computed isLoggedIn() { return this.current !== null; - } + }*/ } diff --git a/src/mobx/stores/ServerConfig.ts b/src/mobx/stores/ServerConfig.ts index 4212e82d..17ee1230 100644 --- a/src/mobx/stores/ServerConfig.ts +++ b/src/mobx/stores/ServerConfig.ts @@ -1,7 +1,5 @@ import { action, computed, makeAutoObservable } from "mobx"; -import { API } from "revolt.js"; -import { Client } from "revolt.js"; -import { Nullable } from "revolt.js"; +import { API, Client, Nullable } from "revolt.js"; import { isDebug } from "../../revision"; import Persistent from "../interfaces/Persistent"; diff --git a/src/types/revolt-api.d.ts b/src/types/revolt-api.d.ts index ae1bb349..577b1b52 100644 --- a/src/types/revolt-api.d.ts +++ b/src/types/revolt-api.d.ts @@ -5,3 +5,5 @@ declare type Session = { name: string; user_id: string; }; + +declare type SessionPrivate = Session;