mirror of
https://github.com/revoltchat/revite.git
synced 2025-02-21 07:42:52 -05:00
feat: build finite state machine for sessions
This commit is contained in:
parent
1cfcb20d4d
commit
80f4bb3d98
6 changed files with 235 additions and 28 deletions
49
src/controllers/client/ClientController.tsx
Normal file
49
src/controllers/client/ClientController.tsx
Normal 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();
|
164
src/controllers/client/Session.tsx
Normal file
164
src/controllers/client/Session.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,7 @@ import localforage from "localforage";
|
||||||
import { makeAutoObservable, reaction, runInAction } from "mobx";
|
import { makeAutoObservable, reaction, runInAction } from "mobx";
|
||||||
import { Client } from "revolt.js";
|
import { Client } from "revolt.js";
|
||||||
|
|
||||||
import { reportError } from "../lib/ErrorBoundary";
|
import { clientController } from "../controllers/client/ClientController";
|
||||||
|
|
||||||
import Persistent from "./interfaces/Persistent";
|
import Persistent from "./interfaces/Persistent";
|
||||||
import Syncable from "./interfaces/Syncable";
|
import Syncable from "./interfaces/Syncable";
|
||||||
import Auth from "./stores/Auth";
|
import Auth from "./stores/Auth";
|
||||||
|
@ -24,6 +23,7 @@ import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync";
|
||||||
|
|
||||||
export const MIGRATIONS = {
|
export const MIGRATIONS = {
|
||||||
REDUX: 1640305719826,
|
REDUX: 1640305719826,
|
||||||
|
MULTI_SERVER_CONFIG: 1656350006152,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -253,6 +253,9 @@ export default class State {
|
||||||
|
|
||||||
// Post-hydration, init plugins.
|
// Post-hydration, init plugins.
|
||||||
this.plugins.init();
|
this.plugins.init();
|
||||||
|
|
||||||
|
// Push authentication information forwards to client controller.
|
||||||
|
clientController.hydrate(this.auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
|
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
|
||||||
import { API } from "revolt.js";
|
|
||||||
import { Nullable } from "revolt.js";
|
|
||||||
|
|
||||||
import { mapToRecord } from "../../lib/conversion";
|
import { mapToRecord } from "../../lib/conversion";
|
||||||
|
|
||||||
|
@ -13,7 +11,6 @@ interface Account {
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
sessions: Record<string, Account>;
|
sessions: Record<string, Account>;
|
||||||
current?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,14 +19,12 @@ export interface Data {
|
||||||
*/
|
*/
|
||||||
export default class Auth implements Store, Persistent<Data> {
|
export default class Auth implements Store, Persistent<Data> {
|
||||||
private sessions: ObservableMap<string, Account>;
|
private sessions: ObservableMap<string, Account>;
|
||||||
private current: Nullable<string>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct new Auth store.
|
* Construct new Auth store.
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sessions = new ObservableMap();
|
this.sessions = new ObservableMap();
|
||||||
this.current = null;
|
|
||||||
|
|
||||||
// Inject session token if it is provided.
|
// Inject session token if it is provided.
|
||||||
if (import.meta.env.VITE_SESSION_TOKEN) {
|
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,
|
token: import.meta.env.VITE_SESSION_TOKEN as string,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.current = "0";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
|
@ -54,7 +47,6 @@ export default class Auth implements Store, Persistent<Data> {
|
||||||
@action toJSON() {
|
@action toJSON() {
|
||||||
return {
|
return {
|
||||||
sessions: JSON.parse(JSON.stringify(mapToRecord(this.sessions))),
|
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]),
|
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) {
|
@action setSession(session: Session) {
|
||||||
this.sessions.set(session.user_id, { 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
|
* @param user_id User ID tied to session
|
||||||
*/
|
*/
|
||||||
@action removeSession(user_id: string) {
|
@action removeSession(user_id: string) {
|
||||||
if (user_id == this.current) {
|
|
||||||
this.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessions.delete(user_id);
|
this.sessions.delete(user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known accounts.
|
||||||
|
* @returns Array of accounts
|
||||||
|
*/
|
||||||
|
@computed getAccounts() {
|
||||||
|
return [...this.sessions.values()];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove current session.
|
* Remove current session.
|
||||||
*/
|
*/
|
||||||
@action logout() {
|
/*@action logout() {
|
||||||
this.current && this.removeSession(this.current);
|
this.current && this.removeSession(this.current);
|
||||||
}
|
}*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current session.
|
* Get current session.
|
||||||
* @returns Current session
|
* @returns Current session
|
||||||
*/
|
*/
|
||||||
@computed getSession() {
|
/*@computed getSession() {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
return this.sessions.get(this.current)!.session;
|
return this.sessions.get(this.current)!.session;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether we are currently logged in.
|
* Check whether we are currently logged in.
|
||||||
* @returns Whether we are logged in
|
* @returns Whether we are logged in
|
||||||
*/
|
*/
|
||||||
@computed isLoggedIn() {
|
/*@computed isLoggedIn() {
|
||||||
return this.current !== null;
|
return this.current !== null;
|
||||||
}
|
}*/
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { action, computed, makeAutoObservable } from "mobx";
|
import { action, computed, makeAutoObservable } from "mobx";
|
||||||
import { API } from "revolt.js";
|
import { API, Client, Nullable } from "revolt.js";
|
||||||
import { Client } from "revolt.js";
|
|
||||||
import { Nullable } from "revolt.js";
|
|
||||||
|
|
||||||
import { isDebug } from "../../revision";
|
import { isDebug } from "../../revision";
|
||||||
import Persistent from "../interfaces/Persistent";
|
import Persistent from "../interfaces/Persistent";
|
||||||
|
|
2
src/types/revolt-api.d.ts
vendored
2
src/types/revolt-api.d.ts
vendored
|
@ -5,3 +5,5 @@ declare type Session = {
|
||||||
name: string;
|
name: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare type SessionPrivate = Session;
|
||||||
|
|
Loading…
Add table
Reference in a new issue