@kitten-science/kitten-analysts
Version:
197 lines • 8.8 kB
JavaScript
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