113 lines
4.4 KiB
TypeScript
113 lines
4.4 KiB
TypeScript
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<string, Set<ServerWebSocket<ClientData>>>();
|
|
|
|
// Debounced watcher: on any source change, rebuild CSS only for affected games
|
|
const watcher = fs.watch(srcDir, { recursive: true });
|
|
let debounce: ReturnType<typeof setTimeout> | 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<ClientData>({
|
|
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 });
|
|
}
|
|
}
|
|
});
|