overhaul
This commit is contained in:
66 files changed
+9227
-3679
No files matched your search
@@ -0,0 +1,240 @@
|
||||
import WebSocket from 'ws';
|
||||
import type { Logger } from '../../lib/logger.js';
|
||||
import { BaseConsoleHub, mapPowerState, type LiveStats } from './console-hub.js';
|
||||
|
||||
type WebsocketCredentials = { token: string; socket: string };
|
||||
|
||||
type WingsMessage = { event?: string; args?: unknown[] };
|
||||
|
||||
const CREDENTIAL_TIMEOUT_MS = 10_000;
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
/** Wings tokens last 10 minutes; refresh well before the warning arrives. */
|
||||
const TOKEN_REFRESH_MS = 8 * 60_000;
|
||||
|
||||
/**
|
||||
* Keeps one authenticated Wings websocket open for the server and republishes
|
||||
* everything it emits: console output (including the SteamCMD/mod-download
|
||||
* phase that never reaches the game's own log file), install output, power
|
||||
* state transitions, and resource frames.
|
||||
*
|
||||
* One upstream connection is shared by every panel viewer, and the base class
|
||||
* keeps a line backlog so a browser attaching mid-session immediately sees
|
||||
* context instead of an empty pane.
|
||||
*/
|
||||
export class WingsConsoleHub extends BaseConsoleHub {
|
||||
private socket: WebSocket | null = null;
|
||||
private stopped = true;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
serverId: string;
|
||||
logger: Logger;
|
||||
fetchImpl?: typeof fetch;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.stopped) return;
|
||||
this.stopped = false;
|
||||
this.refreshTimer = setInterval(() => void this.reauthenticate(), TOKEN_REFRESH_MS);
|
||||
this.refreshTimer.unref?.();
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.refreshTimer = null;
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.setConnected(false);
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
/** Fetches a fresh websocket token from the Pterodactyl client API. */
|
||||
private async fetchCredentials(): Promise<WebsocketCredentials> {
|
||||
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(
|
||||
this.options.serverId,
|
||||
)}/websocket`;
|
||||
const response = await this.fetchImpl(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(CREDENTIAL_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`websocket credentials returned HTTP ${response.status}`);
|
||||
}
|
||||
const body = (await response.json()) as { data?: { token?: string; socket?: string } };
|
||||
const token = body.data?.token;
|
||||
const socket = body.data?.socket;
|
||||
if (!token || !socket) throw new Error('websocket credentials response was incomplete');
|
||||
return { token, socket };
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempt);
|
||||
this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 5);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.connect();
|
||||
}, delay);
|
||||
this.reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.stopped || this.socket) return;
|
||||
let credentials: WebsocketCredentials;
|
||||
try {
|
||||
credentials = await this.fetchCredentials();
|
||||
} catch (error) {
|
||||
this.options.logger.warn(
|
||||
{ err: error instanceof Error ? error.message : String(error) },
|
||||
'wings websocket credentials failed',
|
||||
);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
if (this.stopped) return;
|
||||
|
||||
// Wings only upgrades connections whose Origin matches the panel URL
|
||||
// (see wings router/websocket GetHandler.CheckOrigin), which is why this
|
||||
// uses `ws` rather than Node's built-in WebSocket.
|
||||
const socket = new WebSocket(credentials.socket, { origin: this.baseUrl });
|
||||
this.socket = socket;
|
||||
|
||||
socket.on('open', () => {
|
||||
this.reconnectAttempt = 0;
|
||||
this.setConnected(true);
|
||||
this.send(socket, 'auth', credentials.token);
|
||||
});
|
||||
|
||||
socket.on('message', (raw: WebSocket.RawData) => this.handleMessage(String(raw)));
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
this.options.logger.debug({ err: error.message }, 'wings websocket error');
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
if (this.socket === socket) this.socket = null;
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
private send(socket: WebSocket, event: string, ...args: string[]): void {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify({ event, args }));
|
||||
}
|
||||
|
||||
/** Re-auths the live socket with a fresh token before the old one expires. */
|
||||
private async reauthenticate(): Promise<void> {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const { token } = await this.fetchCredentials();
|
||||
this.send(socket, 'auth', token);
|
||||
} catch (error) {
|
||||
this.options.logger.debug(
|
||||
{ err: error instanceof Error ? error.message : String(error) },
|
||||
'wings token refresh failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(raw: string): void {
|
||||
let message: WingsMessage;
|
||||
try {
|
||||
message = JSON.parse(raw) as WingsMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const arg = typeof message.args?.[0] === 'string' ? (message.args[0] as string) : '';
|
||||
|
||||
switch (message.event) {
|
||||
case 'auth success':
|
||||
// Ask for the recent backlog and an immediate resource frame so the
|
||||
// panel is populated the moment it attaches.
|
||||
if (this.socket) {
|
||||
this.send(this.socket, 'send logs');
|
||||
this.send(this.socket, 'send stats');
|
||||
}
|
||||
break;
|
||||
case 'console output':
|
||||
this.pushOutput('console', arg);
|
||||
break;
|
||||
case 'install output':
|
||||
this.pushOutput('install', arg);
|
||||
break;
|
||||
case 'install started':
|
||||
this.pushOutput('daemon', 'Installation started.');
|
||||
break;
|
||||
case 'install completed':
|
||||
this.pushOutput('daemon', 'Installation completed.');
|
||||
break;
|
||||
case 'daemon message':
|
||||
this.pushOutput('daemon', arg);
|
||||
break;
|
||||
case 'daemon error':
|
||||
case 'jwt error':
|
||||
this.pushOutput('daemon', arg);
|
||||
void this.reauthenticate();
|
||||
break;
|
||||
case 'token expiring':
|
||||
case 'token expired':
|
||||
void this.reauthenticate();
|
||||
break;
|
||||
case 'status':
|
||||
this.setStatus(mapPowerState(arg));
|
||||
break;
|
||||
case 'stats':
|
||||
this.handleStats(arg);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleStats(payload: string): void {
|
||||
let parsed: {
|
||||
state?: string;
|
||||
cpu_absolute?: number;
|
||||
memory_bytes?: number;
|
||||
disk_bytes?: number;
|
||||
uptime?: number;
|
||||
network?: { rx_bytes?: number; tx_bytes?: number };
|
||||
};
|
||||
try {
|
||||
parsed = JSON.parse(payload) as typeof parsed;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stats: LiveStats = {
|
||||
status: mapPowerState(parsed.state),
|
||||
cpuPercent: parsed.cpu_absolute ?? 0,
|
||||
memoryBytes: parsed.memory_bytes ?? 0,
|
||||
diskBytes: parsed.disk_bytes ?? 0,
|
||||
networkRxBytes: parsed.network?.rx_bytes ?? 0,
|
||||
networkTxBytes: parsed.network?.tx_bytes ?? 0,
|
||||
uptimeMs: parsed.uptime ?? 0,
|
||||
at: Date.now(),
|
||||
};
|
||||
this.setStats(stats);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user