import Bun, { type ServerWebSocket } from 'bun'; import path from 'path'; import fs from 'fs'; import browser from 'browser-detect'; import { buildHTML, buildCSS } from './html'; import { isGame } from './isGame'; // ── CSS Hot Reload via WebSocket ──────────────────────────────────── const srcDir = path.resolve(import.meta.dir, '..', 'src'); interface ClientData { game: string; } // Map of game name → set of WebSocket connections const wsClients = new Map>>(); // Debounced watcher: on any source change, rebuild CSS only for affected games const watcher = fs.watch(srcDir, { recursive: true }); let debounce: ReturnType | null = null; watcher.on('change', async (_, filename) => { if (!filename) return; const filenameStr = filename.toString(); const rel = filenameStr.replace(/\\/g, '/'); const gameMatch = rel.match(/^games\/([^/]+)\//); const changedGame = gameMatch ? gameMatch[1] : null; if (debounce) clearTimeout(debounce); debounce = setTimeout(async () => { if (changedGame && !wsClients.has(changedGame)) return; const affectedGames = changedGame ? [[changedGame, wsClients.get(changedGame)!] as const] : [...wsClients.entries()]; // common/ → all games for (const [game, clients] of affectedGames) { const css = await buildCSS(game); if (css === null) continue; const message = JSON.stringify({ type: 'css', css }); for (const ws of clients) { ws.send(message); } } }, 50); }); // ───────────────────────────────────────────────────────────────────── Bun.serve({ websocket: { open(ws) { const game = ws.data.game; if (!wsClients.has(game)) wsClients.set(game, new Set()); wsClients.get(game)!.add(ws); console.log(`[css-hot] ${game} connected (${wsClients.get(game)!.size} clients)`); }, close(ws) { const game = ws.data.game; const clients = wsClients.get(game); if (clients) { clients.delete(ws); if (clients.size === 0) wsClients.delete(game); } }, message() { }, }, async fetch(req, server) { const url = new URL(req.url); const pathname = path.basename(url.pathname); const gameParam = url.searchParams.get('game'); const game = (gameParam && await isGame(gameParam)) ? gameParam : 'index'; switch (pathname) { case 'css-ws': { if (!(await isGame(gameParam))) { return new Response('Unknown game', { status: 400 }); } const upgraded = server.upgrade(req, { data: { game: gameParam! } satisfies ClientData, }); if (upgraded) return undefined; return new Response('Upgrade failed', { status: 500 }); } case '': case '/': case 'index.html': try { const detectedBrowser = browser(req.headers.get('user-agent') ?? ''); const html = await buildHTML( game, { production: url.searchParams.get('production') === 'true', mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true', local: true, } ); if (html) { return new Response(html, { headers: { 'Content-Type': 'text/html;charset=utf-8' } }); } } catch (e) { console.error(e); return new Response(`Error building HTML: ${e}`, { status: 500 }); } return new Response(`Error building HTML`, { status: 500 }); default: console.log(`[Dev Server] requested unknown pathname: ${pathname}`); return new Response(null, { status: 404 }); } } });