UNPKG

@kitten-science/kitten-analysts

Version:
197 lines 8.8 kB
import { writeFileSync } from "node:fs"; import { sleep } from "@oliversalzburg/js-utils/async/async.js"; import { isNil } from "@oliversalzburg/js-utils/data/nil.js"; import { compressToUTF16 } from "lz-string"; import { Histogram, exponentialBuckets, linearBuckets } from "prom-client"; import { WebSocket, WebSocketServer } from "ws"; import { LOCAL_STORAGE_PATH } from "../globals.js"; import { identifyExchange } from "../tools/MessageFormat.js"; export class KittensGameRemote { location; port; pendingRequests = new Map(); printProtocolMessages; saveStore; sockets = new Set(); wss; ks_iterate_duration = new Histogram({ name: "ks_iterate_duration", help: "How long each iteration of KS took.", buckets: [...linearBuckets(0, 1, 100), ...exponentialBuckets(100, 1.125, 30)], labelNames: ["client_type", "guid", "location", "manager"], }); #lastKnownHeadlessSocket = null; constructor(saveStore, port = 9093, printProtocolMessages = false) { this.port = port; this.printProtocolMessages = printProtocolMessages; this.saveStore = saveStore; this.wss = new WebSocketServer({ port }); this.location = `ws://${this.wss.address()?.address ?? "localhost"}:${this.port}/`; this.wss.on("listening", () => { process.stderr.write(`WS server listening on port ${port}...\n`); }); // eslint-disable-next-line @typescript-eslint/no-this-alias const host = this; this.wss.on("connection", ws => { ws.on("error", console.error); const socket = { ws, isAlive: true }; this.sockets.add(socket); ws.on("pong", () => { socket.isAlive = true; }); ws.on("message", function (data) { host.handleMessage(this, data); }); void this.sendMessage({ type: "connected" }); }); const interval = setInterval(() => { for (const socket of [...this.sockets.values()]) { if (!socket.isAlive) { socket.ws.terminate(); this.sockets.delete(socket); continue; } socket.isAlive = false; socket.ws.ping(); } }, 30000); this.wss.on("close", () => { clearInterval(interval); }); } closeAll() { for (const socket of this.sockets) { socket.ws.close(); } this.wss.close(); } handleMessage(socket, data) { // eslint-disable-next-line @typescript-eslint/no-base-to-string const message = JSON.parse(data.toString()); if (message.location.includes("headless.html")) { this.#lastKnownHeadlessSocket = { isAlive: true, ws: socket }; } if (!message.responseId) { switch (message.type) { case "connected": { process.stderr.write(`=> ${message.client_type}:${message.location} connected.\n`); return; } case "reportFrame": { const payload = message.data; const delta = payload.exit - payload.entry; if (this.printProtocolMessages) process.stderr.write(`=> Received frame report (${message.location}).\n`); this.ks_iterate_duration.observe({ client_type: message.location.includes("headless.html") ? "headless" : "browser", guid: message.guid, location: message.location, manager: "all", }, delta); for (const [measurement, timeTaken] of Object.entries(payload.measurements)) { if (isNil(timeTaken)) { continue; } this.ks_iterate_duration.observe({ client_type: message.location.includes("headless.html") ? "headless" : "browser", guid: message.guid, location: message.location, manager: measurement, }, timeTaken); } return; } case "reportSavegame": { const payload = message.data; if (this.printProtocolMessages) process.stderr.write(`=> Received savegame (${message.location}).\n`); const isHeadlessReport = message.location.includes("headless.html"); if (isHeadlessReport) { payload.telemetry.guid = "ka-internal-savestate"; } const calendar = payload.calendar; const saveDataCompressed = compressToUTF16(JSON.stringify(payload)); const savegame = { archived: false, guid: payload.telemetry.guid, index: { calendar: { day: calendar.day, year: calendar.year } }, label: isHeadlessReport ? "Background Game" : "Browser Game", saveData: saveDataCompressed, size: saveDataCompressed.length, timestamp: Date.now(), }; this.saveStore.set(payload.telemetry.guid, savegame); try { writeFileSync(`${LOCAL_STORAGE_PATH}/${payload.telemetry.guid}.json`, JSON.stringify(savegame)); // process.stderr.write("=> Savegame persisted to disc.\n"); } catch (error) { console.error("!> Error while persisting savegame to disc!", error); } return; } default: process.stderr.write(`!> Report with type '${message.type}' is unexpected! Message ignored.\n`); return; } } if (!this.pendingRequests.has(message.responseId)) { process.stderr.write(`!> Response ID '${message.responseId}' is unexpected! Message ignored.\n`); return; } const pendingRequest = this.pendingRequests.get(message.responseId); this.pendingRequests.delete(message.responseId); pendingRequest?.resolve(message); if (this.printProtocolMessages) process.stderr.write(`=> Request ID '${message.responseId}' was resolved.\n`); } sendMessage(message) { const clientRequests = [...this.sockets.values()].map(socket => this.#sendMessageToSocket({ ...message, client_type: "backend", guid: "ka-backend", location: this.location, }, socket)); return Promise.all(clientRequests); } #sendMessageToSocket(message, socket) { const requestId = crypto.randomUUID(); message.responseId = requestId; if (this.printProtocolMessages) process.stderr.write(`<= ${identifyExchange(message)}...\n`); const request = new Promise((resolve, reject) => { if (!socket.isAlive || socket.ws.readyState === WebSocket.CLOSED || socket.ws.readyState === WebSocket.CLOSING) { process.stderr.write("Send request can't be handled, because socket is dead!\n"); socket.isAlive = false; resolve(null); return; } this.pendingRequests.set(requestId, { resolve, reject }); socket.ws.send(JSON.stringify(message), error => { if (error) { reject(error); } }); }); return Promise.race([request, sleep(2000).then(() => null)]); } toHeadless(message) { if (isNil(this.#lastKnownHeadlessSocket)) { process.stderr.write("No headless connection registered. Message is dropped!\n"); return Promise.resolve(null); } if (!this.#lastKnownHeadlessSocket.isAlive) { process.stderr.write("Trying to send to headless session, but last known headless socket is no longer alive. Request is dropped!\n"); return Promise.resolve(null); } return this.#sendMessageToSocket({ ...message, client_type: "backend", guid: "ka-backend", location: this.location, }, this.#lastKnownHeadlessSocket); } } //# sourceMappingURL=KittensGameRemote.js.map