Files
svelte-mud/src/lib/connection/MudConnection.ts
T

233 lines
9.7 KiB
TypeScript

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);
}
}