123 lines
3.9 KiB
TypeScript
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));
|
|
});
|
|
}
|