Add css hot reload
This commit is contained in:
parent
b93424fece
commit
1b366f8fc5
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>>({});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue