build.js

  1import * as esbuild from 'esbuild';
  2import * as fs from 'fs';
  3import * as zlib from 'zlib';
  4import * as crypto from 'crypto';
  5import { execSync } from 'child_process';
  6
  7const isWatch = process.argv.includes('--watch');
  8const isProd = !isWatch;
  9const verbose = process.env.VERBOSE === '1' || process.env.VERBOSE === 'true';
 10
 11function log(...args) {
 12  if (verbose) console.log(...args);
 13}
 14
 15async function build() {
 16  const startTime = Date.now();
 17  try {
 18    // Ensure dist directory exists
 19    if (!fs.existsSync('dist')) {
 20      fs.mkdirSync('dist');
 21    }
 22
 23    // Build Monaco editor worker separately (IIFE format for web worker)
 24    log('Building Monaco editor worker...');
 25    await esbuild.build({
 26      entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.worker.js'],
 27      bundle: true,
 28      outfile: 'dist/editor.worker.js',
 29      format: 'iife',
 30      minify: isProd,
 31      sourcemap: true,
 32    });
 33
 34    // Build Monaco editor as a separate chunk (JS + CSS)
 35    log('Building Monaco editor bundle...');
 36    await esbuild.build({
 37      entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.main.js'],
 38      bundle: true,
 39      outfile: 'dist/monaco-editor.js',
 40      format: 'esm',
 41      minify: isProd,
 42      sourcemap: true,
 43      loader: {
 44        '.ttf': 'file',
 45      },
 46    });
 47
 48    // Build main app - exclude monaco-editor, we'll load it dynamically
 49    log('Building main application...');
 50    const result = await esbuild.build({
 51      entryPoints: ['src/main.tsx'],
 52      bundle: true,
 53      outfile: 'dist/main.js',
 54      format: 'esm',
 55      minify: isProd,
 56      sourcemap: true,
 57      metafile: true,
 58      external: ['monaco-editor', '/monaco-editor.js'],
 59    });
 60
 61    // Copy static files
 62    fs.copyFileSync('src/index.html', 'dist/index.html');
 63    fs.copyFileSync('src/styles.css', 'dist/styles.css');
 64
 65    // Copy assets (icons, manifest, etc.)
 66    const assetsDir = 'src/assets';
 67    if (fs.existsSync(assetsDir)) {
 68      for (const file of fs.readdirSync(assetsDir)) {
 69        fs.copyFileSync(`${assetsDir}/${file}`, `dist/${file}`);
 70      }
 71    }
 72
 73    // Write build info
 74    // Get the absolute path to the src directory for staleness checking
 75    const srcDir = new URL('../src', import.meta.url).pathname;
 76
 77    // Get git commit info
 78    let commit = '';
 79    let commitTime = '';
 80    let modified = false;
 81    try {
 82      commit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
 83      commitTime = execSync('git log -1 --format=%cI', { encoding: 'utf8' }).trim();
 84      // Check for modifications, excluding the dist/ directory (which we're currently building)
 85      const status = execSync('git status --porcelain --ignore-submodules', { encoding: 'utf8' });
 86      // Filter out dist/ changes since those are expected during build
 87      const significantChanges = status.split('\n').filter(line =>
 88        line.trim() && !line.includes('dist/')
 89      );
 90      modified = significantChanges.length > 0;
 91    } catch (e) {
 92      // Git not available or not a git repo
 93    }
 94
 95    const buildInfo = {
 96      timestamp: Date.now(),
 97      date: new Date().toISOString(),
 98      srcDir: srcDir,
 99      commit: commit,
100      commitTime: commitTime,
101      modified: modified,
102    };
103    fs.writeFileSync('dist/build-info.json', JSON.stringify(buildInfo, null, 2));
104
105    // Generate gzip versions of large files and remove originals to reduce binary size
106    // The server will decompress on-the-fly for the rare clients that don't support gzip
107    log('\nGenerating gzip compressed files...');
108    const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'main.js', 'monaco-editor.css', 'styles.css', 'main.css'];
109    const checksums = {};
110    let totalOrigSize = 0;
111    let totalGzSize = 0;
112
113    for (const file of filesToCompress) {
114      const inputPath = `dist/${file}`;
115      const outputPath = `dist/${file}.gz`;
116      if (fs.existsSync(inputPath)) {
117        const input = fs.readFileSync(inputPath);
118        const compressed = zlib.gzipSync(input, { level: 9 });
119        fs.writeFileSync(outputPath, compressed);
120
121        // Compute SHA256 of the compressed content for ETag
122        const hash = crypto.createHash('sha256').update(compressed).digest('hex').slice(0, 16);
123        checksums[file] = hash;
124
125        totalOrigSize += input.length;
126        totalGzSize += compressed.length;
127
128        if (verbose) {
129          const origKb = (input.length / 1024).toFixed(1);
130          const gzKb = (compressed.length / 1024).toFixed(1);
131          const ratio = ((compressed.length / input.length) * 100).toFixed(0);
132          console.log(`  ${file}: ${origKb} KB -> ${gzKb} KB gzip (${ratio}%) [${hash}]`);
133        }
134
135        // Remove original to save space in embedded binary
136        fs.unlinkSync(inputPath);
137      }
138    }
139
140    // Write checksums for ETag support
141    fs.writeFileSync('dist/checksums.json', JSON.stringify(checksums, null, 2));
142    log('\nChecksums written to dist/checksums.json');
143
144    if (verbose) {
145      console.log('\nOther files:');
146      const otherFiles = fs.readdirSync('dist').filter(f =>
147        (f.endsWith('.ttf') || f.endsWith('.map')) && !f.endsWith('.gz')
148      );
149      for (const file of otherFiles.sort()) {
150        const stats = fs.statSync(`dist/${file}`);
151        const sizeKb = (stats.size / 1024).toFixed(1);
152        console.log(`  ${file}: ${sizeKb} KB`);
153      }
154    }
155
156    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
157    const totalGzKb = (totalGzSize / 1024).toFixed(0);
158    console.log(`UI built in ${elapsed}s (${totalGzKb} KB gzipped)`);
159  } catch (error) {
160    console.error('Build failed:', error);
161    process.exit(1);
162  }
163}
164
165build();