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

19
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY package.json pnpm-workspace.yaml turbo.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --filter @hexahost/api...
COPY apps/api apps/api
RUN pnpm --filter @hexahost/api build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV API_PORT=3001
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/package.json ./
EXPOSE 3001
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3001/healthz || exit 1
CMD ["node", "dist/main.js"]

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

17
apps/api/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js",
"start": "node dist/main.js",
"dev": "tsc && node dist/main.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

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();
}
}

View File

@@ -0,0 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
describe("api health", () => {
it("returns ok for healthz", async () => {
const server = http.createServer((req, res) => {
if (req.url === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "api" }));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
assert.ok(addr && typeof addr === "object");
const port = addr.port;
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
assert.equal(response.status, 200);
const body = (await response.json()) as { status: string };
assert.equal(body.status, "ok");
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

13
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,29 @@
package main
import (
"log/slog"
"os"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
enabled := os.Getenv("EDGE_GATEWAY_ENABLED")
if enabled != "true" && enabled != "1" {
log.Info("edge gateway disabled for MVP",
"feature", "edge-gateway",
"status", "not_implemented",
"message", "Join-to-Start TCP/UDP gateway is planned for Phase 8; set EDGE_GATEWAY_ENABLED=true to acknowledge early enablement",
)
os.Exit(0)
}
log.Info("edge gateway not implemented in MVP",
"feature", "edge-gateway",
"status", "not_implemented",
"roadmap", "Phase 8 DNS and Join-to-Start",
)
os.Exit(0)
}

3
apps/edge-gateway/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/hexahost/gamecloud/edge-gateway
go 1.23

24
apps/node-agent/Makefile Normal file
View File

@@ -0,0 +1,24 @@
MODULE := github.com/hexahost/gamecloud/node-agent
BINARY := hexahost-node-agent
VERSION ?= 0.1.0-phase0
.PHONY: build test lint fmt clean run
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/$(BINARY) ./cmd/agent
test:
go test ./... -count=1 -race
lint:
go vet ./...
go test ./... -run=^$$ -count=0
fmt:
gofmt -w .
clean:
rm -rf bin/
run: build
./bin/$(BINARY)

51
apps/node-agent/README.md Normal file
View File

@@ -0,0 +1,51 @@
# HexaHost GameCloud Node Agent
Go daemon that runs on each game node. It maintains an outbound connection to the control plane, reports heartbeats and resource metrics, and executes signed lifecycle commands for Minecraft server containers.
## Phase 0 scope
- Environment-based configuration loader
- Versioned JSON protocol message types (`agent.hello`, `agent.heartbeat`, `server.start`, …)
- Health endpoints (`/healthz`, `/readyz`)
- Main loop stub with graceful shutdown on `SIGINT`/`SIGTERM`
## Build
```bash
make build
make test
```
## Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `NODE_ID` | yes | Unique node identifier |
| `NODE_AGENT_PUBLIC_URL` | yes | Control plane WebSocket URL |
| `NODE_TOKEN` | no* | Enrollment token (*required in production) |
| `NODE_AGENT_HEALTH_ADDR` | no | Health listen address (default `:9100`) |
| `NODE_HEARTBEAT_INTERVAL` | no | Heartbeat interval (default `10s`) |
| `NODE_DATA_DIR` | no | Server data root (default `/var/lib/hexahost-gamecloud`) |
| `LOG_LEVEL` | no | Log level |
See repository root `.env.example` for the full variable list.
## Protocol
Messages use a common envelope:
```json
{
"protocolVersion": 1,
"messageId": "01...",
"type": "agent.heartbeat",
"timestamp": "2026-01-01T00:00:00Z",
"payload": {}
}
```
See `internal/protocol/messages.go` for all supported types.
## Deployment
Production deployment uses systemd. See `deploy/systemd/node-agent.service`.

View File

@@ -0,0 +1,29 @@
package main
import (
"context"
"log/slog"
"os"
"github.com/hexahost/gamecloud/node-agent/internal/agent"
"github.com/hexahost/gamecloud/node-agent/internal/config"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
cfg, err := config.Load()
if err != nil {
log.Error("configuration error", "error", err)
os.Exit(1)
}
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
if err := agent.New(cfg, log).Run(context.Background()); err != nil {
log.Error("agent exited with error", "error", err)
os.Exit(1)
}
}

3
apps/node-agent/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/hexahost/gamecloud/node-agent
go 1.23

View File

@@ -0,0 +1,92 @@
package agent
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/hexahost/gamecloud/node-agent/internal/config"
"github.com/hexahost/gamecloud/node-agent/internal/health"
)
const Version = "0.1.0-phase0"
// Agent coordinates the node agent lifecycle until shutdown.
type Agent struct {
cfg *config.Config
log *slog.Logger
health *health.Server
}
// New creates an agent instance from configuration.
func New(cfg *config.Config, log *slog.Logger) *Agent {
return &Agent{
cfg: cfg,
log: log,
health: health.NewServer(cfg.HealthListenAddr, Version),
}
}
// Run starts the agent loop and blocks until context cancellation or signal.
func (a *Agent) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
errCh := make(chan error, 1)
go func() {
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
if err := a.health.ListenAndServe(); err != nil {
errCh <- err
}
}()
a.health.SetReady(true)
a.log.Info("agent started",
"node_id", a.cfg.NodeID,
"control_plane", a.cfg.ControlPlaneURL,
"version", Version,
)
ticker := time.NewTicker(a.cfg.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return a.shutdown(ctx.Err())
case sig := <-sigCh:
a.log.Info("shutdown signal received", "signal", sig.String())
return a.shutdown(nil)
case err := <-errCh:
return err
case <-ticker.C:
a.log.Debug("heartbeat tick",
"node_id", a.cfg.NodeID,
"interval", a.cfg.HeartbeatInterval.String(),
)
}
}
}
func (a *Agent) shutdown(reason error) error {
a.health.SetReady(false)
a.log.Info("agent shutting down", "grace", a.cfg.ShutdownGracePeriod.String())
timer := time.NewTimer(a.cfg.ShutdownGracePeriod)
defer timer.Stop()
<-timer.C
if reason != nil && reason != context.Canceled {
a.log.Warn("shutdown with error", "error", reason)
return reason
}
a.log.Info("agent stopped")
return nil
}

View File

@@ -0,0 +1,88 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
// Config holds runtime configuration loaded from environment variables.
type Config struct {
AppName string
NodeID string
ControlPlaneURL string
NodeToken string
HealthListenAddr string
HeartbeatInterval time.Duration
ReconnectBackoff time.Duration
DataDir string
DockerSocket string
LogLevel string
TLSCertFile string
TLSKeyFile string
TLSCAFile string
TLSSkipVerify bool
ShutdownGracePeriod time.Duration
}
// Load reads configuration from environment variables with sensible defaults for development.
func Load() (*Config, error) {
cfg := &Config{
AppName: envOrDefault("APP_NAME", "HexaHost GameCloud"),
NodeID: os.Getenv("NODE_ID"),
ControlPlaneURL: os.Getenv("NODE_AGENT_PUBLIC_URL"),
NodeToken: os.Getenv("NODE_TOKEN"),
HealthListenAddr: envOrDefault("NODE_AGENT_HEALTH_ADDR", ":9100"),
HeartbeatInterval: durationFromEnv("NODE_HEARTBEAT_INTERVAL", 10*time.Second),
ReconnectBackoff: durationFromEnv("NODE_RECONNECT_BACKOFF", 5*time.Second),
DataDir: envOrDefault("NODE_DATA_DIR", "/var/lib/hexahost-gamecloud"),
DockerSocket: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
LogLevel: envOrDefault("LOG_LEVEL", "info"),
TLSCertFile: os.Getenv("NODE_TLS_CERT"),
TLSKeyFile: os.Getenv("NODE_TLS_KEY"),
TLSCAFile: os.Getenv("NODE_CA_CERT"),
TLSSkipVerify: boolFromEnv("NODE_TLS_SKIP_VERIFY", false),
ShutdownGracePeriod: durationFromEnv("NODE_SHUTDOWN_GRACE", 30*time.Second),
}
if cfg.NodeID == "" {
return nil, fmt.Errorf("NODE_ID is required")
}
if cfg.ControlPlaneURL == "" {
return nil, fmt.Errorf("NODE_AGENT_PUBLIC_URL is required")
}
return cfg, nil
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func durationFromEnv(key string, fallback time.Duration) time.Duration {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
d, err := time.ParseDuration(raw)
if err != nil {
return fallback
}
return d
}
func boolFromEnv(key string, fallback bool) bool {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
v, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return v
}

View File

@@ -0,0 +1,78 @@
package health
import (
"encoding/json"
"net/http"
"sync/atomic"
"time"
)
// Status represents agent health for liveness and readiness probes.
type Status struct {
Status string `json:"status"`
Agent string `json:"agent"`
Version string `json:"version"`
Uptime string `json:"uptime"`
Timestamp time.Time `json:"timestamp"`
}
// Server exposes HTTP health endpoints for the node agent.
type Server struct {
addr string
version string
startedAt time.Time
ready atomic.Bool
mux *http.ServeMux
}
// NewServer creates a health HTTP server bound to addr.
func NewServer(addr, version string) *Server {
s := &Server{
addr: addr,
version: version,
startedAt: time.Now().UTC(),
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.handleLiveness)
s.mux.HandleFunc("GET /readyz", s.handleReadiness)
return s
}
// SetReady toggles readiness for orchestration probes.
func (s *Server) SetReady(ready bool) {
s.ready.Store(ready)
}
// Handler returns the HTTP handler for embedding in tests.
func (s *Server) Handler() http.Handler {
return s.mux
}
// ListenAndServe starts the health server.
func (s *Server) ListenAndServe() error {
return http.ListenAndServe(s.addr, s.mux)
}
func (s *Server) handleLiveness(w http.ResponseWriter, _ *http.Request) {
s.writeStatus(w, http.StatusOK, "alive")
}
func (s *Server) handleReadiness(w http.ResponseWriter, _ *http.Request) {
if !s.ready.Load() {
s.writeStatus(w, http.StatusServiceUnavailable, "not_ready")
return
}
s.writeStatus(w, http.StatusOK, "ready")
}
func (s *Server) writeStatus(w http.ResponseWriter, code int, status string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(Status{
Status: status,
Agent: "node-agent",
Version: s.version,
Uptime: time.Since(s.startedAt).Round(time.Second).String(),
Timestamp: time.Now().UTC(),
})
}

View File

@@ -0,0 +1,209 @@
package protocol
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const CurrentProtocolVersion = 1
// MessageType identifies a versioned agent/control-plane message.
type MessageType string
const (
// Agent → Control Plane
TypeAgentHello MessageType = "agent.hello"
TypeAgentHeartbeat MessageType = "agent.heartbeat"
TypeAgentInventory MessageType = "agent.inventory"
TypeServerState MessageType = "server.state"
TypeServerMetrics MessageType = "server.metrics"
TypeServerLog MessageType = "server.log"
TypeServerOperationProgress MessageType = "server.operation.progress"
TypeServerOperationComplete MessageType = "server.operation.completed"
TypeServerOperationFailed MessageType = "server.operation.failed"
// Control Plane → Agent
TypeServerProvision MessageType = "server.provision"
TypeServerInstall MessageType = "server.install"
TypeServerStart MessageType = "server.start"
TypeServerStop MessageType = "server.stop"
TypeServerKill MessageType = "server.kill"
TypeServerDelete MessageType = "server.delete"
TypeServerInspect MessageType = "server.inspect"
TypeServerCommand MessageType = "server.command"
TypeServerBackupPrepare MessageType = "server.backup.prepare"
TypeServerBackupRelease MessageType = "server.backup.release"
TypeServerFilesList MessageType = "server.files.list"
TypeServerFilesRead MessageType = "server.files.read"
TypeServerFilesWrite MessageType = "server.files.write"
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
TypeServerWorldValidate MessageType = "server.world.validate"
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
)
var agentToControlTypes = map[MessageType]struct{}{
TypeAgentHello: {},
TypeAgentHeartbeat: {},
TypeAgentInventory: {},
TypeServerState: {},
TypeServerMetrics: {},
TypeServerLog: {},
TypeServerOperationProgress: {},
TypeServerOperationComplete: {},
TypeServerOperationFailed: {},
}
var controlToAgentTypes = map[MessageType]struct{}{
TypeServerProvision: {},
TypeServerInstall: {},
TypeServerStart: {},
TypeServerStop: {},
TypeServerKill: {},
TypeServerDelete: {},
TypeServerInspect: {},
TypeServerCommand: {},
TypeServerBackupPrepare: {},
TypeServerBackupRelease: {},
TypeServerFilesList: {},
TypeServerFilesRead: {},
TypeServerFilesWrite: {},
TypeServerFilesUploadDone: {},
TypeServerWorldValidate: {},
TypeServerMetricsSub: {},
TypeServerLogsSubscribe: {},
}
// Envelope is the common wrapper for all protocol messages.
type Envelope struct {
ProtocolVersion int `json:"protocolVersion"`
MessageID string `json:"messageId"`
CorrelationID string `json:"correlationId,omitempty"`
Type MessageType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Payload json.RawMessage `json:"payload"`
}
// OperationMeta is shared metadata for lifecycle operations.
type OperationMeta struct {
ServerID string `json:"serverId"`
Generation int64 `json:"generation"`
Deadline *time.Time `json:"deadline,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
// AgentHelloPayload is sent when the agent connects to the control plane.
type AgentHelloPayload struct {
NodeID string `json:"nodeId"`
AgentVersion string `json:"agentVersion"`
ProtocolVersion int `json:"protocolVersion"`
Hostname string `json:"hostname,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// AgentHeartbeatPayload reports node health and resource usage.
type AgentHeartbeatPayload struct {
NodeID string `json:"nodeId"`
CPUUsagePercent float64 `json:"cpuUsagePercent"`
MemoryUsedBytes int64 `json:"memoryUsedBytes"`
MemoryTotalBytes int64 `json:"memoryTotalBytes"`
DiskUsedBytes int64 `json:"diskUsedBytes"`
DiskTotalBytes int64 `json:"diskTotalBytes"`
RunningServers int `json:"runningServers"`
}
// ServerStartPayload instructs the agent to start a game server.
type ServerStartPayload struct {
OperationMeta
DesiredGeneration int64 `json:"desiredGeneration"`
}
// ServerStatePayload reports the observed server state.
type ServerStatePayload struct {
OperationMeta
State string `json:"state"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// Validate checks envelope fields and known message types.
func (e *Envelope) Validate() error {
if e == nil {
return fmt.Errorf("envelope is nil")
}
if e.ProtocolVersion < 1 {
return fmt.Errorf("protocolVersion must be >= 1")
}
if e.ProtocolVersion > CurrentProtocolVersion {
return fmt.Errorf("unsupported protocolVersion %d", e.ProtocolVersion)
}
if strings.TrimSpace(e.MessageID) == "" {
return fmt.Errorf("messageId is required")
}
if strings.TrimSpace(string(e.Type)) == "" {
return fmt.Errorf("type is required")
}
if e.Timestamp.IsZero() {
return fmt.Errorf("timestamp is required")
}
if !e.Timestamp.UTC().Equal(e.Timestamp) {
return fmt.Errorf("timestamp must be UTC")
}
if _, ok := agentToControlTypes[e.Type]; !ok {
if _, ok := controlToAgentTypes[e.Type]; !ok {
return fmt.Errorf("unknown message type: %s", e.Type)
}
}
if len(e.Payload) == 0 {
return fmt.Errorf("payload is required")
}
if !json.Valid(e.Payload) {
return fmt.Errorf("payload must be valid JSON")
}
return nil
}
// IsAgentToControl returns true when the message flows from agent to control plane.
func (t MessageType) IsAgentToControl() bool {
_, ok := agentToControlTypes[t]
return ok
}
// IsControlToAgent returns true when the message flows from control plane to agent.
func (t MessageType) IsControlToAgent() bool {
_, ok := controlToAgentTypes[t]
return ok
}
// DecodeEnvelope parses and validates a JSON envelope.
func DecodeEnvelope(data []byte) (*Envelope, error) {
var env Envelope
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("decode envelope: %w", err)
}
if err := env.Validate(); err != nil {
return nil, err
}
return &env, nil
}
// NewEnvelope creates a validated outbound envelope.
func NewEnvelope(msgType MessageType, messageID string, payload any) (*Envelope, error) {
raw, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: messageID,
Type: msgType,
Timestamp: time.Now().UTC(),
Payload: raw,
}
if err := env.Validate(); err != nil {
return nil, err
}
return env, nil
}

View File

@@ -0,0 +1,151 @@
package protocol
import (
"encoding/json"
"testing"
"time"
)
func TestEnvelopeValidate_AgentHello(t *testing.T) {
payload := AgentHelloPayload{
NodeID: "node-01",
AgentVersion: "0.1.0",
ProtocolVersion: CurrentProtocolVersion,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-001",
Type: TypeAgentHello,
Timestamp: time.Now().UTC(),
Payload: raw,
}
if err := env.Validate(); err != nil {
t.Fatalf("expected valid envelope, got %v", err)
}
if !env.Type.IsAgentToControl() {
t.Fatal("agent.hello should be agent-to-control")
}
}
func TestEnvelopeValidate_ServerStart(t *testing.T) {
payload := ServerStartPayload{
OperationMeta: OperationMeta{
ServerID: "srv-01",
Generation: 4,
},
DesiredGeneration: 4,
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
env := &Envelope{
ProtocolVersion: CurrentProtocolVersion,
MessageID: "msg-002",
CorrelationID: "corr-002",
Type: TypeServerStart,
Timestamp: time.Now().UTC(),
Payload: raw,
}
if err := env.Validate(); err != nil {
t.Fatalf("expected valid envelope, got %v", err)
}
if !env.Type.IsControlToAgent() {
t.Fatal("server.start should be control-to-agent")
}
}
func TestEnvelopeValidate_RejectsInvalid(t *testing.T) {
tests := []struct {
name string
env Envelope
}{
{
name: "missing message id",
env: Envelope{
ProtocolVersion: 1,
Type: TypeAgentHeartbeat,
Timestamp: time.Now().UTC(),
Payload: json.RawMessage(`{"nodeId":"n1"}`),
},
},
{
name: "unknown type",
env: Envelope{
ProtocolVersion: 1,
MessageID: "m1",
Type: "agent.unknown",
Timestamp: time.Now().UTC(),
Payload: json.RawMessage(`{}`),
},
},
{
name: "non-utc timestamp",
env: Envelope{
ProtocolVersion: 1,
MessageID: "m1",
Type: TypeAgentHeartbeat,
Timestamp: time.Now().Local(),
Payload: json.RawMessage(`{"nodeId":"n1"}`),
},
},
{
name: "invalid payload json",
env: Envelope{
ProtocolVersion: 1,
MessageID: "m1",
Type: TypeAgentHeartbeat,
Timestamp: time.Now().UTC(),
Payload: json.RawMessage(`{not-json`),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.env.Validate(); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestDecodeEnvelope(t *testing.T) {
data := []byte(`{
"protocolVersion": 1,
"messageId": "01HXYZ",
"type": "agent.heartbeat",
"timestamp": "2026-01-01T00:00:00Z",
"payload": {"nodeId":"node-01","runningServers":0}
}`)
env, err := DecodeEnvelope(data)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if env.Type != TypeAgentHeartbeat {
t.Fatalf("unexpected type %s", env.Type)
}
}
func TestNewEnvelope(t *testing.T) {
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
NodeID: "n1",
AgentVersion: "0.1.0",
ProtocolVersion: 1,
})
if err != nil {
t.Fatalf("new envelope: %v", err)
}
if env.ProtocolVersion != CurrentProtocolVersion {
t.Fatalf("protocol version %d", env.ProtocolVersion)
}
}

3
apps/web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Public environment variables (exposed to the browser)
NEXT_PUBLIC_APP_NAME=HexaHost GameCloud
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1

3
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

75
apps/web/messages/de.json Normal file
View File

@@ -0,0 +1,75 @@
{
"common": {
"appName": "HexaHost GameCloud",
"login": "Anmelden",
"register": "Registrieren",
"dashboard": "Dashboard",
"logout": "Abmelden",
"theme": "Design",
"themeLight": "Hell",
"themeDark": "Dunkel",
"themeSystem": "System",
"language": "Sprache",
"loading": "Wird geladen…",
"submit": "Absenden",
"cancel": "Abbrechen",
"back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform."
},
"landing": {
"badge": "Phase 0 · Control Plane",
"title": "Minecraft-Server on demand",
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
"ctaPrimary": "Jetzt starten",
"ctaSecondary": "Anmelden",
"features": {
"control": {
"title": "Zentrale Steuerung",
"description": "Server-Lifecycle, Quotas und Scheduling über eine einheitliche Control Plane."
},
"nodes": {
"title": "Multi-Node",
"description": "Horizontale Skalierung über dedizierte Game-Nodes mit Agent-basierter Orchestrierung."
},
"security": {
"title": "Enterprise-Sicherheit",
"description": "mTLS, granulare Berechtigungen und auditierbare Zustandsübergänge von Anfang an."
}
}
},
"auth": {
"loginTitle": "Anmelden",
"loginSubtitle": "Melden Sie sich mit Ihrem Konto an.",
"registerTitle": "Konto erstellen",
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
"email": "E-Mail",
"emailPlaceholder": "name@beispiel.de",
"password": "Passwort",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Passwort bestätigen",
"noAccount": "Noch kein Konto?",
"hasAccount": "Bereits registriert?",
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen in Ihrem Kontrollbereich.",
"servers": "Server",
"serversEmpty": "Noch keine Server",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
"createServer": "Server erstellen",
"status": "Status",
"resources": "Ressourcen",
"activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse",
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Ungültige E-Mail-Adresse",
"passwordRequired": "Passwort ist erforderlich",
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
"passwordMismatch": "Passwörter stimmen nicht überein"
}
}

75
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,75 @@
{
"common": {
"appName": "HexaHost GameCloud",
"login": "Sign in",
"register": "Register",
"dashboard": "Dashboard",
"logout": "Sign out",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"language": "Language",
"loading": "Loading…",
"submit": "Submit",
"cancel": "Cancel",
"back": "Back",
"footer": "Self-hosted Minecraft server platform."
},
"landing": {
"badge": "Phase 0 · Control Plane",
"title": "Minecraft servers on demand",
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
"ctaPrimary": "Get started",
"ctaSecondary": "Sign in",
"features": {
"control": {
"title": "Central control",
"description": "Server lifecycle, quotas, and scheduling through a unified control plane."
},
"nodes": {
"title": "Multi-node",
"description": "Horizontal scaling across dedicated game nodes with agent-based orchestration."
},
"security": {
"title": "Enterprise security",
"description": "mTLS, granular permissions, and auditable state transitions from day one."
}
}
},
"auth": {
"loginTitle": "Sign in",
"loginSubtitle": "Sign in with your account.",
"registerTitle": "Create account",
"registerSubtitle": "Register for the platform.",
"email": "Email",
"emailPlaceholder": "name@example.com",
"password": "Password",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirm password",
"noAccount": "No account yet?",
"hasAccount": "Already registered?",
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome to your control area.",
"servers": "Servers",
"serversEmpty": "No servers yet",
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
"createServer": "Create server",
"status": "Status",
"resources": "Resources",
"activity": "Activity",
"activityEmpty": "No recent events",
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
},
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email address",
"passwordRequired": "Password is required",
"passwordMin": "Password must be at least 8 characters",
"confirmPasswordRequired": "Password confirmation is required",
"passwordMismatch": "Passwords do not match"
}
}

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

10
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
transpilePackages: ["@hexahost/ui"],
};
export default withNextIntl(nextConfig);

35
apps/web/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@hexahost/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hexahost/ui": "workspace:*",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.67.2",
"next": "^15.2.3",
"next-intl": "^4.0.2",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-config-next": "^15.2.3",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,41 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
interface DashboardLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function DashboardLayout({
children,
params,
}: DashboardLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("common");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Dashboard navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("dashboard")}
</Link>
</li>
</ul>
</nav>
<p className="font-mono text-xs text-zinc-500">
auth: pending · session: none
</p>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { DashboardContent } from "@/components/dashboard/dashboard-content";
interface DashboardPageProps {
params: Promise<{ locale: string }>;
}
export default async function DashboardPage({ params }: DashboardPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <DashboardContent />;
}

View File

@@ -0,0 +1,38 @@
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { SiteLayout } from "@/components/layout/site-layout";
import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { routing, type Locale } from "@/i18n/routing";
interface LocaleLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<QueryProvider>
<SiteLayout>{children}</SiteLayout>
</QueryProvider>
</ThemeProvider>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { LoginForm } from "@/components/auth/login-form";
interface LoginPageProps {
params: Promise<{ locale: string }>;
}
export default async function LoginPage({ params }: LoginPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<LoginForm />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
interface HomePageProps {
params: Promise<{ locale: string }>;
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("landing");
const appName = getAppName();
const features = [
{ key: "control" as const, title: t("features.control.title"), description: t("features.control.description") },
{ key: "nodes" as const, title: t("features.nodes.title"), description: t("features.nodes.description") },
{ key: "security" as const, title: t("features.security.title"), description: t("features.security.description") },
];
return (
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<section className="mx-auto max-w-3xl text-center">
<p className="mb-4 inline-flex rounded-full border border-zinc-200 bg-white px-3 py-1 font-mono text-xs text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
{t("badge")}
</p>
<h1 className="text-4xl font-semibold tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
{appName}
</h1>
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link
href="/register"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-6 text-sm font-medium text-white transition-colors hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("ctaPrimary")}
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-6 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("ctaSecondary")}
</Link>
</div>
</section>
<section className="mt-20 grid gap-6 md:grid-cols-3">
{features.map((feature) => (
<Card key={feature.key}>
<CardHeader>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="h-1 w-12 rounded-full bg-sky-600 dark:bg-sky-500" aria-hidden="true" />
</CardContent>
</Card>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { RegisterForm } from "@/components/auth/register-form";
interface RegisterPageProps {
params: Promise<{ locale: string }>;
}
export default async function RegisterPage({ params }: RegisterPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<RegisterForm />
</div>
);
}

View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--surface: 250 250 250;
--surface-muted: 244 244 245;
--border: 228 228 231;
}
.dark {
--surface: 9 9 11;
--surface-muted: 24 24 27;
--border: 39 39 42;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-50;
}
}
@layer utilities {
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud",
template: `%s · ${process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud"}`,
},
description: "Self-hosted Minecraft server platform control plane.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans`}>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitLogin } from "@/lib/api/auth";
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
export function LoginForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const schema = createLoginSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
async function onSubmit(values: LoginFormValues) {
await submitLogin(values);
setSubmitted(true);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("loginTitle")}</CardTitle>
<CardDescription>{t("loginSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="current-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("loginTitle")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
{t("noAccount")}{" "}
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("registerTitle")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitRegister } from "@/lib/api/auth";
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
export function RegisterForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const schema = createRegisterSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
confirmPasswordRequired: tv("confirmPasswordRequired"),
passwordMismatch: tv("passwordMismatch"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "", confirmPassword: "" },
});
async function onSubmit(values: RegisterFormValues) {
await submitRegister({ email: values.email, password: values.password });
setSubmitted(true);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("registerTitle")}</CardTitle>
<CardDescription>{t("registerSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.confirmPassword ? "true" : "false"}
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
{...register("confirmPassword")}
/>
{errors.confirmPassword ? (
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
{errors.confirmPassword.message}
</p>
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("registerTitle")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
{t("hasAccount")}{" "}
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("loginTitle")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { EmptyState } from "@/components/dashboard/empty-state";
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
export function DashboardContent() {
const t = useTranslations("dashboard");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = window.setTimeout(() => setIsLoading(false), 600);
return () => window.clearTimeout(timer);
}, []);
if (isLoading) {
return <DashboardSkeleton />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p>
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("status")}</CardTitle>
<CardDescription>Control Plane · Online</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
0
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("resources")}</CardTitle>
<CardDescription>Allocated / Quota</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
0 / 0
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>Last 24 hours</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
</CardContent>
</Card>
</div>
<section aria-labelledby="servers-heading">
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t("servers")}
</h2>
<EmptyState
title={t("serversEmpty")}
description={t("serversEmptyDescription")}
actionLabel={t("createServer")}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
interface EmptyStateProps {
title: string;
description: string;
actionLabel: string;
onAction?: () => void;
}
export function EmptyState({
title,
description,
actionLabel,
onAction,
}: EmptyStateProps) {
return (
<Card className="border-dashed">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="secondary" onClick={onAction} disabled={!onAction}>
{actionLabel}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-800 ${className}`}
aria-hidden="true"
/>
);
}
export function DashboardSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="Loading dashboard">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { useTranslations } from "next-intl";
import { getAppName } from "@/lib/env";
export function Footer() {
const t = useTranslations("common");
const appName = getAppName();
const year = new Date().getFullYear();
return (
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-8 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:text-zinc-400">
<p>
© {year} {appName}. {t("footer")}
</p>
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-500">
Phase 0 · Frontend Foundation
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
import { LocaleSwitcher } from "./locale-switcher";
import { ThemeToggle } from "./theme-toggle";
export function Header() {
const t = useTranslations("common");
const appName = getAppName();
return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<Link
href="/"
className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"
>
{appName}
</Link>
<nav
className="flex items-center gap-2 sm:gap-4"
aria-label="Main navigation"
>
<Link
href="/dashboard"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 sm:inline"
>
{t("dashboard")}
</Link>
<LocaleSwitcher />
<ThemeToggle />
<Link
href="/login"
className="inline-flex h-8 items-center rounded-md px-3 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
{t("login")}
</Link>
<Link
href="/register"
className="inline-flex h-8 items-center rounded-md bg-sky-600 px-3 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("register")}
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
export function LocaleSwitcher() {
const t = useTranslations("common");
const locale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
function onChange(nextLocale: string) {
router.replace(pathname, { locale: nextLocale as Locale });
}
return (
<label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<span className="sr-only">{t("language")}</span>
<select
value={locale}
onChange={(event) => onChange(event.target.value)}
className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
aria-label={t("language")}
>
{routing.locales.map((item) => (
<option key={item} value={item}>
{item.toUpperCase()}
</option>
))}
</select>
</label>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { Footer } from "./footer";
import { Header } from "./header";
interface SiteLayoutProps {
children: ReactNode;
}
export function SiteLayout({ children }: SiteLayoutProps) {
return (
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@hexahost/ui";
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="sm" aria-label="Theme" disabled>
</Button>
);
}
const isDark = (theme === "system" ? resolvedTheme : theme) === "dark";
return (
<Button
variant="ghost"
size="sm"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? "☀" : "☾"}
</Button>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "de" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

View File

@@ -0,0 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["de", "en"],
defaultLocale: "de",
localePrefix: "as-needed",
});
export type Locale = (typeof routing.locales)[number];

View File

@@ -0,0 +1,45 @@
import { getApiUrl } from "./env";
export interface AuthCredentials {
email: string;
password: string;
}
export interface AuthResponse {
accessToken?: string;
refreshToken?: string;
}
/**
* Prepared API client for Phase 1 authentication.
* Currently validates structure only; no network request is performed.
*/
export async function submitLogin(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/login`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
}
export async function submitRegister(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/register`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
}

7
apps/web/src/lib/env.ts Normal file
View File

@@ -0,0 +1,7 @@
export function getAppName(): string {
return process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud";
}
export function getApiUrl(): string {
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1";
}

View File

@@ -0,0 +1,48 @@
import { z } from "zod";
export function createLoginSchema(messages: {
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
passwordMin: string;
}) {
return z.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
});
}
export function createRegisterSchema(messages: {
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
passwordMin: string;
confirmPasswordRequired: string;
passwordMismatch: string;
}) {
return z
.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
})
.refine((data) => data.password === data.confirmPassword, {
message: messages.passwordMismatch,
path: ["confirmPassword"],
});
}
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;

View File

@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./src/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],
};

View File

@@ -0,0 +1,34 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: [
"var(--font-geist-sans)",
"ui-sans-serif",
"system-ui",
"sans-serif",
],
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
},
colors: {
surface: {
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
muted: "rgb(var(--surface-muted) / <alpha-value>)",
},
border: {
DEFAULT: "rgb(var(--border) / <alpha-value>)",
},
},
},
},
plugins: [],
};
export default config;

23
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

17
apps/worker/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/worker",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js",
"start": "node dist/main.js",
"dev": "tsc && node dist/main.js"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

69
apps/worker/src/health.ts Normal file
View File

@@ -0,0 +1,69 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { prisma } from '@hexahost/database';
import { getConfig } from '@hexahost/config';
import { logger } from './logger';
export function startHealthServer(): ReturnType<typeof createServer> {
const config = getConfig();
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url !== '/health' && req.url !== '/health/ready') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'error', message: 'Not found' }));
return;
}
const isReady = req.url === '/health/ready';
const timestamp = new Date().toISOString();
if (!isReady) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'ok',
service: config.APP_NAME,
timestamp,
}),
);
return;
}
try {
await prisma.$queryRaw`SELECT 1`;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'ok',
service: config.APP_NAME,
timestamp,
checks: [{ name: 'database', status: 'ok' }],
}),
);
} catch (error) {
logger.error({ err: error }, 'Readiness check failed');
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
status: 'error',
service: config.APP_NAME,
timestamp,
checks: [
{
name: 'database',
status: 'error',
message: error instanceof Error ? error.message : 'Database check failed',
},
],
}),
);
}
});
server.listen(config.WORKER_PORT, '0.0.0.0', () => {
logger.info({ port: config.WORKER_PORT }, 'Worker health server listening');
});
return server;
}

80
apps/worker/src/index.ts Normal file
View File

@@ -0,0 +1,80 @@
import { Worker, type ConnectionOptions } from 'bullmq';
import IORedis from 'ioredis';
import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database';
import { startHealthServer } from './health';
import { logger } from './logger';
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
function createRedisConnection(redisUrl: string): IORedis {
return new IORedis(redisUrl, {
maxRetriesPerRequest: null,
});
}
function createQueueWorker(
queueName: WorkerQueueName,
connection: ConnectionOptions,
): Worker {
return new Worker(
queueName,
async (job) => {
logger.info(
{ queue: queueName, jobId: job.id, jobName: job.name },
'Processing job',
);
return { acknowledged: true, queue: queueName };
},
{ connection },
);
}
async function bootstrap(): Promise<void> {
const config = validateConfig();
const healthServer = startHealthServer();
const redis = createRedisConnection(config.REDIS_URL);
const workers = WORKER_QUEUES.map((queueName) =>
createQueueWorker(queueName, redis),
);
for (const worker of workers) {
worker.on('completed', (job) => {
logger.info({ queue: worker.name, jobId: job.id }, 'Job completed');
});
worker.on('failed', (job, error) => {
logger.error(
{ queue: worker.name, jobId: job?.id, err: error },
'Job failed',
);
});
}
logger.info({ queues: WORKER_QUEUES }, 'Worker started');
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, 'Shutting down worker');
await Promise.all(workers.map((worker) => worker.close()));
await redis.quit();
await prisma.$disconnect();
healthServer.close(() => {
process.exit(0);
});
};
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
}
void bootstrap();

10
apps/worker/src/logger.ts Normal file
View File

@@ -0,0 +1,10 @@
import pino from 'pino';
export const logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
base: {
service: process.env['APP_NAME'] ?? 'HexaHost GameCloud Worker',
env: process.env['APP_ENV'] ?? 'development',
},
timestamp: pino.stdTimeFunctions.isoTime,
});

14
apps/worker/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
console.log(
JSON.stringify({
level: "info",
msg: "worker started (phase 0 stub)",
redis: redisUrl.replace(/:[^:@]+@/, ":***@"),
}),
);
// Phase 0: worker process stays alive for compose; BullMQ queues land in Phase 1+
setInterval(() => {
console.log(JSON.stringify({ level: "debug", msg: "worker heartbeat" }));
}, 60_000);

11
apps/worker/src/queues.ts Normal file
View File

@@ -0,0 +1,11 @@
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
export const QUEUE_NOTIFICATIONS = 'notifications' as const;
export const WORKER_QUEUES = [
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
QUEUE_NOTIFICATIONS,
] as const;
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];

13
apps/worker/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}