/** 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'; import { Audit, Roles } from './admin/roles'; import { SettingsStore } from './admin/settings'; export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => 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 settings = new SettingsStore(db, GAMES); const roles = new Roles(db, config.superadminDiscordIds); const audit = new Audit(db); const startedAt = Date.now(); const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings }); const restored = rooms.restoreAll(); log('info', 'rooms restored', restored); const gateway = new Gateway({ rooms, me: publicUser, log }); let ready = true; const ipOf = (req: Request) => (config.trustProxy ? req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() : undefined) || 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 }), roles, settings, admin: { audit, games: GAMES, disconnectUser: (id) => gateway.disconnectUser(id), stats: () => { const all = rooms.all(); const day = Date.now() - 24 * 3600_000; return { connections: gateway.connectionCount, rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length }, games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day), uptimeSec: Math.round((Date.now() - startedAt) / 1000), memoryMb: Math.round(process.memoryUsage().rss / 1048576), }; }, }, }); const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir; const serveStatic = staticDir && existsSync(staticDir); const server: Server = Bun.serve({ 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(); // Bun's stop() promise can stay pending after server-initiated WS closes even though the // listener is already closed, so bound the wait. await Promise.race([server.stop(true), Bun.sleep(1000)]); db.close(); }; return { server, stop, rooms, users, sessions, db, gateway, settings, roles }; } async function serveFile(root: string, pathname: string): Promise { 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' } }); }