diff --git a/PERFORMANCE_OPTIMIZATION.md b/PERFORMANCE_OPTIMIZATION.md new file mode 100644 index 0000000..d035dee --- /dev/null +++ b/PERFORMANCE_OPTIMIZATION.md @@ -0,0 +1,139 @@ +# MUD Terminal Performance Optimization Summary + +## Problem Identified + +The MUD terminal component had severe performance issues when the message history grew large due to expensive text processing operations being performed in the Svelte template on every render cycle. + +### Key Issues: +1. **Expensive re-computation on every render**: `processAnsi()`, `applyHighlights()`, and `splitIntoLines()` were called for every message on every component re-render +2. **Complex text processing in template**: ANSI color processing, regex highlighting, and line splitting happened in the template using `{@const}` blocks +3. **Inefficient reactive statements**: Triggered unnecessary DOM work on every history change +4. **No caching**: The same text processing was repeated multiple times for the same content + +## Optimizations Implemented + +### 1. **Pre-processing in Store Layer** +- Created `textProcessing.ts` utility with all text processing logic +- Moved expensive operations to happen once when messages are added to store +- Added `processedOutputHistory` store that contains pre-processed messages + +### 2. **Intelligent Caching System** +- Implemented `ProcessedMessage` interface with built-in cache +- Cache keyed by UI settings that affect rendering (e.g., ANSI color enabled/disabled) +- Messages are re-processed only when relevant settings change +- Uses `Map` for efficient cache lookups + +### 3. **Flattened Renderable Lines Store** +- Created `activeRenderableLines` derived store that provides a flat array of all renderable lines +- Eliminates complex nested logic in template +- Each line has pre-computed properties (content, styling, metadata) +- Single `{#each}` loop instead of nested processing + +### 4. **Optimized Template Rendering** +```svelte + +{#each safeOutputHistory as item (item.id)} + {#if item.isInput} + + {:else} + {@const processedContent = applyHighlights(processAnsi(item.text), item.highlights || [])} + {@const lines = splitIntoLines(processedContent)} + {#if lines.length <= 1} + + {:else} + {#each lines as line, lineIndex} + + {/each} + {/if} + {/if} +{/each} + + +{#each safeRenderableLines as line (line.id)} +
+ {#if $uiSettings.showTimestamps && line.lineIndex === 0} + [{formatTimestamp(line.timestamp)}] + {/if} +
+ {@html line.content} +
+
+{/each} +``` + +### 5. **Optimized Reactive Statements** +- Replaced `setTimeout()` with `Promise.resolve().then()` for better microtask scheduling +- More targeted reactive updates that only trigger when necessary +- Removed redundant reactive blocks + +### 6. **Store Architecture Improvements** +- Separated raw message storage from processed message storage +- Made `addToOutputHistory()` handle both raw and processed storage +- Ensured cache consistency when clearing history + +## Performance Benefits + +### Before Optimization: +- **O(n)** text processing operations on every render for **n** messages +- Multiple expensive regex operations per message per render +- ANSI-to-HTML conversion happening repeatedly +- Complex DOM operations during each reactive update + +### After Optimization: +- **O(1)** amortized cost per message (processing happens once) +- **O(1)** cache lookups for repeated operations +- Text processing only when messages are added or settings change +- Simple, flat DOM structure with minimal reactive overhead + +## Technical Implementation Details + +### New Files Created: +- `src/lib/utils/textProcessing.ts` - Centralized text processing utilities + +### Modified Files: +- `src/lib/stores/mudStore.ts` - Added processed stores and caching +- `src/lib/components/MudTerminal.svelte` - Simplified template and removed processing functions + +### Key Data Structures: +```typescript +interface ProcessedMessage { + id: string; + originalText: string; + timestamp: number; + isInput: boolean; + highlights: HighlightRule[]; + processedContent: string; + lines: ProcessedLine[]; + processedCache: Map; // Cache by UI settings +} + +interface ProcessedLine { + id: string; + content: string; // Pre-processed HTML content + isSubline: boolean; // For indentation + parentId: string; // Reference to parent message + lineIndex: number; // Position in parent message +} +``` + +## Leveraging Svelte's Strengths + +The optimization takes full advantage of Svelte's reactive system: + +1. **Derived Stores**: Used for computed values that automatically update when dependencies change +2. **Keyed Each Blocks**: Ensures efficient DOM updates with `{#each items as item (item.id)}` +3. **Conditional Classes**: Uses `class:name={condition}` for efficient class toggling +4. **Reactive Declarations**: Optimized `$:` statements that only run when necessary +5. **Store Composition**: Layered stores that build upon each other efficiently + +## Expected Performance Gains + +For a terminal with 1000+ messages: +- **Before**: ~1000 × (ANSI processing + regex highlighting + line splitting) per render +- **After**: ~0 processing per render (cached results) +- **Memory**: Slightly higher due to caching, but with configurable limits +- **Responsiveness**: Should feel instant even with large message histories + +The optimization maintains all existing functionality while dramatically improving performance, especially as message history grows. diff --git a/src/lib/accessibility/AriaLiveAnnouncer.svelte b/src/lib/accessibility/AriaLiveAnnouncer.svelte new file mode 100644 index 0000000..e79e733 --- /dev/null +++ b/src/lib/accessibility/AriaLiveAnnouncer.svelte @@ -0,0 +1,53 @@ + + +
+ + diff --git a/src/lib/accessibility/AriaLiveAnnouncer.ts b/src/lib/accessibility/AriaLiveAnnouncer.ts new file mode 100644 index 0000000..66370f5 --- /dev/null +++ b/src/lib/accessibility/AriaLiveAnnouncer.ts @@ -0,0 +1,132 @@ +/** + * AriaLiveAnnouncer - A dedicated component for screen reader announcements + * + * This component provides a better alternative to using aria-live="log" on the main terminal. + * It buffers incoming text and announces complete messages, then clears after a delay. + */ + +import { onMount, onDestroy } from 'svelte'; + +interface AnnouncerOptions { + bufferDelay?: number; // How long to wait before announcing buffered text + clearDelay?: number; // How long to wait before clearing announced text + maxBufferSize?: number; // Maximum characters to buffer before forcing announcement +} + +export class AriaLiveAnnouncer { + private element: HTMLDivElement; + private bufferTimeout: number | null = null; + private clearTimeout: number | null = null; + private textBuffer: string = ''; + private options: Required; + + constructor(container: HTMLElement, options: AnnouncerOptions = {}) { + this.options = { + bufferDelay: 250, // 250ms buffer delay + clearDelay: 1000, // Clear after 1 second + maxBufferSize: 1000, // Force announce if buffer gets too large + ...options + }; + + // Create the aria-live element + this.element = document.createElement('div'); + this.element.setAttribute('aria-live', 'polite'); + this.element.setAttribute('aria-atomic', 'true'); + this.element.className = 'sr-only'; + this.element.style.cssText = ` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + `; + + container.appendChild(this.element); + } + + /** + * Add text to the buffer for announcement + */ + announce(text: string): void { + // Add to buffer + this.textBuffer += (this.textBuffer ? ' ' : '') + text.trim(); + + // Clear any existing buffer timeout + if (this.bufferTimeout !== null) { + clearTimeout(this.bufferTimeout); + } + + // If buffer is getting too large, announce immediately + if (this.textBuffer.length > this.options.maxBufferSize) { + this.forceAnnounce(); + return; + } + + // Set up buffer timeout + this.bufferTimeout = window.setTimeout(() => { + this.forceAnnounce(); + }, this.options.bufferDelay); + } + + /** + * Force immediate announcement of buffered text + */ + private forceAnnounce(): void { + if (!this.textBuffer.trim()) return; + + // Clear any pending timeouts + if (this.bufferTimeout !== null) { + clearTimeout(this.bufferTimeout); + this.bufferTimeout = null; + } + + if (this.clearTimeout !== null) { + clearTimeout(this.clearTimeout); + this.clearTimeout = null; + } + + // Set the text for announcement + this.element.textContent = this.textBuffer; + + // Clear the buffer + this.textBuffer = ''; + + // Schedule clearing the announcement + this.clearTimeout = window.setTimeout(() => { + this.element.textContent = ''; + this.clearTimeout = null; + }, this.options.clearDelay); + } + + /** + * Clear all pending announcements and timeouts + */ + clear(): void { + if (this.bufferTimeout !== null) { + clearTimeout(this.bufferTimeout); + this.bufferTimeout = null; + } + + if (this.clearTimeout !== null) { + clearTimeout(this.clearTimeout); + this.clearTimeout = null; + } + + this.textBuffer = ''; + this.element.textContent = ''; + } + + /** + * Destroy the announcer and clean up resources + */ + destroy(): void { + this.clear(); + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + } +} diff --git a/src/lib/components/MudTerminal.svelte b/src/lib/components/MudTerminal.svelte index f7f86d3..983bac6 100644 --- a/src/lib/components/MudTerminal.svelte +++ b/src/lib/components/MudTerminal.svelte @@ -3,6 +3,7 @@ import { activeRenderableLines, addToOutputHistory, addToInputHistory, navigateInputHistory, activeInputHistoryIndex, activeConnection, uiSettings, accessibilitySettings, activeInputHistory, activeProfileId, connectionStatus } from '$lib/stores/mudStore'; import { tick } from 'svelte'; import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager'; + import AriaLiveAnnouncer from '$lib/accessibility/AriaLiveAnnouncer.svelte'; // Create safe defaults for reactivity $: safeRenderableLines = $activeRenderableLines || []; @@ -21,11 +22,16 @@ let inputElement: HTMLInputElement; let currentInput = ''; let accessibilityManager: AccessibilityManager | null = null; + let ariaAnnouncer: any = null; // Reference to the AriaLiveAnnouncer component // Message navigation state let currentFocusedMessageIndex: number = -1; let messageElements: HTMLElement[] = []; + // Track last announced content to avoid duplicates + let lastAnnouncedContent = ''; + let lastAnnouncedTime = 0; + // Handle input submission async function handleSubmit(event: Event) { event.preventDefault(); @@ -184,7 +190,8 @@ // Make sure the message is in view messageElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - // Announce for screen readers - simplified and concise announcement + // Announce for screen readers using the legacy announcement element + // This is for message navigation, not new content const messageNumber = currentFocusedMessageIndex + 1; const totalMessages = messageElements.length; const messageContent = messageElement.textContent || ''; @@ -192,7 +199,7 @@ // Only announce the message number and content, not terminal instructions const announcement = `${messageNumber} of ${totalMessages}: ${messageContent.substring(0, 100)}`; - // Use aria-live region for announcement + // Use aria-live region for navigation announcement (not the main announcer) const announcementElement = document.getElementById('message-announcement'); if (announcementElement) { announcementElement.textContent = announcement; @@ -226,8 +233,47 @@ Promise.resolve().then(() => { scrollToBottom(); updateMessageElements(); + announceNewContent(); }); } + + /** + * Announce new content using the aria-live announcer + * This handles buffering and ensures complete messages are read + */ + function announceNewContent(): void { + if (!ariaAnnouncer || !$accessibilitySettings.textToSpeech) return; + + // Get the latest content from the last few lines + const recentLines = safeRenderableLines.slice(-5); // Last 5 lines + if (recentLines.length === 0) return; + + // Extract text content from the recent lines + const newContent = recentLines + .filter(line => !line.isInput) // Don't announce input echoes + .map(line => { + // Strip HTML tags to get plain text + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = line.content; + return tempDiv.textContent || tempDiv.innerText || ''; + }) + .join(' ') + .trim(); + + // Avoid announcing duplicate content or empty content + const now = Date.now(); + if (!newContent || + (newContent === lastAnnouncedContent && now - lastAnnouncedTime < 1000)) { + return; + } + + // Update tracking + lastAnnouncedContent = newContent; + lastAnnouncedTime = now; + + // Announce the content + ariaAnnouncer.announce(newContent); + } // Watch for active profile changes $: if ($activeProfileId) { @@ -275,16 +321,23 @@ aria-label="MUD Terminal" tabindex="-1"> - + + + +
+ +
diff --git a/src/lib/connection/MudConnection.ts b/src/lib/connection/MudConnection.ts index df51272..2fc0653 100644 --- a/src/lib/connection/MudConnection.ts +++ b/src/lib/connection/MudConnection.ts @@ -13,16 +13,26 @@ enum TelnetCommand { GMCP = 201, // Generic MUD Communication Protocol } -interface MudConnectionOptions { +export interface MudConnectionOptions { + id: string; host: string; port: number; useSSL?: boolean; - id: string; +} + +// Connection persistence state +interface ConnectionPersistence { + sessionId?: string; + reconnectAttempts: number; + maxReconnectAttempts: number; + reconnectDelay: number; + lastDisconnectTime?: 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; @@ -36,6 +46,15 @@ export class MudConnection extends EventEmitter { 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; constructor(options: MudConnectionOptions) { super(); @@ -58,25 +77,25 @@ export class MudConnection extends EventEmitter { */ private setupGmcpEvents(): void { // Forward all GMCP events to listeners of this connection - this.gmcpHandler.on('gmcp', (module, data) => { + this.gmcpHandler.on('gmcp', (module: string, data: any) => { this.emit('gmcp', module, data); }); // Forward specific module events (like gmcp:Core.Ping) - this.gmcpHandler.on('*', (eventName, ...args) => { + 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, volume, loop) => { + 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, data) => { + this.gmcpHandler.on('sendGmcp', (module: string, data: any) => { this.sendGmcp(module, data); }); } @@ -90,6 +109,9 @@ export class MudConnection extends EventEmitter { return; } + // Reset explicit disconnect flag + this.explicitDisconnect = false; + // Determine the WebSocket URL based on environment const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; let wsUrl; @@ -102,6 +124,12 @@ export class MudConnection extends EventEmitter { 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}`); + } + console.log(`Connecting to WebSocket server: ${wsUrl}`); this.webSocket = new WebSocket(wsUrl); @@ -109,6 +137,7 @@ export class MudConnection extends EventEmitter { 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'); @@ -121,6 +150,12 @@ export class MudConnection extends EventEmitter { 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) => { @@ -133,9 +168,14 @@ export class MudConnection extends EventEmitter { // Binary data this.handleIncomingData(new Uint8Array(event.data)); } else if (typeof event.data === 'string') { - // Text data - let listeners process it directly - // TriggerSystem will handle gagging and replacing in the component - this.emit('received', event.data); + // 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.emit('received', event.data); + } } else if (event.data instanceof Blob) { // Blob data (sometimes WebSockets send this instead of ArrayBuffer) const reader = new FileReader(); @@ -176,7 +216,35 @@ export class MudConnection extends EventEmitter { this.emit('sent', text); } catch (error) { console.error('Error sending data:', error); - this.emit('error', `Failed to send message: ${error.message}`); + 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; + console.log('Updated session ID:', this.persistence.sessionId); + } + + // Handle other system messages as needed + if (systemData.type === 'session_resumed') { + console.log('Session successfully resumed'); + this.emit('session_resumed'); + } + } catch (error) { + console.error('Error parsing system message:', error); } } @@ -184,10 +252,31 @@ export class MudConnection extends EventEmitter { * 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; + + // Clear reconnect timeout if active + if (this.reconnectTimeoutId !== null) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = null; + } } /** @@ -390,4 +479,52 @@ export class MudConnection extends EventEmitter { public isConnected(): boolean { return this.connected; } + + /** + * 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); + } + + /** + * Get the current session ID + */ + public getSessionId(): string | undefined { + return this.persistence.sessionId; + } + + /** + * Reset reconnection state + */ + public resetReconnectionState(): void { + this.persistence.reconnectAttempts = 0; + this.persistence.lastDisconnectTime = undefined; + if (this.reconnectTimeoutId !== null) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = null; + } + } } \ No newline at end of file diff --git a/src/websocket-server.js b/src/websocket-server.js index 842c1d4..ffca0d3 100644 --- a/src/websocket-server.js +++ b/src/websocket-server.js @@ -4,6 +4,10 @@ import * as tls from 'tls'; import http from 'http'; import { parse } from 'url'; +// Configuration for connection persistence +const CONNECTION_PERSISTENCE_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds +const HEARTBEAT_INTERVAL = 30 * 1000; // 30 seconds + // Create HTTP server const server = http.createServer(); @@ -13,6 +17,36 @@ const wss = new WebSocketServer({ noServer: true }); // Active connections and their proxies const connections = new Map(); +// Persistent connections waiting for reconnection +// Key: sessionId, Value: { socket, mudHost, mudPort, useSSL, timeoutId, lastActivity } +const persistentConnections = new Map(); + +// Generate a unique session ID for persistent connections +function generateSessionId() { + return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +// Clean up a persistent connection +function cleanupPersistentConnection(sessionId) { + const persistentConn = persistentConnections.get(sessionId); + if (persistentConn) { + console.log(`Cleaning up persistent connection for session ${sessionId}`); + + // Clear timeout + if (persistentConn.timeoutId) { + clearTimeout(persistentConn.timeoutId); + } + + // Close MUD socket + if (persistentConn.socket && !persistentConn.socket.destroyed) { + persistentConn.socket.end(); + } + + // Remove from map + persistentConnections.delete(sessionId); + } +} + // Handle WebSocket connections wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { console.log(`WebSocket connection established for ${mudHost}:${mudPort} (SSL: ${useSSL})`); @@ -20,6 +54,11 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { // Create a unique ID for this connection const connectionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // Check for session ID in query parameters for reconnection + const url = req.url || ''; + const urlParts = new URL(`http://localhost${url}`); + const sessionId = urlParts.searchParams.get('sessionId'); + // Special handling for test connections if (mudHost === 'example.com' && mudPort === '23') { console.log('Test connection detected - using echo server mode'); @@ -45,37 +84,66 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { } let socket; - try { - // Create a TCP socket connection to the MUD server - // Use tls for SSL connections, net for regular connections - socket = useSSL - ? tls.connect({ host: mudHost, port: parseInt(mudPort), rejectUnauthorized: false }) - : net.createConnection({ host: mudHost, port: parseInt(mudPort) }); + let currentSessionId = sessionId; + + // Check if this is a reconnection to an existing persistent session + if (sessionId && persistentConnections.has(sessionId)) { + console.log(`Reconnecting to existing session: ${sessionId}`); - // Add error handler - socket.on('error', (error) => { - console.error(`Socket error for ${mudHost}:${mudPort}:`, error.message); - // Send error to client - if (ws.readyState === 1) { - ws.send(Buffer.from(`ERROR: Connection to MUD server failed: ${error.message}\r\n`)); - setTimeout(() => { - if (ws.readyState === 1) ws.close(); - }, 1000); - } - // Remove from connections map - connections.delete(connectionId); - }); + const persistentConn = persistentConnections.get(sessionId); + socket = persistentConn.socket; - // Store the connection - connections.set(connectionId, { ws, socket }); - } catch (error) { - console.error(`Error creating socket connection: ${error.message}`); - if (ws.readyState === 1) { - ws.send(Buffer.from(`ERROR: Failed to connect to MUD server: ${error.message}\r\n`)); - ws.close(); + // Clear the timeout since client reconnected + if (persistentConn.timeoutId) { + clearTimeout(persistentConn.timeoutId); + } + + // Remove from persistent connections (now active again) + persistentConnections.delete(sessionId); + + // Send reconnection notification with session ID in proper JSON format + ws.send(`[SYSTEM]${JSON.stringify({ type: 'session_resumed', sessionId: sessionId })}`); + } else { + // Create new connection + currentSessionId = generateSessionId(); + console.log(`Creating new session: ${currentSessionId}`); + + try { + // Create a TCP socket connection to the MUD server + // Use tls for SSL connections, net for regular connections + socket = useSSL + ? tls.connect({ host: mudHost, port: parseInt(mudPort), rejectUnauthorized: false }) + : net.createConnection({ host: mudHost, port: parseInt(mudPort) }); + + // Add error handler + socket.on('error', (error) => { + console.error(`Socket error for ${mudHost}:${mudPort}:`, error.message); + // Send error to client + if (ws.readyState === 1) { + ws.send(Buffer.from(`ERROR: Connection to MUD server failed: ${error.message}\r\n`)); + setTimeout(() => { + if (ws.readyState === 1) ws.close(); + }, 1000); + } + // Remove from connections map + connections.delete(connectionId); + }); + + // Send session ID to client in proper JSON format + ws.send(`[SYSTEM]${JSON.stringify({ sessionId: currentSessionId })}`); + + } catch (error) { + console.error(`Error creating socket connection: ${error.message}`); + if (ws.readyState === 1) { + ws.send(Buffer.from(`ERROR: Failed to connect to MUD server: ${error.message}\r\n`)); + ws.close(); + } + return; } - return; } + + // Store the connection + connections.set(connectionId, { ws, socket, sessionId: currentSessionId }); // Handle data from the MUD server - only in regular mode, not test mode if (socket) { @@ -99,27 +167,76 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { }); } - // Socket error handler already defined above - - // Handle socket close + // Handle socket close from MUD server - this should trigger cleanup if (socket) { socket.on('close', () => { - console.log(`MUD connection closed for ${mudHost}:${mudPort}`); + console.log(`MUD connection closed by server for ${mudHost}:${mudPort}`); // Close WebSocket if it's still open if (ws.readyState === 1) { ws.close(); } // Remove from connections map connections.delete(connectionId); + + // Also cleanup any persistent connection + if (currentSessionId) { + cleanupPersistentConnection(currentSessionId); + } }); } - + // Handle WebSocket messages (data from client to server) ws.on('message', (message) => { try { // Skip if this is a test connection (already handled in the test mode section) const conn = connections.get(connectionId); - if (conn.testMode) return; + if (conn && conn.testMode) return; + + // Check for system messages + const messageStr = message.toString(); + if (messageStr.startsWith('[SYSTEM]')) { + try { + const jsonStr = messageStr.substring(8); // Remove "[SYSTEM]" + const systemData = JSON.parse(jsonStr); + + if (systemData.type === 'explicit_disconnect') { + console.log(`Received explicit disconnect command for session ${currentSessionId}`); + // This is an explicit disconnect - don't persist the connection + if (socket && socket.writable) { + socket.end(); + } + if (ws.readyState === 1) { + ws.close(); + } + connections.delete(connectionId); + if (currentSessionId) { + cleanupPersistentConnection(currentSessionId); + } + return; + } + } catch (error) { + console.error('Error parsing system message:', error); + } + // Don't forward system messages to the MUD server + return; + } + + // Legacy support for old disconnect command + if (messageStr.trim() === '[DISCONNECT]') { + console.log(`Received legacy disconnect command for session ${currentSessionId}`); + // This is an explicit disconnect - don't persist the connection + if (socket && socket.writable) { + socket.end(); + } + if (ws.readyState === 1) { + ws.close(); + } + connections.delete(connectionId); + if (currentSessionId) { + cleanupPersistentConnection(currentSessionId); + } + return; + } // Check for GMCP data (IAC SB GMCP) in client messages let isGmcp = false; @@ -135,7 +252,7 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { // Forward data to the MUD server // The message might be Buffer, ArrayBuffer, or string - if (conn.socket && conn.socket.writable) { + if (conn && conn.socket && conn.socket.writable) { conn.socket.write(message); console.log(`WebSocket server: Sent ${message.length} bytes to MUD server${isGmcp ? ' (contains GMCP data)' : ''}`); } else { @@ -147,27 +264,49 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { } catch (error) { console.error('Error forwarding message to MUD server:', error); if (ws.readyState === 1) { // WebSocket.OPEN - ws.send(Buffer.from(`ERROR: Failed to send data to MUD server: ${error.message}\r\n`)); + const errorMessage = error instanceof Error ? error.message : String(error); + ws.send(Buffer.from(`ERROR: Failed to send data to MUD server: ${errorMessage}\r\n`)); } } }); - // Handle WebSocket close + // Handle WebSocket close - THIS IS THE KEY CHANGE FOR PERSISTENCE ws.on('close', () => { - console.log(`WebSocket closed for ${mudHost}:${mudPort}`); - // Close socket if it's still open + console.log(`WebSocket closed for ${mudHost}:${mudPort} (session: ${currentSessionId})`); + const conn = connections.get(connectionId); - if (conn && conn.socket) { + if (conn && !conn.testMode && conn.socket && !conn.socket.destroyed) { + console.log(`Moving connection to persistent state for ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`); + + // Move the connection to persistent storage instead of closing it + const timeoutId = setTimeout(() => { + console.log(`Session ${currentSessionId} timed out, closing MUD connection`); + cleanupPersistentConnection(currentSessionId); + }, CONNECTION_PERSISTENCE_TIMEOUT); + + persistentConnections.set(currentSessionId, { + socket: conn.socket, + mudHost, + mudPort, + useSSL, + timeoutId, + lastActivity: Date.now() + }); + + console.log(`Session ${currentSessionId} will persist for ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`); + } else if (conn && conn.socket) { + // Fallback to immediate cleanup if needed conn.socket.end(); } - // Remove from connections map + + // Remove from active connections map connections.delete(connectionId); }); // Handle WebSocket errors ws.on('error', (error) => { console.error(`WebSocket error for ${mudHost}:${mudPort}:`, error.message); - // Close socket on error + // Close socket on error - but only if it's not going to be persisted const conn = connections.get(connectionId); if (conn && conn.socket) { conn.socket.end(); @@ -180,7 +319,7 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => { // Handle HTTP server upgrade (WebSocket handshake) server.on('upgrade', (request, socket, head) => { // Parse URL to get query parameters - const { pathname, query } = parse(request.url, true); + const { pathname, query } = parse(request.url || '', true); // Only handle WebSocket connections to /mud-ws if (pathname === '/mud-ws') { @@ -203,10 +342,23 @@ server.on('upgrade', (request, socket, head) => { } }); +// Periodic cleanup of abandoned persistent connections +setInterval(() => { + const now = Date.now(); + for (const [sessionId, persistentConn] of persistentConnections.entries()) { + // Clean up connections that have been inactive for too long + if (now - persistentConn.lastActivity > CONNECTION_PERSISTENCE_TIMEOUT * 2) { + console.log(`Cleaning up abandoned session: ${sessionId}`); + cleanupPersistentConnection(sessionId); + } + } +}, CONNECTION_PERSISTENCE_TIMEOUT); + // Start the WebSocket server const PORT = process.env.WS_PORT || 3001; server.listen(PORT, () => { console.log(`WebSocket server is running on port ${PORT}`); + console.log(`Connection persistence timeout: ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`); }); export default server; \ No newline at end of file