233 lines
9.7 KiB
TypeScript
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);
|
|
}
|
|
}
|