'use strict'; const API_BASE = `https://jessemcdonald.info/pacosako/api`; const SHORT_TIMEOUT = 5000/*ms*/; const LONG_TIMEOUT = 90000/*ms*/; const RETRIES = 3; const RETRY_DELAY = 1000/*ms*/; const games = {}; const meta = { games: {}, lastModified: 0, listeners: {}, nextId: 1, currentRequest: null, }; /* One-time request, no caching or polling */ function getGameState(gameId, retries) { if (arguments.length < 2) { retries = RETRIES; } return new Promise((resolve, reject) => { $.ajax({ dataType: 'json', contentType: 'application/json', url: `${API_BASE}/game/${gameId}`, cache: false, timeout: SHORT_TIMEOUT, }).done((data, textStatus, jqXHR) => { resolve(data); }).fail((jqXHR, textStatus, errorThrown) => { if ((!jqXHR.status || jqXHR.status < 400 || jqXHR.status > 499) && retries > 0) { setTimeout(() => { getGameState(gameId, retries - 1).then(resolve, reject); }, RETRY_DELAY); } else { reject({ status: jqXHR.status, data: jqXHR.responseJSON, textStatus, errorThrown }); } }); }); } /* One-time request, no caching or polling */ function getGameMeta(gameId, retries) { if (arguments.length < 2) { retries = RETRIES; } return new Promise((resolve, reject) => { $.ajax({ dataType: 'json', contentType: 'application/json', url: `${API_BASE}/meta/${gameId}`, cache: false, timeout: SHORT_TIMEOUT, }).done((data, textStatus, jqXHR) => { resolve(data); }).fail((jqXHR, textStatus, errorThrown) => { if ((!jqXHR.status || jqXHR.status < 400 || jqXHR.status > 499) && retries > 0) { setTimeout(() => { getGameMeta(gameId, retries - 1).then(resolve, reject); }, RETRY_DELAY); } else { reject({ status: jqXHR.status, data: jqXHR.responseJSON, textStatus, errorThrown }); } }); }); } function onGameUpdate(gameId, callback) { const game = gamePollState(gameId); if (Object.keys(game.listeners).length === 0) { startGamePoll(gameId, game.data.modified); } const cbId = game.nextId; game.nextId += 1; game.listeners[cbId] = callback; /* delay the initial run of the callback to the microtask queue */ (Promise.resolve()).then(() => { if (cbId in game.listeners) { try { callback(game.data, gameId); } catch(err) { console.error('uncaught exception in callback', err); } } }); return function clearGameUpdate(keepPolling) { if (cbId in game.listeners) { delete game.listeners[cbId]; if (!keepPolling && Object.keys(game.listeners).length === 0) { stopGamePoll(gameId); } } } } function onMetaUpdate(callback) { if (Object.keys(meta.listeners).length === 0) { startMetaPoll(meta.lastModified); } const cbId = meta.nextId; meta.nextId += 1; meta.listeners[cbId] = callback; /* delay the initial run of the callback to the microtask queue */ (Promise.resolve()).then(() => { if (cbId in meta.listeners) { for (const gameId in meta.games) { try { callback(meta.games[gameId], gameId); } catch(err) { console.error('uncaught exception in callback', err); } } } }); return function clearMetaUpdate(keepPolling) { if (cbId in meta.listeners) { delete meta.listeners[cbId]; if (!keepPolling && Object.keys(meta.listeners).length === 0) { stopMetaPoll(); } } } } function sendUpdate(gameId, data, retries) { if (arguments.length < 3) { retries = RETRIES; } return new Promise((resolve, reject) => { $.ajax({ dataType: 'json', contentType: 'application/json', url: `${API_BASE}/game/${gameId}`, method: 'POST', cache: false, data: JSON.stringify(data), timeout: SHORT_TIMEOUT, }).done((responseData, textStatus, jqXHR) => { resolve({ status: jqXHR.status, data: responseData }); }).fail((jqXHR, textStatus, errorThrown) => { if ((!jqXHR.status || jqXHR.status < 400 || jqXHR.status > 499) && retries > 0) { setTimeout(() => { sendUpdate(gameId, data, retries - 1).then(resolve, reject); }, RETRY_DELAY); } else { reject({ status: jqXHR.status, data: jqXHR.responseJSON, textStatus, errorThrown }); } }); }); } function gamePollState(gameId) { if (gameId in games === false) { games[gameId] = { currentRequest: null, data: { gameId, modified: 0 }, listeners: [], nextId: 1, }; } return games[gameId]; } function processGame(gameId, data) { const game = gamePollState(gameId); if (data && data.modified > game.data.modified) { game.data = data; for (const cbId in game.listeners) { try { game.listeners[cbId](data, gameId); } catch(err) { console.error('uncaught exception in callback', err); } } } } function startGamePoll(gameId, afterTime) { const game = gamePollState(gameId); if (game.currentRequest === null) { if (afterTime === undefined) { afterTime = game.data.modified; } const thisRequest = game.currentRequest = $.ajax({ dataType: 'json', contentType: 'application/json', url: `${API_BASE}/game/${gameId}/poll/${afterTime}`, cache: false, timeout: LONG_TIMEOUT, }).done((data, textStatus, jqXHR) => { if (game.currentRequest === thisRequest) { game.currentRequest = null; if (jqXHR.status == 204) { startGamePoll(gameId, afterTime); } else { processGame(gameId, data); startGamePoll(gameId, data.modified); } } }).fail((jqXHR, textStatus, errorThrown) => { if (game.currentRequest === thisRequest) { setTimeout(() => { if (game.currentRequest === thisRequest) { game.currentRequest = null; startGamePoll(gameId, 0); } }, RETRY_DELAY); } }); } } function stopGamePoll(gameId) { if (gameId in games === false) { return; } const game = games[gameId]; const request = game.currentRequest; if (request !== null) { game.currentRequest = null; request.abort(); } } function processMeta(data) { meta.lastModified = data.modified; for (const game of data.games) { if (game.gameId in meta.games === false || game.modified > meta.games[game.gameId].modified) { meta.games[game.gameId] = game; for (const cbId in meta.listeners) { try { meta.listeners[cbId](game, game.gameId); } catch(err) { console.error('uncaught exception in callback', err); } } } } } function startMetaPoll(afterTime) { if (meta.currentRequest === null) { if (afterTime === undefined) { afterTime = meta.lastModified; } const thisRequest = meta.currentRequest = $.ajax({ dataType: 'json', contentType: 'application/json', url: `https://jessemcdonald.info/pacosako/api/games/poll/${afterTime}`, cache: false, timeout: LONG_TIMEOUT, }).done((data, textStatus, jqXHR) => { if (meta.currentRequest === thisRequest) { meta.currentRequest = null; if (jqXHR.status == 204) { startMetaPoll(afterTime); } else { processMeta(data); startMetaPoll(data.modified); } } }).fail((jqXHR, textStatus, errorThrown) => { if (meta.currentRequest === thisRequest) { setTimeout(() => { if (meta.currentRequest === thisRequest) { meta.currentRequest = null; startMetaPoll(0); } }, RETRY_DELAY); } }); } } function stopMetaPoll() { const request = meta.currentRequest; if (request !== null) { meta.currentRequest = null; request.abort(); } } module.exports = { getGameState, getGameMeta, onGameUpdate, onMetaUpdate, sendUpdate, }; /* vim:set expandtab sw=3 ts=8: */