From 1fff5c605f972ff8fdf627408edc4f60a111fce5 Mon Sep 17 00:00:00 2001 From: Matt Mackay Date: Fri, 23 Oct 2020 14:05:01 -0400 Subject: [PATCH] feat: add esbuild package --- WORKSPACE | 5 + docs/BUILD.bazel | 1 + packages/esbuild/BUILD.bazel | 81 +++++++ packages/esbuild/_README.md | 60 +++++ packages/esbuild/esbuild.bzl | 207 ++++++++++++++++++ packages/esbuild/esbuild_repo.bzl | 69 ++++++ packages/esbuild/index.bzl | 33 +++ packages/esbuild/package.json | 17 ++ packages/esbuild/test/bundle/BUILD.bazel | 67 ++++++ packages/esbuild/test/bundle/a.ts | 3 + .../esbuild/test/bundle/bundle.golden.txt | 8 + .../esbuild/test/bundle/bundle.min.golden.txt | 2 + packages/esbuild/test/bundle/index.ts | 1 + packages/esbuild/test/splitting/BUILD.bazel | 30 +++ .../esbuild/test/splitting/bundle.spec.js | 15 ++ packages/esbuild/test/splitting/main.ts | 1 + 16 files changed, 600 insertions(+) create mode 100644 packages/esbuild/BUILD.bazel create mode 100644 packages/esbuild/_README.md create mode 100644 packages/esbuild/esbuild.bzl create mode 100644 packages/esbuild/esbuild_repo.bzl create mode 100644 packages/esbuild/index.bzl create mode 100644 packages/esbuild/package.json create mode 100644 packages/esbuild/test/bundle/BUILD.bazel create mode 100644 packages/esbuild/test/bundle/a.ts create mode 100644 packages/esbuild/test/bundle/bundle.golden.txt create mode 100644 packages/esbuild/test/bundle/bundle.min.golden.txt create mode 100644 packages/esbuild/test/bundle/index.ts create mode 100644 packages/esbuild/test/splitting/BUILD.bazel create mode 100644 packages/esbuild/test/splitting/bundle.spec.js create mode 100644 packages/esbuild/test/splitting/main.ts diff --git a/WORKSPACE b/WORKSPACE index a904d52f65..200396d3bd 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -155,6 +155,11 @@ browser_repositories( firefox = True, ) +# Setup esbuild dependencies +load("//packages/esbuild:index.bzl", "esbuild_repository") + +esbuild_repository(name = "esbuild") + # # Dependencies to run stardoc & generating documentation # diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index e25b8eaf58..c7b61aae2a 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -81,6 +81,7 @@ rules_nodejs_docs( "Rollup": "//packages/rollup:README.md", "Terser": "//packages/terser:README.md", "TypeScript": "//packages/typescript:README.md", + "esbuild": "//packages/esbuild:README.md", }, tags = [ "fix-windows", diff --git a/packages/esbuild/BUILD.bazel b/packages/esbuild/BUILD.bazel new file mode 100644 index 0000000000..774cd930a9 --- /dev/null +++ b/packages/esbuild/BUILD.bazel @@ -0,0 +1,81 @@ +# Copyright 2020 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("@build_bazel_rules_nodejs//:tools/defaults.bzl", "codeowners", "pkg_npm") +load("@build_bazel_rules_nodejs//tools/stardoc:index.bzl", "stardoc") +load("//third_party/github.com/bazelbuild/bazel-skylib:rules/copy_file.bzl", "copy_file") + +package(default_visibility = ["//visibility:public"]) + +codeowners( + teams = ["@mattem"], +) + +bzl_library( + name = "bzl", + srcs = glob(["**/*.bzl"]), + deps = [ + "@build_bazel_rules_nodejs//:bzl", + "@build_bazel_rules_nodejs//internal/common:bzl", + "@build_bazel_rules_nodejs//internal/node:bzl", + ], +) + +stardoc( + name = "docs", + testonly = True, + out = "index.md", + input = "index.bzl", + tags = ["fix-windows"], + deps = [":bzl"], +) + +genrule( + name = "generate_README", + srcs = [ + "_README.md", + "index.md", + ], + outs = ["README.md"], + cmd = """cat $(execpath _README.md) $(execpath index.md) | sed 's/^##/\\\n##/' > $@""", + tags = ["fix-windows"], + visibility = ["//docs:__pkg__"], +) + +copy_file( + name = "npm_version_check", + src = "//internal:npm_version_check.js", + out = ":npm_version_check.js", +) + +pkg_npm( + name = "npm_package", + srcs = [ + "esbuild.bzl", + "esbuild_repo.bzl", + "index.bzl", + "package.json", + ], + build_file_content = " ", + deps = [ + ":npm_version_check", + ] + select({ + # FIXME: fix stardoc on Windows; //packages/karma:index.md generation fails with: + # ERROR: D:/b/62unjjin/external/npm_bazel_karma/BUILD.bazel:65:1: Couldn't build file + # external/npm_bazel_karma/docs.raw: Generating proto for Starlark doc for docs failed (Exit 1) + "@bazel_tools//src/conditions:windows": [], + "//conditions:default": [":generate_README"], + }), +) diff --git a/packages/esbuild/_README.md b/packages/esbuild/_README.md new file mode 100644 index 0000000000..0a4805a20c --- /dev/null +++ b/packages/esbuild/_README.md @@ -0,0 +1,60 @@ +# esbuild rules for Bazel + +The esbuild rules runs the [esbuild](https://github.com/evanw/esbuild) bundler tool with Bazel. + +## Installation + +Add the `@bazel/esbuild` npm packages to your `devDependencies` in `package.json`. + +``` +npm install --save-dev @bazel/esbuild +``` +or using yarn +``` +yarn add -D @bazel/esbuild +``` + +Add the esbuild repository to the `WORKSPACE` file + +```python +load("@npm//@bazel/esbuild:index.bzl", "esbuild_repository") + +esbuild_repository(name = "esbuild") +``` + +The version of `esbuild` can be specified by passing the `version` and `platform_sha` attributes + +```python +load("@npm//@bazel/esbuild:index.bzl", "esbuild_repository") + +esbuild_repository( + name = "esbuild", + version = "0.7.19", + platform_sha = { + "darwin_64": "deadf43c0868430983234f90781e1b542975a2bc3549b2353303fac236816149", + "linux_64": "2d25ad82dba8f565e8766b838acd3b966f9a2417499105ec10afb01611594ef1", + "windows_64": "135b2ff549d4b1cfa4f8e2226f85ee97641b468aaced7585112ebe8c0af2d766", + } +) +``` + +## Example use of esbuild + +The `esbuild` rule can take a JS or TS dependency tree and bundle it to a single file, or split across multiple files, outputting a directory. + +```python +ts_library( + name = "lib", + srcs = ["a.ts"], +) + +esbuild( + name = "bundle", + entry_point = "a.ts", + deps = [":lib"], +) +``` + +The above will create three output files, `bundle.js`, `bundle.js.map` and `bundle_metadata.json` which contains the bundle metadata to aid in debugging and resoloution tracing. + +Further options for minifying and splitting are avialable. \ No newline at end of file diff --git a/packages/esbuild/esbuild.bzl b/packages/esbuild/esbuild.bzl new file mode 100644 index 0000000000..b87e703172 --- /dev/null +++ b/packages/esbuild/esbuild.bzl @@ -0,0 +1,207 @@ +""" +esbuild rule +""" + +load("@build_bazel_rules_nodejs//:providers.bzl", "JSEcmaScriptModuleInfo", "JSModuleInfo", "NpmPackageInfo", "node_modules_aspect") +load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "module_mappings_aspect") + +def _strip_ext(f): + return f.short_path[:-len(f.extension) - 1] + +def _resolve_js_input(f, inputs): + if f.extension == "js" or f.extension == "mjs": + return f + + no_ext = _strip_ext(f) + for i in inputs: + if i.extension == "js" or i.extension == "mjs": + if _strip_ext(i) == no_ext: + return i + fail("Could not find corresponding javascript entry point for %s. Add the %s.js to your deps." % (f.path, no_ext)) + +def _filter_js(files): + return [f for f in files if f.extension == "js" or f.extension == "mjs"] + +def _esbuild_impl(ctx): + # For each dep, JSEcmaScriptModuleInfo is used if found, then JSModuleInfo and finally + # the DefaultInfo files are used if the former providers are not found. + deps_depsets = [] + for dep in ctx.attr.deps: + if JSEcmaScriptModuleInfo in dep: + deps_depsets.append(dep[JSEcmaScriptModuleInfo].sources) + elif JSModuleInfo in dep: + deps_depsets.append(dep[JSModuleInfo].sources) + elif hasattr(dep, "files"): + deps_depsets.append(dep.files) + + if NpmPackageInfo in dep: + deps_depsets.append(dep[NpmPackageInfo].sources) + + deps_inputs = depset(transitive = deps_depsets).to_list() + inputs = _filter_js(ctx.files.entry_point) + ctx.files.srcs + deps_inputs + + metafile = ctx.actions.declare_file("%s_metadata.json" % ctx.attr.name) + outputs = [metafile] + + entry_point = _resolve_js_input(ctx.file.entry_point, inputs) + + args = ctx.actions.args() + args.add("--bundle", entry_point.path) + args.add("--sourcemap") + args.add_joined(["--platform", ctx.attr.platform], join_with = "=") + args.add_joined(["--target", ctx.attr.target], join_with = "=") + args.add_joined(["--log-level", "info"], join_with = "=") + args.add_joined(["--metafile", metafile.path], join_with = "=") + args.add_joined(["--define:process.env.NODE_ENV", '"production"'], join_with = "=") + + # disable the error limit and show all errors + args.add_joined(["--error-limit", "0"], join_with = "=") + + if ctx.attr.splitting: + js_out = ctx.actions.declare_directory("%s" % ctx.attr.name) + outputs.append(js_out) + + args.add("--splitting") + args.add_joined(["--format", "esm"], join_with = "=") + args.add_joined(["--outdir", js_out.path], join_with = "=") + else: + js_out = ctx.outputs.output + js_out_map = ctx.outputs.output_map + outputs.extend([js_out, js_out_map]) + + if ctx.attr.format: + args.add_joined(["--format", ctx.attr.format], join_with = "=") + + args.add_joined(["--outfile", js_out.path], join_with = "=") + + if len(ctx.attr.module_mappings): + rel_path_to_root = "/".join([".." for seg in ctx.build_file_path.split("/")[:-1]]) + + # generate a tsconfig.json file with module mappings + # we might be able to figure these out from the DeclarationInfo on the deps? + tsconfig = """{"compilerOptions":{"baseUrl":"%s","rootDirs":["."],"paths":%s}}""" % (rel_path_to_root, ctx.attr.module_mappings) + + tsconfig_file = ctx.actions.declare_file("%s_tsconfig.json" % ctx.attr.name) + inputs.append(tsconfig_file) + ctx.actions.write(tsconfig_file, tsconfig) + + args.add_joined(["--tsconfig", tsconfig_file.path], join_with = "=") + + for ext in ctx.attr.external: + args.add("--external:%s" % ext) + + if ctx.attr.minify: + args.add("--minify") + + ctx.actions.run( + inputs = inputs, + outputs = outputs, + executable = ctx.executable.esbuild, + arguments = [args], + progress_message = "%s Javascript %s [esbuild]" % ("Bundling" if not ctx.attr.splitting else "Splitting", entry_point.short_path), + execution_requirements = { + "no-remote-exec": "1", + }, + ) + + return [ + DefaultInfo(files = depset(outputs)), + ] + +esbuild_bundle = rule( + attrs = { + "deps": attr.label_list( + aspects = [module_mappings_aspect, node_modules_aspect], + doc = "A list of direct dependencies that are required to build the bundle", + ), + "entry_point": attr.label( + mandatory = True, + allow_single_file = True, + doc = "The bundle's entry point (e.g. your main.js or app.js or index.js)", + ), + "esbuild": attr.label( + allow_single_file = True, + default = "@esbuild//:bin/esbuild", + executable = True, + cfg = "exec", + doc = "An executable for the esbuild binary, can be overriden if a custom esbuild binary is needed", + ), + "external": attr.string_list( + default = [], + doc = "A list of module names that are treated as external and not included in the resulting bundle", + ), + "format": attr.string( + values = ["iife", "cjs", "esm", ""], + mandatory = False, + doc = """The output format of the bundle, defaults to iife when platform is browser +and cjs when platform is node. If performing code splitting, defaults to esm""", + ), + "minify": attr.bool( + default = False, + doc = "If true, produce a minified output", + ), + "module_mappings": attr.string_list_dict( + doc = """A tsconfig style mapping of module names to paths for module import resolution. +To import from 'lib', set the module mapping to the path to the files: + +```python +module_mappings = { + "lib": ["path/to/module"], +}, +```""", + ), + "output": attr.output( + mandatory = False, + doc = "Name of the output file when bundling", + ), + "output_map": attr.output( + mandatory = False, + doc = "Name of the output source map when bundling", + ), + "platform": attr.string( + default = "browser", + values = ["node", "browser", ""], + doc = "The platform to bundle for", + ), + "splitting": attr.bool( + default = False, + doc = """If true, esbuild produces an output directory containing all the output files from code splitting + """, + ), + "srcs": attr.label_list( + allow_files = True, + default = [], + doc = """Non-entry point JavaScript source files from the workspace. + +You must not repeat file(s) passed to entry_point""", + ), + "target": attr.string( + default = "es2015", + doc = "Language target for esbuild", + ), + }, + implementation = _esbuild_impl, +) + +def esbuild(name, splitting = False, **kwargs): + """esbuild helper macro around the `esbuild_bundle` rule + + Args: + name: The name used for this rule and output files + splitting: If `True`, produce a code split bundle in an output directory + **kwargs: All other args from `esbuild_bundle` + """ + + if splitting == True: + esbuild_bundle( + name = name, + splitting = True, + **kwargs + ) + else: + esbuild_bundle( + name = name, + output = "%s.js" % name, + output_map = "%s.js.map" % name, + **kwargs + ) diff --git a/packages/esbuild/esbuild_repo.bzl b/packages/esbuild/esbuild_repo.bzl new file mode 100644 index 0000000000..f685c5c8ec --- /dev/null +++ b/packages/esbuild/esbuild_repo.bzl @@ -0,0 +1,69 @@ +""" +Repository rule for downloading a version of esbuild for the current platform +""" + +_PLATFORM_SHA = { + "darwin_64": "deadf43c0868430983234f90781e1b542975a2bc3549b2353303fac236816149", + "linux_64": "2d25ad82dba8f565e8766b838acd3b966f9a2417499105ec10afb01611594ef1", + "windows_64": "135b2ff549d4b1cfa4f8e2226f85ee97641b468aaced7585112ebe8c0af2d766", +} + +_VERSION = "0.7.19" + +def _esbuild_repository_impl(rctx): + platform_sha = rctx.attr.platform_sha + version = rctx.attr.version + + URLS = { + "linux": { + "sha": platform_sha["linux_64"], + "url": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-%s.tgz" % version, + }, + "mac os": { + "sha": platform_sha["darwin_64"], + "url": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-%s.tgz" % version, + }, + "windows": { + "sha": platform_sha["windows_64"], + "url": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-%s.tgz" % version, + }, + } + + os_name = rctx.os.name.lower() + if os_name.startswith("mac os"): + value = URLS["mac os"] + elif os_name.find("windows") != -1: + value = URLS["windows"] + elif os_name.startswith("linux"): + value = URLS["linux"] + else: + fail("Unsupported operating system: " + os_name) + + rctx.download_and_extract( + value["url"], + sha256 = value["sha"], + stripPrefix = "package", + ) + + BUILD_FILE_CONTENT = """exports_files(["bin/esbuild"])""" + + rctx.file("BUILD", content = BUILD_FILE_CONTENT) + +esbuild_repository = repository_rule( + implementation = _esbuild_repository_impl, + attrs = { + "platform_sha": attr.string_dict( + default = _PLATFORM_SHA, + doc = """A dict mapping the platform to the SHA256 sum for that platforms esbuild package +The following platforms and archs are supported: +* darwin_64 +* linux_64 +* windows_64 + """, + ), + "version": attr.string( + default = _VERSION, + doc = "The version of esbuild to use", + ), + }, +) diff --git a/packages/esbuild/index.bzl b/packages/esbuild/index.bzl new file mode 100644 index 0000000000..e12762f44f --- /dev/null +++ b/packages/esbuild/index.bzl @@ -0,0 +1,33 @@ +# Copyright 2020 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Public API surface is re-exported here. +""" + +load( + "@build_bazel_rules_nodejs//packages/esbuild:esbuild.bzl", + _esbuild = "esbuild", + _esbuild_bundle = "esbuild_bundle", +) +load( + "@build_bazel_rules_nodejs//packages/esbuild:esbuild_repo.bzl", + _esbuild_repository = "esbuild_repository", +) + +esbuild = _esbuild +esbuild_bundle = _esbuild_bundle +esbuild_repository = _esbuild_repository + +# DO NOT ADD MORE rules here unless they appear in the generated docsite. +# Run yarn stardoc to re-generate the docsite. diff --git a/packages/esbuild/package.json b/packages/esbuild/package.json new file mode 100644 index 0000000000..23ba1748b0 --- /dev/null +++ b/packages/esbuild/package.json @@ -0,0 +1,17 @@ +{ + "name": "@bazel/esbuild", + "description": "esbuild rules for Bazel", + "license": "Apache-2.0", + "version": "0.0.0-PLACEHOLDER", + "repository": { + "type": "git", + "url": "https://github.com/bazelbuild/rules_nodejs.git", + "directory": "packages/esbuild" + }, + "bugs": { + "url": "https://github.com/bazelbuild/rules_nodejs/issues" + }, + "keywords": [ + "bazel" + ] +} diff --git a/packages/esbuild/test/bundle/BUILD.bazel b/packages/esbuild/test/bundle/BUILD.bazel new file mode 100644 index 0000000000..9fd713fb37 --- /dev/null +++ b/packages/esbuild/test/bundle/BUILD.bazel @@ -0,0 +1,67 @@ +load("//:index.bzl", "generated_file_test") +load("//packages/esbuild:index.bzl", "esbuild") +load("//packages/typescript:index.bzl", "ts_library") + +ts_library( + name = "index", + srcs = ["index.ts"], + module_name = "lib", +) + +ts_library( + name = "lib", + srcs = ["a.ts"], + deps = [":index"], +) + +esbuild( + name = "bundle", + entry_point = "a.ts", + module_mappings = { + "lib": ["packages/esbuild/test/bundle"], + }, + deps = [":lib"], +) + +esbuild( + name = "bundle.min", + entry_point = "a.ts", + minify = True, + module_mappings = { + "lib": ["packages/esbuild/test/bundle"], + }, + deps = [":lib"], +) + +esbuild( + name = "bundle.split", + entry_point = "a.ts", + module_mappings = { + "lib": ["packages/esbuild/test/bundle"], + }, + splitting = True, + deps = [":lib"], +) + +# esbuild will put the filepath in a comment within the non-minified file, +# on different platforms this will cause the golden test to differ +# strip them here +# note that this regex doesn't strip the sourcemap URI comment +genrule( + name = "strip_bundle_comments", + srcs = ["bundle.js"], + outs = ["bundle.stripped.js"], + cmd = "cat $(location :bundle.js) | sed \"s#// .*##\" > $@", +) + +generated_file_test( + name = "bundle_test", + src = "bundle.golden.txt", + generated = "bundle.stripped.js", +) + +generated_file_test( + name = "bundle_min_test", + src = "bundle.min.golden.txt", + generated = "bundle.min.js", +) diff --git a/packages/esbuild/test/bundle/a.ts b/packages/esbuild/test/bundle/a.ts new file mode 100644 index 0000000000..4979aafe0b --- /dev/null +++ b/packages/esbuild/test/bundle/a.ts @@ -0,0 +1,3 @@ +import {NAME} from 'lib'; + +console.log(`Hello ${NAME}`); \ No newline at end of file diff --git a/packages/esbuild/test/bundle/bundle.golden.txt b/packages/esbuild/test/bundle/bundle.golden.txt new file mode 100644 index 0000000000..7d7eca0a28 --- /dev/null +++ b/packages/esbuild/test/bundle/bundle.golden.txt @@ -0,0 +1,8 @@ +(() => { + + const NAME = "bazel"; + + + console.log(`Hello ${NAME}`); +})(); +//# sourceMappingURL=bundle.js.map diff --git a/packages/esbuild/test/bundle/bundle.min.golden.txt b/packages/esbuild/test/bundle/bundle.min.golden.txt new file mode 100644 index 0000000000..aeb1af43a3 --- /dev/null +++ b/packages/esbuild/test/bundle/bundle.min.golden.txt @@ -0,0 +1,2 @@ +(()=>{const i="bazel";console.log(`Hello ${i}`);})(); +//# sourceMappingURL=bundle.min.js.map diff --git a/packages/esbuild/test/bundle/index.ts b/packages/esbuild/test/bundle/index.ts new file mode 100644 index 0000000000..6d6d9837ea --- /dev/null +++ b/packages/esbuild/test/bundle/index.ts @@ -0,0 +1 @@ +export const NAME = 'bazel'; diff --git a/packages/esbuild/test/splitting/BUILD.bazel b/packages/esbuild/test/splitting/BUILD.bazel new file mode 100644 index 0000000000..3d20543207 --- /dev/null +++ b/packages/esbuild/test/splitting/BUILD.bazel @@ -0,0 +1,30 @@ +load("//:index.bzl", "nodejs_test") +load("//packages/esbuild:index.bzl", "esbuild") +load("//packages/typescript:index.bzl", "ts_library") + +ts_library( + name = "main", + srcs = ["main.ts"], + deps = [ + "@npm//@angular/core", + ], +) + +esbuild( + name = "bundle", + entry_point = "main.ts", + splitting = True, + deps = [":main"], +) + +nodejs_test( + name = "bundle_test", + data = [ + "bundle.spec.js", + ":bundle", + ], + entry_point = ":bundle.spec.js", + templated_args = [ + "$(locations :bundle)", + ], +) diff --git a/packages/esbuild/test/splitting/bundle.spec.js b/packages/esbuild/test/splitting/bundle.spec.js new file mode 100644 index 0000000000..af21254772 --- /dev/null +++ b/packages/esbuild/test/splitting/bundle.spec.js @@ -0,0 +1,15 @@ +const {join} = require('path'); +const {readFileSync, lstatSync} = require('fs'); + +const location = process.argv[2]; +const main = readFileSync(join('..', location, 'main.js'), {encoding: 'utf8'}); +const hasImportOfCore = main.indexOf(`import("./core.js")`) > -1; + +if (!hasImportOfCore) { + console.error(`Expected entry_point 'main.js' to have an import of './core.js'`); +} + +// throws if file does not exist +lstatSync(join('..', location, 'core.js')); + +process.exit(hasImportOfCore ? 0 : 1); \ No newline at end of file diff --git a/packages/esbuild/test/splitting/main.ts b/packages/esbuild/test/splitting/main.ts new file mode 100644 index 0000000000..29546656eb --- /dev/null +++ b/packages/esbuild/test/splitting/main.ts @@ -0,0 +1 @@ +import('@angular/core').then(core => {console.log(core.VERSION);}); \ No newline at end of file