M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구 - packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그 - packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개) - apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
128
apps/server/src/server.ts
Normal file
128
apps/server/src/server.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/** Wires DB, auth, rooms, HTTP and WebSocket into one Bun server (docs/02 §2). */
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import type { Server } from 'bun';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { Config } from './config';
|
||||
import { openDb } from './db';
|
||||
import { SESSION_COOKIE, Sessions } from './auth/sessions';
|
||||
import { Users, toPublicUser } from './auth/users';
|
||||
import { createHttpApp, type HttpDeps } from './http/app';
|
||||
import { RoomManager } from './rooms/manager';
|
||||
import { RoomStore } from './rooms/store';
|
||||
import { Gateway, type WsData } from './ws/gateway';
|
||||
|
||||
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
|
||||
export const jsonLog: LogFn = (level, msg, extra) => {
|
||||
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
|
||||
if (level === 'error') console.error(line);
|
||||
else console.log(line);
|
||||
};
|
||||
|
||||
export interface StartOptions {
|
||||
config: Config;
|
||||
log?: LogFn;
|
||||
staticDir?: string | null;
|
||||
discordExchange?: HttpDeps['discordExchange'];
|
||||
}
|
||||
|
||||
export function startServer(opts: StartOptions) {
|
||||
const { config } = opts;
|
||||
const log = opts.log ?? jsonLog;
|
||||
const db = openDb(config.dbPath);
|
||||
const users = new Users(db);
|
||||
const sessions = new Sessions(db);
|
||||
const store = new RoomStore(db);
|
||||
const publicUser = (id: string) => {
|
||||
const u = users.get(id);
|
||||
return u ? toPublicUser(u) : null;
|
||||
};
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
let ready = true;
|
||||
|
||||
const ipOf = (req: Request) =>
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || server?.requestIP(req)?.address || 'unknown';
|
||||
|
||||
const app = createHttpApp({
|
||||
config,
|
||||
users,
|
||||
sessions,
|
||||
rooms,
|
||||
ipOf,
|
||||
discordExchange: opts.discordExchange,
|
||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||
});
|
||||
|
||||
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
|
||||
const serveStatic = staticDir && existsSync(staticDir);
|
||||
|
||||
const server: Server<WsData> = Bun.serve<WsData>({
|
||||
port: config.port,
|
||||
async fetch(req, srv) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/ws') {
|
||||
const origin = req.headers.get('origin');
|
||||
if (!origin || !config.allowedOrigins.includes(origin)) return new Response('forbidden origin', { status: 403 });
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
const token = cookie
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith(`${SESSION_COOKIE}=`))
|
||||
?.slice(SESSION_COOKIE.length + 1);
|
||||
const userId = token ? sessions.resolve(decodeURIComponent(token)) : null;
|
||||
if (!userId) return new Response('unauthorized', { status: 401 });
|
||||
return gateway.upgrade(req, srv, userId);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/') || url.pathname === '/healthz') return app.fetch(req);
|
||||
if (url.pathname.startsWith('/internal/')) return new Response('not found', { status: 404 });
|
||||
if (serveStatic) return serveFile(staticDir!, url.pathname);
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
websocket: {
|
||||
...gateway.handlers,
|
||||
idleTimeout: 40,
|
||||
sendPings: true,
|
||||
perMessageDeflate: true,
|
||||
maxPayloadLength: 64 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const sweeper = setInterval(() => {
|
||||
try {
|
||||
rooms.sweep();
|
||||
sessions.purgeExpired();
|
||||
store.purgeOldLogs();
|
||||
} catch (err) {
|
||||
log('error', 'sweep failed', { err: String(err) });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const stop = async () => {
|
||||
ready = false;
|
||||
clearInterval(sweeper);
|
||||
gateway.shutdown();
|
||||
rooms.shutdown();
|
||||
await server.stop(true);
|
||||
db.close();
|
||||
};
|
||||
|
||||
return { server, stop, rooms, users, sessions, db, gateway };
|
||||
}
|
||||
|
||||
async function serveFile(root: string, pathname: string): Promise<Response> {
|
||||
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
||||
const candidate = join(root, safe);
|
||||
if (candidate.startsWith(root) && !candidate.endsWith('/')) {
|
||||
const f = Bun.file(candidate);
|
||||
if (await f.exists()) {
|
||||
const immutable = safe.startsWith('/assets/');
|
||||
return new Response(f, { headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' } });
|
||||
}
|
||||
}
|
||||
// SPA fallback.
|
||||
return new Response(Bun.file(join(root, 'index.html')), { headers: { 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
}
|
||||
Reference in New Issue
Block a user