'use strict'; // Use the full URL to access production data from clients hosted on other servers. //const API_BASE = `https://pacosako.jessemcdonald.info/api`; const API_BASE = `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, connectionState: 'initializing', }; const stateListeners = {}; let stateNextId = 1; /* 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 getCachedGame(gameId) { if (gameId in games) { return JSON.stringify(games[gameId].data); } else { return undefined; } } function getCachedMeta() { return JSON.parse(JSON.stringify(meta.games)); } 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 onConnectionStateChanged(gameId, callback) { if (arguments.length < 2) { callback = gameId; gameId = undefined; } if (gameId !== undefined) { const original = callback; callback = function(cbGameId, cbState) { if (cbGameId === gameId) { original(cbState); } }; } const cbId = stateNextId; stateNextId += 1; stateListeners[cbId] = callback; return function clearConnectionStateChanged() { delete stateListeners[cbId]; }; } function getConnectionState(gameId) { if (arguments.length < 1) { return meta.connectionState; } else { return games[gameId] && games[gameId].connectionState; } } function setConnectionState(game, state) { let gameId; if (arguments.length < 2) { gameId = 'meta'; state = game; game = meta; } else if (typeof game === 'string') { gameId = game; game = gamePollState(game); } else { gameId = game.gameId; } if (state !== game.connectionState) { if (state !== 'initializing' && state !== 'connecting' && state !== 'polling' && state !== 'failed' && state !== 'stopped') { throw new TypeError(`invalid connection state: ${state}`); } game.connectionState = state; for (const cbId in stateListeners) { try { stateListeners[cbId](gameId, state); } catch(err) { console.error('uncaught exception in callback', err); } } } } function gamePollState(gameId) { if (gameId in games === false) { games[gameId] = { currentRequest: null, data: { gameId, modified: 0 }, listeners: [], nextId: 1, connectionState: 'initializing', }; } return games[gameId]; } function processGame(gameId, data) { const game = gamePollState(gameId); if (data && data.modified > game.data.modified) { data.gameId = data.gameId || gameId; game.data = data; for (const cbId in game.listeners) { try { const copy = JSON.parse(JSON.stringify(data)); game.listeners[cbId](copy, 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; } if (game.connectionState !== 'polling') { if (game.connectionState !== 'failed') { setConnectionState(game, 'connecting'); } } const thisRequest = game.currentRequest = $.ajax({ dataType: 'json', contentType: 'application/json', url: `${API_BASE}/game/${gameId}/poll/${afterTime || 0}`, cache: false, timeout: LONG_TIMEOUT, }).done((data, textStatus, jqXHR) => { if (game.currentRequest === thisRequest) { game.currentRequest = null; if (jqXHR.status !== 204) { afterTime = data.modified; processGame(gameId, data); } setConnectionState(game, 'polling'); startGamePoll(gameId, afterTime); } }).fail((/*jqXHR, textStatus, errorThrown*/) => { if (game.currentRequest === thisRequest) { setConnectionState(game, 'failed'); 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; setConnectionState(game, 'stopped'); request.abort(); } } function processMeta(data) { if (data.modified > meta.lastModified) { meta.lastModified = data.modified; } for (const game of data.games) { const gameId = game.gameId; if (gameId in meta.games === false || game.modified > meta.games[gameId].modified) { meta.games[gameId] = game; for (const cbId in meta.listeners) { try { const copy = Object.assign({}, game); meta.listeners[cbId](copy, gameId); } catch(err) { console.error('uncaught exception in callback', err); } } } } } function startMetaPoll(afterTime) { if (meta.currentRequest === null) { if (afterTime === undefined) { afterTime = meta.lastModified; } if (meta.connectionState !== 'polling') { if (meta.connectionState !== 'failed') { setConnectionState('connecting'); } } const thisRequest = meta.currentRequest = $.ajax({ dataType: 'json', contentType: 'application/json', url: `${API_BASE}/games${!afterTime ? '' : `/poll/${afterTime}`}`, cache: false, timeout: afterTime ? LONG_TIMEOUT : SHORT_TIMEOUT, }).done((data, textStatus, jqXHR) => { if (meta.currentRequest === thisRequest) { meta.currentRequest = null; if (jqXHR.status !== 204) { afterTime = data.modified; processMeta(data); } setConnectionState('polling'); startMetaPoll(afterTime); } }).fail((/*jqXHR, textStatus, errorThrown*/) => { if (meta.currentRequest === thisRequest) { setConnectionState('failed'); setTimeout(() => { if (meta.currentRequest === thisRequest) { meta.currentRequest = null; startMetaPoll(0); } }, RETRY_DELAY); } }); } } function stopMetaPoll() { const request = meta.currentRequest; if (request !== null) { meta.currentRequest = null; setConnectionState('stopped'); request.abort(); } } module.exports = { getGameState, getGameMeta, onGameUpdate, onMetaUpdate, getCachedGame, getCachedMeta, sendUpdate, getConnectionState, onConnectionStateChanged, }; /* vim:set expandtab sw=3 ts=8: */