import esbuild from 'esbuild' import fs from 'node:fs' import zlib from 'node:zlib' import path from 'node:path' import { fileURLToPath } from 'node:url' const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const pub = path.join(root, 'public') fs.mkdirSync(pub, { recursive: true }) // version stamp: the server rejects websocket clients from other builds so a // stale tab can never silently corrupt a doc with its old state const buildId = String(Date.now()) await esbuild.build({ entryPoints: [path.join(root, 'client/src/doc.js'), path.join(root, 'client/src/home.js')], bundle: true, minify: true, format: 'iife', outdir: pub, define: { 'process.env.NODE_ENV': '"production"', __BUILD_ID__: JSON.stringify(buildId) }, logLevel: 'info', }) // Asset URLs carry the build id. Without it a cached bundle keeps losing the // websocket build-check on every reload, and the tab is stuck outdated forever: // it still talks HTTP, so edits and accepts look like they work while the // collaboration socket is dead. Versioned URLs make a reload always escape that. for (const f of ['doc.html', 'index.html', 'app.css']) { let body = fs.readFileSync(path.join(root, 'client', f), 'utf8') if (f.endsWith('.html')) { body = body .replace(/(src|href)="\/(doc|home)\.js"/g, `$1="/$2.js?v=${buildId}"`) .replace(/href="\/app\.css"/g, `href="/app.css?v=${buildId}"`) } fs.writeFileSync(path.join(pub, f), body) } // self-hosted webfont fs.mkdirSync(path.join(pub, 'fonts'), { recursive: true }) for (const f of fs.readdirSync(path.join(root, 'client/fonts'))) { fs.copyFileSync(path.join(root, 'client/fonts', f), path.join(pub, 'fonts', f)) } // KaTeX, kept OUT of the main bundle: it is only fetched by a page that // actually has math on it. The prebuilt UMD file already assigns window.katex, // so there is nothing to re-bundle. Its stylesheet points at fonts/KaTeX_*, // which resolves against /fonts — the same directory, already immutable-cached. // Only woff2 is copied; every browser that runs this app prefers it. const katexDist = path.join(root, 'node_modules/katex/dist') fs.copyFileSync(path.join(katexDist, 'katex.min.js'), path.join(pub, 'katex.js')) fs.copyFileSync(path.join(katexDist, 'katex.min.css'), path.join(pub, 'katex.css')) for (const f of fs.readdirSync(path.join(katexDist, 'fonts'))) { if (f.endsWith('.woff2')) fs.copyFileSync(path.join(katexDist, 'fonts', f), path.join(pub, 'fonts', f)) } fs.writeFileSync(path.join(pub, 'build-id'), buildId) // Pre-compress the bundles. The editor bundle is ~640KB raw and ~160KB brotli, // and that transfer was most of a cold page load. Doing it here rather than in // a runtime middleware costs nothing per request and gets brotli's slowest, // smallest setting for free. const compressed = [] for (const f of fs.readdirSync(pub)) { if (!/\.(js|css)$/.test(f)) continue const raw = fs.readFileSync(path.join(pub, f)) const br = zlib.brotliCompressSync(raw, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.length, }, }) const gz = zlib.gzipSync(raw, { level: zlib.constants.Z_BEST_COMPRESSION }) fs.writeFileSync(path.join(pub, f + '.br'), br) fs.writeFileSync(path.join(pub, f + '.gz'), gz) compressed.push(`${f} ${kb(raw.length)}K -> br ${kb(br.length)}K / gz ${kb(gz.length)}K`) } console.log('build done ->', pub, 'build-id', buildId) for (const line of compressed) console.log(' ', line) function kb(n) { return Math.round(n / 1024) }