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;