trivabble/server/trivabble-server.js

1173 lines
35 KiB
JavaScript

/* eslint-disable no-process-env */
/**
* Copyright (C) 2016-2020 Raphaël Jakse <raphael.trivabble@jakse.fr>
*
* @licstart
* This file is part of Trivabble.
*
* Trivabble is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License (GNU AGPL)
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* Trivabble is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Trivabble. If not, see <http://www.gnu.org/licenses/>.
* @licend
*
* @source: https://trivabble.1s.fr/
* @source: https://gitlab.com/raphj/trivabble/
*/
/*eslint strict: [2, "global"]*/
"use strict";
const port = parseInt(process.env.TRIVABBLE_PORT || "3000");
const SAVE_TIMEOUT = 5000;
const KEEP_ALIVE = 30000;
const GAMES_BACKUP = process.env.TRIVABBLE_GAMES_BACKUP || "games.backup.json";
const DEFAULT_BOARD_LANG = process.env.TRIVABBLE_DEFAULT_BOARD_LANG || "fr";
const VERSION = 202005070100;
function envTrue(name) {
return (process.env[name] || "").toLowerCase() === "true";
}
const DEV_ENABLE_SERVING_FILES = envTrue("DEV_ENABLE_SERVING_FILES");
const DEBUG_LOG = DEV_ENABLE_SERVING_FILES || envTrue("DEBUG_LOG");
if (DEV_ENABLE_SERVING_FILES) {
console.log("DEV_ENABLE_SERVING_FILES: Serving files in the current directory. Please never do this on a production server, this is for development purposes only.");
}
const debuglog = DEBUG_LOG ? console.log.bind(console) : () => null;
const http = require("http");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const REQUEST_TYPE_LONG_POLLING = 1;
const REQUEST_TYPE_SSE = 2;
const REQUEST_TYPE_WEBSOCKET = 3;
/* eslint no-sync: ["error", { allowAtRootLevel: true }] */
/* Manage multi language board */
const boardTilesPerLang = {};
const availableBoardLangs = {};
for (const lang of fs.readdirSync(path.join(__dirname, "lang"))) {
const data = require(path.join(__dirname, "lang", lang)); // eslint-disable-line global-require
boardTilesPerLang[data.code] = data;
availableBoardLangs[data.code] = data.name;
}
const games = {};
let saveTimeout = null;
let running = true;
const server = http.createServer(handleRequest);
server.setTimeout(0); // The default in node 13 is 0. Earlier versions have 120.
function saveGames(callback) {
fs.writeFile(GAMES_BACKUP, JSON.stringify(games), function (err) {
if (err) {
console.error("ERROR: Cannot save games!");
}
if (typeof callback === "function") {
return callback(err);
}
});
saveTimeout = null;
}
function shuffleInPlace(a) {
// https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const x = a[i];
a[i] = a[j];
a[j] = x;
}
}
function Game() {
this.init();
this.listeningPlayers = [];
this.pendingEvents = [];
}
function writeMessage(responseAndType, data, terminate) {
if (!responseAndType[0]) {
return;
}
if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET) {
webSocketWrite(responseAndType[0], data);
} else {
responseAndType[0][terminate ? "end" : "write"](
responseAndType[1] === REQUEST_TYPE_SSE
? "data:" + data + "\n\n"
: data.length + data
);
}
if (terminate) {
stopKeepAlive(responseAndType);
if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET && responseAndType[0] && !responseAndType[0].isDestroyed) {
responseAndType[0].end(webSocketCloseBuffer);
responseAndType[0] = null;
}
} else {
keepAlive(responseAndType);
}
}
function stop() {
console.log("Saving games a first time");
if (saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = null;
}
saveGames(function () {
running = false;
console.log("Closing connections and saving the game state...");
let listeningPlayerCount = 0;
let gamesCount = 0;
for (const gameID of Object.keys(games)) {
const game = games[gameID];
for (const listeningPlayer of game.listeningPlayers) {
writeMessage(listeningPlayer, '{"stopping": 2000}', true);
}
if (game.listeningPlayers.length) {
listeningPlayerCount += game.listeningPlayers.length;
gamesCount++;
}
}
console.log(
"Stopped", gamesCount, "game" + (gamesCount === 1 ? "" : "s"),
"and", listeningPlayerCount, "player connection" + (listeningPlayerCount === 1 ? "" : "s") +
"."
);
server.close(saveGames);
});
}
const webSocketPingBuffer = Buffer.from([
0b10001001, 0b00000000
]);
const webSocketCloseBuffer = Buffer.from([
0b10001000, 0b00000000
]);
function stopKeepAlive(responseAndType) {
if (responseAndType.keepAliveTimeout) {
clearTimeout(responseAndType.keepAliveTimeout);
responseAndType.keepAliveTimeout = 0;
}
}
function keepAlive(responseAndType) {
stopKeepAlive(responseAndType);
responseAndType.keepAliveTimeout = setTimeout(function keepAliveTimeout() {
responseAndType[0].write(
responseAndType[1] === REQUEST_TYPE_WEBSOCKET
? webSocketPingBuffer
: (
responseAndType[1] === REQUEST_TYPE_SSE
? ":\n\n"
: "2[]"
)
);
}, KEEP_ALIVE);
}
function newBoard() {
const res = new Array(15 * 15);
for (let i = 0; i < 15 * 15; i++) {
res[i] = "";
}
return res;
}
Game.prototype.init = function (lang) {
this.board = newBoard();
this.lang = lang || DEFAULT_BOARD_LANG;
this.bag = boardTilesPerLang[this.lang].bag.slice();
this.letterValues = boardTilesPerLang[this.lang].letterValues;
this.racks = {};
this.scores = {};
this.lastUpdated = new Date();
this.currentPlayer = "";
shuffleInPlace(this.bag);
};
Game.prototype.toJSON = function () {
return {
board: this.board,
lang: this.lang,
bag: this.bag,
letterValues: this.letterValues,
racks: this.racks,
scores: this.scores,
lastUpdated: this.lastUpdated.toISOString(),
currentPlayer: this.currentPlayer
};
};
Game.fromJSON = function (obj) {
const game = new Game();
game.board = obj.board || newBoard();
game.lang = obj.lang || DEFAULT_BOARD_LANG;
game.bag = boardTilesPerLang[game.lang].bag.slice();
game.letterValues = boardTilesPerLang[game.lang].letterValues;
game.racks = obj.racks || {};
game.scores = obj.scores || {};
game.lastUpdated = obj.lastUpdated ? new Date(obj.lastUpdated) : new Date();
game.currentPlayer = obj.currentPlayer || "";
return game;
};
Game.prototype.getPlayerRack = function (player) {
const playerID = "#" + player;
return (this.racks[playerID] || (this.racks[playerID] = []));
};
Game.prototype.getPlayerScore = function (player) {
const playerID = "#" + player;
return (this.scores[playerID] || (this.scores[playerID] = 0));
};
Game.prototype.setPlayerScore = function (player, score) {
const playerID = "#" + player;
if (!Object.prototype.hasOwnProperty.call(this.racks, playerID) || typeof score !== "number") {
return;
}
this.scores[playerID] = score;
this.pendingEvents.push({
players: [{
player: player,
score: score
}]
});
};
Game.prototype.setCurrentPlayer = function (player) {
this.currentPlayer = player;
this.pendingEvents.push({currentPlayer: player});
};
Game.prototype.playerJoined = function (playerName) {
if (playerName) {
this.getPlayerRack(playerName); // Create the player's rack
}
const players = [];
for (let player of Object.keys(this.racks)) {
player = player.slice(1); // '#'
players.push({
player: player,
score: this.getPlayerScore(player),
rackCount: countTiles(this.getPlayerRack(player))
});
}
this.pendingEvents.push({players: players});
};
Game.prototype.addListeningPlayer = function (playerName, responseAndType) {
const that = this;
that.listeningPlayers.push(responseAndType);
keepAlive(responseAndType);
let closed = false;
function close() {
if (closed) {
return;
}
closed = true;
stopKeepAlive(responseAndType);
responseAndType[0] = null;
const index = that.listeningPlayers.indexOf(responseAndType);
if (index !== -1) {
that.listeningPlayers[index] = that.listeningPlayers[that.listeningPlayers.length - 1];
that.listeningPlayers.pop();
}
}
responseAndType[0].on("error", close);
responseAndType[0].on("close", close);
responseAndType[0].on("finish", close);
responseAndType[0].on("prefinish", close);
this.playerJoined(playerName);
this.commit();
};
Game.prototype.commit = function () {
const msg = JSON.stringify(this.pendingEvents);
this.pendingEvents = [];
for (let i = 0; i < this.listeningPlayers.length; i++) {
while (i < this.listeningPlayers.length && !this.listeningPlayers[i]) {
this.listeningPlayers[i] = this.listeningPlayers[this.listeningPlayers.length - 1];
this.listeningPlayers.pop();
}
if (this.listeningPlayers[i]) {
writeMessage(this.listeningPlayers[i], msg);
}
}
if (saveTimeout === null) {
saveTimeout = setTimeout(saveGames, SAVE_TIMEOUT);
}
};
Game.prototype.bagPopLetter = function (player) {
if (this.bag.length) {
const letter = this.bag.pop();
this.pendingEvents.push({
player: player,
action: "popBag",
remainingLetters: this.bag.length
});
return letter;
}
return "";
};
Game.prototype.getCell = function (index) {
return this.board[index];
};
Game.prototype.setCell = function (index, letter, player) {
this.board[index] = letter;
this.pendingEvents.push({
player: player,
action: "setCell",
indexTo: index,
letter: letter
});
};
Game.prototype.bagPushLetter = function (letter, player) {
if (letter) {
this.bag.push(letter);
shuffleInPlace(this.bag);
this.pendingEvents.push({
player: player,
action: "pushBag",
remainingLetters: this.bag.length
});
}
};
Game.prototype.reset = function (player) {
this.init(this.lang);
this.pendingEvents.push({
player: player,
action: "reset",
board: this.board,
remainingLetters: this.bag.length,
rack: []
});
this.playerJoined();
};
function newGameId() {
let number;
let k = 10000;
let retries = 0;
do {
number = Math.floor(Math.random() * k).toString();
if (retries > 10) {
retries = 0;
k *= 10;
} else {
retries++;
}
} while (games[number]);
return number.toString();
}
function countTiles(rack) {
let count = 0;
for (let i = 0; i < rack.length; i++) {
if (rack[i]) {
count++;
}
}
return count;
}
function joinGame(gameNumber) {
if (!gameNumber) {
gameNumber = newGameId();
}
const game = games[gameNumber] || (games[gameNumber] = new Game());
return {gameNumber, game};
}
function reply(message, response, cmdNumber, r) {
response.write(
JSON.stringify({...r, id: message.id || message.cmds[cmdNumber].id})
);
}
function handleCommand(cmdNumber, message, response) {
const {gameNumber, game} = joinGame(message.gameNumber);
game.lastUpdated = new Date();
const playerName = message.playerName;
let rack = null;
const cmd = message.cmds[cmdNumber];
switch (cmd.cmd) {
case "sync": // DEPRECATED. Here for old clients.
case "joinGame": {
reply(message, response, cmdNumber, {
error: 0,
gameNumber: gameNumber,
playerName: playerName,
boardLang: game.lang,
availableBoardLangs: availableBoardLangs,
currentPlayer: game.currentPlayer,
rack: game.getPlayerRack(playerName),
board: game.board,
remainingLetters: game.bag.length,
letterletterValues: game.letterValues,
version: VERSION
});
break;
}
case "hello": {
game.playerJoined(playerName);
reply(message, response, cmdNumber, {error: 0, boardLang: game.lang, version: VERSION});
break;
}
case "score": {
game.setPlayerScore(cmd.player, cmd.score);
reply(message, response, cmdNumber, {error: 0});
break;
}
case "currentPlayer": {
game.setCurrentPlayer(cmd.player);
reply(message, response, cmdNumber, {error: 0});
break;
}
case "moveLetter": {
let letter = "";
// This case can fail in various ways. Instead altering the state
// of the game immediately, we store the operations to run them
// at the very end, if nothing failed.
const operations = [];
switch (cmd.from) {
case "rack":
rack = game.getPlayerRack(playerName);
if (cmd.indexFrom > 6 || cmd.indexFrom < 0) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"});
return false;
}
letter = rack[cmd.indexFrom];
if (!letter) {
reply(message, response, cmdNumber, {error: 1, reason: "Moving from an empty location"});
return false;
}
operations.push(() => {
rack[cmd.indexFrom] = "";
});
break;
case "board":
if (cmd.indexFrom < 0 || cmd.indexFrom >= 15 * 15) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"});
return false;
}
letter = game.getCell(cmd.indexFrom);
operations.push(game.setCell.bind(game, cmd.indexFrom, "", playerName));
break;
case "bag":
if (!game.bag.length) {
reply(message, response, cmdNumber, {error: 1, reason: "Empty bag"});
return false;
}
operations.push(() => {
letter = game.bagPopLetter(playerName);
});
break;
default:
reply(message, response, cmdNumber, {error: 1, reason: "Moving letter from an unknown place"});
return false;
}
switch (cmd.to) {
case "rack":
if (cmd.indexTo < 0 || cmd.indexTo > 6) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"});
return false;
}
rack = rack || game.getPlayerRack(playerName);
if (rack[cmd.indexTo]) {
reply(message, response, cmdNumber, {error: 1, reason: "Moving a tile to a non-empty location"});
return false;
}
operations.push(() => {
rack[cmd.indexTo] = letter;
});
break;
case "board":
if (cmd.indexTo < 0 || cmd.indexTo >= 15 * 15) {
reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"});
return false;
}
operations.push(game.setCell.bind(game, cmd.indexTo, letter, playerName));
break;
case "bag":
operations.push(game.bagPushLetter.bind(game, letter, playerName));
break;
default:
response.write("{\"error\":1, \"reason\":\"Moving letter to an unknown place\"}");
return false;
}
for (const operation of operations) {
operation();
}
reply(message, response, cmdNumber, {
error: 0,
rack: (
(cmd.from === "bag" && cmd.to === "rack")
? rack
: undefined // eslint-disable-line no-undefined
),
remainingLetters: game.bag.length
});
if (rack) {
game.pendingEvents.push({
players: [{
player: playerName,
rackCount: countTiles(rack)
}]
});
}
break;
}
case "setRack": {
if (cmd.rack.length > 7) {
reply(message, response, cmdNumber, {
error: 1,
rack: rack,
reason: "the new rack is not at the right size"
});
return false;
}
rack = game.getPlayerRack(playerName);
const oldRackSorted = rack.filter((l) => l);
oldRackSorted.sort();
const newRackSorted = cmd.rack.filter((l) => l);
newRackSorted.sort();
for (let i = 0; i < 7; i++) {
if ((oldRackSorted[i] !== newRackSorted[i]) && (oldRackSorted[i] || newRackSorted[i])) {
reply(message, response, cmdNumber, {
error: 1,
rack: rack,
reason: "the new rack doesn't contain the same number of letters"
});
return false;
}
}
for (let i = 0; i < 7; i++) {
rack[i] = cmd.rack[i];
}
reply(message, response, cmdNumber, {error: 0});
break;
}
case "resetGame": {
game.reset();
reply(message, response, cmdNumber, {error: 0});
break;
}
case "changeBoard": {
game.lang = cmd.lang || DEFAULT_BOARD_LANG;
game.reset();
reply(message, response, cmdNumber, {error: 0, boardLang: game.lang, letterValues: game.letterValues});
break;
}
case "msg": {
game.pendingEvents.push({
msg: {
sender: playerName,
content: cmd.msg,
specialMsg: cmd.specialMsg
}
});
reply(message, response, cmdNumber, {error: 0});
break;
}
default: {
reply(message, response, cmdNumber, {error: 1, reason: "Unknown command"});
return false;
}
}
return true;
}
function handleCommands(message, responseAndType) {
if (!message.cmds || !message.cmds.length) {
const {gameNumber, game} = joinGame(message.gameNumber);
writeMessage(responseAndType,
JSON.stringify({
playerName: message.playerName,
currentPlayer: game.currentPlayer,
gameNumber: gameNumber,
boardLang: game.lang,
availableBoardLangs: availableBoardLangs,
letterValues: game.letterValues,
rack: game.getPlayerRack(message.playerName),
board: game.board,
remainingLetters: game.bag.length,
version: VERSION
})
);
game.addListeningPlayer(message.playerName, responseAndType);
return;
}
let wsMessage = "";
const response = (
responseAndType[1] === REQUEST_TYPE_WEBSOCKET
? {
write: function (s) {
wsMessage += s;
},
end: function (s) {
if (s) {
wsMessage += s;
}
webSocketWrite(responseAndType[0], wsMessage);
}
}
: responseAndType[0]
);
response.write("[");
for (let i = 0; i < message.cmds.length; i++) {
if (i) {
response.write(",");
}
if (!handleCommand(i, message, response)) {
break;
}
}
response.end("]");
if (games[message.gameNumber]) {
games[message.gameNumber].commit();
}
}
// Thx https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8
function webSocketWrite(socket, data) {
/* eslint-disable capitalized-comments */
// Copy the data into a buffer
data = Buffer.from(data);
const byteLength = data.length;
// Note: we're not supporting > 65535 byte payloads at this stage
const lengthByteCount = byteLength < 126 ? 0 : 2;
const payloadLength = lengthByteCount === 0 ? byteLength : 126;
const buffer = Buffer.alloc(2 + lengthByteCount + byteLength);
// Write out the first byte, using opcode `1` to indicate that the message
// payload contains text data
buffer.writeUInt8(0b10000001, 0);
buffer.writeUInt8(payloadLength, 1);
// Write the length of the payload to the second byte
if (lengthByteCount === 2) {
buffer.writeUInt16BE(byteLength, 2);
}
// Write the data to the data buffer
data.copy(buffer, 2 + lengthByteCount);
socket.write(buffer);
}
function webSocketGetMessage(buffer) {
/* eslint-disable no-bitwise, capitalized-comments */
let dataAfter = "";
let finalOffset = -1;
if (buffer.length < 2) {
return ["", 0];
}
const firstByte = buffer.readUInt8(0);
const isFinalFrame = Boolean((firstByte >>> 7) & 0x1);
// const [reserved1, reserved2, reserved3] = [
// Boolean((firstByte >>> 6) & 0x1),
// Boolean((firstByte >>> 5) & 0x1),
// Boolean((firstByte >>> 4) & 0x1)
// ];
const opCode = firstByte & 0xF;
// We can return null to signify that this is a connection termination frame
if (opCode === 0x8) {
return null;
}
const secondByte = buffer.readUInt8(1);
const isMasked = (secondByte >>> 7) & 0x1;
// Keep track of our current position as we advance through the buffer
let currentOffset = 2;
let payloadLength = secondByte & 0x7F;
if (payloadLength > 125) {
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(currentOffset);
currentOffset += 2;
} else {
// 127
// If this has a value, the frame size is ridiculously huge!
// const leftPart = buffer.readUInt32BE(currentOffset);
// const rightPart = buffer.readUInt32BE(currentOffset += 4);
// Honestly, if the frame length requires 64 bits, you're probably doing it wrong.
// In Node.js you'll require the BigInt type, or a special library to handle this.
throw new Error("Large websocket payloads not currently implemented");
}
}
if (payloadLength + currentOffset > buffer.length) {
return ["", 0];
}
if (!isFinalFrame) {
const message = webSocketGetMessage(buffer.slice(payloadLength + currentOffset));
if (message) {
if (message[1]) {
dataAfter = message[0];
finalOffset = message[1];
} else {
return ["", 0];
}
} else if (message === null) {
// ??!?
return null;
}
}
let maskingKey;
if (isMasked) {
maskingKey = buffer.readUInt32BE(currentOffset);
currentOffset += 4;
}
// Allocate somewhere to store the final message data
const data = Buffer.alloc(payloadLength);
// Only unmask the data if the masking bit was set to 1
if (isMasked) {
// Loop through the source buffer one byte at a time, keeping track of which
// byte in the masking key to use in the next XOR calculation
for (let i = 0, j = 0; i < payloadLength; ++i, j = i % 4) {
// Extract the correct byte mask from the masking key
const shift = (j === 3) ? 0 : (3 - j) << 3;
const mask = (shift === 0 ? maskingKey : (maskingKey >>> shift)) & 0xFF;
// Read a byte from the source buffer
const source = buffer.readUInt8(currentOffset++);
// XOR the source byte and write the result to the data buffer
data.writeUInt8(mask ^ source, i);
}
} else {
// Not masked - we can just read the data as-is
buffer.copy(data, 0, currentOffset);
}
return [
opCode === 0x1
? (data.toString("utf8") + dataAfter)
: "",
finalOffset === -1
? currentOffset
: finalOffset
];
}
function handleRequest(request, response) {
let post = "";
const responseAndType = [response, REQUEST_TYPE_LONG_POLLING];
let upgradedToWebSocket = false;
response.on("error", function connectionError(e) {
console.error("An error occurred while trying to write on a socket", e);
stopKeepAlive(responseAndType);
});
// Thx http://stackoverflow.com/questions/4295782/how-do-you-extract-post-data-in-node-js
request.on("data", function (data) {
if (upgradedToWebSocket) {
return;
}
post += data;
// Too much POST data, kill the connection!
if (post.length > 1e6) {
request.connection.destroy();
}
});
request.on("upgrade", function () {
upgradedToWebSocket = true;
});
request.on("error", function (error) {
console.error("Error while handling this request", request, error);
});
request.on("end", function () {
if (upgradedToWebSocket) {
return;
}
if (!running) {
response.statusCode = 503;
response.statusMessage = "Server is stopping";
response.end("Server is stopping");
return;
}
if (DEV_ENABLE_SERVING_FILES && !request.url.startsWith("/:")) {
if (request.url === "/") {
request.url = "/index.html";
}
debuglog("Serving " + request.url);
const requestedPath = path.join(__dirname, "..", "public", request.url);
fs.exists(requestedPath, function (exists) {
if (exists) {
fs.readFile(requestedPath, function (err, contents) {
if (err) {
response.statusCode = 500;
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.end(err);
}
let mime = "application/xhtml+xml; charset=utf-8";
const mimes = {
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml"
};
for (const i in mimes) {
if (Object.prototype.hasOwnProperty.call(mimes, i) && request.url.endsWith(i)) {
mime = mimes[i];
if (i === ".js") {
contents = contents.toString().replace(
/(?<before>^|\s|\()(?:const|let)(?<after>\s)/gu,
"$1var$2"
);
}
break;
}
}
response.setHeader("Content-Type", mime);
response.end(contents);
});
} else {
response.statusCode = 404;
response.end("404");
}
});
return;
}
const sseMatch = request.url.match(/\/(?::[^/]+\/)?sse\/(?<data>[\s\S]+)$/u);
if (sseMatch) {
response.setHeader("Content-Type", "text/event-stream");
post = decodeURIComponent(sseMatch.groups.data);
responseAndType[1] = REQUEST_TYPE_SSE;
} else {
response.setHeader("Content-Type", "text/plain");
response.setHeader("Transfer-Encoding", "chunked");
}
debuglog("REQ:", request.url, post, responseAndType[1]);
try {
post = post && JSON.parse(post);
} catch (e) {
response.statusCode = 400;
response.setHeader("application/json");
response.end('{"error":1, "reason": "Unable to parse your JSON data"}');
return;
}
handleCommands(post, responseAndType);
});
}
fs.readFile(GAMES_BACKUP, function (err, data) {
try {
if (err) {
console.error("WARNING: Could not restore previous backup of the games");
} else {
const backup = JSON.parse(data);
for (const gameNumber of Object.keys(backup)) {
games[gameNumber] = Game.fromJSON(backup[gameNumber]);
}
}
} catch (e) {
console.error("WARNING: Could not restore previous backup of the games: file is broken:");
console.error("WARNING: ", e);
}
// See https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
server.on("upgrade", function upgradeToWebsocket(request, socket) {
const responseAndType = [socket, REQUEST_TYPE_WEBSOCKET];
function closeSocket() {
responseAndType[0] = null;
}
socket.on("error", function (e) {
console.error("Socket error", e.toString());
closeSocket();
});
socket.on("close", closeSocket);
const webSocketMatch = request.url.match(/\/(?::[^/]+\/)?ws\/(?<data>[\s\S]+)$/u);
let decodedData;
try {
decodedData = decodeURIComponent(webSocketMatch.groups.data);
} catch (e) {
socket.end("HTTP/1.1 400 Bad Request");
console.error("WS: FAILED - Could not decode the given data " + webSocketMatch.groups.data);
return;
}
let message;
if (webSocketMatch) {
try {
message = JSON.parse(decodedData);
} catch (e) {
socket.end("HTTP/1.1 400 Bad Request");
console.error("WS: FAILED - Could not parse the given data " + decodedData);
return;
}
} else {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
if (request.headers.upgrade.toLowerCase() !== "websocket") {
socket.end("HTTP/1.1 400 Bad Request");
console.error("WS: FAILED - HTTP header 'Upgrade' is not 'websocket' " + decodedData);
return;
}
socket.write(
"HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
"Upgrade: WebSocket\r\n" +
"Connection: Upgrade\r\n"
);
const webSocketKey = request.headers["sec-websocket-key"];
if (webSocketKey) {
socket.write(
"Sec-WebSocket-Accept: " + (
crypto
.createHash("sha1")
.update(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "binary")
.digest("base64")
) + "\r\n"
);
}
socket.write("\r\n");
debuglog("WS: established " + decodedData);
let receivedBytes = null;
socket.on("data", function webSocketData(buffer) {
if (receivedBytes) {
buffer = Buffer.concat([receivedBytes, buffer]);
receivedBytes = null;
}
while (true) {
const wsMessage = webSocketGetMessage(buffer);
if (wsMessage && wsMessage[1]) {
if (wsMessage[0]) {
debuglog("WS message: " + wsMessage[0]);
try {
message.cmds = JSON.parse(wsMessage[0]);
} catch (e) {
writeMessage(responseAndType, '{"error":1, "reason": "Unable to parse your JSON data"}');
return;
}
handleCommands(message, responseAndType);
message.cmds = null;
}
} else if (wsMessage === null) {
if (responseAndType[0] && !socket.isDestroyed) {
responseAndType[0] = null;
socket.end(webSocketCloseBuffer);
}
return;
}
if (wsMessage[1] === buffer.length) {
break;
}
if (wsMessage[1] === 0) {
receivedBytes = buffer;
return;
}
buffer = buffer.slice(wsMessage[1]);
}
});
handleCommands(message, responseAndType);
});
server.on("error", function (error) {
console.error("An error happened in the HTTP server", error);
});
server.listen(port, function () {
console.log("Server listening on: http://localhost:%s", port);
});
});
process.once("SIGINT", function () {
console.log("SIGINT received...");
stop();
});
process.once("SIGTERM", function () {
console.log("SIGTERM received...");
stop();
});
process.on("uncaughtException", function (err) {
console.log(err);
});