#!/usr/bin/env python3
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import itertools
import json
import os
import glob
import platform
import re
import shutil
import sys
_HERE_PATH = os.path.dirname(__file__)
_SRC_PATH = os.path.normpath(os.path.join(_HERE_PATH, '..', '..', '..', '..'))
_CWD = os.getcwd() # NOTE(dbeam): this is typically out/<gn_name>/.
sys.path.append(os.path.join(_SRC_PATH, 'third_party', 'node'))
import node
import node_modules
def _request_list_path(out_folder, target_name):
# Using |target_name| as a prefix which is guaranteed to be unique within the
# same folder, to avoid problems when multiple bundle_js() targets in the
# same BUILD.gn file exist.
return os.path.join(out_folder, target_name + '_requestlist.txt')
def _get_dep_path(dep, host_url, out_folder):
if dep.startswith(host_url):
return dep.replace(host_url, os.path.relpath(out_folder, _CWD))
elif not (dep.startswith('chrome://') or dep.startswith('//')):
return os.path.relpath(out_folder, _CWD) + '/' + dep
return dep
# Get a list of all files that were bundled with rollup and update the
# depfile accordingly such that Ninja knows when to re-trigger.
def _update_dep_file(in_folder, args, out_file_path, manifest):
in_path = os.path.join(_CWD, in_folder)
# Gather the dependencies of all bundled root files.
request_list = []
for out_file in manifest:
request_list += manifest[out_file]
# Add a slash in front of every dependency that is not a chrome:// URL, so
# that we can map it to the correct source file path below.
request_list = map(
lambda dep: _get_dep_path(dep, args.host_url, args.out_folder),
request_list)
deps = map(os.path.normpath, request_list)
with open(
os.path.join(_CWD, args.depfile), 'w', newline='', encoding='utf-8') as f:
f.write(out_file_path + ': ' + ' '.join(deps))
# Autogenerate a rollup config file so that we can import the plugin and
# pass it information about the location of the directories and files to
# exclude from the bundle.
# Arguments:
# out_dir: The root directory for the output (i.e. corresponding to
# host_url at runtime).
# in_path: Root directory for the input files.
# bundle_dir_path: Path to the directory holding the bundled output files
# relative to the root output directory. E.g. if bundle is
# chrome://<blah>/foo/bundle.js, this is |foo|.
# host_url: URL of the host. Usually something like "chrome://settings".
# excludes: Imports to exclude from the bundle.
# external_paths: Path mappings for import paths that are outside of
# |in_path|. For example:
# chrome://resources/|gen/ui/webui/resources/tsc
def _generate_rollup_config(out_dir, in_path, bundle_dir_path, host_url,
excludes, external_paths):
rollup_config_file = os.path.join(out_dir, 'rollup.config.mjs')
path_to_plugin = os.path.join(
os.path.relpath(_HERE_PATH, out_dir), 'rollup_plugin.mjs')
config_content = r'''
import plugin from '{plugin_path}';
export default ({{
plugins: [
plugin('{in_path}', '{bundle_dir_path}', '{host_url}', {exclude_list},
{external_path_list}) ]
}});
'''.format(
plugin_path=path_to_plugin.replace('\\', '/'),
in_path=in_path.replace('\\', '/'),
bundle_dir_path=bundle_dir_path.replace('\\', '/'),
host_url=host_url,
exclude_list=json.dumps(excludes),
external_path_list=json.dumps(external_paths))
with open(rollup_config_file, 'w', newline='', encoding='utf-8') as f:
f.write(config_content)
return rollup_config_file
# Create the manifest file from the sourcemap generated by rollup and return the
# list of bundles.
def _generate_manifest_file(out_dir, bundled_paths, bundle_dir_path,
manifest_out_path):
manifest = {}
for bundled_path in bundled_paths:
sourcemap_file = bundled_path + '.map'
with open(sourcemap_file, 'r', encoding='utf-8') as f:
sourcemap = json.loads(f.read())
if not 'sources' in sourcemap:
raise Exception('rollup could not construct source map')
sources = sourcemap['sources']
replaced_sources = []
# Normalize everything to be relative to the output directory. This is
# where the conversion to a dependency file expects it to be.
bundle_to_output = os.path.relpath(out_dir,
os.path.join(out_dir, bundle_dir_path))
for source in sources:
if bundle_to_output != ".":
replaced_sources.append(source.replace(bundle_to_output + "/", "", 1))
else:
replaced_sources.append(source)
filepath = os.path.join(bundle_dir_path,
os.path.basename(bundled_path)).replace(
'\\', '/')
manifest[filepath] = replaced_sources
with open(manifest_out_path, 'w', newline='', encoding='utf-8') as f:
f.write(json.dumps(manifest))
def _bundle(out_folder, in_path, manifest_out_path, js_module_in_files,
rollup_config_file):
bundle_dir_path = os.path.dirname(js_module_in_files[0])
out_dir = out_folder if not bundle_dir_path else os.path.join(
out_folder, bundle_dir_path)
if not os.path.exists(out_dir):
os.makedirs(out_dir)
rollup_args = [os.path.join(in_path, f) for f in js_module_in_files]
# Confirm names are as expected. This is necessary to avoid having to replace
# import statements in the generated output files.
# TODO(rbpotter): Is it worth adding import statement replacement to support
# arbitrary names?
bundled_paths = []
bundle_names = []
assert len(js_module_in_files) < 3, '3+ input files not supported'
for index, js_file in enumerate(js_module_in_files):
bundle_name = '%s.rollup.js' % js_file[:-len('.js')]
assert os.path.dirname(js_file) == bundle_dir_path, \
'All input files must be in the same directory.'
bundled_paths.append(os.path.join(out_folder, bundle_name))
bundle_names.append(bundle_name)
# This indicates that rollup is expected to generate a shared chunk file as
# well as one file per module. Set its name using --chunkFileNames. Note:
# Currently, this only supports 2 entry points, which generate 2 corresponding
# outputs and 1 shared output.
if (len(js_module_in_files) == 2):
shared_file_name = 'shared.rollup.js'
rollup_args += ['--chunkFileNames', shared_file_name]
bundled_paths.append(os.path.join(out_dir, shared_file_name))
bundle_names.append(os.path.join(bundle_dir_path, shared_file_name))
node.RunNode([node_modules.PathToRollup()] + rollup_args + [
'--format',
'esm',
'--dir',
out_dir,
'--entryFileNames',
'[name].rollup.js',
'--sourcemap',
'--sourcemapExcludeSources',
'--config',
rollup_config_file,
])
# Create the manifest file from the sourcemaps generated by rollup.
_generate_manifest_file(out_folder, bundled_paths, bundle_dir_path,
manifest_out_path)
for bundled_file in bundled_paths:
with open(bundled_file, 'r', encoding='utf-8') as f:
output = f.read()
assert "<if expr" not in output, \
'Unexpected <if expr> found in bundled output. Check that all ' + \
'input files using such expressions are preprocessed.'
return bundle_names
def _optimize(in_folder, args):
in_path = os.path.normpath(os.path.join(_CWD, in_folder)).replace('\\', '/')
out_path = os.path.join(_CWD, args.out_folder).replace('\\', '/')
manifest_out_path = _request_list_path(out_path, args.target_name)
excludes = [
# This file is dynamically created by C++. Should always be imported with
# a relative path.
'strings.m.js',
]
excludes.extend(args.exclude or [])
for exclude in excludes:
extension = os.path.splitext(exclude)[1]
assert extension == '.js', f'Unexpected |excludes| entry: {exclude}.' + \
' Only .js files can appear in |excludes|.'
external_paths = args.external_paths or []
if args.rollup_config:
# Use configuration provided from the caller.
rollup_config_file = args.rollup_config
else:
# Use default configuration.
bundle_dir_path = os.path.dirname(args.js_module_in_files[0])
rollup_config_file = _generate_rollup_config(out_path, in_path,
bundle_dir_path, args.host_url,
excludes, external_paths)
js_module_out_files = _bundle(out_path, in_path, manifest_out_path,
args.js_module_in_files, rollup_config_file)
return {
'manifest_out_path': manifest_out_path,
'js_module_out_files': js_module_out_files,
}
def main(argv):
parser = argparse.ArgumentParser()
parser.add_argument('--depfile', required=True)
parser.add_argument('--target_name', required=True)
parser.add_argument('--exclude', nargs='*')
parser.add_argument('--external_paths', nargs='*')
parser.add_argument('--host', required=True)
parser.add_argument('--input', required=True)
parser.add_argument('--out_folder', required=True)
parser.add_argument('--js_module_in_files', nargs='*', required=True)
parser.add_argument('--out-manifest')
parser.add_argument('--rollup_config')
args = parser.parse_args(argv)
# NOTE(dbeam): on Windows, GN can send dirs/like/this. When joined, you might
# get dirs/like/this\file.txt. This looks odd to windows. Normalize to right
# the slashes.
args.depfile = os.path.normpath(args.depfile)
args.input = os.path.normpath(args.input)
args.out_folder = os.path.normpath(args.out_folder)
scheme_end_index = args.host.find('://')
if (scheme_end_index == -1):
args.host_url = 'chrome://%s/' % args.host
else:
args.host_url = args.host
optimize_output = _optimize(args.input, args)
# Prior call to _optimize() generated an output manifest file, containing
# information about all files that were bundled. Grab it from there.
with open(optimize_output['manifest_out_path'], 'r', encoding='utf-8') as f:
manifest = json.loads(f.read())
# Output a manifest file that will be used to auto-generate a grd file
# later.
if args.out_manifest:
manifest_data = {
'base_dir': args.out_folder.replace('\\', '/'),
'files': list(manifest.keys()),
}
with open(
os.path.normpath(os.path.join(_CWD, args.out_manifest)),
'w',
newline='',
encoding='utf-8') as manifest_file:
json.dump(manifest_data, manifest_file)
dep_file_header = os.path.join(args.out_folder,
optimize_output['js_module_out_files'][0])
_update_dep_file(args.input, args, dep_file_header, manifest)
if __name__ == '__main__':
main(sys.argv[1:])