-/games.backup.json
+/server/games.backup.json
/public/config.js
if [ -d "$WORKING_DIRECTORY/.git" ]; then
if [ -z "$(git status --porcelain | grep '.js$')" ]; then
- sed -i -E 's/(^|\(|\s)(let|const)(\s)/\1var\3/g' "$WORKING_DIRECTORY"/*.js
+ sed -i -E 's/(^|\(|\s)(let|const)(\s)/\1var\3/g' "$WORKING_DIRECTORY/public"/*.js
else
>&2 echo "You have untracked changes in JS files. I'm not going to mess up with your working copy."
exit 1
exec "$(dirname "$0")/disable-const-let.sh"
-if ! [ -f "$(dirname "$(dirname "$0")")/config.js" ]; then
- touch "$(dirname "$(dirname "$0")")/config.js"
+if ! [ -f "$(dirname "$(dirname "$0")")/public/config.js" ]; then
+ touch "$(dirname "$(dirname "$0")")/public/config.js"
fi
exit 1
}
-if [ -z "${1+x}" ]; then
- error "Please provide the production directory in parameter."
-fi
+help() {
+ cat <<EOF
+$0: - update the production.
+
+This program updates Trivabble to the latest version tagged for production, and copies and prepares Trivabble for use in production.
+Trivabble is composed of two parts: the server and the client.
+The client is to be served by a regular HTTP server and lives in the public folder.
+The server is to be stored somewhere on your system that is not served by your HTTP server.
+
+Parameters:
+ --prod-public-dir DIR: the path to the public directory, from which your HTTP server will serve the client
+ --prod-server-dir DIR: the path to the server directory, not served by your HTTP server.
+EOF
+ exit 0
+}
if [ "$(id -u)" = "0" ]; then
error "Not running as root. Please run me as your webserver's user for instance, using something like sudo -H -u www-data $0 $1"
fi
-PROD_DIR="$1"
+while ! [ -z "${1+x}" ]; do
+ if [ "$1" = "--prod-public-dir" ]; then
+ PROD_PUBLIC_DIR=$2
+ shift
+ shift
+ elif [ "$1" = "--prod-server-dir" ]; then
+ PROD_SERVER_DIR=$2
+ shift
+ shift
+ elif [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
+ help
+ else
+ error "Unrecognized argument $1. Type --help for more information."
+ fi
+done
+
+if [ -z "${PROD_SERVER_DIR+x}" ]; then
+ error "The server production directory is not set. Type --help for more information."
+fi
-if ! [ -d "$PROD_DIR" ]; then
- error "The production directory $PROD_DIR does not exist. I give up."
+if [ -z "${PROD_PUBLIC_DIR+x}" ]; then
+ error "The public production directory is not set. Type --help for more information."
fi
-if [ -d "$PROD_DIR/.git" ]; then
- error "The production folder should not be a git repository."
+if ! [ -d "$PROD_SERVER_DIR" ]; then
+ error "The server production directory $PROD_SERVER_DIR does not exist. I give up."
+fi
+
+if ! [ -d "$PROD_PUBLIC_DIR" ]; then
+ error "The public production directory $PROD_PUBLIC_DIR does not exist. I give up."
fi
WORKING_DIRECTORY="$(dirname "$(dirname "$0")")"
error "Not a git repository. Cannot update."
fi
-if [ -f "$WORKING_DIRECTORY/games.backup.json" ]; then
+if [ -f "$WORKING_DIRECTORY/server/games.backup.json" ] || [ -f "$WORKING_DIRECTORY/games.backup.json" ]; then
error "Your working copy has a 'games.backup.json' file. I give up."
fi
echo "Setting up production mode..."
"$(dirname $0)"/setup_prod.sh
-BACKUP_DIR="$WORKING_DIRECTORY/../trivabble-prod.bak"
-
-echo "Backing up production to $BACKUP_DIR ..."
-rsync -avp --delete "$PROD_DIR/" "$BACKUP_DIR/"
-
echo "Sending to production..."
-rsync -avp --delete --exclude 'games.backup.json' --exclude 'config.js' --exclude '/.git' "$WORKING_DIRECTORY/" "$PROD_DIR/"
+rsync -avp --delete --exclude 'config.js' "$WORKING_DIRECTORY/public/" "$PROD_PUBLIC_DIR/"
+rsync -avp --delete --exclude 'config.js' "$WORKING_DIRECTORY/server/" "$PROD_SERVER_DIR/"
echo echo "Resetting the directory..."
git reset --hard "$new_version"
--- /dev/null
+/* 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 VERSION = 202004281800;
+
+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 fs = require("fs");
+const crypto = require("crypto");
+
+const REQUEST_TYPE_LONG_POLLING = 1;
+const REQUEST_TYPE_SSE = 2;
+const REQUEST_TYPE_WEBSOCKET = 3;
+
+const frBag = [
+ " ", " ", // Jokers
+ "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E",
+ "A", "A", "A", "A", "A", "A", "A", "A", "A",
+ "I", "I", "I", "I", "I", "I", "I", "I",
+ "N", "N", "N", "N", "N", "N",
+ "O", "O", "O", "O", "O", "O",
+ "R", "R", "R", "R", "R", "R",
+ "S", "S", "S", "S", "S", "S",
+ "T", "T", "T", "T", "T", "T",
+ "U", "U", "U", "U", "U", "U",
+ "L", "L", "L", "L", "L",
+ "D", "D", "D",
+ "M", "M", "M",
+ "G", "G",
+ "B", "B",
+ "C", "C",
+ "P", "P",
+ "F", "F",
+ "H", "H",
+ "V", "V",
+ "J",
+ "Q",
+ "K",
+ "W",
+ "X",
+ "Y",
+ "Z"
+];
+
+/* eslint-disable quote-props */
+
+const frValues = {
+ " ": 0,
+ "E": 1,
+ "A": 1,
+ "I": 1,
+ "N": 1,
+ "O": 1,
+ "R": 1,
+ "S": 1,
+ "T": 1,
+ "U": 1,
+ "L": 1,
+ "D": 2,
+ "M": 2,
+ "G": 2,
+ "B": 3,
+ "C": 3,
+ "P": 3,
+ "F": 4,
+ "H": 4,
+ "V": 4,
+ "J": 8,
+ "Q": 8,
+ "K": 10,
+ "W": 10,
+ "X": 10,
+ "Y": 10,
+ "Z": 10
+};
+
+/* eslint-enable quote-props */
+
+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.letterValues = frValues;
+ 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 () {
+ this.board = newBoard();
+ this.bag = frBag.slice();
+ this.racks = {};
+ this.scores = {};
+ this.lastUpdated = new Date();
+ this.currentPlayer = "";
+
+ shuffleInPlace(this.bag);
+};
+
+Game.prototype.toJSON = function () {
+ return {
+ board: this.board,
+ bag: this.bag,
+ 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.bag = obj.bag || frBag.slice();
+ 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 in this.racks) {
+ if (Object.prototype.hasOwnProperty.call(this.racks, player)) {
+ 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.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,
+ currentPlayer: game.currentPlayer,
+ rack: game.getPlayerRack(playerName),
+ board: game.board,
+ remainingLetters: game.bag.length,
+ letterValues: frValues,
+ version: VERSION
+ });
+ break;
+ }
+
+ case "hello": {
+ game.playerJoined(playerName);
+ reply(message, response, cmdNumber, {error: 0, 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];
+ 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 "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,
+ 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);
+
+ fs.exists("public/" + request.url, function (exists) {
+ if (exists) {
+ fs.readFile("public/" + request.url, 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 in backup) {
+ if (Object.prototype.hasOwnProperty.call(backup, gameNumber)) {
+ 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);
+});
+++ /dev/null
-/* 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 VERSION = 202004281800;
-
-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 fs = require("fs");
-const crypto = require("crypto");
-
-const REQUEST_TYPE_LONG_POLLING = 1;
-const REQUEST_TYPE_SSE = 2;
-const REQUEST_TYPE_WEBSOCKET = 3;
-
-const frBag = [
- " ", " ", // Jokers
- "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E",
- "A", "A", "A", "A", "A", "A", "A", "A", "A",
- "I", "I", "I", "I", "I", "I", "I", "I",
- "N", "N", "N", "N", "N", "N",
- "O", "O", "O", "O", "O", "O",
- "R", "R", "R", "R", "R", "R",
- "S", "S", "S", "S", "S", "S",
- "T", "T", "T", "T", "T", "T",
- "U", "U", "U", "U", "U", "U",
- "L", "L", "L", "L", "L",
- "D", "D", "D",
- "M", "M", "M",
- "G", "G",
- "B", "B",
- "C", "C",
- "P", "P",
- "F", "F",
- "H", "H",
- "V", "V",
- "J",
- "Q",
- "K",
- "W",
- "X",
- "Y",
- "Z"
-];
-
-/* eslint-disable quote-props */
-
-const frValues = {
- " ": 0,
- "E": 1,
- "A": 1,
- "I": 1,
- "N": 1,
- "O": 1,
- "R": 1,
- "S": 1,
- "T": 1,
- "U": 1,
- "L": 1,
- "D": 2,
- "M": 2,
- "G": 2,
- "B": 3,
- "C": 3,
- "P": 3,
- "F": 4,
- "H": 4,
- "V": 4,
- "J": 8,
- "Q": 8,
- "K": 10,
- "W": 10,
- "X": 10,
- "Y": 10,
- "Z": 10
-};
-
-/* eslint-enable quote-props */
-
-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.letterValues = frValues;
- 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 () {
- this.board = newBoard();
- this.bag = frBag.slice();
- this.racks = {};
- this.scores = {};
- this.lastUpdated = new Date();
- this.currentPlayer = "";
-
- shuffleInPlace(this.bag);
-};
-
-Game.prototype.toJSON = function () {
- return {
- board: this.board,
- bag: this.bag,
- 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.bag = obj.bag || frBag.slice();
- 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 in this.racks) {
- if (Object.prototype.hasOwnProperty.call(this.racks, player)) {
- 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.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,
- currentPlayer: game.currentPlayer,
- rack: game.getPlayerRack(playerName),
- board: game.board,
- remainingLetters: game.bag.length,
- letterValues: frValues,
- version: VERSION
- });
- break;
- }
-
- case "hello": {
- game.playerJoined(playerName);
- reply(message, response, cmdNumber, {error: 0, 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];
- 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 "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,
- 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);
-
- fs.exists("public/" + request.url, function (exists) {
- if (exists) {
- fs.readFile("public/" + request.url, 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 in backup) {
- if (Object.prototype.hasOwnProperty.call(backup, gameNumber)) {
- 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);
-});