try to fix this spaghetti of a code

This commit is contained in:
Frederik Spirgi
2026-09-09 10:55:40 +02:00
parent 2194ea4f0d
commit 101116472a
26 changed files with 1938 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
'use strict';
window.apiRequest = async function (url, options = {}) {
const response = await fetch(url, {credentials: 'same-origin', ...options});
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
localStorage.removeItem('activeLobbyId');
window.location.assign('/login');
}
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
return data;
};
window.RealtimeClient = class extends EventTarget {
constructor() {
super(); this.socket = null; this.ready = false; this.attempts = 0;
this.pending = new Map(); this.stopped = false;
window.addEventListener('offline', () => this.socket?.close());
window.addEventListener('pagehide', () => this.close());
}
status(message) { this.dispatchEvent(new CustomEvent('status', {detail: message})); }
connect() {
if (this.ready) return Promise.resolve();
if (this.connecting) return this.connecting;
this.stopped = false;
this.connecting = new Promise((resolve, reject) => {this.resolveReady = resolve; this.rejectReady = reject;});
if (!this.reconnectTimer && (!this.socket || this.socket.readyState >= WebSocket.CLOSING)) this.open();
return this.connecting;
}
open() {
this.reconnectTimer = null;
if (this.stopped) return;
const socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/`);
this.socket = socket;
const timeout = setTimeout(() => socket.close(), 5000);
socket.addEventListener('message', event => {
let message; try {message = JSON.parse(event.data);} catch {this.status('Server sent an invalid response.'); return;}
if (message.type === 'auth:success') {
clearTimeout(timeout); this.ready = true; this.attempts = 0;
this.resolveReady?.(); this.connecting = null; this.status('');
for (const item of this.pending.values()) socket.send(JSON.stringify(item.message));
}
const pending = this.pending.get(message.requestId);
if (pending && (message.type === 'command:ack' || message.type === 'error')) {
clearTimeout(pending.timer); this.pending.delete(message.requestId);
if (message.type === 'error') pending.reject(new Error(message.message)); else pending.resolve(message);
}
this.dispatchEvent(new CustomEvent('message', {detail: message}));
});
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…');
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.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));
});
}
async send(message) {
if (message.type === 'poker:join') { await this.connect(); this.socket.send(JSON.stringify(message)); return; }
message = {...message, requestId: message.requestId || crypto.randomUUID()};
const invalid = Protocol.validate(message); if (invalid) throw new Error(invalid);
await this.connect();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(message.requestId);
reject(new Error('No acknowledgement received. Reload to check the latest table state before retrying.'));
}, 30000);
this.pending.set(message.requestId, {message, resolve, reject, timer});
this.socket.send(JSON.stringify(message));
});
}
close() {
this.stopped = true; this.ready = false; clearTimeout(this.reconnectTimer);
this.rejectReady?.(new Error('Connection closed')); this.connecting = null;
for (const item of this.pending.values()) {clearTimeout(item.timer); item.reject(new Error('Connection closed'));}
this.pending.clear(); this.socket?.close();
}
};
+42
View File
@@ -0,0 +1,42 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.Protocol = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const string = {type: 'string', minLength: 1, maxLength: 128};
const lobbyId = {type: 'string', pattern: '^[a-f0-9]{16}$'};
const requestId = {type: 'string', pattern: '^[a-zA-Z0-9_-]{8,128}$'};
const schemas = {
'lobby:create': {type: 'object', required: ['type', 'requestId'], properties: {type: {const: 'lobby:create'}, requestId, private: {type: 'boolean'}}, additionalProperties: false},
'lobby:join': command('lobby:join'), 'lobby:leave': command('lobby:leave'), 'poker:start': command('poker:start'),
'poker:join': {type: 'object', required: ['type', 'lobbyId'], properties: {type: {const: 'poker:join'}, lobbyId}, additionalProperties: false},
'poker:action': {type: 'object', required: ['type', 'lobbyId', 'requestId', 'action'], properties: {
type: {const: 'poker:action'}, lobbyId, requestId, action: {enum: ['hit', 'fold', 'bet']},
payload: {type: 'object', properties: {amount: {type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER}}, additionalProperties: false}
}, additionalProperties: false},
credentials: {type: 'object', required: ['username', 'password'], properties: {
username: {type: 'string', minLength: 1, maxLength: 40, pattern: '^[^\\u0000-\\u001f\\u007f]+$'},
password: {type: 'string', minLength: 1, maxLength: 72}
}, additionalProperties: false}
};
function command(type) { return {type: 'object', required: ['type', 'lobbyId', 'requestId'], properties: {type: {const: type}, lobbyId, requestId}, additionalProperties: false}; }
function matches(value, schema) {
if (schema.const !== undefined && value !== schema.const) return false;
if (schema.enum && !schema.enum.includes(value)) return false;
if (schema.type === 'object') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if ((schema.required || []).some(key => !Object.hasOwn(value, key))) return false;
return Object.keys(value).every(key => Object.hasOwn(schema.properties, key) ? matches(value[key], schema.properties[key]) : schema.additionalProperties !== false);
}
if (schema.type === 'string') return typeof value === 'string' && (!schema.minLength || value.length >= schema.minLength) && (!schema.maxLength || value.length <= schema.maxLength) && (!schema.pattern || new RegExp(schema.pattern).test(value));
if (schema.type === 'integer') return Number.isSafeInteger(value) && value >= schema.minimum && value <= schema.maximum;
if (schema.type === 'boolean') return typeof value === 'boolean';
return true;
}
function validate(message) {
if (!message || !Object.hasOwn(schemas, message.type) || message.type === 'credentials' || !matches(message, schemas[message.type])) return 'Invalid message shape or fields';
if (message.type === 'poker:action' && message.action === 'bet' && !Object.hasOwn(message.payload || {}, 'amount')) return 'Bet amount is required';
return null;
}
return {schemas, matches, validate, string};
});
+7
View File
@@ -0,0 +1,7 @@
:root { --accent: #0f0; --surface: #111; --foreground: #fff; }
* { box-sizing: border-box; }
.hidden { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }
button:disabled { cursor: not-allowed; opacity: .65; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }