Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ web/dist/*
web/dist/js/*
!web/dist/js/runtime-bundle.min.js

# ── Pre-compressed static assets (S3) ──────────────────────────────
# Generated by `node scripts/precompress-static.mjs` during build.
# Runtime gzip in services/gzipped_static_files.py handles the missing case,
# so dev mode without a build step still works.
web/**/*.gz

# ── IDE & OS ───────────────────────────────────────────────────────
.DS_Store
Thumbs.db
Expand Down
5 changes: 3 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from fastapi.staticfiles import StaticFiles

from config import ConfigManager
from services.gzipped_static_files import GzippedStaticFiles

# Logging
from services.structured_logger import configure_logging
Expand Down Expand Up @@ -293,9 +294,9 @@ async def dispatch(self, request: Request, call_next):

# Mount locales FIRST (more specific path takes precedence)
if os.path.isdir(locales_dir):
main_app.mount("/static/locales", StaticFiles(directory=locales_dir), name="locales")
main_app.mount("/static/locales", GzippedStaticFiles(directory=locales_dir), name="locales")
# Then mount web/ for remaining static files
main_app.mount("/static", StaticFiles(directory=web_dir), name="static")
main_app.mount("/static", GzippedStaticFiles(directory=web_dir), name="static")

# Generated chapter images
images_dir = os.path.join(base_dir, "output", "images")
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.build.json && node scripts/build-runtime-bundle.mjs",
"build": "tsc -p tsconfig.build.json && node scripts/build-runtime-bundle.mjs && node scripts/precompress-static.mjs",
"build:precompress": "node scripts/precompress-static.mjs",
"build:bundle": "node scripts/build-runtime-bundle.mjs",
"build:css": "tailwindcss -i web/css/main.css -o web/css/main.built.css --minify",
"build:vite": "vite build",
Expand Down
101 changes: 62 additions & 39 deletions scripts/build-runtime-bundle.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
#!/usr/bin/env node
/**
* build-runtime-bundle.mjs — concat + minify the 13 runtime <script src> files
* referenced at the bottom of web/index.html into a single bundle.
* build-runtime-bundle.mjs — assemble web/dist/js/runtime-bundle.min.js.
*
* Sprint perf/forge-shell P2: collapses 13 HTTP requests + parse round-trips
* into one. The source files use classic-script globals (no ES module syntax),
* so plain concatenation preserves semantics — they share window scope today
* and they share it after bundling.
* Two-stage build:
* 1. CLASSIC concat — files that publish window-scoped globals consumed by
* inline x-data="pipelinePage()" / x-data="libraryPage()" / etc. handlers
* in web/index.html. We strip stray `export`/`import` so a top-level
* `export function X` becomes a classic `function X` global.
* 2. ESM IIFE bundle — web/js/app.js as the entry point. esbuild resolves
* the full ESM graph (stores/, components/, page exports, d3-force from
* node_modules) into one self-contained IIFE. This block owns the
* `alpine:init` listener that registers stores and components.
*
* Output: web/dist/js/runtime-bundle.min.js (committed for prod serving)
* Re-run: npm run build:bundle
*/

Expand All @@ -20,86 +23,106 @@ import { build } from 'esbuild';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');

// Order MUST match the 13 <script src> tags at the bottom of web/index.html.
// storage-manager + api-client define globals consumed by pages/*; app.js boots
// Alpine after every page function is registered, so it MUST come last.
const SOURCES = [
// Classic-script sources. Each file's top-level `function name()` (or
// `export function name()` after `export` is stripped) becomes a window
// global so inline x-data="..." handlers resolve. app.js is NOT here — it
// lives in the ESM bundle (stage 2) because its imports require resolution.
const CLASSIC_SOURCES = [
'web/js/storage-manager.js',
'web/js/api-client.js',
'web/js/i18n.js',
'web/js/pages/library.js',
'web/js/pages/export.js',
'web/js/pages/analytics.js',
'web/js/pages/branching.js',
'web/js/pages/pipeline.js',
'web/js/pages/providers.js',
'web/js/pages/analytics.js',
'web/js/pages/branching.js',
'web/js/pages/export.js',
'web/js/pages/settings.js',
'web/js/pages/reader.js',
'web/js/branch-reader.js',
'web/js/tree-visualizer.js',
'web/js/i18n.js',
'web/js/app.js',
];

const ESM_ENTRY = 'web/js/app.js';
const OUT_DIR = path.join(ROOT, 'web', 'dist', 'js');
const OUT_FILE = path.join(OUT_DIR, 'runtime-bundle.min.js');

async function main() {
async function buildClassicBlock() {
const missing = [];
for (const rel of SOURCES) {
for (const rel of CLASSIC_SOURCES) {
try {
await fs.access(path.join(ROOT, rel));
} catch {
missing.push(rel);
}
}
if (missing.length) {
console.error('[bundle] missing sources (run `npm run build` first?):\n ' + missing.join('\n '));
process.exit(1);
throw new Error('missing classic sources:\n ' + missing.join('\n '));
}

const parts = [];
parts.push('/* runtime-bundle.min.js — concat of ' + SOURCES.length + ' source files (perf/forge-shell P2) */');
for (const rel of SOURCES) {
parts.push('/* runtime-bundle.min.js — classic concat block */');
for (const rel of CLASSIC_SOURCES) {
let code = await fs.readFile(path.join(ROOT, rel), 'utf8');
// tsc emits `export function`/`export const` even with isolatedModules=true; the
// pre-bundling world treated these as window globals, so strip the `export ` prefix
// to keep the classic-script semantics under concatenation.
code = code.replace(/^\s*export\s+(function|const|let|var|class|async\s+function)\s+/gm, '$1 ');
// Strip any stray ESM marker that would force module mode at runtime.
code = code.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '');
code = code.replace(/^Object\.defineProperty\(exports,\s*"__esModule",[^)]*\);?\s*$/gm, '');
// Drop `import { X, Y } from '...';` lines — concatenation already puts every
// symbol in shared global scope, and the import binding collides with the
// `function X` declaration once `export` is stripped from the source module.
code = code.replace(/^\s*import\s+(?:[\s\S]*?)\s+from\s+['"][^'"]+['"];?\s*$/gm, '');
code = code.replace(/^\s*import\s+['"][^'"]+['"];?\s*$/gm, '');
parts.push(`/* === ${rel} === */`);
parts.push(code);
}
const concatenated = parts.join('\n');

await fs.mkdir(OUT_DIR, { recursive: true });

const result = await build({
stdin: { contents: concatenated, loader: 'js', resolveDir: ROOT },
write: false,
bundle: false,
minify: true,
target: 'es2020',
// No format: classic-script semantics — every top-level `function name()` stays
// a window-scoped global so Alpine.data() and inline event handlers can resolve
// them, exactly as they did before bundling.
legalComments: 'none',
logLevel: 'warning',
});
return { rawSize: Buffer.byteLength(concatenated, 'utf8'), minified: result.outputFiles[0].text };
}

const out = result.outputFiles[0].text;
async function buildEsmBlock() {
const result = await build({
entryPoints: [path.join(ROOT, ESM_ENTRY)],
write: false,
bundle: true,
format: 'iife',
minify: true,
target: 'es2020',
platform: 'browser',
legalComments: 'none',
logLevel: 'warning',
});
return { minified: result.outputFiles[0].text };
}

async function main() {
const [classicBlock, esmBlock] = await Promise.all([
buildClassicBlock(),
buildEsmBlock(),
]);

const out =
'/* runtime-bundle.min.js — classic globals + ESM IIFE (perf/forge-shell-2 S?) */\n' +
classicBlock.minified +
'\n' +
esmBlock.minified +
'\n';

await fs.mkdir(OUT_DIR, { recursive: true });
await fs.writeFile(OUT_FILE, out, 'utf8');

const sizeRaw = Buffer.byteLength(concatenated, 'utf8');
const sizeMin = Buffer.byteLength(out, 'utf8');
console.log(`[bundle] wrote ${path.relative(ROOT, OUT_FILE)}`);
console.log(`[bundle] sources: ${SOURCES.length} files`);
console.log(`[bundle] raw: ${sizeRaw.toLocaleString()} bytes`);
console.log(`[bundle] minified: ${sizeMin.toLocaleString()} bytes (${((sizeMin / sizeRaw) * 100).toFixed(1)}% of raw)`);
console.log(`[bundle] classic raw: ${classicBlock.rawSize.toLocaleString()} bytes`);
console.log(`[bundle] classic min: ${Buffer.byteLength(classicBlock.minified, 'utf8').toLocaleString()} bytes`);
console.log(`[bundle] esm iife min: ${Buffer.byteLength(esmBlock.minified, 'utf8').toLocaleString()} bytes`);
console.log(`[bundle] total: ${sizeMin.toLocaleString()} bytes`);
}

main().catch((err) => {
Expand Down
116 changes: 116 additions & 0 deletions scripts/precompress-static.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* Pre-compress static assets with gzip (level 9) for the FastAPI server's
* GzippedStaticFiles handler to serve verbatim.
*
* Why: at runtime we compress on-demand at level 6 with an LRU cache, which
* is fine — but level 9 squeezes another ~3-5% out of CSS/JS for the cost of
* a few hundred ms of build-time CPU, paid once. Production wins.
*
* Walks a hard-coded list of asset dirs, writes `<file>.gz` next to every
* candidate > 1KB when the .gz is missing or older than the source.
*
* Run via `npm run build` (wired in package.json after the JS bundle step).
*/

import { promises as fs } from 'node:fs';
import { gzipSync, constants as zlibConstants } from 'node:zlib';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');

const TARGET_DIRS = [
'web/css',
'web/dist/js',
'web/static',
'web/js', // built TS output, if present
];

const COMPRESSIBLE_EXTS = new Set(['.css', '.js', '.mjs', '.svg', '.json', '.map', '.html']);
const MIN_SIZE = 1024;

/** Recursively yield files under `dir`. Missing dirs are silently skipped. */
async function* walk(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === 'ENOENT') return;
throw err;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.isFile()) {
yield full;
}
}
}

async function precompress(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (!COMPRESSIBLE_EXTS.has(ext)) return null;
if (filePath.endsWith('.gz')) return null;

const srcStat = await fs.stat(filePath);
if (srcStat.size < MIN_SIZE) return null;

const gzPath = filePath + '.gz';
try {
const gzStat = await fs.stat(gzPath);
if (gzStat.mtimeMs >= srcStat.mtimeMs) {
return { filePath, skipped: true, savedBytes: 0 };
}
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}

const raw = await fs.readFile(filePath);
const compressed = gzipSync(raw, { level: zlibConstants.Z_BEST_COMPRESSION });
await fs.writeFile(gzPath, compressed);
return {
filePath,
skipped: false,
savedBytes: raw.length - compressed.length,
rawSize: raw.length,
gzSize: compressed.length,
};
}

async function main() {
let compressed = 0;
let skipped = 0;
let savedBytes = 0;

for (const rel of TARGET_DIRS) {
const abs = path.join(ROOT, rel);
for await (const file of walk(abs)) {
const result = await precompress(file);
if (!result) continue;
if (result.skipped) {
skipped += 1;
} else {
compressed += 1;
savedBytes += result.savedBytes;
const relPath = path.relative(ROOT, result.filePath);
const pct = ((result.savedBytes / result.rawSize) * 100).toFixed(1);
console.log(
` gz ${relPath} ${result.rawSize}b → ${result.gzSize}b (-${pct}%)`,
);
}
}
}

const savedKb = (savedBytes / 1024).toFixed(1);
console.log(
`precompress-static: ${compressed} files compressed, ${skipped} up-to-date, ${savedKb} KB saved`,
);
}

main().catch((err) => {
console.error('precompress-static failed:', err);
process.exit(1);
});
Loading
Loading