""" Wrapper around calling esbuild through a subprocess. """ import logging from pathlib import Path from typing import Any, Callable, Dict, List, Union from aws_lambda_builders.actions import ActionFailedError from aws_lambda_builders.workflows.nodejs_npm.utils import OSUtils from aws_lambda_builders.workflows.nodejs_npm_esbuild.exceptions import EsbuildCommandError, EsbuildExecutionError LOG = logging.getLogger(__name__) class SubprocessEsbuild(object): """ Wrapper around the Esbuild command line utility, making it easy to consume execution results. """ def __init__(self, osutils, executable_search_paths, which): """ :type osutils: aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils :param osutils: An instance of OS Utilities for file manipulation :type executable_search_paths: list :param executable_search_paths: List of paths to the NPM package binary utilities. This will be used to find embedded esbuild at runtime if present in the package :type which: aws_lambda_builders.utils.which :param which: Function to get paths which conform to the given mode on the PATH with the prepended additional search paths """ self.osutils = osutils self.executable_search_paths = executable_search_paths self.which = which def esbuild_binary(self): """ Finds the esbuild binary at runtime. The utility may be present as a package dependency of the Lambda project, or in the global path. If there is one in the Lambda project, it should be preferred over a global utility. The check has to be executed at runtime, since NPM dependencies will be installed by the workflow using one of the previous actions. """ LOG.debug("checking for esbuild in: %s", self.executable_search_paths) binaries = self.which("esbuild", executable_search_paths=self.executable_search_paths) LOG.debug("potential esbuild binaries: %s", binaries) if binaries: return binaries[0] else: raise EsbuildExecutionError( message="Cannot find esbuild. esbuild must be installed on the host machine to use this feature. " "It is recommended to be installed on the PATH, " "but can also be included as a project dependency." ) def run(self, args, cwd=None): """ Runs the action. :type args: list :param args: Command line arguments to pass to Esbuild :type cwd: str :param cwd: Directory where to execute the command (defaults to current dir) :rtype: str :return: text of the standard output from the command :raises aws_lambda_builders.workflows.nodejs_npm.npm.EsbuildExecutionError: when the command executes with a non-zero return code. The exception will contain the text of the standard error output from the command. :raises ValueError: if arguments are not provided, or not a list """ if not isinstance(args, list): raise ValueError("args must be a list") if not args: raise ValueError("requires at least one arg") invoke_esbuild = [self.esbuild_binary()] + args LOG.debug("executing Esbuild: %s", invoke_esbuild) p = self.osutils.popen(invoke_esbuild, stdout=self.osutils.pipe, stderr=self.osutils.pipe, cwd=cwd) out, err = p.communicate() if p.returncode != 0: raise EsbuildExecutionError(message=err.decode("utf8").strip()) return out.decode("utf8").strip() NON_CONFIGURABLE_VALUES = {"bundle", "platform", "outdir"} # Ignore the values below. These are options that Lambda Builders accepts for # Node.js related workflows, but are not relevant to esbuild itself. ESBUILD_IGNORE_VALUES = {"use_npm_ci", "entry_points"} class EsbuildCommandBuilder: ENTRY_POINTS = "entry_points" def __init__( self, scratch_dir: str, artifacts_dir: str, bundler_config: Dict[Any, Any], osutils: OSUtils, manifest: str ): self._scratch_dir = scratch_dir self._artifacts_dir = artifacts_dir self._bundler_config = bundler_config self._osutils = osutils self._manifest = manifest self._command: List[str] = [] def get_command(self) -> List[str]: """ Get all of the commands flags created by the command builder :rtype: List[str] :return: List of esbuild commands to be executed """ return self._command def build_esbuild_args_from_config(self) -> "EsbuildCommandBuilder": """ Build arguments configured in the command config (e.g. template.yaml) :rtype: EsbuildCommandBuilder :return: An instance of the command builder """ args = [] for config_key, config_value in self._bundler_config.items(): if config_key in NON_CONFIGURABLE_VALUES: LOG.debug( "'%s=%s' was not a used configuration since AWS Lambda Builders " "sets these values for the code to be correctly consumed by AWS Lambda", config_key, config_value, ) continue if config_key in ESBUILD_IGNORE_VALUES: continue configuration_type_callback = self._get_config_type_callback(config_value) LOG.debug("Configuring the parameter '%s=%s'", config_key, config_value) args.extend(configuration_type_callback(config_key, config_value)) LOG.debug("Found the following args in the config: %s", str(args)) self._command.extend(args) return self def _get_config_type_callback( self, config_value: Union[bool, str, list] ) -> Callable[[str, Union[bool, str, list]], List[str]]: """ Determines the type of the command and returns the corresponding function to build out that command line argument type :param config_value: Union[bool, str, list] The configuration value configured through the options. The configuration should be one of the supported types as defined by the esbuild API (https://esbuild.github.io/api/). :return: Callable[[str, Union[bool, str, list]], List[str]] Returns a function that the caller can use to turn the relevant configuration into the correctly formatted command line argument. """ if isinstance(config_value, bool): return self._create_boolean_config elif isinstance(config_value, str): return self._create_str_config elif isinstance(config_value, list): return self._create_list_config raise EsbuildCommandError("Failed to determine the type of the configuration: %s", config_value) def _create_boolean_config(self, config_key: str, config_value: bool) -> List[str]: """ Given boolean-type configuration, convert it to a string representation suitable for the esbuild API Should be created in the form ([--config-key]) :param config_key: str The configuration key to be used :param config_value: bool The configuration value to be used :return: List[str] List of resolved command line arguments to be appended to the builder """ if config_value is True: return [f"--{self._convert_snake_to_kebab_case(config_key)}"] return [] def _create_str_config(self, config_key: str, config_value: str) -> List[str]: """ Given string-type configuration, convert it to a string representation suitable for the esbuild API Should be created in the form ([--config-key=config_value]) :param config_key: str The configuration key to be used :param config_value: List[str] The configuration value to be used :return: List[str] List of resolved command line arguments to be appended to the builder """ return [f"--{self._convert_snake_to_kebab_case(config_key)}={config_value}"] def _create_list_config(self, config_key: str, config_value: List[str]) -> List[str]: """ Given list-type configuration, convert it to a string representation suitable for the esbuild API Should be created in the form ([--config-key:config_value_a, --config_key:config_value_b]) :param config_key: str The configuration key to be used :param config_value: List[str] The configuration value to be used :return: List[str] List of resolved command line arguments to be appended to the builder """ args = [] for config_item in config_value: args.append(f"--{self._convert_snake_to_kebab_case(config_key)}:{config_item}") return args def build_entry_points(self) -> "EsbuildCommandBuilder": """ Build the entry points to the command :rtype: EsbuildCommandBuilder :return: An instance of the command builder """ if self.ENTRY_POINTS not in self._bundler_config: raise EsbuildCommandError(f"{self.ENTRY_POINTS} not set ({self._bundler_config})") entry_points = self._bundler_config[self.ENTRY_POINTS] if not isinstance(entry_points, list): raise EsbuildCommandError(f"{self.ENTRY_POINTS} must be a list ({self._bundler_config})") if not entry_points: raise EsbuildCommandError(f"{self.ENTRY_POINTS} must not be empty ({self._bundler_config})") entry_paths = [self._osutils.joinpath(self._scratch_dir, entry_point) for entry_point in entry_points] LOG.debug("NODEJS building %s using esbuild to %s", entry_paths, self._artifacts_dir) for entry_path, entry_point in zip(entry_paths, entry_points): self._command.append(self._get_explicit_file_type(entry_point, entry_path)) return self def build_default_values(self) -> "EsbuildCommandBuilder": """ Build the default values that each call to esbuild should contain :rtype: EsbuildCommandBuilder :return: An instance of the command builder """ args = ["--bundle", "--platform=node", "--outdir={}".format(self._artifacts_dir)] if "target" not in self._bundler_config: args.append("--target=es2020") if "format" not in self._bundler_config: args.append("--format=cjs") if "minify" not in self._bundler_config: args.append("--minify") LOG.debug("Using the following default args: %s", str(args)) self._command.extend(args) return self def build_with_no_dependencies(self) -> "EsbuildCommandBuilder": """ Set all dependencies located in the package.json to external so as to not bundle them with the source code :rtype: EsbuildCommandBuilder :return: An instance of the command builder """ package = self._osutils.parse_json(self._manifest) dependencies = package.get("dependencies", {}).keys() args = ["--external:{}".format(dep) for dep in dependencies] self._command.extend(args) return self def _get_explicit_file_type(self, entry_point, entry_path): """ Get an entry point with an explicit .ts or .js suffix. :type entry_point: str :param entry_point: path to entry file from code uri :type entry_path: str :param entry_path: full path of entry file :rtype: str :return: entry point with appropriate file extension :raises lambda_builders.actions.ActionFailedError: when esbuild packaging fails """ if Path(entry_point).suffix: if self._osutils.file_exists(entry_path): return entry_point raise ActionFailedError("entry point {} does not exist".format(entry_path)) for ext in [".ts", ".js"]: entry_path_with_ext = entry_path + ext if self._osutils.file_exists(entry_path_with_ext): return entry_point + ext raise ActionFailedError("entry point {} does not exist".format(entry_path)) @staticmethod def _convert_snake_to_kebab_case(arg: str) -> str: """ The configuration properties passed down to Lambda Builders are done so using snake case e.g. "main_fields" but esbuild expects them using kebab-case "main-fields" :rtype: str :return: mutated string to match the esbuild argument format """ return arg.replace("_", "-")