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 engine-strict=true
resolution-mode=highest
+9 -2
View File
@@ -1,10 +1,17 @@
mud.iamtalon.me { 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 # For WebSocket requests to /mud-ws, proxy to the WebSocket server on port 3001
@websocket { @websocket {
path /mud-ws* 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 # For all other requests, proxy to the SvelteKit app on port 3000
reverse_proxy svelte-mud:3000 reverse_proxy svelte-mud-app:3000
} }
+30 -52
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/) 1. Change `mud.iamtalon.me` in `docker-compose.yml` and `Caddyfile` to the actual public hostname.
- [Docker Compose](https://docs.docker.com/compose/install/) (usually included with Docker Desktop) 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
```
2. Build and start the container:
```bash
docker-compose up -d
```
3. Access the application:
- Web interface: http://localhost:3000
- WebSocket server: ws://localhost:3001/mud-ws
## Docker Commands
### Starting the Application
```bash ```bash
# Build and start in detached mode docker network create revproxy
docker-compose up -d
# Build and start with logs
docker-compose up
# Force rebuild
docker-compose up --build
``` ```
### Stopping the Application 3. Build and start both services:
```bash ```bash
# Stop containers docker compose up --build -d
docker-compose down
``` ```
### Viewing Logs 4. Inspect health and logs:
```bash ```bash
# View logs docker compose ps
docker-compose logs -f docker compose logs -f
``` ```
## Caddy Configuration Stop the deployment with `docker compose down`.
For use with Caddy as a reverse proxy, use this simple configuration: ## 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.
mud.example.com {
reverse_proxy svelte-mud:3000 `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
``` ```
Both the web interface and WebSocket connections will be routed correctly through this single reverse proxy rule. Do not publish port 3001 publicly. Origin checks are a browser boundary, not an authentication mechanism.
## Troubleshooting ## Updating
If you encounter any issues, check the container logs:
```bash ```bash
docker-compose logs -f docker compose build --pull
docker compose up -d
``` ```
The containers run as an unprivileged user and include independent health checks for the web app and proxy.
+6 -5
View File
@@ -1,7 +1,7 @@
# Multi-stage build Dockerfile for Svelte MUD client # Multi-stage build Dockerfile for Svelte MUD client
# Build stage # Build stage
FROM node:20-alpine AS build FROM node:22-alpine AS build
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -10,7 +10,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm install RUN npm ci
# Copy source files # Copy source files
COPY . . COPY . .
@@ -19,7 +19,7 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM node:20-alpine AS production FROM node:22-alpine AS production
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -30,7 +30,7 @@ RUN addgroup -g 1001 -S nodejs && \
# Install only production dependencies # Install only production dependencies
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm ci --omit=dev
# Copy built application from the build stage # Copy built application from the build stage
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
@@ -43,5 +43,6 @@ USER nodejs
# Set environment variables # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
# Start both servers using the production script # 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"] 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 ## Features
### Core Functionality - Multiple persistent connection tabs, including background output
- WebSocket to Telnet proxy for connecting to MUD servers - Incremental Telnet parsing and GMCP negotiation
- Multiple simultaneous MUD connections via an MDI (Multiple Document Interface) - ANSI and 256-color output
- ANSI color support - Plain-text and constrained regular-expression triggers
- Command history - Trigger highlighting, sounds, and command sending
- Configurable profiles for different MUD servers - Per-profile command/output history
- Auto-login functionality - High contrast, text-to-speech, font scaling, and keyboard navigation
- Progressive Web App (PWA) support for offline use and installation - PWA installation and generated application icons
### GMCP Support ## Development
- 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
### Triggers System Requires Node.js 22 or newer.
- 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
```bash ```bash
# Clone the repository npm ci
git clone https://your-repo-url/svelte-mud.git npm run dev:full
cd svelte-mud
# Install dependencies
npm install
# Start the development server
npm run dev
# Build for production
npm run build
``` ```
## 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 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).
- `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
## Configuration ## Data migration
The client can be configured through the UI, with settings stored in local browser storage: 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.
- MUD server profiles
- Trigger patterns and actions
- UI preferences (dark mode, font size, etc.)
- Accessibility settings
## 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. - `src/lib/connection/` — WebSocket control protocol and incremental Telnet parser
- `src/lib/gmcp/` — GMCP packages and routing
## Progressive Web App (PWA) Support - `src/lib/triggers/` — trigger validation and execution
- `src/lib/profiles/` — profile storage and in-memory credentials
SvelteMUD is configured as a Progressive Web App, allowing users to install it on their devices and use it offline: - `src/lib/stores/` — per-profile application state
- `src/websocket-server.js` — constrained WebSocket-to-Telnet proxy
### Features - `static/icons/` — generated PWA icons
- **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`
## License ## License
This project is licensed under the [MIT License](LICENSE). [MIT](LICENSE)
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+33 -8
View File
@@ -1,20 +1,45 @@
version: '3.8'
services: services:
svelte-mud: svelte-mud-app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: svelte-mud image: svelte-mud:local
container_name: svelte-mud-app
command: ["node", "build/index.js"]
restart: unless-stopped restart: unless-stopped
networks: networks:
- revproxy - revproxy
environment: environment:
- NODE_ENV=production NODE_ENV: production
# No need to publish ports to host, but expose them to container network
expose: expose:
- 3000 - "3000"
- 3001 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 # Define networks to connect to external services
networks: networks:
+6 -3
View File
@@ -2,9 +2,12 @@
// Requires: npm install sharp // Requires: npm install sharp
// Usage: node generate-icons.js // Usage: node generate-icons.js
const fs = require('fs'); import fs from 'node:fs';
const path = require('path'); import path from 'node:path';
const sharp = require('sharp'); import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Define icon sizes needed // Define icon sizes needed
const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
+3025 -4596
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -10,36 +10,37 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "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", "start": "node run-production.js",
"generate-icons": "node generate-icons.js" "generate-icons": "node generate-icons.js"
}, },
"dependencies": { "dependencies": {
"@types/node": "^22.14.1", "@types/node": "^22.20.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"ansi-to-html": "^0.7.2",
"events": "^3.3.0",
"express": "^4.18.2",
"howler": "^2.2.4", "howler": "^2.2.4",
"net": "^1.0.2",
"split.js": "^1.6.5", "split.js": "^1.6.5",
"ws": "^8.18.1" "ws": "^8.21.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.70.3",
"@sveltejs/kit": "^2.5.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@types/howler": "^2.2.11", "@types/howler": "^2.2.11",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"sharp": "^0.33.2", "sharp": "^0.35.4",
"svelte": "^4.2.8", "svelte": "^5.57.0",
"svelte-check": "^3.6.2", "svelte-check": "^4.7.6",
"tailwindcss": "^3.3.6", "tailwindcss": "^3.3.6",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.3.3", "typescript": "^5.9.3",
"vite": "^5.0.10", "vite": "^8.2.2",
"vite-plugin-node-polyfills": "^0.19.0", "vite-plugin-pwa": "^1.3.0",
"vite-plugin-pwa": "^0.19.4" "vitest": "^4.1.11"
},
"overrides": {
"cookie": "^0.7.2"
} }
} }
+2 -2
View File
@@ -8,7 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('Starting WebSocket server'); console.log('Starting WebSocket server');
const wsServer = spawn('node', ['src/websocket-server.js'], { const wsServer = spawn('node', ['src/websocket-server.js'], {
stdio: 'inherit', stdio: 'inherit',
shell: true, shell: false,
cwd: __dirname cwd: __dirname
}); });
@@ -16,7 +16,7 @@ const wsServer = spawn('node', ['src/websocket-server.js'], {
console.log('Starting SvelteKit production server'); console.log('Starting SvelteKit production server');
const sveltekit = spawn('node', ['build/index.js'], { const sveltekit = spawn('node', ['build/index.js'], {
stdio: 'inherit', stdio: 'inherit',
shell: true, shell: false,
cwd: __dirname cwd: __dirname
}); });
-4
View File
@@ -13,12 +13,8 @@
<meta name="apple-mobile-web-app-title" content="SvelteMUD" /> <meta name="apple-mobile-web-app-title" content="SvelteMUD" />
<!-- PWA Icons and Manifest --> <!-- PWA Icons and Manifest -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/icon-192x192.png" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
-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 isImporting = false;
let importError = ''; let importError = '';
let importSuccess = ''; let importSuccess = '';
let backupStats = null; let backupStats: { profileCount: number; triggerCount: number; hasSettings: boolean; timestamp: string } | null = null;
// Handle export button click // Handle export button click
async function handleExport() { async function handleExport() {
@@ -18,7 +18,7 @@
backupManager.exportBackup(); backupManager.exportBackup();
importSuccess = 'Backup exported successfully!'; importSuccess = 'Backup exported successfully!';
} catch (error) { } catch (error) {
importError = `Export failed: ${error.message}`; importError = `Export failed: ${error instanceof Error ? error.message : String(error)}`;
} finally { } finally {
isExporting = false; isExporting = false;
@@ -41,7 +41,7 @@
input.accept = '.json'; input.accept = '.json';
input.onchange = async (e) => { input.onchange = async (e) => {
const file = e.target.files?.[0]; const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
isImporting = true; isImporting = true;
@@ -51,7 +51,7 @@
reader.onload = async (event) => { reader.onload = async (event) => {
try { try {
const json = event.target.result as string; const json = event.target?.result as string;
await backupManager.importBackup(json); await backupManager.importBackup(json);
importSuccess = 'Backup imported successfully! The page will reload in 2 seconds.'; importSuccess = 'Backup imported successfully! The page will reload in 2 seconds.';
@@ -61,7 +61,7 @@
window.location.reload(); window.location.reload();
}, 2000); }, 2000);
} catch (error) { } catch (error) {
importError = `Import failed: ${error.message}`; importError = `Import failed: ${error instanceof Error ? error.message : String(error)}`;
isImporting = false; isImporting = false;
} }
}; };
@@ -73,7 +73,7 @@
reader.readAsText(file); reader.readAsText(file);
} catch (error) { } catch (error) {
importError = `Import failed: ${error.message}`; importError = `Import failed: ${error instanceof Error ? error.message : String(error)}`;
isImporting = false; isImporting = false;
} }
}; };
+15 -17
View File
@@ -7,20 +7,20 @@
// Props // Props
export let title = ''; export let title = '';
export let closable = true; export let closable = true;
export let component = null; export let component: any = null;
export let componentProps = {}; export let componentProps: Record<string, unknown> = {};
// State // State
let isOpen = false; let isOpen = false;
let modalContent; let modalContent: HTMLDivElement;
let componentInstance = null; let componentInstance: any = null;
// Event callbacks // Event callbacks
let onSubmitCallback = null; let onSubmitCallback: ((detail: any) => void) | null = null;
let onCancelCallback = null; let onCancelCallback: (() => void) | null = null;
// Handle component dispatch events // Handle component dispatch events
function handleComponentEvent(event) { function handleComponentEvent(event: { type: string; detail?: any }) {
if (event.type === 'save') { if (event.type === 'save') {
if (onSubmitCallback) { if (onSubmitCallback) {
onSubmitCallback(event.detail); onSubmitCallback(event.detail);
@@ -54,7 +54,7 @@
} }
// Set properties and callbacks // 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.title !== undefined) title = props.title;
if (props.closable !== undefined) closable = props.closable; if (props.closable !== undefined) closable = props.closable;
if (props.component !== undefined) component = props.component; if (props.component !== undefined) component = props.component;
@@ -93,7 +93,7 @@
// Listen for events from the component // Listen for events from the component
for (const event of ['save', 'cancel']) { 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) { } catch (error) {
console.error('Error creating component in modal:', error); console.error('Error creating component in modal:', error);
@@ -101,7 +101,7 @@
} }
// Close on ESC key // Close on ESC key
function handleKeydown(event) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && closable && isOpen) { if (event.key === 'Escape' && closable && isOpen) {
close(); close();
if (onCancelCallback) onCancelCallback(); if (onCancelCallback) onCancelCallback();
@@ -123,24 +123,22 @@
}); });
// Prevent clicks inside the modal from bubbling up // Prevent clicks inside the modal from bubbling up
function handleModalClick(event) { function handleModalClick(event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
} }
// Handle backdrop click // Handle backdrop click
function handleBackdropClick() { function handleBackdropClick(event: MouseEvent) {
if (closable) { if (event.target === event.currentTarget && closable) {
close(); close();
if (onCancelCallback) onCancelCallback(); if (onCancelCallback) onCancelCallback();
} }
} }
</script> </script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen} {#if isOpen}
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: 150 }}> <div class="modal-backdrop" role="presentation" on:click={handleBackdropClick} on:keydown={handleKeydown} transition:fade={{ duration: 150 }}>
<div class="modal-content" on:click={handleModalClick} transition:scale={{ start: 0.95, duration: 200 }}> <div class="modal-content" role="dialog" aria-modal="true" tabindex="-1" transition:scale={{ start: 0.95, duration: 200 }}>
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">{title}</h2> <h2 class="modal-title">{title}</h2>
{#if closable} {#if closable}
+78 -67
View File
@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte'; 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 { TriggerSystem } from '$lib/triggers/TriggerSystem';
import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager'; import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager';
import { import {
@@ -8,11 +11,15 @@
activeProfileId, activeProfileId,
activeProfile, activeProfile,
profiles, profiles,
addToOutputHistory, appendOutput,
updateGmcpData, updateGmcpData,
accessibilitySettings logGmcpMessage,
accessibilitySettings,
connections,
sensitiveInput
} from '$lib/stores/mudStore'; } from '$lib/stores/mudStore';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { credentialVault } from '$lib/profiles/CredentialVault';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -22,7 +29,7 @@
// Local state // Local state
let connectionManager: ConnectionManager; let connectionManager: ConnectionManager;
let connection = null; let connection: MudConnection | null = null;
let triggerSystem: TriggerSystem; let triggerSystem: TriggerSystem;
let accessibilityManager: AccessibilityManager; let accessibilityManager: AccessibilityManager;
let awaitingSessionResumed = false; let awaitingSessionResumed = false;
@@ -49,7 +56,19 @@
} }
// Cleanup functions // 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(() => { onMount(() => {
console.log(`MudConnection component mounted for profile: ${profileId}`); console.log(`MudConnection component mounted for profile: ${profileId}`);
@@ -89,6 +108,7 @@
}); });
onDestroy(() => { onDestroy(() => {
clearLoginTimers();
console.log(`MudConnection component being destroyed for profile: ${profileId}`); console.log(`MudConnection component being destroyed for profile: ${profileId}`);
// Remove keyboard listener // Remove keyboard listener
@@ -116,6 +136,9 @@
// Initialize trigger system // Initialize trigger system
triggerSystem = new TriggerSystem(); triggerSystem = new TriggerSystem();
triggerSystem.on('sendText', (text: string) => {
if (connection?.isConnected()) connection.send(text);
});
console.log('Trigger system created'); console.log('Trigger system created');
// Initialize accessibility manager // Initialize accessibility manager
@@ -166,7 +189,7 @@
/** /**
* Set up listeners for a specific connection * Set up listeners for a specific connection
*/ */
function setupConnectionListeners(conn) { function setupConnectionListeners(conn: MudConnection) {
console.log('Setting up connection listeners'); console.log('Setting up connection listeners');
// Remove any existing listeners to prevent duplicates // Remove any existing listeners to prevent duplicates
@@ -189,6 +212,7 @@
conn.on('session_resumed', handleSessionResumed); conn.on('session_resumed', handleSessionResumed);
conn.on('message_replay_start', handleMessageReplayStart); conn.on('message_replay_start', handleMessageReplayStart);
conn.on('message_replay_complete', handleMessageReplayComplete); conn.on('message_replay_complete', handleMessageReplayComplete);
conn.on('sensitiveInput', handleSensitiveInput);
console.log('Connection listeners attached successfully'); console.log('Connection listeners attached successfully');
} }
@@ -196,7 +220,7 @@
/** /**
* Remove listeners from a connection * Remove listeners from a connection
*/ */
function removeConnectionListeners(conn) { function removeConnectionListeners(conn: MudConnection | null) {
if (!conn) return; if (!conn) return;
conn.off('received', handleReceived); conn.off('received', handleReceived);
@@ -209,6 +233,7 @@
conn.off('session_resumed', handleSessionResumed); conn.off('session_resumed', handleSessionResumed);
conn.off('message_replay_start', handleMessageReplayStart); conn.off('message_replay_start', handleMessageReplayStart);
conn.off('message_replay_complete', handleMessageReplayComplete); conn.off('message_replay_complete', handleMessageReplayComplete);
conn.off('sensitiveInput', handleSensitiveInput);
} }
/** /**
@@ -226,7 +251,7 @@
const profile = allProfiles.find(p => p.id === profileId); const profile = allProfiles.find(p => p.id === profileId);
if (!profile) { if (!profile) {
addToOutputHistory(`Error: Profile ${profileId} not found.`); appendOutput(profileId, `Error: Profile ${profileId} not found.`);
console.error(`Profile ${profileId} not found`); console.error(`Profile ${profileId} not found`);
return; return;
} }
@@ -238,7 +263,7 @@
})); }));
if (get(activeProfileId) === profileId) { if (get(activeProfileId) === profileId) {
addToOutputHistory(`Connecting to ${profile.host}:${profile.port}...`); appendOutput(profileId, `Connecting to ${profile.host}:${profile.port}...`);
} }
// Connect using the connection manager // Connect using the connection manager
@@ -256,7 +281,7 @@
})); }));
if (get(activeProfileId) === profileId) { 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 * Disconnect from the MUD server
*/ */
export function disconnect() { export function disconnect() {
clearLoginTimers();
credentialVault.clear(profileId);
connectionManager.disconnect(profileId); connectionManager.disconnect(profileId);
} }
/** /**
* Handle connection established * Handle connection established
*/ */
function handleConnected() { function handleConnected(connectionInfo: { resumed?: boolean } = {}) {
// Find the profile // Find the profile
const profile = get(profiles).find(p => p.id === profileId); 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 // Only add to output history if this is the active profile
if (get(activeProfileId) === profileId) { 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 // Check if we're reconnecting to an existing session
const hasStoredSession = connection && connection.getSessionId(); if (!connectionInfo.resumed) {
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 {
// Fresh connection, proceed with autologin immediately // Fresh connection, proceed with autologin immediately
console.log('Fresh connection, proceeding with autologin'); console.log('Fresh connection, proceeding with autologin');
performAutoLogin(profile); performAutoLogin(profile);
@@ -308,26 +321,28 @@
/** /**
* Perform auto-login if enabled * Perform auto-login if enabled
*/ */
function performAutoLogin(profile) { function performAutoLogin(profile: MudProfile | undefined) {
// Handle auto-login if enabled // Handle auto-login if enabled
if (profile?.autoLogin?.enabled) { if (profile?.autoLogin?.enabled) {
setTimeout(() => { scheduleLogin(() => {
// Send username // Send username
if (profile.autoLogin?.username && connection) { if (profile.autoLogin?.username && connection) {
connection.send(profile.autoLogin.username); connection.send(profile.autoLogin.username);
} }
// Send password after a delay // Send password after a delay
if (profile.autoLogin?.password) { const password = credentialVault.getPassword(profileId) ?? window.prompt(`Password for ${profile.name} (kept only until this page closes):`) ?? '';
setTimeout(() => { if (password) {
if (connection) connection.send(profile.autoLogin?.password || ''); credentialVault.setPassword(profileId, password);
scheduleLogin(() => {
if (connection?.isConnected()) connection.send(password);
// Send additional commands // Send additional commands
if (profile.autoLogin?.commands && profile.autoLogin.commands.length > 0) { if (profile.autoLogin?.commands && profile.autoLogin.commands.length > 0) {
let delay = 500; let delay = 500;
profile.autoLogin.commands.forEach((cmd) => { profile.autoLogin.commands.forEach((cmd: string) => {
setTimeout(() => { scheduleLogin(() => {
if (connection) connection.send(cmd); if (connection?.isConnected()) connection.send(cmd);
}, delay); }, delay);
delay += 500; delay += 500;
}); });
@@ -342,11 +357,12 @@
* Handle connection closed * Handle connection closed
*/ */
function handleDisconnected() { function handleDisconnected() {
clearLoginTimers();
console.log(`Profile ${profileId} disconnected`); console.log(`Profile ${profileId} disconnected`);
// Only add to output history if this is the active profile // Only add to output history if this is the active profile
if (get(activeProfileId) === profileId) { if (get(activeProfileId) === profileId) {
addToOutputHistory('Disconnected from server.'); appendOutput(profileId, 'Disconnected from server.');
} }
dispatch('disconnected'); dispatch('disconnected');
@@ -355,17 +371,14 @@
/** /**
* Handle connection error * Handle connection error
*/ */
function handleError(error) { function handleError(error: unknown) {
console.log(`Profile ${profileId} connection error:`, error);
// Format the error message for display // Format the error message for display
const errorMessage = typeof error === 'object' ? const errorMessage = error instanceof Error ? error.message : String(error);
(error.message || JSON.stringify(error)) :
String(error);
// Only add to output history if this is the active profile // Only add to output history if this is the active profile
if (get(activeProfileId) === profileId) { if (get(activeProfileId) === profileId) {
addToOutputHistory(`Connection error: ${errorMessage}`, false, [ appendOutput(profileId, `Connection error: ${errorMessage}`, false, [
{ pattern: 'Connection error', color: '#ff5555', isRegex: false } { pattern: 'Connection error', color: '#ff5555', isRegex: false }
]); ]);
} }
@@ -376,7 +389,7 @@
/** /**
* Handle received data * Handle received data
*/ */
function handleReceived(text) { function handleReceived(text: string) {
console.log(`Profile ${profileId} received data`); console.log(`Profile ${profileId} received data`);
try { try {
@@ -386,13 +399,14 @@
let processedText = text; let processedText = text;
let isGagged = false; let isGagged = false;
let triggerMatched = false; let triggerMatched = false;
let triggerResult: TriggerResult = { processed: text, gagged: false, matched: false, highlights: [] };
if (triggerSystem) { if (triggerSystem) {
try { try {
const result = triggerSystem.processTriggers(text); triggerResult = triggerSystem.processTriggers(text);
processedText = result.processed; processedText = triggerResult.processed;
isGagged = result.gagged; isGagged = triggerResult.gagged;
triggerMatched = result.matched; triggerMatched = triggerResult.matched;
console.log(`Trigger processing result - gagged: ${isGagged}, matched: ${triggerMatched}, modified: ${processedText !== text}`); console.log(`Trigger processing result - gagged: ${isGagged}, matched: ${triggerMatched}, modified: ${processedText !== text}`);
} catch (error) { } catch (error) {
@@ -402,7 +416,7 @@
// Add to output history if not gagged // Add to output history if not gagged
if (!isGagged) { if (!isGagged) {
addToOutputHistory(processedText); appendOutput(profileId, processedText, false, triggerResult.highlights);
// Handle text-to-speech for processed text // Handle text-to-speech for processed text
console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`); console.log(`TTS check for ${profileId}: isTTS=${$accessibilitySettings.textToSpeech}, isActive=${isActiveProfile}, speakAll=${$accessibilitySettings.speakAllProfiles}`);
@@ -415,7 +429,6 @@
try { try {
// If not active profile, add profile name prefix for context // If not active profile, add profile name prefix for context
const speechText = isActiveProfile ? processedText : `From ${getProfileName(profileId)}: ${processedText}`; const speechText = isActiveProfile ? processedText : `From ${getProfileName(profileId)}: ${processedText}`;
console.log(`Speaking text for ${profileId}:`, speechText.substring(0, 50) + (speechText.length > 50 ? '...' : ''));
accessibilityManager.speak(speechText); accessibilityManager.speak(speechText);
} catch (error) { } catch (error) {
console.error('Error using text-to-speech:', error); console.error('Error using text-to-speech:', error);
@@ -446,7 +459,7 @@
/** /**
* Helper to get profile name for speech announcements * Helper to get profile name for speech announcements
*/ */
function getProfileName(id) { function getProfileName(id: string) {
const allProfiles = get(profiles); const allProfiles = get(profiles);
const profile = allProfiles.find(p => p.id === id); const profile = allProfiles.find(p => p.id === id);
return profile ? profile.name : id; return profile ? profile.name : id;
@@ -455,16 +468,20 @@
/** /**
* Handle sent data * Handle sent data
*/ */
function handleSent(text) { function handleSent(text: string) {
dispatch('sent', { text }); dispatch('sent', { text });
} }
function handleSensitiveInput(enabled: boolean) {
sensitiveInput.update((values) => ({ ...values, [profileId]: enabled }));
}
/** /**
* Handle GMCP message * Handle GMCP message
*/ */
function handleGmcp(module, data) { function handleGmcp(module: string, data: unknown) {
console.log(`GMCP message received for ${profileId}: ${module}`, data); updateGmcpData(profileId, module, data);
updateGmcpData(module, data); logGmcpMessage(profileId, module, data);
// Forward GMCP events // Forward GMCP events
dispatch('gmcp', { module, data }); dispatch('gmcp', { module, data });
@@ -473,16 +490,14 @@
/** /**
* Handle play sound event * Handle play sound event
*/ */
function handlePlaySound(options) { function handlePlaySound(options: { url: string; volume: number; loop: boolean }) {
console.log(`Play sound event for ${profileId}:`, options);
dispatch('playSound', options); dispatch('playSound', options);
} }
/** /**
* Handle session resumed event * Handle session resumed event
*/ */
function handleSessionResumed(data) { function handleSessionResumed(data: { messagesReplayed: number }) {
console.log(`Session resumed for ${profileId}:`, data);
// We successfully resumed a session, so don't perform autologin // We successfully resumed a session, so don't perform autologin
if (awaitingSessionResumed) { if (awaitingSessionResumed) {
@@ -492,9 +507,9 @@
if (data.messagesReplayed > 0) { if (data.messagesReplayed > 0) {
// Add a system message to the output to notify the user // 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 { } else {
addToOutputHistory('[SYSTEM] Reconnected to MUD.', false); appendOutput(profileId, '[SYSTEM] Reconnected to MUD.', false);
} }
dispatch('sessionResumed', data); dispatch('sessionResumed', data);
@@ -503,13 +518,9 @@
/** /**
* Handle message replay start event * Handle message replay start event
*/ */
function handleMessageReplayStart(data) { function handleMessageReplayStart(data: { messageCount: number; timespan?: number }) {
console.log(`Message replay starting for ${profileId}:`, data);
const timeAgo = Math.round(data.timespan / 1000); appendOutput(profileId, `[SYSTEM] Replaying ${data.messageCount} buffered messages...`, false);
const timeUnit = timeAgo > 60 ? `${Math.round(timeAgo / 60)} minutes` : `${timeAgo} seconds`;
addToOutputHistory(`[SYSTEM] Replaying ${data.messageCount} messages from the last ${timeUnit}...`, false);
dispatch('messageReplayStart', data); dispatch('messageReplayStart', data);
} }
@@ -518,7 +529,7 @@
*/ */
function handleMessageReplayComplete() { function handleMessageReplayComplete() {
console.log(`Message replay complete for ${profileId}`); 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'); dispatch('messageReplayComplete');
} }
</script> </script>
+11 -6
View File
@@ -68,7 +68,6 @@
try { try {
// Get the profiles from the store // Get the profiles from the store
const allProfiles = $profiles || []; const allProfiles = $profiles || [];
console.log('Initializing tabs with profiles:', allProfiles);
if (allProfiles.length === 0) { if (allProfiles.length === 0) {
console.warn('No profiles available to create tabs'); console.warn('No profiles available to create tabs');
@@ -106,7 +105,8 @@
// Auto-connect if enabled // Auto-connect if enabled
if (autoConnectOnStart && tabs.length > 0 && !$connectionStatus[activeTab]) { if (autoConnectOnStart && tabs.length > 0 && !$connectionStatus[activeTab]) {
console.log(`Auto-connecting to tab: ${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 // Make sure the connection store is updated with the active profile
const connectionManager = ConnectionManager.getInstance(); 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 // Update the connections store - this ensures the connection is associated with the profile ID
if (existingConnection) { if (existingConnection) {
@@ -257,7 +257,6 @@
// Update when profiles change or active profile changes // Update when profiles change or active profile changes
$: if ($profiles) { $: if ($profiles) {
console.log('Profiles updated in store, reinitializing tabs:', $profiles);
initializeTabs(); initializeTabs();
} }
@@ -332,10 +331,12 @@
</div> </div>
</div> </div>
{:else} {:else}
<!-- Only render the active tab --> <!-- Keep every connection component mounted so background sessions retain their listeners. -->
{#each safeTabs.filter(tab => tab.id === activeTab) as tab (tab.id)} {#each safeTabs as tab (tab.id)}
<div <div
class="mud-mdi-pane" class="mud-mdi-pane"
class:active={tab.id === activeTab}
hidden={tab.id !== activeTab}
role="tabpanel" role="tabpanel"
id={`panel-${tab.id}`} id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`} aria-labelledby={`tab-${tab.id}`}
@@ -514,6 +515,10 @@
overflow: hidden; /* Prevent overflow issues */ overflow: hidden; /* Prevent overflow issues */
} }
.mud-mdi-pane[hidden] {
display: none;
}
.mud-mdi-pane-header { .mud-mdi-pane-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+10 -16
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte'; 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 { tick } from 'svelte';
import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager'; import { AccessibilityManager } from '$lib/accessibility/AccessibilityManager';
import AriaLiveAnnouncer from '$lib/accessibility/AriaLiveAnnouncer.svelte'; import AriaLiveAnnouncer from '$lib/accessibility/AriaLiveAnnouncer.svelte';
import { segmentStyle } from '$lib/utils/textProcessing';
// Create safe defaults for reactivity // Create safe defaults for reactivity
$: safeRenderableLines = $activeRenderableLines || []; $: safeRenderableLines = $activeRenderableLines || [];
@@ -43,12 +44,10 @@
accessibilityManager.stopSpeech(); accessibilityManager.stopSpeech();
} }
// Add to input history if (!$activeSensitiveInput) addToInputHistory(currentInput);
addToInputHistory(currentInput);
// Show the command in the output (only if not password - for privacy) // 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 (!$activeSensitiveInput) {
if (!isPassword) {
addToOutputHistory(`> ${currentInput}`, true); addToOutputHistory(`> ${currentInput}`, true);
} else { } else {
addToOutputHistory(`> ********`, true); addToOutputHistory(`> ********`, true);
@@ -64,14 +63,10 @@
if (status === 'connected') { if (status === 'connected') {
try { try {
// Try using the activeConnection first
if ($activeConnection) { if ($activeConnection) {
$activeConnection.send(currentInput); $activeConnection.send(currentInput);
} else { } else {
// If not available, use the ConnectionManager directly throw new Error('Active connection is unavailable.');
const { ConnectionManager } = await import('$lib/connection/ConnectionManager');
const connectionManager = ConnectionManager.getInstance();
connectionManager.send(profileId, currentInput);
} }
} catch (error) { } catch (error) {
console.error('Error sending command:', error); console.error('Error sending command:', error);
@@ -252,10 +247,7 @@
const newContent = recentLines const newContent = recentLines
.filter(line => !line.isInput) // Don't announce input echoes .filter(line => !line.isInput) // Don't announce input echoes
.map(line => { .map(line => {
// Strip HTML tags to get plain text return line.content;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = line.content;
return tempDiv.textContent || tempDiv.innerText || '';
}) })
.join(' ') .join(' ')
.trim(); .trim();
@@ -352,7 +344,9 @@
<span class="mud-timestamp" aria-hidden="true">[{formatTimestamp(line.timestamp)}]</span> <span class="mud-timestamp" aria-hidden="true">[{formatTimestamp(line.timestamp)}]</span>
{/if} {/if}
<div class="mud-terminal-content"> <div class="mud-terminal-content">
{@html line.content} {#each line.segments as segment}
<span style={segmentStyle(segment)}>{segment.text}</span>
{/each}
</div> </div>
</div> </div>
{/each} {/each}
@@ -360,7 +354,7 @@
<form class="mud-terminal-input-form" on:submit={handleSubmit} aria-label="MUD command input form"> <form class="mud-terminal-input-form" on:submit={handleSubmit} aria-label="MUD command input form">
<input <input
type="text" type={$activeSensitiveInput ? 'password' : 'text'}
class="mud-terminal-input" class="mud-terminal-input"
bind:this={inputElement} bind:this={inputElement}
bind:value={currentInput} bind:value={currentInput}
+2 -10
View File
@@ -19,7 +19,6 @@
autoLogin: { autoLogin: {
enabled: false, enabled: false,
username: '', username: '',
password: '',
commands: [] commands: []
}, },
accessibilityOptions: { accessibilityOptions: {
@@ -36,8 +35,6 @@
// Local state - merge default with provided profile // Local state - merge default with provided profile
let localProfile = { ...defaultProfile, ...profile }; let localProfile = { ...defaultProfile, ...profile };
console.log('Initialized profile editor with:', localProfile);
// Extract nested objects for easier binding // Extract nested objects for easier binding
let autoLogin = { let autoLogin = {
...defaultProfile.autoLogin, ...defaultProfile.autoLogin,
@@ -53,7 +50,6 @@
// Handle form submission // Handle form submission
function handleSubmit() { function handleSubmit() {
console.log('Saving profile:', localProfile);
// Update local profile // Update local profile
localProfile.autoLogin = autoLogin; localProfile.autoLogin = autoLogin;
localProfile.accessibilityOptions = accessibilityOptions; localProfile.accessibilityOptions = accessibilityOptions;
@@ -129,13 +125,10 @@
<input type="text" id="username" bind:value={autoLogin.username} /> <input type="text" id="username" bind:value={autoLogin.username} />
</div> </div>
<div class="form-group"> <p class="credential-note">For security, the password is requested when connecting and is kept only in memory.</p>
<label for="password">Password</label>
<input type="password" id="password" bind:value={autoLogin.password} />
</div>
<div class="form-group"> <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"> <div class="commands-list" role="list" aria-labelledby="commands-label">
{#each autoLogin.commands as command, index} {#each autoLogin.commands as command, index}
<div class="command-item" role="listitem"> <div class="command-item" role="listitem">
@@ -254,7 +247,6 @@
input[type="text"], input[type="text"],
input[type="number"], input[type="number"],
input[type="password"],
select { select {
width: 100%; width: 100%;
padding: 8px; padding: 8px;
+1 -2
View File
@@ -16,7 +16,6 @@
try { try {
ModalHelper.showProfileEditor( ModalHelper.showProfileEditor(
(profile) => { (profile) => {
console.log('Profile saved from profile editor:', profile);
dispatch('saveProfile', { profile }); dispatch('saveProfile', { profile });
}, },
() => { () => {
@@ -25,7 +24,7 @@
); );
} catch (error) { } catch (error) {
console.error('Error showing profile editor:', 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)));
} }
} }
+18 -63
View File
@@ -4,12 +4,7 @@
import { settingsManager } from '$lib/settings/SettingsManager'; import { settingsManager } from '$lib/settings/SettingsManager';
import BackupPanel from './BackupPanel.svelte'; import BackupPanel from './BackupPanel.svelte';
// Declare global window property for volume debounce let volumeDebounceTimeout: ReturnType<typeof setTimeout> | undefined;
declare global {
interface Window {
volumeDebounceTimeout?: number;
}
}
// Reset settings to defaults // Reset settings to defaults
function resetSettings() { function resetSettings() {
@@ -41,16 +36,16 @@
input.accept = '.json'; input.accept = '.json';
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files?.[0]; const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
try { try {
const json = event.target.result as string; const json = event.target?.result as string;
settingsManager.importSettings(json); settingsManager.importSettings(json);
} catch (error) { } 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" step="0.1"
on:input={(e) => { on:input={(e) => {
// Debounce volume changes to avoid performance issues with rapid changes // Debounce volume changes to avoid performance issues with rapid changes
if (window.volumeDebounceTimeout) { if (volumeDebounceTimeout) {
clearTimeout(window.volumeDebounceTimeout); clearTimeout(volumeDebounceTimeout);
} }
// Read value directly from the input // 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 // Update the store with a slight delay to avoid excessive updates
window.volumeDebounceTimeout = setTimeout(() => { volumeDebounceTimeout = setTimeout(() => {
uiSettings.update(settings => ({ uiSettings.update(settings => ({
...settings, ...settings,
globalVolume: newVolume globalVolume: newVolume
@@ -146,58 +141,18 @@
</div> </div>
</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> <h4>Connection</h4>
<div class="setting-item"> <div class="setting-description">Disconnected sessions are retained for five minutes. Buffer and quota limits are enforced by the proxy.</div>
<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>
<h4>Debugging</h4> <h4>Debugging</h4>
+3 -3
View File
@@ -68,7 +68,7 @@
aria-controls="panel-profiles" aria-controls="panel-profiles"
aria-selected={activeTab === 'profiles'} aria-selected={activeTab === 'profiles'}
class:active={activeTab === 'profiles'} class:active={activeTab === 'profiles'}
tabindex={activeTab === 'profiles' ? "0" : "-1"} tabindex={activeTab === 'profiles' ? 0 : -1}
on:click={() => dispatch('tabChange', { tab: 'profiles' })} on:click={() => dispatch('tabChange', { tab: 'profiles' })}
on:keydown={handleSidebarTabKeydown}> on:keydown={handleSidebarTabKeydown}>
Profiles Profiles
@@ -80,7 +80,7 @@
aria-controls="panel-triggers" aria-controls="panel-triggers"
aria-selected={activeTab === 'triggers'} aria-selected={activeTab === 'triggers'}
class:active={activeTab === 'triggers'} class:active={activeTab === 'triggers'}
tabindex={activeTab === 'triggers' ? "0" : "-1"} tabindex={activeTab === 'triggers' ? 0 : -1}
on:click={() => dispatch('tabChange', { tab: 'triggers' })} on:click={() => dispatch('tabChange', { tab: 'triggers' })}
on:keydown={handleSidebarTabKeydown}> on:keydown={handleSidebarTabKeydown}>
Triggers Triggers
@@ -92,7 +92,7 @@
aria-controls="panel-settings" aria-controls="panel-settings"
aria-selected={activeTab === 'settings'} aria-selected={activeTab === 'settings'}
class:active={activeTab === 'settings'} class:active={activeTab === 'settings'}
tabindex={activeTab === 'settings' ? "0" : "-1"} tabindex={activeTab === 'settings' ? 0 : -1}
on:click={() => dispatch('tabChange', { tab: 'settings' })} on:click={() => dispatch('tabChange', { tab: 'settings' })}
on:keydown={handleSidebarTabKeydown}> on:keydown={handleSidebarTabKeydown}>
Settings Settings
+5 -10
View File
@@ -1,15 +1,11 @@
<script> <script lang="ts">
export let show = true; // Force modal to be visible by default export let show = true; // Force modal to be visible by default
function closeModal() { function closeModal(e?: MouseEvent) {
show = false; if (!e || e.target === e.currentTarget) show = false;
} }
function stopPropagation(e) { function handleKeydown(e: KeyboardEvent) {
e.stopPropagation();
}
function handleKeydown(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeModal(); closeModal();
} }
@@ -22,12 +18,11 @@
on:click={closeModal} on:click={closeModal}
on:keydown={handleKeydown} on:keydown={handleKeydown}
role="dialog" role="dialog"
tabindex="-1"
aria-modal="true" aria-modal="true"
> >
<div <div
class="modal-content" class="modal-content"
on:click={stopPropagation}
on:keydown={handleKeydown}
role="document" role="document"
> >
<slot></slot> <slot></slot>
@@ -14,7 +14,6 @@
}; };
function handleSubmit() { function handleSubmit() {
console.log('Saving profile:', profile);
dispatch('save', { profile }); dispatch('save', { profile });
} }
+2 -7
View File
@@ -150,7 +150,7 @@
max="1" max="1"
step="0.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> </div>
<small>Trigger sound volume is multiplied by global volume setting</small> <small>Trigger sound volume is multiplied by global volume setting</small>
</div> </div>
@@ -161,7 +161,7 @@
<select <select
id="textAction" id="textAction"
on:change={(e) => { on:change={(e) => {
const val = e.target.value; const val = (e.currentTarget as HTMLSelectElement).value;
if (val === 'gag') { if (val === 'gag') {
localTrigger.gag = true; localTrigger.gag = true;
localTrigger.replaceText = ''; localTrigger.replaceText = '';
@@ -209,11 +209,6 @@
<input type="color" id="highlightColor" bind:value={localTrigger.highlightColor} /> <input type="color" id="highlightColor" bind:value={localTrigger.highlightColor} />
</div> </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>
<fieldset> <fieldset>
+2 -2
View File
@@ -28,7 +28,7 @@
); );
} catch (error) { } catch (error) {
console.error('Error showing trigger editor:', 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) { } catch (error) {
console.error('Error showing trigger editor:', 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)));
} }
} }
+6 -9
View File
@@ -1,9 +1,8 @@
import { writable, get } from 'svelte/store'; import { get } from 'svelte/store';
import { MudConnection } from './MudConnection'; import { MudConnection } from './MudConnection';
import { connectionStatus } from '$lib/stores/mudStore'; import { connectionStatus, connections } from '$lib/stores/mudStore';
// Simple store for active connections export { connections } from '$lib/stores/mudStore';
export const connections = writable<Record<string, MudConnection>>({});
/** /**
* ConnectionManager - Singleton service to manage MUD connections * ConnectionManager - Singleton service to manage MUD connections
@@ -55,10 +54,11 @@ export class ConnectionManager {
// Check if a connection already exists for this profile // Check if a connection already exists for this profile
const existingConnection = this.getConnection(profileId); const existingConnection = this.getConnection(profileId);
if (existingConnection) { if (existingConnection && existingConnection.matchesTarget({ host, port, useSSL })) {
console.log(`Connection already exists for profile ${profileId}`); console.log(`Connection already exists for profile ${profileId}`);
return existingConnection; return existingConnection;
} }
if (existingConnection) this.removeConnection(profileId);
// Create a new connection with the profile ID as the connection ID // Create a new connection with the profile ID as the connection ID
console.log(`Creating new connection for profile ${profileId}`); console.log(`Creating new connection for profile ${profileId}`);
@@ -149,10 +149,7 @@ export class ConnectionManager {
const connection = this.getConnection(profileId); const connection = this.getConnection(profileId);
if (connection) { if (connection) {
// Disconnect first if needed
if (connection.isConnected()) {
connection.disconnect(); connection.disconnect();
}
// Remove from store // Remove from store
connections.update(conns => { connections.update(conns => {
@@ -199,7 +196,7 @@ export class ConnectionManager {
}); });
// Handle connection error // Handle connection error
connection.on('error', (error) => { connection.on('error', (error: unknown) => {
console.error(`Connection error for profile ${profileId}:`, error); console.error(`Connection error for profile ${profileId}:`, error);
// Update connection status // Update connection status
+175 -663
View File
@@ -1,19 +1,6 @@
import { EventEmitter } from '$lib/utils/EventEmitter';
import { GmcpHandler } from '$lib/gmcp/GmcpHandler'; import { GmcpHandler } from '$lib/gmcp/GmcpHandler';
import { get } from 'svelte/store'; import { EventEmitter } from '$lib/utils/EventEmitter';
import { connectionSettings } from '$lib/stores/mudStore'; import { TELNET, TelnetParser } from './TelnetParser';
// 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
}
export interface MudConnectionOptions { export interface MudConnectionOptions {
id: string; id: string;
@@ -22,699 +9,224 @@ export interface MudConnectionOptions {
useSSL?: boolean; useSSL?: boolean;
} }
// Connection persistence state export type MudConnectionState = 'idle' | 'connecting' | 'connected' | 'resuming' | 'disconnecting' | 'error';
interface ConnectionPersistence {
sessionId?: string; interface ProxyControlMessage {
reconnectAttempts: number; type: string;
maxReconnectAttempts: number; resumeToken?: string;
reconnectDelay: number; messageCount?: number;
lastDisconnectTime?: 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 { 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; public readonly id: string;
private readonly host: string;
// Connection persistence properties private readonly port: number;
private persistence: ConnectionPersistence = { private readonly useSSL: boolean;
reconnectAttempts: 0, private webSocket: WebSocket | null = null;
maxReconnectAttempts: 3, private state: MudConnectionState = 'idle';
reconnectDelay: 5000 // 5 seconds private explicitDisconnect = false;
}; private reconnectAttempts = 0;
private reconnectTimeoutId: number | null = null; private reconnectTimer: number | null = null;
private explicitDisconnect: boolean = false; private resumeToken?: string;
private gmcpEnabled = false;
private readonly gmcpHandler: GmcpHandler;
private readonly parser: TelnetParser;
constructor(options: MudConnectionOptions) { constructor(options: MudConnectionOptions) {
super(); super();
this.id = options.id;
this.host = options.host; this.host = options.host;
this.port = options.port; this.port = options.port;
this.useSSL = options.useSSL || false; this.useSSL = options.useSSL ?? false;
this.id = options.id; this.resumeToken = this.loadResumeToken();
// Create GMCP handler
this.gmcpHandler = new GmcpHandler(); this.gmcpHandler = new GmcpHandler();
this.parser = new TelnetParser({
// Set up GMCP event forwarding onText: (text) => this.emit('received', text),
this.setupGmcpEvents(); onNegotiation: (command, option) => this.handleNegotiation(command, option),
onSubnegotiation: (option, payload) => this.handleSubnegotiation(option, payload),
// Try to restore session from localStorage onProtocolError: (message) => this.emit('error', message)
this.loadStoredSession(); });
this.gmcpHandler.on('gmcp', (module: string, data: unknown) => this.emit('gmcp', module, data));
console.log(`MudConnection created for ${this.host}:${this.port} with ID ${this.id}`); 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));
} }
/**
* 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);
});
// 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);
});
}
/**
* Connect to the MUD server
*/
public connect(): void { public connect(): void {
if (this.connected) { if (this.webSocket && (this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING)) return;
console.log(`Already connected to ${this.host}:${this.port}`);
return;
}
// Reset explicit disconnect flag
this.explicitDisconnect = false; this.explicitDisconnect = false;
this.setState(this.resumeToken ? 'resuming' : 'connecting');
// Determine the WebSocket URL based on environment const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const authority = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
let wsUrl; ? `${window.location.hostname}:3001`
: window.location.host;
// In development, use port 3001 const socket = new WebSocket(`${protocol}://${authority}/mud-ws`);
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { socket.binaryType = 'arraybuffer';
wsUrl = `${wsProtocol}://${window.location.hostname}:3001/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`; this.webSocket = socket;
} else { socket.onopen = () => socket.send(JSON.stringify({
// In production, use the same domain & port as the web app type: 'connect', host: this.host, port: this.port, tls: this.useSSL, resumeToken: this.resumeToken
wsUrl = `${wsProtocol}://${window.location.host}/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`; }));
} socket.onmessage = (event) => this.handleWebSocketMessage(event.data);
socket.onerror = () => {
// Include session ID in URL if we have one (for reconnection) this.setState('error');
if (this.persistence.sessionId) { this.emit('error', `WebSocket connection to the proxy failed for ${this.host}:${this.port}.`);
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);
}; };
socket.onclose = () => {
this.webSocket.onclose = () => { if (this.webSocket === socket) this.webSocket = null;
this.connected = false; if (this.explicitDisconnect) {
console.log(`Disconnected from ${this.host}:${this.port}`); this.setState('idle');
this.emit('disconnected'); this.emit('disconnected');
// Handle reconnection if not explicitly disconnected
if (!this.explicitDisconnect) {
this.persistence.lastDisconnectTime = Date.now();
this.handleReconnect();
}
};
this.webSocket.onerror = (error) => {
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 { } else {
// Text data - let listeners process it directly this.setState('idle');
// TriggerSystem will handle gagging and replacing in the component this.emit('disconnected');
this.updateStoredSessionActivity(); this.scheduleReconnect();
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);
} }
}; };
} }
/**
* 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 { public disconnect(): void {
this.explicitDisconnect = true; // Set flag for explicit disconnect this.explicitDisconnect = true;
this.clearReconnectTimer();
// Signal to server that this is an explicit disconnect this.clearResumeToken();
if (this.connected && this.webSocket && this.webSocket.readyState === WebSocket.OPEN) { this.setState('disconnecting');
try { const socket = this.webSocket;
this.webSocket.send('[SYSTEM]{"type":"explicit_disconnect"}'); if (!socket) {
} catch (error) { this.setState('idle');
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;
}
}
/**
* 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;
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; return;
} }
if (socket.readyState === WebSocket.OPEN) {
// Process each byte in the incoming data socket.send(JSON.stringify({ type: 'disconnect' }));
for (let i = 0; i < data.length; i++) { window.setTimeout(() => { if (socket.readyState < WebSocket.CLOSING) socket.close(); }, 1_000);
const byte = data[i]; } else socket.close();
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 public send(text: string): void {
if (byte === TelnetCommand.SB) { this.sendBytes(new TextEncoder().encode(`${text}\r\n`));
// Start of subnegotiation this.emit('sent', text);
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 public sendGmcp(module: string, data: unknown): void {
this.processSimpleTelnetCommand(); if (!this.gmcpEnabled) return;
this.isInIAC = false; const payload = new TextEncoder().encode(`${module} ${JSON.stringify(data)}`);
this.negotiationBuffer = []; 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);
} }
} else if (byte === TelnetCommand.IAC) {
// Start of telnet command public getGmcpHandler(): GmcpHandler { return this.gmcpHandler; }
this.isInIAC = true; public isConnected(): boolean { return this.state === 'connected'; }
this.negotiationBuffer = [byte]; public getState(): MudConnectionState { return this.state; }
console.log('IAC command detected'); public getSessionId(): string | undefined { return this.resumeToken; }
} else { public matchesTarget(options: { host: string; port: number; useSSL?: boolean }): boolean {
// Normal data byte, add to buffer return this.host === options.host && this.port === options.port && this.useSSL === (options.useSSL ?? false);
this.buffer.push(byte); }
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;
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 any complete text in the buffer private handleNegotiation(command: number, option: number): void {
if (this.buffer.length > 0) { if (command === TELNET.WILL) {
const text = new TextDecoder().decode(new Uint8Array(this.buffer)); if (option === TELNET.GMCP) {
this.buffer = []; this.sendIac(TELNET.DO, option);
if (!this.gmcpEnabled) {
// Emit the received text for display and trigger processing this.gmcpEnabled = true;
this.emit('received', text);
}
}
/**
* 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(); 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);
}
} else { } 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);
} }
/** private handleSubnegotiation(option: number, payload: Uint8Array): void {
* Send a telnet IAC sequence if (option === TELNET.GMCP && payload.length <= 64 * 1024) this.gmcpHandler.handleGmcpMessage(new TextDecoder().decode(payload));
*/
private sendIAC(command: TelnetCommand, option: TelnetCommand): void {
if (!this.connected || !this.webSocket) {
return;
} }
const data = new Uint8Array([TelnetCommand.IAC, command, option]); 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); this.webSocket.send(data);
} }
private setState(state: MudConnectionState): void { this.state = state; this.emit('stateChanged', state); }
/** private scheduleReconnect(): void {
* Send a GMCP message if (this.explicitDisconnect || this.reconnectAttempts >= 3) return;
*/ const delay = 5_000 * Math.pow(1.5, this.reconnectAttempts++);
public sendGmcp(module: string, data: any): void { this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = null; this.connect(); }, delay);
if (!this.connected || !this.webSocket) {
console.log('Cannot send GMCP - not connected');
return;
} }
private clearReconnectTimer(): void {
console.log(`Sending GMCP: ${module}`, data); if (this.reconnectTimer !== null) window.clearTimeout(this.reconnectTimer);
const gmcpString = `${module} ${JSON.stringify(data)}`; this.reconnectTimer = null;
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 sessionKey(): string { return `mudResume:${this.id}`; }
/** private loadResumeToken(): string | undefined {
* Get the GMCP handler associated with this connection if (typeof sessionStorage === 'undefined') return undefined;
*/ const legacyKey = `mudSession_${this.id}`;
public getGmcpHandler(): GmcpHandler { localStorage.removeItem(legacyKey);
return this.gmcpHandler; return sessionStorage.getItem(this.sessionKey()) ?? undefined;
} }
private storeResumeToken(token?: string): void {
/** if (!token) return;
* Check if the connection is active this.resumeToken = token;
*/ sessionStorage.setItem(this.sessionKey(), token);
public isConnected(): boolean {
return this.connected;
} }
private clearResumeToken(): void {
/** this.resumeToken = undefined;
* Handle reconnection logic if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(this.sessionKey());
*/
private handleReconnect(): void {
// If too much time has passed since disconnect, don't attempt to reconnect with session
if (this.persistence.lastDisconnectTime &&
Date.now() - this.persistence.lastDisconnectTime > 5 * 60 * 1000) { // 5 minutes
console.log('Too much time has passed, clearing session for fresh connection');
this.persistence.sessionId = undefined;
this.persistence.reconnectAttempts = 0;
} }
if (this.persistence.reconnectAttempts >= this.persistence.maxReconnectAttempts) {
console.log('Max reconnect attempts reached, giving up');
this.persistence.sessionId = undefined; // Clear session since we're giving up
return;
}
this.persistence.reconnectAttempts++;
const delay = this.persistence.reconnectDelay * Math.pow(1.5, this.persistence.reconnectAttempts - 1); // Exponential backoff
console.log(`Reconnecting in ${delay / 1000} seconds... (Attempt ${this.persistence.reconnectAttempts}/${this.persistence.maxReconnectAttempts})`);
this.reconnectTimeoutId = window.setTimeout(() => {
console.log('Reconnecting...');
this.connect();
}, delay);
}
/**
* Get the current session ID
*/
public getSessionId(): string | undefined {
return this.persistence.sessionId;
}
/**
* Reset reconnection state
*/
public resetReconnectionState(): void {
this.persistence.reconnectAttempts = 0;
this.persistence.lastDisconnectTime = undefined;
if (this.reconnectTimeoutId !== null) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = null;
}
}
/**
* 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 { public static cleanupOldStoredSessions(): void {
try { if (typeof localStorage === 'undefined') return;
const maxAge = 60 * 60 * 1000; // 1 hour for (const key of Object.keys(localStorage)) if (key.startsWith('mudSession_')) localStorage.removeItem(key);
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);
}
} }
} }
+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 { EventEmitter } from '$lib/utils/EventEmitter';
import { logGmcpMessage } from '$lib/stores/mudStore';
import type { GmcpPackageHandler } from './packages/GmcpPackageHandler'; import type { GmcpPackageHandler } from './packages/GmcpPackageHandler';
import { ClientMediaPackage } from './packages/ClientMediaPackage'; import { ClientMediaPackage } from './packages/ClientMediaPackage';
import { ClientKeystrokePackage } from './packages/ClientKeystrokePackage'; import { ClientKeystrokePackage } from './packages/ClientKeystrokePackage';
@@ -67,13 +66,11 @@ export class GmcpHandler extends EventEmitter {
*/ */
public handleGmcpMessage(message: string): void { public handleGmcpMessage(message: string): void {
try { try {
console.log('GmcpHandler received message:', message);
// Extract module and data from the message // Extract module and data from the message
const spaceIndex = message.indexOf(' '); const spaceIndex = message.indexOf(' ');
if (spaceIndex === -1) { if (spaceIndex === -1) {
// No space found, might be a module without data // 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, {}); this.emit('gmcp', message, {});
return; return;
} }
@@ -84,9 +81,8 @@ export class GmcpHandler extends EventEmitter {
try { try {
data = JSON.parse(jsonData); data = JSON.parse(jsonData);
console.log('GMCP data successfully parsed for module:', module, data);
} catch (e) { } catch (e) {
console.error('Failed to parse GMCP data:', jsonData); console.error('Failed to parse GMCP data.');
data = {}; data = {};
} }
@@ -97,7 +93,7 @@ export class GmcpHandler extends EventEmitter {
for (const [packagePrefix, handler] of this.packageHandlers.entries()) { for (const [packagePrefix, handler] of this.packageHandlers.entries()) {
console.log(`Checking if ${module} starts with ${packagePrefix}`); 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}`); console.log(`Found handler for ${module}: ${packagePrefix}`);
try { try {
handler.handleMessage(module, data); handler.handleMessage(module, data);
@@ -115,10 +111,6 @@ export class GmcpHandler extends EventEmitter {
console.log(`No specific handler for GMCP module: ${module}`); 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 // Emit the general GMCP event for custom handling
this.emit('gmcp', module, data); 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 activeSounds: Map<string, SoundInfo> = new Map(); // id -> SoundInfo
private keyToIdMap: Map<number, string> = new Map(); // key -> id private keyToIdMap: Map<number, string> = new Map(); // key -> id
private tagToIdsMap: Map<string, Set<string>> = new Map(); // tag -> Set of ids private tagToIdsMap: Map<string, Set<string>> = new Map(); // tag -> Set of ids
private recentStarts: number[] = [];
initialize(emitter: EventEmitter): void { initialize(emitter: EventEmitter): void {
this.emitter = emitter; this.emitter = emitter;
@@ -90,7 +91,6 @@ export class ClientMediaPackage implements GmcpPackageHandler {
handleMessage(module: string, data: any): void { handleMessage(module: string, data: any): void {
try { try {
console.log(`ClientMediaPackage handling message: ${module}`, data);
if (module === 'Client.Media.Play') { if (module === 'Client.Media.Play') {
console.log('Processing Client.Media.Play message'); console.log('Processing Client.Media.Play message');
@@ -111,7 +111,11 @@ export class ClientMediaPackage implements GmcpPackageHandler {
*/ */
private handlePlay(data: any): void { private handlePlay(data: any): void {
try { 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 // Extract key and tag if present
const key = data.key; const key = data.key;
@@ -134,11 +138,15 @@ export class ClientMediaPackage implements GmcpPackageHandler {
console.error('No URL provided for media playback'); console.error('No URL provided for media playback');
return; 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}`); console.log(`Playing sound from: ${fullUrl}`);
// Get global volume setting // 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) // Calculate volume (normalize from 0-100 to 0-1 if needed)
let soundVolume = globalVolume; let soundVolume = globalVolume;
@@ -241,7 +249,6 @@ export class ClientMediaPackage implements GmcpPackageHandler {
*/ */
private handleStop(data: any): void { private handleStop(data: any): void {
try { try {
console.log('GMCP Media.Stop received:', data);
// Stop by key if provided // Stop by key if provided
if (data.key !== undefined) { 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?: { autoLogin?: {
enabled: boolean; enabled: boolean;
username: string; username: string;
password: string;
commands: string[]; commands: string[];
}; };
triggers?: string; // JSON string of triggers triggers?: string; // JSON string of triggers
@@ -53,17 +52,21 @@ export class ProfileManager extends EventEmitter {
try { try {
const storedProfiles = localStorage.getItem(this.storageKey); const storedProfiles = localStorage.getItem(this.storageKey);
console.log('Retrieved from localStorage:', storedProfiles);
if (storedProfiles) { if (storedProfiles) {
const parsed = JSON.parse(storedProfiles); const parsed = JSON.parse(storedProfiles);
console.log('Parsed profiles:', parsed);
// Validate profiles before assigning // Validate profiles before assigning
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
// Filter out invalid profiles // Filter out invalid profiles
this.profiles = parsed.filter(profile => this.isValidProfile(profile)); let migrated = false;
console.log('Loaded profiles from localStorage:', this.profiles); 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) { if (this.profiles.length > 0) {
this.emit('profilesLoaded', this.profiles); this.emit('profilesLoaded', this.profiles);
@@ -89,7 +92,6 @@ export class ProfileManager extends EventEmitter {
this.addProfile(defaultProfile); this.addProfile(defaultProfile);
} catch (error) { } catch (error) {
console.error('Failed to load profiles from local storage:', 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 // Add a default profile if there was an error
const defaultProfile = this.createDefaultProfile(); const defaultProfile = this.createDefaultProfile();
@@ -112,21 +114,10 @@ export class ProfileManager extends EventEmitter {
} }
try { try {
// Add logging to help debug
console.log('Saving profiles to localStorage:', this.profiles);
const profilesJson = JSON.stringify(this.profiles); const profilesJson = JSON.stringify(this.profiles);
console.log('Profiles JSON:', profilesJson);
localStorage.setItem(this.storageKey, 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) { } catch (error) {
console.error('Failed to save profiles to local storage:', 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 * Add a new profile
*/ */
public addProfile(profile: MudProfile): void { 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 // Ensure all required fields are present
if (!profile.id) { if (!profile.id) {
profile.id = `profile-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; profile.id = `profile-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
@@ -153,12 +148,10 @@ export class ProfileManager extends EventEmitter {
if (existingIndex !== -1) { if (existingIndex !== -1) {
// Update existing profile // Update existing profile
this.profiles[existingIndex] = profile; this.profiles[existingIndex] = profile;
console.log(`Updated profile ${profile.id} (${profile.name})`, profile);
this.emit('profileUpdated', profile); this.emit('profileUpdated', profile);
} else { } else {
// Add new profile // Add new profile
this.profiles.push(profile); this.profiles.push(profile);
console.log(`Added new profile ${profile.id} (${profile.name})`, profile);
this.emit('profileAdded', profile); this.emit('profileAdded', profile);
} }
@@ -259,13 +252,20 @@ export class ProfileManager extends EventEmitter {
*/ */
private isValidProfile(obj: any): boolean { private isValidProfile(obj: any): boolean {
return ( return (
typeof obj === 'object' && obj !== null && typeof obj === 'object' &&
typeof obj.id === 'string' && typeof obj.id === 'string' && obj.id.length > 0 && obj.id.length <= 128 &&
typeof obj.name === 'string' && typeof obj.name === 'string' && obj.name.length > 0 && obj.name.length <= 128 &&
typeof obj.host === 'string' && typeof obj.host === 'string' && obj.host.length > 0 && obj.host.length <= 253 && /^[a-zA-Z0-9._:-]+$/.test(obj.host) &&
typeof obj.port === 'number' && Number.isInteger(obj.port) && obj.port >= 1 && obj.port <= 65535 &&
typeof obj.useSSL === 'boolean' && 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: { autoLogin: {
enabled: false, enabled: false,
username: '', username: '',
password: '',
commands: [] commands: []
}, },
aliases: {}, aliases: {},
+54 -31
View File
@@ -24,6 +24,7 @@ export interface Settings {
font: string; font: string;
debugGmcp: boolean; debugGmcp: boolean;
globalVolume: number; globalVolume: number;
allowServerMedia: boolean;
}; };
connection: { connection: {
persistenceTimeoutMinutes: number; persistenceTimeoutMinutes: number;
@@ -68,12 +69,13 @@ export class SettingsManager extends EventEmitter {
ansiColor: true, ansiColor: true,
font: 'monospace', font: 'monospace',
debugGmcp: false, debugGmcp: false,
globalVolume: 0.7 globalVolume: 0.7,
allowServerMedia: false
}, },
connection: { connection: {
persistenceTimeoutMinutes: 5, persistenceTimeoutMinutes: 5,
maxBufferMessages: 100, maxBufferMessages: 250,
maxBufferSizeKB: 10 maxBufferSizeKB: 256
} }
}; };
@@ -137,20 +139,7 @@ export class SettingsManager extends EventEmitter {
// Merge with defaults to ensure all properties exist // Merge with defaults to ensure all properties exist
if (parsedSettings && typeof parsedSettings === 'object') { if (parsedSettings && typeof parsedSettings === 'object') {
// Update internal settings // Update internal settings
this.settings = { this.settings = this.normalizeSettings(parsedSettings);
accessibility: {
...this.settings.accessibility,
...(parsedSettings.accessibility || {})
},
ui: {
...this.settings.ui,
...(parsedSettings.ui || {})
},
connection: {
...this.settings.connection,
...(parsedSettings.connection || {})
}
};
console.log('Loaded settings from localStorage:', this.settings); console.log('Loaded settings from localStorage:', this.settings);
@@ -211,7 +200,7 @@ export class SettingsManager extends EventEmitter {
// Reset settings to defaults // Reset settings to defaults
public resetSettings(): void { public resetSettings(): void {
const defaults = { const defaults: Settings = {
accessibility: { accessibility: {
textToSpeech: false, textToSpeech: false,
highContrast: false, highContrast: false,
@@ -233,12 +222,13 @@ export class SettingsManager extends EventEmitter {
ansiColor: true, ansiColor: true,
font: 'monospace', font: 'monospace',
debugGmcp: false, debugGmcp: false,
globalVolume: 0.7 globalVolume: 0.7,
allowServerMedia: false
}, },
connection: { connection: {
persistenceTimeoutMinutes: 5, persistenceTimeoutMinutes: 5,
maxBufferMessages: 100, maxBufferMessages: 250,
maxBufferSizeKB: 10 maxBufferSizeKB: 256
} }
}; };
@@ -255,20 +245,12 @@ export class SettingsManager extends EventEmitter {
if (typeof imported === 'object' && imported !== null) { if (typeof imported === 'object' && imported !== null) {
// Create a valid settings object with defaults for missing properties // Create a valid settings object with defaults for missing properties
const newSettings = { const newSettings = this.normalizeSettings(imported);
accessibility: {
...this.settings.accessibility,
...(imported.accessibility || {})
},
ui: {
...this.settings.ui,
...(imported.ui || {})
}
};
// Update stores // Update stores
this.accessibilitySettings.set(newSettings.accessibility); this.accessibilitySettings.set(newSettings.accessibility);
this.uiSettings.set(newSettings.ui); this.uiSettings.set(newSettings.ui);
this.connectionSettings.set(newSettings.connection);
// Update internal settings // Update internal settings
this.settings = newSettings; this.settings = newSettings;
@@ -288,6 +270,47 @@ export class SettingsManager extends EventEmitter {
return JSON.stringify(this.settings, null, 2); 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 // 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();
+24 -8
View File
@@ -3,7 +3,7 @@ import { settingsManager } from '$lib/settings/SettingsManager';
import type { MudProfile } from '$lib/profiles/ProfileManager'; import type { MudProfile } from '$lib/profiles/ProfileManager';
import type { MudConnection } from '$lib/connection/MudConnection'; import type { MudConnection } from '$lib/connection/MudConnection';
import type { Trigger } from '$lib/triggers/TriggerSystem'; 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 // Store for active connections
export const connections = writable<{ [key: string]: MudConnection }>({}); export const connections = writable<{ [key: string]: MudConnection }>({});
@@ -35,6 +35,7 @@ export const processedOutputHistory = writable<{
// Store for connection status // Store for connection status
export const connectionStatus = writable<{ [key: string]: 'connected' | 'disconnected' | 'connecting' | 'error' }>({}); export const connectionStatus = writable<{ [key: string]: 'connected' | 'disconnected' | 'connecting' | 'error' }>({});
export const sensitiveInput = writable<Record<string, boolean>>({});
// Use the stores from SettingsManager // Use the stores from SettingsManager
export const accessibilitySettings = settingsManager.accessibilitySettings; export const accessibilitySettings = settingsManager.accessibilitySettings;
@@ -146,6 +147,7 @@ export const activeRenderableLines = derived(
id: string; id: string;
messageId: string; messageId: string;
content: string; content: string;
segments: RenderedSegment[];
timestamp: number; timestamp: number;
isInput: boolean; isInput: boolean;
isSubline: boolean; isSubline: boolean;
@@ -159,6 +161,7 @@ export const activeRenderableLines = derived(
id: message.id, id: message.id,
messageId: message.id, messageId: message.id,
content: message.processedContent, content: message.processedContent,
segments: message.lines[0]?.segments ?? [{ text: message.processedContent }],
timestamp: message.timestamp, timestamp: message.timestamp,
isInput: true, isInput: true,
isSubline: false, isSubline: false,
@@ -171,6 +174,7 @@ export const activeRenderableLines = derived(
id: line.id, id: line.id,
messageId: message.id, messageId: message.id,
content: line.content, content: line.content,
segments: line.segments,
timestamp: message.timestamp, timestamp: message.timestamp,
isInput: false, isInput: false,
isSubline: line.isSubline, 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 // 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 // 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 // Helper functions
export function addToOutputHistory(text: string, isInput = false, highlights: { pattern: string; color: string; isRegex: boolean }[] = []) { export function appendOutput(profileId: string | null, text: string, isInput = false, highlights: { pattern: string; color: string; isRegex: boolean }[] = []) {
const profileId = get(activeProfileId);
const targetProfileId = profileId || 'default'; const targetProfileId = profileId || 'default';
const maxSize = get(uiSettings).outputBufferSize; const maxSize = get(uiSettings).outputBufferSize;
const currentAnsiSetting = get(uiSettings).ansiColor; 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 * 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); console.log('logGmcpMessage called for module:', module);
// Always add to debug log // 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 maxSize = 100; // Keep last 100 GMCP messages
const newItem = { const newItem = {
id: `gmcp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, id: `gmcp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
profileId,
module, module,
data, data,
timestamp: Date.now() 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 dataString = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data);
const gmcpText = `[GMCP] ${module}: ${dataString}`; const gmcpText = `[GMCP] ${module}: ${dataString}`;
addToOutputHistory(gmcpText, false, [ appendOutput(profileId, gmcpText, false, [
{ pattern: '\\[GMCP\\]', color: '#8be9fd', isRegex: true }, { pattern: '\\[GMCP\\]', color: '#8be9fd', isRegex: true },
{ pattern: module, color: '#ff79c6', isRegex: false } { 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 => { gmcpData.update(currentData => {
return { return {
...currentData, ...currentData,
[profileId]: {
...(currentData[profileId] || {}),
[module]: data [module]: data
}
}; };
}); });
} }
+39 -22
View File
@@ -11,7 +11,6 @@ export interface Trigger {
isEnabled: boolean; isEnabled: boolean;
soundFile?: string; soundFile?: string;
soundVolume?: number; // Sound volume (0-1) soundVolume?: number; // Sound volume (0-1)
action?: string;
sendText?: string; sendText?: string;
highlightColor?: string; highlightColor?: string;
priority: number; priority: number;
@@ -19,6 +18,13 @@ export interface Trigger {
gag?: boolean; // If true, don't display the matched text at all 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 { export class TriggerSystem extends EventEmitter {
private triggers: Trigger[] = []; private triggers: Trigger[] = [];
private sounds: Map<string, Howl> = new Map(); private sounds: Map<string, Howl> = new Map();
@@ -47,6 +53,7 @@ export class TriggerSystem extends EventEmitter {
const loadedTriggers = JSON.parse(triggersJson); const loadedTriggers = JSON.parse(triggersJson);
if (Array.isArray(loadedTriggers)) { if (Array.isArray(loadedTriggers)) {
loadedTriggers.forEach(trigger => { loadedTriggers.forEach(trigger => {
delete trigger.action;
if (this.isValidTrigger(trigger)) { if (this.isValidTrigger(trigger)) {
this.triggers.push(trigger); this.triggers.push(trigger);
} }
@@ -81,6 +88,8 @@ export class TriggerSystem extends EventEmitter {
* Add a new trigger * Add a new trigger
*/ */
public addTrigger(trigger: Trigger): void { 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); const existingTriggerIndex = this.triggers.findIndex(t => t.id === trigger.id);
if (existingTriggerIndex !== -1) { if (existingTriggerIndex !== -1) {
@@ -133,14 +142,11 @@ export class TriggerSystem extends EventEmitter {
* Process text for triggers * Process text for triggers
* @returns Object with information about gagging and replacement * @returns Object with information about gagging and replacement
*/ */
public processTriggers(text: string): { public processTriggers(text: string): TriggerResult {
processed: string; // Text after all replacements
gagged: boolean; // Whether the text should be completely hidden
matched: boolean; // Whether any triggers matched
} {
let processedText = text; let processedText = text;
let isGagged = false; let isGagged = false;
let anyTriggerMatched = false; let anyTriggerMatched = false;
const highlights: TriggerResult['highlights'] = [];
// Process only enabled triggers in priority order // Process only enabled triggers in priority order
for (const trigger of this.triggers.filter(t => t.isEnabled)) { for (const trigger of this.triggers.filter(t => t.isEnabled)) {
@@ -149,9 +155,9 @@ export class TriggerSystem extends EventEmitter {
if (trigger.isRegex) { if (trigger.isRegex) {
try { try {
const regex = new RegExp(trigger.pattern, 'g'); const regex = new RegExp(trigger.pattern);
matches = processedText.match(regex); matches = regex.exec(processedText);
matched = matches !== null && matches.length > 0; matched = matches !== null;
} catch (error) { } catch (error) {
console.error(`Invalid regex pattern in trigger ${trigger.name}:`, error); console.error(`Invalid regex pattern in trigger ${trigger.name}:`, error);
} }
@@ -170,6 +176,9 @@ export class TriggerSystem extends EventEmitter {
if (trigger.gag) { if (trigger.gag) {
isGagged = true; 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 // Handle text replacement if not gagged
if (!isGagged && trigger.replaceText) { if (!isGagged && trigger.replaceText) {
@@ -213,7 +222,8 @@ export class TriggerSystem extends EventEmitter {
return { return {
processed: processedText, processed: processedText,
gagged: isGagged, gagged: isGagged,
matched: anyTriggerMatched matched: anyTriggerMatched,
highlights
}; };
} }
@@ -224,13 +234,24 @@ export class TriggerSystem extends EventEmitter {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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 * Execute a triggered action with support for advanced features
*/ */
private executeTrigger(trigger: Trigger, text: string, matches: RegExpMatchArray | null): void { private executeTrigger(trigger: Trigger, text: string, matches: RegExpMatchArray | null): void {
// Get settings // Get settings
const uiSettingsValue = get(uiSettings); const uiSettingsValue = get(uiSettings);
const globalVolume = uiSettingsValue.globalVolume || 0.7; const globalVolume = uiSettingsValue.globalVolume ?? 0.7;
// Handle sound playback, loading on demand if needed // Handle sound playback, loading on demand if needed
if (trigger.soundFile) { if (trigger.soundFile) {
@@ -297,16 +318,6 @@ export class TriggerSystem extends EventEmitter {
// Emit basic trigger fired event // Emit basic trigger fired event
this.emit('triggerFired', trigger.id); 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; 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 // Build path based on whether it's a URL or local file
const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/') const soundPath = soundFile.startsWith('http') || soundFile.startsWith('/')
? soundFile ? soundFile
@@ -370,6 +385,7 @@ export class TriggerSystem extends EventEmitter {
if (trigger) { if (trigger) {
trigger.isEnabled = enabled; trigger.isEnabled = enabled;
this.saveTriggersToStorage();
this.emit('triggerUpdated', trigger); this.emit('triggerUpdated', trigger);
} }
} }
@@ -414,7 +430,8 @@ export class TriggerSystem extends EventEmitter {
typeof obj.pattern === 'string' && typeof obj.pattern === 'string' &&
typeof obj.isRegex === 'boolean' && typeof obj.isRegex === 'boolean' &&
typeof obj.isEnabled === '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 // Current backup format version
const BACKUP_FORMAT_VERSION = '1.0.0'; const BACKUP_FORMAT_VERSION = '2.0.0';
// Interface for backup file format // Interface for backup file format
interface BackupData { interface BackupData {
@@ -28,8 +28,8 @@ interface BackupData {
} }
export class BackupManager extends EventEmitter { export class BackupManager extends EventEmitter {
private profileManager: ProfileManager; private profileManager: ProfileManager | null;
private triggerSystem: TriggerSystem; private triggerSystem: TriggerSystem | null;
constructor() { constructor() {
super(); super();
@@ -70,6 +70,7 @@ export class BackupManager extends EventEmitter {
// Add all localStorage items that match our known keys // Add all localStorage items that match our known keys
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (!key) continue;
// Skip items that don't look like our app data // Skip items that don't look like our app data
if (!key.startsWith('svelte-mud-') && !key.startsWith('mud-')) { if (!key.startsWith('svelte-mud-') && !key.startsWith('mud-')) {
@@ -84,14 +85,19 @@ export class BackupManager extends EventEmitter {
// Store by category // Store by category
if (key === STORAGE_KEYS.PROFILES) { 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) { } 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) { } else if (key === STORAGE_KEYS.SETTINGS) {
backup.data.settings = parsedValue; backup.data.settings = parsedValue;
} else {
// Store other items directly by key
backup.data[key] = parsedValue;
} }
} }
} catch (error) { } catch (error) {
@@ -129,7 +135,7 @@ export class BackupManager extends EventEmitter {
this.emit('backupExported', backup); this.emit('backupExported', backup);
} catch (error) { } catch (error) {
console.error('Failed to export backup:', 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 * Import backup from a JSON string
*/ */
public async importBackup(json: string): Promise<void> { public async importBackup(json: string): Promise<void> {
const previous = Object.fromEntries(Object.values(STORAGE_KEYS).map(key => [key, localStorage.getItem(key)]));
try { try {
if (new Blob([json]).size > 1024 * 1024) throw new Error('Backup exceeds the 1 MB limit');
// Parse backup data // Parse backup data
const backup = JSON.parse(json) as BackupData; const backup = JSON.parse(json) as BackupData;
// Verify format version // Verify format version
if (!backup.version) { if (!backup.version || !backup.data || typeof backup.data !== 'object') {
throw new Error('Invalid backup file format: Missing version'); throw new Error('Invalid backup file format: Missing version');
} }
@@ -173,22 +181,24 @@ export class BackupManager extends EventEmitter {
await this.restoreSettings(backup.data.settings); 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); this.emit('backupImported', backup);
} catch (error) { } 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); 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'); throw new Error('Invalid profiles data: Expected array');
} }
// Clear existing profiles const sanitized = profilesData.map(profile => {
localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify([])); if (!profile || typeof profile !== 'object') throw new Error('Invalid profile entry');
const clone = structuredClone(profile);
// Import each profile if (clone.autoLogin) delete clone.autoLogin.password;
for (const profile of profilesData) { return clone;
this.profileManager.addProfile(profile); });
} localStorage.setItem(STORAGE_KEYS.PROFILES, JSON.stringify(sanitized));
this.profileManager = new ProfileManager();
// Update the profiles store
profiles.set(this.profileManager.getProfiles()); profiles.set(this.profileManager.getProfiles());
} }
@@ -220,15 +229,16 @@ export class BackupManager extends EventEmitter {
throw new Error('Invalid triggers data: Expected array'); throw new Error('Invalid triggers data: Expected array');
} }
// Clear existing triggers const validator = new TriggerSystem();
localStorage.setItem(STORAGE_KEYS.TRIGGERS, JSON.stringify([])); const sanitized = triggersData.map(trigger => {
if (!trigger || typeof trigger !== 'object') throw new Error('Invalid trigger entry');
// Import each trigger const clone = structuredClone(trigger);
for (const trigger of triggersData) { delete clone.action;
this.triggerSystem.addTrigger(trigger); if (clone.isRegex && !validator.isSafeRegex(clone.pattern)) throw new Error(`Unsafe regex in trigger: ${clone.name || clone.id}`);
} return clone;
});
// Update the triggers store localStorage.setItem(STORAGE_KEYS.TRIGGERS, JSON.stringify(sanitized));
this.triggerSystem = new TriggerSystem();
triggers.set(this.triggerSystem.getTriggers()); triggers.set(this.triggerSystem.getTriggers());
} }
@@ -240,19 +250,10 @@ export class BackupManager extends EventEmitter {
throw new Error('Invalid settings data: Expected object'); throw new Error('Invalid settings data: Expected object');
} }
// Update settings in localStorage // Normalize untrusted fields and update both the manager and app stores.
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settingsData)); settingsManager.importSettings(JSON.stringify(settingsData));
accessibilitySettings.set(get(settingsManager.accessibilitySettings));
// Update the settings stores uiSettings.set(get(settingsManager.uiSettings));
if (settingsData.accessibility) {
accessibilitySettings.set(settingsData.accessibility);
}
if (settingsData.ui) {
uiSettings.set(settingsData.ui);
}
// Make sure settings manager knows about the changes
settingsManager.saveSettings(); settingsManager.saveSettings();
} }
} }
+2 -7
View File
@@ -67,7 +67,6 @@ export class ModalHelper {
autoLogin: { autoLogin: {
enabled: false, enabled: false,
username: '', username: '',
password: '',
commands: [] commands: []
}, },
aliases: {}, aliases: {},
@@ -83,7 +82,6 @@ export class ModalHelper {
}; };
const isNewProfile = !existingProfile; 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 // Set up the modal with a short delay to ensure the DOM is ready
setTimeout(() => { setTimeout(() => {
@@ -96,8 +94,7 @@ export class ModalHelper {
profile, profile,
isNewProfile isNewProfile
}, },
onSubmit: (result) => { onSubmit: (result: { profile: MudProfile }) => {
console.log('Modal submit callback with result:', result);
onSave(result.profile); onSave(result.profile);
}, },
onCancel: () => { onCancel: () => {
@@ -166,7 +163,6 @@ export class ModalHelper {
const isNewTrigger = !existingTrigger; 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 // Set up the modal with a short delay to ensure the DOM is ready
setTimeout(() => { setTimeout(() => {
@@ -179,8 +175,7 @@ export class ModalHelper {
trigger: existingTrigger || null, trigger: existingTrigger || null,
isNew: isNewTrigger isNew: isNewTrigger
}, },
onSubmit: (result) => { onSubmit: (result: { trigger: Trigger }) => {
console.log('Modal submit callback with result:', result);
onSave(result.trigger); onSave(result.trigger);
}, },
onCancel: () => { 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'; export interface RenderedSegment {
text: string;
// Create a singleton instance of the ANSI converter for consistent processing color?: string;
const ansiConverter = new AnsiToHtml({ backgroundColor?: string;
fg: '#f8f8f2', bold?: boolean;
bg: '#282a36', italic?: boolean;
newline: false, // We'll handle newlines ourselves underline?: boolean;
escapeXML: true, }
stream: false
});
export interface ProcessedLine { export interface ProcessedLine {
id: string; id: string;
content: string; content: string;
segments: RenderedSegment[];
isSubline: boolean; isSubline: boolean;
parentId: string; parentId: string;
lineIndex: number; lineIndex: number;
@@ -22,154 +21,140 @@ export interface ProcessedMessage {
originalText: string; originalText: string;
timestamp: number; timestamp: number;
isInput: boolean; isInput: boolean;
highlights: { pattern: string; color: string; isRegex: boolean }[]; highlights: Highlight[];
processedContent: string; processedContent: string;
lines: ProcessedLine[]; lines: ProcessedLine[];
// Cache for different UI settings
processedCache: Map<string, { content: string; lines: ProcessedLine[] }>; processedCache: Map<string, { content: string; lines: ProcessedLine[] }>;
} }
/** interface Highlight { pattern: string; color: string; isRegex: boolean }
* Process ANSI color codes const normalColors = ['#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa'];
*/ const brightColors = ['#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff'];
export function processAnsi(text: string, ansiEnabled: boolean): string { const ANSI_PATTERN = /\x1b\[([0-9;]*)m/g;
if (ansiEnabled) {
try {
// First process ANSI to HTML without replacing newlines
const ansiProcessed = ansiConverter.toHtml(text);
// Then replace newlines with <br> tags export function createCacheKey(ansiEnabled: boolean): string { return `ansi:${ansiEnabled}`; }
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>');
}
}
/**
* 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);
}
/**
* 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( export function processMessage(
message: { message: { id: string; text: string; timestamp: number; isInput?: boolean; highlights?: Highlight[] },
id: string;
text: string;
timestamp: number;
isInput?: boolean;
highlights?: { pattern: string; color: string; isRegex: boolean }[]
},
ansiEnabled: boolean ansiEnabled: boolean
): ProcessedMessage { ): ProcessedMessage {
const processedMessage: ProcessedMessage = { const lineTexts = message.text.split(/\r\n|\r|\n/).filter((line) => line.trim().length > 0);
id: message.id, const safeLines = lineTexts.length > 0 ? lineTexts : [''];
originalText: message.text, const lines = safeLines.map((line, index) => {
timestamp: message.timestamp, const segments = applyHighlights(parseAnsi(line, ansiEnabled), message.highlights || []);
isInput: message.isInput || false, return {
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) => ({
id: `${message.id}-line-${index}`, id: `${message.id}-line-${index}`,
content: line, content: segments.map((segment) => segment.text).join(''),
segments,
isSubline: index > 0, isSubline: index > 0,
parentId: message.id, parentId: message.id,
lineIndex: index 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(';');
} }
+4 -19
View File
@@ -1,12 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 MudMdi from '$lib/components/MudMdi.svelte';
import KeyboardShortcutsHelp from '$lib/components/KeyboardShortcutsHelp.svelte'; import KeyboardShortcutsHelp from '$lib/components/KeyboardShortcutsHelp.svelte';
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
@@ -36,9 +30,8 @@
let sidebarTab: 'profiles' | 'triggers' | 'settings' = 'profiles'; let sidebarTab: 'profiles' | 'triggers' | 'settings' = 'profiles';
// Save profile from component // Save profile from component
function saveProfile(event) { function saveProfile(event: { detail: { profile: MudProfile } }) {
const profile = event.detail.profile; const profile = event.detail.profile;
console.log('Saving profile from component:', profile);
if (profileManager) { if (profileManager) {
// Ensure profile has a valid ID // Ensure profile has a valid ID
@@ -49,7 +42,6 @@
// Ensure all required fields are set // Ensure all required fields are set
if (!profile.ansiColor) profile.ansiColor = true; if (!profile.ansiColor) profile.ansiColor = true;
console.log('Saving validated profile:', profile);
profileManager.addProfile(profile); profileManager.addProfile(profile);
// Force reload all profiles // Force reload all profiles
@@ -115,7 +107,6 @@
}); });
// Ensure settings are properly loaded and initialized // Ensure settings are properly loaded and initialized
console.log('Initial profiles state:', $profiles);
console.log('Active profile ID:', $activeProfileId); console.log('Active profile ID:', $activeProfileId);
// Make sure we have an active profile selected if any profiles exist // Make sure we have an active profile selected if any profiles exist
@@ -125,7 +116,7 @@
} }
// Initialize output history for all profiles // Initialize output history for all profiles
const outputHistoryObject = {}; const outputHistoryObject: Record<string, []> = {};
$profiles.forEach(profile => { $profiles.forEach(profile => {
outputHistoryObject[profile.id] = []; outputHistoryObject[profile.id] = [];
}); });
@@ -134,9 +125,8 @@
} catch (error) { } catch (error) {
console.error('Error during page initialization:', error); console.error('Error during page initialization:', error);
console.error('Error details:', error.message, error.stack);
// Provide user feedback about the error // 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 // Get all profiles from the manager
const allProfiles = profileManager.getProfiles(); const allProfiles = profileManager.getProfiles();
console.log('Loaded profiles from manager:', allProfiles);
// Create a default profile if none exist // Create a default profile if none exist
if (allProfiles.length === 0) { if (allProfiles.length === 0) {
@@ -187,7 +176,6 @@
// Get profiles again after adding the default one // Get profiles again after adding the default one
const updatedProfiles = profileManager.getProfiles(); const updatedProfiles = profileManager.getProfiles();
console.log('Profiles after adding default:', updatedProfiles);
profiles.set(updatedProfiles); profiles.set(updatedProfiles);
// Set this as the active profile // Set this as the active profile
@@ -221,8 +209,7 @@
} }
} catch (error) { } catch (error) {
console.error('Error loading profiles:', error); console.error('Error loading profiles:', error);
console.error('Error details:', error.message, error.stack); addToOutputHistory(`Error loading profiles: ${error instanceof Error ? error.message : String(error)}`);
addToOutputHistory(`Error loading profiles: ${error.message}`);
} }
} }
@@ -234,10 +221,8 @@
// Edit an existing profile // Edit an existing profile
function editProfile(profile: MudProfile) { function editProfile(profile: MudProfile) {
console.log('Editing profile:', profile);
ModalHelper.showProfileEditor( ModalHelper.showProfileEditor(
(updatedProfile) => { (updatedProfile) => {
console.log('Profile updated from modal:', updatedProfile);
// Use the same saveProfile function for consistency // Use the same saveProfile function for consistency
saveProfile({ detail: { profile: updatedProfile } }); saveProfile({ detail: { profile: updatedProfile } });
}, },
-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' });
});
});
+265 -463
View File
@@ -1,491 +1,293 @@
import { WebSocketServer } from 'ws'; import { randomBytes } from 'node:crypto';
import * as net from 'net'; import { lookup } from 'node:dns/promises';
import * as tls from 'tls'; import http from 'node:http';
import http from 'http'; import net, { BlockList } from 'node:net';
import { parse } from 'url'; import tls from 'node:tls';
import { WebSocket, WebSocketServer } from 'ws';
// Default configuration for connection persistence (fallback values) const PORT = numberFromEnv('WS_PORT', 3001, 1, 65535);
const DEFAULT_PERSISTENCE_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds const MAX_SESSIONS_PER_IP = numberFromEnv('PROXY_MAX_SESSIONS_PER_IP', 3, 1, 20);
const DEFAULT_MAX_BUFFER_MESSAGES = 100; // Maximum number of messages to buffer const MAX_GLOBAL_SESSIONS = numberFromEnv('PROXY_MAX_GLOBAL_SESSIONS', 200, 1, 10_000);
const DEFAULT_MAX_BUFFER_SIZE_KB = 10; // Maximum buffer size in KB 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 const server = http.createServer((request, response) => {
if (request.url === '/health') {
// Create HTTP server response.writeHead(200, { 'content-type': 'application/json' });
const server = http.createServer(); response.end(JSON.stringify({ ok: true, sessions: sessions.size }));
// 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 });
return; return;
} }
response.writeHead(404);
let socket; response.end();
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);
}); });
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
// Send session ID to client in proper JSON format function numberFromEnv(name, fallback, minimum, maximum) {
ws.send(`[SYSTEM]${JSON.stringify({ sessionId: currentSessionId })}`); const parsed = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
} 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 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';
}
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;
} }
} }
// Store the connection with its settings function destroySession(session, reason = 'closed') {
connections.set(connectionId, { if (session.closed) return;
ws, session.closed = true;
socket, if (session.persistenceTimer) clearTimeout(session.persistenceTimer);
sessionId: currentSessionId, sessions.delete(session.token);
settings: connectionSettings 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) => { socket.on('data', (data) => {
// Check for GMCP data (IAC SB GMCP) - very basic check for debugging if (!consumeTraffic(session, 'inbound', data.length)) return destroySession(session, 'traffic_limit');
// IAC = 255, SB = 250, GMCP = 201 if (session.ws?.readyState === WebSocket.OPEN) session.ws.send(data);
let isGmcp = false; else bufferMessage(session, data);
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('error', (error) => { if (session.ws) sendControl(session.ws, { type: 'error', code: 'UPSTREAM_ERROR', message: error.message }); });
socket.on('close', () => destroySession(session, 'upstream_closed'));
// Handle socket close from MUD server - this should trigger cleanup socket.on('drain', () => session.ws?.resume());
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);
}
});
}
// Handle WebSocket messages (data from client to server)
ws.on('message', (message) => {
try { try {
// Skip if this is a test connection (already handled in the test mode section) await connected;
const conn = connections.get(connectionId); } catch (error) {
if (conn && conn.testMode) return; destroySession(session, 'connect_failed');
throw error;
}
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;
}
// Check for system messages function tryResume(ws, request, target) {
const messageStr = message.toString(); if (!target.resumeToken) return null;
if (messageStr.startsWith('[SYSTEM]')) { 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 { try {
const jsonStr = messageStr.substring(8); // Remove "[SYSTEM]" if (!initialized) {
const systemData = JSON.parse(jsonStr); if (isBinary || data.length > MAX_CONTROL_BYTES) throw new Error('Invalid connect control frame.');
const target = validateConnectMessage(JSON.parse(data.toString('utf8')));
if (systemData.type === 'explicit_disconnect') { initialized = true;
console.log(`Received explicit disconnect command for session ${currentSessionId}`); clearTimeout(initializationTimer);
// This is an explicit disconnect - don't persist the connection session = tryResume(ws, request, target) || await createSession(ws, request, target);
if (socket && socket.writable) {
socket.end();
}
if (ws.readyState === 1) {
ws.close();
}
connections.delete(connectionId);
if (currentSessionId) {
cleanupPersistentConnection(currentSessionId);
}
return; return;
} }
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) { } catch (error) {
console.error('Error parsing system message:', error); 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');
// Don't forward system messages to the MUD server
return;
}
// Legacy support for old disconnect command
if (messageStr.trim() === '[DISCONNECT]') {
console.log(`Received legacy disconnect command for session ${currentSessionId}`);
// This is an explicit disconnect - don't persist the connection
if (socket && socket.writable) {
socket.end();
}
if (ws.readyState === 1) {
ws.close();
}
connections.delete(connectionId);
if (currentSessionId) {
cleanupPersistentConnection(currentSessionId);
}
return;
}
// Check for GMCP data (IAC SB GMCP) in client messages
let isGmcp = false;
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`));
}
}
} 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`));
}
} }
}); });
// Handle WebSocket close - THIS IS THE KEY CHANGE FOR PERSISTENCE
ws.on('close', () => { ws.on('close', () => {
console.log(`WebSocket closed for ${mudHost}:${mudPort} (session: ${currentSessionId})`); clearTimeout(initializationTimer);
if (!session || explicitDisconnect || session.ws !== ws) return;
const conn = connections.get(connectionId); session.ws = null;
if (conn && !conn.testMode && conn.socket && !conn.socket.destroyed) { session.persistenceTimer = setTimeout(() => destroySession(session, 'resume_timeout'), PERSISTENCE_TIMEOUT_MS);
console.log(`Moving connection to persistent state for ${conn.settings.persistenceTimeoutMs / 1000} seconds`); });
ws.on('error', () => ws.close());
// 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);
});
});
// Handle HTTP server upgrade (WebSocket handshake)
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (request, socket, head) => {
// Parse URL to get query parameters let pathname;
const { pathname, query } = parse(request.url || '', true); 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');
// Only handle WebSocket connections to /mud-ws const origin = request.headers.origin;
if (pathname === '/mud-ws') { if (typeof origin !== 'string' || !allowedOrigins.has(origin)) return failUpgrade(socket, '403 Forbidden', 'Origin not allowed');
// Extract MUD server details from query parameters wss.handleUpgrade(request, socket, head, (client) => wss.emit('connection', client, request));
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();
}
}); });
// Periodic cleanup of abandoned persistent connections function shutdown() {
setInterval(() => { wss.close();
const now = Date.now(); for (const session of [...sessions.values()]) destroySession(session, 'server_shutdown');
for (const [sessionId, persistentConn] of persistentConnections.entries()) { server.close(() => process.exit(0));
// Clean up connections that have been inactive for too long setTimeout(() => process.exit(1), 5_000).unref();
// 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);
} }
} process.on('SIGINT', shutdown);
}, DEFAULT_PERSISTENCE_TIMEOUT); // Run cleanup every default timeout period process.on('SIGTERM', shutdown);
if (process.env.NODE_ENV !== 'test') server.listen(PORT, () => console.log(`MUD WebSocket proxy listening on port ${PORT}`));
// Start the WebSocket server export { server, validateConnectMessage, resolvePublicTarget, isDeniedAddress };
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;
+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);
});
});
+4 -3
View File
@@ -8,15 +8,16 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('Starting WebSocket server'); console.log('Starting WebSocket server');
const wsServer = spawn('node', ['src/websocket-server.js'], { const wsServer = spawn('node', ['src/websocket-server.js'], {
stdio: 'inherit', stdio: 'inherit',
shell: true, shell: false,
cwd: __dirname cwd: __dirname
}); });
// Start the SvelteKit dev server // Start the SvelteKit dev server
console.log('Starting 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', stdio: 'inherit',
shell: true, shell: false,
cwd: __dirname cwd: __dirname
}); });
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} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
compilerOptions: {
compatibility: { componentApi: 4 }
},
kit: { kit: {
adapter: nodeAdapter({ adapter: nodeAdapter({
+1 -1
View File
@@ -5,7 +5,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": false,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
-17
View File
@@ -80,21 +80,4 @@ export default defineConfig({
server: { server: {
// No proxy - we're using a standalone WebSocket server on port 3001 // 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
}
}
}); });