import { GmcpHandler } from '$lib/gmcp/GmcpHandler'; import { EventEmitter } from '$lib/utils/EventEmitter'; import { TELNET, TelnetParser } from './TelnetParser'; export interface MudConnectionOptions { id: string; host: string; port: number; useSSL?: boolean; } export type MudConnectionState = 'idle' | 'connecting' | 'connected' | 'resuming' | 'disconnecting' | 'error'; interface ProxyControlMessage { type: string; resumeToken?: string; messageCount?: number; messagesReplayed?: number; code?: string; message?: string; reason?: string; } export class MudConnection extends EventEmitter { public readonly id: string; private readonly host: string; private readonly port: number; private readonly useSSL: boolean; private webSocket: WebSocket | null = null; private state: MudConnectionState = 'idle'; private explicitDisconnect = false; private reconnectAttempts = 0; private reconnectTimer: number | null = null; private resumeToken?: string; private gmcpEnabled = false; private readonly gmcpHandler: GmcpHandler; private readonly parser: TelnetParser; constructor(options: MudConnectionOptions) { super(); this.id = options.id; this.host = options.host; this.port = options.port; this.useSSL = options.useSSL ?? false; this.resumeToken = this.loadResumeToken(); this.gmcpHandler = new GmcpHandler(); this.parser = new TelnetParser({ onText: (text) => this.emit('received', text), onNegotiation: (command, option) => this.handleNegotiation(command, option), onSubnegotiation: (option, payload) => this.handleSubnegotiation(option, payload), onProtocolError: (message) => this.emit('error', message) }); this.gmcpHandler.on('gmcp', (module: string, data: unknown) => this.emit('gmcp', module, data)); this.gmcpHandler.on('*', (eventName: string, ...args: unknown[]) => { if (eventName.startsWith('gmcp:')) this.emit(eventName, ...args); }); this.gmcpHandler.on('playSound', (url: string, volume: number, loop: boolean) => this.emit('playSound', { url, volume, loop })); this.gmcpHandler.on('sendGmcp', (module: string, data: unknown) => this.sendGmcp(module, data)); } public connect(): void { if (this.webSocket && (this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING)) return; this.explicitDisconnect = false; this.setState(this.resumeToken ? 'resuming' : 'connecting'); const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const authority = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ? `${window.location.hostname}:3001` : window.location.host; const socket = new WebSocket(`${protocol}://${authority}/mud-ws`); socket.binaryType = 'arraybuffer'; this.webSocket = socket; socket.onopen = () => socket.send(JSON.stringify({ type: 'connect', host: this.host, port: this.port, tls: this.useSSL, resumeToken: this.resumeToken })); socket.onmessage = (event) => this.handleWebSocketMessage(event.data); socket.onerror = () => { this.setState('error'); this.emit('error', `WebSocket connection to the proxy failed for ${this.host}:${this.port}.`); }; socket.onclose = () => { if (this.webSocket === socket) this.webSocket = null; if (this.explicitDisconnect) { this.setState('idle'); this.emit('disconnected'); } else { this.setState('idle'); this.emit('disconnected'); this.scheduleReconnect(); } }; } public disconnect(): void { this.explicitDisconnect = true; this.clearReconnectTimer(); this.clearResumeToken(); this.setState('disconnecting'); const socket = this.webSocket; if (!socket) { this.setState('idle'); return; } if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'disconnect' })); window.setTimeout(() => { if (socket.readyState < WebSocket.CLOSING) socket.close(); }, 1_000); } else socket.close(); } public send(text: string): void { this.sendBytes(new TextEncoder().encode(`${text}\r\n`)); this.emit('sent', text); } public sendGmcp(module: string, data: unknown): void { if (!this.gmcpEnabled) return; const payload = new TextEncoder().encode(`${module} ${JSON.stringify(data)}`); const message = new Uint8Array(payload.length + 5); message.set([TELNET.IAC, TELNET.SB, TELNET.GMCP], 0); message.set(payload, 3); message.set([TELNET.IAC, TELNET.SE], payload.length + 3); this.sendBytes(message); } public getGmcpHandler(): GmcpHandler { return this.gmcpHandler; } public isConnected(): boolean { return this.state === 'connected'; } public getState(): MudConnectionState { return this.state; } public getSessionId(): string | undefined { return this.resumeToken; } public matchesTarget(options: { host: string; port: number; useSSL?: boolean }): boolean { return this.host === options.host && this.port === options.port && this.useSSL === (options.useSSL ?? false); } public resetReconnectionState(): void { this.reconnectAttempts = 0; this.clearReconnectTimer(); } private handleWebSocketMessage(data: string | ArrayBuffer | Blob): void { if (typeof data === 'string') { this.handleControlMessage(data); return; } if (data instanceof ArrayBuffer) this.parser.feed(new Uint8Array(data)); else data.arrayBuffer().then((buffer) => this.parser.feed(new Uint8Array(buffer))).catch(() => this.emit('error', 'Unable to read proxy data.')); } private handleControlMessage(raw: string): void { let control: ProxyControlMessage; try { control = JSON.parse(raw) as ProxyControlMessage; } catch { this.emit('error', 'The proxy returned an invalid control message.'); return; } switch (control.type) { case 'session_started': this.storeResumeToken(control.resumeToken); this.reconnectAttempts = 0; this.setState('connected'); this.emit('connected', { resumed: false }); break; case 'session_resumed': this.storeResumeToken(control.resumeToken); this.reconnectAttempts = 0; this.setState('connected'); this.emit('connected', { resumed: true }); this.emit('session_resumed', { messagesReplayed: 0 }); break; case 'replay_started': this.emit('message_replay_start', { messageCount: control.messageCount ?? 0 }); break; case 'replay_finished': this.emit('message_replay_complete', { messagesReplayed: control.messagesReplayed ?? 0 }); break; case 'upstream_closed': this.clearResumeToken(); this.emit('error', `MUD connection closed: ${control.reason ?? 'upstream closed'}`); break; case 'error': if (control.code === 'CONNECT_ERROR') this.clearResumeToken(); this.setState('error'); this.emit('error', control.message ?? 'Proxy error.'); break; default: this.emit('error', `Unknown proxy message type: ${control.type}`); } } private handleNegotiation(command: number, option: number): void { if (command === TELNET.WILL) { if (option === TELNET.GMCP) { this.sendIac(TELNET.DO, option); if (!this.gmcpEnabled) { this.gmcpEnabled = true; this.gmcpHandler.requestCapabilities(); } } else { this.sendIac(option === TELNET.ECHO ? TELNET.DO : TELNET.DONT, option); if (option === TELNET.ECHO) this.emit('sensitiveInput', true); } } else if (command === TELNET.WONT) { if (option === TELNET.GMCP) this.gmcpEnabled = false; if (option === TELNET.ECHO) this.emit('sensitiveInput', false); } else if (command === TELNET.DO) this.sendIac(option === TELNET.GMCP ? TELNET.WILL : TELNET.WONT, option); } private handleSubnegotiation(option: number, payload: Uint8Array): void { if (option === TELNET.GMCP && payload.length <= 64 * 1024) this.gmcpHandler.handleGmcpMessage(new TextDecoder().decode(payload)); } private sendIac(command: number, option: number): void { this.sendBytes(new Uint8Array([TELNET.IAC, command, option])); } private sendBytes(data: Uint8Array): void { if (this.state !== 'connected' || !this.webSocket || this.webSocket.readyState !== WebSocket.OPEN) throw new Error('Not connected to MUD server.'); this.webSocket.send(data); } private setState(state: MudConnectionState): void { this.state = state; this.emit('stateChanged', state); } private scheduleReconnect(): void { if (this.explicitDisconnect || this.reconnectAttempts >= 3) return; const delay = 5_000 * Math.pow(1.5, this.reconnectAttempts++); this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = null; this.connect(); }, delay); } private clearReconnectTimer(): void { if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } private sessionKey(): string { return `mudResume:${this.id}`; } private loadResumeToken(): string | undefined { if (typeof sessionStorage === 'undefined') return undefined; const legacyKey = `mudSession_${this.id}`; localStorage.removeItem(legacyKey); return sessionStorage.getItem(this.sessionKey()) ?? undefined; } private storeResumeToken(token?: string): void { if (!token) return; this.resumeToken = token; sessionStorage.setItem(this.sessionKey(), token); } private clearResumeToken(): void { this.resumeToken = undefined; if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(this.sessionKey()); } public static cleanupOldStoredSessions(): void { if (typeof localStorage === 'undefined') return; for (const key of Object.keys(localStorage)) if (key.startsWith('mudSession_')) localStorage.removeItem(key); } }