initial commit
Some checks failed
CI / Go — node-agent tests (push) Has been cancelled
CI / Go — edge-gateway build (push) Has been cancelled
CI / Node — lint, typecheck, test, build (push) Has been cancelled

This commit is contained in:
smueller
2026-06-26 10:45:08 +02:00
commit e37ea87b35
118 changed files with 7726 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] === 'development'
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
},
}
: undefined,
serializers: {
req: (req: { id?: string; method?: string; url?: string }) => ({
id: req.id,
method: req.method,
url: req.url,
}),
},
customProps: (req: { id?: string }) => ({
requestId: req.id,
}),
},
}),
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
},
]),
PrismaModule,
HealthModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}

View File

@@ -0,0 +1,102 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { createProblemDetails, type ProblemDetails } from '@hexahost/contracts';
@Catch()
export class Rfc7807ExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const status = this.resolveStatus(exception);
const problem = this.toProblemDetails(exception, status, request.url);
void response
.status(status)
.header('Content-Type', 'application/problem+json')
.send(problem);
}
private resolveStatus(exception: unknown): number {
if (exception instanceof HttpException) {
return exception.getStatus();
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
private toProblemDetails(
exception: unknown,
status: number,
instance: string,
): ProblemDetails {
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === 'object' && response !== null) {
const body = response as Record<string, unknown>;
if (typeof body['status'] === 'string' && Array.isArray(body['checks'])) {
return createProblemDetails({
title: 'Service Unavailable',
status,
detail: 'One or more readiness checks failed',
instance,
});
}
if (typeof body['message'] === 'string') {
return createProblemDetails({
title: exception.name,
status,
detail: body['message'],
instance,
});
}
if (Array.isArray(body['message'])) {
return createProblemDetails({
title: 'Validation Error',
status,
detail: 'Request validation failed',
instance,
errors: body['message'].map((message) => ({
message: String(message),
})),
});
}
}
return createProblemDetails({
title: exception.name,
status,
detail: exception.message,
instance,
});
}
if (exception instanceof Error) {
return createProblemDetails({
title: 'Internal Server Error',
status,
detail: exception.message,
instance,
});
}
return createProblemDetails({
title: 'Internal Server Error',
status,
detail: 'An unexpected error occurred',
instance,
});
}
}

View File

@@ -0,0 +1,20 @@
import { randomUUID } from 'node:crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void): void {
const incoming =
(req.headers['x-request-id'] as string | undefined) ??
(req.headers['x-correlation-id'] as string | undefined);
const requestId = incoming && incoming.trim().length > 0 ? incoming : randomUUID();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
next();
}
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, HttpCode, HttpStatus, ServiceUnavailableException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { HealthResponse } from '@hexahost/contracts';
import { HealthService } from './health.service';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get('live')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Liveness probe' })
@ApiResponse({ status: 200, description: 'Service is alive' })
getLive(): HealthResponse {
return this.healthService.getLive();
}
@Get('ready')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Readiness probe' })
@ApiResponse({ status: 200, description: 'Service is ready' })
@ApiResponse({ status: 503, description: 'Service is not ready' })
async getReady(): Promise<HealthResponse> {
const response = await this.healthService.getReady();
if (response.status === 'error') {
throw new ServiceUnavailableException(response);
}
return response;
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { getConfig } from '@hexahost/config';
import type { HealthCheck, HealthResponse } from '@hexahost/contracts';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class HealthService {
constructor(private readonly prisma: PrismaService) {}
getLive(): HealthResponse {
const config = getConfig();
return {
status: 'ok',
service: config.APP_NAME,
timestamp: new Date().toISOString(),
};
}
async getReady(): Promise<HealthResponse> {
const config = getConfig();
const checks: HealthCheck[] = [];
const startedAt = Date.now();
try {
await this.prisma.$queryRaw`SELECT 1`;
checks.push({
name: 'database',
status: 'ok',
durationMs: Date.now() - startedAt,
});
} catch (error) {
checks.push({
name: 'database',
status: 'error',
message: error instanceof Error ? error.message : 'Database check failed',
durationMs: Date.now() - startedAt,
});
}
const hasError = checks.some((check) => check.status === 'error');
return {
status: hasError ? 'error' : 'ok',
service: config.APP_NAME,
timestamp: new Date().toISOString(),
checks,
};
}
}

26
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,26 @@
import http from "node:http";
const port = Number(process.env.API_PORT ?? 3001);
const appName = process.env.APP_NAME ?? "HexaHost GameCloud";
const server = http.createServer((req, res) => {
if (req.url === "/healthz" || req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "ok",
service: "api",
app: appName,
phase: "0",
}),
);
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "not_found" }));
});
server.listen(port, () => {
console.log(JSON.stringify({ level: "info", msg: "api listening", port }));
});

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@hexahost/database';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}