paco_sako_server/index.js

450 lines
12 KiB
JavaScript

'use strict';
var fs = require('fs');
var sqlite3 = require('./sqlite3-promises');
var express = require('express');
const POLLING_TIMEOUT = 60000/*ms*/;
let nextMonotonicTime = 1;
function monotonicTime() {
const now = +new Date();
const mono = (now >= nextMonotonicTime) ? now : nextMonotonicTime;
nextMonotonicTime = mono + 1;
return mono;
}
var appendJournal = (function() {
let lastJournalText = null;
return function appendJournal(text) {
if (text !== lastJournalText) {
fs.appendFileSync('journal/journal.txt', text + '\n');
lastJournalText = text;
}
};
})();
function logDbError(label) {
return function(err) {
console.error(label + ':', ((err && err.message) || err));
};
}
const dbInit = (async function dbInit() {
const db = await sqlite3.openAsync('./pacosako.db');
try {
await db.runAsync(`VACUUM`);
} catch(err) {
logDbError('vacuum')(err);
}
await db.runAsync(`
CREATE TABLE IF NOT EXISTS games (
gameId TEXT PRIMARY KEY,
lightName TEXT NOT NULL,
darkName TEXT NOT NULL,
moves INTEGER NOT NULL,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
board TEXT NOT NULL,
added INTEGER NOT NULL,
modified INTEGER NOT NULL
)
`);
await db.runAsync(`
CREATE INDEX IF NOT EXISTS games_timestamp ON games(timestamp)
`);
await db.runAsync(`
CREATE INDEX IF NOT EXISTS games_modified ON games(modified)
`);
const maxModified = await db.getAsync(`
SELECT MAX(modified) as result FROM games
`).catch(logDbError('maxModified'));
/* Just in case the system clock moved backward since the last record was written. */
if (maxModified && maxModified.result) {
nextMonotonicTime = maxModified.result + 1;
}
console.log('Connected to the SQLite database.');
return db;
})().catch(logDbError('dbInit'));
function pruneEmpty(obj) {
const copy = {};
for (const key in obj) {
const val = obj[key];
if (val !== undefined && val !== null && val !== '') {
copy[key] = obj[key];
}
}
return copy;
}
let waitingAnyGame = null;
const waitingGames = {};
function signalGameUpdate(gameId) {
const promise = waitingGames[gameId];
if (promise) {
delete waitingGames[gameId];
promise.resolve();
}
const anyPromise = waitingAnyGame;
if (anyPromise) {
waitingAnyGame = null;
anyPromise.resolve();
}
}
function waitForGameUpdate(gameId) {
let promise = waitingGames[gameId];
if (promise === undefined) {
let resolve;
promise = waitingGames[gameId] = new Promise((res) => { resolve = res; });
promise.resolve = resolve;
}
return promise;
}
function waitForAnyGameUpdate() {
if (waitingAnyGame === null) {
let resolve;
waitingAnyGame = new Promise((res) => { resolve = res; });
waitingAnyGame.resolve = resolve;
}
return waitingAnyGame;
}
function waitFor(duration) {
return new Promise((resolve, reject) => setTimeout(() => { resolve(); }, duration));
}
function checkString(value, label, dflt) {
try {
if (arguments.length >= 3 && (value === undefined || value === null)) {
return dflt;
} else if (typeof value === 'string') {
return value;
}
} catch (err) {}
throw { message: `${label || 'value'} should be a string` };
}
function checkInteger(value, label, dflt) {
try {
if (arguments.length >= 3 && (value === undefined || value === null)) {
return dflt;
} else if (Number.isInteger(value)) {
return value;
}
} catch(err) {}
throw { message: `${label || 'value'} should be an integer` };
}
function catchExceptionsJson(wrapped) {
const context = this;
return function(req, res, next) {
function internalErrorJson(err) {
res.status(500);
if (err && 'message' in err) {
console.error(err.message);
res.json({ message: 'internal error: ' + err.message });
} else {
console.error(err);
res.json({ message: 'internal error' });
}
}
try {
const result = wrapped.call(context, req, res, next);
if (result instanceof Promise) {
return result.catch(internalErrorJson);
}
} catch (err) {
internalErrorJson(err);
}
}
}
async function getGameListHandler(req, res, next) {
res.set('Cache-Control', 'no-store');
const afterTime =
(typeof req.params.afterTime === 'string') ?
Number(req.params.afterTime) :
undefined;
if (afterTime !== undefined) {
if (!Number.isInteger(afterTime) || String(afterTime) !== req.params.afterTime) {
res.status(400).json({ message: 'malformed time' });
return;
}
}
const pollTimeout = waitFor(POLLING_TIMEOUT).then(() => 'timeout').catch(()=>{});
while (true) {
/* Save the async promise _before_ the query so we don't miss any updates while suspended. */
const gameUpdate = waitForAnyGameUpdate().then(() => 'update');
const cutoff = (+new Date()) - (2 * 7 * 24 * 3600 * 1000); /* 2 weeks ago */
const whereClause = (afterTime === undefined) ? `true` : `modified > $afterTime`;
const querySql = `
SELECT gameId, lightName, darkName, moves, status, timestamp, modified
FROM games WHERE timestamp >= $cutoff AND ${whereClause} ORDER BY timestamp DESC
LIMIT 1000
`;
const results = await (await dbInit).allAsync(querySql, {
$afterTime: afterTime,
$cutoff: cutoff,
});
if (afterTime === undefined || results.length > 0) {
let lastModified = afterTime || 0;
for (const result of results) {
if (result.modified > lastModified) {
lastModified = result.modified;
}
}
res.json({ games: results.map(pruneEmpty), modified: lastModified });
return;
}
if (await Promise.race([gameUpdate, pollTimeout]) === 'timeout') {
res.status(204).json({ retry: true });
return;
}
}
}
async function getGameHandler(req, res, next) {
res.set('Cache-Control', 'no-store');
const gameId = req.params.gameId;
if (typeof gameId !== 'string' || !gameId.match(/^[0-9a-f]{16}$/)) {
res.status(400).json({ message: 'malformed game ID' });
return;
}
const afterTime =
(typeof req.params.afterTime === 'string') ?
Number(req.params.afterTime) :
undefined;
if (afterTime !== undefined && String(afterTime) !== req.params.afterTime) {
res.status(400).json({ message: 'malformed time' });
return;
}
const pollTimeout = waitFor(POLLING_TIMEOUT).then(() => 'timeout').catch(()=>{});
while (true) {
/* Save the async promise _before_ the query so we don't miss any updates while suspended. */
const gameUpdate = waitForGameUpdate(gameId).then(() => 'update');
const querySql = `
SELECT lightName, darkName, moves, status, timestamp, board, modified FROM games
WHERE gameId = $gameId
`;
const result = await (await dbInit).getAsync(querySql, { $gameId: gameId });
if (result && (afterTime === undefined || result.modified > afterTime)) {
if (req.params.type === 'meta') {
delete result.board;
} else {
result.board = (result.board === '') ? null : JSON.parse(result.board);
}
res.json(pruneEmpty(result));
return;
}
if (afterTime === undefined) {
res.status(404).json({ message: 'unknown game ID' });
return;
}
if (await Promise.race([gameUpdate, pollTimeout]) === 'timeout') {
res.status(204).json({ retry: true });
return;
}
}
}
const updateTemplate = {
lightName(x) { return typeof x === 'string'; },
darkName(x) { return typeof x === 'string'; },
moves(x) { return Number.isInteger(x); },
status(x) { return typeof x === 'string'; },
timestamp(x) { return Number.isInteger(x); },
board(x) { return true; },
modified(x) { return Number.isInteger(x); },
};
function validateUpdate(body) {
try {
if (typeof body !== 'object' || 'modified' in body === false) {
return null;
}
for (const key in body) {
if (key in updateTemplate === false) {
return null;
}
if (updateTemplate[key](body[key]) === false) {
return null;
}
}
} catch(err) {
return null;
}
return body;
}
async function postGameHandler(req, res, next) {
res.set('Cache-Control', 'no-store');
const gameId = req.params.gameId;
if (typeof gameId !== 'string' || !gameId.match(/^[0-9a-f]{16}$/)) {
res.status(400).json({ message: 'malformed game ID' });
return;
}
const body = validateUpdate(req.body);
if (!body) {
res.status(400).json({ message: 'invalid request' });
return;
}
const time = monotonicTime();
const params = {
$gameId: gameId,
$lightName: '',
$darkName: '',
$moves: 0,
$status: '',
$timestamp: 0,
$board: '',
$modified: body.modified,
$time: time,
};
let setClause = '';
let whereClause = '';
let hasBoard = false;
let hasMeta = false;
for (const key in body) {
if (key !== 'modified') {
if (key in updateTemplate === false) {
throw { message: 'internal error' };
}
if (key === 'board') {
params['$' + key] = JSON.stringify(body[key]);
hasBoard = true;
} else {
params['$' + key] = body[key];
hasMeta = true;
}
if (setClause !== '') {
setClause += ', ';
whereClause += ' OR ';
}
setClause += key + ' = $' + key;
whereClause += key + ' <> $' + key;
}
}
if (setClause === '') {
res.status(400).json({ message: 'empty update' });
return;
}
const db = await dbInit;
const insertSql = `
INSERT INTO games (gameId, lightName, darkName, moves, status, timestamp, board, added, modified)
VALUES ($gameId, $lightName, $darkName, $moves, $status, $timestamp, $board, $time, $time)
ON CONFLICT (gameId) DO UPDATE SET ${setClause}, modified = $time
WHERE (${whereClause}) AND modified = $modified
`;
const selectSql = `SELECT * FROM games WHERE gameId = $gameId`
let transactionP;
db.serialize(() => {
/* Important: We need to start all these queries without waiting in between. */
/* Otherwise db.serialize() will not have the desired effect. */
const beginP = db.execAsync(`BEGIN TRANSACTION`);
const insertP = db.runAsync(insertSql, params);
const selectP = db.getAsync(selectSql, { $gameId: gameId });
const commitP = db.execAsync(`COMMIT TRANSACTION`);
transactionP = Promise.all([beginP, insertP, commitP]).then(() => selectP);
});
/* Now wait for all the queries to finish. */
const result = await transactionP;
if (result && result.modified === params.$time) {
/* Only signal an update if the record was actually changed. No-ops don't count. */
signalGameUpdate(gameId);
} else {
/*
* If the record exists and all the values (other than .modified) match the
* request, treat this as a successful no-op. This ensures that repeated
* requests for the same update (i.e. retries) do not fail just because the
* modified time was changed.
*/
let same = false;
if (result) {
same = true;
for (const key in body) {
/* Use the value from params since e.g. body.board may be adjusted. */
if (key !== 'modified' && result[key] !== params['$' + key]) {
same = false;
break;
}
}
}
if (!same) {
res.status(409).json({ message: 'update failed', modified: (result || {}).modified });
return;
}
}
res.json({ success: true, modified: result.modified });
}
const app = express();
app.get('/pacosako/api/games/poll/:afterTime', catchExceptionsJson(getGameListHandler));
app.get('/pacosako/api/games', catchExceptionsJson(getGameListHandler));
app.get('/pacosako/api/:type(game|meta)/:gameId/poll/:afterTime', catchExceptionsJson(getGameHandler));
app.get('/pacosako/api/:type(game|meta)/:gameId', catchExceptionsJson(getGameHandler));
app.post('/pacosako/api/:type(game|meta)/:gameId', express.json(), catchExceptionsJson(postGameHandler));
var config = { port: process.env.OPENSHIFT_NODEJS_PORT || process.env.VCAP_APP_PORT || process.env.PORT || process.argv[2] || 8765 };
if (process.env.HTTPS_KEY) {
config.key = fs.readFileSync(process.env.HTTPS_KEY);
config.cert = fs.readFileSync(process.env.HTTPS_CERT);
config.server = require('https').createServer(config, app);
} else {
config.server = require('http').createServer(app);
}
config.server.keepAliveTimeout = 65 * 1000;
config.server.listen(config.port);