From 17fcbf4d93800cd205b3783b28f739c10ed9f38c Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Sun, 22 Mar 2026 10:35:43 +0100 Subject: [PATCH] Added `--dev` flag to ./serve-locally.sh --- README.md | 6 ++ serve-locally.sh | 28 ++++-- web/build.mjs | 241 ++++++++++++++++++++++++++--------------------- web/serve.mjs | 9 +- 4 files changed, 166 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 71c4143..b08fcbd 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,12 @@ This serves the app at `http://localhost:8888`. The script automatically: You can delete the entire `web/dist/` folder and re-run `serve-locally.sh` to regenerate everything. +To automatically rebuild when source files change: + +```bash +./serve-locally.sh --dev +``` + ## Testing Run all tests (WASM integration + E2E): diff --git a/serve-locally.sh b/serve-locally.sh index c806338..5734137 100755 --- a/serve-locally.sh +++ b/serve-locally.sh @@ -1,10 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -if [[ "${1:-}" == "--fake-analytics" ]]; then - export UMAMI_WEBSITE_ID="fake" - export UMAMI_SCRIPT_URL="data:," -fi +DEV_MODE=false +for arg in "$@"; do + case "$arg" in + --fake-analytics) + export UMAMI_WEBSITE_ID="fake" + export UMAMI_SCRIPT_URL="data:," + ;; + --dev) + DEV_MODE=true + ;; + esac +done SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" WEB_DIR="$SCRIPT_DIR/web" @@ -40,5 +48,13 @@ if [ ! -f "$DIST_DIR/wasm/kobopatch.wasm" ]; then "$WASM_DIR/build.sh" fi -echo "Serving at http://localhost:8888" -node serve.mjs +if [ "$DEV_MODE" = true ]; then + echo "Serving at http://localhost:8888 (dev mode, watching for changes)" + NO_CACHE=1 node serve.mjs & + SERVER_PID=$! + trap "kill $SERVER_PID 2>/dev/null" EXIT + node build.mjs --watch +else + echo "Serving at http://localhost:8888" + node serve.mjs +fi diff --git a/web/build.mjs b/web/build.mjs index 6968572..dccc884 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -1,5 +1,5 @@ import esbuild from 'esbuild'; -import { cpSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from 'fs'; +import { cpSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync, watch } from 'fs'; import { join, relative } from 'path'; import { createHash } from 'crypto'; import { execSync } from 'child_process'; @@ -9,29 +9,8 @@ const repoDir = join(webDir, '..'); const srcDir = join(webDir, 'src'); const distDir = join(webDir, 'dist'); const isDev = process.argv.includes('--dev'); +const isWatch = process.argv.includes('--watch'); -// Clean dist/ (preserve wasm/ which is built separately) -if (existsSync(distDir)) { - for (const entry of readdirSync(distDir)) { - if (entry !== 'wasm') { - rmSync(join(distDir, entry), { recursive: true, force: true }); - } - } -} - -// Build JS bundle -await esbuild.build({ - entryPoints: [join(srcDir, 'js', 'app.js')], - bundle: true, - format: 'iife', - target: ['es2020'], - outfile: join(distDir, 'bundle.js'), - minify: !isDev, - sourcemap: isDev, - logLevel: 'warning', -}); - -// Copy all of src/ to dist/, skipping js/ (bundled separately), css/ (minified), and index.html (generated) function copyDir(src, dst, skip = new Set()) { mkdirSync(dst, { recursive: true }); for (const entry of readdirSync(src)) { @@ -45,102 +24,148 @@ function copyDir(src, dst, skip = new Set()) { } } } -copyDir(srcDir, distDir, new Set(['js', 'css', 'index.html'])); -// Minify CSS -mkdirSync(join(distDir, 'css'), { recursive: true }); -const cssSrc = readFileSync(join(srcDir, 'css', 'style.css'), 'utf-8'); -const { code: cssMinified } = await esbuild.transform(cssSrc, { - loader: 'css', - minify: !isDev, -}); -writeFileSync(join(distDir, 'css', 'style.css'), cssMinified); - -// Copy worker files from src/js/ (not bundled, served separately) -mkdirSync(join(distDir, 'js'), { recursive: true }); - -// Copy wasm_exec.js as-is -const wasmExecSrc = join(srcDir, 'js', 'wasm_exec.js'); -if (existsSync(wasmExecSrc)) { - cpSync(wasmExecSrc, join(distDir, 'js', 'wasm_exec.js')); -} - -// Copy patch-worker.js with WASM hash injected -const workerSrc = join(srcDir, 'js', 'patch-worker.js'); -if (existsSync(workerSrc)) { - let workerContent = readFileSync(workerSrc, 'utf-8'); - const wasmFile = join(distDir, 'wasm', 'kobopatch.wasm'); - if (existsSync(wasmFile)) { - const wasmHash = createHash('md5').update(readFileSync(wasmFile)).digest('hex').slice(0, 8); - workerContent = workerContent.replace( - "kobopatch.wasm'", - `kobopatch.wasm?h=${wasmHash}'` - ); - } - writeFileSync(join(distDir, 'js', 'patch-worker.js'), workerContent); -} - -// Get git version string -let versionStr = 'unknown'; -let versionLink = 'https://github.com/nicoverbruggen/kobopatch-webui'; -const nixpacksCommit = process.env.SOURCE_COMMIT; -try { - if (nixpacksCommit) { - versionStr = nixpacksCommit.slice(0, 7); - versionLink = `https://github.com/nicoverbruggen/kobopatch-webui/commit/${nixpacksCommit}`; - } else { - const hash = String(execSync('git rev-parse --short HEAD', { cwd: repoDir })).trim(); - - let tag = ''; - try { - tag = String(execSync('git describe --tags --exact-match 2>/dev/null', { cwd: repoDir })).trim(); - } catch {} - if (tag) { - versionStr = tag; - const dirty = String(execSync('git status --porcelain', { cwd: repoDir })).trim(); - if (dirty) versionStr += ' (D)'; - versionLink = `https://github.com/nicoverbruggen/kobopatch-webui/releases/tag/${tag}`; - } else { - const dirty = String(execSync('git status --porcelain', { cwd: repoDir })).trim(); - versionStr = dirty ? `${hash} (D)` : hash; - versionLink = `https://github.com/nicoverbruggen/kobopatch-webui/commit/${hash}`; +async function build() { + // Clean dist/ (preserve wasm/ which is built separately) + if (existsSync(distDir)) { + for (const entry of readdirSync(distDir)) { + if (entry !== 'wasm') { + rmSync(join(distDir, entry), { recursive: true, force: true }); + } } } -} catch {} -// Generate cache-busted index.html -const bundleContent = readFileSync(join(distDir, 'bundle.js')); -const bundleHash = createHash('md5').update(bundleContent).digest('hex').slice(0, 8); + // Build JS bundle + await esbuild.build({ + entryPoints: [join(srcDir, 'js', 'app.js')], + bundle: true, + format: 'iife', + target: ['es2020'], + outfile: join(distDir, 'bundle.js'), + minify: !isDev && !isWatch, + sourcemap: isDev || isWatch, + logLevel: 'warning', + }); -const cssContent = readFileSync(join(distDir, 'css/style.css')); -const cssHash = createHash('md5').update(cssContent).digest('hex').slice(0, 8); + // Copy all of src/ to dist/, skipping js/ (bundled separately), css/ (minified), and index.html (generated) + copyDir(srcDir, distDir, new Set(['js', 'css', 'index.html'])); -let html = readFileSync(join(srcDir, 'index.html'), 'utf-8'); + // Minify CSS + mkdirSync(join(distDir, 'css'), { recursive: true }); + const cssSrc = readFileSync(join(srcDir, 'css', 'style.css'), 'utf-8'); + const { code: cssMinified } = await esbuild.transform(cssSrc, { + loader: 'css', + minify: !isDev && !isWatch, + }); + writeFileSync(join(distDir, 'css', 'style.css'), cssMinified); -// Remove all \n` -); + // Copy worker files from src/js/ (not bundled, served separately) + mkdirSync(join(distDir, 'js'), { recursive: true }); -// Update CSS cache bust -html = html.replace( - /css\/style\.css\?[^"]*/, - `css/style.css?h=${cssHash}` -); + // Copy wasm_exec.js as-is + const wasmExecSrc = join(srcDir, 'js', 'wasm_exec.js'); + if (existsSync(wasmExecSrc)) { + cpSync(wasmExecSrc, join(distDir, 'js', 'wasm_exec.js')); + } -// Inject version string and link -html = html.replace('', `${versionStr}`); -html = html.replace( - 'href="https://github.com/nicoverbruggen/kobopatch-webui"', - `href="${versionLink}"` -); + // Copy patch-worker.js with WASM hash injected + const workerSrc = join(srcDir, 'js', 'patch-worker.js'); + if (existsSync(workerSrc)) { + let workerContent = readFileSync(workerSrc, 'utf-8'); + const wasmFile = join(distDir, 'wasm', 'kobopatch.wasm'); + if (existsSync(wasmFile)) { + const wasmHash = createHash('md5').update(readFileSync(wasmFile)).digest('hex').slice(0, 8); + workerContent = workerContent.replace( + "kobopatch.wasm'", + `kobopatch.wasm?h=${wasmHash}'` + ); + } + writeFileSync(join(distDir, 'js', 'patch-worker.js'), workerContent); + } -writeFileSync(join(distDir, 'index.html'), html); + // Get git version string + let versionStr = 'unknown'; + let versionLink = 'https://github.com/nicoverbruggen/kobopatch-webui'; + const nixpacksCommit = process.env.SOURCE_COMMIT; + try { + if (nixpacksCommit) { + versionStr = nixpacksCommit.slice(0, 7); + versionLink = `https://github.com/nicoverbruggen/kobopatch-webui/commit/${nixpacksCommit}`; + } else { + const hash = String(execSync('git rev-parse --short HEAD', { cwd: repoDir })).trim(); -console.log(`Built to ${distDir} (bundle: ${bundleHash}, css: ${cssHash}, version: ${versionStr})`); + let tag = ''; + try { + tag = String(execSync('git describe --tags --exact-match 2>/dev/null', { cwd: repoDir })).trim(); + } catch {} + if (tag) { + versionStr = tag; + const dirty = String(execSync('git status --porcelain', { cwd: repoDir })).trim(); + if (dirty) versionStr += ' (D)'; + versionLink = `https://github.com/nicoverbruggen/kobopatch-webui/releases/tag/${tag}`; + } else { + const dirty = String(execSync('git status --porcelain', { cwd: repoDir })).trim(); + versionStr = dirty ? `${hash} (D)` : hash; + versionLink = `https://github.com/nicoverbruggen/kobopatch-webui/commit/${hash}`; + } + } + } catch {} + + // Generate cache-busted index.html + const bundleContent = readFileSync(join(distDir, 'bundle.js')); + const bundleHash = createHash('md5').update(bundleContent).digest('hex').slice(0, 8); + + const cssContent = readFileSync(join(distDir, 'css/style.css')); + const cssHash = createHash('md5').update(cssContent).digest('hex').slice(0, 8); + + let html = readFileSync(join(srcDir, 'index.html'), 'utf-8'); + + // Remove all \n` + ); + + // Update CSS cache bust + html = html.replace( + /css\/style\.css\?[^"]*/, + `css/style.css?h=${cssHash}` + ); + + // Inject version string and link + html = html.replace('', `${versionStr}`); + html = html.replace( + 'href="https://github.com/nicoverbruggen/kobopatch-webui"', + `href="${versionLink}"` + ); + + writeFileSync(join(distDir, 'index.html'), html); + + console.log(`Built to ${distDir} (bundle: ${bundleHash}, css: ${cssHash}, version: ${versionStr})`); +} + +await build(); + +// Watch mode: rebuild on source changes +if (isWatch) { + let rebuildTimer = null; + + watch(srcDir, { recursive: true }, (eventType, filename) => { + if (rebuildTimer) clearTimeout(rebuildTimer); + rebuildTimer = setTimeout(async () => { + console.log(`\nChange detected: ${filename}`); + try { + await build(); + } catch (err) { + console.error('Build failed:', err.message); + } + }, 200); + }); + + console.log('Watching src/ for changes...'); +} // Dev server mode if (isDev) { diff --git a/web/serve.mjs b/web/serve.mjs index d9351ca..7b574fc 100644 --- a/web/serve.mjs +++ b/web/serve.mjs @@ -17,18 +17,19 @@ if (analyticsEnabled) { ` \n`; } -// Cache the processed index.html at startup +// Cache the processed index.html (disabled when NO_CACHE is set, e.g. during --dev) +const noCache = !!process.env.NO_CACHE; let cachedIndexHtml = null; function getIndexHtml() { - if (cachedIndexHtml) return cachedIndexHtml; + if (!noCache && cachedIndexHtml) return cachedIndexHtml; const indexPath = join(DIST, 'index.html'); if (!existsSync(indexPath)) return null; let html = readFileSync(indexPath, 'utf-8'); if (analyticsSnippet) { html = html.replace('', analyticsSnippet + ''); } - cachedIndexHtml = html; - return cachedIndexHtml; + if (!noCache) cachedIndexHtml = html; + return html; } const MIME = {