feat: implement and deploy blind_chess MVP
- pnpm workspace: shared/server/client packages - Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy), per-player view filter, zod validation, rate limiting, grace-window disconnect handling - Client: Svelte 5 + Vite, click-to-move board, moderator panel, promotion/draw dialogs - Shared: protocol types, ModeratorText enum, geometricMoves helper (provably zero opponent-info leak) - 43 tests pass (21 shared, 22 server incl. 4 real-WS integration) - Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed, Caddy block for chess.sethpc.xyz - Live at https://chess.sethpc.xyz Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
} from './games.js';
|
||||
import { attachSocket } from './ws.js';
|
||||
import { createGameSchema } from './validation.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 } = parsed.data;
|
||||
const creatorSide = chooseSide(side);
|
||||
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled });
|
||||
|
||||
const publicBase = PUBLIC_BASE
|
||||
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
|
||||
const joinUrl = `${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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user