1173 lines
35 KiB
JavaScript
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);
|
|
});
|