Try to keep connections alive for longer
This commit is contained in:
139
PERFORMANCE_OPTIMIZATION.md
Normal file
139
PERFORMANCE_OPTIMIZATION.md
Normal 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.
|
||||||
53
src/lib/accessibility/AriaLiveAnnouncer.svelte
Normal file
53
src/lib/accessibility/AriaLiveAnnouncer.svelte
Normal 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>
|
||||||
132
src/lib/accessibility/AriaLiveAnnouncer.ts
Normal file
132
src/lib/accessibility/AriaLiveAnnouncer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { activeRenderableLines, addToOutputHistory, addToInputHistory, navigateInputHistory, activeInputHistoryIndex, activeConnection, uiSettings, accessibilitySettings, activeInputHistory, activeProfileId, connectionStatus } from '$lib/stores/mudStore';
|
import { activeRenderableLines, addToOutputHistory, addToInputHistory, navigateInputHistory, activeInputHistoryIndex, activeConnection, uiSettings, accessibilitySettings, activeInputHistory, activeProfileId, connectionStatus } from '$lib/stores/mudStore';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager';
|
import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager';
|
||||||
|
import AriaLiveAnnouncer from '$lib/accessibility/AriaLiveAnnouncer.svelte';
|
||||||
|
|
||||||
// Create safe defaults for reactivity
|
// Create safe defaults for reactivity
|
||||||
$: safeRenderableLines = $activeRenderableLines || [];
|
$: safeRenderableLines = $activeRenderableLines || [];
|
||||||
@@ -21,11 +22,16 @@
|
|||||||
let inputElement: HTMLInputElement;
|
let inputElement: HTMLInputElement;
|
||||||
let currentInput = '';
|
let currentInput = '';
|
||||||
let accessibilityManager: AccessibilityManager | null = null;
|
let accessibilityManager: AccessibilityManager | null = null;
|
||||||
|
let ariaAnnouncer: any = null; // Reference to the AriaLiveAnnouncer component
|
||||||
|
|
||||||
// Message navigation state
|
// Message navigation state
|
||||||
let currentFocusedMessageIndex: number = -1;
|
let currentFocusedMessageIndex: number = -1;
|
||||||
let messageElements: HTMLElement[] = [];
|
let messageElements: HTMLElement[] = [];
|
||||||
|
|
||||||
|
// Track last announced content to avoid duplicates
|
||||||
|
let lastAnnouncedContent = '';
|
||||||
|
let lastAnnouncedTime = 0;
|
||||||
|
|
||||||
// Handle input submission
|
// Handle input submission
|
||||||
async function handleSubmit(event: Event) {
|
async function handleSubmit(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -184,7 +190,8 @@
|
|||||||
// Make sure the message is in view
|
// Make sure the message is in view
|
||||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
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 messageNumber = currentFocusedMessageIndex + 1;
|
||||||
const totalMessages = messageElements.length;
|
const totalMessages = messageElements.length;
|
||||||
const messageContent = messageElement.textContent || '';
|
const messageContent = messageElement.textContent || '';
|
||||||
@@ -192,7 +199,7 @@
|
|||||||
// Only announce the message number and content, not terminal instructions
|
// Only announce the message number and content, not terminal instructions
|
||||||
const announcement = `${messageNumber} of ${totalMessages}: ${messageContent.substring(0, 100)}`;
|
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');
|
const announcementElement = document.getElementById('message-announcement');
|
||||||
if (announcementElement) {
|
if (announcementElement) {
|
||||||
announcementElement.textContent = announcement;
|
announcementElement.textContent = announcement;
|
||||||
@@ -226,9 +233,48 @@
|
|||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
updateMessageElements();
|
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
|
// Watch for active profile changes
|
||||||
$: if ($activeProfileId) {
|
$: if ($activeProfileId) {
|
||||||
console.log(`Active profile is now: ${$activeProfileId}, updating output display`);
|
console.log(`Active profile is now: ${$activeProfileId}, updating output display`);
|
||||||
@@ -275,16 +321,23 @@
|
|||||||
aria-label="MUD Terminal"
|
aria-label="MUD Terminal"
|
||||||
tabindex="-1">
|
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>
|
<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"
|
<div class="mud-terminal-output"
|
||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
role="log"
|
role="region"
|
||||||
aria-live="polite"
|
aria-label="MUD output - Use arrow keys to navigate messages"
|
||||||
aria-atomic="false"
|
|
||||||
aria-relevant="additions"
|
|
||||||
aria-label="MUD output"
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:keydown={handleOutputKeyDown}
|
on:keydown={handleOutputKeyDown}
|
||||||
style="font-family: {$uiSettings.font}; font-size: {$accessibilitySettings.fontSize}px; line-height: {$accessibilitySettings.lineSpacing};">
|
style="font-family: {$uiSettings.font}; font-size: {$accessibilitySettings.fontSize}px; line-height: {$accessibilitySettings.lineSpacing};">
|
||||||
|
|||||||
@@ -13,16 +13,26 @@ enum TelnetCommand {
|
|||||||
GMCP = 201, // Generic MUD Communication Protocol
|
GMCP = 201, // Generic MUD Communication Protocol
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MudConnectionOptions {
|
export interface MudConnectionOptions {
|
||||||
|
id: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
useSSL?: boolean;
|
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
|
* MudConnection - Handles a single connection to a MUD server
|
||||||
* Each instance has its own GMCP handler and maintains its own state
|
* Each instance has its own GMCP handler and maintains its own state
|
||||||
|
* Now supports connection persistence and automatic reconnection
|
||||||
*/
|
*/
|
||||||
export class MudConnection extends EventEmitter {
|
export class MudConnection extends EventEmitter {
|
||||||
private host: string;
|
private host: string;
|
||||||
@@ -37,6 +47,15 @@ export class MudConnection extends EventEmitter {
|
|||||||
private inSubnegotiation: boolean = false;
|
private inSubnegotiation: boolean = false;
|
||||||
public readonly id: string;
|
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) {
|
constructor(options: MudConnectionOptions) {
|
||||||
super();
|
super();
|
||||||
this.host = options.host;
|
this.host = options.host;
|
||||||
@@ -58,25 +77,25 @@ export class MudConnection extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private setupGmcpEvents(): void {
|
private setupGmcpEvents(): void {
|
||||||
// Forward all GMCP events to listeners of this connection
|
// 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);
|
this.emit('gmcp', module, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forward specific module events (like gmcp:Core.Ping)
|
// Forward specific module events (like gmcp:Core.Ping)
|
||||||
this.gmcpHandler.on('*', (eventName, ...args) => {
|
this.gmcpHandler.on('*', (eventName: string, ...args: any[]) => {
|
||||||
if (eventName.startsWith('gmcp:')) {
|
if (eventName.startsWith('gmcp:')) {
|
||||||
this.emit(eventName, ...args);
|
this.emit(eventName, ...args);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle GMCP events that need special processing
|
// 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}`);
|
console.log(`MudConnection forwarding playSound event: ${url}`);
|
||||||
this.emit('playSound', { url, volume, loop });
|
this.emit('playSound', { url, volume, loop });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for sendGmcp events from the GMCP handler
|
// 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);
|
this.sendGmcp(module, data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -90,6 +109,9 @@ export class MudConnection extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset explicit disconnect flag
|
||||||
|
this.explicitDisconnect = false;
|
||||||
|
|
||||||
// Determine the WebSocket URL based on environment
|
// Determine the WebSocket URL based on environment
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
let wsUrl;
|
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}`;
|
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}`);
|
console.log(`Connecting to WebSocket server: ${wsUrl}`);
|
||||||
|
|
||||||
this.webSocket = new WebSocket(wsUrl);
|
this.webSocket = new WebSocket(wsUrl);
|
||||||
@@ -109,6 +137,7 @@ export class MudConnection extends EventEmitter {
|
|||||||
|
|
||||||
this.webSocket.onopen = () => {
|
this.webSocket.onopen = () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
this.persistence.reconnectAttempts = 0; // Reset reconnect attempts on successful connection
|
||||||
console.log(`Connected to ${this.host}:${this.port}`);
|
console.log(`Connected to ${this.host}:${this.port}`);
|
||||||
this.emit('connected');
|
this.emit('connected');
|
||||||
|
|
||||||
@@ -121,6 +150,12 @@ export class MudConnection extends EventEmitter {
|
|||||||
this.connected = false;
|
this.connected = false;
|
||||||
console.log(`Disconnected from ${this.host}:${this.port}`);
|
console.log(`Disconnected from ${this.host}:${this.port}`);
|
||||||
this.emit('disconnected');
|
this.emit('disconnected');
|
||||||
|
|
||||||
|
// Handle reconnection if not explicitly disconnected
|
||||||
|
if (!this.explicitDisconnect) {
|
||||||
|
this.persistence.lastDisconnectTime = Date.now();
|
||||||
|
this.handleReconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.webSocket.onerror = (error) => {
|
this.webSocket.onerror = (error) => {
|
||||||
@@ -133,9 +168,14 @@ export class MudConnection extends EventEmitter {
|
|||||||
// Binary data
|
// Binary data
|
||||||
this.handleIncomingData(new Uint8Array(event.data));
|
this.handleIncomingData(new Uint8Array(event.data));
|
||||||
} else if (typeof event.data === 'string') {
|
} 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
|
// Text data - let listeners process it directly
|
||||||
// TriggerSystem will handle gagging and replacing in the component
|
// TriggerSystem will handle gagging and replacing in the component
|
||||||
this.emit('received', event.data);
|
this.emit('received', event.data);
|
||||||
|
}
|
||||||
} else if (event.data instanceof Blob) {
|
} else if (event.data instanceof Blob) {
|
||||||
// Blob data (sometimes WebSockets send this instead of ArrayBuffer)
|
// Blob data (sometimes WebSockets send this instead of ArrayBuffer)
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -176,7 +216,35 @@ export class MudConnection extends EventEmitter {
|
|||||||
this.emit('sent', text);
|
this.emit('sent', text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending data:', 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
|
* Disconnect from the MUD server
|
||||||
*/
|
*/
|
||||||
public disconnect(): void {
|
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) {
|
if (this.webSocket) {
|
||||||
this.webSocket.close();
|
this.webSocket.close();
|
||||||
this.webSocket = null;
|
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 {
|
public isConnected(): boolean {
|
||||||
return this.connected;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,10 @@ import * as tls from 'tls';
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { parse } from 'url';
|
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
|
// Create HTTP server
|
||||||
const server = http.createServer();
|
const server = http.createServer();
|
||||||
|
|
||||||
@@ -13,6 +17,36 @@ const wss = new WebSocketServer({ noServer: true });
|
|||||||
// Active connections and their proxies
|
// Active connections and their proxies
|
||||||
const connections = new Map();
|
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
|
// Handle WebSocket connections
|
||||||
wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
||||||
console.log(`WebSocket connection established for ${mudHost}:${mudPort} (SSL: ${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
|
// Create a unique ID for this connection
|
||||||
const connectionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
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
|
// Special handling for test connections
|
||||||
if (mudHost === 'example.com' && mudPort === '23') {
|
if (mudHost === 'example.com' && mudPort === '23') {
|
||||||
console.log('Test connection detected - using echo server mode');
|
console.log('Test connection detected - using echo server mode');
|
||||||
@@ -45,6 +84,30 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let socket;
|
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 {
|
try {
|
||||||
// Create a TCP socket connection to the MUD server
|
// Create a TCP socket connection to the MUD server
|
||||||
// Use tls for SSL connections, net for regular connections
|
// Use tls for SSL connections, net for regular connections
|
||||||
@@ -66,8 +129,9 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
connections.delete(connectionId);
|
connections.delete(connectionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the connection
|
// Send session ID to client in proper JSON format
|
||||||
connections.set(connectionId, { ws, socket });
|
ws.send(`[SYSTEM]${JSON.stringify({ sessionId: currentSessionId })}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error creating socket connection: ${error.message}`);
|
console.error(`Error creating socket connection: ${error.message}`);
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState === 1) {
|
||||||
@@ -76,6 +140,10 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
}
|
}
|
||||||
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
|
// Handle data from the MUD server - only in regular mode, not test mode
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -99,18 +167,21 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Socket error handler already defined above
|
// Handle socket close from MUD server - this should trigger cleanup
|
||||||
|
|
||||||
// Handle socket close
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on('close', () => {
|
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
|
// Close WebSocket if it's still open
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState === 1) {
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
// Remove from connections map
|
// Remove from connections map
|
||||||
connections.delete(connectionId);
|
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 {
|
try {
|
||||||
// Skip if this is a test connection (already handled in the test mode section)
|
// Skip if this is a test connection (already handled in the test mode section)
|
||||||
const conn = connections.get(connectionId);
|
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
|
// Check for GMCP data (IAC SB GMCP) in client messages
|
||||||
let isGmcp = false;
|
let isGmcp = false;
|
||||||
@@ -135,7 +252,7 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
|
|
||||||
// Forward data to the MUD server
|
// Forward data to the MUD server
|
||||||
// The message might be Buffer, ArrayBuffer, or string
|
// 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);
|
conn.socket.write(message);
|
||||||
console.log(`WebSocket server: Sent ${message.length} bytes to MUD server${isGmcp ? ' (contains GMCP data)' : ''}`);
|
console.log(`WebSocket server: Sent ${message.length} bytes to MUD server${isGmcp ? ' (contains GMCP data)' : ''}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -147,27 +264,49 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error forwarding message to MUD server:', error);
|
console.error('Error forwarding message to MUD server:', error);
|
||||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
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', () => {
|
ws.on('close', () => {
|
||||||
console.log(`WebSocket closed for ${mudHost}:${mudPort}`);
|
console.log(`WebSocket closed for ${mudHost}:${mudPort} (session: ${currentSessionId})`);
|
||||||
// Close socket if it's still open
|
|
||||||
const conn = connections.get(connectionId);
|
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();
|
conn.socket.end();
|
||||||
}
|
}
|
||||||
// Remove from connections map
|
|
||||||
|
// Remove from active connections map
|
||||||
connections.delete(connectionId);
|
connections.delete(connectionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle WebSocket errors
|
// Handle WebSocket errors
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (error) => {
|
||||||
console.error(`WebSocket error for ${mudHost}:${mudPort}:`, error.message);
|
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);
|
const conn = connections.get(connectionId);
|
||||||
if (conn && conn.socket) {
|
if (conn && conn.socket) {
|
||||||
conn.socket.end();
|
conn.socket.end();
|
||||||
@@ -180,7 +319,7 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
|
|||||||
// Handle HTTP server upgrade (WebSocket handshake)
|
// Handle HTTP server upgrade (WebSocket handshake)
|
||||||
server.on('upgrade', (request, socket, head) => {
|
server.on('upgrade', (request, socket, head) => {
|
||||||
// Parse URL to get query parameters
|
// 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
|
// Only handle WebSocket connections to /mud-ws
|
||||||
if (pathname === '/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
|
// Start the WebSocket server
|
||||||
const PORT = process.env.WS_PORT || 3001;
|
const PORT = process.env.WS_PORT || 3001;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`WebSocket server is running on port ${PORT}`);
|
console.log(`WebSocket server is running on port ${PORT}`);
|
||||||
|
console.log(`Connection persistence timeout: ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default server;
|
export default server;
|
||||||
Reference in New Issue
Block a user