Harden client and WebSocket proxy
This commit is contained in:
+180
-668
@@ -1,19 +1,6 @@
|
||||
import { EventEmitter } from '$lib/utils/EventEmitter';
|
||||
import { GmcpHandler } from '$lib/gmcp/GmcpHandler';
|
||||
import { get } from 'svelte/store';
|
||||
import { connectionSettings } from '$lib/stores/mudStore';
|
||||
|
||||
// IAC codes for telnet negotiation
|
||||
enum TelnetCommand {
|
||||
IAC = 255, // Interpret As Command
|
||||
DONT = 254,
|
||||
DO = 253,
|
||||
WONT = 252,
|
||||
WILL = 251,
|
||||
SB = 250, // Subnegotiation Begin
|
||||
SE = 240, // Subnegotiation End
|
||||
GMCP = 201, // Generic MUD Communication Protocol
|
||||
}
|
||||
import { EventEmitter } from '$lib/utils/EventEmitter';
|
||||
import { TELNET, TelnetParser } from './TelnetParser';
|
||||
|
||||
export interface MudConnectionOptions {
|
||||
id: string;
|
||||
@@ -22,699 +9,224 @@ export interface MudConnectionOptions {
|
||||
useSSL?: boolean;
|
||||
}
|
||||
|
||||
// Connection persistence state
|
||||
interface ConnectionPersistence {
|
||||
sessionId?: string;
|
||||
reconnectAttempts: number;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectDelay: number;
|
||||
lastDisconnectTime?: number;
|
||||
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;
|
||||
}
|
||||
|
||||
// Stored session data in localStorage
|
||||
interface StoredSessionData {
|
||||
sessionId: string;
|
||||
profileId: string;
|
||||
host: string;
|
||||
port: number;
|
||||
useSSL: boolean;
|
||||
lastActivity: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MudConnection - Handles a single connection to a MUD server
|
||||
* Each instance has its own GMCP handler and maintains its own state
|
||||
* Now supports connection persistence and automatic reconnection
|
||||
*/
|
||||
export class MudConnection extends EventEmitter {
|
||||
private host: string;
|
||||
private port: number;
|
||||
private useSSL: boolean;
|
||||
private webSocket: WebSocket | null = null;
|
||||
private gmcpHandler: GmcpHandler;
|
||||
private buffer: number[] = [];
|
||||
private connected: boolean = false;
|
||||
private negotiationBuffer: number[] = [];
|
||||
private isInIAC: boolean = false;
|
||||
private inSubnegotiation: boolean = false;
|
||||
public readonly id: string;
|
||||
|
||||
// Connection persistence properties
|
||||
private persistence: ConnectionPersistence = {
|
||||
reconnectAttempts: 0,
|
||||
maxReconnectAttempts: 3,
|
||||
reconnectDelay: 5000 // 5 seconds
|
||||
};
|
||||
private reconnectTimeoutId: number | null = null;
|
||||
private explicitDisconnect: boolean = false;
|
||||
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.id = options.id;
|
||||
|
||||
// Create GMCP handler
|
||||
this.useSSL = options.useSSL ?? false;
|
||||
this.resumeToken = this.loadResumeToken();
|
||||
this.gmcpHandler = new GmcpHandler();
|
||||
|
||||
// Set up GMCP event forwarding
|
||||
this.setupGmcpEvents();
|
||||
|
||||
// Try to restore session from localStorage
|
||||
this.loadStoredSession();
|
||||
|
||||
console.log(`MudConnection created for ${this.host}:${this.port} with ID ${this.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event forwarding from GMCP handler
|
||||
*/
|
||||
private setupGmcpEvents(): void {
|
||||
// Forward all GMCP events to listeners of this connection
|
||||
this.gmcpHandler.on('gmcp', (module: string, data: any) => {
|
||||
this.emit('gmcp', module, data);
|
||||
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)
|
||||
});
|
||||
|
||||
// Forward specific module events (like gmcp:Core.Ping)
|
||||
this.gmcpHandler.on('*', (eventName: string, ...args: any[]) => {
|
||||
if (eventName.startsWith('gmcp:')) {
|
||||
this.emit(eventName, ...args);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle GMCP events that need special processing
|
||||
this.gmcpHandler.on('playSound', (url: string, volume: number, loop: boolean) => {
|
||||
console.log(`MudConnection forwarding playSound event: ${url}`);
|
||||
this.emit('playSound', { url, volume, loop });
|
||||
});
|
||||
|
||||
// Listen for sendGmcp events from the GMCP handler
|
||||
this.gmcpHandler.on('sendGmcp', (module: string, data: any) => {
|
||||
this.sendGmcp(module, data);
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the MUD server
|
||||
*/
|
||||
public connect(): void {
|
||||
if (this.connected) {
|
||||
console.log(`Already connected to ${this.host}:${this.port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset explicit disconnect flag
|
||||
if (this.webSocket && (this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING)) return;
|
||||
this.explicitDisconnect = false;
|
||||
|
||||
// Determine the WebSocket URL based on environment
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
let wsUrl;
|
||||
|
||||
// In development, use port 3001
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
wsUrl = `${wsProtocol}://${window.location.hostname}:3001/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`;
|
||||
} else {
|
||||
// In production, use the same domain & port as the web app
|
||||
wsUrl = `${wsProtocol}://${window.location.host}/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`;
|
||||
}
|
||||
|
||||
// Include session ID in URL if we have one (for reconnection)
|
||||
if (this.persistence.sessionId) {
|
||||
wsUrl += `&sessionId=${encodeURIComponent(this.persistence.sessionId)}`;
|
||||
console.log(`Reconnecting with session ID: ${this.persistence.sessionId}`);
|
||||
}
|
||||
|
||||
// Include connection settings in URL
|
||||
const settings = get(connectionSettings);
|
||||
wsUrl += `&persistenceTimeout=${settings.persistenceTimeoutMinutes}`;
|
||||
wsUrl += `&maxBufferMessages=${settings.maxBufferMessages}`;
|
||||
wsUrl += `&maxBufferSizeKB=${settings.maxBufferSizeKB}`;
|
||||
|
||||
console.log(`Connecting to WebSocket server: ${wsUrl}`);
|
||||
|
||||
this.webSocket = new WebSocket(wsUrl);
|
||||
this.webSocket.binaryType = 'arraybuffer';
|
||||
|
||||
this.webSocket.onopen = () => {
|
||||
this.connected = true;
|
||||
this.persistence.reconnectAttempts = 0; // Reset reconnect attempts on successful connection
|
||||
console.log(`Connected to ${this.host}:${this.port}`);
|
||||
this.emit('connected');
|
||||
|
||||
// Update stored session activity
|
||||
this.updateStoredSessionActivity();
|
||||
|
||||
// Send GMCP negotiation upon connection
|
||||
console.log('Sending GMCP negotiation');
|
||||
this.sendIAC(TelnetCommand.WILL, TelnetCommand.GMCP);
|
||||
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}.`);
|
||||
};
|
||||
|
||||
this.webSocket.onclose = () => {
|
||||
this.connected = false;
|
||||
console.log(`Disconnected from ${this.host}:${this.port}`);
|
||||
this.emit('disconnected');
|
||||
|
||||
// Handle reconnection if not explicitly disconnected
|
||||
if (!this.explicitDisconnect) {
|
||||
this.persistence.lastDisconnectTime = Date.now();
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.webSocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.emit('error', `WebSocket error: Connection to ${this.host}:${this.port} failed. Please check your settings and ensure the MUD server is running.`);
|
||||
};
|
||||
|
||||
this.webSocket.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// Binary data
|
||||
this.handleIncomingData(new Uint8Array(event.data));
|
||||
} else if (typeof event.data === 'string') {
|
||||
// Check if this is a system message from the server
|
||||
if (event.data.startsWith('[SYSTEM]')) {
|
||||
this.handleSystemMessage(event.data);
|
||||
} else {
|
||||
// Text data - let listeners process it directly
|
||||
// TriggerSystem will handle gagging and replacing in the component
|
||||
this.updateStoredSessionActivity();
|
||||
this.emit('received', event.data);
|
||||
}
|
||||
} else if (event.data instanceof Blob) {
|
||||
// Blob data (sometimes WebSockets send this instead of ArrayBuffer)
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
this.handleIncomingData(new Uint8Array(reader.result));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(event.data);
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text to the MUD server
|
||||
*/
|
||||
public send(text: string): void {
|
||||
if (!this.connected) {
|
||||
throw new Error('Not connected to MUD server');
|
||||
}
|
||||
|
||||
if (!this.webSocket) {
|
||||
throw new Error('WebSocket not initialized');
|
||||
}
|
||||
|
||||
// Check if the WebSocket is in a valid state for sending
|
||||
if (this.webSocket.readyState !== WebSocket.OPEN) {
|
||||
this.emit('error', `Cannot send message: WebSocket is not open (state: ${this.webSocket.readyState})`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Append newline to the text
|
||||
const data = new TextEncoder().encode(text + '\n');
|
||||
this.webSocket.send(data);
|
||||
|
||||
// Update stored session activity on send
|
||||
this.updateStoredSessionActivity();
|
||||
|
||||
// Emit the data for possible triggers
|
||||
this.emit('sent', text);
|
||||
} catch (error) {
|
||||
console.error('Error sending data:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.emit('error', `Failed to send message: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system messages from the server
|
||||
*/
|
||||
private handleSystemMessage(message: string): void {
|
||||
console.log('Received system message:', message);
|
||||
|
||||
try {
|
||||
// Remove the [SYSTEM] prefix and parse as JSON
|
||||
const jsonStr = message.substring(8); // Remove "[SYSTEM]"
|
||||
const systemData = JSON.parse(jsonStr);
|
||||
|
||||
// Handle session ID updates
|
||||
if (systemData.sessionId) {
|
||||
this.persistence.sessionId = systemData.sessionId;
|
||||
this.saveSessionToStorage();
|
||||
console.log('Updated session ID:', this.persistence.sessionId);
|
||||
}
|
||||
|
||||
// Handle other system messages as needed
|
||||
if (systemData.type === 'session_resumed') {
|
||||
console.log('Session successfully resumed');
|
||||
if (systemData.messagesReplayed > 0) {
|
||||
console.log(`${systemData.messagesReplayed} messages were replayed`);
|
||||
}
|
||||
this.emit('session_resumed', systemData);
|
||||
} else if (systemData.type === 'message_replay_start') {
|
||||
console.log(`Starting message replay: ${systemData.messageCount} messages from ${systemData.timespan}ms ago`);
|
||||
this.emit('message_replay_start', systemData);
|
||||
} else if (systemData.type === 'message_replay_complete') {
|
||||
console.log('Message replay complete');
|
||||
this.emit('message_replay_complete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing system message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the MUD server
|
||||
*/
|
||||
public disconnect(): void {
|
||||
this.explicitDisconnect = true; // Set flag for explicit disconnect
|
||||
|
||||
// Signal to server that this is an explicit disconnect
|
||||
if (this.connected && this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.webSocket.send('[SYSTEM]{"type":"explicit_disconnect"}');
|
||||
} catch (error) {
|
||||
console.error('Error sending explicit disconnect signal:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.webSocket) {
|
||||
this.webSocket.close();
|
||||
this.webSocket = null;
|
||||
}
|
||||
|
||||
// Clear session ID since we're explicitly disconnecting
|
||||
this.persistence.sessionId = undefined;
|
||||
this.persistence.reconnectAttempts = 0;
|
||||
|
||||
// Remove stored session from localStorage
|
||||
this.clearStoredSession();
|
||||
|
||||
// Clear reconnect timeout if active
|
||||
if (this.reconnectTimeoutId !== null) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = null;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming data from the MUD server
|
||||
*/
|
||||
private handleIncomingData(data: Uint8Array): void {
|
||||
// Quickly check if we need to handle telnet negotiation
|
||||
let containsIAC = false;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i] === TelnetCommand.IAC) {
|
||||
containsIAC = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log raw data for debugging if it contains IAC
|
||||
if (containsIAC) {
|
||||
const hexData = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
console.log(`Raw data with IAC: ${hexData}`);
|
||||
}
|
||||
|
||||
// Fast path if no IAC codes
|
||||
if (!containsIAC && !this.isInIAC) {
|
||||
const text = new TextDecoder().decode(data);
|
||||
this.emit('received', text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each byte in the incoming data
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const byte = data[i];
|
||||
|
||||
if (this.isInIAC) {
|
||||
// Add byte to negotiation buffer
|
||||
this.negotiationBuffer.push(byte);
|
||||
|
||||
// Check for special sequences
|
||||
if (this.inSubnegotiation) {
|
||||
// Inside subnegotiation - look for IAC SE
|
||||
if (byte === TelnetCommand.SE &&
|
||||
this.negotiationBuffer.length > 0 &&
|
||||
this.negotiationBuffer[this.negotiationBuffer.length - 2] === TelnetCommand.IAC) {
|
||||
|
||||
console.log('End of subnegotiation found');
|
||||
|
||||
// Process the complete subnegotiation
|
||||
this.handleCompleteSubnegotiation();
|
||||
|
||||
// Reset state
|
||||
this.isInIAC = false;
|
||||
this.inSubnegotiation = false;
|
||||
this.negotiationBuffer = [];
|
||||
}
|
||||
} else if (this.negotiationBuffer.length === 2) {
|
||||
// After IAC, check what command it is
|
||||
if (byte === TelnetCommand.SB) {
|
||||
// Start of subnegotiation
|
||||
this.inSubnegotiation = true;
|
||||
} else if (byte === TelnetCommand.WILL || byte === TelnetCommand.DO) {
|
||||
// Need one more byte for option
|
||||
} else {
|
||||
// Simple 3-byte command
|
||||
this.processSimpleTelnetCommand();
|
||||
this.isInIAC = false;
|
||||
this.negotiationBuffer = [];
|
||||
}
|
||||
} else if (this.negotiationBuffer.length === 3 && !this.inSubnegotiation) {
|
||||
// Complete 3-byte command like IAC WILL X or IAC DO X
|
||||
this.processSimpleTelnetCommand();
|
||||
this.isInIAC = false;
|
||||
this.negotiationBuffer = [];
|
||||
}
|
||||
} else if (byte === TelnetCommand.IAC) {
|
||||
// Start of telnet command
|
||||
this.isInIAC = true;
|
||||
this.negotiationBuffer = [byte];
|
||||
console.log('IAC command detected');
|
||||
} else {
|
||||
// Normal data byte, add to buffer
|
||||
this.buffer.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
// Process any complete text in the buffer
|
||||
if (this.buffer.length > 0) {
|
||||
const text = new TextDecoder().decode(new Uint8Array(this.buffer));
|
||||
this.buffer = [];
|
||||
|
||||
// Emit the received text for display and trigger processing
|
||||
this.emit('received', text);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a simple telnet command (3 bytes: IAC CMD OPTION)
|
||||
*/
|
||||
private processSimpleTelnetCommand(): void {
|
||||
try {
|
||||
const [iac, command, option] = this.negotiationBuffer;
|
||||
|
||||
// Handle specific commands
|
||||
if ((command === TelnetCommand.WILL || command === TelnetCommand.DO) && option === TelnetCommand.GMCP) {
|
||||
console.log('Server supports GMCP, responding with DO GMCP');
|
||||
// Server wants to use GMCP, we'll respond with IAC DO GMCP
|
||||
this.sendIAC(TelnetCommand.DO, TelnetCommand.GMCP);
|
||||
|
||||
// Request GMCP capabilities
|
||||
console.log('Requesting GMCP capabilities');
|
||||
this.gmcpHandler.requestCapabilities();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing telnet command:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a complete telnet subnegotiation sequence
|
||||
*/
|
||||
private handleCompleteSubnegotiation(): void {
|
||||
try {
|
||||
// Debug buffer contents
|
||||
const bufferHex = this.negotiationBuffer.map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
console.log(`Processing subnegotiation, buffer: ${bufferHex}`);
|
||||
|
||||
// Check if this is a GMCP subnegotiation
|
||||
// IAC SB GMCP ... IAC SE
|
||||
// Indexes: 0 1 2 ... -2 -1
|
||||
if (this.negotiationBuffer.length >= 5 && this.negotiationBuffer[2] === TelnetCommand.GMCP) {
|
||||
console.log('Processing GMCP subnegotiation');
|
||||
|
||||
try {
|
||||
// Extract the GMCP data (skip IAC SB GMCP, and the final IAC SE)
|
||||
const gmcpData = this.negotiationBuffer.slice(3, -2);
|
||||
const gmcpText = new TextDecoder().decode(new Uint8Array(gmcpData));
|
||||
|
||||
console.log(`GMCP message: ${gmcpText}`);
|
||||
|
||||
// Process the GMCP message immediately
|
||||
console.log('Passing GMCP to handler:', gmcpText);
|
||||
this.gmcpHandler.handleGmcpMessage(gmcpText);
|
||||
} catch (error) {
|
||||
console.error('Error processing GMCP data:', error);
|
||||
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 {
|
||||
console.log(`Non-GMCP subnegotiation received: ${this.negotiationBuffer[2]}`);
|
||||
this.sendIac(option === TELNET.ECHO ? TELNET.DO : TELNET.DONT, option);
|
||||
if (option === TELNET.ECHO) this.emit('sensitiveInput', true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling subnegotiation:', error);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a telnet IAC sequence
|
||||
*/
|
||||
private sendIAC(command: TelnetCommand, option: TelnetCommand): void {
|
||||
if (!this.connected || !this.webSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array([TelnetCommand.IAC, command, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a GMCP message
|
||||
*/
|
||||
public sendGmcp(module: string, data: any): void {
|
||||
if (!this.connected || !this.webSocket) {
|
||||
console.log('Cannot send GMCP - not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Sending GMCP: ${module}`, data);
|
||||
const gmcpString = `${module} ${JSON.stringify(data)}`;
|
||||
const gmcpData = new TextEncoder().encode(gmcpString);
|
||||
|
||||
// Create the IAC SB GMCP <data> IAC SE sequence
|
||||
const telnetSequence = new Uint8Array([
|
||||
TelnetCommand.IAC,
|
||||
TelnetCommand.SB,
|
||||
TelnetCommand.GMCP,
|
||||
...gmcpData,
|
||||
TelnetCommand.IAC,
|
||||
TelnetCommand.SE
|
||||
]);
|
||||
|
||||
this.webSocket.send(telnetSequence);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GMCP handler associated with this connection
|
||||
*/
|
||||
public getGmcpHandler(): GmcpHandler {
|
||||
return this.gmcpHandler;
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connection is active
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.connected;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reconnection logic
|
||||
*/
|
||||
private handleReconnect(): void {
|
||||
// If too much time has passed since disconnect, don't attempt to reconnect with session
|
||||
if (this.persistence.lastDisconnectTime &&
|
||||
Date.now() - this.persistence.lastDisconnectTime > 5 * 60 * 1000) { // 5 minutes
|
||||
console.log('Too much time has passed, clearing session for fresh connection');
|
||||
this.persistence.sessionId = undefined;
|
||||
this.persistence.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
if (this.persistence.reconnectAttempts >= this.persistence.maxReconnectAttempts) {
|
||||
console.log('Max reconnect attempts reached, giving up');
|
||||
this.persistence.sessionId = undefined; // Clear session since we're giving up
|
||||
return;
|
||||
}
|
||||
|
||||
this.persistence.reconnectAttempts++;
|
||||
const delay = this.persistence.reconnectDelay * Math.pow(1.5, this.persistence.reconnectAttempts - 1); // Exponential backoff
|
||||
|
||||
console.log(`Reconnecting in ${delay / 1000} seconds... (Attempt ${this.persistence.reconnectAttempts}/${this.persistence.maxReconnectAttempts})`);
|
||||
|
||||
this.reconnectTimeoutId = window.setTimeout(() => {
|
||||
console.log('Reconnecting...');
|
||||
this.connect();
|
||||
}, delay);
|
||||
private storeResumeToken(token?: string): void {
|
||||
if (!token) return;
|
||||
this.resumeToken = token;
|
||||
sessionStorage.setItem(this.sessionKey(), token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session ID
|
||||
*/
|
||||
public getSessionId(): string | undefined {
|
||||
return this.persistence.sessionId;
|
||||
private clearResumeToken(): void {
|
||||
this.resumeToken = undefined;
|
||||
if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(this.sessionKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset reconnection state
|
||||
*/
|
||||
public resetReconnectionState(): void {
|
||||
this.persistence.reconnectAttempts = 0;
|
||||
this.persistence.lastDisconnectTime = undefined;
|
||||
if (this.reconnectTimeoutId !== null) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current session to localStorage
|
||||
*/
|
||||
private saveSessionToStorage(): void {
|
||||
if (!this.persistence.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData: StoredSessionData = {
|
||||
sessionId: this.persistence.sessionId,
|
||||
profileId: this.id,
|
||||
host: this.host,
|
||||
port: this.port,
|
||||
useSSL: this.useSSL,
|
||||
lastActivity: Date.now(),
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
try {
|
||||
const storageKey = `mudSession_${this.id}`;
|
||||
localStorage.setItem(storageKey, JSON.stringify(sessionData));
|
||||
console.log(`Saved session ${this.persistence.sessionId} to localStorage for profile ${this.id}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save session to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored session from localStorage
|
||||
*/
|
||||
private loadStoredSession(): void {
|
||||
try {
|
||||
const storageKey = `mudSession_${this.id}`;
|
||||
const storedData = localStorage.getItem(storageKey);
|
||||
|
||||
if (!storedData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData: StoredSessionData = JSON.parse(storedData);
|
||||
|
||||
// Validate that the stored session matches this connection
|
||||
if (sessionData.profileId === this.id &&
|
||||
sessionData.host === this.host &&
|
||||
sessionData.port === this.port &&
|
||||
sessionData.useSSL === this.useSSL) {
|
||||
|
||||
// Check if the session is still within a reasonable timeframe
|
||||
const maxAge = 60 * 60 * 1000; // 1 hour max age
|
||||
const age = Date.now() - sessionData.lastActivity;
|
||||
|
||||
if (age <= maxAge) {
|
||||
this.persistence.sessionId = sessionData.sessionId;
|
||||
console.log(`Restored session ${sessionData.sessionId} from localStorage for profile ${this.id} (age: ${Math.round(age/1000)}s)`);
|
||||
} else {
|
||||
console.log(`Stored session for profile ${this.id} is too old (${Math.round(age/1000)}s), discarding`);
|
||||
this.clearStoredSession();
|
||||
}
|
||||
} else {
|
||||
console.log(`Stored session for profile ${this.id} doesn't match current connection parameters, discarding`);
|
||||
this.clearStoredSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session from localStorage:', error);
|
||||
this.clearStoredSession();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored session from localStorage
|
||||
*/
|
||||
private clearStoredSession(): void {
|
||||
try {
|
||||
const storageKey = `mudSession_${this.id}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
console.log(`Cleared stored session for profile ${this.id}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear stored session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity timestamp in stored session
|
||||
*/
|
||||
private updateStoredSessionActivity(): void {
|
||||
if (!this.persistence.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storageKey = `mudSession_${this.id}`;
|
||||
const storedData = localStorage.getItem(storageKey);
|
||||
|
||||
if (storedData) {
|
||||
const sessionData: StoredSessionData = JSON.parse(storedData);
|
||||
sessionData.lastActivity = Date.now();
|
||||
localStorage.setItem(storageKey, JSON.stringify(sessionData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update stored session activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old stored sessions from localStorage (static method)
|
||||
*/
|
||||
public static cleanupOldStoredSessions(): void {
|
||||
try {
|
||||
const maxAge = 60 * 60 * 1000; // 1 hour
|
||||
const now = Date.now();
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
// Iterate through all localStorage keys
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('mudSession_')) {
|
||||
try {
|
||||
const storedData = localStorage.getItem(key);
|
||||
if (storedData) {
|
||||
const sessionData: StoredSessionData = JSON.parse(storedData);
|
||||
const age = now - sessionData.lastActivity;
|
||||
|
||||
if (age > maxAge) {
|
||||
keysToRemove.push(key);
|
||||
console.log(`Marking old session for cleanup: ${key} (age: ${Math.round(age/1000)}s)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't parse the session data, remove it
|
||||
keysToRemove.push(key);
|
||||
console.log(`Marking corrupted session for cleanup: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old sessions
|
||||
for (const key of keysToRemove) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
console.log(`Cleaned up ${keysToRemove.length} old stored sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup old stored sessions:', error);
|
||||
}
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
for (const key of Object.keys(localStorage)) if (key.startsWith('mudSession_')) localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user