Phase3
This commit is contained in:
12
apps/api/src/nodes/node-bridge.module.ts
Normal file
12
apps/api/src/nodes/node-bridge.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { ServersModule } from '../servers/servers.module';
|
||||
|
||||
import { NodeBridgeService } from './node-bridge.service';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ServersModule)],
|
||||
providers: [NodeBridgeService],
|
||||
exports: [NodeBridgeService],
|
||||
})
|
||||
export class NodeBridgeModule {}
|
||||
85
apps/api/src/nodes/node-bridge.service.ts
Normal file
85
apps/api/src/nodes/node-bridge.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
GatewayTimeoutException,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||
|
||||
export interface AgentResponse {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NodeBridgeService {
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
private readonly serversService: ServersService,
|
||||
) {}
|
||||
|
||||
async sendAgentRequest(
|
||||
nodeId: string,
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<AgentResponse> {
|
||||
const messageId = randomUUID();
|
||||
|
||||
const config = getConfig();
|
||||
const subscriber = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
const responseChannel = `hgc:node:responses:${messageId}`;
|
||||
|
||||
try {
|
||||
const responsePromise = new Promise<AgentResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new GatewayTimeoutException('Agent request timed out'));
|
||||
}, timeoutMs);
|
||||
|
||||
subscriber.on('message', (channel, message) => {
|
||||
if (channel !== responseChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(message) as AgentResponse);
|
||||
} catch {
|
||||
reject(new ServiceUnavailableException('Invalid agent response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await subscriber.subscribe(responseChannel);
|
||||
|
||||
await this.serversService.publishNodeCommand(nodeId, {
|
||||
protocolVersion: 1,
|
||||
messageId,
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
...payload,
|
||||
generation: payload['generation'] ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return await responsePromise;
|
||||
} finally {
|
||||
await subscriber.unsubscribe(responseChannel);
|
||||
await subscriber.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { ServersModule } from '../servers/servers.module';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NodesGateway } from './nodes.gateway';
|
||||
import { NodesService } from './nodes.service';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule],
|
||||
imports: [forwardRef(() => ServersModule)],
|
||||
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
|
||||
exports: [NodesService],
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { verifyTokenHash } from '@hexahost/auth';
|
||||
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ConsoleSessionService } from '../servers/console/console-session.service';
|
||||
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
@@ -47,6 +48,7 @@ export class NodesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serversService: ServersService,
|
||||
private readonly consoleSessionService: ConsoleSessionService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@@ -145,12 +147,21 @@ export class NodesService {
|
||||
case 'server.state':
|
||||
await this.handleServerState(envelope);
|
||||
break;
|
||||
case 'server.log':
|
||||
this.handleServerLog(envelope);
|
||||
break;
|
||||
case 'server.operation.completed':
|
||||
await this.handleOperationCompleted(envelope);
|
||||
break;
|
||||
case 'server.operation.failed':
|
||||
await this.handleOperationFailed(envelope);
|
||||
break;
|
||||
case 'server.files.list.result':
|
||||
case 'server.files.read.result':
|
||||
case 'server.files.write.result':
|
||||
case 'server.command.result':
|
||||
await this.handleDirectResult(envelope);
|
||||
break;
|
||||
default:
|
||||
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
|
||||
}
|
||||
@@ -210,28 +221,53 @@ export class NodesService {
|
||||
});
|
||||
}
|
||||
|
||||
private handleServerLog(envelope: AgentEnvelope): void {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const line =
|
||||
envelope.payload?.['line'] ?? envelope.payload?.['message'];
|
||||
|
||||
if (typeof serverId === 'string' && typeof line === 'string') {
|
||||
this.consoleSessionService.broadcast(serverId, line);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDirectResult(envelope: AgentEnvelope): Promise<void> {
|
||||
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||
|
||||
if (typeof messageId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.publishNodeResponse(messageId, {
|
||||
success: true,
|
||||
result: envelope.payload?.['result'] ?? envelope.payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOperationCompleted(
|
||||
envelope: AgentEnvelope,
|
||||
): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const operation = envelope.payload?.['operation'];
|
||||
const messageId = envelope.messageId;
|
||||
const operation =
|
||||
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
|
||||
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||
const result = envelope.payload?.['result'];
|
||||
const isBridgeOperation = this.isBridgeOperation(operation);
|
||||
|
||||
if (typeof serverId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.resolveCompletedStatus(operation);
|
||||
if (target) {
|
||||
await this.serversService.applyAgentStatus(serverId, target, {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||
});
|
||||
if (typeof serverId === 'string' && !isBridgeOperation) {
|
||||
const target = this.resolveCompletedStatus(operation);
|
||||
if (target) {
|
||||
await this.serversService.applyAgentStatus(serverId, target, {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof messageId === 'string') {
|
||||
await this.publishNodeResponse(messageId, {
|
||||
success: true,
|
||||
result,
|
||||
resultCode: typeof operation === 'string' ? operation : 'completed',
|
||||
});
|
||||
}
|
||||
@@ -239,9 +275,12 @@ export class NodesService {
|
||||
|
||||
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const messageId = envelope.messageId;
|
||||
const operation =
|
||||
envelope.payload?.['operation'] ?? envelope.payload?.['resultCode'];
|
||||
const messageId = envelope.correlationId ?? envelope.messageId;
|
||||
const isBridgeOperation = this.isBridgeOperation(operation);
|
||||
|
||||
if (typeof serverId === 'string') {
|
||||
if (typeof serverId === 'string' && !isBridgeOperation) {
|
||||
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: 'agent operation failed',
|
||||
@@ -271,10 +310,25 @@ export class NodesService {
|
||||
}
|
||||
}
|
||||
|
||||
private async publishNodeResponse(
|
||||
private isBridgeOperation(operation: unknown): boolean {
|
||||
if (typeof operation !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = operation.replace(/^server\./, '');
|
||||
return (
|
||||
normalized === 'files.list' ||
|
||||
normalized === 'files.read' ||
|
||||
normalized === 'files.write' ||
|
||||
normalized === 'command'
|
||||
);
|
||||
}
|
||||
|
||||
async publishNodeResponse(
|
||||
messageId: string,
|
||||
response: {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
Reference in New Issue
Block a user