import grpc from '@grpc/grpc-js'; import protoLoader from '@grpc/proto-loader'; import type { PowerAction } from '@source/shared'; import { PROTO_PATH } from '@source/proto'; export interface DaemonNodeConnection { fqdn: string; grpcPort: number; daemonToken: string; } export interface DaemonPortMapping { host_port: number; container_port: number; protocol: 'tcp' | 'udp'; } export interface DaemonCreateServerRequest { uuid: string; docker_image: string; memory_limit: number; disk_limit: number; cpu_limit: number; startup_command: string; environment: Record; ports: DaemonPortMapping[]; install_plugin_urls: string[]; } interface DaemonServerResponse { uuid: string; status: string; } interface DaemonNodeStatusRaw { version: string; is_healthy: boolean; uptime_seconds: number; active_servers: number; } interface DaemonNodeStatsRaw { cpu_percent: number; memory_used: number; memory_total: number; disk_used: number; disk_total: number; } interface DaemonStatusResponse { uuid: string; state: string; } interface EmptyResponse { [key: string]: never; } interface DaemonFileListResponseRaw { files: { name: string; path: string; is_directory: boolean; size: number; modified_at: number; mime_type: string; }[]; } interface DaemonFileContentRaw { data: Uint8Array | Buffer; mime_type: string; } interface DaemonPlayerListRaw { players: { name: string; uuid: string; connected_at: number; }[]; max_players: number; } interface DaemonBackupResponseRaw { backup_id: string; size_bytes: number; checksum: string; success: boolean; } export interface DaemonConsoleOutput { uuid: string; line: string; timestamp: number; } export interface DaemonConsoleStreamHandle { stream: grpc.ClientReadableStream; close: () => void; } export interface DaemonFileEntry { name: string; path: string; isDirectory: boolean; size: number; modifiedAt: number; mimeType: string; } export interface DaemonPlayersResponse { players: Array<{ name: string; id: string; connectedAt: number; }>; maxPlayers: number; } export interface DaemonBackupResponse { backupId: string; sizeBytes: number; checksum: string; success: boolean; } export interface DaemonNodeStatus { version: string; isHealthy: boolean; uptimeSeconds: number; activeServers: number; } export interface DaemonNodeStats { cpuPercent: number; memoryUsed: number; memoryTotal: number; diskUsed: number; diskTotal: number; } type UnaryCallback = (error: grpc.ServiceError | null, response: TResponse) => void; interface DaemonServiceClient extends grpc.Client { getNodeStatus( request: EmptyResponse, metadata: grpc.Metadata, callback: UnaryCallback, ): void; streamNodeStats( request: EmptyResponse, metadata: grpc.Metadata, ): grpc.ClientReadableStream; createServer( request: DaemonCreateServerRequest, metadata: grpc.Metadata, callback: UnaryCallback, ): void; deleteServer( request: { uuid: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; setPowerState( request: { uuid: string; action: number }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; getServerStatus( request: { uuid: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; streamConsole( request: { uuid: string }, metadata: grpc.Metadata, ): grpc.ClientReadableStream; sendCommand( request: { uuid: string; command: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; listFiles( request: { uuid: string; path: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; readFile( request: { uuid: string; path: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; writeFile( request: { uuid: string; path: string; data: Uint8Array | Buffer }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; deleteFiles( request: { uuid: string; paths: string[] }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; createBackup( request: { server_uuid: string; backup_id: string; cdn_upload_url?: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; restoreBackup( request: { server_uuid: string; backup_id: string; cdn_download_url?: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; deleteBackup( request: { server_uuid: string; backup_id: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; getActivePlayers( request: { uuid: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; } const packageDefinition = protoLoader.loadSync(PROTO_PATH, { keepCase: true, longs: Number, enums: Number, defaults: true, oneofs: true, }); const loaded = grpc.loadPackageDefinition(packageDefinition) as { gamepanel?: { daemon?: { DaemonService?: grpc.ServiceClientConstructor; }; }; }; const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService; if (!DaemonServiceCtor) { throw new Error('Failed to load DaemonService gRPC definition'); } const DaemonService = DaemonServiceCtor; const POWER_ACTIONS: Record = { start: 0, stop: 1, restart: 2, kill: 3, }; const MAX_GRPC_MESSAGE_BYTES = 32 * 1024 * 1024; function buildGrpcTarget(fqdn: string, grpcPort: number): string { const trimmed = fqdn.trim(); if (!trimmed) throw new Error('Node FQDN is empty'); let host = trimmed; if (trimmed.includes('://')) { try { const parsed = new URL(trimmed); host = parsed.hostname || parsed.host; if (!host) throw new Error('Node FQDN has no hostname'); } catch { // Fall through to raw handling below. } } const withoutPath = host.replace(/\/.*$/, ''); if (/^\[.+\](?::\d+)?$/.test(withoutPath)) { const innerHost = withoutPath .replace(/^\[/, '') .replace(/\](?::\d+)?$/, ''); return `[${innerHost}]:${grpcPort}`; } if (/^[^:]+:\d+$/.test(withoutPath)) { const hostOnly = withoutPath.replace(/:\d+$/, ''); return `${hostOnly}:${grpcPort}`; } if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`; return `${withoutPath}:${grpcPort}`; } function getMetadata(daemonToken: string): grpc.Metadata { const metadata = new grpc.Metadata(); metadata.set('authorization', `Bearer ${daemonToken}`); return metadata; } function createClient(node: DaemonNodeConnection): DaemonServiceClient { const target = buildGrpcTarget(node.fqdn, node.grpcPort); return new DaemonService( target, grpc.credentials.createInsecure(), { 'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES, 'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES, }, ) as unknown as DaemonServiceClient; } function waitForReady(client: grpc.Client, timeoutMs: number): Promise { return new Promise((resolve, reject) => { client.waitForReady(Date.now() + timeoutMs, (err) => { if (err) { reject(err); return; } resolve(); }); }); } function callUnary( invoke: (callback: UnaryCallback) => void, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { let completed = false; const timeout = setTimeout(() => { if (completed) return; completed = true; reject(new Error(`gRPC request timed out after ${timeoutMs}ms`)); }, timeoutMs); invoke((error, response) => { if (completed) return; completed = true; clearTimeout(timeout); if (error) { reject(error); return; } resolve(response); }); }); } function readFirstStreamMessage( stream: grpc.ClientReadableStream, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { let completed = false; const timeout = setTimeout(() => { if (completed) return; completed = true; reject(new Error(`gRPC stream timed out after ${timeoutMs}ms`)); }, timeoutMs); const onData = (message: TMessage) => { if (completed) return; completed = true; clearTimeout(timeout); resolve(message); }; const onError = (error: Error) => { if (completed) return; completed = true; clearTimeout(timeout); reject(error); }; const onEnd = () => { if (completed) return; completed = true; clearTimeout(timeout); reject(new Error('gRPC stream ended before first message')); }; stream.on('data', onData); stream.on('error', onError); stream.on('end', onEnd); }); } function toBuffer(data: Uint8Array | Buffer): Buffer { if (Buffer.isBuffer(data)) return data; return Buffer.from(data); } const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; const DEFAULT_RPC_TIMEOUT_MS = 20_000; export async function daemonGetNodeStatus( node: DaemonNodeConnection, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( (callback) => client.getNodeStatus({}, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); return { version: response.version, isHealthy: response.is_healthy, uptimeSeconds: Number(response.uptime_seconds), activeServers: Number(response.active_servers), }; } finally { client.close(); } } export async function daemonGetNodeStats( node: DaemonNodeConnection, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const stream = client.streamNodeStats({}, getMetadata(node.daemonToken)); const response = await readFirstStreamMessage(stream, DEFAULT_RPC_TIMEOUT_MS); return { cpuPercent: Number(response.cpu_percent), memoryUsed: Number(response.memory_used), memoryTotal: Number(response.memory_total), diskUsed: Number(response.disk_used), diskTotal: Number(response.disk_total), }; } finally { client.close(); } } export async function daemonCreateServer( node: DaemonNodeConnection, request: DaemonCreateServerRequest, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); return await callUnary( (callback) => client.createServer(request, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonDeleteServer( node: DaemonNodeConnection, serverUuid: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonSetPowerState( node: DaemonNodeConnection, serverUuid: string, action: PowerAction, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonGetServerStatus( node: DaemonNodeConnection, serverUuid: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); return await callUnary( (callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonOpenConsoleStream( node: DaemonNodeConnection, serverUuid: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken)); const close = () => { try { stream.cancel(); } catch { // no-op } client.close(); }; stream.on('end', () => client.close()); stream.on('error', () => client.close()); return { stream, close }; } catch (error) { client.close(); throw error; } } export async function daemonSendCommand( node: DaemonNodeConnection, serverUuid: string, command: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonListFiles( node: DaemonNodeConnection, serverUuid: string, path: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( (callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); return response.files.map((file) => ({ name: file.name, path: file.path, isDirectory: file.is_directory, size: Number(file.size), modifiedAt: Number(file.modified_at), mimeType: file.mime_type, })); } finally { client.close(); } } export async function daemonReadFile( node: DaemonNodeConnection, serverUuid: string, path: string, ): Promise<{ data: Buffer; mimeType: string }> { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( (callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); return { data: toBuffer(response.data), mimeType: response.mime_type, }; } finally { client.close(); } } export async function daemonWriteFile( node: DaemonNodeConnection, serverUuid: string, path: string, data: string | Buffer, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.writeFile( { uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data }, getMetadata(node.daemonToken), callback, ), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonDeleteFiles( node: DaemonNodeConnection, serverUuid: string, paths: string[], ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonCreateBackup( node: DaemonNodeConnection, serverUuid: string, backupId: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( (callback) => client.createBackup( { server_uuid: serverUuid, backup_id: backupId }, getMetadata(node.daemonToken), callback, ), DEFAULT_RPC_TIMEOUT_MS, ); return { backupId: response.backup_id, sizeBytes: Number(response.size_bytes), checksum: response.checksum, success: response.success, }; } finally { client.close(); } } export async function daemonRestoreBackup( node: DaemonNodeConnection, serverUuid: string, backupId: string, cdnPath?: string | null, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.restoreBackup( { server_uuid: serverUuid, backup_id: backupId, cdn_download_url: cdnPath ?? '', }, getMetadata(node.daemonToken), callback, ), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonDeleteBackup( node: DaemonNodeConnection, serverUuid: string, backupId: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.deleteBackup( { server_uuid: serverUuid, backup_id: backupId }, getMetadata(node.daemonToken), callback, ), DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); } } export async function daemonGetActivePlayers( node: DaemonNodeConnection, serverUuid: string, ): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( (callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); return { players: response.players.map((player) => ({ name: player.name, id: player.uuid, connectedAt: Number(player.connected_at), })), maxPlayers: Number(response.max_players), }; } finally { client.close(); } }