422 lines
11 KiB
JavaScript
422 lines
11 KiB
JavaScript
'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 === undefined) ? '' : `/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: */
|