check-browser-baseline.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. #!/usr/bin/env node
  2. /**
  3. * Fail the build when the bundle uses a JS feature our oldest supported browser
  4. * cannot parse (#2971).
  5. *
  6. * Why this exists as a grep rather than a build target: Vite's `build.target`
  7. * only governs *syntax lowering*. esbuild does not rewrite regular expressions,
  8. * so a lookbehind assertion - unsupported before Safari 16.4 - builds silently
  9. * under `safari15`, `safari16.0` and `es2020` alike (measured, all three). That
  10. * is exactly how #2971 shipped: `remark-gfm` pulled a lookbehind regex literal
  11. * into the entry chunk, iOS 16.0-16.3 refused to compile the module, and every
  12. * page rendered as a blank white screen from v1.2.5 until it was found in the
  13. * field two months later.
  14. *
  15. * A regex literal is validated when its module is *compiled*, so one of these
  16. * anywhere in the entry chunk takes down the entire app, not just the feature
  17. * that pulled it in. There is no graceful degradation to fall back on, which is
  18. * why this is a hard build failure and not a warning.
  19. *
  20. * BASELINE: Safari 16.0 / iOS 16.0. Raising it is a product decision - if you
  21. * do, drop the entries that the new floor supports rather than deleting the
  22. * check.
  23. *
  24. * Scope: parse-time failures only. Runtime APIs (`Object.groupBy`,
  25. * `Promise.withResolvers`, ...) break one feature rather than the whole bundle
  26. * and are better caught by real-browser testing, so they are deliberately not
  27. * listed here.
  28. */
  29. import { readdirSync, readFileSync } from 'node:fs';
  30. import { join, dirname, relative, resolve } from 'node:path';
  31. import { fileURLToPath } from 'node:url';
  32. // The build's assets; another directory as the first argument, for a build
  33. // made elsewhere (test_frontend.sh builds into a temporary directory).
  34. const ASSETS = process.argv[2]
  35. ? resolve(process.argv[2])
  36. : resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
  37. /**
  38. * Every extension the build can emit executable JavaScript under. `.mjs`
  39. * because a dependency's file imported with `?url` is copied verbatim under
  40. * its own extension and bypasses `build.target` - the pdf.js worker shipped a
  41. * class static block that way (#2976).
  42. */
  43. const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs'];
  44. /**
  45. * Every script under `dir`, recursively, as paths relative to ASSETS. The
  46. * subdirectories matter: pdf.js's decoder fallbacks are published verbatim
  47. * under assets/pdfjs/ (vite.config.ts) and run in its worker (#2976).
  48. */
  49. function collectScripts(dir) {
  50. const found = [];
  51. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  52. const full = join(dir, entry.name);
  53. if (entry.isDirectory()) {
  54. found.push(...collectScripts(full));
  55. } else if (SCRIPT_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
  56. found.push(relative(ASSETS, full));
  57. }
  58. }
  59. return found;
  60. }
  61. /**
  62. * Each pattern must match only real occurrences of the feature. Anything that
  63. * needs context to tell a false positive from a real hit (regex flags, for
  64. * instance, are indistinguishable from division by a variable in a minified
  65. * bundle without parsing) is left out rather than made noisy.
  66. */
  67. const FORBIDDEN = [
  68. {
  69. pattern: /\(\?<[=!]/g,
  70. feature: 'regex lookbehind assertion',
  71. since: 'Safari 16.4',
  72. hint: 'A dependency shipped `(?<=` or `(?<!` in a regex literal. Find it with:\n'
  73. + ' grep -rl \'(?<[=!]\' --include=*.js node_modules/\n'
  74. + ' then avoid importing that module (see src/utils/remarkGfmNoAutolink.ts).',
  75. },
  76. {
  77. // The one pattern here that can in principle fire on a string literal
  78. // containing the text `static {`. No bundle has ever hit it, and the
  79. // snippet printed above makes such a hit obvious at a glance - if that is
  80. // what you are looking at, narrow this pattern rather than deleting it.
  81. pattern: /\bstatic\s*\{/g,
  82. feature: 'class static initialisation block',
  83. since: 'Safari 16.4',
  84. hint: 'Set `build.target` low enough that esbuild lowers it, or drop the dependency.',
  85. },
  86. ];
  87. let bundles;
  88. try {
  89. bundles = collectScripts(ASSETS);
  90. } catch {
  91. console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
  92. process.exit(1);
  93. }
  94. if (bundles.length === 0) {
  95. console.error(
  96. `check-browser-baseline: no ${SCRIPT_EXTENSIONS.join('/')} files in ${ASSETS} - did the build succeed?`,
  97. );
  98. process.exit(1);
  99. }
  100. const failures = [];
  101. for (const name of bundles) {
  102. const source = readFileSync(join(ASSETS, name), 'utf8');
  103. for (const { pattern, feature, since, hint } of FORBIDDEN) {
  104. const hits = source.match(pattern);
  105. if (!hits) continue;
  106. const index = source.search(pattern);
  107. failures.push(
  108. ` ${name}: ${hits.length}x ${feature} (requires ${since})\n`
  109. + ` ...${source.slice(Math.max(0, index - 70), index + 70).replace(/\n/g, ' ')}...\n`
  110. + ` ${hint}`,
  111. );
  112. }
  113. }
  114. if (failures.length > 0) {
  115. console.error(
  116. `\ncheck-browser-baseline: bundle uses syntax that Safari 16.0 / iOS 16.0 cannot parse.\n`
  117. + `A parse error takes down the WHOLE app on those browsers - blank white screen (#2971).\n\n`
  118. + `${failures.join('\n\n')}\n`,
  119. );
  120. process.exit(1);
  121. }
  122. console.log(`✓ ${bundles.length} bundle(s) parse-compatible with the Safari 16.0 baseline.`);