feat: wire daemon console/files/config/players and improve runtime fallbacks
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
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<string, string>;
|
||||
ports: DaemonPortMapping[];
|
||||
install_plugin_urls: string[];
|
||||
}
|
||||
|
||||
interface DaemonServerResponse {
|
||||
uuid: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface DaemonConsoleOutput {
|
||||
uuid: string;
|
||||
line: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DaemonConsoleStreamHandle {
|
||||
stream: grpc.ClientReadableStream<DaemonConsoleOutput>;
|
||||
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;
|
||||
}
|
||||
|
||||
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
|
||||
|
||||
interface DaemonServiceClient extends grpc.Client {
|
||||
createServer(
|
||||
request: DaemonCreateServerRequest,
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonServerResponse>,
|
||||
): void;
|
||||
deleteServer(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
setPowerState(
|
||||
request: { uuid: string; action: number },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
getServerStatus(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonStatusResponse>,
|
||||
): void;
|
||||
streamConsole(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
): grpc.ClientReadableStream<DaemonConsoleOutput>;
|
||||
sendCommand(
|
||||
request: { uuid: string; command: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
listFiles(
|
||||
request: { uuid: string; path: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonFileListResponseRaw>,
|
||||
): void;
|
||||
readFile(
|
||||
request: { uuid: string; path: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonFileContentRaw>,
|
||||
): void;
|
||||
writeFile(
|
||||
request: { uuid: string; path: string; data: Uint8Array | Buffer },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
deleteFiles(
|
||||
request: { uuid: string; paths: string[] },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
getActivePlayers(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonPlayerListRaw>,
|
||||
): 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<PowerAction, number> = {
|
||||
start: 0,
|
||||
stop: 1,
|
||||
restart: 2,
|
||||
kill: 3,
|
||||
};
|
||||
|
||||
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
|
||||
const trimmed = fqdn.trim();
|
||||
if (!trimmed) throw new Error('Node FQDN is empty');
|
||||
|
||||
if (trimmed.includes('://')) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const host = parsed.hostname || parsed.host;
|
||||
if (!host) throw new Error('Node FQDN has no hostname');
|
||||
if (parsed.port) return `${host}:${parsed.port}`;
|
||||
return `${host}:${grpcPort}`;
|
||||
} catch {
|
||||
// Fall through to raw handling below.
|
||||
}
|
||||
}
|
||||
|
||||
const withoutPath = trimmed.replace(/\/.*$/, '');
|
||||
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
|
||||
return /\]:\d+$/.test(withoutPath) ? withoutPath : `${withoutPath}:${grpcPort}`;
|
||||
}
|
||||
if (/^[^:]+:\d+$/.test(withoutPath)) return withoutPath;
|
||||
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(),
|
||||
) as unknown as DaemonServiceClient;
|
||||
}
|
||||
|
||||
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.waitForReady(Date.now() + timeoutMs, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function callUnary<TResponse>(
|
||||
invoke: (callback: UnaryCallback<TResponse>) => void,
|
||||
timeoutMs: number,
|
||||
): Promise<TResponse> {
|
||||
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 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 daemonCreateServer(
|
||||
node: DaemonNodeConnection,
|
||||
request: DaemonCreateServerRequest,
|
||||
): Promise<DaemonServerResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonServerResponse>(
|
||||
(callback) => client.createServer(request, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteServer(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(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<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(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<DaemonStatusResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonStatusResponse>(
|
||||
(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<DaemonConsoleStreamHandle> {
|
||||
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<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(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<DaemonFileEntry[]> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileListResponseRaw>(
|
||||
(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<DaemonFileContentRaw>(
|
||||
(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<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(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<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetActivePlayers(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonPlayersResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonPlayerListRaw>(
|
||||
(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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user