Harden client and WebSocket proxy

This commit is contained in:
2026-09-09 13:04:56 +02:00
parent f4f95ffff4
commit 8986f6270f
64 changed files with 4410 additions and 7313 deletions
-1
View File
@@ -1,2 +1 @@
engine-strict=true
resolution-mode=highest
+9 -2
View File
@@ -1,10 +1,17 @@
mud.iamtalon.me {
header {
Content-Security-Policy "default-src 'self'; connect-src 'self' wss://mud.iamtalon.me; img-src 'self' data:; media-src 'self' https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
Referrer-Policy "no-referrer"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
}
# For WebSocket requests to /mud-ws, proxy to the WebSocket server on port 3001
@websocket {
path /mud-ws*
}
reverse_proxy @websocket svelte-mud:3001
reverse_proxy @websocket svelte-mud-proxy:3001
# For all other requests, proxy to the SvelteKit app on port 3000
reverse_proxy svelte-mud:3000
reverse_proxy svelte-mud-app:3000
}
+37 -59
View File
@@ -1,77 +1,55 @@
# Svelte MUD Docker Setup
# Docker deployment
This guide explains how to use Docker to build and run the Svelte MUD client.
The Compose deployment uses two containers made from the same image:
## Solution Overview
- `svelte-mud-app` runs the adapter-node SvelteKit build on port 3000.
- `svelte-mud-proxy` runs the WebSocket-to-Telnet proxy on port 3001.
This setup runs both the SvelteKit application and the WebSocket server in a single container, avoiding CORS issues. It follows the same approach used in development, where both servers run as separate processes but within the same context.
Neither port is published to the host. Both services join the external `revproxy` network for access by Caddy.
## Prerequisites
## Deploy
- [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose](https://docs.docker.com/compose/install/) (usually included with Docker Desktop)
1. Change `mud.iamtalon.me` in `docker-compose.yml` and `Caddyfile` to the actual public hostname.
2. Create the proxy network if it does not already exist:
## Quick Start
1. Navigate to the project directory:
```bash
cd path/to/svelte-mud
docker network create revproxy
```
2. Build and start the container:
3. Build and start both services:
```bash
docker-compose up -d
docker compose up --build -d
```
3. Access the application:
- Web interface: http://localhost:3000
- WebSocket server: ws://localhost:3001/mud-ws
4. Inspect health and logs:
## Docker Commands
```bash
docker compose ps
docker compose logs -f
```
### Starting the Application
Stop the deployment with `docker compose down`.
## Reverse proxy
The included `Caddyfile` sends `/mud-ws` to `svelte-mud-proxy:3001` and other requests to `svelte-mud-app:3000`. It also installs a restrictive content security policy and related browser security headers.
`TRUST_PROXY=1` must only be used when clients cannot reach the proxy container directly and the forwarding proxy overwrites `X-Forwarded-For`. Otherwise remove it so connection quotas use the actual socket address.
`ALLOWED_ORIGINS` is an exact, comma-separated allowlist. For example:
```yaml
ALLOWED_ORIGINS: https://mud.example.com
```
Do not publish port 3001 publicly. Origin checks are a browser boundary, not an authentication mechanism.
## Updating
```bash
# Build and start in detached mode
docker-compose up -d
# Build and start with logs
docker-compose up
# Force rebuild
docker-compose up --build
docker compose build --pull
docker compose up -d
```
### Stopping the Application
```bash
# Stop containers
docker-compose down
```
### Viewing Logs
```bash
# View logs
docker-compose logs -f
```
## Caddy Configuration
For use with Caddy as a reverse proxy, use this simple configuration:
```
mud.example.com {
reverse_proxy svelte-mud:3000
}
```
Both the web interface and WebSocket connections will be routed correctly through this single reverse proxy rule.
## Troubleshooting
If you encounter any issues, check the container logs:
```bash
docker-compose logs -f
```
The containers run as an unprivileged user and include independent health checks for the web app and proxy.
+7 -6
View File
@@ -1,7 +1,7 @@
# Multi-stage build Dockerfile for Svelte MUD client
# Build stage
FROM node:20-alpine AS build
FROM node:22-alpine AS build
# Set working directory
WORKDIR /app
@@ -10,7 +10,7 @@ WORKDIR /app
COPY package*.json ./
# Install dependencies
RUN npm install
RUN npm ci
# Copy source files
COPY . .
@@ -19,7 +19,7 @@ COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
FROM node:22-alpine AS production
# Set working directory
WORKDIR /app
@@ -30,7 +30,7 @@ RUN addgroup -g 1001 -S nodejs && \
# Install only production dependencies
COPY package*.json ./
RUN npm install --omit=dev
RUN npm ci --omit=dev
# Copy built application from the build stage
COPY --from=build /app/build ./build
@@ -43,5 +43,6 @@ USER nodejs
# Set environment variables
ENV NODE_ENV=production
# Start both servers using the production script
CMD ["node", "run-production.js"]
# Compose overrides this command to run the app and proxy separately. This
# remains a convenient single-container fallback for local deployments.
CMD ["node", "run-production.js"]
+50 -105
View File
@@ -1,128 +1,73 @@
# SvelteMUD - A Modern MUD Client
# SvelteMUD
SvelteMUD is a feature-rich MUD (Multi-User Dungeon) client built with Svelte and SvelteKit, designed to provide a modern, accessible, and customizable interface for connecting to MUD servers.
SvelteMUD is an accessible, installable MUD client built with Svelte 5 and SvelteKit. It supports multiple simultaneous Telnet connections through a constrained WebSocket proxy, ANSI color, GMCP, triggers, profiles, and screen-reader-oriented navigation.
## Security model
- Browsers connect only to the same-origin `/mud-ws` endpoint. The proxy accepts a small JSON control protocol and carries Telnet data in binary WebSocket frames.
- The proxy rejects private, loopback, link-local, multicast, and otherwise special-use targets after DNS resolution. It pins the selected public address for the connection and verifies TLS certificates.
- Anonymous proxy use is rate-, connection-, bandwidth-, and buffer-limited. Resume tokens are random, single-tab credentials stored in `sessionStorage`, rotate after use, and expire after five minutes.
- Passwords are held in memory only. Existing passwords from older profile data are removed during migration, and backups never contain passwords.
- Server output is rendered as text, not HTML. Triggers cannot execute JavaScript. Trigger regular expressions are length- and complexity-limited.
- Server-requested remote media is disabled by default and must be enabled explicitly in Settings.
The proxy is intended for public MUD servers. It is not a general TCP tunnel and deliberately blocks access to private infrastructure and common mail ports.
## Features
### Core Functionality
- WebSocket to Telnet proxy for connecting to MUD servers
- Multiple simultaneous MUD connections via an MDI (Multiple Document Interface)
- ANSI color support
- Command history
- Configurable profiles for different MUD servers
- Auto-login functionality
- Progressive Web App (PWA) support for offline use and installation
- Multiple persistent connection tabs, including background output
- Incremental Telnet parsing and GMCP negotiation
- ANSI and 256-color output
- Plain-text and constrained regular-expression triggers
- Trigger highlighting, sounds, and command sending
- Per-profile command/output history
- High contrast, text-to-speech, font scaling, and keyboard navigation
- PWA installation and generated application icons
### GMCP Support
- Generic MUD Communication Protocol (GMCP) handling
- Support for common packages:
- Client.Media for sound playback
- Client.Keystroke for key capturing
- Easily extendable with custom GMCP packages
## Development
### Triggers System
- Pattern matching with plain text or regular expressions
- Actions:
- Sound playback on triggers
- Highlight matched text
- Send commands to the server
- Execute custom JavaScript code
### Accessibility Features
- Text-to-speech for incoming MUD text
- High contrast mode
- Configurable font size and family
- Keyboard navigation
- ARIA attributes for screen readers
## Installation
Requires Node.js 22 or newer.
```bash
# Clone the repository
git clone https://your-repo-url/svelte-mud.git
cd svelte-mud
# Install dependencies
npm install
# Start the development server
npm run dev
# Build for production
npm run build
npm ci
npm run dev:full
```
## Usage
`npm run dev:full` starts Vite and the WebSocket proxy. By default, development browser origins on localhost are accepted. Useful checks are:
1. **Creating a Profile**: Click "New Profile" to set up a connection to your MUD server. Configure host, port, and optional auto-login.
```bash
npm test
npm run check
npm run build
npm audit --omit=dev
```
2. **Connecting**: After creating a profile, click the connect button in the tab to establish a connection.
## Production configuration
3. **Setting Up Triggers**: Navigate to the Triggers tab and click "New Trigger" to create pattern matching triggers with various actions.
The supplied Docker Compose file runs the SvelteKit app and proxy as separate processes/containers. Caddy routes normal requests to the app and `/mud-ws` to the proxy.
4. **Customizing Settings**: Adjust appearance and accessibility options in the Settings tab.
Proxy environment variables:
## Project Structure
- `ALLOWED_ORIGINS`: comma-separated exact browser origins; required in production
- `TRUST_PROXY=1`: trust the first `X-Forwarded-For` address when deployed behind the configured reverse proxy
- `WS_PORT`: proxy listen port, default `3001`
- `src/lib/connection/` - MUD connection handling code
- `src/lib/gmcp/` - GMCP protocol handling
- `src/lib/triggers/` - Trigger system implementation
- `src/lib/accessibility/` - Accessibility features
- `src/lib/profiles/` - Profile management
- `src/lib/components/` - Svelte components
- `src/lib/stores/` - Svelte stores for state management
- `src/routes/api/` - Server endpoints for WebSocket proxying
- `static/sounds/` - Trigger sound files
- `static/icons/` - PWA icons in various sizes
- `static/manifest.json` - PWA manifest file
- `static/service-worker.js` - Service worker for offline capabilities
Set `ALLOWED_ORIGINS` to the deployed HTTPS origin and keep the proxy reachable only through a trusted reverse proxy. See [DOCKER-README.md](DOCKER-README.md).
## Configuration
## Data migration
The client can be configured through the UI, with settings stored in local browser storage:
- MUD server profiles
- Trigger patterns and actions
- UI preferences (dark mode, font size, etc.)
- Accessibility settings
Older local data is migrated on load. Stored profile passwords and trigger JavaScript actions are discarded. Backup import accepts only known settings, profile, and trigger keys, enforces a size limit, and sanitizes legacy data before applying it.
## WebSocket to Telnet Proxy
## Project layout
For security reasons, browser WebSockets cannot connect directly to telnet ports. SvelteMUD uses a server-side proxy to facilitate this connection. The proxy is implemented in the `src/routes/api/mud-connect` and `src/routes/api/mud-ws` endpoints.
## Progressive Web App (PWA) Support
SvelteMUD is configured as a Progressive Web App, allowing users to install it on their devices and use it offline:
### Features
- **Installable**: Add to home screen on mobile or desktop
- **Offline Support**: Basic functionality works without an internet connection
- **Automatic Updates**: Notifies users when a new version is available
- **Responsive Design**: Works on all screen sizes
### Installation
#### Mobile (iOS/Android)
1. Open SvelteMUD in your browser
2. Tap the Share button (iOS) or menu (Android)
3. Select "Add to Home Screen" or "Install App"
#### Desktop (Chrome, Edge, etc.)
1. Open SvelteMUD in your browser
2. Look for the install icon in the address bar
3. Click "Install" when prompted
### Customizing Icons
To replace the default PWA icons:
1. Replace the SVG template in `/static/icons/icon-512x512.svg`
2. Run the icon generator: `npm run generate-icons`
- `src/lib/connection/` — WebSocket control protocol and incremental Telnet parser
- `src/lib/gmcp/` — GMCP packages and routing
- `src/lib/triggers/` — trigger validation and execution
- `src/lib/profiles/` — profile storage and in-memory credentials
- `src/lib/stores/` — per-profile application state
- `src/websocket-server.js` — constrained WebSocket-to-Telnet proxy
- `static/icons/` — generated PWA icons
## License
This project is licensed under the [MIT License](LICENSE).
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
[MIT](LICENSE)
+34 -9
View File
@@ -1,22 +1,47 @@
version: '3.8'
services:
svelte-mud:
svelte-mud-app:
build:
context: .
dockerfile: Dockerfile
container_name: svelte-mud
image: svelte-mud:local
container_name: svelte-mud-app
command: ["node", "build/index.js"]
restart: unless-stopped
networks:
- revproxy
environment:
- NODE_ENV=production
# No need to publish ports to host, but expose them to container network
NODE_ENV: production
expose:
- 3000
- 3001
- "3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
svelte-mud-proxy:
image: svelte-mud:local
container_name: svelte-mud-proxy
command: ["node", "src/websocket-server.js"]
restart: unless-stopped
depends_on:
svelte-mud-app:
condition: service_started
networks:
- revproxy
environment:
NODE_ENV: production
ALLOWED_ORIGINS: https://mud.iamtalon.me
TRUST_PROXY: "1"
expose:
- "3001"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
# Define networks to connect to external services
networks:
revproxy:
external: true
external: true
+6 -3
View File
@@ -2,9 +2,12 @@
// Requires: npm install sharp
// Usage: node generate-icons.js
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Define icon sizes needed
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
+3030 -4601
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -10,36 +10,37 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:integration": "vitest run src/**/*.integration.test.*",
"start": "node run-production.js",
"generate-icons": "node generate-icons.js"
},
"dependencies": {
"@types/node": "^22.14.1",
"@types/node": "^22.20.1",
"@types/ws": "^8.18.1",
"ansi-to-html": "^0.7.2",
"events": "^3.3.0",
"express": "^4.18.2",
"howler": "^2.2.4",
"net": "^1.0.2",
"split.js": "^1.6.5",
"ws": "^8.18.1"
"ws": "^8.21.3"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.1.1",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@types/howler": "^2.2.11",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"sharp": "^0.33.2",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"sharp": "^0.35.4",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"tailwindcss": "^3.3.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.0.10",
"vite-plugin-node-polyfills": "^0.19.0",
"vite-plugin-pwa": "^0.19.4"
"typescript": "^5.9.3",
"vite": "^8.2.2",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.11"
},
"overrides": {
"cookie": "^0.7.2"
}
}
+3 -3
View File
@@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('Starting WebSocket server');
const wsServer = spawn('node', ['src/websocket-server.js'], {
stdio: 'inherit',
shell: true,
shell: false,
cwd: __dirname
});
@@ -16,7 +16,7 @@ const wsServer = spawn('node', ['src/websocket-server.js'], {
console.log('Starting SvelteKit production server');
const sveltekit = spawn('node', ['build/index.js'], {
stdio: 'inherit',
shell: true,
shell: false,
cwd: __dirname
});
@@ -41,4 +41,4 @@ process.on('SIGTERM', () => {
wsServer.kill('SIGTERM');
sveltekit.kill('SIGTERM');
process.exit(0);
});
});
+1 -5
View File
@@ -13,15 +13,11 @@
<meta name="apple-mobile-web-app-title" content="SvelteMUD" />
<!-- PWA Icons and Manifest -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/icon-192x192.png" />
<!-- Service Worker Registration -->
<script src="%sveltekit.assets%/register-sw.js" defer></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
</html>
-205
View File
@@ -1,205 +0,0 @@
import { WebSocketServer } from 'ws';
import * as net from 'net';
import * as tls from 'tls';
import { parse } from 'url';
// Create WebSocket server instance
const wss = new WebSocketServer({ noServer: true });
// Active connections and their proxies
const connections = new Map();
// Handle WebSocket connections
wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
console.log(`WebSocket connection established for ${mudHost}:${mudPort} (SSL: ${useSSL})`);
// Create a unique ID for this connection
const connectionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Special handling for test connections
if (mudHost === 'example.com' && mudPort === '23') {
console.log('Test connection detected - using echo server mode');
// Send welcome message
ws.send('Hello from WebSocket test server! This is an echo server.');
// Echo back messages
ws.on('message', (message) => {
console.log('Test server received:', message.toString());
ws.send(`Echo: ${message.toString()}`);
});
// Handle close
ws.on('close', () => {
console.log('Test connection closed');
connections.delete(connectionId);
});
// Store the connection (without a socket)
connections.set(connectionId, { ws, testMode: true });
return;
}
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) });
// 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);
});
// 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();
}
return;
}
// Handle data from the MUD server - only in regular mode, not test mode
if (socket) {
socket.on('data', (data) => {
// Check for GMCP data (IAC SB GMCP) - very basic check for debugging
// IAC = 255, SB = 250, GMCP = 201
let isGmcp = false;
for (let i = 0; i < data.length - 2; i++) {
if (data[i] === 255 && data[i+1] === 250 && data[i+2] === 201) {
isGmcp = true;
console.log('WebSocket server: Detected GMCP data in server response');
break;
}
}
// Forward data to the WebSocket client if it's still open
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(data);
console.log(`WebSocket server: Sent ${data.length} bytes to client${isGmcp ? ' (contains GMCP data)' : ''}`);
}
});
}
// Handle socket close
if (socket) {
socket.on('close', () => {
console.log(`MUD connection closed for ${mudHost}:${mudPort}`);
// Close WebSocket if it's still open
if (ws.readyState === 1) {
ws.close();
}
// Remove from connections map
connections.delete(connectionId);
});
}
// 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;
// Check for GMCP data (IAC SB GMCP) in client messages
let isGmcp = false;
if (message instanceof Buffer || message instanceof Uint8Array) {
for (let i = 0; i < message.length - 2; i++) {
if (message[i] === 255 && message[i+1] === 250 && message[i+2] === 201) {
isGmcp = true;
console.log('WebSocket server: Detected GMCP data in client message');
break;
}
}
}
// Forward data to the MUD server
// The message might be Buffer, ArrayBuffer, or string
if (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 {
console.error('Socket not writable, cannot send data to MUD server');
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(Buffer.from(`ERROR: Cannot send data to MUD server: Socket not connected\r\n`));
}
}
} 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`));
}
}
});
// Handle WebSocket close
ws.on('close', () => {
console.log(`WebSocket closed for ${mudHost}:${mudPort}`);
// Close socket if it's still open
const conn = connections.get(connectionId);
if (conn && conn.socket) {
conn.socket.end();
}
// Remove from 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
const conn = connections.get(connectionId);
if (conn && conn.socket) {
conn.socket.end();
}
// Remove from connections map
connections.delete(connectionId);
});
});
// Handle WebSocket upgrades
export const handleWebSocket = (server) => {
server.on('upgrade', (request, socket, head) => {
// Parse URL to get query parameters
const { pathname, query } = parse(request.url, true);
// Only handle WebSocket connections to /mud-ws
if (pathname === '/mud-ws') {
// Extract MUD server details from query parameters
const { host, port, useSSL } = query;
if (!host || !port) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
// Handle WebSocket upgrade
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, host, port, useSSL === 'true');
});
} else {
// For other upgrades (not to /mud-ws), let SvelteKit handle them
// Don't destroy the socket here
}
});
};
// Standard SvelteKit hook
export async function handle({ event, resolve }) {
return await resolve(event);
}
+6 -6
View File
@@ -6,7 +6,7 @@
let isImporting = false;
let importError = '';
let importSuccess = '';
let backupStats = null;
let backupStats: { profileCount: number; triggerCount: number; hasSettings: boolean; timestamp: string } | null = null;
// Handle export button click
async function handleExport() {
@@ -18,7 +18,7 @@
backupManager.exportBackup();
importSuccess = 'Backup exported successfully!';
} catch (error) {
importError = `Export failed: ${error.message}`;
importError = `Export failed: ${error instanceof Error ? error.message : String(error)}`;
} finally {
isExporting = false;
@@ -41,7 +41,7 @@
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files?.[0];
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
isImporting = true;
@@ -51,7 +51,7 @@
reader.onload = async (event) => {
try {
const json = event.target.result as string;
const json = event.target?.result as string;
await backupManager.importBackup(json);
importSuccess = 'Backup imported successfully! The page will reload in 2 seconds.';
@@ -61,7 +61,7 @@
window.location.reload();
}, 2000);
} catch (error) {
importError = `Import failed: ${error.message}`;
importError = `Import failed: ${error instanceof Error ? error.message : String(error)}`;
isImporting = false;
}
};
@@ -73,7 +73,7 @@
reader.readAsText(file);
} catch (error) {
importError = `Import failed: ${error.message}`;
importError = `Import failed: ${error instanceof Error ? error.message : String(error)}`;
isImporting = false;
}
};
+16 -18
View File
@@ -7,20 +7,20 @@
// Props
export let title = '';
export let closable = true;
export let component = null;
export let componentProps = {};
export let component: any = null;
export let componentProps: Record<string, unknown> = {};
// State
let isOpen = false;
let modalContent;
let componentInstance = null;
let modalContent: HTMLDivElement;
let componentInstance: any = null;
// Event callbacks
let onSubmitCallback = null;
let onCancelCallback = null;
let onSubmitCallback: ((detail: any) => void) | null = null;
let onCancelCallback: (() => void) | null = null;
// Handle component dispatch events
function handleComponentEvent(event) {
function handleComponentEvent(event: { type: string; detail?: any }) {
if (event.type === 'save') {
if (onSubmitCallback) {
onSubmitCallback(event.detail);
@@ -54,7 +54,7 @@
}
// Set properties and callbacks
export function setProps(props) {
export function setProps(props: { title?: string; closable?: boolean; component?: any; componentProps?: Record<string, unknown>; onSubmit?: (detail: any) => void; onCancel?: () => void }) {
if (props.title !== undefined) title = props.title;
if (props.closable !== undefined) closable = props.closable;
if (props.component !== undefined) component = props.component;
@@ -93,7 +93,7 @@
// Listen for events from the component
for (const event of ['save', 'cancel']) {
componentInstance.$on(event, (e) => handleComponentEvent({ type: event, detail: e.detail }));
componentInstance.$on(event, (e: CustomEvent) => handleComponentEvent({ type: event, detail: e.detail }));
}
} catch (error) {
console.error('Error creating component in modal:', error);
@@ -101,7 +101,7 @@
}
// Close on ESC key
function handleKeydown(event) {
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && closable && isOpen) {
close();
if (onCancelCallback) onCancelCallback();
@@ -123,24 +123,22 @@
});
// Prevent clicks inside the modal from bubbling up
function handleModalClick(event) {
function handleModalClick(event: MouseEvent) {
event.stopPropagation();
}
// Handle backdrop click
function handleBackdropClick() {
if (closable) {
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && closable) {
close();
if (onCancelCallback) onCancelCallback();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: 150 }}>
<div class="modal-content" on:click={handleModalClick} transition:scale={{ start: 0.95, duration: 200 }}>
<div class="modal-backdrop" role="presentation" on:click={handleBackdropClick} on:keydown={handleKeydown} transition:fade={{ duration: 150 }}>
<div class="modal-content" role="dialog" aria-modal="true" tabindex="-1" transition:scale={{ start: 0.95, duration: 200 }}>
<div class="modal-header">
<h2 class="modal-title">{title}</h2>
{#if closable}
@@ -241,4 +239,4 @@
color: #f8f9fa;
background-color: #4a5568;
}
</style>
</style>
+79 -68
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { ConnectionManager, connections } from '$lib/connection/ConnectionManager';
import { ConnectionManager } from '$lib/connection/ConnectionManager';
import type { MudConnection } from '$lib/connection/MudConnection';
import type { MudProfile } from '$lib/profiles/ProfileManager';
import type { TriggerResult } from '$lib/triggers/TriggerSystem';
import { TriggerSystem } from '$lib/triggers/TriggerSystem';
import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager';
import {
@@ -8,11 +11,15 @@
activeProfileId,
activeProfile,
profiles,
addToOutputHistory,
appendOutput,
updateGmcpData,
accessibilitySettings
logGmcpMessage,
accessibilitySettings,
connections,
sensitiveInput
} from '$lib/stores/mudStore';
import { get } from 'svelte/store';
import { credentialVault } from '$lib/profiles/CredentialVault';
const dispatch = createEventDispatcher();
@@ -22,7 +29,7 @@
// Local state
let connectionManager: ConnectionManager;
let connection = null;
let connection: MudConnection | null = null;
let triggerSystem: TriggerSystem;
let accessibilityManager: AccessibilityManager;
let awaitingSessionResumed = false;
@@ -49,7 +56,19 @@
}
// Cleanup functions
let unsubscribeFunctions = [];
let unsubscribeFunctions: Array<() => void> = [];
let loginTimers: number[] = [];
const scheduleLogin = (callback: () => void, delay: number) => {
const timer = window.setTimeout(() => {
loginTimers = loginTimers.filter((value) => value !== timer);
callback();
}, delay);
loginTimers.push(timer);
};
const clearLoginTimers = () => {
loginTimers.forEach((timer) => window.clearTimeout(timer));
loginTimers = [];
};
onMount(() => {
console.log(`MudConnection component mounted for profile: ${profileId}`);
@@ -89,6 +108,7 @@
});
onDestroy(() => {
clearLoginTimers();
console.log(`MudConnection component being destroyed for profile: ${profileId}`);
// Remove keyboard listener
@@ -116,6 +136,9 @@
// Initialize trigger system
triggerSystem = new TriggerSystem();
triggerSystem.on('sendText', (text: string) => {
if (connection?.isConnected()) connection.send(text);
});
console.log('Trigger system created');
// Initialize accessibility manager
@@ -166,7 +189,7 @@
/**
* Set up listeners for a specific connection
*/
function setupConnectionListeners(conn) {
function setupConnectionListeners(conn: MudConnection) {
console.log('Setting up connection listeners');
// Remove any existing listeners to prevent duplicates
@@ -189,6 +212,7 @@
conn.on('session_resumed', handleSessionResumed);
conn.on('message_replay_start', handleMessageReplayStart);
conn.on('message_replay_complete', handleMessageReplayComplete);
conn.on('sensitiveInput', handleSensitiveInput);
console.log('Connection listeners attached successfully');
}
@@ -196,7 +220,7 @@
/**
* Remove listeners from a connection
*/
function removeConnectionListeners(conn) {
function removeConnectionListeners(conn: MudConnection | null) {
if (!conn) return;
conn.off('received', handleReceived);
@@ -209,6 +233,7 @@
conn.off('session_resumed', handleSessionResumed);
conn.off('message_replay_start', handleMessageReplayStart);
conn.off('message_replay_complete', handleMessageReplayComplete);
conn.off('sensitiveInput', handleSensitiveInput);
}
/**
@@ -226,7 +251,7 @@
const profile = allProfiles.find(p => p.id === profileId);
if (!profile) {
addToOutputHistory(`Error: Profile ${profileId} not found.`);
appendOutput(profileId, `Error: Profile ${profileId} not found.`);
console.error(`Profile ${profileId} not found`);
return;
}
@@ -238,7 +263,7 @@
}));
if (get(activeProfileId) === profileId) {
addToOutputHistory(`Connecting to ${profile.host}:${profile.port}...`);
appendOutput(profileId, `Connecting to ${profile.host}:${profile.port}...`);
}
// Connect using the connection manager
@@ -256,7 +281,7 @@
}));
if (get(activeProfileId) === profileId) {
addToOutputHistory(`Error connecting: ${error.message}`);
appendOutput(profileId, `Error connecting: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
@@ -265,38 +290,26 @@
* Disconnect from the MUD server
*/
export function disconnect() {
clearLoginTimers();
credentialVault.clear(profileId);
connectionManager.disconnect(profileId);
}
/**
* Handle connection established
*/
function handleConnected() {
function handleConnected(connectionInfo: { resumed?: boolean } = {}) {
// Find the profile
const profile = get(profiles).find(p => p.id === profileId);
console.log(`Profile ${profileId} connected:`, profile);
// Only add to output history if this is the active profile
if (get(activeProfileId) === profileId) {
addToOutputHistory(`Connected to ${profile?.host}:${profile?.port}`);
appendOutput(profileId, `Connected to ${profile?.host}:${profile?.port}`);
}
// Check if we're reconnecting to an existing session
const hasStoredSession = connection && connection.getSessionId();
if (hasStoredSession) {
console.log(`Connection has existing session ID, waiting for session_resumed event before autologin`);
awaitingSessionResumed = true;
// Set a timeout in case session_resumed event doesn't come
setTimeout(() => {
if (awaitingSessionResumed) {
console.log('Timeout waiting for session_resumed, proceeding with autologin');
awaitingSessionResumed = false;
performAutoLogin(profile);
}
}, 5000); // 5 second timeout
} else {
if (!connectionInfo.resumed) {
// Fresh connection, proceed with autologin immediately
console.log('Fresh connection, proceeding with autologin');
performAutoLogin(profile);
@@ -308,26 +321,28 @@
/**
* Perform auto-login if enabled
*/
function performAutoLogin(profile) {
function performAutoLogin(profile: MudProfile | undefined) {
// Handle auto-login if enabled
if (profile?.autoLogin?.enabled) {
setTimeout(() => {
scheduleLogin(() => {
// Send username
if (profile.autoLogin?.username && connection) {
connection.send(profile.autoLogin.username);
}
// Send password after a delay
if (profile.autoLogin?.password) {
setTimeout(() => {
if (connection) connection.send(profile.autoLogin?.password || '');
const password = credentialVault.getPassword(profileId) ?? window.prompt(`Password for ${profile.name} (kept only until this page closes):`) ?? '';
if (password) {
credentialVault.setPassword(profileId, password);
scheduleLogin(() => {
if (connection?.isConnected()) connection.send(password);
// Send additional commands
if (profile.autoLogin?.commands && profile.autoLogin.commands.length > 0) {
let delay = 500;
profile.autoLogin.commands.forEach((cmd) => {
setTimeout(() => {
if (connection) connection.send(cmd);
profile.autoLogin.commands.forEach((cmd: string) => {
scheduleLogin(() => {
if (connection?.isConnected()) connection.send(cmd);
}, delay);
delay += 500;
});
@@ -342,11 +357,12 @@
* Handle connection closed
*/
function handleDisconnected() {
clearLoginTimers();
console.log(`Profile ${profileId} disconnected`);
// Only add to output history if this is the active profile
if (get(activeProfileId) === profileId) {
addToOutputHistory('Disconnected from server.');
appendOutput(profileId, 'Disconnected from server.');
}
dispatch('disconnected');
@@ -355,17 +371,14 @@
/**
* Handle connection error
*/
function handleError(error) {
console.log(`Profile ${profileId} connection error:`, error);
function handleError(error: unknown) {
// Format the error message for display
const errorMessage = typeof error === 'object' ?
(error.message || JSON.stringify(error)) :
String(error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Only add to output history if this is the active profile
if (get(activeProfileId) === profileId) {
addToOutputHistory(`Connection error: ${errorMessage}`, false, [
appendOutput(profileId, `Connection error: ${errorMessage}`, false, [
{ pattern: 'Connection error', color: '#ff5555', isRegex: false }
]);
}
@@ -376,7 +389,7 @@
/**
* Handle received data
*/
function handleReceived(text) {
function handleReceived(text: string) {
console.log(`Profile ${profileId} received data`);
try {
@@ -386,13 +399,14 @@
let processedText = text;
let isGagged = false;
let triggerMatched = false;
let triggerResult: TriggerResult = { processed: text, gagged: false, matched: false, highlights: [] };
if (triggerSystem) {
try {
const result = triggerSystem.processTriggers(text);
processedText = result.processed;
isGagged = result.gagged;
triggerMatched = result.matched;
triggerResult = triggerSystem.processTriggers(text);
processedText = triggerResult.processed;
isGagged = triggerResult.gagged;
triggerMatched = triggerResult.matched;
console.log(`Trigger processing result - gagged: ${isGagged}, matched: ${triggerMatched}, modified: ${processedText !== text}`);
} catch (error) {
@@ -402,7 +416,7 @@
// Add to output history if not gagged
if (!isGagged) {
addToOutputHistory(processedText);
appendOutput(profileId, processedText, false, triggerResult.highlights);
// Handle text-to-speech for processed text
console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`);
@@ -415,7 +429,6 @@
try {
// If not active profile, add profile name prefix for context
const speechText = isActiveProfile ? processedText : `From ${getProfileName(profileId)}: ${processedText}`;
console.log(`Speaking text for ${profileId}:`, speechText.substring(0, 50) + (speechText.length > 50 ? '...' : ''));
accessibilityManager.speak(speechText);
} catch (error) {
console.error('Error using text-to-speech:', error);
@@ -446,7 +459,7 @@
/**
* Helper to get profile name for speech announcements
*/
function getProfileName(id) {
function getProfileName(id: string) {
const allProfiles = get(profiles);
const profile = allProfiles.find(p => p.id === id);
return profile ? profile.name : id;
@@ -455,16 +468,20 @@
/**
* Handle sent data
*/
function handleSent(text) {
function handleSent(text: string) {
dispatch('sent', { text });
}
function handleSensitiveInput(enabled: boolean) {
sensitiveInput.update((values) => ({ ...values, [profileId]: enabled }));
}
/**
* Handle GMCP message
*/
function handleGmcp(module, data) {
console.log(`GMCP message received for ${profileId}: ${module}`, data);
updateGmcpData(module, data);
function handleGmcp(module: string, data: unknown) {
updateGmcpData(profileId, module, data);
logGmcpMessage(profileId, module, data);
// Forward GMCP events
dispatch('gmcp', { module, data });
@@ -473,16 +490,14 @@
/**
* Handle play sound event
*/
function handlePlaySound(options) {
console.log(`Play sound event for ${profileId}:`, options);
function handlePlaySound(options: { url: string; volume: number; loop: boolean }) {
dispatch('playSound', options);
}
/**
* Handle session resumed event
*/
function handleSessionResumed(data) {
console.log(`Session resumed for ${profileId}:`, data);
function handleSessionResumed(data: { messagesReplayed: number }) {
// We successfully resumed a session, so don't perform autologin
if (awaitingSessionResumed) {
@@ -492,9 +507,9 @@
if (data.messagesReplayed > 0) {
// Add a system message to the output to notify the user
addToOutputHistory(`[SYSTEM] Reconnected to MUD. ${data.messagesReplayed} messages have been replayed.`, false);
appendOutput(profileId, `[SYSTEM] Reconnected to MUD. ${data.messagesReplayed} messages have been replayed.`, false);
} else {
addToOutputHistory('[SYSTEM] Reconnected to MUD.', false);
appendOutput(profileId, '[SYSTEM] Reconnected to MUD.', false);
}
dispatch('sessionResumed', data);
@@ -503,13 +518,9 @@
/**
* Handle message replay start event
*/
function handleMessageReplayStart(data) {
console.log(`Message replay starting for ${profileId}:`, data);
function handleMessageReplayStart(data: { messageCount: number; timespan?: number }) {
const timeAgo = Math.round(data.timespan / 1000);
const timeUnit = timeAgo > 60 ? `${Math.round(timeAgo / 60)} minutes` : `${timeAgo} seconds`;
addToOutputHistory(`[SYSTEM] Replaying ${data.messageCount} messages from the last ${timeUnit}...`, false);
appendOutput(profileId, `[SYSTEM] Replaying ${data.messageCount} buffered messages...`, false);
dispatch('messageReplayStart', data);
}
@@ -518,7 +529,7 @@
*/
function handleMessageReplayComplete() {
console.log(`Message replay complete for ${profileId}`);
addToOutputHistory('[SYSTEM] Message replay complete. You are now up to date.', false);
appendOutput(profileId, '[SYSTEM] Message replay complete. You are now up to date.', false);
dispatch('messageReplayComplete');
}
</script>
@@ -575,4 +586,4 @@
white-space: nowrap;
border-width: 0;
}
</style>
</style>
+11 -6
View File
@@ -68,7 +68,6 @@
try {
// Get the profiles from the store
const allProfiles = $profiles || [];
console.log('Initializing tabs with profiles:', allProfiles);
if (allProfiles.length === 0) {
console.warn('No profiles available to create tabs');
@@ -106,7 +105,8 @@
// Auto-connect if enabled
if (autoConnectOnStart && tabs.length > 0 && !$connectionStatus[activeTab]) {
console.log(`Auto-connecting to tab: ${activeTab}`);
setTimeout(() => connectToMud(activeTab), 1000);
const profileToConnect = activeTab;
setTimeout(() => { if (profileToConnect) connectToMud(profileToConnect); }, 1000);
}
}
@@ -136,7 +136,7 @@
// Make sure the connection store is updated with the active profile
const connectionManager = ConnectionManager.getInstance();
const existingConnection = connectionManager.getExistingConnection(tabId);
const existingConnection = connectionManager.getConnection(tabId);
// Update the connections store - this ensures the connection is associated with the profile ID
if (existingConnection) {
@@ -257,7 +257,6 @@
// Update when profiles change or active profile changes
$: if ($profiles) {
console.log('Profiles updated in store, reinitializing tabs:', $profiles);
initializeTabs();
}
@@ -332,10 +331,12 @@
</div>
</div>
{:else}
<!-- Only render the active tab -->
{#each safeTabs.filter(tab => tab.id === activeTab) as tab (tab.id)}
<!-- Keep every connection component mounted so background sessions retain their listeners. -->
{#each safeTabs as tab (tab.id)}
<div
class="mud-mdi-pane"
class:active={tab.id === activeTab}
hidden={tab.id !== activeTab}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
@@ -513,6 +514,10 @@
width: 100%;
overflow: hidden; /* Prevent overflow issues */
}
.mud-mdi-pane[hidden] {
display: none;
}
.mud-mdi-pane-header {
display: flex;
+11 -17
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { activeRenderableLines, addToOutputHistory, addToInputHistory, navigateInputHistory, activeInputHistoryIndex, activeConnection, uiSettings, accessibilitySettings, activeInputHistory, activeProfileId, connectionStatus } from '$lib/stores/mudStore';
import { activeRenderableLines, addToOutputHistory, addToInputHistory, navigateInputHistory, activeInputHistoryIndex, activeConnection, activeSensitiveInput, uiSettings, accessibilitySettings, activeProfileId, connectionStatus } from '$lib/stores/mudStore';
import { tick } from 'svelte';
import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager';
import AriaLiveAnnouncer from '$lib/accessibility/AriaLiveAnnouncer.svelte';
import { segmentStyle } from '$lib/utils/textProcessing';
// Create safe defaults for reactivity
$: safeRenderableLines = $activeRenderableLines || [];
@@ -43,12 +44,10 @@
accessibilityManager.stopSpeech();
}
// Add to input history
addToInputHistory(currentInput);
if (!$activeSensitiveInput) addToInputHistory(currentInput);
// Show the command in the output (only if not password - for privacy)
const isPassword = currentInput.startsWith('password') || currentInput.toLowerCase() === $activeInputHistory[$activeInputHistory.length - 2]?.toLowerCase().replace('username', 'password');
if (!isPassword) {
if (!$activeSensitiveInput) {
addToOutputHistory(`> ${currentInput}`, true);
} else {
addToOutputHistory(`> ********`, true);
@@ -64,14 +63,10 @@
if (status === 'connected') {
try {
// Try using the activeConnection first
if ($activeConnection) {
$activeConnection.send(currentInput);
} else {
// If not available, use the ConnectionManager directly
const { ConnectionManager } = await import('$lib/connection/ConnectionManager');
const connectionManager = ConnectionManager.getInstance();
connectionManager.send(profileId, currentInput);
throw new Error('Active connection is unavailable.');
}
} catch (error) {
console.error('Error sending command:', error);
@@ -252,10 +247,7 @@
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 || '';
return line.content;
})
.join(' ')
.trim();
@@ -352,7 +344,9 @@
<span class="mud-timestamp" aria-hidden="true">[{formatTimestamp(line.timestamp)}]</span>
{/if}
<div class="mud-terminal-content">
{@html line.content}
{#each line.segments as segment}
<span style={segmentStyle(segment)}>{segment.text}</span>
{/each}
</div>
</div>
{/each}
@@ -360,7 +354,7 @@
<form class="mud-terminal-input-form" on:submit={handleSubmit} aria-label="MUD command input form">
<input
type="text"
type={$activeSensitiveInput ? 'password' : 'text'}
class="mud-terminal-input"
bind:this={inputElement}
bind:value={currentInput}
@@ -561,4 +555,4 @@
outline: 3px solid #fff;
background-color: #444;
}
</style>
</style>
+2 -10
View File
@@ -19,7 +19,6 @@
autoLogin: {
enabled: false,
username: '',
password: '',
commands: []
},
accessibilityOptions: {
@@ -36,8 +35,6 @@
// Local state - merge default with provided profile
let localProfile = { ...defaultProfile, ...profile };
console.log('Initialized profile editor with:', localProfile);
// Extract nested objects for easier binding
let autoLogin = {
...defaultProfile.autoLogin,
@@ -53,7 +50,6 @@
// Handle form submission
function handleSubmit() {
console.log('Saving profile:', localProfile);
// Update local profile
localProfile.autoLogin = autoLogin;
localProfile.accessibilityOptions = accessibilityOptions;
@@ -129,13 +125,10 @@
<input type="text" id="username" bind:value={autoLogin.username} />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" bind:value={autoLogin.password} />
</div>
<p class="credential-note">For security, the password is requested when connecting and is kept only in memory.</p>
<div class="form-group">
<label id="commands-label">Auto-Login Commands</label>
<span class="field-label" id="commands-label">Auto-Login Commands</span>
<div class="commands-list" role="list" aria-labelledby="commands-label">
{#each autoLogin.commands as command, index}
<div class="command-item" role="listitem">
@@ -254,7 +247,6 @@
input[type="text"],
input[type="number"],
input[type="password"],
select {
width: 100%;
padding: 8px;
+2 -3
View File
@@ -16,7 +16,6 @@
try {
ModalHelper.showProfileEditor(
(profile) => {
console.log('Profile saved from profile editor:', profile);
dispatch('saveProfile', { profile });
},
() => {
@@ -25,7 +24,7 @@
);
} catch (error) {
console.error('Error showing profile editor:', error);
alert('Error showing modal: ' + error.message);
alert('Error showing modal: ' + (error instanceof Error ? error.message : String(error)));
}
}
@@ -126,4 +125,4 @@
color: var(--color-text-muted);
font-style: italic;
}
</style>
</style>
+18 -63
View File
@@ -4,12 +4,7 @@
import { settingsManager } from '$lib/settings/SettingsManager';
import BackupPanel from './BackupPanel.svelte';
// Declare global window property for volume debounce
declare global {
interface Window {
volumeDebounceTimeout?: number;
}
}
let volumeDebounceTimeout: ReturnType<typeof setTimeout> | undefined;
// Reset settings to defaults
function resetSettings() {
@@ -41,16 +36,16 @@
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files?.[0];
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = event.target.result as string;
const json = event.target?.result as string;
settingsManager.importSettings(json);
} catch (error) {
alert(`Failed to import settings: ${error.message}`);
alert(`Failed to import settings: ${error instanceof Error ? error.message : String(error)}`);
}
};
@@ -125,15 +120,15 @@
step="0.1"
on:input={(e) => {
// Debounce volume changes to avoid performance issues with rapid changes
if (window.volumeDebounceTimeout) {
clearTimeout(window.volumeDebounceTimeout);
if (volumeDebounceTimeout) {
clearTimeout(volumeDebounceTimeout);
}
// Read value directly from the input
const newVolume = parseFloat(e.target.value);
const newVolume = parseFloat((e.currentTarget as HTMLInputElement).value);
// Update the store with a slight delay to avoid excessive updates
window.volumeDebounceTimeout = setTimeout(() => {
volumeDebounceTimeout = setTimeout(() => {
uiSettings.update(settings => ({
...settings,
globalVolume: newVolume
@@ -145,59 +140,19 @@
<span class="range-value">{($uiSettings.globalVolume * 100).toFixed(0)}%</span>
</div>
</div>
<div class="setting-item">
<span class="setting-name">Allow Server Media</span>
<label class="switch">
<input type="checkbox" bind:checked={$uiSettings.allowServerMedia}>
<span class="slider round"></span>
</label>
<div class="setting-description">Allows connected MUDs to load HTTPS audio. Disabled by default.</div>
</div>
<h4>Connection</h4>
<div class="setting-item">
<span class="setting-name">Connection Persistence Timeout</span>
<div class="range-control">
<input
type="range"
min="1"
max="60"
step="1"
bind:value={$connectionSettings.persistenceTimeoutMinutes}
>
<span class="range-value">{$connectionSettings.persistenceTimeoutMinutes} min</span>
</div>
<div class="setting-description">
How long to keep MUD connections alive when the app goes to the background (useful for mobile devices)
</div>
</div>
<div class="setting-item">
<span class="setting-name">Message Buffer Size</span>
<div class="range-control">
<input
type="range"
min="50"
max="500"
step="25"
bind:value={$connectionSettings.maxBufferMessages}
>
<span class="range-value">{$connectionSettings.maxBufferMessages} messages</span>
</div>
<div class="setting-description">
Maximum number of messages to buffer while disconnected for replay on reconnection
</div>
</div>
<div class="setting-item">
<span class="setting-name">Buffer Memory Limit</span>
<div class="range-control">
<input
type="range"
min="5"
max="50"
step="5"
bind:value={$connectionSettings.maxBufferSizeKB}
>
<span class="range-value">{$connectionSettings.maxBufferSizeKB} KB</span>
</div>
<div class="setting-description">
Maximum memory to use for buffering messages (prevents excessive memory usage)
</div>
</div>
<div class="setting-description">Disconnected sessions are retained for five minutes. Buffer and quota limits are enforced by the proxy.</div>
<h4>Debugging</h4>
+4 -4
View File
@@ -68,7 +68,7 @@
aria-controls="panel-profiles"
aria-selected={activeTab === 'profiles'}
class:active={activeTab === 'profiles'}
tabindex={activeTab === 'profiles' ? "0" : "-1"}
tabindex={activeTab === 'profiles' ? 0 : -1}
on:click={() => dispatch('tabChange', { tab: 'profiles' })}
on:keydown={handleSidebarTabKeydown}>
Profiles
@@ -80,7 +80,7 @@
aria-controls="panel-triggers"
aria-selected={activeTab === 'triggers'}
class:active={activeTab === 'triggers'}
tabindex={activeTab === 'triggers' ? "0" : "-1"}
tabindex={activeTab === 'triggers' ? 0 : -1}
on:click={() => dispatch('tabChange', { tab: 'triggers' })}
on:keydown={handleSidebarTabKeydown}>
Triggers
@@ -92,7 +92,7 @@
aria-controls="panel-settings"
aria-selected={activeTab === 'settings'}
class:active={activeTab === 'settings'}
tabindex={activeTab === 'settings' ? "0" : "-1"}
tabindex={activeTab === 'settings' ? 0 : -1}
on:click={() => dispatch('tabChange', { tab: 'settings' })}
on:keydown={handleSidebarTabKeydown}>
Settings
@@ -130,4 +130,4 @@
.sidebar-tab:focus:not(:focus-visible) {
outline: none;
}
</style>
</style>
+5 -10
View File
@@ -1,15 +1,11 @@
<script>
<script lang="ts">
export let show = true; // Force modal to be visible by default
function closeModal() {
show = false;
function closeModal(e?: MouseEvent) {
if (!e || e.target === e.currentTarget) show = false;
}
function stopPropagation(e) {
e.stopPropagation();
}
function handleKeydown(e) {
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeModal();
}
@@ -22,12 +18,11 @@
on:click={closeModal}
on:keydown={handleKeydown}
role="dialog"
tabindex="-1"
aria-modal="true"
>
<div
class="modal-content"
on:click={stopPropagation}
on:keydown={handleKeydown}
role="document"
>
<slot></slot>
@@ -14,7 +14,6 @@
};
function handleSubmit() {
console.log('Saving profile:', profile);
dispatch('save', { profile });
}
+2 -7
View File
@@ -150,7 +150,7 @@
max="1"
step="0.1"
/>
<span class="range-value">{Math.round(localTrigger.soundVolume * 100)}%</span>
<span class="range-value">{Math.round((localTrigger.soundVolume ?? 0.7) * 100)}%</span>
</div>
<small>Trigger sound volume is multiplied by global volume setting</small>
</div>
@@ -161,7 +161,7 @@
<select
id="textAction"
on:change={(e) => {
const val = e.target.value;
const val = (e.currentTarget as HTMLSelectElement).value;
if (val === 'gag') {
localTrigger.gag = true;
localTrigger.replaceText = '';
@@ -209,11 +209,6 @@
<input type="color" id="highlightColor" bind:value={localTrigger.highlightColor} />
</div>
<div class="form-group">
<label for="action">Custom Action (JavaScript)</label>
<textarea id="action" bind:value={localTrigger.action} rows="5" placeholder="// JavaScript code to run when trigger matches"></textarea>
<small>Available variables: text, matches</small>
</div>
</fieldset>
<fieldset>
+3 -3
View File
@@ -28,7 +28,7 @@
);
} catch (error) {
console.error('Error showing trigger editor:', error);
alert('Error showing modal: ' + error.message);
alert('Error showing modal: ' + (error instanceof Error ? error.message : String(error)));
}
}
@@ -48,7 +48,7 @@
);
} catch (error) {
console.error('Error showing trigger editor:', error);
alert('Error showing modal: ' + error.message);
alert('Error showing modal: ' + (error instanceof Error ? error.message : String(error)));
}
}
@@ -181,4 +181,4 @@
color: var(--color-text-muted);
font-style: italic;
}
</style>
</style>
+8 -11
View File
@@ -1,9 +1,8 @@
import { writable, get } from 'svelte/store';
import { get } from 'svelte/store';
import { MudConnection } from './MudConnection';
import { connectionStatus } from '$lib/stores/mudStore';
import { connectionStatus, connections } from '$lib/stores/mudStore';
// Simple store for active connections
export const connections = writable<Record<string, MudConnection>>({});
export { connections } from '$lib/stores/mudStore';
/**
* ConnectionManager - Singleton service to manage MUD connections
@@ -55,10 +54,11 @@ export class ConnectionManager {
// Check if a connection already exists for this profile
const existingConnection = this.getConnection(profileId);
if (existingConnection) {
if (existingConnection && existingConnection.matchesTarget({ host, port, useSSL })) {
console.log(`Connection already exists for profile ${profileId}`);
return existingConnection;
}
if (existingConnection) this.removeConnection(profileId);
// Create a new connection with the profile ID as the connection ID
console.log(`Creating new connection for profile ${profileId}`);
@@ -149,10 +149,7 @@ export class ConnectionManager {
const connection = this.getConnection(profileId);
if (connection) {
// Disconnect first if needed
if (connection.isConnected()) {
connection.disconnect();
}
connection.disconnect();
// Remove from store
connections.update(conns => {
@@ -199,7 +196,7 @@ export class ConnectionManager {
});
// Handle connection error
connection.on('error', (error) => {
connection.on('error', (error: unknown) => {
console.error(`Connection error for profile ${profileId}:`, error);
// Update connection status
@@ -209,4 +206,4 @@ export class ConnectionManager {
}));
});
}
}
}
+180 -668
View File
@@ -1,19 +1,6 @@
import { EventEmitter } from '$lib/utils/EventEmitter';
import { GmcpHandler } from '$lib/gmcp/GmcpHandler';
import { get } from 'svelte/store';
import { connectionSettings } from '$lib/stores/mudStore';
// IAC codes for telnet negotiation
enum TelnetCommand {
IAC = 255, // Interpret As Command
DONT = 254,
DO = 253,
WONT = 252,
WILL = 251,
SB = 250, // Subnegotiation Begin
SE = 240, // Subnegotiation End
GMCP = 201, // Generic MUD Communication Protocol
}
import { EventEmitter } from '$lib/utils/EventEmitter';
import { TELNET, TelnetParser } from './TelnetParser';
export interface MudConnectionOptions {
id: string;
@@ -22,699 +9,224 @@ export interface MudConnectionOptions {
useSSL?: boolean;
}
// Connection persistence state
interface ConnectionPersistence {
sessionId?: string;
reconnectAttempts: number;
maxReconnectAttempts: number;
reconnectDelay: number;
lastDisconnectTime?: number;
export type MudConnectionState = 'idle' | 'connecting' | 'connected' | 'resuming' | 'disconnecting' | 'error';
interface ProxyControlMessage {
type: string;
resumeToken?: string;
messageCount?: number;
messagesReplayed?: number;
code?: string;
message?: string;
reason?: string;
}
// Stored session data in localStorage
interface StoredSessionData {
sessionId: string;
profileId: string;
host: string;
port: number;
useSSL: boolean;
lastActivity: number;
createdAt: number;
}
/**
* MudConnection - Handles a single connection to a MUD server
* Each instance has its own GMCP handler and maintains its own state
* Now supports connection persistence and automatic reconnection
*/
export class MudConnection extends EventEmitter {
private host: string;
private port: number;
private useSSL: boolean;
private webSocket: WebSocket | null = null;
private gmcpHandler: GmcpHandler;
private buffer: number[] = [];
private connected: boolean = false;
private negotiationBuffer: number[] = [];
private isInIAC: boolean = false;
private inSubnegotiation: boolean = false;
public readonly id: string;
// Connection persistence properties
private persistence: ConnectionPersistence = {
reconnectAttempts: 0,
maxReconnectAttempts: 3,
reconnectDelay: 5000 // 5 seconds
};
private reconnectTimeoutId: number | null = null;
private explicitDisconnect: boolean = false;
private readonly host: string;
private readonly port: number;
private readonly useSSL: boolean;
private webSocket: WebSocket | null = null;
private state: MudConnectionState = 'idle';
private explicitDisconnect = false;
private reconnectAttempts = 0;
private reconnectTimer: number | null = null;
private resumeToken?: string;
private gmcpEnabled = false;
private readonly gmcpHandler: GmcpHandler;
private readonly parser: TelnetParser;
constructor(options: MudConnectionOptions) {
super();
this.id = options.id;
this.host = options.host;
this.port = options.port;
this.useSSL = options.useSSL || false;
this.id = options.id;
// Create GMCP handler
this.useSSL = options.useSSL ?? false;
this.resumeToken = this.loadResumeToken();
this.gmcpHandler = new GmcpHandler();
// Set up GMCP event forwarding
this.setupGmcpEvents();
// Try to restore session from localStorage
this.loadStoredSession();
console.log(`MudConnection created for ${this.host}:${this.port} with ID ${this.id}`);
}
/**
* Set up event forwarding from GMCP handler
*/
private setupGmcpEvents(): void {
// Forward all GMCP events to listeners of this connection
this.gmcpHandler.on('gmcp', (module: string, data: any) => {
this.emit('gmcp', module, data);
this.parser = new TelnetParser({
onText: (text) => this.emit('received', text),
onNegotiation: (command, option) => this.handleNegotiation(command, option),
onSubnegotiation: (option, payload) => this.handleSubnegotiation(option, payload),
onProtocolError: (message) => this.emit('error', message)
});
// Forward specific module events (like gmcp:Core.Ping)
this.gmcpHandler.on('*', (eventName: string, ...args: any[]) => {
if (eventName.startsWith('gmcp:')) {
this.emit(eventName, ...args);
}
});
// Handle GMCP events that need special processing
this.gmcpHandler.on('playSound', (url: string, volume: number, loop: boolean) => {
console.log(`MudConnection forwarding playSound event: ${url}`);
this.emit('playSound', { url, volume, loop });
});
// Listen for sendGmcp events from the GMCP handler
this.gmcpHandler.on('sendGmcp', (module: string, data: any) => {
this.sendGmcp(module, data);
this.gmcpHandler.on('gmcp', (module: string, data: unknown) => this.emit('gmcp', module, data));
this.gmcpHandler.on('*', (eventName: string, ...args: unknown[]) => {
if (eventName.startsWith('gmcp:')) this.emit(eventName, ...args);
});
this.gmcpHandler.on('playSound', (url: string, volume: number, loop: boolean) => this.emit('playSound', { url, volume, loop }));
this.gmcpHandler.on('sendGmcp', (module: string, data: unknown) => this.sendGmcp(module, data));
}
/**
* Connect to the MUD server
*/
public connect(): void {
if (this.connected) {
console.log(`Already connected to ${this.host}:${this.port}`);
return;
}
// Reset explicit disconnect flag
if (this.webSocket && (this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING)) return;
this.explicitDisconnect = false;
// Determine the WebSocket URL based on environment
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
let wsUrl;
// In development, use port 3001
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
wsUrl = `${wsProtocol}://${window.location.hostname}:3001/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`;
} else {
// In production, use the same domain & port as the web app
wsUrl = `${wsProtocol}://${window.location.host}/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`;
}
// Include session ID in URL if we have one (for reconnection)
if (this.persistence.sessionId) {
wsUrl += `&sessionId=${encodeURIComponent(this.persistence.sessionId)}`;
console.log(`Reconnecting with session ID: ${this.persistence.sessionId}`);
}
// Include connection settings in URL
const settings = get(connectionSettings);
wsUrl += `&persistenceTimeout=${settings.persistenceTimeoutMinutes}`;
wsUrl += `&maxBufferMessages=${settings.maxBufferMessages}`;
wsUrl += `&maxBufferSizeKB=${settings.maxBufferSizeKB}`;
console.log(`Connecting to WebSocket server: ${wsUrl}`);
this.webSocket = new WebSocket(wsUrl);
this.webSocket.binaryType = 'arraybuffer';
this.webSocket.onopen = () => {
this.connected = true;
this.persistence.reconnectAttempts = 0; // Reset reconnect attempts on successful connection
console.log(`Connected to ${this.host}:${this.port}`);
this.emit('connected');
// Update stored session activity
this.updateStoredSessionActivity();
// Send GMCP negotiation upon connection
console.log('Sending GMCP negotiation');
this.sendIAC(TelnetCommand.WILL, TelnetCommand.GMCP);
this.setState(this.resumeToken ? 'resuming' : 'connecting');
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const authority = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? `${window.location.hostname}:3001`
: window.location.host;
const socket = new WebSocket(`${protocol}://${authority}/mud-ws`);
socket.binaryType = 'arraybuffer';
this.webSocket = socket;
socket.onopen = () => socket.send(JSON.stringify({
type: 'connect', host: this.host, port: this.port, tls: this.useSSL, resumeToken: this.resumeToken
}));
socket.onmessage = (event) => this.handleWebSocketMessage(event.data);
socket.onerror = () => {
this.setState('error');
this.emit('error', `WebSocket connection to the proxy failed for ${this.host}:${this.port}.`);
};
this.webSocket.onclose = () => {
this.connected = false;
console.log(`Disconnected from ${this.host}:${this.port}`);
this.emit('disconnected');
// Handle reconnection if not explicitly disconnected
if (!this.explicitDisconnect) {
this.persistence.lastDisconnectTime = Date.now();
this.handleReconnect();
}
};
this.webSocket.onerror = (error) => {
console.error('WebSocket error:', error);
this.emit('error', `WebSocket error: Connection to ${this.host}:${this.port} failed. Please check your settings and ensure the MUD server is running.`);
};
this.webSocket.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary data
this.handleIncomingData(new Uint8Array(event.data));
} else if (typeof event.data === 'string') {
// Check if this is a system message from the server
if (event.data.startsWith('[SYSTEM]')) {
this.handleSystemMessage(event.data);
} else {
// Text data - let listeners process it directly
// TriggerSystem will handle gagging and replacing in the component
this.updateStoredSessionActivity();
this.emit('received', event.data);
}
} else if (event.data instanceof Blob) {
// Blob data (sometimes WebSockets send this instead of ArrayBuffer)
const reader = new FileReader();
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
this.handleIncomingData(new Uint8Array(reader.result));
}
};
reader.readAsArrayBuffer(event.data);
socket.onclose = () => {
if (this.webSocket === socket) this.webSocket = null;
if (this.explicitDisconnect) {
this.setState('idle');
this.emit('disconnected');
} else {
this.setState('idle');
this.emit('disconnected');
this.scheduleReconnect();
}
};
}
/**
* Send text to the MUD server
*/
public send(text: string): void {
if (!this.connected) {
throw new Error('Not connected to MUD server');
}
if (!this.webSocket) {
throw new Error('WebSocket not initialized');
}
// Check if the WebSocket is in a valid state for sending
if (this.webSocket.readyState !== WebSocket.OPEN) {
this.emit('error', `Cannot send message: WebSocket is not open (state: ${this.webSocket.readyState})`);
return;
}
try {
// Append newline to the text
const data = new TextEncoder().encode(text + '\n');
this.webSocket.send(data);
// Update stored session activity on send
this.updateStoredSessionActivity();
// Emit the data for possible triggers
this.emit('sent', text);
} catch (error) {
console.error('Error sending data:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
this.emit('error', `Failed to send message: ${errorMessage}`);
}
}
/**
* Handle system messages from the server
*/
private handleSystemMessage(message: string): void {
console.log('Received system message:', message);
try {
// Remove the [SYSTEM] prefix and parse as JSON
const jsonStr = message.substring(8); // Remove "[SYSTEM]"
const systemData = JSON.parse(jsonStr);
// Handle session ID updates
if (systemData.sessionId) {
this.persistence.sessionId = systemData.sessionId;
this.saveSessionToStorage();
console.log('Updated session ID:', this.persistence.sessionId);
}
// Handle other system messages as needed
if (systemData.type === 'session_resumed') {
console.log('Session successfully resumed');
if (systemData.messagesReplayed > 0) {
console.log(`${systemData.messagesReplayed} messages were replayed`);
}
this.emit('session_resumed', systemData);
} else if (systemData.type === 'message_replay_start') {
console.log(`Starting message replay: ${systemData.messageCount} messages from ${systemData.timespan}ms ago`);
this.emit('message_replay_start', systemData);
} else if (systemData.type === 'message_replay_complete') {
console.log('Message replay complete');
this.emit('message_replay_complete');
}
} catch (error) {
console.error('Error parsing system message:', error);
}
}
/**
* Disconnect from the MUD server
*/
public disconnect(): void {
this.explicitDisconnect = true; // Set flag for explicit disconnect
// Signal to server that this is an explicit disconnect
if (this.connected && this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
try {
this.webSocket.send('[SYSTEM]{"type":"explicit_disconnect"}');
} catch (error) {
console.error('Error sending explicit disconnect signal:', error);
}
}
if (this.webSocket) {
this.webSocket.close();
this.webSocket = null;
}
// Clear session ID since we're explicitly disconnecting
this.persistence.sessionId = undefined;
this.persistence.reconnectAttempts = 0;
// Remove stored session from localStorage
this.clearStoredSession();
// Clear reconnect timeout if active
if (this.reconnectTimeoutId !== null) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = null;
this.explicitDisconnect = true;
this.clearReconnectTimer();
this.clearResumeToken();
this.setState('disconnecting');
const socket = this.webSocket;
if (!socket) {
this.setState('idle');
return;
}
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'disconnect' }));
window.setTimeout(() => { if (socket.readyState < WebSocket.CLOSING) socket.close(); }, 1_000);
} else socket.close();
}
/**
* Handle incoming data from the MUD server
*/
private handleIncomingData(data: Uint8Array): void {
// Quickly check if we need to handle telnet negotiation
let containsIAC = false;
for (let i = 0; i < data.length; i++) {
if (data[i] === TelnetCommand.IAC) {
containsIAC = true;
public send(text: string): void {
this.sendBytes(new TextEncoder().encode(`${text}\r\n`));
this.emit('sent', text);
}
public sendGmcp(module: string, data: unknown): void {
if (!this.gmcpEnabled) return;
const payload = new TextEncoder().encode(`${module} ${JSON.stringify(data)}`);
const message = new Uint8Array(payload.length + 5);
message.set([TELNET.IAC, TELNET.SB, TELNET.GMCP], 0);
message.set(payload, 3);
message.set([TELNET.IAC, TELNET.SE], payload.length + 3);
this.sendBytes(message);
}
public getGmcpHandler(): GmcpHandler { return this.gmcpHandler; }
public isConnected(): boolean { return this.state === 'connected'; }
public getState(): MudConnectionState { return this.state; }
public getSessionId(): string | undefined { return this.resumeToken; }
public matchesTarget(options: { host: string; port: number; useSSL?: boolean }): boolean {
return this.host === options.host && this.port === options.port && this.useSSL === (options.useSSL ?? false);
}
public resetReconnectionState(): void { this.reconnectAttempts = 0; this.clearReconnectTimer(); }
private handleWebSocketMessage(data: string | ArrayBuffer | Blob): void {
if (typeof data === 'string') {
this.handleControlMessage(data);
return;
}
if (data instanceof ArrayBuffer) this.parser.feed(new Uint8Array(data));
else data.arrayBuffer().then((buffer) => this.parser.feed(new Uint8Array(buffer))).catch(() => this.emit('error', 'Unable to read proxy data.'));
}
private handleControlMessage(raw: string): void {
let control: ProxyControlMessage;
try { control = JSON.parse(raw) as ProxyControlMessage; }
catch { this.emit('error', 'The proxy returned an invalid control message.'); return; }
switch (control.type) {
case 'session_started':
this.storeResumeToken(control.resumeToken);
this.reconnectAttempts = 0;
this.setState('connected');
this.emit('connected', { resumed: false });
break;
}
}
// Debug: Log raw data for debugging if it contains IAC
if (containsIAC) {
const hexData = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log(`Raw data with IAC: ${hexData}`);
}
// Fast path if no IAC codes
if (!containsIAC && !this.isInIAC) {
const text = new TextDecoder().decode(data);
this.emit('received', text);
return;
}
// Process each byte in the incoming data
for (let i = 0; i < data.length; i++) {
const byte = data[i];
if (this.isInIAC) {
// Add byte to negotiation buffer
this.negotiationBuffer.push(byte);
// Check for special sequences
if (this.inSubnegotiation) {
// Inside subnegotiation - look for IAC SE
if (byte === TelnetCommand.SE &&
this.negotiationBuffer.length > 0 &&
this.negotiationBuffer[this.negotiationBuffer.length - 2] === TelnetCommand.IAC) {
console.log('End of subnegotiation found');
// Process the complete subnegotiation
this.handleCompleteSubnegotiation();
// Reset state
this.isInIAC = false;
this.inSubnegotiation = false;
this.negotiationBuffer = [];
}
} else if (this.negotiationBuffer.length === 2) {
// After IAC, check what command it is
if (byte === TelnetCommand.SB) {
// Start of subnegotiation
this.inSubnegotiation = true;
} else if (byte === TelnetCommand.WILL || byte === TelnetCommand.DO) {
// Need one more byte for option
} else {
// Simple 3-byte command
this.processSimpleTelnetCommand();
this.isInIAC = false;
this.negotiationBuffer = [];
}
} else if (this.negotiationBuffer.length === 3 && !this.inSubnegotiation) {
// Complete 3-byte command like IAC WILL X or IAC DO X
this.processSimpleTelnetCommand();
this.isInIAC = false;
this.negotiationBuffer = [];
}
} else if (byte === TelnetCommand.IAC) {
// Start of telnet command
this.isInIAC = true;
this.negotiationBuffer = [byte];
console.log('IAC command detected');
} else {
// Normal data byte, add to buffer
this.buffer.push(byte);
}
}
// Process any complete text in the buffer
if (this.buffer.length > 0) {
const text = new TextDecoder().decode(new Uint8Array(this.buffer));
this.buffer = [];
// Emit the received text for display and trigger processing
this.emit('received', text);
case 'session_resumed':
this.storeResumeToken(control.resumeToken);
this.reconnectAttempts = 0;
this.setState('connected');
this.emit('connected', { resumed: true });
this.emit('session_resumed', { messagesReplayed: 0 });
break;
case 'replay_started': this.emit('message_replay_start', { messageCount: control.messageCount ?? 0 }); break;
case 'replay_finished': this.emit('message_replay_complete', { messagesReplayed: control.messagesReplayed ?? 0 }); break;
case 'upstream_closed':
this.clearResumeToken();
this.emit('error', `MUD connection closed: ${control.reason ?? 'upstream closed'}`);
break;
case 'error':
if (control.code === 'CONNECT_ERROR') this.clearResumeToken();
this.setState('error');
this.emit('error', control.message ?? 'Proxy error.');
break;
default: this.emit('error', `Unknown proxy message type: ${control.type}`);
}
}
/**
* Process a simple telnet command (3 bytes: IAC CMD OPTION)
*/
private processSimpleTelnetCommand(): void {
try {
const [iac, command, option] = this.negotiationBuffer;
// Handle specific commands
if ((command === TelnetCommand.WILL || command === TelnetCommand.DO) && option === TelnetCommand.GMCP) {
console.log('Server supports GMCP, responding with DO GMCP');
// Server wants to use GMCP, we'll respond with IAC DO GMCP
this.sendIAC(TelnetCommand.DO, TelnetCommand.GMCP);
// Request GMCP capabilities
console.log('Requesting GMCP capabilities');
this.gmcpHandler.requestCapabilities();
}
} catch (error) {
console.error('Error processing telnet command:', error);
}
}
/**
* Handle a complete telnet subnegotiation sequence
*/
private handleCompleteSubnegotiation(): void {
try {
// Debug buffer contents
const bufferHex = this.negotiationBuffer.map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log(`Processing subnegotiation, buffer: ${bufferHex}`);
// Check if this is a GMCP subnegotiation
// IAC SB GMCP ... IAC SE
// Indexes: 0 1 2 ... -2 -1
if (this.negotiationBuffer.length >= 5 && this.negotiationBuffer[2] === TelnetCommand.GMCP) {
console.log('Processing GMCP subnegotiation');
try {
// Extract the GMCP data (skip IAC SB GMCP, and the final IAC SE)
const gmcpData = this.negotiationBuffer.slice(3, -2);
const gmcpText = new TextDecoder().decode(new Uint8Array(gmcpData));
console.log(`GMCP message: ${gmcpText}`);
// Process the GMCP message immediately
console.log('Passing GMCP to handler:', gmcpText);
this.gmcpHandler.handleGmcpMessage(gmcpText);
} catch (error) {
console.error('Error processing GMCP data:', error);
private handleNegotiation(command: number, option: number): void {
if (command === TELNET.WILL) {
if (option === TELNET.GMCP) {
this.sendIac(TELNET.DO, option);
if (!this.gmcpEnabled) {
this.gmcpEnabled = true;
this.gmcpHandler.requestCapabilities();
}
} else {
console.log(`Non-GMCP subnegotiation received: ${this.negotiationBuffer[2]}`);
this.sendIac(option === TELNET.ECHO ? TELNET.DO : TELNET.DONT, option);
if (option === TELNET.ECHO) this.emit('sensitiveInput', true);
}
} catch (error) {
console.error('Error handling subnegotiation:', error);
}
} else if (command === TELNET.WONT) {
if (option === TELNET.GMCP) this.gmcpEnabled = false;
if (option === TELNET.ECHO) this.emit('sensitiveInput', false);
} else if (command === TELNET.DO) this.sendIac(option === TELNET.GMCP ? TELNET.WILL : TELNET.WONT, option);
}
/**
* Send a telnet IAC sequence
*/
private sendIAC(command: TelnetCommand, option: TelnetCommand): void {
if (!this.connected || !this.webSocket) {
return;
}
const data = new Uint8Array([TelnetCommand.IAC, command, option]);
private handleSubnegotiation(option: number, payload: Uint8Array): void {
if (option === TELNET.GMCP && payload.length <= 64 * 1024) this.gmcpHandler.handleGmcpMessage(new TextDecoder().decode(payload));
}
private sendIac(command: number, option: number): void { this.sendBytes(new Uint8Array([TELNET.IAC, command, option])); }
private sendBytes(data: Uint8Array): void {
if (this.state !== 'connected' || !this.webSocket || this.webSocket.readyState !== WebSocket.OPEN) throw new Error('Not connected to MUD server.');
this.webSocket.send(data);
}
/**
* Send a GMCP message
*/
public sendGmcp(module: string, data: any): void {
if (!this.connected || !this.webSocket) {
console.log('Cannot send GMCP - not connected');
return;
}
console.log(`Sending GMCP: ${module}`, data);
const gmcpString = `${module} ${JSON.stringify(data)}`;
const gmcpData = new TextEncoder().encode(gmcpString);
// Create the IAC SB GMCP <data> IAC SE sequence
const telnetSequence = new Uint8Array([
TelnetCommand.IAC,
TelnetCommand.SB,
TelnetCommand.GMCP,
...gmcpData,
TelnetCommand.IAC,
TelnetCommand.SE
]);
this.webSocket.send(telnetSequence);
private setState(state: MudConnectionState): void { this.state = state; this.emit('stateChanged', state); }
private scheduleReconnect(): void {
if (this.explicitDisconnect || this.reconnectAttempts >= 3) return;
const delay = 5_000 * Math.pow(1.5, this.reconnectAttempts++);
this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = null; this.connect(); }, delay);
}
/**
* Get the GMCP handler associated with this connection
*/
public getGmcpHandler(): GmcpHandler {
return this.gmcpHandler;
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
/**
* Check if the connection is active
*/
public isConnected(): boolean {
return this.connected;
private sessionKey(): string { return `mudResume:${this.id}`; }
private loadResumeToken(): string | undefined {
if (typeof sessionStorage === 'undefined') return undefined;
const legacyKey = `mudSession_${this.id}`;
localStorage.removeItem(legacyKey);
return sessionStorage.getItem(this.sessionKey()) ?? undefined;
}
/**
* Handle reconnection logic
*/
private handleReconnect(): void {
// If too much time has passed since disconnect, don't attempt to reconnect with session
if (this.persistence.lastDisconnectTime &&
Date.now() - this.persistence.lastDisconnectTime > 5 * 60 * 1000) { // 5 minutes
console.log('Too much time has passed, clearing session for fresh connection');
this.persistence.sessionId = undefined;
this.persistence.reconnectAttempts = 0;
}
if (this.persistence.reconnectAttempts >= this.persistence.maxReconnectAttempts) {
console.log('Max reconnect attempts reached, giving up');
this.persistence.sessionId = undefined; // Clear session since we're giving up
return;
}
this.persistence.reconnectAttempts++;
const delay = this.persistence.reconnectDelay * Math.pow(1.5, this.persistence.reconnectAttempts - 1); // Exponential backoff
console.log(`Reconnecting in ${delay / 1000} seconds... (Attempt ${this.persistence.reconnectAttempts}/${this.persistence.maxReconnectAttempts})`);
this.reconnectTimeoutId = window.setTimeout(() => {
console.log('Reconnecting...');
this.connect();
}, delay);
private storeResumeToken(token?: string): void {
if (!token) return;
this.resumeToken = token;
sessionStorage.setItem(this.sessionKey(), token);
}
/**
* Get the current session ID
*/
public getSessionId(): string | undefined {
return this.persistence.sessionId;
private clearResumeToken(): void {
this.resumeToken = undefined;
if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(this.sessionKey());
}
/**
* Reset reconnection state
*/
public resetReconnectionState(): void {
this.persistence.reconnectAttempts = 0;
this.persistence.lastDisconnectTime = undefined;
if (this.reconnectTimeoutId !== null) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = null;
}
}
/**
* Save current session to localStorage
*/
private saveSessionToStorage(): void {
if (!this.persistence.sessionId) {
return;
}
const sessionData: StoredSessionData = {
sessionId: this.persistence.sessionId,
profileId: this.id,
host: this.host,
port: this.port,
useSSL: this.useSSL,
lastActivity: Date.now(),
createdAt: Date.now()
};
try {
const storageKey = `mudSession_${this.id}`;
localStorage.setItem(storageKey, JSON.stringify(sessionData));
console.log(`Saved session ${this.persistence.sessionId} to localStorage for profile ${this.id}`);
} catch (error) {
console.error('Failed to save session to localStorage:', error);
}
}
/**
* Load stored session from localStorage
*/
private loadStoredSession(): void {
try {
const storageKey = `mudSession_${this.id}`;
const storedData = localStorage.getItem(storageKey);
if (!storedData) {
return;
}
const sessionData: StoredSessionData = JSON.parse(storedData);
// Validate that the stored session matches this connection
if (sessionData.profileId === this.id &&
sessionData.host === this.host &&
sessionData.port === this.port &&
sessionData.useSSL === this.useSSL) {
// Check if the session is still within a reasonable timeframe
const maxAge = 60 * 60 * 1000; // 1 hour max age
const age = Date.now() - sessionData.lastActivity;
if (age <= maxAge) {
this.persistence.sessionId = sessionData.sessionId;
console.log(`Restored session ${sessionData.sessionId} from localStorage for profile ${this.id} (age: ${Math.round(age/1000)}s)`);
} else {
console.log(`Stored session for profile ${this.id} is too old (${Math.round(age/1000)}s), discarding`);
this.clearStoredSession();
}
} else {
console.log(`Stored session for profile ${this.id} doesn't match current connection parameters, discarding`);
this.clearStoredSession();
}
} catch (error) {
console.error('Failed to load session from localStorage:', error);
this.clearStoredSession();
}
}
/**
* Clear stored session from localStorage
*/
private clearStoredSession(): void {
try {
const storageKey = `mudSession_${this.id}`;
localStorage.removeItem(storageKey);
console.log(`Cleared stored session for profile ${this.id}`);
} catch (error) {
console.error('Failed to clear stored session:', error);
}
}
/**
* Update last activity timestamp in stored session
*/
private updateStoredSessionActivity(): void {
if (!this.persistence.sessionId) {
return;
}
try {
const storageKey = `mudSession_${this.id}`;
const storedData = localStorage.getItem(storageKey);
if (storedData) {
const sessionData: StoredSessionData = JSON.parse(storedData);
sessionData.lastActivity = Date.now();
localStorage.setItem(storageKey, JSON.stringify(sessionData));
}
} catch (error) {
console.error('Failed to update stored session activity:', error);
}
}
/**
* Clean up old stored sessions from localStorage (static method)
*/
public static cleanupOldStoredSessions(): void {
try {
const maxAge = 60 * 60 * 1000; // 1 hour
const now = Date.now();
const keysToRemove: string[] = [];
// Iterate through all localStorage keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('mudSession_')) {
try {
const storedData = localStorage.getItem(key);
if (storedData) {
const sessionData: StoredSessionData = JSON.parse(storedData);
const age = now - sessionData.lastActivity;
if (age > maxAge) {
keysToRemove.push(key);
console.log(`Marking old session for cleanup: ${key} (age: ${Math.round(age/1000)}s)`);
}
}
} catch (error) {
// If we can't parse the session data, remove it
keysToRemove.push(key);
console.log(`Marking corrupted session for cleanup: ${key}`);
}
}
}
// Remove old sessions
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
if (keysToRemove.length > 0) {
console.log(`Cleaned up ${keysToRemove.length} old stored sessions`);
}
} catch (error) {
console.error('Failed to cleanup old stored sessions:', error);
}
if (typeof localStorage === 'undefined') return;
for (const key of Object.keys(localStorage)) if (key.startsWith('mudSession_')) localStorage.removeItem(key);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { TELNET, TelnetParser } from './TelnetParser';
function harness() {
const text: string[] = [];
const negotiations: Array<[number, number]> = [];
const subnegotiations: Array<[number, number[]]> = [];
const errors: string[] = [];
const parser = new TelnetParser({
onText: (value) => text.push(value),
onNegotiation: (command, option) => negotiations.push([command, option]),
onSubnegotiation: (option, payload) => subnegotiations.push([option, [...payload]]),
onProtocolError: (message) => errors.push(message)
});
return { parser, text, negotiations, subnegotiations, errors };
}
describe('TelnetParser', () => {
it('preserves UTF-8 split across network frames', () => {
const test = harness();
const bytes = new TextEncoder().encode('A€B');
test.parser.feed(bytes.slice(0, 2));
test.parser.feed(bytes.slice(2, 3));
test.parser.feed(bytes.slice(3));
expect(test.text.join('')).toBe('A€B');
});
it('parses fragmented negotiation and GMCP', () => {
const test = harness();
const sequence = new Uint8Array([
TELNET.IAC, TELNET.WILL, TELNET.GMCP,
TELNET.IAC, TELNET.SB, TELNET.GMCP, ...new TextEncoder().encode('Core.Ping {}'), TELNET.IAC, TELNET.SE
]);
for (const byte of sequence) test.parser.feed(new Uint8Array([byte]));
expect(test.negotiations).toEqual([[TELNET.WILL, TELNET.GMCP]]);
expect(new TextDecoder().decode(new Uint8Array(test.subnegotiations[0][1]))).toBe('Core.Ping {}');
expect(test.errors).toEqual([]);
});
it('treats IAC IAC as literal data', () => {
const test = harness();
test.parser.feed(new Uint8Array([65, TELNET.IAC, TELNET.IAC, 66]));
expect(test.text.join('')).toContain('A');
expect(test.text.join('')).toContain('B');
});
});
+103
View File
@@ -0,0 +1,103 @@
export const TELNET = {
SE: 240, SB: 250, WILL: 251, WONT: 252, DO: 253, DONT: 254, IAC: 255,
ECHO: 1, GMCP: 201
} as const;
type NegotiationCommand = typeof TELNET.WILL | typeof TELNET.WONT | typeof TELNET.DO | typeof TELNET.DONT;
type ParserState = 'data' | 'iac' | 'option' | 'sb-option' | 'sb-data' | 'sb-iac';
export interface TelnetParserCallbacks {
onText(text: string): void;
onNegotiation(command: NegotiationCommand, option: number): void;
onSubnegotiation(option: number, payload: Uint8Array): void;
onProtocolError(message: string): void;
}
export class TelnetParser {
private state: ParserState = 'data';
private pendingCommand: NegotiationCommand | null = null;
private subnegotiationOption = 0;
private textBytes: number[] = [];
private subnegotiationBytes: number[] = [];
private readonly decoder = new TextDecoder('utf-8', { fatal: false });
constructor(
private readonly callbacks: TelnetParserCallbacks,
private readonly maxSubnegotiationBytes = 64 * 1024
) {}
feed(input: Uint8Array): void {
for (const byte of input) {
switch (this.state) {
case 'data':
if (byte === TELNET.IAC) {
this.flushText();
this.state = 'iac';
} else this.textBytes.push(byte);
break;
case 'iac':
if (byte === TELNET.IAC) {
this.textBytes.push(byte);
this.state = 'data';
} else if (byte === TELNET.SB) this.state = 'sb-option';
else if (byte === TELNET.WILL || byte === TELNET.WONT || byte === TELNET.DO || byte === TELNET.DONT) {
this.pendingCommand = byte;
this.state = 'option';
} else this.state = 'data';
break;
case 'option':
if (this.pendingCommand !== null) this.callbacks.onNegotiation(this.pendingCommand, byte);
this.pendingCommand = null;
this.state = 'data';
break;
case 'sb-option':
this.subnegotiationOption = byte;
this.subnegotiationBytes = [];
this.state = 'sb-data';
break;
case 'sb-data':
if (byte === TELNET.IAC) this.state = 'sb-iac';
else this.pushSubnegotiationByte(byte);
break;
case 'sb-iac':
if (byte === TELNET.SE) {
this.callbacks.onSubnegotiation(this.subnegotiationOption, new Uint8Array(this.subnegotiationBytes));
this.subnegotiationBytes = [];
this.state = 'data';
} else if (byte === TELNET.IAC) {
this.pushSubnegotiationByte(byte);
this.state = 'sb-data';
} else {
this.callbacks.onProtocolError('Malformed Telnet subnegotiation.');
this.subnegotiationBytes = [];
this.state = 'data';
}
break;
}
}
this.flushText();
}
finish(): void {
this.flushText();
const remaining = this.decoder.decode();
if (remaining) this.callbacks.onText(remaining);
}
private pushSubnegotiationByte(byte: number): void {
if (this.subnegotiationBytes.length >= this.maxSubnegotiationBytes) {
this.callbacks.onProtocolError('Telnet subnegotiation exceeded the size limit.');
this.subnegotiationBytes = [];
this.state = 'data';
return;
}
this.subnegotiationBytes.push(byte);
}
private flushText(): void {
if (this.textBytes.length === 0) return;
const text = this.decoder.decode(new Uint8Array(this.textBytes), { stream: true });
this.textBytes = [];
if (text) this.callbacks.onText(text);
}
}
+2 -10
View File
@@ -1,5 +1,4 @@
import { EventEmitter } from '$lib/utils/EventEmitter';
import { logGmcpMessage } from '$lib/stores/mudStore';
import type { GmcpPackageHandler } from './packages/GmcpPackageHandler';
import { ClientMediaPackage } from './packages/ClientMediaPackage';
import { ClientKeystrokePackage } from './packages/ClientKeystrokePackage';
@@ -67,13 +66,11 @@ export class GmcpHandler extends EventEmitter {
*/
public handleGmcpMessage(message: string): void {
try {
console.log('GmcpHandler received message:', message);
// Extract module and data from the message
const spaceIndex = message.indexOf(' ');
if (spaceIndex === -1) {
// No space found, might be a module without data
console.log('GMCP message has no data, using module name only:', message);
this.emit('gmcp', message, {});
return;
}
@@ -84,9 +81,8 @@ export class GmcpHandler extends EventEmitter {
try {
data = JSON.parse(jsonData);
console.log('GMCP data successfully parsed for module:', module, data);
} catch (e) {
console.error('Failed to parse GMCP data:', jsonData);
console.error('Failed to parse GMCP data.');
data = {};
}
@@ -97,7 +93,7 @@ export class GmcpHandler extends EventEmitter {
for (const [packagePrefix, handler] of this.packageHandlers.entries()) {
console.log(`Checking if ${module} starts with ${packagePrefix}`);
if (module.startsWith(packagePrefix)) {
if (module === packagePrefix || module.startsWith(`${packagePrefix}.`)) {
console.log(`Found handler for ${module}: ${packagePrefix}`);
try {
handler.handleMessage(module, data);
@@ -115,10 +111,6 @@ export class GmcpHandler extends EventEmitter {
console.log(`No specific handler for GMCP module: ${module}`);
}
// Log the GMCP message for debugging
console.log('Calling logGmcpMessage for module:', module);
logGmcpMessage(module, data);
// Emit the general GMCP event for custom handling
this.emit('gmcp', module, data);
+11 -4
View File
@@ -22,6 +22,7 @@ export class ClientMediaPackage implements GmcpPackageHandler {
private activeSounds: Map<string, SoundInfo> = new Map(); // id -> SoundInfo
private keyToIdMap: Map<number, string> = new Map(); // key -> id
private tagToIdsMap: Map<string, Set<string>> = new Map(); // tag -> Set of ids
private recentStarts: number[] = [];
initialize(emitter: EventEmitter): void {
this.emitter = emitter;
@@ -90,7 +91,6 @@ export class ClientMediaPackage implements GmcpPackageHandler {
handleMessage(module: string, data: any): void {
try {
console.log(`ClientMediaPackage handling message: ${module}`, data);
if (module === 'Client.Media.Play') {
console.log('Processing Client.Media.Play message');
@@ -111,7 +111,11 @@ export class ClientMediaPackage implements GmcpPackageHandler {
*/
private handlePlay(data: any): void {
try {
console.log('GMCP Media.Play received:', data);
const settings = get(uiSettings);
if (!settings.allowServerMedia) return;
const now = Date.now();
this.recentStarts = this.recentStarts.filter(timestamp => timestamp > now - 60_000);
if (this.recentStarts.length >= 10 || this.activeSounds.size >= 4) return;
// Extract key and tag if present
const key = data.key;
@@ -134,11 +138,15 @@ export class ClientMediaPackage implements GmcpPackageHandler {
console.error('No URL provided for media playback');
return;
}
let parsedUrl: URL;
try { parsedUrl = new URL(fullUrl); } catch { return; }
if (parsedUrl.protocol !== 'https:' || /^(localhost|127\.|0\.|\[?::1\]?$)/i.test(parsedUrl.hostname)) return;
this.recentStarts.push(now);
console.log(`Playing sound from: ${fullUrl}`);
// Get global volume setting
const globalVolume = get(uiSettings).globalVolume || 0.7;
const globalVolume = settings.globalVolume ?? 0.7;
// Calculate volume (normalize from 0-100 to 0-1 if needed)
let soundVolume = globalVolume;
@@ -241,7 +249,6 @@ export class ClientMediaPackage implements GmcpPackageHandler {
*/
private handleStop(data: any): void {
try {
console.log('GMCP Media.Stop received:', data);
// Stop by key if provided
if (data.key !== undefined) {
+10
View File
@@ -0,0 +1,10 @@
class CredentialVault {
private readonly passwords = new Map<string, string>();
getPassword(profileId: string): string | undefined { return this.passwords.get(profileId); }
setPassword(profileId: string, password: string): void { this.passwords.set(profileId, password); }
clear(profileId: string): void { this.passwords.delete(profileId); }
clearAll(): void { this.passwords.clear(); }
}
export const credentialVault = new CredentialVault();
+26 -27
View File
@@ -9,7 +9,6 @@ export interface MudProfile {
autoLogin?: {
enabled: boolean;
username: string;
password: string;
commands: string[];
};
triggers?: string; // JSON string of triggers
@@ -53,17 +52,21 @@ export class ProfileManager extends EventEmitter {
try {
const storedProfiles = localStorage.getItem(this.storageKey);
console.log('Retrieved from localStorage:', storedProfiles);
if (storedProfiles) {
const parsed = JSON.parse(storedProfiles);
console.log('Parsed profiles:', parsed);
// Validate profiles before assigning
if (Array.isArray(parsed)) {
// Filter out invalid profiles
this.profiles = parsed.filter(profile => this.isValidProfile(profile));
console.log('Loaded profiles from localStorage:', this.profiles);
let migrated = false;
this.profiles = parsed.filter(profile => this.isValidProfile(profile)).map(profile => {
if (profile.autoLogin && 'password' in profile.autoLogin) {
delete profile.autoLogin.password;
migrated = true;
}
return profile;
});
if (migrated) this.saveProfiles();
if (this.profiles.length > 0) {
this.emit('profilesLoaded', this.profiles);
@@ -89,7 +92,6 @@ export class ProfileManager extends EventEmitter {
this.addProfile(defaultProfile);
} catch (error) {
console.error('Failed to load profiles from local storage:', error);
console.error('Error details:', error.message, error.stack);
// Add a default profile if there was an error
const defaultProfile = this.createDefaultProfile();
@@ -112,21 +114,10 @@ export class ProfileManager extends EventEmitter {
}
try {
// Add logging to help debug
console.log('Saving profiles to localStorage:', this.profiles);
const profilesJson = JSON.stringify(this.profiles);
console.log('Profiles JSON:', profilesJson);
localStorage.setItem(this.storageKey, profilesJson);
console.log('Profiles saved successfully');
// Validate by reading back
const readBack = localStorage.getItem(this.storageKey);
console.log('Read back from localStorage:', readBack);
} catch (error) {
console.error('Failed to save profiles to local storage:', error);
console.error('Error details:', error.message, error.stack);
}
}
@@ -134,6 +125,10 @@ export class ProfileManager extends EventEmitter {
* Add a new profile
*/
public addProfile(profile: MudProfile): void {
if (profile.autoLogin && 'password' in profile.autoLogin) {
delete (profile.autoLogin as MudProfile['autoLogin'] & { password?: string }).password;
}
if (!this.isValidProfile(profile)) throw new Error('Invalid profile.');
// Ensure all required fields are present
if (!profile.id) {
profile.id = `profile-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
@@ -153,12 +148,10 @@ export class ProfileManager extends EventEmitter {
if (existingIndex !== -1) {
// Update existing profile
this.profiles[existingIndex] = profile;
console.log(`Updated profile ${profile.id} (${profile.name})`, profile);
this.emit('profileUpdated', profile);
} else {
// Add new profile
this.profiles.push(profile);
console.log(`Added new profile ${profile.id} (${profile.name})`, profile);
this.emit('profileAdded', profile);
}
@@ -259,13 +252,20 @@ export class ProfileManager extends EventEmitter {
*/
private isValidProfile(obj: any): boolean {
return (
typeof obj === 'object' &&
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
typeof obj.host === 'string' &&
typeof obj.port === 'number' &&
obj !== null && typeof obj === 'object' &&
typeof obj.id === 'string' && obj.id.length > 0 && obj.id.length <= 128 &&
typeof obj.name === 'string' && obj.name.length > 0 && obj.name.length <= 128 &&
typeof obj.host === 'string' && obj.host.length > 0 && obj.host.length <= 253 && /^[a-zA-Z0-9._:-]+$/.test(obj.host) &&
Number.isInteger(obj.port) && obj.port >= 1 && obj.port <= 65535 &&
typeof obj.useSSL === 'boolean' &&
typeof obj.ansiColor === 'boolean'
typeof obj.ansiColor === 'boolean' &&
(obj.autoLogin === undefined || (
obj.autoLogin !== null && typeof obj.autoLogin === 'object' &&
typeof obj.autoLogin.enabled === 'boolean' &&
typeof obj.autoLogin.username === 'string' && obj.autoLogin.username.length <= 512 &&
Array.isArray(obj.autoLogin.commands) && obj.autoLogin.commands.length <= 100 &&
obj.autoLogin.commands.every((command: unknown) => typeof command === 'string' && command.length <= 4096)
))
);
}
@@ -285,7 +285,6 @@ export class ProfileManager extends EventEmitter {
autoLogin: {
enabled: false,
username: '',
password: '',
commands: []
},
aliases: {},
+54 -31
View File
@@ -24,6 +24,7 @@ export interface Settings {
font: string;
debugGmcp: boolean;
globalVolume: number;
allowServerMedia: boolean;
};
connection: {
persistenceTimeoutMinutes: number;
@@ -68,12 +69,13 @@ export class SettingsManager extends EventEmitter {
ansiColor: true,
font: 'monospace',
debugGmcp: false,
globalVolume: 0.7
globalVolume: 0.7,
allowServerMedia: false
},
connection: {
persistenceTimeoutMinutes: 5,
maxBufferMessages: 100,
maxBufferSizeKB: 10
maxBufferMessages: 250,
maxBufferSizeKB: 256
}
};
@@ -137,20 +139,7 @@ export class SettingsManager extends EventEmitter {
// Merge with defaults to ensure all properties exist
if (parsedSettings && typeof parsedSettings === 'object') {
// Update internal settings
this.settings = {
accessibility: {
...this.settings.accessibility,
...(parsedSettings.accessibility || {})
},
ui: {
...this.settings.ui,
...(parsedSettings.ui || {})
},
connection: {
...this.settings.connection,
...(parsedSettings.connection || {})
}
};
this.settings = this.normalizeSettings(parsedSettings);
console.log('Loaded settings from localStorage:', this.settings);
@@ -211,7 +200,7 @@ export class SettingsManager extends EventEmitter {
// Reset settings to defaults
public resetSettings(): void {
const defaults = {
const defaults: Settings = {
accessibility: {
textToSpeech: false,
highContrast: false,
@@ -233,12 +222,13 @@ export class SettingsManager extends EventEmitter {
ansiColor: true,
font: 'monospace',
debugGmcp: false,
globalVolume: 0.7
globalVolume: 0.7,
allowServerMedia: false
},
connection: {
persistenceTimeoutMinutes: 5,
maxBufferMessages: 100,
maxBufferSizeKB: 10
maxBufferMessages: 250,
maxBufferSizeKB: 256
}
};
@@ -255,20 +245,12 @@ export class SettingsManager extends EventEmitter {
if (typeof imported === 'object' && imported !== null) {
// Create a valid settings object with defaults for missing properties
const newSettings = {
accessibility: {
...this.settings.accessibility,
...(imported.accessibility || {})
},
ui: {
...this.settings.ui,
...(imported.ui || {})
}
};
const newSettings = this.normalizeSettings(imported);
// Update stores
this.accessibilitySettings.set(newSettings.accessibility);
this.uiSettings.set(newSettings.ui);
this.connectionSettings.set(newSettings.connection);
// Update internal settings
this.settings = newSettings;
@@ -288,6 +270,47 @@ export class SettingsManager extends EventEmitter {
return JSON.stringify(this.settings, null, 2);
}
private normalizeSettings(value: any): Settings {
const accessibility = value?.accessibility || {};
const ui = value?.ui || {};
const connection = value?.connection || {};
const number = (candidate: unknown, fallback: number, min: number, max: number) =>
typeof candidate === 'number' && Number.isFinite(candidate) ? Math.min(max, Math.max(min, candidate)) : fallback;
const bool = (candidate: unknown, fallback: boolean) => typeof candidate === 'boolean' ? candidate : fallback;
const fonts = new Set(['monospace', "'Courier New', monospace", "'Roboto Mono', monospace", "'Source Code Pro', monospace"]);
return {
accessibility: {
textToSpeech: bool(accessibility.textToSpeech, this.settings.accessibility.textToSpeech),
highContrast: bool(accessibility.highContrast, this.settings.accessibility.highContrast),
fontSize: number(accessibility.fontSize, this.settings.accessibility.fontSize, 8, 32),
lineSpacing: number(accessibility.lineSpacing, this.settings.accessibility.lineSpacing, 1, 3),
speechRate: number(accessibility.speechRate, this.settings.accessibility.speechRate, 0.5, 2),
speechPitch: number(accessibility.speechPitch, this.settings.accessibility.speechPitch, 0.5, 2),
speechVolume: number(accessibility.speechVolume, this.settings.accessibility.speechVolume, 0, 1),
interruptSpeechOnEnter: bool(accessibility.interruptSpeechOnEnter, this.settings.accessibility.interruptSpeechOnEnter),
speakAllProfiles: bool(accessibility.speakAllProfiles, this.settings.accessibility.speakAllProfiles)
},
ui: {
isDarkMode: bool(ui.isDarkMode, this.settings.ui.isDarkMode),
showTimestamps: bool(ui.showTimestamps, this.settings.ui.showTimestamps),
showSidebar: bool(ui.showSidebar, this.settings.ui.showSidebar),
splitViewDirection: ui.splitViewDirection === 'vertical' ? 'vertical' : 'horizontal',
inputHistorySize: number(ui.inputHistorySize, this.settings.ui.inputHistorySize, 10, 1_000),
outputBufferSize: number(ui.outputBufferSize, this.settings.ui.outputBufferSize, 100, 10_000),
ansiColor: bool(ui.ansiColor, this.settings.ui.ansiColor),
font: fonts.has(ui.font) ? ui.font : this.settings.ui.font,
debugGmcp: bool(ui.debugGmcp, this.settings.ui.debugGmcp),
globalVolume: number(ui.globalVolume, this.settings.ui.globalVolume, 0, 1),
allowServerMedia: bool(ui.allowServerMedia, false)
},
connection: {
persistenceTimeoutMinutes: 5,
maxBufferMessages: 250,
maxBufferSizeKB: 256
}
};
}
}
// Create a singleton instance
-47
View File
@@ -1,47 +0,0 @@
// This is a simple browser console test that you can run to verify settings localStorage functionality
// Copy and paste this into the browser console after loading the application
function testSettingsStorage() {
console.log("=== Settings Storage Test ===");
// Get current settings from localStorage
const currentSettings = localStorage.getItem('svelte-mud-settings');
console.log("Current settings in localStorage:", currentSettings ? JSON.parse(currentSettings) : "None");
// Get settings from the stores
const mudStore = (window.mudStore || {});
if (!mudStore.uiSettings || !mudStore.accessibilitySettings) {
console.error("Could not access mudStore. Make sure it's exposed to window in development.");
return;
}
// Toggle dark mode
const isDarkMode = mudStore.uiSettings.isDarkMode;
console.log(`Current dark mode: ${isDarkMode}, toggling to ${!isDarkMode}`);
mudStore.uiSettings.isDarkMode = !isDarkMode;
// Check localStorage again after changes
setTimeout(() => {
const updatedSettings = localStorage.getItem('svelte-mud-settings');
console.log("Updated settings in localStorage:", updatedSettings ? JSON.parse(updatedSettings) : "None");
if (!updatedSettings) {
console.error("Settings were not saved to localStorage!");
return;
}
const parsedSettings = JSON.parse(updatedSettings);
if (parsedSettings.ui.isDarkMode !== !isDarkMode) {
console.error("Dark mode setting was not updated correctly in localStorage!");
} else {
console.log("✅ Settings were properly saved to localStorage");
}
// Revert the change
console.log("Reverting dark mode setting...");
mudStore.uiSettings.isDarkMode = isDarkMode;
}, 200); // Wait for the debounce timeout
}
// Run the test
testSettingsStorage();
+25 -9
View File
@@ -3,7 +3,7 @@ import { settingsManager } from '$lib/settings/SettingsManager';
import type { MudProfile } from '$lib/profiles/ProfileManager';
import type { MudConnection } from '$lib/connection/MudConnection';
import type { Trigger } from '$lib/triggers/TriggerSystem';
import { processMessage, createCacheKey, type ProcessedMessage } from '$lib/utils/textProcessing';
import { processMessage, createCacheKey, type ProcessedMessage, type RenderedSegment } from '$lib/utils/textProcessing';
// Store for active connections
export const connections = writable<{ [key: string]: MudConnection }>({});
@@ -35,6 +35,7 @@ export const processedOutputHistory = writable<{
// Store for connection status
export const connectionStatus = writable<{ [key: string]: 'connected' | 'disconnected' | 'connecting' | 'error' }>({});
export const sensitiveInput = writable<Record<string, boolean>>({});
// Use the stores from SettingsManager
export const accessibilitySettings = settingsManager.accessibilitySettings;
@@ -146,6 +147,7 @@ export const activeRenderableLines = derived(
id: string;
messageId: string;
content: string;
segments: RenderedSegment[];
timestamp: number;
isInput: boolean;
isSubline: boolean;
@@ -159,6 +161,7 @@ export const activeRenderableLines = derived(
id: message.id,
messageId: message.id,
content: message.processedContent,
segments: message.lines[0]?.segments ?? [{ text: message.processedContent }],
timestamp: message.timestamp,
isInput: true,
isSubline: false,
@@ -171,6 +174,7 @@ export const activeRenderableLines = derived(
id: line.id,
messageId: message.id,
content: line.content,
segments: line.segments,
timestamp: message.timestamp,
isInput: false,
isSubline: line.isSubline,
@@ -208,15 +212,19 @@ export const activeInputHistoryIndex = derived(
}
);
export const activeSensitiveInput = derived(
[sensitiveInput, activeProfileId],
([$sensitiveInput, $activeProfileId]) => $activeProfileId ? ($sensitiveInput[$activeProfileId] ?? false) : false
);
// Store for GMCP data
export const gmcpData = writable<{ [module: string]: any }>({});
export const gmcpData = writable<{ [profileId: string]: { [module: string]: unknown } }>({});
// Store for GMCP debug messages
export const gmcpDebugLog = writable<{ id: string; module: string; data: any; timestamp: number }[]>([]);
export const gmcpDebugLog = writable<{ id: string; profileId: string; module: string; data: unknown; timestamp: number }[]>([]);
// Helper functions
export function addToOutputHistory(text: string, isInput = false, highlights: { pattern: string; color: string; isRegex: boolean }[] = []) {
const profileId = get(activeProfileId);
export function appendOutput(profileId: string | null, text: string, isInput = false, highlights: { pattern: string; color: string; isRegex: boolean }[] = []) {
const targetProfileId = profileId || 'default';
const maxSize = get(uiSettings).outputBufferSize;
const currentAnsiSetting = get(uiSettings).ansiColor;
@@ -269,10 +277,14 @@ export function addToOutputHistory(text: string, isInput = false, highlights: {
});
}
export function addToOutputHistory(text: string, isInput = false, highlights: { pattern: string; color: string; isRegex: boolean }[] = []) {
appendOutput(get(activeProfileId), text, isInput, highlights);
}
/**
* Add GMCP message to debug log and possibly to output history if enabled
*/
export function logGmcpMessage(module: string, data: any) {
export function logGmcpMessage(profileId: string, module: string, data: unknown) {
console.log('logGmcpMessage called for module:', module);
// Always add to debug log
@@ -280,6 +292,7 @@ export function logGmcpMessage(module: string, data: any) {
const maxSize = 100; // Keep last 100 GMCP messages
const newItem = {
id: `gmcp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
profileId,
module,
data,
timestamp: Date.now()
@@ -302,7 +315,7 @@ export function logGmcpMessage(module: string, data: any) {
const dataString = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data);
const gmcpText = `[GMCP] ${module}: ${dataString}`;
addToOutputHistory(gmcpText, false, [
appendOutput(profileId, gmcpText, false, [
{ pattern: '\\[GMCP\\]', color: '#8be9fd', isRegex: true },
{ pattern: module, color: '#ff79c6', isRegex: false }
]);
@@ -416,11 +429,14 @@ export function clearOutputHistory() {
});
}
export function updateGmcpData(module: string, data: any) {
export function updateGmcpData(profileId: string, module: string, data: unknown) {
gmcpData.update(currentData => {
return {
...currentData,
[module]: data
[profileId]: {
...(currentData[profileId] || {}),
[module]: data
}
};
});
}
+40 -23
View File
@@ -11,7 +11,6 @@ export interface Trigger {
isEnabled: boolean;
soundFile?: string;
soundVolume?: number; // Sound volume (0-1)
action?: string;
sendText?: string;
highlightColor?: string;
priority: number;
@@ -19,6 +18,13 @@ export interface Trigger {
gag?: boolean; // If true, don't display the matched text at all
}
export interface TriggerResult {
processed: string;
gagged: boolean;
matched: boolean;
highlights: { pattern: string; color: string; isRegex: boolean }[];
}
export class TriggerSystem extends EventEmitter {
private triggers: Trigger[] = [];
private sounds: Map<string, Howl> = new Map();
@@ -47,6 +53,7 @@ export class TriggerSystem extends EventEmitter {
const loadedTriggers = JSON.parse(triggersJson);
if (Array.isArray(loadedTriggers)) {
loadedTriggers.forEach(trigger => {
delete trigger.action;
if (this.isValidTrigger(trigger)) {
this.triggers.push(trigger);
}
@@ -81,6 +88,8 @@ export class TriggerSystem extends EventEmitter {
* Add a new trigger
*/
public addTrigger(trigger: Trigger): void {
delete (trigger as Trigger & { action?: string }).action;
if (trigger.isRegex && !this.isSafeRegex(trigger.pattern)) throw new Error('Unsafe or unsupported regular expression.');
const existingTriggerIndex = this.triggers.findIndex(t => t.id === trigger.id);
if (existingTriggerIndex !== -1) {
@@ -133,14 +142,11 @@ export class TriggerSystem extends EventEmitter {
* Process text for triggers
* @returns Object with information about gagging and replacement
*/
public processTriggers(text: string): {
processed: string; // Text after all replacements
gagged: boolean; // Whether the text should be completely hidden
matched: boolean; // Whether any triggers matched
} {
public processTriggers(text: string): TriggerResult {
let processedText = text;
let isGagged = false;
let anyTriggerMatched = false;
const highlights: TriggerResult['highlights'] = [];
// Process only enabled triggers in priority order
for (const trigger of this.triggers.filter(t => t.isEnabled)) {
@@ -149,9 +155,9 @@ export class TriggerSystem extends EventEmitter {
if (trigger.isRegex) {
try {
const regex = new RegExp(trigger.pattern, 'g');
matches = processedText.match(regex);
matched = matches !== null && matches.length > 0;
const regex = new RegExp(trigger.pattern);
matches = regex.exec(processedText);
matched = matches !== null;
} catch (error) {
console.error(`Invalid regex pattern in trigger ${trigger.name}:`, error);
}
@@ -170,6 +176,9 @@ export class TriggerSystem extends EventEmitter {
if (trigger.gag) {
isGagged = true;
}
if (trigger.highlightColor && /^#[0-9a-f]{6}$/i.test(trigger.highlightColor)) {
highlights.push({ pattern: trigger.pattern, color: trigger.highlightColor, isRegex: trigger.isRegex });
}
// Handle text replacement if not gagged
if (!isGagged && trigger.replaceText) {
@@ -213,7 +222,8 @@ export class TriggerSystem extends EventEmitter {
return {
processed: processedText,
gagged: isGagged,
matched: anyTriggerMatched
matched: anyTriggerMatched,
highlights
};
}
@@ -223,6 +233,17 @@ export class TriggerSystem extends EventEmitter {
private escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
public isSafeRegex(pattern: string): boolean {
if (pattern.length === 0 || pattern.length > 256) return false;
// Keep expressions within a deliberately small, RE2-like subset. Native
// browser RegExp has no cancellation mechanism once matching begins.
if (/\\[1-9]|\(\?/.test(pattern)) return false;
if (/\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) return false;
if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false;
if (/\{\d{4,}(?:,\d*)?\}/.test(pattern)) return false;
try { new RegExp(pattern); return true; } catch { return false; }
}
/**
* Execute a triggered action with support for advanced features
@@ -230,7 +251,7 @@ export class TriggerSystem extends EventEmitter {
private executeTrigger(trigger: Trigger, text: string, matches: RegExpMatchArray | null): void {
// Get settings
const uiSettingsValue = get(uiSettings);
const globalVolume = uiSettingsValue.globalVolume || 0.7;
const globalVolume = uiSettingsValue.globalVolume ?? 0.7;
// Handle sound playback, loading on demand if needed
if (trigger.soundFile) {
@@ -297,16 +318,6 @@ export class TriggerSystem extends EventEmitter {
// Emit basic trigger fired event
this.emit('triggerFired', trigger.id);
// Execute custom action if specified and in browser environment
if (trigger.action && typeof window !== 'undefined') {
try {
// Create a sandboxed function with limited scope
const actionFn = new Function('text', 'matches', trigger.action);
actionFn(text, matches);
} catch (error) {
console.error(`Error executing custom action for trigger ${trigger.id}:`, error);
}
}
}
/**
@@ -325,6 +336,10 @@ export class TriggerSystem extends EventEmitter {
return;
}
if (/^https?:/i.test(soundFile) && !/^https:/i.test(soundFile)) {
reject(new Error('Remote trigger sounds must use HTTPS.'));
return;
}
// Build path based on whether it's a URL or local file
const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/')
? soundFile
@@ -370,6 +385,7 @@ export class TriggerSystem extends EventEmitter {
if (trigger) {
trigger.isEnabled = enabled;
this.saveTriggersToStorage();
this.emit('triggerUpdated', trigger);
}
}
@@ -414,7 +430,8 @@ export class TriggerSystem extends EventEmitter {
typeof obj.pattern === 'string' &&
typeof obj.isRegex === 'boolean' &&
typeof obj.isEnabled === 'boolean' &&
typeof obj.priority === 'number'
typeof obj.priority === 'number' &&
(!obj.isRegex || this.isSafeRegex(obj.pattern))
);
}
}
}
+55 -54
View File
@@ -13,7 +13,7 @@ const STORAGE_KEYS = {
};
// Current backup format version
const BACKUP_FORMAT_VERSION = '1.0.0';
const BACKUP_FORMAT_VERSION = '2.0.0';
// Interface for backup file format
interface BackupData {
@@ -28,8 +28,8 @@ interface BackupData {
}
export class BackupManager extends EventEmitter {
private profileManager: ProfileManager;
private triggerSystem: TriggerSystem;
private profileManager: ProfileManager | null;
private triggerSystem: TriggerSystem | null;
constructor() {
super();
@@ -70,6 +70,7 @@ export class BackupManager extends EventEmitter {
// Add all localStorage items that match our known keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
// Skip items that don't look like our app data
if (!key.startsWith('svelte-mud-') && !key.startsWith('mud-')) {
@@ -84,14 +85,19 @@ export class BackupManager extends EventEmitter {
// Store by category
if (key === STORAGE_KEYS.PROFILES) {
backup.data.profiles = parsedValue;
backup.data.profiles = Array.isArray(parsedValue) ? parsedValue.map(profile => {
const clone = structuredClone(profile);
if (clone.autoLogin) delete clone.autoLogin.password;
return clone;
}) : [];
} else if (key === STORAGE_KEYS.TRIGGERS) {
backup.data.triggers = parsedValue;
backup.data.triggers = Array.isArray(parsedValue) ? parsedValue.map(trigger => {
const clone = structuredClone(trigger);
delete clone.action;
return clone;
}) : [];
} else if (key === STORAGE_KEYS.SETTINGS) {
backup.data.settings = parsedValue;
} else {
// Store other items directly by key
backup.data[key] = parsedValue;
}
}
} catch (error) {
@@ -129,7 +135,7 @@ export class BackupManager extends EventEmitter {
this.emit('backupExported', backup);
} catch (error) {
console.error('Failed to export backup:', error);
throw new Error(`Failed to export backup: ${error.message}`);
throw new Error(`Failed to export backup: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -137,12 +143,14 @@ export class BackupManager extends EventEmitter {
* Import backup from a JSON string
*/
public async importBackup(json: string): Promise<void> {
const previous = Object.fromEntries(Object.values(STORAGE_KEYS).map(key => [key, localStorage.getItem(key)]));
try {
if (new Blob([json]).size > 1024 * 1024) throw new Error('Backup exceeds the 1 MB limit');
// Parse backup data
const backup = JSON.parse(json) as BackupData;
// Verify format version
if (!backup.version) {
if (!backup.version || !backup.data || typeof backup.data !== 'object') {
throw new Error('Invalid backup file format: Missing version');
}
@@ -173,22 +181,24 @@ export class BackupManager extends EventEmitter {
await this.restoreSettings(backup.data.settings);
}
// Restore any other data
for (const key in backup.data) {
if (
key !== 'profiles' &&
key !== 'triggers' &&
key !== 'settings' &&
key.startsWith('svelte-mud-') || key.startsWith('mud-')
) {
localStorage.setItem(key, JSON.stringify(backup.data[key]));
}
}
this.emit('backupImported', backup);
} catch (error) {
for (const [key, value] of Object.entries(previous)) {
if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
}
// Keep the live stores consistent with the rolled-back persistent state.
this.profileManager = new ProfileManager();
profiles.set(this.profileManager.getProfiles());
this.triggerSystem = new TriggerSystem();
triggers.set(this.triggerSystem.getTriggers());
const oldSettings = previous[STORAGE_KEYS.SETTINGS];
if (oldSettings) {
settingsManager.importSettings(oldSettings);
accessibilitySettings.set(get(settingsManager.accessibilitySettings));
uiSettings.set(get(settingsManager.uiSettings));
}
console.error('Failed to import backup:', error);
throw new Error(`Failed to import backup: ${error.message}`);
throw new Error(`Failed to import backup: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -200,15 +210,14 @@ export class BackupManager extends EventEmitter {
throw new Error('Invalid profiles data: Expected array');
}
// Clear existing profiles
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify([]));
// Import each profile
for (const profile of profilesData) {
this.profileManager.addProfile(profile);
}
// Update the profiles store
const sanitized = profilesData.map(profile => {
if (!profile || typeof profile !== 'object') throw new Error('Invalid profile entry');
const clone = structuredClone(profile);
if (clone.autoLogin) delete clone.autoLogin.password;
return clone;
});
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify(sanitized));
this.profileManager = new ProfileManager();
profiles.set(this.profileManager.getProfiles());
}
@@ -220,15 +229,16 @@ export class BackupManager extends EventEmitter {
throw new Error('Invalid triggers data: Expected array');
}
// Clear existing triggers
localStorage.setItem(STORAGE_KEYS.TRIGGERS, JSON.stringify([]));
// Import each trigger
for (const trigger of triggersData) {
this.triggerSystem.addTrigger(trigger);
}
// Update the triggers store
const validator = new TriggerSystem();
const sanitized = triggersData.map(trigger => {
if (!trigger || typeof trigger !== 'object') throw new Error('Invalid trigger entry');
const clone = structuredClone(trigger);
delete clone.action;
if (clone.isRegex && !validator.isSafeRegex(clone.pattern)) throw new Error(`Unsafe regex in trigger: ${clone.name || clone.id}`);
return clone;
});
localStorage.setItem(STORAGE_KEYS.TRIGGERS, JSON.stringify(sanitized));
this.triggerSystem = new TriggerSystem();
triggers.set(this.triggerSystem.getTriggers());
}
@@ -240,19 +250,10 @@ export class BackupManager extends EventEmitter {
throw new Error('Invalid settings data: Expected object');
}
// Update settings in localStorage
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settingsData));
// Update the settings stores
if (settingsData.accessibility) {
accessibilitySettings.set(settingsData.accessibility);
}
if (settingsData.ui) {
uiSettings.set(settingsData.ui);
}
// Make sure settings manager knows about the changes
// Normalize untrusted fields and update both the manager and app stores.
settingsManager.importSettings(JSON.stringify(settingsData));
accessibilitySettings.set(get(settingsManager.accessibilitySettings));
uiSettings.set(get(settingsManager.uiSettings));
settingsManager.saveSettings();
}
}
+2 -7
View File
@@ -67,7 +67,6 @@ export class ModalHelper {
autoLogin: {
enabled: false,
username: '',
password: '',
commands: []
},
aliases: {},
@@ -83,7 +82,6 @@ export class ModalHelper {
};
const isNewProfile = !existingProfile;
console.log('Setting up modal with profile:', profile);
// Set up the modal with a short delay to ensure the DOM is ready
setTimeout(() => {
@@ -96,8 +94,7 @@ export class ModalHelper {
profile,
isNewProfile
},
onSubmit: (result) => {
console.log('Modal submit callback with result:', result);
onSubmit: (result: { profile: MudProfile }) => {
onSave(result.profile);
},
onCancel: () => {
@@ -166,7 +163,6 @@ export class ModalHelper {
const isNewTrigger = !existingTrigger;
console.log('Setting up trigger modal:', existingTrigger || 'new trigger');
// Set up the modal with a short delay to ensure the DOM is ready
setTimeout(() => {
@@ -179,8 +175,7 @@ export class ModalHelper {
trigger: existingTrigger || null,
isNew: isNewTrigger
},
onSubmit: (result) => {
console.log('Modal submit callback with result:', result);
onSubmit: (result: { trigger: Trigger }) => {
onSave(result.trigger);
},
onCancel: () => {
-43
View File
@@ -1,43 +0,0 @@
// This is a simple browser console test that you can run to verify backup/restore functionality
// Copy and paste this into the browser console after loading the application
function testBackupSystem() {
console.log("=== Backup System Test ===");
// First check if we can access the backup manager
if (!window.backupManager) {
console.error("Could not access backupManager. Add 'window.backupManager = backupManager;' to BackupManager.ts for testing.");
return;
}
// Create a backup object without downloading the file
console.log("Creating backup object...");
const backup = window.backupManager.createBackup();
console.log("Backup object created:", backup);
console.log("Backup version:", backup.version);
console.log("Timestamp:", new Date(backup.timestamp).toLocaleString());
// Check what data was backed up
console.log("Profiles found:", backup.data.profiles ? backup.data.profiles.length : 0);
console.log("Triggers found:", backup.data.triggers ? backup.data.triggers.length : 0);
console.log("Settings found:", backup.data.settings ? "Yes" : "No");
// Count total number of items in the backup
const totalItems = Object.keys(backup.data).length;
console.log("Total data items:", totalItems);
if (totalItems === 0) {
console.error("No data was found in the backup. Make sure you have some profiles, triggers, or settings saved.");
return;
}
console.log("✅ Backup creation successful");
// We can't test actual import here as it would overwrite the user's data
console.log("To test import functionality, export a backup file,");
console.log("modify some settings, then import the backup file back.");
}
// Run the test
testBackupSystem();
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { processMessage, segmentStyle } from './textProcessing';
describe('terminal text processing', () => {
it('keeps hostile markup as inert text with ANSI enabled or disabled', () => {
for (const ansiEnabled of [true, false]) {
const result = processMessage({ id: 'x', text: '<img src=x onerror=alert(1)>', timestamp: 1 }, ansiEnabled);
expect(result.lines[0].content).toBe('<img src=x onerror=alert(1)>');
expect(result.lines[0].segments[0].text).toContain('onerror');
}
});
it('only accepts validated highlight colors', () => {
const result = processMessage({
id: 'x', text: 'danger', timestamp: 1,
highlights: [{ pattern: 'danger', color: 'red\" onmouseover=alert(1)', isRegex: false }]
}, true);
expect(result.lines[0].segments.every((segment) => !segment.backgroundColor)).toBe(true);
});
it('converts ANSI styling into fixed style segments', () => {
const result = processMessage({ id: 'x', text: '\x1b[31;1mred\x1b[0m', timestamp: 1 }, true);
expect(result.lines[0].content).toBe('red');
expect(segmentStyle(result.lines[0].segments[0])).toContain('color:#aa0000');
});
});
+129 -144
View File
@@ -1,17 +1,16 @@
import AnsiToHtml from 'ansi-to-html';
// Create a singleton instance of the ANSI converter for consistent processing
const ansiConverter = new AnsiToHtml({
fg: '#f8f8f2',
bg: '#282a36',
newline: false, // We'll handle newlines ourselves
escapeXML: true,
stream: false
});
export interface RenderedSegment {
text: string;
color?: string;
backgroundColor?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
export interface ProcessedLine {
id: string;
content: string;
segments: RenderedSegment[];
isSubline: boolean;
parentId: string;
lineIndex: number;
@@ -22,154 +21,140 @@ export interface ProcessedMessage {
originalText: string;
timestamp: number;
isInput: boolean;
highlights: { pattern: string; color: string; isRegex: boolean }[];
highlights: Highlight[];
processedContent: string;
lines: ProcessedLine[];
// Cache for different UI settings
processedCache: Map<string, { content: string; lines: ProcessedLine[] }>;
}
/**
* Process ANSI color codes
*/
export function processAnsi(text: string, ansiEnabled: boolean): string {
if (ansiEnabled) {
try {
// First process ANSI to HTML without replacing newlines
const ansiProcessed = ansiConverter.toHtml(text);
// Then replace newlines with <br> tags
return ansiProcessed.replace(/\r\n|\r|\n/g, '<br>');
} catch (error) {
console.error('Error processing ANSI colors:', error);
// Fallback to just replacing newlines
return text.replace(/\r\n|\r|\n/g, '<br>');
}
} else {
// Strip ANSI codes if color is disabled
return text.replace(/\u001b\[\d+(;\d+)*m/g, '')
.replace(/\r\n|\r|\n/g, '<br>');
}
}
interface Highlight { pattern: string; color: string; isRegex: boolean }
const normalColors = ['#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa'];
const brightColors = ['#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff'];
const ANSI_PATTERN = /\x1b\[([0-9;]*)m/g;
/**
* Split text into individual lines (for better screen reader navigation)
*/
export function splitIntoLines(text: string): string[] {
// First handle any text that already has <br> tags from ANSI processing
if (text.includes('<br>')) {
return text.split('<br>').filter(line => line.trim().length > 0);
}
// Otherwise split by newlines
return text.split(/\r\n|\r|\n/).filter(line => line.trim().length > 0);
}
export function createCacheKey(ansiEnabled: boolean): string { return `ansi:${ansiEnabled}`; }
/**
* Apply highlighting to text
*/
export function applyHighlights(text: string, highlights: { pattern: string; color: string; isRegex: boolean }[]): string {
if (!highlights || highlights.length === 0) return text;
let highlightedText = text;
highlights.forEach(({ pattern, color, isRegex }) => {
if (isRegex) {
try {
const regex = new RegExp(pattern, 'g');
highlightedText = highlightedText.replace(regex, (match) => {
return `<span style="background-color: ${color};">${match}</span>`;
});
} catch (error) {
console.error('Invalid regex pattern:', pattern, error);
}
} else {
// Escape special characters in the pattern for use in a regex
const safePattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(safePattern, 'g');
highlightedText = highlightedText.replace(regex, (match) => {
return `<span style="background-color: ${color};">${match}</span>`;
});
}
});
return highlightedText;
}
/**
* Create a cache key for UI settings that affect text processing
*/
export function createCacheKey(ansiEnabled: boolean): string {
return `ansi:${ansiEnabled}`;
}
/**
* Process a message completely with caching
*/
export function processMessage(
message: {
id: string;
text: string;
timestamp: number;
isInput?: boolean;
highlights?: { pattern: string; color: string; isRegex: boolean }[]
},
message: { id: string; text: string; timestamp: number; isInput?: boolean; highlights?: Highlight[] },
ansiEnabled: boolean
): ProcessedMessage {
const processedMessage: ProcessedMessage = {
id: message.id,
originalText: message.text,
timestamp: message.timestamp,
isInput: message.isInput || false,
highlights: message.highlights || [],
processedContent: '',
lines: [],
processedCache: new Map()
};
const cacheKey = createCacheKey(ansiEnabled);
// Check if we have cached processed content for these settings
const cached = processedMessage.processedCache.get(cacheKey);
if (cached) {
processedMessage.processedContent = cached.content;
processedMessage.lines = cached.lines;
return processedMessage;
}
// Process the content
const ansiProcessed = processAnsi(message.text, ansiEnabled);
const highlighted = applyHighlights(ansiProcessed, message.highlights || []);
const lines = splitIntoLines(highlighted);
processedMessage.processedContent = highlighted;
// Create processed line objects
if (lines.length <= 1) {
// Single line or no lines
processedMessage.lines = [{
id: `${message.id}-line-0`,
content: highlighted,
isSubline: false,
parentId: message.id,
lineIndex: 0
}];
} else {
// Multiple lines
processedMessage.lines = lines.map((line, index) => ({
const lineTexts = message.text.split(/\r\n|\r|\n/).filter((line) => line.trim().length > 0);
const safeLines = lineTexts.length > 0 ? lineTexts : [''];
const lines = safeLines.map((line, index) => {
const segments = applyHighlights(parseAnsi(line, ansiEnabled), message.highlights || []);
return {
id: `${message.id}-line-${index}`,
content: line,
content: segments.map((segment) => segment.text).join(''),
segments,
isSubline: index > 0,
parentId: message.id,
lineIndex: index
}));
}
// Cache the result
processedMessage.processedCache.set(cacheKey, {
content: highlighted,
lines: processedMessage.lines
};
});
const processedContent = lines.map((line) => line.content).join('\n');
const processed: ProcessedMessage = {
id: message.id,
originalText: message.text,
timestamp: message.timestamp,
isInput: message.isInput ?? false,
highlights: message.highlights || [],
processedContent,
lines,
processedCache: new Map()
};
processed.processedCache.set(createCacheKey(ansiEnabled), { content: processedContent, lines });
return processed;
}
return processedMessage;
function parseAnsi(text: string, enabled: boolean): RenderedSegment[] {
if (!enabled) return [{ text: text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') }];
const result: RenderedSegment[] = [];
let cursor = 0;
let style: Omit<RenderedSegment, 'text'> = {};
ANSI_PATTERN.lastIndex = 0;
for (let match = ANSI_PATTERN.exec(text); match; match = ANSI_PATTERN.exec(text)) {
if (match.index > cursor) result.push({ text: text.slice(cursor, match.index), ...style });
style = applySgr(style, (match[1] || '0').split(';').map(Number));
cursor = ANSI_PATTERN.lastIndex;
}
if (cursor < text.length) result.push({ text: text.slice(cursor), ...style });
return result.length > 0 ? result : [{ text: '' }];
}
function applySgr(current: Omit<RenderedSegment, 'text'>, codes: number[]): Omit<RenderedSegment, 'text'> {
let style = { ...current };
for (let index = 0; index < codes.length; index += 1) {
const code = codes[index];
if (code === 0) style = {};
else if (code === 1) style.bold = true;
else if (code === 3) style.italic = true;
else if (code === 4) style.underline = true;
else if (code === 22) style.bold = false;
else if (code === 23) style.italic = false;
else if (code === 24) style.underline = false;
else if (code >= 30 && code <= 37) style.color = normalColors[code - 30];
else if (code >= 90 && code <= 97) style.color = brightColors[code - 90];
else if (code === 39) delete style.color;
else if (code >= 40 && code <= 47) style.backgroundColor = normalColors[code - 40];
else if (code >= 100 && code <= 107) style.backgroundColor = brightColors[code - 100];
else if (code === 49) delete style.backgroundColor;
else if ((code === 38 || code === 48) && codes[index + 1] === 5 && Number.isInteger(codes[index + 2])) {
const color = ansi256Color(codes[index + 2]);
if (code === 38) style.color = color; else style.backgroundColor = color;
index += 2;
}
}
return style;
}
function ansi256Color(index: number): string {
const safe = Math.max(0, Math.min(255, index));
if (safe < 8) return normalColors[safe];
if (safe < 16) return brightColors[safe - 8];
if (safe >= 232) {
const value = 8 + (safe - 232) * 10;
return `rgb(${value}, ${value}, ${value})`;
}
const cube = safe - 16;
const channel = (value: number) => value === 0 ? 0 : 55 + value * 40;
return `rgb(${channel(Math.floor(cube / 36))}, ${channel(Math.floor(cube / 6) % 6)}, ${channel(cube % 6)})`;
}
function applyHighlights(segments: RenderedSegment[], highlights: Highlight[]): RenderedSegment[] {
let result = segments;
for (const highlight of highlights) {
if (!/^#[0-9a-f]{6}$/i.test(highlight.color) || highlight.pattern.length === 0) continue;
let regex: RegExp;
try {
regex = highlight.isRegex ? new RegExp(highlight.pattern, 'g') : new RegExp(escapeRegex(highlight.pattern), 'g');
} catch { continue; }
result = result.flatMap((segment) => splitHighlightedSegment(segment, regex, highlight.color));
}
return result;
}
function splitHighlightedSegment(segment: RenderedSegment, regex: RegExp, color: string): RenderedSegment[] {
const output: RenderedSegment[] = [];
let cursor = 0;
regex.lastIndex = 0;
for (let match = regex.exec(segment.text); match; match = regex.exec(segment.text)) {
if (match[0].length === 0) { regex.lastIndex += 1; continue; }
if (match.index > cursor) output.push({ ...segment, text: segment.text.slice(cursor, match.index) });
output.push({ ...segment, text: match[0], backgroundColor: color });
cursor = match.index + match[0].length;
}
if (cursor < segment.text.length) output.push({ ...segment, text: segment.text.slice(cursor) });
return output.length > 0 ? output : [segment];
}
function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
export function segmentStyle(segment: RenderedSegment): string {
const rules: string[] = [];
if (segment.color) rules.push(`color:${segment.color}`);
if (segment.backgroundColor) rules.push(`background-color:${segment.backgroundColor}`);
if (segment.bold) rules.push('font-weight:bold');
if (segment.italic) rules.push('font-style:italic');
if (segment.underline) rules.push('text-decoration:underline');
return rules.join(';');
}
+5 -20
View File
@@ -1,12 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
// Declare global window property for volume debounce
declare global {
interface Window {
volumeDebounceTimeout?: number;
}
}
import MudMdi from '$lib/components/MudMdi.svelte';
import KeyboardShortcutsHelp from '$lib/components/KeyboardShortcutsHelp.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
@@ -36,9 +30,8 @@
let sidebarTab: 'profiles' | 'triggers' | 'settings' = 'profiles';
// Save profile from component
function saveProfile(event) {
function saveProfile(event: { detail: { profile: MudProfile } }) {
const profile = event.detail.profile;
console.log('Saving profile from component:', profile);
if (profileManager) {
// Ensure profile has a valid ID
@@ -49,7 +42,6 @@
// Ensure all required fields are set
if (!profile.ansiColor) profile.ansiColor = true;
console.log('Saving validated profile:', profile);
profileManager.addProfile(profile);
// Force reload all profiles
@@ -115,7 +107,6 @@
});
// Ensure settings are properly loaded and initialized
console.log('Initial profiles state:', $profiles);
console.log('Active profile ID:', $activeProfileId);
// Make sure we have an active profile selected if any profiles exist
@@ -125,7 +116,7 @@
}
// Initialize output history for all profiles
const outputHistoryObject = {};
const outputHistoryObject: Record<string, []> = {};
$profiles.forEach(profile => {
outputHistoryObject[profile.id] = [];
});
@@ -134,9 +125,8 @@
} catch (error) {
console.error('Error during page initialization:', error);
console.error('Error details:', error.message, error.stack);
// Provide user feedback about the error
addToOutputHistory(`Error initializing client: ${error.message}. Please reload the page.`);
addToOutputHistory(`Error initializing client: ${error instanceof Error ? error.message : String(error)}. Please reload the page.`);
}
});
@@ -174,7 +164,6 @@
// Get all profiles from the manager
const allProfiles = profileManager.getProfiles();
console.log('Loaded profiles from manager:', allProfiles);
// Create a default profile if none exist
if (allProfiles.length === 0) {
@@ -187,7 +176,6 @@
// Get profiles again after adding the default one
const updatedProfiles = profileManager.getProfiles();
console.log('Profiles after adding default:', updatedProfiles);
profiles.set(updatedProfiles);
// Set this as the active profile
@@ -221,8 +209,7 @@
}
} catch (error) {
console.error('Error loading profiles:', error);
console.error('Error details:', error.message, error.stack);
addToOutputHistory(`Error loading profiles: ${error.message}`);
addToOutputHistory(`Error loading profiles: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -234,10 +221,8 @@
// Edit an existing profile
function editProfile(profile: MudProfile) {
console.log('Editing profile:', profile);
ModalHelper.showProfileEditor(
(updatedProfile) => {
console.log('Profile updated from modal:', updatedProfile);
// Use the same saveProfile function for consistency
saveProfile({ detail: { profile: updatedProfile } });
},
@@ -378,4 +363,4 @@
overflow: hidden;
position: relative;
}
</style>
</style>
-36
View File
@@ -1,36 +0,0 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as net from 'net';
// This is a server endpoint to handle MUD connections
export const POST: RequestHandler = async ({ request }) => {
try {
const data = await request.json();
const { host, port } = data;
if (!host || !port) {
throw error(400, 'Missing host or port');
}
// Validate host and port
if (typeof host !== 'string' || typeof port !== 'number') {
throw error(400, 'Invalid host or port');
}
// In a real implementation, we would establish a WebSocket connection
// and proxy data to a telnet connection here.
// For security reasons, this would typically be done on the server side.
return new Response(JSON.stringify({
success: true,
message: `Connection request received for ${host}:${port}`
}), {
headers: {
'content-type': 'application/json'
}
});
} catch (err) {
console.error('Error connecting to MUD server:', err);
throw error(500, 'Failed to connect to MUD server');
}
};
-27
View File
@@ -1,27 +0,0 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// WebSocket server for MUD connections
export const GET: RequestHandler = async ({ request, url }) => {
const host = url.searchParams.get('host');
const port = url.searchParams.get('port');
const useSSL = url.searchParams.get('useSSL') === 'true';
if (!host || !port) {
throw error(400, 'Missing host or port parameters');
}
// In a real implementation, we would establish a WebSocket proxy to the MUD server
// Since SvelteKit server endpoints don't natively support WebSockets,
// this endpoint would be used to create a connection in a dedicated WebSocket server
// Use proper response status and headers for WebSocket upgrade
return new Response(null, {
status: 101,
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Accept': 'placeholder-for-real-implementation' // In a real implementation, this would be calculated
}
});
};
-240
View File
@@ -1,240 +0,0 @@
<script>
import { onMount } from 'svelte';
let messages = [];
let status = 'Disconnected';
let errorMessage = '';
let socket = null;
// Connect to the WebSocket server
function connect() {
try {
// Clear previous state
status = 'Connecting...';
errorMessage = '';
messages = [];
// Create WebSocket connection to the standalone server on port 3001
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsHost = `${window.location.hostname}:3001`;
const url = `${wsProtocol}://${wsHost}/mud-ws?host=example.com&port=23&useSSL=false`;
addMessage('System', `Connecting to ${url}`);
socket = new WebSocket(url);
// Connection opened
socket.addEventListener('open', (event) => {
status = 'Connected';
addMessage('System', 'Connection established');
});
// Listen for messages
socket.addEventListener('message', (event) => {
const text = event.data instanceof Blob
? '[Binary data]'
: event.data;
addMessage('Server', text);
});
// Connection closed
socket.addEventListener('close', (event) => {
status = 'Disconnected';
addMessage('System', `Connection closed: ${event.code}`);
});
// Connection error
socket.addEventListener('error', (event) => {
status = 'Error';
errorMessage = 'Connection error, check console for details';
addMessage('System', 'Connection error');
});
} catch (error) {
status = 'Error';
errorMessage = error.message;
addMessage('System', `Error: ${error.message}`);
}
}
// Send a test message
function sendMessage() {
if (socket && socket.readyState === WebSocket.OPEN) {
const testMessage = 'Test message from client';
socket.send(testMessage);
addMessage('Client', testMessage);
} else {
errorMessage = 'Socket is not connected';
}
}
// Disconnect
function disconnect() {
if (socket) {
socket.close();
socket = null;
}
}
// Add message to the log
function addMessage(source, text) {
messages = [...messages, { source, text, timestamp: new Date() }];
}
// Cleanup on component unmount
onMount(() => {
return () => {
if (socket) {
socket.close();
}
};
});
</script>
<div class="websocket-test">
<h1>WebSocket Test Page</h1>
<p>This page tests the standalone WebSocket server at port 3001</p>
<div class="connection-status">
<strong>Status:</strong> <span class={status.toLowerCase()}>{status}</span>
{#if errorMessage}
<div class="error">{errorMessage}</div>
{/if}
</div>
<div class="controls">
<button on:click={connect} disabled={status === 'Connected' || status === 'Connecting...'}>
Connect
</button>
<button on:click={sendMessage} disabled={status !== 'Connected'}>
Send Test Message
</button>
<button on:click={disconnect} disabled={status !== 'Connected'}>
Disconnect
</button>
</div>
<div class="message-log">
<h2>Message Log</h2>
{#if messages.length === 0}
<p class="empty-log">No messages yet. Connect and send a test message.</p>
{:else}
<div class="messages">
{#each messages as message}
<div class="message">
<div class="message-header">
<span class="source">{message.source}</span>
<span class="timestamp">{message.timestamp.toLocaleTimeString()}</span>
</div>
<pre class="message-text">{message.text}</pre>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.websocket-test {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.connection-status {
margin: 20px 0;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
.connected {
color: green;
}
.disconnected {
color: gray;
}
.connecting\.\.\. {
color: blue;
}
.error {
color: red;
}
.controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
button {
padding: 8px 16px;
background-color: #4caf50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.message-log {
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
max-height: 400px;
overflow-y: auto;
}
.empty-log {
color: #888;
font-style: italic;
}
.messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.message {
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.message:last-child {
border-bottom: none;
}
.message-header {
display: flex;
justify-content: space-between;
font-size: 0.9em;
margin-bottom: 5px;
}
.source {
font-weight: bold;
}
.timestamp {
color: #888;
}
.message-text {
margin: 0;
padding: 10px;
background-color: #f8f8f8;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
font-family: monospace;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { server } from './websocket-server.js';
let address;
beforeAll(async () => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const bound = server.address();
if (!bound || typeof bound === 'string') throw new Error('Proxy test server did not bind.');
address = `ws://127.0.0.1:${bound.port}/mud-ws`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
describe('proxy WebSocket boundary', () => {
it('rejects an untrusted browser origin before upgrading', async () => {
const status = await new Promise((resolve, reject) => {
const socket = new WebSocket(address, { origin: 'https://attacker.example' });
socket.on('unexpected-response', (_request, response) => resolve(response.statusCode));
socket.on('open', () => reject(new Error('Untrusted origin was upgraded.')));
socket.on('error', () => undefined);
});
expect(status).toBe(403);
});
it('requires a valid connect control frame first', async () => {
const message = await new Promise((resolve, reject) => {
const socket = new WebSocket(address, { origin: 'http://localhost:5173' });
socket.on('open', () => socket.send('{}'));
socket.on('message', (data) => resolve(JSON.parse(data.toString())));
socket.on('error', reject);
});
expect(message).toMatchObject({ type: 'error', code: 'CONNECT_ERROR' });
});
});
+267 -465
View File
@@ -1,491 +1,293 @@
import { WebSocketServer } from 'ws';
import * as net from 'net';
import * as tls from 'tls';
import http from 'http';
import { parse } from 'url';
import { randomBytes } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import http from 'node:http';
import net, { BlockList } from 'node:net';
import tls from 'node:tls';
import { WebSocket, WebSocketServer } from 'ws';
// Default configuration for connection persistence (fallback values)
const DEFAULT_PERSISTENCE_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
const DEFAULT_MAX_BUFFER_MESSAGES = 100; // Maximum number of messages to buffer
const DEFAULT_MAX_BUFFER_SIZE_KB = 10; // Maximum buffer size in KB
const PORT = numberFromEnv('WS_PORT', 3001, 1, 65535);
const MAX_SESSIONS_PER_IP = numberFromEnv('PROXY_MAX_SESSIONS_PER_IP', 3, 1, 20);
const MAX_GLOBAL_SESSIONS = numberFromEnv('PROXY_MAX_GLOBAL_SESSIONS', 200, 1, 10_000);
const NEW_CONNECTION_LIMIT = numberFromEnv('PROXY_NEW_CONNECTIONS_PER_10_MIN', 20, 1, 1_000);
const CONNECT_TIMEOUT_MS = 10_000;
const PERSISTENCE_TIMEOUT_MS = 5 * 60_000;
const MAX_BUFFER_MESSAGES = 250;
const MAX_BUFFER_BYTES = 256 * 1024;
const MAX_CONTROL_BYTES = 4 * 1024;
const MAX_BYTES_PER_SECOND = 2 * 1024 * 1024;
const DENIED_PORTS = new Set([25, 465, 587, 2525]);
const production = process.env.NODE_ENV === 'production';
const allowedOrigins = new Set(
(process.env.ALLOWED_ORIGINS || (production ? '' : 'http://localhost:5173,http://127.0.0.1:5173'))
.split(',').map((origin) => origin.trim()).filter(Boolean)
);
const deniedAddresses = createDeniedAddressList();
const sessions = new Map();
const connectionAttempts = new Map();
const HEARTBEAT_INTERVAL = 30 * 1000; // 30 seconds
// Create HTTP server
const server = http.createServer();
// Create WebSocket server
const wss = new WebSocketServer({ noServer: true });
// Active connections and their proxies
// Key: connectionId, Value: { ws, socket, sessionId, settings }
const connections = new Map();
// Persistent connections waiting for reconnection
// Key: sessionId, Value: { socket, mudHost, mudPort, useSSL, timeoutId, lastActivity, messageBuffer, settings }
const persistentConnections = new Map();
// Parse connection settings from URL parameters with defaults
function parseConnectionSettings(urlParts) {
const persistenceTimeoutParam = urlParts.searchParams.get('persistenceTimeout');
const maxBufferMessagesParam = urlParts.searchParams.get('maxBufferMessages');
const maxBufferSizeKBParam = urlParts.searchParams.get('maxBufferSizeKB');
return {
persistenceTimeoutMs: persistenceTimeoutParam ?
parseInt(persistenceTimeoutParam) * 60 * 1000 : // Convert minutes to milliseconds
DEFAULT_PERSISTENCE_TIMEOUT,
maxBufferMessages: maxBufferMessagesParam ?
parseInt(maxBufferMessagesParam) :
DEFAULT_MAX_BUFFER_MESSAGES,
maxBufferSizeKB: maxBufferSizeKBParam ?
parseInt(maxBufferSizeKBParam) :
DEFAULT_MAX_BUFFER_SIZE_KB
};
}
// Generate a unique session ID for persistent connections
function generateSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Buffer a message for a persistent connection
function bufferMessage(sessionId, data) {
const persistentConn = persistentConnections.get(sessionId);
if (!persistentConn) {
return; // No persistent connection to buffer for
}
if (!persistentConn.messageBuffer) {
persistentConn.messageBuffer = [];
persistentConn.bufferSize = 0;
}
// Add timestamp to the message
const bufferedMessage = {
data: data,
timestamp: Date.now()
};
persistentConn.messageBuffer.push(bufferedMessage);
persistentConn.bufferSize += data.length;
// Use this connection's specific settings for buffer limits
const settings = persistentConn.settings || {
maxBufferMessages: DEFAULT_MAX_BUFFER_MESSAGES,
maxBufferSizeKB: DEFAULT_MAX_BUFFER_SIZE_KB
};
// Trim buffer if it gets too large
while (persistentConn.messageBuffer.length > settings.maxBufferMessages ||
persistentConn.bufferSize > settings.maxBufferSizeKB * 1000) {
const removed = persistentConn.messageBuffer.shift();
if (removed) {
persistentConn.bufferSize -= removed.data.length;
}
}
console.log(`Buffered ${data.length} bytes for session ${sessionId} (${persistentConn.messageBuffer.length} messages, ${persistentConn.bufferSize} bytes total, limits: ${settings.maxBufferMessages} msgs/${settings.maxBufferSizeKB}KB)`);
}
// Replay buffered messages to a reconnected client
function replayBufferedMessages(ws, sessionId) {
const persistentConn = persistentConnections.get(sessionId);
if (!persistentConn || !persistentConn.messageBuffer) {
return 0; // No messages to replay
}
const messages = persistentConn.messageBuffer;
console.log(`Replaying ${messages.length} buffered messages for session ${sessionId}`);
// Send a notification about message replay
const replayNotification = `[SYSTEM]${JSON.stringify({
type: 'message_replay_start',
messageCount: messages.length,
timespan: messages.length > 0 ? Date.now() - messages[0].timestamp : 0
})}`;
ws.send(replayNotification);
// Send all buffered messages
for (const message of messages) {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(message.data);
}
}
// Send replay complete notification
const replayComplete = `[SYSTEM]${JSON.stringify({ type: 'message_replay_complete' })}`;
ws.send(replayComplete);
// Clear the buffer since messages have been replayed
const messageCount = messages.length;
persistentConn.messageBuffer = [];
persistentConn.bufferSize = 0;
return messageCount;
}
// 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})`);
// Create a unique ID for this connection
const connectionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Check for session ID and settings in query parameters
const url = req.url || '';
const urlParts = new URL(`http://localhost${url}`);
const sessionId = urlParts.searchParams.get('sessionId');
// Parse connection settings for this specific connection
const connectionSettings = parseConnectionSettings(urlParts);
console.log(`Connection settings for ${connectionId}: timeout=${connectionSettings.persistenceTimeoutMs/1000/60}min, maxMessages=${connectionSettings.maxBufferMessages}, maxSizeKB=${connectionSettings.maxBufferSizeKB}`);
// Special handling for test connections
if (mudHost === 'example.com' && mudPort === '23') {
console.log('Test connection detected - using echo server mode');
// Send welcome message
ws.send('Hello from WebSocket test server! This is an echo server.');
// Echo back messages
ws.on('message', (message) => {
console.log('Test server received:', message.toString());
ws.send(`Echo: ${message.toString()}`);
});
// Handle close
ws.on('close', () => {
console.log('Test connection closed');
connections.delete(connectionId);
});
// Store the connection (without a socket)
connections.set(connectionId, { ws, testMode: true });
const server = http.createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ ok: true, sessions: sessions.size }));
return;
}
response.writeHead(404);
response.end();
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
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);
}
// Replay any buffered messages first
const replayedCount = replayBufferedMessages(ws, sessionId);
// Remove from persistent connections (now active again) - do this after replay
persistentConnections.delete(sessionId);
// Send reconnection notification with session ID in proper JSON format
ws.send(`[SYSTEM]${JSON.stringify({
type: 'session_resumed',
sessionId: sessionId,
messagesReplayed: replayedCount
})}`);
} 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;
}
function numberFromEnv(name, fallback, minimum, maximum) {
const parsed = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
}
function createDeniedAddressList() {
const list = new BlockList();
for (const [address, prefix] of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4]
]) list.addSubnet(address, prefix, 'ipv4');
for (const [address, prefix] of [
['::', 128], ['::1', 128], ['64:ff9b::', 96], ['2001::', 32],
['2001:db8::', 32], ['2002::', 16], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]
]) list.addSubnet(address, prefix, 'ipv6');
return list;
}
function getClientIp(request) {
if (process.env.TRUST_PROXY === '1') {
const forwarded = request.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) return forwarded.split(',')[0].trim();
}
return request.socket.remoteAddress || 'unknown';
}
// Store the connection with its settings
connections.set(connectionId, {
ws,
socket,
sessionId: currentSessionId,
settings: connectionSettings
function sendControl(ws, payload) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
}
function failUpgrade(socket, status, message) {
socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${message}`);
socket.destroy();
}
function countSessionsForIp(clientIp) {
let count = 0;
for (const session of sessions.values()) if (session.clientIp === clientIp) count += 1;
return count;
}
function recordConnectionAttempt(clientIp) {
const cutoff = Date.now() - 10 * 60_000;
const recent = (connectionAttempts.get(clientIp) || []).filter((timestamp) => timestamp > cutoff);
if (recent.length >= NEW_CONNECTION_LIMIT) return false;
recent.push(Date.now());
connectionAttempts.set(clientIp, recent);
return true;
}
function validateConnectMessage(value) {
if (!value || typeof value !== 'object' || value.type !== 'connect') throw new Error('The first frame must be a connect control message.');
if (typeof value.host !== 'string' || value.host.length < 1 || value.host.length > 253) throw new Error('Invalid host.');
if (!/^[a-zA-Z0-9._:-]+$/.test(value.host)) throw new Error('Invalid host characters.');
if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || DENIED_PORTS.has(value.port)) throw new Error('Invalid or denied port.');
if (typeof value.tls !== 'boolean') throw new Error('Invalid TLS setting.');
if (value.resumeToken !== undefined && (typeof value.resumeToken !== 'string' || value.resumeToken.length > 128)) throw new Error('Invalid resume token.');
return { host: value.host, port: value.port, useTls: value.tls, resumeToken: value.resumeToken };
}
async function resolvePublicTarget(host) {
const directFamily = net.isIP(host);
const answers = directFamily ? [{ address: host, family: directFamily }] : await lookup(host, { all: true, verbatim: true });
if (answers.length === 0) throw new Error('Host did not resolve.');
for (const answer of answers) {
if (isDeniedAddress(answer.address, answer.family)) throw new Error('Target resolves to a non-public address.');
}
return answers[0];
}
function isDeniedAddress(address, family = net.isIP(address)) {
const mapped = address.match(/^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]+):([0-9a-f]+))$/i);
if (mapped) {
let ipv4 = mapped[1];
if (!ipv4) {
const high = Number.parseInt(mapped[2], 16);
const low = Number.parseInt(mapped[3], 16);
ipv4 = `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
}
return deniedAddresses.check(ipv4, 'ipv4');
}
return deniedAddresses.check(address, family === 4 ? 'ipv4' : 'ipv6');
}
function consumeTraffic(session, direction, bytes) {
if (Date.now() - session.traffic.startedAt >= 1_000) session.traffic = { startedAt: Date.now(), inbound: 0, outbound: 0 };
session.traffic[direction] += bytes;
return session.traffic[direction] <= MAX_BYTES_PER_SECOND;
}
function bufferMessage(session, data) {
session.buffer.push(Buffer.from(data));
session.bufferBytes += data.length;
while (session.buffer.length > MAX_BUFFER_MESSAGES || session.bufferBytes > MAX_BUFFER_BYTES) {
const removed = session.buffer.shift();
if (removed) session.bufferBytes -= removed.length;
}
}
function destroySession(session, reason = 'closed') {
if (session.closed) return;
session.closed = true;
if (session.persistenceTimer) clearTimeout(session.persistenceTimer);
sessions.delete(session.token);
const attached = session.ws;
session.ws = null;
if (attached) sendControl(attached, { type: 'upstream_closed', reason });
if (!session.socket.destroyed) session.socket.destroy();
}
function rotateSessionToken(session) {
sessions.delete(session.token);
session.token = randomBytes(32).toString('base64url');
sessions.set(session.token, session);
}
function attachWebSocket(session, ws, resumed) {
if (session.persistenceTimer) clearTimeout(session.persistenceTimer);
session.persistenceTimer = null;
session.ws = ws;
rotateSessionToken(session);
if (resumed) {
sendControl(ws, { type: 'replay_started', messageCount: session.buffer.length });
for (const message of session.buffer) if (ws.readyState === WebSocket.OPEN) ws.send(message);
const messagesReplayed = session.buffer.length;
session.buffer = [];
session.bufferBytes = 0;
sendControl(ws, { type: 'replay_finished', messagesReplayed });
}
sendControl(ws, { type: resumed ? 'session_resumed' : 'session_started', resumeToken: session.token });
}
async function createSession(ws, request, target) {
const clientIp = getClientIp(request);
const origin = request.headers.origin || '';
if (sessions.size >= MAX_GLOBAL_SESSIONS) throw new Error('Proxy capacity reached.');
if (countSessionsForIp(clientIp) >= MAX_SESSIONS_PER_IP) throw new Error('Per-client connection limit reached.');
if (!recordConnectionAttempt(clientIp)) throw new Error('Connection rate limit reached.');
const resolved = await resolvePublicTarget(target.host);
const socketOptions = { host: resolved.address, port: target.port, family: resolved.family,
...(target.useTls ? { servername: net.isIP(target.host) ? undefined : target.host, rejectUnauthorized: true } : {}) };
const socket = target.useTls ? tls.connect(socketOptions) : net.createConnection(socketOptions);
const session = { token: randomBytes(32).toString('base64url'), clientIp, origin, target, socket, ws: null,
buffer: [], bufferBytes: 0, persistenceTimer: null, closed: false,
traffic: { startedAt: Date.now(), inbound: 0, outbound: 0 } };
sessions.set(session.token, session);
const connected = new Promise((resolve, reject) => {
const connectedEvent = target.useTls ? 'secureConnect' : 'connect';
const fail = (error) => reject(error instanceof Error ? error : new Error('Upstream connection failed.'));
socket.once(connectedEvent, resolve);
socket.once('error', fail);
socket.once('close', () => fail(new Error('Upstream closed before connecting.')));
socket.setTimeout(CONNECT_TIMEOUT_MS, () => {
fail(new Error('Upstream connection timed out.'));
destroySession(session, 'timeout');
});
});
// Handle data from the MUD server - only in regular mode, not test mode
if (socket) {
socket.on('data', (data) => {
// Check for GMCP data (IAC SB GMCP) - very basic check for debugging
// IAC = 255, SB = 250, GMCP = 201
let isGmcp = false;
for (let i = 0; i < data.length - 2; i++) {
if (data[i] === 255 && data[i+1] === 250 && data[i+2] === 201) {
isGmcp = true;
console.log('WebSocket server: Detected GMCP data in server response');
break;
}
}
// Forward data to the WebSocket client if it's still open
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(data);
console.log(`WebSocket server: Sent ${data.length} bytes to client${isGmcp ? ' (contains GMCP data)' : ''}`);
} else {
// WebSocket is not open, buffer the message if we have a session
if (currentSessionId) {
bufferMessage(currentSessionId, data);
}
}
});
socket.on('data', (data) => {
if (!consumeTraffic(session, 'inbound', data.length)) return destroySession(session, 'traffic_limit');
if (session.ws?.readyState === WebSocket.OPEN) session.ws.send(data);
else bufferMessage(session, data);
});
socket.on('error', (error) => { if (session.ws) sendControl(session.ws, { type: 'error', code: 'UPSTREAM_ERROR', message: error.message }); });
socket.on('close', () => destroySession(session, 'upstream_closed'));
socket.on('drain', () => session.ws?.resume());
try {
await connected;
} catch (error) {
destroySession(session, 'connect_failed');
throw error;
}
// Handle socket close from MUD server - this should trigger cleanup
if (socket) {
socket.on('close', () => {
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);
}
});
socket.setTimeout(0);
if (ws.readyState !== WebSocket.OPEN) {
destroySession(session, 'client_closed_during_connect');
throw new Error('Client closed while the upstream connection was opening.');
}
attachWebSocket(session, ws, false);
return session;
}
// Handle WebSocket messages (data from client to server)
ws.on('message', (message) => {
function tryResume(ws, request, target) {
if (!target.resumeToken) return null;
const session = sessions.get(target.resumeToken);
const clientIp = getClientIp(request);
const origin = request.headers.origin || '';
if (!session || session.ws || session.clientIp !== clientIp || session.origin !== origin || session.target.host !== target.host ||
session.target.port !== target.port || session.target.useTls !== target.useTls) return null;
attachWebSocket(session, ws, true);
return session;
}
wss.on('connection', (ws, request) => {
let session = null;
let explicitDisconnect = false;
let initialized = false;
const initializationTimer = setTimeout(() => { if (!initialized) ws.close(1008, 'Connect control timeout'); }, 5_000);
ws.on('message', async (data, isBinary) => {
try {
// Skip if this is a test connection (already handled in the test mode section)
const conn = connections.get(connectionId);
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
if (!initialized) {
if (isBinary || data.length > MAX_CONTROL_BYTES) throw new Error('Invalid connect control frame.');
const target = validateConnectMessage(JSON.parse(data.toString('utf8')));
initialized = true;
clearTimeout(initializationTimer);
session = tryResume(ws, request, target) || await createSession(ws, request, target);
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;
if (message instanceof Buffer || message instanceof Uint8Array) {
for (let i = 0; i < message.length - 2; i++) {
if (message[i] === 255 && message[i+1] === 250 && message[i+2] === 201) {
isGmcp = true;
console.log('WebSocket server: Detected GMCP data in client message');
break;
}
}
}
// Forward data to the MUD server
// The message might be Buffer, ArrayBuffer, or string
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 {
console.error('Socket not writable, cannot send data to MUD server');
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(Buffer.from(`ERROR: Cannot send data to MUD server: Socket not connected\r\n`));
if (!isBinary) {
if (data.length > MAX_CONTROL_BYTES) throw new Error('Control frame too large.');
const control = JSON.parse(data.toString('utf8'));
if (control.type === 'disconnect') {
explicitDisconnect = true;
if (session) destroySession(session, 'client_disconnect');
ws.close(1000, 'Disconnected');
return;
}
throw new Error('Unknown control frame.');
}
if (!session || session.ws !== ws || !session.socket.writable) throw new Error('Upstream connection is not writable.');
if (!consumeTraffic(session, 'outbound', data.length)) return destroySession(session, 'traffic_limit');
if (!session.socket.write(data)) ws.pause();
} catch (error) {
console.error('Error forwarding message to MUD server:', error);
if (ws.readyState === 1) { // WebSocket.OPEN
const errorMessage = error instanceof Error ? error.message : String(error);
ws.send(Buffer.from(`ERROR: Failed to send data to MUD server: ${errorMessage}\r\n`));
}
sendControl(ws, { type: 'error', code: initialized ? 'PROTOCOL_ERROR' : 'CONNECT_ERROR', message: error instanceof Error ? error.message : 'Unknown proxy error.' });
if (!session) ws.close(1008, 'Connection rejected');
}
});
// Handle WebSocket close - THIS IS THE KEY CHANGE FOR PERSISTENCE
ws.on('close', () => {
console.log(`WebSocket closed for ${mudHost}:${mudPort} (session: ${currentSessionId})`);
const conn = connections.get(connectionId);
if (conn && !conn.testMode && conn.socket && !conn.socket.destroyed) {
console.log(`Moving connection to persistent state for ${conn.settings.persistenceTimeoutMs / 1000} seconds`);
// Move the connection to persistent storage instead of closing it
// Use this connection's specific timeout setting
const timeoutId = setTimeout(() => {
console.log(`Session ${currentSessionId} timed out, closing MUD connection`);
cleanupPersistentConnection(currentSessionId);
}, conn.settings.persistenceTimeoutMs);
persistentConnections.set(currentSessionId, {
socket: conn.socket,
mudHost,
mudPort,
useSSL,
timeoutId,
lastActivity: Date.now(),
messageBuffer: [],
bufferSize: 0,
settings: conn.settings // Store the connection's settings
});
console.log(`Session ${currentSessionId} will persist for ${conn.settings.persistenceTimeoutMs / 1000} seconds with settings: ${conn.settings.maxBufferMessages} msgs/${conn.settings.maxBufferSizeKB}KB`);
} else if (conn && conn.socket) {
// Fallback to immediate cleanup if needed
conn.socket.end();
}
// 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 - but only if it's not going to be persisted
const conn = connections.get(connectionId);
if (conn && conn.socket) {
conn.socket.end();
}
// Remove from connections map
connections.delete(connectionId);
clearTimeout(initializationTimer);
if (!session || explicitDisconnect || session.ws !== ws) return;
session.ws = null;
session.persistenceTimer = setTimeout(() => destroySession(session, 'resume_timeout'), PERSISTENCE_TIMEOUT_MS);
});
ws.on('error', () => ws.close());
});
// 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);
// Only handle WebSocket connections to /mud-ws
if (pathname === '/mud-ws') {
// Extract MUD server details from query parameters
const { host, port, useSSL } = query;
if (!host || !port) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
// Handle WebSocket upgrade
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, host, port, useSSL === 'true');
});
} else {
// For other upgrades (not to /mud-ws), close the connection
socket.destroy();
}
let pathname;
try { pathname = new URL(request.url || '/', 'http://localhost').pathname; } catch { return failUpgrade(socket, '400 Bad Request', 'Invalid URL'); }
if (pathname !== '/mud-ws') return failUpgrade(socket, '404 Not Found', 'Not found');
const origin = request.headers.origin;
if (typeof origin !== 'string' || !allowedOrigins.has(origin)) return failUpgrade(socket, '403 Forbidden', 'Origin not allowed');
wss.handleUpgrade(request, socket, head, (client) => wss.emit('connection', client, request));
});
// 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
// Use double the connection's specific timeout or default
const timeoutThreshold = (persistentConn.settings?.persistenceTimeoutMs || DEFAULT_PERSISTENCE_TIMEOUT) * 2;
if (now - persistentConn.lastActivity > timeoutThreshold) {
console.log(`Cleaning up abandoned session: ${sessionId}`);
cleanupPersistentConnection(sessionId);
}
}
}, DEFAULT_PERSISTENCE_TIMEOUT); // Run cleanup every default timeout period
// 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(`Default connection persistence timeout: ${DEFAULT_PERSISTENCE_TIMEOUT / 1000} seconds (configurable per connection)`);
});
export default server;
function shutdown() {
wss.close();
for (const session of [...sessions.values()]) destroySession(session, 'server_shutdown');
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5_000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
if (process.env.NODE_ENV !== 'test') server.listen(PORT, () => console.log(`MUD WebSocket proxy listening on port ${PORT}`));
export { server, validateConnectMessage, resolvePublicTarget, isDeniedAddress };
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isDeniedAddress, validateConnectMessage } from './websocket-server.js';
describe('proxy target policy', () => {
it('rejects malformed targets and abuse ports', () => {
expect(() => validateConnectMessage({ type: 'connect', host: 'example.org', port: 25, tls: false })).toThrow();
expect(() => validateConnectMessage({ type: 'connect', host: 'bad host', port: 4000, tls: false })).toThrow();
expect(validateConnectMessage({ type: 'connect', host: 'example.org', port: 4000, tls: true })).toMatchObject({ port: 4000, useTls: true });
});
it('blocks local, private, mapped, and documentation addresses', () => {
expect(isDeniedAddress('127.0.0.1')).toBe(true);
expect(isDeniedAddress('192.168.1.2')).toBe(true);
expect(isDeniedAddress('::1')).toBe(true);
expect(isDeniedAddress('::ffff:127.0.0.1')).toBe(true);
expect(isDeniedAddress('2001:db8::1')).toBe(true);
expect(isDeniedAddress('1.1.1.1')).toBe(false);
});
});
+5 -4
View File
@@ -8,15 +8,16 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('Starting WebSocket server');
const wsServer = spawn('node', ['src/websocket-server.js'], {
stdio: 'inherit',
shell: true,
shell: false,
cwd: __dirname
});
// Start the SvelteKit dev server
console.log('Starting SvelteKit dev server');
const sveltekit = spawn('npm', ['run', 'dev'], {
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const sveltekit = spawn(npmCommand, ['run', 'dev'], {
stdio: 'inherit',
shell: true,
shell: false,
cwd: __dirname
});
@@ -41,4 +42,4 @@ process.on('SIGTERM', () => {
wsServer.kill('SIGTERM');
sveltekit.kill('SIGTERM');
process.exit(0);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

-59
View File
@@ -1,59 +0,0 @@
{
"name": "SvelteMUD Client",
"short_name": "SvelteMUD",
"description": "A modern MUD client built with Svelte",
"start_url": "/",
"display": "standalone",
"background_color": "#282a36",
"theme_color": "#6272a4",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
-35
View File
@@ -1,35 +0,0 @@
// Check if service workers are supported
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
// Check for updates to the Service Worker
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('Service Worker update found!');
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New content is available, notify user
console.log('New version available! Reload to update.');
// Optionally, display a notification to the user
if (window.confirm('A new version of this app is available. Reload to update?')) {
window.location.reload();
}
} else {
// First time install
console.log('App is now available offline!');
}
}
});
});
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
});
}
-98
View File
@@ -1,98 +0,0 @@
// Service worker for SvelteMUD Client PWA
const CACHE_NAME = 'svelte-mud-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/global.css',
'/build/bundle.css',
'/build/bundle.js'
];
// Install event - cache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => {
return self.skipWaiting();
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => {
return self.clients.claim();
})
);
});
// Fetch event - serve from cache if available, otherwise fetch from network
self.addEventListener('fetch', (event) => {
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin) ||
event.request.method !== 'GET') {
return;
}
// For navigation requests (HTML pages), use a network-first strategy
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// For all other requests, use a cache-first strategy
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
// Clone the request because it's a one-time use stream
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then((response) => {
// Check if valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response because it's a one-time use stream
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
// Listen for messages from clients
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
+3
View File
@@ -4,6 +4,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
compilerOptions: {
compatibility: { componentApi: 4 }
},
kit: {
adapter: nodeAdapter({
+1 -1
View File
@@ -5,7 +5,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": true,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
+1 -18
View File
@@ -80,21 +80,4 @@ export default defineConfig({
server: {
// No proxy - we're using a standalone WebSocket server on port 3001
},
resolve: {
alias: {
events: 'events' // This helps with browser compatibility
}
},
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
rollupOptions: {
external: ['net'] // Exclude Node-specific modules
}
}
});
});