1
0
Fork 0

Add css hot reload

This commit is contained in:
Pabloader 2026-04-10 12:43:48 +00:00
parent b93424fece
commit 1b366f8fc5
7 changed files with 140 additions and 17 deletions

View File

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=0">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=0">
<title><!--$TITLE$--></title>
<style>
* {
@ -27,6 +28,31 @@
<body>
<!--$SCRIPT$-->
<script>
function connect() {
const params = new URLSearchParams(location.search);
const game = params.get('game') || '';
const ws = new WebSocket('css-ws?game=' + encodeURIComponent(game));
const onMessage = (e) => {
try {
const { type, css } = JSON.parse(e.data);
if (type === 'css') {
const styleTag = document.querySelector('style');
if (styleTag) styleTag.textContent = css;
}
} catch { }
};
const onClose = () => {
ws.removeEventListener('message', onMessage);
ws.removeEventListener('close', onClose);
setTimeout(connect, 500);
};
ws.addEventListener('message', onMessage);
ws.addEventListener('close', onClose);
}
connect();
</script>
</body>
</html>

View File

@ -34,14 +34,10 @@ interface Args {
mobile?: boolean;
local?: boolean;
}
export async function buildHTML(game: string, { production = false, mobile = false, local = false }: Args = {}) {
const assetsDir = path.resolve(import.meta.dir, 'assets');
const srcDir = path.resolve(import.meta.dir, '..', 'src');
const gameDir = path.resolve(srcDir, 'games', game);
const gameAssetsDir = path.resolve(gameDir, 'assets');
const html = await Bun.file(path.resolve(assetsDir, local ? 'index-local.html' : 'index.html')).text();
const bundle = await Bun.build({
async function buildBundle(game: string, production: boolean) {
const srcDir = path.resolve(import.meta.dir, '..', 'src');
return Bun.build({
outdir: '/tmp',
entrypoints: [path.resolve(srcDir, 'index.ts')],
sourcemap: production ? 'none' : 'inline',
@ -58,6 +54,16 @@ export async function buildHTML(game: string, { production = false, mobile = fal
filePlugin,
]
});
}
export async function buildHTML(game: string, { production = false, mobile = false, local = false }: Args = {}) {
const assetsDir = path.resolve(import.meta.dir, 'assets');
const srcDir = path.resolve(import.meta.dir, '..', 'src');
const gameDir = path.resolve(srcDir, 'games', game);
const gameAssetsDir = path.resolve(gameDir, 'assets');
const html = await Bun.file(path.resolve(assetsDir, local ? 'index-local.html' : 'index.html')).text();
const bundle = await buildBundle(game, production);
if (bundle.success) {
const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js'));
@ -164,3 +170,21 @@ export async function buildHTML(game: string, { production = false, mobile = fal
console.error('Failed: ', !bundle.success, bundle);
}
}
/**
* Rebuild only the CSS bundle (resolves composes: directives via Bun.build).
* Used by the dev server for CSS hot reload.
*/
export async function buildCSS(game: string): Promise<string | null> {
const bundle = await buildBundle(game, false);
if (bundle.success) {
let style = '';
for (const file of bundle.outputs.filter(a => a.kind === 'asset' && a.path.endsWith('.css'))) {
style += await file.text();
}
return style;
} else {
console.error('CSS build failed:', bundle);
return null;
}
}

View File

@ -1,17 +1,84 @@
import Bun from 'bun';
import Bun, { type ServerWebSocket } from 'bun';
import path from 'path';
import fs from 'fs';
import browser from 'browser-detect';
import { buildHTML } from './html';
import { buildHTML, buildCSS } from './html';
import { isGame } from './isGame';
Bun.serve({
async fetch(req) {
// ── 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':
@ -20,7 +87,7 @@ Bun.serve({
const html = await buildHTML(
game,
{
production: url.searchParams.get('production') === 'true', // to debug production builds
production: url.searchParams.get('production') === 'true',
mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true',
local: true,
}
@ -42,4 +109,4 @@ Bun.serve({
return new Response(null, { status: 404 });
}
}
})
});

View File

@ -23,7 +23,6 @@ export const Modal = ({ children, open, title, onClose, sidebar, footer, ['class
if (open) {
ref.current?.showModal();
} else {
console.log(ref.current);
ref.current?.close();
}
}, [open]);

View File

@ -5,6 +5,8 @@ import styles from '../../assets/character-editor.module.css';
import { CharacterRole, useAppState, type Character } from "../../contexts/state";
import LLM from "../../utils/llm";
// TODO fix delete button size
export const CharacterEditor = ({ visible }: { visible: boolean }) => {
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
const [newNickname, setNewNickname] = useState<Record<string, string>>({});

View File

@ -4,6 +4,8 @@ import styles from '../../assets/location-editor.module.css';
import { LocationScale, useAppState, type Location } from "../../contexts/state";
import LLM from "../../utils/llm";
// TODO fix delete button size
export const LocationEditor = ({ visible }: { visible: boolean }) => {
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);

View File

@ -3,6 +3,9 @@ import { useState } from "preact/hooks";
import styles from '../../assets/lore-editor.module.css';
import { useAppState, type LoreEntry } from "../../contexts/state";
// TODO move adding ui below title on mobile
// TODO add confirm to delete
export const LoreEditor = ({ visible }: { visible: boolean }) => {
const { currentWorld, currentStory, dispatch } = useAppState();
const [editingId, setEditingId] = useState<string | null>(null);