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