paco_sako/js/pacosako_io.js

419 lines
11 KiB
JavaScript

'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,
connectionState: 'initializing',
};
const stateListeners = {};
const 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: */