feat: build finite state machine for sessions

This commit is contained in:
Paul Makles 2022-06-28 13:20:08 +01:00
parent 1cfcb20d4d
commit 80f4bb3d98
6 changed files with 235 additions and 28 deletions

View file

@ -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<string, Session>;
/**
* User ID of active session
*/
private current: Nullable<string>;
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();

View file

@ -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;
}
}
}
}

View file

@ -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);
}
/**

View file

@ -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<string, Account>;
current?: string;
}
/**
@ -22,14 +19,12 @@ export interface Data {
*/
export default class Auth implements Store, Persistent<Data> {
private sessions: ObservableMap<string, Account>;
private current: Nullable<string>;
/**
* 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<Data> {
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<Data> {
@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<Data> {
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<Data> {
*/
@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<Data> {
* @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;
}
}*/
}

View file

@ -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";

View file

@ -5,3 +5,5 @@ declare type Session = {
name: string;
user_id: string;
};
declare type SessionPrivate = Session;