servatrice/webclient/src/websocket/WebClient.tsx
Jeremy Letto 19333c53f6
secure webclient socket (#4432)
Co-authored-by: Jeremy Letto <jeremy.letto@datasite.com>
2021-10-14 21:58:34 -04:00

332 lines
7.7 KiB
TypeScript

import protobuf from "protobufjs";
import { StatusEnum } from "types";
import * as roomEvents from "./events/RoomEvents";
import * as sessionEvents from "./events/SessionEvents";
import { RoomService, SessionService } from "./persistence";
import { RoomCommand, SessionCommands } from "./commands";
import ProtoFiles from "./ProtoFiles";
const roomEventKeys = Object.keys(roomEvents);
const sessionEventKeys = Object.keys(sessionEvents);
interface ApplicationCommands {
room: RoomCommand;
session: SessionCommands;
}
interface ApplicationPersistence {
room: RoomService;
session: SessionService;
}
export class WebClient {
private socket: WebSocket;
private status: StatusEnum = StatusEnum.DISCONNECTED;
private keepalivecb;
private lastPingPending = false;
private cmdId = 0;
private pendingCommands = {};
public commands: ApplicationCommands;
public persistence: ApplicationPersistence;
public protocolVersion = 14;
public pb;
public clientConfig = {
"clientver" : "webclient-1.0 (2019-10-31)",
"clientfeatures" : [
"client_id",
"client_ver",
"feature_set",
"room_chat_history",
"client_warnings",
/* unimplemented features */
"forgot_password",
"idle_client",
"mod_log_lookup",
"user_ban_history",
// satisfy server reqs for POC
"websocket",
"2.7.0_min_version",
"2.8.0_min_version"
]
};
public options: any = {
host: "",
port: "",
user: "",
pass: "",
debug: false,
autojoinrooms: true,
keepalive: 5000
};
constructor() {
const files = ProtoFiles.map(file => `${WebClient.PB_FILE_DIR}/${file}`);
this.pb = new protobuf.Root();
this.pb.load(files, { keepCase: false }, (err, root) => {
if (err) {
throw err;
}
});
// This sucks. I can"t seem to get out of this
// circular dependency trap, so this is my current best.
this.commands = {
room: new RoomCommand(this),
session: new SessionCommands(this),
};
this.persistence = {
room: new RoomService(this),
session: new SessionService(this),
};
console.log(this);
}
private clearStores() {
this.persistence.room.clearStore();
this.persistence.session.clearStore();
}
public updateStatus(status, description) {
console.log(`Status: [${status}]: ${description}`);
this.status = status;
this.persistence.session.updateStatus(status, description);
if (status === StatusEnum.DISCONNECTED) {
this.clearStores();
this.endPingLoop();
this.resetConnectionvars();
}
}
public resetConnectionvars() {
this.cmdId = 0;
this.pendingCommands = {};
this.lastPingPending = false;
}
public sendCommand(cmd, callback) {
this.cmdId++;
cmd["cmdId"] = this.cmdId;
this.pendingCommands[this.cmdId] = callback;
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(this.pb.CommandContainer.encode(cmd).finish());
this.debug(() => console.log("Sent: " + cmd.toString()));
} else {
this.debug(() => console.log("Send: Not connected"));
}
}
public sendRoomCommand(roomId, roomCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"roomId" : roomId,
"roomCommand" : [ roomCmd ]
});
this.sendCommand(cmd, raw => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public sendSessionCommand(sesCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"sessionCommand" : [ sesCmd ]
});
this.sendCommand(cmd, (raw) => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public sendModeratorCommand(modCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"moderatorCommand" : [ modCmd ]
});
this.sendCommand(cmd, (raw) => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public startPingLoop() {
this.keepalivecb = setInterval(() => {
// check if the previous ping got no reply
if (this.lastPingPending) {
this.disconnect();
this.updateStatus(StatusEnum.DISCONNECTED, "Connection timeout");
}
// stop the ping loop if we"re disconnected
if (this.status !== StatusEnum.LOGGEDIN) {
this.endPingLoop();
return;
}
// send a ping
this.lastPingPending = true;
const ping = this.pb.Command_Ping.create();
const command = this.pb.SessionCommand.create({
".Command_Ping.ext" : ping
});
this.sendSessionCommand(command, () => {
this.lastPingPending = false;
});
}, this.options.keepalive);
}
private endPingLoop() {
clearInterval(this.keepalivecb);
this.keepalivecb = null;
}
public connect(options) {
this.options = { ...this.options, ...options };
const { host, port } = this.options;
this.socket = new WebSocket("wss://" + host + ":" + port);
this.socket.binaryType = "arraybuffer"; // We are talking binary
this.socket.onopen = () => {
this.updateStatus(StatusEnum.CONNECTED, "Connected");
};
this.socket.onclose = () => {
// dont overwrite failure messages
if (this.status !== StatusEnum.DISCONNECTED) {
this.updateStatus(StatusEnum.DISCONNECTED, "Connection Closed");
}
};
this.socket.onerror = () => {
this.updateStatus(StatusEnum.DISCONNECTED, "Connection Failed");
};
this.socket.onmessage = (event) => {
const msg = this.decodeServerMessage(event);
if (msg) {
switch (msg.messageType) {
case this.pb.ServerMessage.MessageType.RESPONSE:
this.processServerResponse(msg.response);
break;
case this.pb.ServerMessage.MessageType.ROOM_EVENT:
this.processRoomEvent(msg.roomEvent, msg);
break;
case this.pb.ServerMessage.MessageType.SESSION_EVENT:
this.processSessionEvent(msg.sessionEvent, msg);
break;
case this.pb.ServerMessage.MessageType.GAME_EVENT_CONTAINER:
// @TODO
break;
}
}
}
}
public disconnect() {
if (this.socket) {
this.socket.close();
}
}
public debug(debug) {
if (this.options.debug) {
debug();
}
}
private decodeServerMessage(event) {
const uint8msg = new Uint8Array(event.data);
let msg;
try {
msg = this.pb.ServerMessage.decode(uint8msg);
this.debug(() => console.log(msg));
return msg;
} catch (err) {
console.error("Processing failed:", err);
this.debug(() => {
let str = "";
for (let i = 0; i < uint8msg.length; i++) {
str += String.fromCharCode(uint8msg[i]);
}
console.log(str);
});
return;
}
}
private processServerResponse(response) {
const cmdId = response.cmdId;
if (!this.pendingCommands.hasOwnProperty(cmdId)) {
return;
}
this.pendingCommands[cmdId](response);
delete this.pendingCommands[cmdId];
}
private processRoomEvent(response, raw) {
this.processEvent(response, roomEvents, roomEventKeys, raw);
}
private processSessionEvent(response, raw) {
this.processEvent(response, sessionEvents, sessionEventKeys, raw);
}
private processEvent(response, events, keys, raw) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const event = events[key];
const payload = response[event.id];
if (payload) {
events[key].action(payload, this, raw);
return;
}
}
}
static PB_FILE_DIR = `${process.env.PUBLIC_URL}/pb`;
}
const webClient = new WebClient();
export default webClient;