1
0

Added --dev flag to ./serve-locally.sh

This commit is contained in:
2026-03-22 10:35:43 +01:00
parent 993443be9e
commit 17fcbf4d93
4 changed files with 166 additions and 118 deletions

View File

@@ -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. 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 ## Testing
Run all tests (WASM integration + E2E): Run all tests (WASM integration + E2E):

View File

@@ -1,10 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
if [[ "${1:-}" == "--fake-analytics" ]]; then DEV_MODE=false
export UMAMI_WEBSITE_ID="fake" for arg in "$@"; do
export UMAMI_SCRIPT_URL="data:," case "$arg" in
fi --fake-analytics)
export UMAMI_WEBSITE_ID="fake"
export UMAMI_SCRIPT_URL="data:,"
;;
--dev)
DEV_MODE=true
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WEB_DIR="$SCRIPT_DIR/web" WEB_DIR="$SCRIPT_DIR/web"
@@ -40,5 +48,13 @@ if [ ! -f "$DIST_DIR/wasm/kobopatch.wasm" ]; then
"$WASM_DIR/build.sh" "$WASM_DIR/build.sh"
fi fi
echo "Serving at http://localhost:8888" if [ "$DEV_MODE" = true ]; then
node serve.mjs 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

View File

@@ -1,5 +1,5 @@
import esbuild from 'esbuild'; 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 { join, relative } from 'path';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
@@ -9,29 +9,8 @@ const repoDir = join(webDir, '..');
const srcDir = join(webDir, 'src'); const srcDir = join(webDir, 'src');
const distDir = join(webDir, 'dist'); const distDir = join(webDir, 'dist');
const isDev = process.argv.includes('--dev'); 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()) { function copyDir(src, dst, skip = new Set()) {
mkdirSync(dst, { recursive: true }); mkdirSync(dst, { recursive: true });
for (const entry of readdirSync(src)) { 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 async function build() {
mkdirSync(join(distDir, 'css'), { recursive: true }); // Clean dist/ (preserve wasm/ which is built separately)
const cssSrc = readFileSync(join(srcDir, 'css', 'style.css'), 'utf-8'); if (existsSync(distDir)) {
const { code: cssMinified } = await esbuild.transform(cssSrc, { for (const entry of readdirSync(distDir)) {
loader: 'css', if (entry !== 'wasm') {
minify: !isDev, rmSync(join(distDir, entry), { recursive: true, force: true });
}); }
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}`;
} }
} }
} catch {}
// Generate cache-busted index.html // Build JS bundle
const bundleContent = readFileSync(join(distDir, 'bundle.js')); await esbuild.build({
const bundleHash = createHash('md5').update(bundleContent).digest('hex').slice(0, 8); 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')); // Copy all of src/ to dist/, skipping js/ (bundled separately), css/ (minified), and index.html (generated)
const cssHash = createHash('md5').update(cssContent).digest('hex').slice(0, 8); 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 <script src="js/..."> tags // Copy worker files from src/js/ (not bundled, served separately)
html = html.replace(/\s*<script src="js\/[^"]*"><\/script>\n/g, ''); mkdirSync(join(distDir, 'js'), { recursive: true });
// Add the bundle script before </body>
html = html.replace(
'</body>',
` <script src="/bundle.js?h=${bundleHash}"></script>\n</body>`
);
// Update CSS cache bust // Copy wasm_exec.js as-is
html = html.replace( const wasmExecSrc = join(srcDir, 'js', 'wasm_exec.js');
/css\/style\.css\?[^"]*/, if (existsSync(wasmExecSrc)) {
`css/style.css?h=${cssHash}` cpSync(wasmExecSrc, join(distDir, 'js', 'wasm_exec.js'));
); }
// Inject version string and link // Copy patch-worker.js with WASM hash injected
html = html.replace('<span id="commit-hash"></span>', `<span id="commit-hash">${versionStr}</span>`); const workerSrc = join(srcDir, 'js', 'patch-worker.js');
html = html.replace( if (existsSync(workerSrc)) {
'href="https://github.com/nicoverbruggen/kobopatch-webui"', let workerContent = readFileSync(workerSrc, 'utf-8');
`href="${versionLink}"` 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 <script src="js/..."> tags
html = html.replace(/\s*<script src="js\/[^"]*"><\/script>\n/g, '');
// Add the bundle script before </body>
html = html.replace(
'</body>',
` <script src="/bundle.js?h=${bundleHash}"></script>\n</body>`
);
// Update CSS cache bust
html = html.replace(
/css\/style\.css\?[^"]*/,
`css/style.css?h=${cssHash}`
);
// Inject version string and link
html = html.replace('<span id="commit-hash"></span>', `<span id="commit-hash">${versionStr}</span>`);
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 // Dev server mode
if (isDev) { if (isDev) {

View File

@@ -17,18 +17,19 @@ if (analyticsEnabled) {
` <script defer src="${UMAMI_SCRIPT_URL}" data-website-id="${UMAMI_WEBSITE_ID}"></script>\n`; ` <script defer src="${UMAMI_SCRIPT_URL}" data-website-id="${UMAMI_WEBSITE_ID}"></script>\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; let cachedIndexHtml = null;
function getIndexHtml() { function getIndexHtml() {
if (cachedIndexHtml) return cachedIndexHtml; if (!noCache && cachedIndexHtml) return cachedIndexHtml;
const indexPath = join(DIST, 'index.html'); const indexPath = join(DIST, 'index.html');
if (!existsSync(indexPath)) return null; if (!existsSync(indexPath)) return null;
let html = readFileSync(indexPath, 'utf-8'); let html = readFileSync(indexPath, 'utf-8');
if (analyticsSnippet) { if (analyticsSnippet) {
html = html.replace('</head>', analyticsSnippet + '</head>'); html = html.replace('</head>', analyticsSnippet + '</head>');
} }
cachedIndexHtml = html; if (!noCache) cachedIndexHtml = html;
return cachedIndexHtml; return html;
} }
const MIME = { const MIME = {