Persistent connections even if client disconnects

This commit is contained in:
2025-07-25 14:18:40 +01:00
parent 1d39818127
commit 5c9b4e151f
7 changed files with 307 additions and 109 deletions

100
package-lock.json generated
View File

@@ -14,7 +14,6 @@
"events": "^3.3.0", "events": "^3.3.0",
"express": "^4.18.2", "express": "^4.18.2",
"howler": "^2.2.4", "howler": "^2.2.4",
"http-proxy-middleware": "^2.0.6",
"net": "^1.0.2", "net": "^1.0.2",
"split.js": "^1.6.5", "split.js": "^1.6.5",
"ws": "^8.18.1" "ws": "^8.18.1"
@@ -3199,15 +3198,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/http-proxy": {
"version": "1.17.16",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.14.1", "version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
@@ -3707,6 +3697,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -4959,12 +4950,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -5165,6 +5150,7 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -5223,26 +5209,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5690,44 +5656,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
}
},
"node_modules/https-browserify": { "node_modules/https-browserify": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
@@ -6004,6 +5932,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6058,6 +5987,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -6107,6 +6037,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -6139,18 +6070,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -6638,6 +6557,7 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@@ -7216,6 +7136,7 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -7760,12 +7681,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -9068,6 +8983,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"

View File

@@ -184,6 +184,11 @@
// Listen for sound play events // Listen for sound play events
conn.on('playSound', handlePlaySound); conn.on('playSound', handlePlaySound);
// Listen for message replay events
conn.on('session_resumed', handleSessionResumed);
conn.on('message_replay_start', handleMessageReplayStart);
conn.on('message_replay_complete', handleMessageReplayComplete);
console.log('Connection listeners attached successfully'); console.log('Connection listeners attached successfully');
} }
@@ -200,6 +205,9 @@
conn.off('error', handleError); conn.off('error', handleError);
conn.off('gmcp', handleGmcp); conn.off('gmcp', handleGmcp);
conn.off('playSound', handlePlaySound); conn.off('playSound', handlePlaySound);
conn.off('session_resumed', handleSessionResumed);
conn.off('message_replay_start', handleMessageReplayStart);
conn.off('message_replay_complete', handleMessageReplayComplete);
} }
/** /**
@@ -443,6 +451,44 @@
console.log(`Play sound event for ${profileId}:`, options); console.log(`Play sound event for ${profileId}:`, options);
dispatch('playSound', options); dispatch('playSound', options);
} }
/**
* Handle session resumed event
*/
function handleSessionResumed(data) {
console.log(`Session resumed for ${profileId}:`, data);
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);
} else {
addToOutputHistory('[SYSTEM] Reconnected to MUD.', false);
}
dispatch('sessionResumed', data);
}
/**
* Handle message replay start event
*/
function handleMessageReplayStart(data) {
console.log(`Message replay starting for ${profileId}:`, data);
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);
dispatch('messageReplayStart', data);
}
/**
* Handle message replay complete event
*/
function handleMessageReplayComplete() {
console.log(`Message replay complete for ${profileId}`);
addToOutputHistory('[SYSTEM] Message replay complete. You are now up to date.', false);
dispatch('messageReplayComplete');
}
</script> </script>
{#if $connectionStatus[profileId]} {#if $connectionStatus[profileId]}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { uiSettings, accessibilitySettings } from '$lib/stores/mudStore'; import { uiSettings, accessibilitySettings, connectionSettings } from '$lib/stores/mudStore';
import { settingsManager } from '$lib/settings/SettingsManager'; import { settingsManager } from '$lib/settings/SettingsManager';
import BackupPanel from './BackupPanel.svelte'; import BackupPanel from './BackupPanel.svelte';
@@ -146,6 +146,59 @@
</div> </div>
</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>
<h4>Debugging</h4> <h4>Debugging</h4>
<div class="setting-item"> <div class="setting-item">

View File

@@ -1,5 +1,7 @@
import { EventEmitter } from '$lib/utils/EventEmitter'; import { EventEmitter } from '$lib/utils/EventEmitter';
import { GmcpHandler } from '$lib/gmcp/GmcpHandler'; import { GmcpHandler } from '$lib/gmcp/GmcpHandler';
import { get } from 'svelte/store';
import { connectionSettings } from '$lib/stores/mudStore';
// IAC codes for telnet negotiation // IAC codes for telnet negotiation
enum TelnetCommand { enum TelnetCommand {
@@ -130,6 +132,12 @@ export class MudConnection extends EventEmitter {
console.log(`Reconnecting with session ID: ${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}`); console.log(`Connecting to WebSocket server: ${wsUrl}`);
this.webSocket = new WebSocket(wsUrl); this.webSocket = new WebSocket(wsUrl);
@@ -241,7 +249,16 @@ export class MudConnection extends EventEmitter {
// Handle other system messages as needed // Handle other system messages as needed
if (systemData.type === 'session_resumed') { if (systemData.type === 'session_resumed') {
console.log('Session successfully resumed'); console.log('Session successfully resumed');
this.emit('session_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) { } catch (error) {
console.error('Error parsing system message:', error); console.error('Error parsing system message:', error);

View File

@@ -25,6 +25,11 @@ export interface Settings {
debugGmcp: boolean; debugGmcp: boolean;
globalVolume: number; globalVolume: number;
}; };
connection: {
persistenceTimeoutMinutes: number;
maxBufferMessages: number;
maxBufferSizeKB: number;
};
} }
export class SettingsManager extends EventEmitter { export class SettingsManager extends EventEmitter {
@@ -35,6 +40,7 @@ export class SettingsManager extends EventEmitter {
// Create our own stores rather than depending on the ones from mudStore // Create our own stores rather than depending on the ones from mudStore
public accessibilitySettings: Writable<Settings['accessibility']>; public accessibilitySettings: Writable<Settings['accessibility']>;
public uiSettings: Writable<Settings['ui']>; public uiSettings: Writable<Settings['ui']>;
public connectionSettings: Writable<Settings['connection']>;
constructor() { constructor() {
super(); super();
@@ -63,12 +69,18 @@ export class SettingsManager extends EventEmitter {
font: 'monospace', font: 'monospace',
debugGmcp: false, debugGmcp: false,
globalVolume: 0.7 globalVolume: 0.7
},
connection: {
persistenceTimeoutMinutes: 5,
maxBufferMessages: 100,
maxBufferSizeKB: 10
} }
}; };
// Create the stores with default values // Create the stores with default values
this.accessibilitySettings = writable(this.settings.accessibility); this.accessibilitySettings = writable(this.settings.accessibility);
this.uiSettings = writable(this.settings.ui); this.uiSettings = writable(this.settings.ui);
this.connectionSettings = writable(this.settings.connection);
// Set up subscriptions to save settings when they change // Set up subscriptions to save settings when they change
this.accessibilitySettings.subscribe(value => { this.accessibilitySettings.subscribe(value => {
@@ -91,6 +103,16 @@ export class SettingsManager extends EventEmitter {
} }
}); });
this.connectionSettings.subscribe(value => {
// Skip during initialization
if (!this.initialized) return;
if (typeof window !== 'undefined') {
// Use a small timeout to batch multiple rapid changes
setTimeout(() => this.saveSettings(), 100);
}
});
// Load settings from storage // Load settings from storage
this.loadSettings(); this.loadSettings();
} }
@@ -123,6 +145,10 @@ export class SettingsManager extends EventEmitter {
ui: { ui: {
...this.settings.ui, ...this.settings.ui,
...(parsedSettings.ui || {}) ...(parsedSettings.ui || {})
},
connection: {
...this.settings.connection,
...(parsedSettings.connection || {})
} }
}; };
@@ -131,6 +157,7 @@ export class SettingsManager extends EventEmitter {
// Set the stores without triggering the save callback // Set the stores without triggering the save callback
this.accessibilitySettings.set(this.settings.accessibility); this.accessibilitySettings.set(this.settings.accessibility);
this.uiSettings.set(this.settings.ui); this.uiSettings.set(this.settings.ui);
this.connectionSettings.set(this.settings.connection);
} else { } else {
console.warn('Invalid settings format found in localStorage'); console.warn('Invalid settings format found in localStorage');
} }
@@ -158,6 +185,7 @@ export class SettingsManager extends EventEmitter {
// Get current values from stores // Get current values from stores
this.settings.accessibility = get(this.accessibilitySettings); this.settings.accessibility = get(this.accessibilitySettings);
this.settings.ui = get(this.uiSettings); this.settings.ui = get(this.uiSettings);
this.settings.connection = get(this.connectionSettings);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.settings)); localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.settings));
console.log('Saved settings to localStorage'); console.log('Saved settings to localStorage');
@@ -177,6 +205,10 @@ export class SettingsManager extends EventEmitter {
this.uiSettings.update(current => ({...current, ...newSettings})); this.uiSettings.update(current => ({...current, ...newSettings}));
} }
public updateConnectionSettings(newSettings: Partial<Settings['connection']>): void {
this.connectionSettings.update(current => ({...current, ...newSettings}));
}
// Reset settings to defaults // Reset settings to defaults
public resetSettings(): void { public resetSettings(): void {
const defaults = { const defaults = {
@@ -202,11 +234,17 @@ export class SettingsManager extends EventEmitter {
font: 'monospace', font: 'monospace',
debugGmcp: false, debugGmcp: false,
globalVolume: 0.7 globalVolume: 0.7
},
connection: {
persistenceTimeoutMinutes: 5,
maxBufferMessages: 100,
maxBufferSizeKB: 10
} }
}; };
this.accessibilitySettings.set(defaults.accessibility); this.accessibilitySettings.set(defaults.accessibility);
this.uiSettings.set(defaults.ui); this.uiSettings.set(defaults.ui);
this.connectionSettings.set(defaults.connection);
this.emit('settingsReset', defaults); this.emit('settingsReset', defaults);
} }

View File

@@ -39,6 +39,7 @@ export const connectionStatus = writable<{ [key: string]: 'connected' | 'disconn
// Use the stores from SettingsManager // Use the stores from SettingsManager
export const accessibilitySettings = settingsManager.accessibilitySettings; export const accessibilitySettings = settingsManager.accessibilitySettings;
export const uiSettings = settingsManager.uiSettings; export const uiSettings = settingsManager.uiSettings;
export const connectionSettings = settingsManager.connectionSettings;
// Store for input history - keyed by profile ID // Store for input history - keyed by profile ID
export const inputHistory = writable<{ [profileId: string]: string[] }>({}); export const inputHistory = writable<{ [profileId: string]: string[] }>({});

View File

@@ -4,8 +4,11 @@ import * as tls from 'tls';
import http from 'http'; import http from 'http';
import { parse } from 'url'; import { parse } from 'url';
// Configuration for connection persistence // Default configuration for connection persistence (fallback values)
const CONNECTION_PERSISTENCE_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds 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 HEARTBEAT_INTERVAL = 30 * 1000; // 30 seconds const HEARTBEAT_INTERVAL = 30 * 1000; // 30 seconds
// Create HTTP server // Create HTTP server
@@ -15,17 +18,113 @@ const server = http.createServer();
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
// Active connections and their proxies // Active connections and their proxies
// Key: connectionId, Value: { ws, socket, sessionId, settings }
const connections = new Map(); const connections = new Map();
// Persistent connections waiting for reconnection // Persistent connections waiting for reconnection
// Key: sessionId, Value: { socket, mudHost, mudPort, useSSL, timeoutId, lastActivity } // Key: sessionId, Value: { socket, mudHost, mudPort, useSSL, timeoutId, lastActivity, messageBuffer, settings }
const persistentConnections = new Map(); 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 // Generate a unique session ID for persistent connections
function generateSessionId() { function generateSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 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 // Clean up a persistent connection
function cleanupPersistentConnection(sessionId) { function cleanupPersistentConnection(sessionId) {
const persistentConn = persistentConnections.get(sessionId); const persistentConn = persistentConnections.get(sessionId);
@@ -54,11 +153,16 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
// Create a unique ID for this connection // Create a unique ID for this connection
const connectionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const connectionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Check for session ID in query parameters for reconnection // Check for session ID and settings in query parameters
const url = req.url || ''; const url = req.url || '';
const urlParts = new URL(`http://localhost${url}`); const urlParts = new URL(`http://localhost${url}`);
const sessionId = urlParts.searchParams.get('sessionId'); 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 // Special handling for test connections
if (mudHost === 'example.com' && mudPort === '23') { if (mudHost === 'example.com' && mudPort === '23') {
console.log('Test connection detected - using echo server mode'); console.log('Test connection detected - using echo server mode');
@@ -98,11 +202,18 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
clearTimeout(persistentConn.timeoutId); clearTimeout(persistentConn.timeoutId);
} }
// Remove from persistent connections (now active again) // Replay any buffered messages first
const replayedCount = replayBufferedMessages(ws, sessionId);
// Remove from persistent connections (now active again) - do this after replay
persistentConnections.delete(sessionId); persistentConnections.delete(sessionId);
// Send reconnection notification with session ID in proper JSON format // Send reconnection notification with session ID in proper JSON format
ws.send(`[SYSTEM]${JSON.stringify({ type: 'session_resumed', sessionId: sessionId })}`); ws.send(`[SYSTEM]${JSON.stringify({
type: 'session_resumed',
sessionId: sessionId,
messagesReplayed: replayedCount
})}`);
} else { } else {
// Create new connection // Create new connection
currentSessionId = generateSessionId(); currentSessionId = generateSessionId();
@@ -142,8 +253,13 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
} }
} }
// Store the connection // Store the connection with its settings
connections.set(connectionId, { ws, socket, sessionId: currentSessionId }); connections.set(connectionId, {
ws,
socket,
sessionId: currentSessionId,
settings: connectionSettings
});
// Handle data from the MUD server - only in regular mode, not test mode // Handle data from the MUD server - only in regular mode, not test mode
if (socket) { if (socket) {
@@ -163,6 +279,11 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
if (ws.readyState === 1) { // WebSocket.OPEN if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(data); ws.send(data);
console.log(`WebSocket server: Sent ${data.length} bytes to client${isGmcp ? ' (contains GMCP 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);
}
} }
}); });
} }
@@ -276,13 +397,14 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
const conn = connections.get(connectionId); const conn = connections.get(connectionId);
if (conn && !conn.testMode && conn.socket && !conn.socket.destroyed) { if (conn && !conn.testMode && conn.socket && !conn.socket.destroyed) {
console.log(`Moving connection to persistent state for ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`); console.log(`Moving connection to persistent state for ${conn.settings.persistenceTimeoutMs / 1000} seconds`);
// Move the connection to persistent storage instead of closing it // Move the connection to persistent storage instead of closing it
// Use this connection's specific timeout setting
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.log(`Session ${currentSessionId} timed out, closing MUD connection`); console.log(`Session ${currentSessionId} timed out, closing MUD connection`);
cleanupPersistentConnection(currentSessionId); cleanupPersistentConnection(currentSessionId);
}, CONNECTION_PERSISTENCE_TIMEOUT); }, conn.settings.persistenceTimeoutMs);
persistentConnections.set(currentSessionId, { persistentConnections.set(currentSessionId, {
socket: conn.socket, socket: conn.socket,
@@ -290,10 +412,13 @@ wss.on('connection', (ws, req, mudHost, mudPort, useSSL) => {
mudPort, mudPort,
useSSL, useSSL,
timeoutId, timeoutId,
lastActivity: Date.now() lastActivity: Date.now(),
messageBuffer: [],
bufferSize: 0,
settings: conn.settings // Store the connection's settings
}); });
console.log(`Session ${currentSessionId} will persist for ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`); 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) { } else if (conn && conn.socket) {
// Fallback to immediate cleanup if needed // Fallback to immediate cleanup if needed
conn.socket.end(); conn.socket.end();
@@ -347,18 +472,20 @@ setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [sessionId, persistentConn] of persistentConnections.entries()) { for (const [sessionId, persistentConn] of persistentConnections.entries()) {
// Clean up connections that have been inactive for too long // Clean up connections that have been inactive for too long
if (now - persistentConn.lastActivity > CONNECTION_PERSISTENCE_TIMEOUT * 2) { // 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}`); console.log(`Cleaning up abandoned session: ${sessionId}`);
cleanupPersistentConnection(sessionId); cleanupPersistentConnection(sessionId);
} }
} }
}, CONNECTION_PERSISTENCE_TIMEOUT); }, DEFAULT_PERSISTENCE_TIMEOUT); // Run cleanup every default timeout period
// Start the WebSocket server // Start the WebSocket server
const PORT = process.env.WS_PORT || 3001; const PORT = process.env.WS_PORT || 3001;
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`WebSocket server is running on port ${PORT}`); console.log(`WebSocket server is running on port ${PORT}`);
console.log(`Connection persistence timeout: ${CONNECTION_PERSISTENCE_TIMEOUT / 1000} seconds`); console.log(`Default connection persistence timeout: ${DEFAULT_PERSISTENCE_TIMEOUT / 1000} seconds (configurable per connection)`);
}); });
export default server; export default server;