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> <head>
<meta charset="UTF-8"> <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> <title><!--$TITLE$--></title>
<style> <style>
* { * {
@ -19,14 +20,39 @@
overflow: hidden; overflow: hidden;
font-family: sans-serif; font-family: sans-serif;
} }
/*$STYLE$*/ /*$STYLE$*/
</style> </style>
<!--$ICON$--> <!--$ICON$-->
</head> </head>
<body> <body>
<!--$SCRIPT$--> <!--$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> </body>
</html> </html>

View File

@ -34,14 +34,10 @@ interface Args {
mobile?: boolean; mobile?: boolean;
local?: 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(); async function buildBundle(game: string, production: boolean) {
const bundle = await Bun.build({ const srcDir = path.resolve(import.meta.dir, '..', 'src');
return Bun.build({
outdir: '/tmp', outdir: '/tmp',
entrypoints: [path.resolve(srcDir, 'index.ts')], entrypoints: [path.resolve(srcDir, 'index.ts')],
sourcemap: production ? 'none' : 'inline', sourcemap: production ? 'none' : 'inline',
@ -58,6 +54,16 @@ export async function buildHTML(game: string, { production = false, mobile = fal
filePlugin, 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) { if (bundle.success) {
const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js')); const scriptFile = bundle.outputs.find(a => a.kind === 'entry-point' && a.path.endsWith('.js'));
@ -163,4 +169,22 @@ export async function buildHTML(game: string, { production = false, mobile = fal
} else { } else {
console.error('Failed: ', !bundle.success, bundle); 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 path from 'path';
import fs from 'fs';
import browser from 'browser-detect'; import browser from 'browser-detect';
import { buildHTML } from './html'; import { buildHTML, buildCSS } from './html';
import { isGame } from './isGame'; import { isGame } from './isGame';
Bun.serve({ // ── CSS Hot Reload via WebSocket ────────────────────────────────────
async fetch(req) { 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 url = new URL(req.url);
const pathname = path.basename(url.pathname); const pathname = path.basename(url.pathname);
const gameParam = url.searchParams.get('game'); const gameParam = url.searchParams.get('game');
const game = (gameParam && await isGame(gameParam)) ? gameParam : 'index'; const game = (gameParam && await isGame(gameParam)) ? gameParam : 'index';
switch (pathname) { 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 '/': case '/':
case 'index.html': case 'index.html':
@ -20,7 +87,7 @@ Bun.serve({
const html = await buildHTML( const html = await buildHTML(
game, 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', mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true',
local: true, local: true,
} }
@ -42,4 +109,4 @@ Bun.serve({
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
} }
}) });

View File

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

View File

@ -5,6 +5,8 @@ import styles from '../../assets/character-editor.module.css';
import { CharacterRole, useAppState, type Character } from "../../contexts/state"; import { CharacterRole, useAppState, type Character } from "../../contexts/state";
import LLM from "../../utils/llm"; import LLM from "../../utils/llm";
// TODO fix delete button size
export const CharacterEditor = ({ visible }: { visible: boolean }) => { export const CharacterEditor = ({ visible }: { visible: boolean }) => {
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState(); const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
const [newNickname, setNewNickname] = useState<Record<string, string>>({}); 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 { LocationScale, useAppState, type Location } from "../../contexts/state";
import LLM from "../../utils/llm"; import LLM from "../../utils/llm";
// TODO fix delete button size
export const LocationEditor = ({ visible }: { visible: boolean }) => { export const LocationEditor = ({ visible }: { visible: boolean }) => {
const { currentWorld, currentStory, dispatch, connection, model } = useAppState(); const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null); 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 styles from '../../assets/lore-editor.module.css';
import { useAppState, type LoreEntry } from "../../contexts/state"; 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 }) => { export const LoreEditor = ({ visible }: { visible: boolean }) => {
const { currentWorld, currentStory, dispatch } = useAppState(); const { currentWorld, currentStory, dispatch } = useAppState();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);