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:
205
apps/server/src/ws/gateway.ts
Normal file
205
apps/server/src/ws/gateway.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/server/src/ws/rate-limit.ts
Normal file
45
apps/server/src/ws/rate-limit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user