Try to keep connections alive for longer

This commit is contained in:
2025-07-10 23:09:27 +01:00
parent e5e857b087
commit 1d39818127
6 changed files with 726 additions and 60 deletions

139
PERFORMANCE_OPTIMIZATION.md Normal file
View File

@@ -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<string, ProcessedContent>` 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
<!-- Before: Complex nested processing -->
{#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}
<!-- After: Simple flat rendering -->
{#each safeRenderableLines as line (line.id)}
<div class="mud-terminal-line"
class:mud-input-line={line.isInput}
class:mud-terminal-subline={line.isSubline}>
{#if $uiSettings.showTimestamps && line.lineIndex === 0}
<span class="mud-timestamp">[{formatTimestamp(line.timestamp)}]</span>
{/if}
<div class="mud-terminal-content">
{@html line.content}
</div>
</div>
{/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<string, ProcessedContent>; // 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.

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { AriaLiveAnnouncer } from './AriaLiveAnnouncer';
// Props
export let bufferDelay: number = 250; // How long to wait before announcing
export let clearDelay: number = 1000; // How long to wait before clearing
export let maxBufferSize: number = 1000; // Max buffer size before forcing announcement
// Expose the announcer instance
export let announcer: AriaLiveAnnouncer | null = null;
let container: HTMLDivElement;
onMount(() => {
if (container) {
announcer = new AriaLiveAnnouncer(container, {
bufferDelay,
clearDelay,
maxBufferSize
});
}
});
onDestroy(() => {
if (announcer) {
announcer.destroy();
announcer = null;
}
});
// Export announce function for easier access
export function announce(text: string): void {
if (announcer) {
announcer.announce(text);
}
}
// Export clear function
export function clear(): void {
if (announcer) {
announcer.clear();
}
}
</script>
<div bind:this={container} class="aria-live-announcer-container"></div>
<style>
.aria-live-announcer-container {
position: relative;
}
</style>

View File

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

View File

@@ -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,9 +233,48 @@
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) {
console.log(`Active profile is now: ${$activeProfileId}, updating output display`);
@@ -275,16 +321,23 @@
aria-label="MUD Terminal"
tabindex="-1">
<!-- Screen reader announcements -->
<!-- Aria-live announcer for screen readers -->
<AriaLiveAnnouncer
bind:this={ariaAnnouncer}
bufferDelay={250}
clearDelay={1000}
maxBufferSize={1000}
/>
<!-- Screen reader announcements for message navigation -->
<div id="message-announcement" class="sr-only" aria-live="polite"></div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="mud-terminal-output"
bind:this={terminalElement}
role="log"
aria-live="polite"
aria-atomic="false"
aria-relevant="additions"
aria-label="MUD output"
role="region"
aria-label="MUD output - Use arrow keys to navigate messages"
tabindex="0"
on:keydown={handleOutputKeyDown}
style="font-family: {$uiSettings.font}; font-size: {$accessibilitySettings.fontSize}px; line-height: {$accessibilitySettings.lineSpacing};">

View File

@@ -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;
@@ -37,6 +47,15 @@ export class MudConnection extends EventEmitter {
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();
this.host = options.host;
@@ -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') {
// 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;
}
}
}

View File

@@ -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,6 +84,30 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
}
let socket;
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}`);
const persistentConn = persistentConnections.get(sessionId);
socket = persistentConn.socket;
// 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
@@ -66,8 +129,9 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
connections.delete(connectionId);
});
// Store the connection
connections.set(connectionId, { ws, socket });
// 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) {
@@ -76,6 +140,10 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
}
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,18 +167,21 @@ 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);
}
});
}
@@ -119,7 +190,53 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
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;