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:
EJClaw
2026-10-04 04:18:29 +09:00
parent f4d63e1544
commit 6ee8da18f1
45 changed files with 4175 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
/** WebSocket gateway: auth on upgrade, validation, rate limits, dispatch (docs/03). */
import type { Server, ServerWebSocket } from 'bun';
import { ClientMessage, MAX_CLIENT_MESSAGE_BYTES, PROTOCOL, type PublicUser, type ServerMessage } from '@bg/shared';
import type { RoomManager } from '../rooms/manager';
import type { Conn, Room } from '../rooms/room';
import { TokenBucket } from './rate-limit';
export interface WsData {
userId: string;
connId: number;
bucket: TokenBucket;
strikes: number;
room: string | null;
conn: Conn | null;
}
export interface GatewayDeps {
rooms: RoomManager;
me(userId: string): PublicUser | null;
now?: () => number;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
}
export const MAX_CONNS_PER_USER = 5;
const MAX_BUFFERED = 1024 * 1024;
export class Gateway {
private nextId = 1;
private byUser = new Map<string, Set<ServerWebSocket<WsData>>>();
private closing = false;
constructor(private deps: GatewayDeps) {}
get connectionCount(): number {
let n = 0;
for (const s of this.byUser.values()) n += s.size;
return n;
}
/** Called from fetch() after the session was resolved. Returns a Response on refusal. */
upgrade(req: Request, server: Server<WsData>, userId: string): Response | undefined {
if (this.closing) return new Response('restarting', { status: 503 });
if ((this.byUser.get(userId)?.size ?? 0) >= MAX_CONNS_PER_USER) return new Response('too many connections', { status: 429 });
const data: WsData = { userId, connId: this.nextId++, bucket: new TokenBucket(20, 40), strikes: 0, room: null, conn: null };
if (server.upgrade(req, { data })) return undefined;
return new Response('upgrade failed', { status: 400 });
}
private send(ws: ServerWebSocket<WsData>, msg: ServerMessage): void {
if (ws.getBufferedAmount() > MAX_BUFFERED) {
ws.close(1013, 'slow consumer');
return;
}
ws.send(JSON.stringify(msg));
}
private connFor(ws: ServerWebSocket<WsData>): Conn {
ws.data.conn ??= {
userId: ws.data.userId,
send: (m) => this.send(ws, m),
close: (code, reason) => ws.close(code, reason),
};
return ws.data.conn;
}
readonly handlers = {
open: (ws: ServerWebSocket<WsData>) => {
const set = this.byUser.get(ws.data.userId) ?? new Set();
set.add(ws);
this.byUser.set(ws.data.userId, set);
const me = this.deps.me(ws.data.userId);
if (!me) {
ws.close(4401, 'unauthorized');
return;
}
this.send(ws, {
t: 'welcome',
me,
serverTime: (this.deps.now ?? Date.now)(),
protocol: PROTOCOL,
activeRoom: this.deps.rooms.activeRoomFor(ws.data.userId),
});
},
message: (ws: ServerWebSocket<WsData>, raw: string | Buffer) => {
const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.byteLength;
if (size > MAX_CLIENT_MESSAGE_BYTES) {
ws.close(1009, 'message too big');
return;
}
if (!ws.data.bucket.take()) {
if (++ws.data.strikes > 50) ws.close(4429, 'rate limited');
return;
}
let parsed: ClientMessage;
try {
const json = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
const r = ClientMessage.safeParse(json);
if (!r.success) {
this.send(ws, { t: 'error', code: 'bad-message', message: '잘못된 요청이에요.' });
return;
}
parsed = r.data;
} catch {
this.send(ws, { t: 'error', code: 'bad-json', message: '잘못된 요청이에요.' });
return;
}
try {
this.dispatch(ws, parsed);
} catch (err) {
this.deps.log?.('error', 'dispatch failed', { user: ws.data.userId, t: parsed.t, err: String(err), stack: (err as Error).stack });
this.send(ws, { t: 'error', code: 'internal', message: '알 수 없는 오류가 발생했어요.' });
}
},
close: (ws: ServerWebSocket<WsData>) => {
this.byUser.get(ws.data.userId)?.delete(ws);
if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId);
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
if (room && ws.data.conn) room.disconnect(ws.data.conn);
},
};
private dispatch(ws: ServerWebSocket<WsData>, m: ClientMessage): void {
const u = ws.data.userId;
const err = (message: string, code = 'rejected') => this.send(ws, { t: 'error', code, message });
if (m.t === 'ping') {
this.send(ws, { t: 'pong', ts: m.ts, serverTime: (this.deps.now ?? Date.now)() });
return;
}
if (m.t === 'join') {
const room = this.deps.rooms.get(m.code);
if (!room) return err('방을 찾을 수 없어요. 코드를 다시 확인해 주세요.', 'room-not-found');
// Leaving a previous room on this socket.
if (ws.data.room && ws.data.room !== m.code) {
const prev = this.deps.rooms.get(ws.data.room);
if (prev && ws.data.conn) prev.disconnect(ws.data.conn);
}
if (m.as === 'player') this.deps.rooms.releaseOtherLobbySeats(u, m.code);
const e = room.join(this.connFor(ws), m.as);
if (e) return err(e, 'join-failed');
ws.data.room = m.code;
return;
}
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
let e: string | null = null;
switch (m.t) {
case 'leave':
room.leave(u);
ws.data.room = null;
return;
case 'seat':
e = room.seat(u, m.seat);
break;
case 'unseat':
e = room.unseat(u);
break;
case 'ready':
e = room.ready(u, m.ready);
break;
case 'config': {
const { t: _t, ...patch } = m;
e = room.configure(u, patch);
break;
}
case 'start':
e = room.start(u);
break;
case 'kick':
e = room.kick(u, m.userId);
break;
case 'host':
e = room.transferHost(u, m.userId);
break;
case 'act':
room.act(u, m.cs, m.a);
return;
case 'chat':
e = room.chat(u, m.text);
break;
case 'emote':
room.emote(u, m.id);
return;
case 'rematch':
e = room.rematch(u);
break;
case 'sync':
room.sendSync(this.connFor(ws));
return;
}
if (e) err(e);
}
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
shutdown(): void {
this.closing = true;
for (const set of this.byUser.values()) {
for (const ws of set) {
this.send(ws, { t: 'bye', reason: 'restart' });
ws.close(1012, 'restart');
}
}
}
}

View File

@@ -0,0 +1,45 @@
/** Token bucket (docs/03 §8). */
export class TokenBucket {
private tokens: number;
private last: number;
constructor(
private ratePerSec: number,
private burst: number,
now = Date.now(),
) {
this.tokens = burst;
this.last = now;
}
take(now = Date.now()): boolean {
this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec);
this.last = now;
if (this.tokens < 1) return false;
this.tokens -= 1;
return true;
}
}
/** Fixed-window counter per key (HTTP endpoints). */
export class WindowLimiter {
private hits = new Map<string, { start: number; n: number }>();
constructor(
private limit: number,
private windowMs: number,
) {}
allow(key: string, now = Date.now()): boolean {
const h = this.hits.get(key);
if (!h || now - h.start >= this.windowMs) {
this.hits.set(key, { start: now, n: 1 });
if (this.hits.size > 50_000) this.prune(now);
return true;
}
h.n++;
return h.n <= this.limit;
}
private prune(now: number): void {
for (const [k, v] of this.hits) if (now - v.start >= this.windowMs) this.hits.delete(k);
}
}