commit a44d6a597f7e99b3cf492807690fc539f5433895
parent 6173a356155d99f3f82de45f3e1c856642110ea2
Author: Alex Hochheiden <ahochheiden@mozilla.com>
Date: Sat, 15 Nov 2025 03:20:54 +0000
Bug 1967968 - Use `Terser` instead of `JSMin` for Javascript minification r=calixte,marco,firefox-build-system-reviewers,Standard8,glandium
- `PDF.js` is now always minified, regardless of config settings.
Differential Revision: https://phabricator.services.mozilla.com/D264160
Diffstat:
7 files changed, 212 insertions(+), 146 deletions(-)
diff --git a/python/mozbuild/mozpack/files.py b/python/mozbuild/mozpack/files.py
@@ -3,25 +3,26 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import bisect
-import codecs
import errno
import inspect
+import json
import os
import platform
import shutil
import stat
import subprocess
+import tempfile
import uuid
from collections import OrderedDict
-from io import BytesIO, StringIO
+from io import BytesIO
from itertools import chain, takewhile
+from pathlib import Path
from tarfile import TarFile, TarInfo
-from tempfile import NamedTemporaryFile, mkstemp
-
-from jsmin import JavascriptMinify
+from tempfile import mkstemp
import mozbuild.makeutil as makeutil
import mozpack.path as mozpath
+from mozbuild.nodeutil import package_setup
from mozbuild.preprocessor import Preprocessor
from mozbuild.util import FileAvoidWrite, ensure_unicode, memoize
from mozpack.chrome.manifest import ManifestEntry, ManifestInterfaces
@@ -757,75 +758,134 @@ class MinifiedCommentStripped(BaseFile):
class MinifiedJavaScript(BaseFile):
"""
- File class for minifying JavaScript files.
+ Minify JavaScript files using Terser while preserving
+ class and function names for better debugging.
"""
- def __init__(self, file, verify_command=None):
- assert isinstance(file, BaseFile)
+ TERSER_CONFIG = {
+ "parse": {
+ "ecma": 2020,
+ "module": True,
+ },
+ "compress": {
+ "unused": True,
+ "passes": 3,
+ "ecma": 2020,
+ },
+ "mangle": {
+ "keep_classnames": True, # Preserve class names
+ "keep_fnames": True, # Preserve function names
+ },
+ "format": {
+ "comments": "/@lic|webpackIgnore|@vite-ignore/i",
+ "ascii_only": True,
+ "ecma": 2020,
+ },
+ "sourceMap": False,
+ }
+
+ def __init__(self, file, filepath):
+ """
+ Initialize with a BaseFile instance to minify.
+ """
self._file = file
- self._verify_command = verify_command
+ self._filepath = filepath
- def open(self):
- output = StringIO()
- minify = JavascriptMinify(
- codecs.getreader("utf-8")(self._file.open()), output, quote_chars="'\"`"
- )
- minify.minify()
- output.seek(0)
- output_source = output.getvalue().encode()
- output = BytesIO(output_source)
+ def _minify_with_terser(self, source_content):
+ """
+ Minify JavaScript content using Terser
+ """
+ if len(source_content) == 0:
+ return source_content
+
+ import buildconfig
+
+ node_path = buildconfig.substs.get("NODEJS")
+ if not node_path:
+ errors.fatal("NODEJS not found in build configuration")
+
+ topsrcdir = Path(buildconfig.topsrcdir)
+
+ if os.environ.get("MOZ_AUTOMATION"):
+ fetches_terser = (
+ Path(os.environ["MOZ_FETCHES_DIR"])
+ / "terser"
+ / "node_modules"
+ / "terser"
+ / "bin"
+ / "terser"
+ )
+ if fetches_terser.exists():
+ terser_path = fetches_terser
+ else:
+ errors.fatal(f"Terser toolchain not found at {fetches_terser}.")
+ else:
+ terser_dir = topsrcdir / "tools" / "terser"
+ terser_path = terser_dir / "node_modules" / "terser" / "bin" / "terser"
- if not self._verify_command:
- return output
+ if not terser_path.exists():
+ # Automatically set up node_modules if terser is not found
+ package_setup(str(terser_dir), "terser")
- input_source = self._file.open().read()
+ # Verify that terser is now available after setup
+ if not terser_path.exists():
+ errors.fatal(
+ f"Terser is required for JavaScript minification but could not be installed at {terser_path}. "
+ "Package setup may have failed."
+ )
- with NamedTemporaryFile("wb+") as fh1, NamedTemporaryFile("wb+") as fh2:
- fh1.write(input_source)
- fh2.write(output_source)
- fh1.flush()
- fh2.flush()
+ terser_cmd = [node_path, str(terser_path)]
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ config_path = temp_path / "terser_config.json"
+ source_path = temp_path / "source.js"
+
+ config_path.write_text(json.dumps(self.TERSER_CONFIG), encoding="utf-8")
+ source_path.write_bytes(source_content)
try:
- args = list(self._verify_command)
- args.extend([fh1.name, fh2.name])
- subprocess.check_output(
- args, stderr=subprocess.STDOUT, universal_newlines=True
- )
- except subprocess.CalledProcessError as e:
- errors.warn(
- "JS minification verification failed for %s:"
- % (getattr(self._file, "path", "<unknown>"))
+ result = subprocess.run(
+ terser_cmd
+ + [
+ source_path,
+ "--config-file",
+ config_path,
+ ],
+ capture_output=True,
+ check=False,
)
- # Prefix each line with "Warning:" so mozharness doesn't
- # think these error messages are real errors.
- for line in e.output.splitlines():
- errors.warn(line)
- return self._file.open()
+ if result.returncode == 0:
+ return result.stdout
+ else:
+ error_msg = result.stderr.decode("utf-8", errors="ignore")
+ errors.error(
+ f"Terser minification failed for {self._filepath}: {error_msg}"
+ )
+ return source_content
- return output
+ except subprocess.SubprocessError as e:
+ errors.error(f"Error running Terser for {self._filepath}: {e}")
+ return source_content
+
+ def open(self):
+ """
+ Return a file-like object with the minified content.
+ """
+ source_content = self._file.open().read()
+ minified = self._minify_with_terser(source_content)
+ return BytesIO(minified)
class BaseFinder:
- def __init__(
- self, base, minify=False, minify_js=False, minify_js_verify_command=None
- ):
+ def __init__(self, base, minify=False, minify_js=False):
"""
Initializes the instance with a reference base directory.
The optional minify argument specifies whether minification of code
should occur. minify_js is an additional option to control minification
of JavaScript. It requires minify to be True.
-
- minify_js_verify_command can be used to optionally verify the results
- of JavaScript minification. If defined, it is expected to be an iterable
- that will constitute the first arguments to a called process which will
- receive the filenames of the original and minified JavaScript files.
- The invoked process can then verify the results. If minification is
- rejected, the process exits with a non-0 exit code and the original
- JavaScript source is used. An example value for this argument is
- ('/path/to/js', '/path/to/verify/script.js').
"""
if minify_js and not minify:
raise ValueError("minify_js requires minify.")
@@ -833,7 +893,6 @@ class BaseFinder:
self.base = mozpath.normsep(base)
self._minify = minify
self._minify_js = minify_js
- self._minify_js_verify_command = minify_js_verify_command
def find(self, pattern):
"""
@@ -897,8 +956,17 @@ class BaseFinder:
if path.endswith((".ftl", ".properties")):
return MinifiedCommentStripped(file)
- if self._minify_js and path.endswith((".js", ".jsm", ".mjs")):
- return MinifiedJavaScript(file, self._minify_js_verify_command)
+ if path.endswith((".js", ".jsm", ".mjs")):
+ file_path = mozpath.normsep(path)
+ filename = mozpath.basename(file_path)
+ # Don't minify prefs files because they use a custom parser that's stricter than JS
+ if filename.endswith("prefs.js") or "/defaults/pref" in file_path:
+ return file
+ # PDF.js files always get minified
+ if "pdfjs" in file_path:
+ return MinifiedJavaScript(file, path)
+ elif self._minify_js:
+ return MinifiedJavaScript(file, path)
return file
@@ -939,7 +1007,7 @@ class FileFinder(BaseFinder):
ignore=(),
ignore_broken_symlinks=False,
find_dotfiles=False,
- **kargs
+ **kargs,
):
"""
Create a FileFinder for files under the given base directory.
diff --git a/python/mozbuild/mozpack/test/support/minify_js_verify.py b/python/mozbuild/mozpack/test/support/minify_js_verify.py
@@ -1,15 +0,0 @@
-# This Source Code Form is subject to the terms of the Mozilla Public
-# License, v. 2.0. If a copy of the MPL was not distributed with this
-# file, You can obtain one at http://mozilla.org/MPL/2.0/.
-
-import sys
-
-if len(sys.argv) != 4:
- raise Exception("Usage: minify_js_verify <exitcode> <orig> <minified>")
-
-retcode = int(sys.argv[1])
-
-if retcode:
- print("Error message", file=sys.stderr)
-
-sys.exit(retcode)
diff --git a/python/mozbuild/mozpack/test/test_files.py b/python/mozbuild/mozpack/test/test_files.py
@@ -2,9 +2,12 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+import buildconfig
+
from mozbuild.dirutils import ensureParentDir
+from mozbuild.nodeutil import find_node_executable
from mozbuild.util import ensure_bytes
-from mozpack.errors import ErrorMessage, errors
+from mozpack.errors import ErrorMessage
from mozpack.files import (
AbsoluteSymlinkFile,
ComposedFinder,
@@ -38,7 +41,7 @@ import random
import sys
import tarfile
import unittest
-from io import BytesIO, StringIO
+from io import BytesIO
from tempfile import mkdtemp
import mozfile
@@ -891,44 +894,91 @@ class TestMinifiedJavaScript(TestWithTmpDir):
"// Another comment",
]
+ def setUp(self):
+ super().setUp()
+ if not buildconfig.substs.get("NODEJS"):
+ node_exe, _ = find_node_executable()
+ if node_exe:
+ buildconfig.substs["NODEJS"] = node_exe
+
def test_minified_javascript(self):
- orig_f = GeneratedFile("\n".join(self.orig_lines))
- min_f = MinifiedJavaScript(orig_f)
-
- mini_lines = min_f.open().readlines()
- self.assertTrue(mini_lines)
- self.assertTrue(len(mini_lines) < len(self.orig_lines))
-
- def _verify_command(self, code):
- our_dir = os.path.abspath(os.path.dirname(__file__))
- return [
- sys.executable,
- os.path.join(our_dir, "support", "minify_js_verify.py"),
- code,
- ]
+ """Test that MinifiedJavaScript minifies JavaScript content."""
+ orig_f = GeneratedFile("\n".join(self.orig_lines).encode())
+ min_f = MinifiedJavaScript(orig_f, "test.js")
+
+ mini_content = min_f.open().read()
+ orig_content = orig_f.open().read()
+
+ # Verify minification occurred (content should be smaller)
+ self.assertTrue(len(mini_content) < len(orig_content))
+ # Verify content is not empty
+ self.assertTrue(len(mini_content) > 0)
+
+ def test_minified_javascript_open(self):
+ """Test that MinifiedJavaScript.open returns appropriately reset file object."""
+ orig_f = GeneratedFile("\n".join(self.orig_lines).encode())
+ min_f = MinifiedJavaScript(orig_f, "test.js")
+
+ # Test reading partial content
+ first_read = min_f.open().read(10)
+ self.assertTrue(len(first_read) <= 10)
+
+ # Test reading full content multiple times
+ full_content = min_f.open().read()
+ second_read = min_f.open().read()
+ self.assertEqual(full_content, second_read)
+
+ def test_preserves_functionality(self):
+ """Test that Terser preserves JavaScript functionality."""
+ # More complex JavaScript with functions and objects
+ complex_js = """
+ // This is a test function
+ function testFunction(param) {
+ let result = {
+ value: param * 2,
+ toString: function() {
+ return "Result: " + this.value;
+ }
+ };
+ return result;
+ }
- def test_minified_verify_success(self):
- orig_f = GeneratedFile("\n".join(self.orig_lines))
- min_f = MinifiedJavaScript(orig_f, verify_command=self._verify_command("0"))
+ // Export for testing
+ var exported = testFunction;
+ """
- mini_lines = [s.decode() for s in min_f.open().readlines()]
- self.assertTrue(mini_lines)
- self.assertTrue(len(mini_lines) < len(self.orig_lines))
+ orig_f = GeneratedFile(complex_js.encode())
+ min_f = MinifiedJavaScript(orig_f, "complex.js")
- def test_minified_verify_failure(self):
- orig_f = GeneratedFile("\n".join(self.orig_lines))
- errors.out = StringIO()
- min_f = MinifiedJavaScript(orig_f, verify_command=self._verify_command("1"))
+ minified_content = min_f.open().read().decode()
- mini_lines = min_f.open().readlines()
- output = errors.out.getvalue()
- errors.out = sys.stderr
- self.assertEqual(
- output,
- "warning: JS minification verification failed for <unknown>:\n"
- "warning: Error message\n",
- )
- self.assertEqual(mini_lines, orig_f.open().readlines())
+ # Verify it's minified
+ self.assertTrue(len(minified_content) < len(complex_js))
+ # Verify functions are still present)
+ self.assertIn("function", minified_content)
+
+ def test_handles_empty_file(self):
+ """Test that MinifiedJavaScript handles empty files gracefully."""
+ empty_f = GeneratedFile(b"")
+ min_f = MinifiedJavaScript(empty_f, "empty.js")
+
+ # Should handle empty content gracefully
+ result = min_f.open().read()
+ self.assertEqual(result, b"")
+
+ def test_handles_syntax_errors(self):
+ """Test that MinifiedJavaScript raises an error for syntax errors."""
+ # JavaScript with syntax error
+ broken_js = b"function broken( { return 'missing parenthesis'; }"
+
+ orig_f = GeneratedFile(broken_js)
+ min_f = MinifiedJavaScript(orig_f, "broken.js")
+
+ # Should raise an ErrorMessage when minification fails
+ from mozpack.errors import ErrorMessage
+
+ with self.assertRaises(ErrorMessage):
+ min_f.open().read()
class MatchTestTemplate:
diff --git a/taskcluster/kinds/source-test/python-android.yml b/taskcluster/kinds/source-test/python-android.yml
@@ -42,6 +42,7 @@ android-gradle-build:
- linux64-android-sdk-linux-repack
- linux64-jdk-repack
- linux64-node
+ - terser
when:
files-changed:
# Build stuff.
diff --git a/toolkit/moz.configure b/toolkit/moz.configure
@@ -1054,7 +1054,7 @@ set_config("MOZ_PACKAGER_FORMAT", packager_format)
@depends(target_is_android, "--enable-debug", milestone.is_nightly)
def enable_minify_default(is_android, debug, is_nightly):
if is_android and not debug and not is_nightly:
- return ("properties", "js")
+ return ("properties",)
return ("properties",)
diff --git a/toolkit/mozapps/installer/js-compare-ast.js b/toolkit/mozapps/installer/js-compare-ast.js
@@ -1,31 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-/**
- * This script compares the AST of two JavaScript files passed as arguments.
- * The script exits with a 0 status code if both files parse properly and the
- * ASTs of both files are identical modulo location differences. The script
- * exits with status code 1 if any of these conditions don't hold.
- *
- * This script is used as part of packaging to verify minified JavaScript files
- * are identical to their original files.
- */
-
-// Available to the js shell.
-/* global snarf, scriptArgs, quit */
-
-"use strict";
-
-function ast(filename) {
- return JSON.stringify(Reflect.parse(snarf(filename), { loc: 0 }));
-}
-
-if (scriptArgs.length !== 2) {
- throw new Error("usage: js js-compare-ast.js FILE1.js FILE2.js");
-}
-
-var ast0 = ast(scriptArgs[0]);
-var ast1 = ast(scriptArgs[1]);
-
-quit(ast0 == ast1 ? 0 : 1);
diff --git a/toolkit/mozapps/installer/packager.py b/toolkit/mozapps/installer/packager.py
@@ -225,13 +225,6 @@ def main():
minify_js=args.minify_js,
ignore_broken_symlinks=args.ignore_broken_symlinks,
)
- if args.js_binary:
- finder_args["minify_js_verify_command"] = [
- args.js_binary,
- os.path.join(
- os.path.abspath(os.path.dirname(__file__)), "js-compare-ast.js"
- ),
- ]
finder = PackagerFileFinder(args.source, find_executables=True, **finder_args)
if "NO_PKG_FILES" in os.environ:
sinkformatter = NoPkgFilesRemover(formatter, args.manifest is not None)