try to fix this spaghetti of a code
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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; } }
|
||||
Reference in New Issue
Block a user