import Fastify from 'fastify'; import websocketPlugin from '@fastify/websocket'; import staticPlugin from '@fastify/static'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { activeGameCount, chooseSide, createGame, pruneFinished, attachBotDriver, } from './games.js'; import { attachSocket } from './ws.js'; import { createGameSchema } from './validation.js'; import { CasualBrain, BotDriver } from './bot/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT ?? '3000', 10); const HOST = process.env.HOST ?? '0.0.0.0'; const STATIC_DIR = process.env.STATIC_DIR ?? path.resolve(__dirname, '../../client/dist'); const PUBLIC_BASE = process.env.PUBLIC_BASE ?? ''; const startedAt = Date.now(); const fastify = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info', transport: process.env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' }, }, }, trustProxy: true, }); await fastify.register(websocketPlugin); fastify.get('/api/health', async () => ({ ok: true, activeGames: activeGameCount(), uptime: Math.floor((Date.now() - startedAt) / 1000), })); fastify.post('/api/games', async (req, reply) => { const parsed = createGameSchema.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'malformed', detail: parsed.error.issues }; } const { mode, side, highlightingEnabled, vsAi } = parsed.data; // Phase 1: only 'casual' is implemented. 'recon' returns 503. if (vsAi && vsAi.brain === 'recon') { reply.code(503); return { error: 'ai_offline', detail: 'recon bot not yet implemented' }; } const creatorSide = chooseSide(side); const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled, vsAi }); // For AI games, wire the bot. if (vsAi && game.aiOpponent) { const brain = new CasualBrain({}); const driver = new BotDriver({ game, brain, color: game.aiOpponent.color }); await driver.init(); attachBotDriver(game.id, driver); } const publicBase = PUBLIC_BASE || (req.headers.host ? `${req.protocol}://${req.headers.host}` : ''); const joinUrl = vsAi ? null : `${publicBase}/g/${game.id}`; return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl }; }); fastify.get('/ws', { websocket: true }, (socket) => { // fastify-websocket v11 passes the raw ws socket directly. const raw = (socket as unknown as { socket?: unknown }).socket ?? socket; attachSocket(raw as never); }); // Static client assets — serve dist/ if present, gracefully degrade if not. import('node:fs').then((fs) => { if (fs.existsSync(STATIC_DIR)) { fastify.register(staticPlugin, { root: STATIC_DIR, prefix: '/', decorateReply: true, }); // SPA fallback: serve index.html for /g/ etc. fastify.setNotFoundHandler((req, reply) => { const accept = String(req.headers.accept ?? ''); if (accept.includes('text/html')) { return (reply as unknown as { sendFile: (n: string) => unknown }).sendFile('index.html'); } reply.code(404).send({ error: 'not_found' }); }); } else { fastify.log.warn({ STATIC_DIR }, 'static client dist not found; serving API only'); } }); // Janitor: prune finished games every 5 min. const janitor = setInterval(() => { const removed = pruneFinished(); if (removed > 0) fastify.log.info({ removed }, 'pruned finished games'); }, 5 * 60 * 1000); janitor.unref(); const ready = fastify.listen({ port: PORT, host: HOST }); ready.then(() => { fastify.log.info(`blind_chess listening on ${HOST}:${PORT}`); }).catch((err) => { fastify.log.error(err); process.exit(1); }); for (const sig of ['SIGTERM', 'SIGINT'] as const) { process.on(sig, () => { fastify.log.info({ sig }, 'shutting down'); fastify.close().then(() => process.exit(0)); }); }