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,59 @@
/** Discord OAuth2 (authorization code + state), docs/05-accounts-auth.md §3. */
export interface DiscordUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
}
const API = 'https://discord.com/api/v10';
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
const p = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri,
scope: 'identify',
state,
prompt: 'none',
});
return `https://discord.com/oauth2/authorize?${p}`;
}
export async function exchangeCode(
cfg: { clientId: string; clientSecret: string },
code: string,
redirectUri: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscordUser> {
const tokenRes = await fetchImpl(`${API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
signal: AbortSignal.timeout(10_000),
});
if (!tokenRes.ok) throw new Error(`discord token exchange failed: ${tokenRes.status}`);
const token = (await tokenRes.json()) as { access_token: string; token_type: string };
const meRes = await fetchImpl(`${API}/users/@me`, {
headers: { Authorization: `${token.token_type} ${token.access_token}` },
signal: AbortSignal.timeout(10_000),
});
if (!meRes.ok) throw new Error(`discord /users/@me failed: ${meRes.status}`);
return (await meRes.json()) as DiscordUser;
}
export function avatarUrl(u: DiscordUser): string | null {
return u.avatar ? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.png?size=128` : null;
}
/** Only same-site relative paths are allowed as post-login redirect targets. */
export function safeNext(next: string | null | undefined): string {
if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\')) return '/';
return next;
}

View File

@@ -0,0 +1,86 @@
import type { Database } from 'bun:sqlite';
const DAY = 24 * 3600_000;
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
export const SESSION_COOKIE = 'sid';
export function randomToken(bytes = 32): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Buffer.from(buf).toString('base64url');
}
export function sha256Hex(input: string): string {
return new Bun.CryptoHasher('sha256').update(input).digest('hex');
}
export class Sessions {
constructor(private db: Database) {}
create(userId: string, kind: 'guest' | 'member', userAgent: string | null, now = Date.now()): { token: string; expiresAt: number } {
const token = randomToken();
const expiresAt = now + SESSION_TTL[kind];
this.db
.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at, last_used_at, user_agent) VALUES (?, ?, ?, ?, ?, ?)')
.run(sha256Hex(token), userId, now, expiresAt, now, userAgent?.slice(0, 200) ?? null);
return { token, expiresAt };
}
/** Resolves a token to a user id; slides expiry at most once per day. */
resolve(token: string, now = Date.now()): string | null {
const hash = sha256Hex(token);
const row = this.db
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND u.deleted_at IS NULL`,
)
.get(hash);
if (!row) return null;
if (row.expires_at <= now) {
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash);
return null;
}
if (now - row.last_used_at > DAY) {
this.db
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
.run(now, now + SESSION_TTL[row.kind], hash);
}
return row.user_id;
}
revoke(token: string): void {
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(sha256Hex(token));
}
revokeAll(userId: string): void {
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
}
purgeExpired(now = Date.now()): number {
return this.db.query('DELETE FROM sessions WHERE expires_at <= ?').run(now).changes;
}
}
/** Minimal HMAC-signed value for short-lived cookies (OAuth state + PKCE verifier). */
export async function signValue(secret: string, value: string): Promise<string> {
const mac = await hmac(secret, value);
return `${Buffer.from(value).toString('base64url')}.${mac}`;
}
export async function unsignValue(secret: string, signed: string): Promise<string | null> {
const dot = signed.lastIndexOf('.');
if (dot < 0) return null;
const value = Buffer.from(signed.slice(0, dot), 'base64url').toString();
const expected = await hmac(secret, value);
const given = signed.slice(dot + 1);
if (expected.length !== given.length) return null;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
return diff === 0 ? value : null;
}
async function hmac(secret: string, value: string): Promise<string> {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value));
return Buffer.from(sig).toString('base64url');
}

View File

@@ -0,0 +1,144 @@
import type { Database } from 'bun:sqlite';
import { ulid } from 'ulid';
import type { PublicUser } from '@bg/shared';
export interface UserRow {
id: string;
kind: 'guest' | 'member';
nickname: string;
avatar_url: string | null;
use_avatar: number;
settings_json: string;
nick_changed_at: number | null;
nick_change_count: number;
created_at: number;
last_seen_at: number;
deleted_at: number | null;
}
export const NICK_CHANGES_PER_DAY = 10;
export class Users {
constructor(private db: Database) {}
get(id: string): UserRow | null {
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
}
createGuest(nickname: string, now = Date.now()): UserRow {
const id = ulid(now);
this.db
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
.run(id, 'guest', nickname, now, now);
return this.get(id)!;
}
findByOauth(provider: string, providerUserId: string): UserRow | null {
return this.db
.query<UserRow, [string, string]>(
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
)
.get(provider, providerUserId);
}
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
const id = ulid(now);
this.db
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, 'member', nickname, avatarUrl, now, now);
return this.get(id)!;
}
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
this.db
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
.run(provider, providerUserId, userId, username, now);
}
promoteToMember(userId: string, avatarUrl: string | null): void {
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
}
setAvatar(userId: string, avatarUrl: string | null): void {
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
}
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
mergeGuestInto(guestId: string, memberId: string): void {
this.db.transaction(() => {
const rows = this.db
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
)
.all(guestId);
for (const r of rows) {
this.db
.query(
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
wins = wins + excluded.wins, draws = draws + excluded.draws`,
)
.run(memberId, r.game_id, r.played, r.wins, r.draws);
}
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
})();
}
/** Returns an error message or null. Enforces the daily change limit. */
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
const u = this.get(userId);
if (!u) return '사용자를 찾을 수 없어요.';
if (u.nickname === nickname) return null;
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
const count = sameDay ? u.nick_change_count : 0;
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
this.db
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
return null;
}
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
if (patch.useAvatar !== undefined) {
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
}
if (patch.settings) {
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
}
}
touch(userId: string, now = Date.now()): void {
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
}
softDelete(userId: string, now = Date.now()): void {
this.db.transaction(() => {
this.db
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
.run(now, userId);
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
})();
}
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
return this.db
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
)
.all(userId);
}
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
nickname: u.nickname,
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
kind: u.kind,
};
}

30
apps/server/src/config.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface Config {
port: number;
publicOrigin: string;
/** Extra origins accepted for WS/CSRF checks (e.g. Vite dev server). */
allowedOrigins: string[];
dbPath: string;
discord: { clientId: string; clientSecret: string } | null;
sessionSecret: string;
secureCookies: boolean;
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const clientId = env.DISCORD_CLIENT_ID;
const clientSecret = env.DISCORD_CLIENT_SECRET;
const sessionSecret = env.SESSION_SECRET ?? '';
if (publicOrigin.startsWith('https://') && sessionSecret.length < 32) {
throw new Error('SESSION_SECRET must be at least 32 characters in production');
}
return {
port: Number(env.PORT ?? 3000),
publicOrigin,
allowedOrigins: [publicOrigin, ...extra],
dbPath: env.DB_PATH ?? './data/app.db',
discord: clientId && clientSecret ? { clientId, clientSecret } : null,
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
secureCookies: publicOrigin.startsWith('https://'),
};
}

View File

@@ -0,0 +1,32 @@
import { Database } from 'bun:sqlite';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const MIGRATIONS_DIR = join(import.meta.dir, 'migrations');
export function openDb(path: string): Database {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
const db = new Database(path, { create: true, strict: true });
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA synchronous = NORMAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA busy_timeout = 5000;');
migrate(db);
return db;
}
export function migrate(db: Database): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)');
const applied = new Set(
db.query<{ name: string }, []>('SELECT name FROM schema_migrations').all().map((r) => r.name),
);
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
db.transaction(() => {
db.exec(sql);
db.query('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now());
})();
}
}

View File

@@ -0,0 +1,100 @@
CREATE TABLE users (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('guest','member')),
nickname TEXT NOT NULL,
avatar_url TEXT,
use_avatar INTEGER NOT NULL DEFAULT 1,
settings_json TEXT NOT NULL DEFAULT '{}',
nick_changed_at INTEGER,
nick_change_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE oauth_accounts (
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
username TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (provider, provider_user_id)
);
CREATE INDEX oauth_accounts_user ON oauth_accounts(user_id);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
user_agent TEXT
);
CREATE INDEX sessions_user ON sessions(user_id);
CREATE TABLE rooms (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
host_id TEXT NOT NULL,
game_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('lobby','playing','finished','paused','broken','closed')),
visibility TEXT NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER
);
CREATE UNIQUE INDEX rooms_open_code ON rooms(code) WHERE status <> 'closed';
CREATE INDEX rooms_status ON rooms(status);
CREATE TABLE room_state (
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE room_log (
room_id TEXT NOT NULL,
seq INTEGER NOT NULL,
game_no INTEGER NOT NULL,
actor_id TEXT,
kind TEXT NOT NULL,
data_json TEXT NOT NULL,
at INTEGER NOT NULL,
PRIMARY KEY (room_id, seq)
);
CREATE TABLE games (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
game_no INTEGER NOT NULL,
game_id TEXT NOT NULL,
options_json TEXT NOT NULL,
seed_hash TEXT NOT NULL,
seed TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER,
end_reason TEXT,
result_json TEXT
);
CREATE INDEX games_room ON games(room_id);
CREATE TABLE game_players (
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
seat INTEGER NOT NULL,
rank INTEGER,
score REAL,
PRIMARY KEY (game_pk, user_id)
);
CREATE INDEX game_players_user ON game_players(user_id);
CREATE TABLE user_stats (
user_id TEXT NOT NULL,
game_id TEXT NOT NULL,
played INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, game_id)
);

268
apps/server/src/http/app.ts Normal file
View File

@@ -0,0 +1,268 @@
/** HTTP API (docs/05 §6, docs/06). */
import { Hono, type Context } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { z } from 'zod';
import { CATALOG, catalogById, nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
import type { Config } from '../config';
import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord';
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
import { toPublicUser, type Users } from '../auth/users';
import type { RoomManager } from '../rooms/manager';
import { WindowLimiter } from '../ws/rate-limit';
export interface HttpDeps {
config: Config;
users: Users;
sessions: Sessions;
rooms: RoomManager;
/** Injected for tests. */
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
ipOf: (req: Request) => string;
health: () => { ok: boolean; [k: string]: unknown };
}
type Env = { Variables: { userId: string | null } };
const OAUTH_COOKIE = 'oauth';
export function createHttpApp(d: HttpDeps) {
const app = new Hono<Env>();
const createLimiter = new WindowLimiter(10, 60_000);
const lookupLimiter = new WindowLimiter(30, 60_000);
const guestLimiter = new WindowLimiter(20, 60_000);
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
const cookieOpts = (maxAgeMs: number) => ({
httpOnly: true,
secure: d.config.secureCookies,
sameSite: 'Lax' as const,
path: '/',
maxAge: Math.floor(maxAgeMs / 1000),
});
// Session resolution.
app.use('/api/*', async (c, next) => {
const token = getCookie(c, SESSION_COOKIE);
c.set('userId', token ? d.sessions.resolve(token) : null);
await next();
});
// CSRF: state-changing requests must come from our origin (docs/04 §7).
app.use('/api/*', async (c, next) => {
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
const origin = c.req.header('origin');
if (!origin || !d.config.allowedOrigins.includes(origin)) return c.json({ error: '잘못된 요청이에요.' }, 403);
}
await next();
});
const requireUser = (c: Context<Env>) => c.get('userId');
const startSession = (c: Context<Env>, userId: string, kind: 'guest' | 'member') => {
const { token } = d.sessions.create(userId, kind, c.req.header('user-agent') ?? null);
setCookie(c, SESSION_COOKIE, token, cookieOpts(SESSION_TTL[kind]));
};
app.get('/healthz', (c) => {
const h = d.health();
return c.json(h, h.ok ? 200 : 503);
});
app.get('/api/config', (c) => c.json({ discord: d.config.discord !== null, catalog: CATALOG }));
app.post('/api/auth/guest', async (c) => {
if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400);
const nick = normalizeNickname(body.data.nickname);
const e = nicknameError(nick);
if (e) return c.json({ error: e }, 400);
const current = requireUser(c);
if (current) {
// Already signed in: treat as a nickname change instead of creating another account.
const err = d.users.changeNickname(current, nick);
if (err) return c.json({ error: err }, 400);
return c.json({ me: toPublicUser(d.users.get(current)!) });
}
const u = d.users.createGuest(nick);
startSession(c, u.id, 'guest');
return c.json({ me: toPublicUser(u) });
});
app.get('/api/auth/discord/start', async (c) => {
if (!d.config.discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
const state = randomToken(24);
const next = safeNext(c.req.query('next'));
setCookie(c, OAUTH_COOKIE, await signValue(d.config.sessionSecret, JSON.stringify({ state, next, at: Date.now() })), {
...cookieOpts(10 * 60_000),
path: '/api/auth/discord',
});
return c.redirect(authorizeUrl(d.config.discord.clientId, redirectUri, state));
});
app.get('/api/auth/discord/callback', async (c) => {
const discord = d.config.discord;
if (!discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
const raw = getCookie(c, OAUTH_COOKIE);
deleteCookie(c, OAUTH_COOKIE, { path: '/api/auth/discord' });
const payload = raw ? await unsignValue(d.config.sessionSecret, raw) : null;
const parsed = payload ? (JSON.parse(payload) as { state: string; next: string; at: number }) : null;
const code = c.req.query('code');
if (!parsed || !code || parsed.state !== c.req.query('state') || Date.now() - parsed.at > 10 * 60_000) {
return c.redirect('/?login=failed');
}
let du: DiscordUser;
try {
du = d.discordExchange ? await d.discordExchange(code, redirectUri) : await exchangeCode(discord, code, redirectUri);
} catch {
return c.redirect('/?login=failed');
}
const avatar = avatarUrl(du);
const existing = d.users.findByOauth('discord', du.id);
const current = requireUser(c) ? d.users.get(requireUser(c)!) : null;
let userId: string;
let merged = false;
if (existing) {
userId = existing.id;
d.users.setAvatar(userId, avatar);
if (current && current.kind === 'guest' && current.id !== existing.id) {
d.users.mergeGuestInto(current.id, existing.id);
merged = true;
}
} else if (current && current.kind === 'guest') {
d.users.linkOauth(current.id, 'discord', du.id, du.username);
d.users.promoteToMember(current.id, avatar);
userId = current.id;
merged = true;
} else {
const nick = normalizeNickname(du.global_name ?? du.username).slice(0, 12);
const u = d.users.createMember(nicknameError(nick) ? '디스코드 사용자' : nick, avatar);
d.users.linkOauth(u.id, 'discord', du.id, du.username);
userId = u.id;
}
const old = getCookie(c, SESSION_COOKIE);
if (old) d.sessions.revoke(old);
startSession(c, userId, 'member');
const next = safeNext(parsed.next);
return c.redirect(merged ? `${next}${next.includes('?') ? '&' : '?'}linked=1` : next);
});
app.post('/api/auth/logout', (c) => {
const token = getCookie(c, SESSION_COOKIE);
if (token) d.sessions.revoke(token);
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ ok: true });
});
app.post('/api/auth/logout-all', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
d.sessions.revokeAll(u);
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ ok: true });
});
app.get('/api/me', (c) => {
const u = requireUser(c);
const row = u ? d.users.get(u) : null;
if (!row) return c.json({ me: null });
return c.json({
me: toPublicUser(row),
settings: JSON.parse(row.settings_json),
useAvatar: !!row.use_avatar,
hasDiscordAvatar: !!row.avatar_url,
activeRoom: d.rooms.activeRoomFor(row.id),
});
});
app.patch('/api/me', async (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const body = z
.object({
nickname: z.string().max(100).optional(),
useAvatar: z.boolean().optional(),
settings: z
.object({
fontScale: z.enum(['normal', 'large', 'xlarge']).optional(),
theme: z.enum(['auto', 'light', 'dark']).optional(),
sound: z.boolean().optional(),
vibrate: z.boolean().optional(),
colorBlind: z.boolean().optional(),
tapConfirm: z.boolean().optional(),
})
.optional(),
})
.safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400);
if (body.data.nickname !== undefined) {
const nick = normalizeNickname(body.data.nickname);
const e = nicknameError(nick) ?? d.users.changeNickname(u, nick);
if (e) return c.json({ error: e }, 400);
}
d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings });
return c.json({ me: toPublicUser(d.users.get(u)!) });
});
app.delete('/api/me', async (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const body = z.object({ confirm: z.literal('탈퇴') }).safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '확인 문구를 정확히 입력해 주세요.' }, 400);
d.users.softDelete(u);
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ ok: true });
});
app.get('/api/me/stats', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
return c.json({ stats: d.users.stats(u) });
});
app.post('/api/rooms', async (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const body = z
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
.safeParse(await c.req.json().catch(() => null));
if (!body.success || !catalogById(body.data.gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
if (typeof r === 'string') return c.json({ error: r }, 429);
return c.json({ code: r.code });
});
app.post('/api/quick/:gameId', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const gameId = c.req.param('gameId');
if (!catalogById(gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const r = d.rooms.quickPlay(u, gameId);
if (typeof r === 'string') return c.json({ error: r }, 429);
return c.json({ code: r.code });
});
/** Room preview for the join screen (no auth needed). */
app.get('/api/rooms/:code', (c) => {
if (!lookupLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const code = normalizeRoomCode(c.req.param('code'));
const r = code ? d.rooms.get(code) : undefined;
if (!r || r.status === 'broken') return c.json({ error: '방을 찾을 수 없어요. 코드를 다시 확인해 주세요.' }, 404);
const host = d.users.get(r.hostId);
return c.json({
code: r.code,
codeLabel: formatRoomCode(r.code),
gameId: r.config.gameId,
host: host?.nickname ?? '',
seated: r.seatedIds().length,
maxPlayers: r.config.maxPlayers,
status: r.status,
});
});
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
return app;
}

27
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { loadConfig } from './config';
import { jsonLog, startServer } from './server';
const config = loadConfig();
const app = startServer({ config });
jsonLog('info', 'server started', { port: app.server.port, origin: config.publicOrigin, discord: config.discord !== null });
let stopping = false;
const shutdown = async (signal: string) => {
if (stopping) return;
stopping = true;
jsonLog('info', 'shutting down', { signal });
const force = setTimeout(() => process.exit(0), 10_000);
await app.stop();
clearTimeout(force);
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('uncaughtException', (err) => {
jsonLog('error', 'uncaughtException', { err: String(err), stack: err.stack });
process.exit(1);
});
process.on('unhandledRejection', (err) => {
jsonLog('error', 'unhandledRejection', { err: String(err) });
process.exit(1);
});

View File

@@ -0,0 +1,187 @@
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
import { ulid } from 'ulid';
import type { AnyGameDefinition } from '@bg/engine';
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
import type { RoomStore } from './store';
export const MAX_ROOMS = 2000;
export const MAX_ROOMS_PER_USER = 3;
const EMPTY_LOBBY_TTL = 10 * 60_000;
const FINISHED_TTL = 30 * 60_000;
export interface ManagerDeps {
store: RoomStore;
games: Record<string, AnyGameDefinition>;
users: { publicUser(id: string): PublicUser | null };
now?: () => number;
log?: RoomDeps['log'];
random?: () => number;
}
export class RoomManager {
private byCode = new Map<string, Room>();
/** userId → room code where the user holds a seat. */
private seatIndex = new Map<string, Set<string>>();
private createdBy = new Map<string, Set<string>>();
private readonly now: () => number;
constructor(private deps: ManagerDeps) {
this.now = deps.now ?? Date.now;
}
private roomDeps(): RoomDeps {
return {
store: this.deps.store,
games: this.deps.games,
users: this.deps.users,
now: this.now,
log: this.deps.log,
onSeatChange: (room, userId, seated) => {
const set = this.seatIndex.get(userId) ?? new Set<string>();
if (seated) set.add(room.code);
else set.delete(room.code);
if (set.size) this.seatIndex.set(userId, set);
else this.seatIndex.delete(userId);
},
};
}
get(code: string): Room | undefined {
return this.byCode.get(code);
}
all(): Room[] {
return [...this.byCode.values()];
}
private newCode(): string {
const rand = this.deps.random ?? Math.random;
for (let i = 0; i < 200; i++) {
const code = String(Math.floor(rand() * 1_000_000)).padStart(6, '0');
if (!isAllowedRoomCode(code) || this.byCode.has(code)) continue;
if (this.deps.store.codeInUse(code, this.now())) continue;
return code;
}
throw new Error('could not allocate room code');
}
/** Returns the room or a Korean error message. */
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
const def = this.deps.games[gameId];
if (!def) return '없는 게임이에요.';
if (this.byCode.size >= MAX_ROOMS) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
if (mine.length >= MAX_ROOMS_PER_USER) return `방은 한 번에 ${MAX_ROOMS_PER_USER}개까지 만들 수 있어요.`;
const config: RoomConfig = {
gameId,
options: def.defaultOptions,
maxPlayers: def.playersFor ? def.playersFor(def.defaultOptions).max : def.maxPlayers,
visibility,
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const code = this.newCode();
const id = ulid(this.now());
const room = new Room(id, code, config, hostId, this.roomDeps());
this.deps.store.insertRoom({
id,
code,
hostId,
gameId,
status: 'lobby',
visibility,
config,
stateJson: JSON.stringify(room.snapshot()),
now: this.now(),
});
this.byCode.set(code, room);
this.createdBy.set(hostId, new Set([...mine, code]));
return room;
}
/** Rooms where the user holds a seat in an unfinished game. */
activeRoomFor(userId: string): string | null {
for (const code of this.seatIndex.get(userId) ?? []) {
const r = this.byCode.get(code);
if (r && (r.inGame(userId) || r.status === 'lobby' || r.status === 'finished')) return code;
}
return null;
}
/** When a user takes a seat in `code`, free lobby seats they hold elsewhere (one room at a time). */
releaseOtherLobbySeats(userId: string, code: string): void {
for (const other of [...(this.seatIndex.get(userId) ?? [])]) {
if (other === code) continue;
const r = this.byCode.get(other);
if (r && !r.inGame(userId)) r.leave(userId);
}
}
publicRooms(gameId: string): { code: string; host: string; seated: number; max: number; options: unknown }[] {
return this.all()
.filter((r) => r.config.gameId === gameId && r.config.visibility === 'public' && r.status === 'lobby')
.map((r) => ({
code: r.code,
host: this.deps.users.publicUser(r.hostId)?.nickname ?? '',
seated: r.seatedIds().length,
max: r.config.maxPlayers,
options: r.config.options,
}))
.filter((r) => r.seated < r.max)
.sort((a, b) => a.max - a.seated - (b.max - b.seated));
}
/** Quick play: fullest open public lobby, else a new public room. */
quickPlay(userId: string, gameId: string): Room | string {
const best = this.publicRooms(gameId)[0];
if (best) return this.byCode.get(best.code)!;
return this.create(userId, gameId, 'public');
}
restoreAll(): { restored: number; broken: number } {
let restored = 0;
let broken = 0;
for (const { row, seq, stateJson } of this.deps.store.openRooms()) {
try {
const snap = JSON.parse(stateJson) as RoomSnapshot;
const room = Room.restore(row.id, row.code, seq, snap, this.roomDeps());
this.byCode.set(row.code, room);
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, true);
restored++;
} catch (err) {
this.deps.log?.('error', 'room restore failed', { code: row.code, err: String(err) });
this.deps.store.markBroken(row.id);
broken++;
}
}
return { restored, broken };
}
/** Periodic cleanup (call every minute). */
sweep(): void {
const now = this.now();
for (const room of this.all()) {
if (room.conns.size > 0) continue;
const idle = now - room.lastActiveAt;
const closable =
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
(room.status === 'finished' && idle > FINISHED_TTL) ||
((room.status === 'paused' || room.status === 'broken') && idle > FINISHED_TTL);
if (closable) this.close(room);
}
}
close(room: Room): void {
room.shutdown();
this.deps.store.close(room.id, this.now());
this.byCode.delete(room.code);
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, false);
}
shutdown(): void {
for (const r of this.all()) r.shutdown();
}
}

View File

@@ -0,0 +1,805 @@
/**
* One room: seats, spectators, chat, game runner, timers (docs/06, docs/03, docs/09 §4).
* All mutations run on the single JS thread, so a room never sees concurrent updates.
*/
import { ulid } from 'ulid';
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
import { sha256Hex } from '../auth/sessions';
import type { RoomStore } from './store';
export interface Conn {
userId: string;
send(msg: ServerMessage): void;
close(code: number, reason: string): void;
}
export interface RoomConfig {
gameId: string;
options: unknown;
maxPlayers: number;
visibility: 'private' | 'public';
allowSpectators: boolean;
chatEnabled: boolean;
chatFilter: boolean;
/** Seconds a disconnected player keeps their turn timer before auto actions kick in (docs/03 §6.2). */
graceSec: number;
}
export interface Seat {
userId: string;
ready: boolean;
}
interface Presence {
connected: boolean;
since: number;
joinedAt: number;
}
interface GameSlot {
gameId: string;
stateVersion: number;
state: unknown;
rng: RngState;
gamePk: string;
seedHex: string;
seedHash: string;
startedAt: number;
players: string[];
finished: boolean;
/** After a restore, deadlines are not enforced before this time (docs/04 §3.3). */
deadlineFloor: number | null;
}
/** Shape persisted to room_state.state_json. */
export interface RoomSnapshot {
v: 1;
config: RoomConfig;
hostId: string;
status: Room['status'];
seats: (Seat | null)[];
gameNo: number;
sessionStats: Record<string, { played: number; wins: number }>;
rematchVotes: string[];
lastSeed: string | null;
lastResult: GameResult | null;
game: GameSlot | null;
joinedAt: Record<string, number>;
}
export interface RoomDeps {
store: RoomStore;
games: Record<string, AnyGameDefinition>;
users: { publicUser(id: string): PublicUser | null };
now?: () => number;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
onEmpty?: (room: Room) => void;
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
}
const CHAT_HISTORY = 30;
const ERROR_LIMIT = 3;
export class Room {
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
seq = 0;
hostId: string;
seats: (Seat | null)[];
spectators = new Set<string>();
presence = new Map<string, Presence>();
conns = new Map<string, Conn>();
kicked = new Set<string>();
gameNo = 0;
sessionStats: Record<string, { played: number; wins: number }> = {};
rematchVotes = new Set<string>();
lastSeed: string | null = null;
lastResult: GameResult | null = null;
game: GameSlot | null = null;
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
lastActiveAt: number;
private timer: ReturnType<typeof setTimeout> | null = null;
private recentCs = new Map<string, number[]>();
private lastChatAt = new Map<string, number>();
private consecutiveErrors = 0;
private readonly now: () => number;
constructor(
readonly id: string,
readonly code: string,
public config: RoomConfig,
hostId: string,
private deps: RoomDeps,
) {
this.hostId = hostId;
this.seats = new Array(config.maxPlayers).fill(null);
this.now = deps.now ?? Date.now;
this.lastActiveAt = this.now();
}
get def(): AnyGameDefinition {
const d = this.deps.games[this.config.gameId];
if (!d) throw new Error(`unknown game ${this.config.gameId}`);
return d;
}
playerRange(): { min: number; max: number } {
const d = this.def;
return d.playersFor ? d.playersFor(this.config.options) : { min: d.minPlayers, max: d.maxPlayers };
}
seatOf(userId: string): number {
return this.seats.findIndex((s) => s?.userId === userId);
}
isParticipant(userId: string): boolean {
return this.seatOf(userId) >= 0 || this.spectators.has(userId);
}
seatedIds(): string[] {
return this.seats.filter((s): s is Seat => s !== null).map((s) => s.userId);
}
inGame(userId: string): boolean {
return !!this.game && !this.game.finished && this.game.players.includes(userId);
}
// ---------------------------------------------------------------- snapshot
snapshot(): RoomSnapshot {
const joinedAt: Record<string, number> = {};
for (const [u, p] of this.presence) if (this.seatOf(u) >= 0) joinedAt[u] = p.joinedAt;
return {
v: 1,
config: this.config,
hostId: this.hostId,
status: this.status,
seats: this.seats,
gameNo: this.gameNo,
sessionStats: this.sessionStats,
rematchVotes: [...this.rematchVotes],
lastSeed: this.lastSeed,
lastResult: this.lastResult,
game: this.game,
joinedAt,
};
}
static restore(id: string, code: string, seq: number, snap: RoomSnapshot, deps: RoomDeps): Room {
const room = new Room(id, code, snap.config, snap.hostId, deps);
room.seq = seq;
room.status = snap.status;
room.seats = snap.seats;
room.gameNo = snap.gameNo;
room.sessionStats = snap.sessionStats;
room.rematchVotes = new Set(snap.rematchVotes);
room.lastSeed = snap.lastSeed;
room.lastResult = snap.lastResult;
room.game = snap.game;
const now = room.now();
for (const s of room.seats) {
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
}
if (room.game && !room.game.finished) {
const def = room.def;
if (def.stateVersion !== room.game.stateVersion) {
if (!def.migrate) throw new Error(`state version mismatch for ${def.id}`);
room.game.state = def.migrate(room.game.state, room.game.stateVersion);
room.game.stateVersion = def.stateVersion;
}
room.game.deadlineFloor = now + 30_000;
room.scheduleTimer();
}
return room;
}
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
const nextSeq = this.seq + 1;
this.deps.store.save(
this.id,
nextSeq,
JSON.stringify(this.snapshot()),
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null,
);
this.seq = nextSeq;
this.lastActiveAt = this.now();
}
// ---------------------------------------------------------------- views
roomView(): RoomView {
const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const seats: (SeatView | null)[] = this.seats.map((s) => {
if (!s) return null;
const p = this.presence.get(s.userId);
const connected = !!p?.connected;
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000;
return { user: pub(s.userId), ready: s.ready, connected, away };
});
const range = this.playerRange();
return {
code: this.code,
hostId: this.hostId,
gameId: this.config.gameId,
options: this.config.options,
maxPlayers: this.config.maxPlayers,
minPlayers: range.min,
visibility: this.config.visibility,
turnSeconds: null,
allowSpectators: this.config.allowSpectators,
chatEnabled: this.config.chatEnabled,
chatFilter: this.config.chatFilter,
seats,
spectators: [...this.spectators].map(pub),
status: this.status,
gameNo: this.gameNo,
rematchVotes: [...this.rematchVotes],
sessionStats: this.sessionStats,
seedHash: this.game?.seedHash ?? null,
lastSeed: this.lastSeed,
};
}
private stateMessage(viewer: string, cs?: number): ServerMessage | null {
if (!this.game) return null;
const def = this.def;
const asPlayer = this.game.players.includes(viewer) ? viewer : null;
let view: unknown;
try {
view = def.view(this.game.state, asPlayer);
} catch (err) {
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
}
return {
t: 'state',
seq: this.seq,
view,
events: [],
active: this.game.finished ? [] : def.activePlayers(this.game.state),
deadline: this.game.finished ? null : this.effectiveDeadline(),
...(cs !== undefined ? { cs } : {}),
};
}
broadcastRoom(): void {
const msg: ServerMessage = { t: 'room', seq: this.seq, room: this.roomView() };
for (const c of this.conns.values()) c.send(msg);
}
private broadcastState(events: unknown[], actor?: string, cs?: number): void {
if (!this.game) return;
let spectatorMsg: ServerMessage | null = null;
for (const c of this.conns.values()) {
let msg: ServerMessage | null;
if (this.game.players.includes(c.userId)) {
msg = this.stateMessage(c.userId, c.userId === actor ? cs : undefined);
} else {
spectatorMsg ??= this.stateMessage('');
msg = spectatorMsg;
}
if (msg && msg.t === 'state') c.send({ ...msg, events });
else if (msg) c.send(msg);
}
}
notice(code: string, message: string): void {
for (const c of this.conns.values()) c.send({ t: 'notice', code, message });
}
// ---------------------------------------------------------------- membership
/** Returns an error message, or null on success. */
join(conn: Conn, as: 'player' | 'spectator'): string | null {
const u = conn.userId;
if (this.kicked.has(u)) return '방장이 내보낸 방에는 다시 들어갈 수 없어요.';
if (this.status === 'broken') return '서버 업데이트로 이 게임을 이어갈 수 없어요. 새로 시작해 주세요.';
const old = this.conns.get(u);
if (old && old !== conn) {
old.send({ t: 'bye', reason: 'replaced' });
old.close(4000, 'replaced');
}
const now = this.now();
const wasSeated = this.seatOf(u) >= 0;
if (!wasSeated && !this.spectators.has(u)) {
const seatable = as === 'player' && (this.status === 'lobby' || this.status === 'finished') && this.seats.some((s) => s === null);
if (seatable) {
const idx = this.seats.findIndex((s) => s === null);
this.seats[idx] = { userId: u, ready: false };
this.deps.onSeatChange?.(this, u, true);
} else {
if (!this.config.allowSpectators && !this.inGame(u)) return '이 방은 가득 찼어요.';
this.spectators.add(u);
}
}
const prev = this.presence.get(u);
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt ?? now });
this.conns.set(u, conn);
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
}
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
this.broadcastRoom();
const st = this.stateMessage(u);
if (st) conn.send(st);
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
for (const m of this.chatLog) conn.send(m);
this.scheduleTimer();
return null;
}
/** Connection dropped (not an intentional leave). */
disconnect(conn: Conn): void {
const u = conn.userId;
if (this.conns.get(u) !== conn) return;
this.conns.delete(u);
const p = this.presence.get(u);
if (p) {
p.connected = false;
p.since = this.now();
}
if (this.spectators.has(u)) {
this.spectators.delete(u);
this.presence.delete(u);
} else if (this.inGame(u)) {
this.notice('disconnected', `${this.nick(u)}님의 연결이 끊겼어요.`);
}
this.broadcastRoom();
this.scheduleTimer();
if (this.conns.size === 0) this.deps.onEmpty?.(this);
}
leave(userId: string): void {
const conn = this.conns.get(userId);
this.conns.delete(userId);
this.spectators.delete(userId);
this.presence.delete(userId);
const seat = this.seatOf(userId);
if (seat >= 0) {
if (this.inGame(userId)) {
const action = this.def.onLeave?.(this.game!.state, userId);
if (action) this.runAction(userId, action, undefined, true);
// Stay in the seat as "away" until the game ends so the game state stays consistent.
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
} else {
this.seats[seat] = null;
this.rematchVotes.delete(userId);
this.deps.onSeatChange?.(this, userId, false);
}
}
if (this.hostId === userId) this.pickNewHost();
this.persist({ actorId: userId, kind: 'leave', data: null });
this.broadcastRoom();
conn?.send({ t: 'bye', reason: 'closed' });
if (this.conns.size === 0) this.deps.onEmpty?.(this);
}
private pickNewHost(): void {
const candidates = [...this.presence.entries()]
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
.sort((a, b) => a[1].joinedAt - b[1].joinedAt);
const next = candidates[0]?.[0] ?? this.seatedIds()[0] ?? [...this.spectators][0];
if (next) this.hostId = next;
}
// ---------------------------------------------------------------- lobby commands
private requireLobby(): string | null {
return this.status === 'lobby' || this.status === 'finished' ? null : '게임 중에는 바꿀 수 없어요.';
}
seat(userId: string, seat?: number): string | null {
const err = this.requireLobby();
if (err) return err;
const target = seat ?? this.seats.findIndex((s) => s === null);
if (target < 0 || target >= this.seats.length) return '빈자리가 없어요.';
if (this.seats[target] && this.seats[target]!.userId !== userId) return '이미 다른 사람이 앉은 자리예요.';
const cur = this.seatOf(userId);
if (cur === target) return null;
const ready = cur >= 0 ? this.seats[cur]!.ready : false;
if (cur >= 0) this.seats[cur] = null;
this.seats[target] = { userId, ready };
this.spectators.delete(userId);
if (cur < 0) this.deps.onSeatChange?.(this, userId, true);
this.persist({ actorId: userId, kind: 'seat', data: { seat: target } });
this.broadcastRoom();
return null;
}
unseat(userId: string): string | null {
const err = this.requireLobby();
if (err) return err;
const cur = this.seatOf(userId);
if (cur < 0) return null;
if (!this.config.allowSpectators) return '이 방은 관전을 허용하지 않아요.';
this.seats[cur] = null;
this.spectators.add(userId);
this.rematchVotes.delete(userId);
this.deps.onSeatChange?.(this, userId, false);
this.persist({ actorId: userId, kind: 'unseat', data: null });
this.broadcastRoom();
return null;
}
ready(userId: string, ready: boolean): string | null {
const cur = this.seatOf(userId);
if (cur < 0) return '자리에 앉아야 준비할 수 있어요.';
if (this.requireLobby()) return this.requireLobby();
this.seats[cur]!.ready = ready;
this.persist(null);
this.broadcastRoom();
return null;
}
configure(
userId: string,
patch: Partial<Omit<RoomConfig, 'graceSec'>> & { gameId?: string },
): string | null {
if (userId !== this.hostId) return '방장만 설정을 바꿀 수 있어요.';
const err = this.requireLobby();
if (err) return err;
const next: RoomConfig = { ...this.config };
if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) {
const def = this.deps.games[patch.gameId];
if (!def) return '없는 게임이에요.';
next.gameId = patch.gameId;
next.options = def.defaultOptions;
next.maxPlayers = def.maxPlayers;
}
const def = this.deps.games[next.gameId]!;
if (patch.options !== undefined) {
const parsed = def.optionsSchema.safeParse(patch.options);
if (!parsed.success) return '설정 값이 올바르지 않아요.';
next.options = parsed.data;
}
const range = def.playersFor ? def.playersFor(next.options) : { min: def.minPlayers, max: def.maxPlayers };
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) {
if (patch[k] !== undefined) (next as unknown as Record<string, unknown>)[k] = patch[k];
}
// Resize seats, compacting occupied seats to the front if needed.
if (next.maxPlayers !== this.seats.length) {
const occupied = this.seats.filter((s): s is Seat => s !== null);
const seats: (Seat | null)[] = new Array(next.maxPlayers).fill(null);
let i = 0;
for (let k = 0; k < this.seats.length && k < next.maxPlayers; k++) if (this.seats[k]) seats[k] = this.seats[k]!;
const placed = new Set(seats.filter(Boolean).map((s) => s!.userId));
for (const s of occupied) {
if (placed.has(s.userId)) continue;
while (seats[i]) i++;
seats[i] = s;
}
this.seats = seats;
}
this.config = next;
for (const s of this.seats) if (s) s.ready = false;
this.persist({ actorId: userId, kind: 'config', data: patch });
this.broadcastRoom();
return null;
}
kick(userId: string, target: string): string | null {
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
if (target === userId) return '자기 자신은 내보낼 수 없어요.';
if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
const conn = this.conns.get(target);
this.kicked.add(target);
this.leave(target);
conn?.send({ t: 'bye', reason: 'kicked' });
conn?.close(4001, 'kicked');
return null;
}
transferHost(userId: string, target: string): string | null {
if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
this.hostId = target;
this.persist({ actorId: userId, kind: 'host', data: { target } });
this.broadcastRoom();
return null;
}
chat(userId: string, text: string): string | null {
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
const now = this.now();
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
this.lastChatAt.set(userId, now);
const clean = text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
if (!clean) return null;
const msg: Extract<ServerMessage, { t: 'chat' }> = {
t: 'chat',
from: this.deps.users.publicUser(userId),
text: this.config.chatFilter ? maskProfanity(clean) : clean,
at: now,
};
this.chatLog.push(msg);
if (this.chatLog.length > CHAT_HISTORY) this.chatLog.shift();
for (const c of this.conns.values()) c.send(msg);
return null;
}
emote(userId: string, id: string): void {
if (!this.isParticipant(userId)) return;
const now = this.now();
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
this.lastChatAt.set(`e:${userId}`, now);
for (const c of this.conns.values()) c.send({ t: 'emote', from: userId, id });
}
// ---------------------------------------------------------------- game lifecycle
start(userId: string): string | null {
if (userId !== this.hostId) return '방장만 시작할 수 있어요.';
const err = this.requireLobby();
if (err) return err;
const players = this.orderedPlayers();
const range = this.playerRange();
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`;
return this.startGame(players);
}
rematch(userId: string): string | null {
if (this.status !== 'finished') return '게임이 끝난 뒤에 누를 수 있어요.';
if (this.seatOf(userId) < 0) return '자리에 앉아야 해요.';
this.rematchVotes.add(userId);
const seated = this.seatedIds();
const range = this.playerRange();
if (seated.length >= range.min && seated.every((u) => this.rematchVotes.has(u))) {
return this.startGame(this.orderedPlayers());
}
this.persist(null);
this.broadcastRoom();
return null;
}
/** Seated players in seat order, rotated so the host (if seated) comes first. */
private orderedPlayers(): string[] {
const ids = this.seatedIds();
const h = ids.indexOf(this.hostId);
return h > 0 ? [...ids.slice(h), ...ids.slice(0, h)] : ids;
}
private startGame(players: string[]): string | null {
const def = this.def;
const parsed = def.optionsSchema.safeParse(this.config.options);
if (!parsed.success) return '설정 값이 올바르지 않아요.';
const seed = createSeed();
const seedHex = seedToHex(seed);
const rng = SeededRng.fromSeed(seed);
const now = this.now();
const gameNo = this.gameNo + 1;
let state: unknown;
try {
state = def.setup({ players, options: parsed.data, rng, now, gameNo });
} catch (err) {
this.deps.log?.('error', 'setup failed', { room: this.code, err: String(err) });
return '게임을 시작하지 못했어요.';
}
const gamePk = ulid(now);
const seedHash = sha256Hex(seedHex);
this.deps.store.insertGame({ id: gamePk, roomId: this.id, gameNo, gameId: def.id, options: parsed.data, seedHash, startedAt: now });
this.gameNo = gameNo;
this.game = {
gameId: def.id,
stateVersion: def.stateVersion,
state,
rng: rng.state(),
gamePk,
seedHex,
seedHash,
startedAt: now,
players,
finished: false,
deadlineFloor: null,
};
this.status = 'playing';
this.rematchVotes.clear();
this.lastResult = null;
this.consecutiveErrors = 0;
for (const s of this.seats) if (s) s.ready = false;
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } });
this.broadcastRoom();
this.broadcastState([{ type: 'gameStarted' }]);
this.scheduleTimer();
return null;
}
/** Client action entry. `cs` dedupes retransmissions (docs/03 §5). */
act(userId: string, cs: number, action: unknown): void {
const conn = this.conns.get(userId);
const reject = (reason: string) => conn?.send({ t: 'reject', cs, reason });
const recent = this.recentCs.get(userId) ?? [];
if (recent.includes(cs)) return;
recent.push(cs);
if (recent.length > 50) recent.shift();
this.recentCs.set(userId, recent);
if (!this.game || this.game.finished || this.status !== 'playing') return reject('진행 중인 게임이 없어요.');
if (!this.game.players.includes(userId)) return reject('이 게임의 플레이어가 아니에요.');
const parsed = this.def.actionSchema.safeParse(action);
if (!parsed.success) return reject('잘못된 요청이에요.');
const err = this.runAction(userId, parsed.data, cs, false);
if (err) reject(err);
}
/** Validates and applies one action; returns an error message on rejection. */
private runAction(userId: string, action: unknown, cs: number | undefined, system: boolean): string | null {
const g = this.game;
if (!g || g.finished) return '진행 중인 게임이 없어요.';
const def = this.def;
let next: { state: unknown; events: unknown[] };
const rng = new SeededRng(g.rng);
try {
const v = def.validate(g.state, userId, action);
if (!v.ok) return v.reason;
next = def.apply(g.state, userId, action, { rng, now: this.now() });
} catch (err) {
this.consecutiveErrors++;
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
if (this.consecutiveErrors >= ERROR_LIMIT) {
this.status = 'paused';
this.clearTimer();
this.persist({ actorId: null, kind: 'paused', data: null });
this.broadcastRoom();
this.notice('paused', '오류가 반복되어 게임을 멈췄어요. 방장은 게임을 끝낼 수 있어요.');
}
return '알 수 없는 오류가 발생했어요.';
}
this.consecutiveErrors = 0;
g.state = next.state;
g.rng = rng.state();
g.deadlineFloor = null;
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
this.broadcastState(next.events, userId, cs);
const result = def.result(g.state);
if (result) this.finishGame(result);
else this.scheduleTimer();
return null;
}
/** Host ends a paused/broken game without counting it. */
voidGame(userId: string): string | null {
if (userId !== this.hostId) return '방장만 할 수 있어요.';
if (!this.game || this.game.finished) return '진행 중인 게임이 없어요.';
this.finishGame({ ranking: [this.game.players], summary: '게임이 무효 처리됐어요.', reason: 'abandoned' }, true);
return null;
}
private finishGame(result: GameResult, voided = false): void {
const g = this.game!;
g.finished = true;
this.clearTimer();
const rankOf = new Map<string, number>();
let rank = 1;
for (const group of result.ranking) {
for (const u of group) rankOf.set(u, rank);
rank += group.length;
}
const players = g.players.map((u) => ({
userId: u,
seat: this.seatOf(u),
rank: rankOf.get(u) ?? null,
score: result.scores?.[u] ?? null,
}));
this.deps.store.finishGame({
id: g.gamePk,
gameId: g.gameId,
seed: g.seedHex,
endedAt: this.now(),
endReason: voided ? 'void' : result.reason,
result,
players,
});
const allFirst = players.every((p) => p.rank === 1);
if (!voided) {
for (const p of players) {
const st = (this.sessionStats[p.userId] ??= { played: 0, wins: 0 });
st.played++;
if (p.rank === 1 && !allFirst) st.wins++;
}
}
this.lastSeed = g.seedHex;
this.lastResult = result;
this.status = 'finished';
// Players who left during the game lose their seat now.
for (let i = 0; i < this.seats.length; i++) {
const s = this.seats[i];
if (s && !this.presence.get(s.userId)?.connected && this.presence.get(s.userId)?.joinedAt === 0) {
this.seats[i] = null;
this.presence.delete(s.userId);
this.deps.onSeatChange?.(this, s.userId, false);
}
}
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
this.broadcastState([{ type: 'gameOver' }]);
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });
this.broadcastRoom();
}
// ---------------------------------------------------------------- timers
/** Earliest time any timed player must be auto-acted for. */
effectiveDeadline(): number | null {
const g = this.game;
if (!g || g.finished || this.status !== 'playing') return null;
const def = this.def;
const base = def.deadline?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
let best: number | null = null;
for (const u of timed) {
let d = base;
const p = this.presence.get(u);
if (p && !p.connected) {
const grace = p.since + this.config.graceSec * 1000;
d = d === null ? grace : Math.min(d, grace);
}
if (d === null) continue;
if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor);
best = best === null ? d : Math.min(best, d);
}
return best;
}
scheduleTimer(): void {
this.clearTimer();
const at = this.effectiveDeadline();
if (at === null) return;
const delay = Math.max(0, at - this.now());
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
}
clearTimer(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
/** Fires due timeouts. Public for tests. */
onTimer(): void {
this.timer = null;
const g = this.game;
if (!g || g.finished || this.status !== 'playing') return;
const def = this.def;
const now = this.now();
const base = def.deadline?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
for (const u of timed) {
let d = base;
const p = this.presence.get(u);
if (p && !p.connected) {
const grace = p.since + this.config.graceSec * 1000;
d = d === null ? grace : Math.min(d, grace);
}
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
if (d === null || d > now) continue;
const action = def.onTimeout(g.state, u);
const err = this.runAction(u, action, undefined, true);
if (err) this.deps.log?.('warn', 'timeout action rejected', { room: this.code, user: u, err });
break; // state changed; re-evaluate via scheduleTimer
}
if (!this.game?.finished) this.scheduleTimer();
}
// ---------------------------------------------------------------- misc
private nick(u: string): string {
return this.deps.users.publicUser(u)?.nickname ?? '알 수 없음';
}
sendSync(conn: Conn): void {
conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
const st = this.stateMessage(conn.userId);
if (st) conn.send(st);
}
shutdown(): void {
this.clearTimer();
for (const c of this.conns.values()) {
c.send({ t: 'bye', reason: 'restart' });
c.close(1012, 'restart');
}
}
}

View File

@@ -0,0 +1,140 @@
/** Room persistence (docs/04 §2, docs/08 §3). All writes are synchronous SQLite transactions. */
import type { Database } from 'bun:sqlite';
export interface RoomRow {
id: string;
code: string;
host_id: string;
game_id: string;
status: string;
visibility: string;
config_json: string;
created_at: number;
updated_at: number;
}
export interface LogEntry {
seq: number;
gameNo: number;
actorId: string | null;
kind: string;
data: unknown;
at: number;
}
export class RoomStore {
constructor(private db: Database) {}
codeInUse(code: string, now = Date.now()): boolean {
// Open rooms, plus rooms closed within the last 24h (docs/06 §3).
const row = this.db
.query<{ n: number }, [string, number]>(
"SELECT COUNT(*) AS n FROM rooms WHERE code = ? AND (status <> 'closed' OR closed_at > ?)",
)
.get(code, now - 24 * 3600_000);
return (row?.n ?? 0) > 0;
}
insertRoom(r: { id: string; code: string; hostId: string; gameId: string; status: string; visibility: string; config: unknown; stateJson: string; now: number }): void {
this.db.transaction(() => {
this.db
.query('INSERT INTO rooms (id, code, host_id, game_id, status, visibility, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(r.id, r.code, r.hostId, r.gameId, r.status, r.visibility, JSON.stringify(r.config), r.now, r.now);
this.db.query('INSERT INTO room_state (room_id, seq, state_json, updated_at) VALUES (?, 0, ?, ?)').run(r.id, r.stateJson, r.now);
})();
}
/** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */
save(
roomId: string,
seq: number,
stateJson: string,
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
log: LogEntry | null,
now = Date.now(),
): void {
this.db.transaction(() => {
if (log) {
this.db
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at);
}
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
this.db
.query('UPDATE rooms SET host_id = ?, game_id = ?, status = ?, visibility = ?, config_json = ?, updated_at = ? WHERE id = ?')
.run(meta.hostId, meta.gameId, meta.status, meta.visibility, JSON.stringify(meta.config), now, roomId);
})();
}
close(roomId: string, now = Date.now()): void {
this.db.transaction(() => {
this.db.query("UPDATE rooms SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?").run(now, now, roomId);
this.db.query('DELETE FROM room_state WHERE room_id = ?').run(roomId);
})();
}
markBroken(roomId: string, now = Date.now()): void {
this.db.query("UPDATE rooms SET status = 'broken', updated_at = ? WHERE id = ?").run(now, roomId);
}
openRooms(): { row: RoomRow; seq: number; stateJson: string }[] {
return this.db
.query<RoomRow & { seq: number; state_json: string }, []>(
"SELECT r.*, s.seq, s.state_json FROM rooms r JOIN room_state s ON s.room_id = r.id WHERE r.status IN ('lobby','playing','finished','paused')",
)
.all()
.map((r) => ({ row: r, seq: r.seq, stateJson: r.state_json }));
}
insertGame(g: { id: string; roomId: string; gameNo: number; gameId: string; options: unknown; seedHash: string; startedAt: number }): void {
this.db
.query('INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(g.id, g.roomId, g.gameNo, g.gameId, JSON.stringify(g.options), g.seedHash, g.startedAt);
}
finishGame(g: {
id: string;
gameId: string;
seed: string;
endedAt: number;
endReason: string;
result: unknown;
players: { userId: string; seat: number; rank: number | null; score: number | null }[];
}): void {
this.db.transaction(() => {
this.db
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id);
const counted = g.endReason !== 'void';
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
const firstCount = ranks.filter((r) => r === 1).length;
for (const p of g.players) {
this.db
.query('INSERT OR REPLACE INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, ?)')
.run(g.id, p.userId, p.seat, p.rank, p.score);
if (!counted) continue;
const draw = p.rank === 1 && firstCount === g.players.length && g.players.length > 1;
const win = p.rank === 1 && !draw;
this.db
.query(
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, 1, ?, ?)
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + 1, wins = wins + excluded.wins, draws = draws + excluded.draws`,
)
.run(p.userId, g.gameId, win ? 1 : 0, draw ? 1 : 0);
}
})();
}
log(roomId: string): LogEntry[] {
return this.db
.query<{ seq: number; game_no: number; actor_id: string | null; kind: string; data_json: string; at: number }, [string]>(
'SELECT seq, game_no, actor_id, kind, data_json, at FROM room_log WHERE room_id = ? ORDER BY seq',
)
.all(roomId)
.map((r) => ({ seq: r.seq, gameNo: r.game_no, actorId: r.actor_id, kind: r.kind, data: JSON.parse(r.data_json), at: r.at }));
}
purgeOldLogs(now = Date.now()): void {
this.db.query('DELETE FROM room_log WHERE at < ?').run(now - 90 * 24 * 3600_000);
}
}

128
apps/server/src/server.ts Normal file
View 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' } });
}

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);
}
}