build.mjs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. // Replaces the old gulpfile. Gulp 3 cannot run on Node 12+, and the project is
  2. // on Node 22, so the previous build was simply dead.
  3. //
  4. // Same contract as before: read src/, write the bundles app/ that app.js serves.
  5. //
  6. // node build.mjs build once
  7. // node build.mjs --watch rebuild on change
  8. import { rm, mkdir, cp, readFile, writeFile } from 'node:fs/promises';
  9. import { existsSync, watch } from 'node:fs';
  10. import path from 'node:path';
  11. import { fileURLToPath } from 'node:url';
  12. import { transform } from 'esbuild';
  13. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  14. const SRC = path.join(__dirname, 'src');
  15. const OUT = path.join(__dirname, 'app');
  16. const NODE_MODULES = path.join(__dirname, 'node_modules');
  17. // Baked into the client bundle at build time. Must be https:// -- the page is
  18. // served over TLS, so http:// stream URLs get blocked as mixed content.
  19. const STREAM_URL = process.env.STREAM_URL ?? 'https://stream.davidawindham.com/stream';
  20. const STREAM_STATUS_URL =
  21. process.env.STREAM_STATUS_URL ?? 'https://stream.davidawindham.com/status-json.xsl';
  22. const STREAM_MOUNT = process.env.STREAM_MOUNT ?? '/stream';
  23. const VENDOR_JS = [
  24. path.join(SRC, 'js/jquery-v2.1.4.js'),
  25. // Was src/js/bootstrap-v3.3.6.js. 3.4.1 patches the XSS fixed in 3.4.0/3.4.1
  26. // (data-target, and the collapse/affix/tooltip selectors).
  27. //
  28. // It does NOT clear `npm audit`: two XSS advisories cover all of 3.1.1-3.4.1
  29. // with no 3.x fix, because Bootstrap 3 went EOL in 2019. Both need
  30. // attacker-controlled content in a popover/tooltip or a data-* attribute,
  31. // and neither exists here -- the only tooltip is on static nav-tabs, and the
  32. // one templated data-* is a literal. Clearing the advisory outright means
  33. // Bootstrap 5, i.e. rewriting the markup. See _claude.md.
  34. path.join(NODE_MODULES, 'bootstrap/dist/js/bootstrap.js'),
  35. path.join(SRC, 'js/bootstrap-validator-v0.9.0.js'),
  36. path.join(SRC, 'js/bootstrap-progress-v0.9.0.js'),
  37. path.join(SRC, 'js/underscore-v1.8.3.js'),
  38. // Was src/js/handlebars-v4.0.5.js. Handlebars < 4.7.7 has a prototype
  39. // pollution RCE, so this now tracks the npm package instead of the checked-in
  40. // copy. The template syntax used here is unchanged between the two.
  41. path.join(NODE_MODULES, 'handlebars/dist/handlebars.min.js'),
  42. ];
  43. const RADIO_JS = [
  44. // base.js must come first: it defines window.RADIO, the shared socket and
  45. // mount-path helper that the other two depend on.
  46. path.join(SRC, 'js/base.js'),
  47. path.join(SRC, 'js/chat.js'),
  48. path.join(SRC, 'js/amplitude-v2.2.0.js'),
  49. path.join(SRC, 'js/radio.js'),
  50. ];
  51. // Bootstrap's CSS ships with its JS, so it tracks the same 3.4.1 package. Its
  52. // glyphicon @font-face rules point at ../fonts/, which is what src/fonts/ is
  53. // copied to (those files are byte-identical between 3.3.6 and 3.4.1).
  54. // main.css comes last so it keeps overriding Bootstrap.
  55. const CSS = [
  56. path.join(NODE_MODULES, 'bootstrap/dist/css/bootstrap.css'),
  57. path.join(SRC, 'css/main.css'),
  58. ];
  59. const DEFINE = {
  60. __STREAM_URL__: JSON.stringify(STREAM_URL),
  61. __STREAM_STATUS_URL__: JSON.stringify(STREAM_STATUS_URL),
  62. __STREAM_MOUNT__: JSON.stringify(STREAM_MOUNT),
  63. };
  64. // `separator` differs by language and it matters. JS files are joined with a
  65. // semicolon so a file ending in an expression can't swallow the next file's
  66. // leading paren (the classic IIFE concat hazard). CSS must NOT get one: a
  67. // stray top-level ';' is a parse error, and the recovery rule makes the parser
  68. // consume it as part of the *next* rule's selector -- silently dropping that
  69. // rule. It ate main.css's first rule until this was fixed.
  70. async function concat(files, separator) {
  71. const parts = [];
  72. for (const file of files) {
  73. if (!existsSync(file)) throw new Error(`missing build input: ${path.relative(__dirname, file)}`);
  74. parts.push(await readFile(file, 'utf8'));
  75. }
  76. return parts.join(separator);
  77. }
  78. async function bundleJs(files, outFile, { define } = {}) {
  79. const source = await concat(files, '\n;\n');
  80. // These are old-style global scripts, not modules -- minify only, no bundling
  81. // or module resolution, or they'd lose their globals.
  82. const result = await transform(source, { loader: 'js', minify: true, define });
  83. await writeFile(path.join(OUT, outFile), result.code);
  84. }
  85. async function bundleCss(files, outFile) {
  86. const source = await concat(files, '\n');
  87. const result = await transform(source, { loader: 'css', minify: true });
  88. await writeFile(path.join(OUT, outFile), result.code);
  89. }
  90. async function build() {
  91. const started = Date.now();
  92. await rm(OUT, { recursive: true, force: true });
  93. await mkdir(path.join(OUT, 'js'), { recursive: true });
  94. await mkdir(path.join(OUT, 'css'), { recursive: true });
  95. await cp(path.join(SRC, 'index.html'), path.join(OUT, 'index.html'));
  96. await cp(path.join(SRC, 'img'), path.join(OUT, 'img'), { recursive: true });
  97. await cp(path.join(SRC, 'fonts'), path.join(OUT, 'fonts'), { recursive: true });
  98. await cp(path.join(SRC, 'js/templates'), path.join(OUT, 'js/templates'), { recursive: true });
  99. await bundleCss(CSS, 'css/style.min.css');
  100. await bundleJs(VENDOR_JS, 'js/vendor.min.js');
  101. await bundleJs(RADIO_JS, 'js/radio.min.js', { define: DEFINE });
  102. console.log(`BUILD: complete in ${Date.now() - started}ms -> ${path.relative(__dirname, OUT)}/`);
  103. }
  104. await build();
  105. if (process.argv.includes('--watch')) {
  106. let queued = null;
  107. watch(SRC, { recursive: true }, () => {
  108. clearTimeout(queued);
  109. queued = setTimeout(() => build().catch((err) => console.error('BUILD FAILED:', err.message)), 100);
  110. });
  111. console.log('WATCH: watching src/');
  112. }