Skip to content

Commit

Permalink
feat(bazel): introduce rule for bundling specs with optional angular …
Browse files Browse the repository at this point in the history
…linker

We exposea macro for bundling Bazel targets with spec files into a single
spec IIFE/AMD IIFE file. Bundling is helpful as it avoids unnecessary complexity
with module resolution at runtime with loaders such as SystemJS or RequireJS.

Additionally, given that Angular framework packages do no longer ship UMD bundles,
bundling simplifies the integration of those FW packages significantly. It also helps
with incorporating the Angular linker-processed output of FW ESM bundles.
  • Loading branch information
devversion committed Nov 16, 2021
1 parent 32a04e0 commit e67feed
Show file tree
Hide file tree
Showing 15 changed files with 764 additions and 12 deletions.
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pkg_npm(
"tsconfig.json",
":index.bzl",
"//bazel:static_files",
"//shared-scripts:static_files",
],
substitutions = NPM_PACKAGE_SUBSTITUTIONS,
deps = [
Expand Down
1 change: 1 addition & 0 deletions bazel/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ filegroup(
"//bazel/esbuild:files",
"//bazel/integration:files",
"//bazel/remote-execution:files",
"//bazel/spec-bundling:files",
],
visibility = ["//:npm"],
)
9 changes: 9 additions & 0 deletions bazel/spec-bundling/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package(default_visibility = ["//visibility:public"])

exports_files(["esbuild.config-tmpl.mjs"])

# Make source files available for distribution via pkg_npm
filegroup(
name = "files",
srcs = glob(["*"]),
)
27 changes: 27 additions & 0 deletions bazel/spec-bundling/bundle-config.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
def _spec_bundle_config_file_impl(ctx):
ctx.actions.expand_template(
template = ctx.file._template,
output = ctx.outputs.output_name,
substitutions = {
"TMPL_RUN_LINKER": "true" if ctx.attr.run_angular_linker else "false",
},
)

spec_bundle_config_file = rule(
implementation = _spec_bundle_config_file_impl,
doc = "Generates an ESBuild configuration file for bundling specs",
attrs = {
"run_angular_linker": attr.bool(
doc = "Whether the Angular linker should process all files.",
default = False,
),
"output_name": attr.output(
mandatory = True,
doc = "Name of the file where the config should be written to.",
),
"_template": attr.label(
allow_single_file = True,
default = "//bazel/spec-bundling:esbuild.config-tmpl.mjs",
),
},
)
36 changes: 36 additions & 0 deletions bazel/spec-bundling/esbuild.config-tmpl.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

/**
* Loads and creates the ESBuild linker plugin.
*
* The plugin is not loaded at top-level as not all spec bundle targets rely
* on the linker and this would slow-down bundling.
*/
async function fetchAndCreateLinkerEsbuildPlugin() {
// Note: This needs to be a NPM module path as this ESBuild config is generated and can
// end up in arbitrary Bazel packages or differently-named consumer workspaces.
const {createLinkerEsbuildPlugin} = await import(
'@angular/dev-infra-private/shared-scripts/angular-linker/esbuild-plugin.mjs'
);
return await createLinkerEsbuildPlugin(/.*/, /* ensureNoPartialDeclaration */ true);
}

// Based on the Bazel action and its substitutions, we run the linker for all inputs.
const plugins = TMPL_RUN_LINKER ? [await fetchAndCreateLinkerEsbuildPlugin()] : [];

export default {
// `tslib` sets the `module` condition to resolve to ESM.
conditions: ['es2020', 'es2015', 'module'],
// This ensures that we prioritize ES2020. RxJS would otherwise use the ESM5 output.
mainFields: ['es2020', 'es2015', 'module', 'main'],
// Use the `iife` format for the test entry-point as tests should run immediately.
// For browser tests which are wrapped in an AMD header and footer, this works as well.
format: 'iife',
plugins,
};
83 changes: 83 additions & 0 deletions bazel/spec-bundling/index.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//bazel/esbuild:index.bzl", "esbuild", "esbuild_amd", "esbuild_config")
load("//bazel/spec-bundling:spec-entrypoint.bzl", "spec_entrypoint")
load("//bazel/spec-bundling:bundle-config.bzl", "spec_bundle_config_file")

"""
Starlark file exposing a macro for bundling Bazel targets with spec files into
a single spec ESM/AMD file. Bundling is helpful as it avoids unnecessary complexity
with module resolution at runtime with loaders such as SystemJS or RequireJS.
Additionally, given that Angular framework packages do no longer ship UMD bundles,
bundling simplifies the integration of those FW packages significantly. It also helps
with incorporating Angular linker-processed output of library ESM files.
"""

def spec_bundle(
name,
deps,
platform,
run_angular_linker = False,
# We cannot use `ES2017` or higher as that would result in `async/await` not being downleveled.
# ZoneJS needs to be able to intercept these as otherwise change detection would not work properly.
target = "es2016",
workspace_name = None,
**kwargs):
"""
Macro that will bundle all test files, with their respective transitive dependencies,
into a single bundle file that can be loaded within Karma or NodeJS directly. Test files
are bundled as Angular framework packages do not ship UMD files and to avoid overall
complexity with maintaining a runtime loader such as RequireJS or SystemJS.
"""

is_browser_test = platform == "browser"
package_name = native.package_name()

spec_entrypoint(
name = "%s_spec_entrypoint" % name,
deps = deps,
testonly = True,
)

spec_bundle_config_file(
name = "%s_config_file" % name,
testonly = True,
output_name = "%s_config.mjs" % name,
run_angular_linker = run_angular_linker,
)

esbuild_config(
name = "%s_config" % name,
config_file = ":%s_config_file" % name,
testonly = True,
deps = ["//shared-scripts/angular-linker:js_lib"],
)

if is_browser_test and not workspace_name:
fail("The spec-bundling target %s is declared as browser test. In order to be able " +
"to construct an AMD module name, the `workspace_name` attribute needs to be set.")

# Browser tests (Karma) need named AMD modules to load.
# TODO(devversion): consider updating `@bazel/concatjs` to support loading JS files directly.
esbuild_rule = esbuild_amd if is_browser_test else esbuild
amd_name = "%s/%s/%s" % (workspace_name, package_name, name + "_spec") if is_browser_test else None

esbuild_rule(
name = "%s_bundle" % name,
testonly = True,
config = ":%s_config" % name,
entry_point = ":%s_spec_entrypoint" % name,
module_name = amd_name,
output = "%s_spec.js" % name,
target = target,
platform = platform,
deps = deps + [":%s_spec_entrypoint" % name],
link_workspace_root = True,
**kwargs
)

js_library(
name = name,
testonly = True,
named_module_srcs = [":%s_bundle" % name],
)
68 changes: 68 additions & 0 deletions bazel/spec-bundling/spec-entrypoint.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
load("@build_bazel_rules_nodejs//:providers.bzl", "JSModuleInfo")

def _is_spec_file(file):
"""Gets whether the given file is a spec file."""
basename = file.basename

# External files (from other workspaces) should never run as specs.
if (file.short_path.startswith("../")):
return False

# `spec.js` or `spec.mjs` files will be imported in the entry-point.
return basename.endswith("spec.js") or basename.endswith("spec.mjs")

def _filter_spec_files(files):
"""Filters the given list of files to only contain spec files."""
result = []
for file in files:
if _is_spec_file(file):
result.append(file)
return result

def _create_entrypoint_file(base_package, spec_files):
"""Creates the contents of the spec entry-point ESM file which imports
all individual spec files so that these are bundled and loaded by Node/Karma."""
output = ""
for file in spec_files:
base_dir_segments = "/".join([".."] * len(base_package.split("/")))
output += """import "%s/%s";\n""" % (base_dir_segments, file.short_path)
return output

def _spec_entrypoint_impl(ctx):
output = ctx.actions.declare_file("%s.mjs" % ctx.attr.name)
spec_depsets = []

for dep in ctx.attr.deps:
if JSModuleInfo in dep:
spec_depsets.append(dep[JSModuleInfo].sources)
else:
spec_depsets.append(dep[DefaultInfo].files)

spec_files = []

for spec_depset in spec_depsets:
# Note: `to_list()` is an expensive operation but we need to do this for every
# dependency here in order to be able to filter out spec files from depsets.
spec_files.extend(_filter_spec_files(spec_depset.to_list()))

ctx.actions.write(
output = output,
content = _create_entrypoint_file(ctx.label.package, spec_files),
)

out_depset = depset([output])

return [
DefaultInfo(files = out_depset),
JSModuleInfo(
direct_sources = out_depset,
sources = depset(transitive = [out_depset] + spec_depsets),
),
]

spec_entrypoint = rule(
implementation = _spec_entrypoint_impl,
attrs = {
"deps": attr.label_list(allow_files = False, mandatory = True),
},
)
25 changes: 25 additions & 0 deletions bazel/spec-bundling/test/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
load("//bazel/spec-bundling:index.bzl", "spec_bundle")
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")

ts_library(
name = "test_lib",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
"@npm//@angular/core",
],
)

spec_bundle(
name = "test_bundle",
platform = "node",
run_angular_linker = True,
deps = [":test_lib"],
)

jasmine_node_test(
name = "test",
specs = [
":test_bundle",
],
)
24 changes: 24 additions & 0 deletions bazel/spec-bundling/test/core_apf_esm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// This is an ESM import that would usually break within `jasmine_node_test` because it
// consumes devmode CommonJS sources and `rules_nodejs` does not support ESM well yet.
import {VERSION} from '@angular/core';
import * as core from '@angular/core';

describe('@angular/core ESM import', () => {
it('should work', () => {
expect(VERSION.major).toBeGreaterThanOrEqual(13);
});

it('should have run the linker', () => {
expect(() => {
class TestCmp {}
core.ɵɵngDeclareComponent({
version: '0.0.0',
minVersion: '12.0.0',
type: TestCmp,
selector: 'test',
ngImport: core,
template: `<span>Test template</span>`,
} as any);
}).not.toThrow();
});
});
1 change: 1 addition & 0 deletions package.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ basePackageSubstitutions = {
"//tslint-rules/": "@npm//@angular/dev-infra-private/tslint-rules/",
"//tslint-rules:": "@npm//@angular/dev-infra-private/tslint-rules:",
"//:tsconfig.json": "@npm//@angular/dev-infra-private:tsconfig.json",
"//shared-scripts[:/][^\"]+": "@npm//@angular/dev-infra-private",
}

NPM_PACKAGE_SUBSTITUTIONS = select({
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@actions/github": "^5.0.0",
"@angular-devkit/build-optimizer": "^0.1300.0",
"@angular/benchpress": "0.2.1",
"@babel/core": "^7.16.0",
"@bazel/bazelisk": "^1.10.1",
"@bazel/buildifier": "^4.0.1",
"@bazel/esbuild": "4.4.5",
Expand Down Expand Up @@ -67,6 +68,9 @@
"yargs": "^17.0.0"
},
"devDependencies": {
"@angular/compiler": "^13.0.1",
"@angular/compiler-cli": "^13.0.1",
"@angular/core": "^13.0.1",
"@octokit/graphql-schema": "^10.72.0",
"@types/cli-progress": "^3.9.1",
"@types/conventional-commits-parser": "^3.0.1",
Expand All @@ -86,6 +90,7 @@
"jsdoc": "^3.6.7",
"minimist": "^1.2.5",
"protobufjs": "^6.11.2",
"rxjs": "^7.4.0",
"uglify-js": "^3.14.2"
}
}
8 changes: 8 additions & 0 deletions shared-scripts/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package(default_visibility = ["//visibility:public"])

filegroup(
name = "static_files",
srcs = [
"//shared-scripts/angular-linker:static_files",
],
)
18 changes: 18 additions & 0 deletions shared-scripts/angular-linker/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")

package(default_visibility = ["//visibility:public"])

filegroup(
name = "static_files",
srcs = glob(["*"]),
)

js_library(
name = "js_lib",
package_name = "@angular/dev-infra-private/shared-scripts/angular-linker",
srcs = ["esbuild-plugin.mjs"],
deps = [
"@npm//@angular/compiler-cli",
"@npm//@babel/core",
],
)

0 comments on commit e67feed

Please sign in to comment.