Files
blind_chess/packages/server/src/server.ts
T
2026-04-28 14:10:19 -04:00

123 lines
3.9 KiB
TypeScript

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/<id> 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));
});
}