Merge pull request #8 from Fretuks/main
Refactor code for improved readability and maintainability
This commit is contained in:
+3
-2
@@ -1,4 +1,4 @@
|
||||
const http = require('node:http');
|
||||
const http = require('node:http');
|
||||
const {configuration} = require('./lib/config');
|
||||
const {createRepository} = require('./lib/repository');
|
||||
const {createSessionService} = require('./lib/sessions');
|
||||
@@ -33,7 +33,8 @@ async function createApplication(options = {}) {
|
||||
if (require.main === module) {
|
||||
require('dotenv').config();
|
||||
createApplication().then(async application => {
|
||||
const address = await application.listen();
|
||||
let address;
|
||||
try {address = await application.listen();} catch (error) {await application.close(); throw error;}
|
||||
console.log(`Server listening at http://${address.address}:${address.port}`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => application.close().catch(error => {console.error(error); process.exitCode = 1;}));
|
||||
}).catch(error => {console.error(error.message); process.exitCode = 1;});
|
||||
|
||||
+7
-2
@@ -1,4 +1,4 @@
|
||||
const crypto = require('node:crypto');
|
||||
const crypto = require('node:crypto');
|
||||
class AppError extends Error {
|
||||
constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; }
|
||||
}
|
||||
@@ -16,4 +16,9 @@ class KeyedQueue {
|
||||
async drain() { await Promise.all([...this.tails.values()]); }
|
||||
}
|
||||
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
module.exports = {AppError, KeyedQueue, hash};
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
||||
return value;
|
||||
}
|
||||
module.exports = {AppError, KeyedQueue, hash, canonical};
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
const crypto = require('node:crypto');
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {KeyedQueue, AppError, hash} = require('./core');
|
||||
const {KeyedQueue, AppError, hash, canonical} = require('./core');
|
||||
const engine = require('./engine');
|
||||
|
||||
function createLobbyService(repository, config) {
|
||||
@@ -37,8 +37,8 @@ function createLobbyService(repository, config) {
|
||||
const membershipMutation = ['lobby:create', 'lobby:join', 'lobby:leave'].includes(message.type);
|
||||
return queue.run([...(membershipMutation ? ['membership'] : []), ...(username ? [`account:${username}`] : []),
|
||||
...(message.lobbyId ? [`lobby:${message.lobbyId}`] : [])], async () => {
|
||||
const id = `${username || 'system'}:${message.requestId}`;
|
||||
const fingerprint = hash(JSON.stringify(message));
|
||||
const id = `${username ? `user:${username}` : 'system'}:${message.requestId}`;
|
||||
const fingerprint = hash(JSON.stringify(canonical(message)));
|
||||
const prior = await repository.command(id);
|
||||
if (prior) {
|
||||
if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused', 409);
|
||||
|
||||
+5
-2
@@ -47,7 +47,7 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {})
|
||||
`);
|
||||
if (old) {
|
||||
// Fails and rolls back rather than silently rounding or losing invalid balances.
|
||||
await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; DROP TABLE legacy_users;');
|
||||
await tx.exec(`INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;`);
|
||||
}
|
||||
await tx.exec('PRAGMA user_version = 1');
|
||||
});
|
||||
@@ -58,7 +58,10 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {})
|
||||
user: username => read(tx => tx.get('SELECT username, password_hash AS passwordHash, session_hash AS sessionHash, money FROM users WHERE username = ?', username)),
|
||||
users: () => read(tx => tx.all('SELECT username, money FROM users ORDER BY money DESC, username ASC')),
|
||||
async register(username, passwordHash, sessionHash) {
|
||||
try { await transaction(tx => tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash)); }
|
||||
try { await transaction(async tx => {
|
||||
await tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash);
|
||||
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,200,?)', `register:${username}`, username, 'Starting chips');
|
||||
}); }
|
||||
catch (error) { if (error.code === 'SQLITE_CONSTRAINT') throw new AppError('USERNAME_EXISTS', 'Username already exists', 409); throw error; }
|
||||
},
|
||||
setSession: (username, sessionHash) => transaction(tx => tx.run('UPDATE users SET session_hash = ? WHERE username = ?', sessionHash, username)),
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
window.apiRequest = async function (url, options = {}) {
|
||||
const response = await fetch(url, {credentials: 'same-origin', ...options});
|
||||
const response = await fetch(url, {credentials: 'same-origin', signal: AbortSignal.timeout(5000), ...options});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('activeLobbyId');
|
||||
@@ -45,15 +45,16 @@ window.RealtimeClient = class extends EventTarget {
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('message', {detail: message}));
|
||||
});
|
||||
socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…'));
|
||||
socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting...'));
|
||||
socket.addEventListener('close', async event => {
|
||||
clearTimeout(timeout); this.ready = false;
|
||||
if (this.stopped) return;
|
||||
this.status('Connection interrupted. Reconnecting…');
|
||||
this.status('Connection interrupted. Reconnecting...');
|
||||
if (event.code === 1008 && /Session/.test(event.reason)) {
|
||||
this.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login'); return;
|
||||
}
|
||||
try { await apiRequest('/verify', {method: 'POST'}); } catch { /* apiRequest redirects expired sessions; network outages are retried. */ }
|
||||
if (this.stopped) return;
|
||||
if (++this.attempts > 5) { this.close(); this.status('Could not reconnect. Reload to try again.'); return; }
|
||||
this.reconnectTimer = setTimeout(() => this.open(), Math.min(500 * 2 ** (this.attempts - 1), 5000));
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
if (winner) {
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = `Winner: ${winner}`;
|
||||
heading.textContent = `Payout recipients: ${winner}`;
|
||||
showdownPanel.appendChild(heading);
|
||||
}
|
||||
|
||||
@@ -248,6 +248,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
if (message.type === "auth:error" || message.type === "poker:error" || message.type === "error") {
|
||||
if (["NOT_SEATED", "LOBBY_NOT_FOUND"].includes(message.code)) {
|
||||
localStorage.removeItem("activeLobbyId");
|
||||
location.assign("/");
|
||||
return;
|
||||
}
|
||||
showError(message.message || "Poker connection failed");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ test('accepted actions persist once; failed commits leave both memory and databa
|
||||
await assert.rejects(api.lobbies.command('b', message), /Injected/);
|
||||
assert.equal(JSON.stringify(api.lobbies.get(lobbyId)), before);
|
||||
assert.equal((await api.repository.user('b')).money, 190);
|
||||
await Promise.all([api.lobbies.command('b', message), api.lobbies.command('b', message)]);
|
||||
const reordered = {payload: message.payload, action: message.action, requestId: message.requestId, lobbyId: message.lobbyId, type: message.type};
|
||||
await Promise.all([api.lobbies.command('b', message), api.lobbies.command('b', reordered)]);
|
||||
assert.equal((await api.repository.user('b')).money, 170);
|
||||
assert.equal(api.lobbies.get(lobbyId).game.pot, 50);
|
||||
await assert.rejects(api.lobbies.command('b', {...message, action: 'fold'}), error => error.code === 'IDEMPOTENCY_CONFLICT');
|
||||
@@ -97,3 +98,30 @@ test('reopening a database refunds interrupted escrow exactly once', async t =>
|
||||
assert.equal((await restarted.user('a')).money, 200); assert.equal((await restarted.user('b')).money, 200);
|
||||
} finally {await restarted.close();}
|
||||
});
|
||||
test('account membership and host starts serialize even when requests arrive together', async t => {
|
||||
const api = await createApplication({secret,databasePath:':memory:',bcryptRounds:4,intermissionMs:60000});
|
||||
t.after(()=>api.close());
|
||||
for(const username of ['a','b']) await api.sessions.register({username,password:'password'});
|
||||
const creates=await Promise.allSettled([command(api.lobbies,'a','lobby:create'),command(api.lobbies,'a','lobby:create')]);
|
||||
assert.equal(creates.filter(item=>item.status==='fulfilled').length,1);
|
||||
const {lobbyId}=creates.find(item=>item.status==='fulfilled').value;
|
||||
await command(api.lobbies,'b','lobby:join',{lobbyId});
|
||||
const starts=await Promise.allSettled([command(api.lobbies,'a','poker:start',{lobbyId}),command(api.lobbies,'a','poker:start',{lobbyId})]);
|
||||
assert.equal(starts.filter(item=>item.status==='fulfilled').length,1);
|
||||
});
|
||||
test('a departure waits for an in-flight action commit and cannot overwrite its balance', async t => {
|
||||
let pause=null;
|
||||
const {api,lobbyId}=await setup(t,{repositoryHooks:{beforeCommit:async()=>{if(pause) await pause();}}});
|
||||
let reached,release;
|
||||
const entered=new Promise(resolve=>{reached=resolve;});
|
||||
const gate=new Promise(resolve=>{release=resolve;});
|
||||
pause=async()=>{reached();await gate;};
|
||||
const action=command(api.lobbies,'b','poker:action',{lobbyId,action:'bet',payload:{amount:20}});
|
||||
await entered;
|
||||
const departure=command(api.lobbies,'b','lobby:leave',{lobbyId});
|
||||
pause=null;release();
|
||||
await Promise.all([action,departure]);
|
||||
assert.equal((await api.repository.user('b')).money,170);
|
||||
assert.equal(api.lobbies.get(lobbyId).game.contributions.b,30);
|
||||
const totals=await api.repository.totals();assert.equal(totals.users+totals.escrow,600);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user