From 542d0bb28f67c307138562cf0da2d17d23bdbec7 Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 9 Feb 2026 00:44:59 +0100 Subject: [PATCH 1/3] feat(environment): add socket and telnet keepalive configurations - Introduced new environment variables for TCP keepalive and Socket.IO settings. - Updated the environment interface and keys to include socket ping interval, timeout, and telnet keepalive delay. - Enhanced the SocketManager and TelnetClient to utilize the new configurations. - Modified the server configuration service to expose new socket settings. --- README.md | 3 +++ backend/src/core/environment/environment.ts | 15 +++++++++++++ .../environment/types/environment-keys.ts | 3 +++ .../src/core/environment/types/environment.ts | 3 +++ .../core/middleware/use-config-endpoint.ts | 3 +++ backend/src/core/sockets/socket-manager.ts | 9 ++++++-- backend/src/features/telnet/telnet-client.ts | 19 +++++++++++++++- .../serverconfig/server-config.service.ts | 22 +++++++++++++++++++ .../app/features/sockets/sockets.service.ts | 16 +++++++++++++- frontend/src/environments/environment.ts | 2 +- shared/src/config/server-config.ts | 16 ++++++++++++++ 11 files changed, 106 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b0696be3..a874c364 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ NAME=webmud3 # Optional | defaults to 'webmud3' TELNET_HOST=127.0.0.1 # Required | the IP of your MUD TELNET_PORT=23 # Required | the PORT of your MUD TELNET_TLS=false # Optional | defaults to 'false' | set this to true if you want a secure connection +TELNET_KEEPALIVE_DELAY=30000 # Optional | defaults to 30000 (30s) | TCP keepalive interval for telnet connections +SOCKET_PING_INTERVAL=25000 # Optional | defaults to 25000 (25s) | Socket.IO ping interval +SOCKET_PING_TIMEOUT=20000 # Optional | defaults to 20000 (20s) | Socket.IO ping timeout SOCKET_TIMEOUT=900000 # Optional | defaults to 900000 (15 min) | timeout for any lost frontend <-> backend connection SOCKET_ROOT=/socket.io # Required | URL for the socket connection. e.g. 'https://mud.example.com/socket.io' ENVIRONMENT='development' # Optional | accepts values 'development' or 'production' | defaults to 'production' | Enables Debug REST Endpoint /api/info and allows for permissive CORS if set to 'development' diff --git a/backend/src/core/environment/environment.ts b/backend/src/core/environment/environment.ts index 585c7259..995be657 100644 --- a/backend/src/core/environment/environment.ts +++ b/backend/src/core/environment/environment.ts @@ -19,7 +19,10 @@ export class Environment implements IEnvironment { public readonly telnetTLS: boolean; public readonly projectRoot: string; public readonly socketRoot: string; + public readonly socketPingInterval: number; + public readonly socketPingTimeout: number; public readonly socketTimeout: number; + public readonly telnetKeepAliveDelay: number; public readonly environment: 'production' | 'development'; public readonly name: string; public readonly corsAllowList: string[]; @@ -49,10 +52,22 @@ export class Environment implements IEnvironment { this.socketRoot = String(getEnvironmentVariable('SOCKET_ROOT')); + this.socketPingInterval = Number( + getEnvironmentVariable('SOCKET_PING_INTERVAL', false, '25000'), + ); + + this.socketPingTimeout = Number( + getEnvironmentVariable('SOCKET_PING_TIMEOUT', false, '20000'), + ); + this.socketTimeout = Number( getEnvironmentVariable('SOCKET_TIMEOUT', false, '900000'), ); + this.telnetKeepAliveDelay = Number( + getEnvironmentVariable('TELNET_KEEPALIVE_DELAY', false, '30000'), + ); + const environment = String( getEnvironmentVariable('ENVIRONMENT', false, 'production'), ).toLocaleLowerCase(); diff --git a/backend/src/core/environment/types/environment-keys.ts b/backend/src/core/environment/types/environment-keys.ts index 4c181d95..368647ef 100644 --- a/backend/src/core/environment/types/environment-keys.ts +++ b/backend/src/core/environment/types/environment-keys.ts @@ -5,6 +5,9 @@ export type EnvironmentKeys = | 'TELNET_HOST' // Required | the IP of your MUD | 'TELNET_PORT' // Required | the PORT of your MUD | 'TELNET_TLS' // Optional | defaults to 'false' | set this to true if you want a secure connection + | 'TELNET_KEEPALIVE_DELAY' // in milliseconds | default: 30000 (30s) | TCP keepalive interval for telnet connections + | 'SOCKET_PING_INTERVAL' // in milliseconds | default: 25000 (25s) | Socket.IO ping interval + | 'SOCKET_PING_TIMEOUT' // in milliseconds | default: 20000 (20s) | Socket.IO ping timeout | 'SOCKET_TIMEOUT' // in milliseconds | default: 900000 (15 min) | determines how long messages are buffed for the disconnected frontend and when the telnet connection is closed | 'SOCKET_ROOT' // Required | URL for the socket connection. e.g. 'https://mud.example.com/socket.io' | 'ENVIRONMENT' // Optional | accepts values 'development' or 'production' | defaults to 'production' | Enables Debug REST Endpoint /api/info and allows for permissive CORS if set to 'development' diff --git a/backend/src/core/environment/types/environment.ts b/backend/src/core/environment/types/environment.ts index 49e7b502..41887989 100644 --- a/backend/src/core/environment/types/environment.ts +++ b/backend/src/core/environment/types/environment.ts @@ -5,7 +5,10 @@ export interface IEnvironment { readonly name: string; readonly projectRoot: string; readonly socketRoot: string; + readonly socketPingInterval: number; + readonly socketPingTimeout: number; readonly socketTimeout: number; + readonly telnetKeepAliveDelay: number; readonly environment: 'production' | 'development'; readonly corsAllowList: string[]; } diff --git a/backend/src/core/middleware/use-config-endpoint.ts b/backend/src/core/middleware/use-config-endpoint.ts index fb4b3348..c1bb57a0 100644 --- a/backend/src/core/middleware/use-config-endpoint.ts +++ b/backend/src/core/middleware/use-config-endpoint.ts @@ -11,6 +11,9 @@ export const useConfigEndpoint = (app: Express) => { res.json({ socketNamespace: environment.socketRoot, + socketPingInterval: environment.socketPingInterval, + socketPingTimeout: environment.socketPingTimeout, + socketTimeout: environment.socketTimeout, }); }); }; diff --git a/backend/src/core/sockets/socket-manager.ts b/backend/src/core/sockets/socket-manager.ts index 81f1fb19..f81be467 100644 --- a/backend/src/core/sockets/socket-manager.ts +++ b/backend/src/core/sockets/socket-manager.ts @@ -34,11 +34,15 @@ export class SocketManager extends Server< clientName: string; }, ) { + const environment = Environment.getInstance(); + super(server, { path: managerOptions.socketRoot, - transports: ['websocket'], + transports: ['websocket', 'polling'], + pingInterval: environment.socketPingInterval, + pingTimeout: environment.socketPingTimeout, connectionStateRecovery: { - maxDisconnectionDuration: Environment.getInstance().socketTimeout, + maxDisconnectionDuration: environment.socketTimeout, }, }); @@ -286,6 +290,7 @@ export class SocketManager extends Server< this.managerOptions.clientName, { initialViewPort, + keepAliveDelayMs: Environment.getInstance().telnetKeepAliveDelay, }, ); diff --git a/backend/src/features/telnet/telnet-client.ts b/backend/src/features/telnet/telnet-client.ts index c4bf2279..e840b0d7 100644 --- a/backend/src/features/telnet/telnet-client.ts +++ b/backend/src/features/telnet/telnet-client.ts @@ -86,7 +86,10 @@ export class TelnetClient extends EventEmitter { telnetPort: number, useTls: boolean, clientName: string, - extraOptions?: { initialViewPort: { columns: number; rows: number } }, + extraOptions?: { + initialViewPort: { columns: number; rows: number }; + keepAliveDelayMs?: number; + }, ) { super(); @@ -96,6 +99,20 @@ export class TelnetClient extends EventEmitter { telnetPort, ); + if (extraOptions?.keepAliveDelayMs !== undefined) { + // Enable TCP keepalive to reduce idle disconnects on intermediaries. + telnetConnection.setKeepAlive(true, extraOptions.keepAliveDelayMs); + } + + telnetConnection.on('error', (error) => { + logger.error( + `[${this.socketId}] [Telnet-Client] Telnet socket error`, + { + error, + }, + ); + }); + if (useTls) { logger.info( `[${this.socketId}] [Telnet-Client] created https connection for telnet`, diff --git a/frontend/src/app/features/serverconfig/server-config.service.ts b/frontend/src/app/features/serverconfig/server-config.service.ts index f90859cb..fc324ff0 100644 --- a/frontend/src/app/features/serverconfig/server-config.service.ts +++ b/frontend/src/app/features/serverconfig/server-config.service.ts @@ -44,6 +44,28 @@ export class ServerConfigService { return this.serverConfiguration.socketNamespace; } + public getSocketTransports(): string[] { + const transports = this.serverConfiguration?.socketTransports; + + if (transports && transports.length > 0) { + return transports; + } + + return ['websocket', 'polling']; + } + + public getSocketPingInterval(): number { + return this.serverConfiguration?.socketPingInterval ?? 25000; + } + + public getSocketPingTimeout(): number { + return this.serverConfiguration?.socketPingTimeout ?? 20000; + } + + public getSocketTimeout(): number { + return this.serverConfiguration?.socketTimeout ?? 900000; + } + public getBackendUrl(): string { return environment.backendUrl(); } diff --git a/frontend/src/app/features/sockets/sockets.service.ts b/frontend/src/app/features/sockets/sockets.service.ts index ac5279f3..bc095042 100644 --- a/frontend/src/app/features/sockets/sockets.service.ts +++ b/frontend/src/app/features/sockets/sockets.service.ts @@ -29,6 +29,7 @@ export class SocketsService { private readonly inputQueue: string[] = []; private isReconnecting = false; private sessionToken: string; + private forceNewSession = false; public onMudConnect = new EventEmitter(); // Emits isNewConnection public onMudDisconnect = new EventEmitter(); @@ -54,7 +55,7 @@ export class SocketsService { this.manager = new Manager(socketUrl, { path: socketNamespace, - transports: ['websocket'], + transports: serverConfigService.getSocketTransports(), reconnectionAttempts: Infinity, reconnection: true, }); @@ -146,6 +147,11 @@ export class SocketsService { columns: number; rows: number; }): void { + if (this.forceNewSession) { + this.resetSessionToken(); + this.forceNewSession = false; + } + console.log( `[Sockets] Sockets-Service: 'connectToMud' with sessionToken: ${this.sessionToken}`, ); @@ -291,6 +297,7 @@ export class SocketsService { private handleReconnectFailed = () => { this.connectedToServer.next(false); + this.forceNewSession = true; console.error('[Sockets] Sockets-Service: Reconnect Failed'); }; @@ -392,4 +399,11 @@ export class SocketsService { }, ); } + + private resetSessionToken(): void { + const newToken = this.generateUUID(); + this.sessionToken = newToken; + this.saveSessionToken(newToken); + this.socket.auth = { sessionToken: newToken }; + } } diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index fb92c79d..acf2c3c6 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -7,7 +7,7 @@ import { Environment } from './environment.interface'; export const environment: Environment = { production: false, // Change this to your local IP if you want to test on a mobile device in the same network - backendUrl: () => "http://localhost:5000", + backendUrl: () => 'http://192.168.178.76:5000', }; /* diff --git a/shared/src/config/server-config.ts b/shared/src/config/server-config.ts index ab1bf4b2..677d3a3d 100644 --- a/shared/src/config/server-config.ts +++ b/shared/src/config/server-config.ts @@ -6,4 +6,20 @@ export interface ServerConfig { * Socket.IO path or namespace exposed by the backend server. */ socketNamespace: string; + /** + * Allowed Socket.IO transports. + */ + socketTransports?: string[]; + /** + * Socket.IO ping interval in milliseconds. + */ + socketPingInterval?: number; + /** + * Socket.IO ping timeout in milliseconds. + */ + socketPingTimeout?: number; + /** + * Maximum disconnection duration for session recovery. + */ + socketTimeout?: number; } From 8b28313d2572dded217838c282f0166bf601825a Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 9 Feb 2026 00:50:33 +0100 Subject: [PATCH 2/3] refactor(socket): remove unused socket configuration properties --- .../core/middleware/use-config-endpoint.ts | 3 --- .../serverconfig/server-config.service.ts | 22 ------------------- .../app/features/sockets/sockets.service.ts | 2 +- shared/src/config/server-config.ts | 16 -------------- 4 files changed, 1 insertion(+), 42 deletions(-) diff --git a/backend/src/core/middleware/use-config-endpoint.ts b/backend/src/core/middleware/use-config-endpoint.ts index c1bb57a0..fb4b3348 100644 --- a/backend/src/core/middleware/use-config-endpoint.ts +++ b/backend/src/core/middleware/use-config-endpoint.ts @@ -11,9 +11,6 @@ export const useConfigEndpoint = (app: Express) => { res.json({ socketNamespace: environment.socketRoot, - socketPingInterval: environment.socketPingInterval, - socketPingTimeout: environment.socketPingTimeout, - socketTimeout: environment.socketTimeout, }); }); }; diff --git a/frontend/src/app/features/serverconfig/server-config.service.ts b/frontend/src/app/features/serverconfig/server-config.service.ts index fc324ff0..f90859cb 100644 --- a/frontend/src/app/features/serverconfig/server-config.service.ts +++ b/frontend/src/app/features/serverconfig/server-config.service.ts @@ -44,28 +44,6 @@ export class ServerConfigService { return this.serverConfiguration.socketNamespace; } - public getSocketTransports(): string[] { - const transports = this.serverConfiguration?.socketTransports; - - if (transports && transports.length > 0) { - return transports; - } - - return ['websocket', 'polling']; - } - - public getSocketPingInterval(): number { - return this.serverConfiguration?.socketPingInterval ?? 25000; - } - - public getSocketPingTimeout(): number { - return this.serverConfiguration?.socketPingTimeout ?? 20000; - } - - public getSocketTimeout(): number { - return this.serverConfiguration?.socketTimeout ?? 900000; - } - public getBackendUrl(): string { return environment.backendUrl(); } diff --git a/frontend/src/app/features/sockets/sockets.service.ts b/frontend/src/app/features/sockets/sockets.service.ts index bc095042..2f9efeba 100644 --- a/frontend/src/app/features/sockets/sockets.service.ts +++ b/frontend/src/app/features/sockets/sockets.service.ts @@ -55,7 +55,7 @@ export class SocketsService { this.manager = new Manager(socketUrl, { path: socketNamespace, - transports: serverConfigService.getSocketTransports(), + transports: ['websocket', 'polling'], reconnectionAttempts: Infinity, reconnection: true, }); diff --git a/shared/src/config/server-config.ts b/shared/src/config/server-config.ts index 677d3a3d..ab1bf4b2 100644 --- a/shared/src/config/server-config.ts +++ b/shared/src/config/server-config.ts @@ -6,20 +6,4 @@ export interface ServerConfig { * Socket.IO path or namespace exposed by the backend server. */ socketNamespace: string; - /** - * Allowed Socket.IO transports. - */ - socketTransports?: string[]; - /** - * Socket.IO ping interval in milliseconds. - */ - socketPingInterval?: number; - /** - * Socket.IO ping timeout in milliseconds. - */ - socketPingTimeout?: number; - /** - * Maximum disconnection duration for session recovery. - */ - socketTimeout?: number; } From 6dcb2ca9911b5e0e8d29dc956cb998223d10e17b Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 9 Feb 2026 15:54:38 +0100 Subject: [PATCH 3/3] fix(sockets): add documentation for session discarding --- frontend/src/app/features/sockets/sockets.service.ts | 5 +++++ frontend/src/environments/environment.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/sockets/sockets.service.ts b/frontend/src/app/features/sockets/sockets.service.ts index 2f9efeba..ebc567e5 100644 --- a/frontend/src/app/features/sockets/sockets.service.ts +++ b/frontend/src/app/features/sockets/sockets.service.ts @@ -29,6 +29,7 @@ export class SocketsService { private readonly inputQueue: string[] = []; private isReconnecting = false; private sessionToken: string; + // Forces a fresh session token after reconnect failed to avoid reusing a dead backend session. private forceNewSession = false; public onMudConnect = new EventEmitter(); // Emits isNewConnection @@ -400,6 +401,10 @@ export class SocketsService { ); } + /** + * Resets the session token to force a clean backend session on next connect. + * This is used after a reconnect failure to avoid reusing a potentially dead backend session. + */ private resetSessionToken(): void { const newToken = this.generateUUID(); this.sessionToken = newToken; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index acf2c3c6..612cc3a1 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -7,7 +7,7 @@ import { Environment } from './environment.interface'; export const environment: Environment = { production: false, // Change this to your local IP if you want to test on a mobile device in the same network - backendUrl: () => 'http://192.168.178.76:5000', + backendUrl: () => 'http://localhost:5000', }; /*