loading...
This Dot

Angular + Bazel: Getting ready!

flakolefluk profile image Ignacio Le Fluk ・8 min read

In this post, I'll make an introduction to what is Bazel, how to use it with the Angular CLI and why it's a good idea to adopt it.

What is Bazel?

Bazel is a build tool/system with multi-language capabilities developed by Google.
It's extensible, meaning that you can add support for other languages and frameworks.
It also knows what and when to rebuild specific parts of your code and caches your build artifacts, making it very fast.

Concepts

Workspace: A workspace is the root directory that contains the source files to build. It should have at least a text file named WORKSPACE in it. If a WORKSPACE file is found in any sub-directory, it will be ignored by the parent workspace, creating a new workspace.

Package: Similar to workspaces, packages are directories beneath a workspace defined by a BUILD (or BUILD.bazel) file. If a subdirectory of a Package contains a BUILD file it's considered a different Package.

Targets: The elements of a Package are mainly of two types:

  • Files that can be generated by the build or source files provided by us.
  • Rules that given an input (source or generated files) will return an output (generated files). Rules outputs always belong to the same package of the rule.

Referencing

There are multiple ways of referencing files and outputs.
If you want to reference a Rule inside the same package you'll do it by using a colon (:) and the name of the rule.

a_rule(
    name = "rule_name"
)

another_rule(
    name = another_rule_name,
    deps = [
        ":rule_name"
    ]
)

When referencing another package we will use a double slash (//) to get to the root of the workspace and from there get to the desired package.

another_package_rule(
    name = "some_name",
    deps = [
        "//foo/bar:a_rule"
    ]
)

If the name of the rule matches the package name, then the rule name after the colon can be omitted.

A reference to //foo/bar:bar equals //foo/bar.

Bazel and Angular

Bazel is language and framework agnostic, but I would like to start by taking a look at how it can be used with Angular and understand what's happening.
Let's install the required dependencies and start a new project.

npm install -g @angular/bazel
ng new --collection=@angular/bazel

This command will create a new project with Bazel already set up as the build tool.

If we serve our app (ng serve) in our new project some new files will be created.

  • /WORKSPACE
  • /BUILD.bazel
  • .bazelignore
  • .bazelrc
  • /src/BUILD.bazel
  • /e2e/BUILD.bazel

We can for sure tell that the CLI defined a workspace, and three packages. Following the Package definition, the package located at the root will contain everything in the workspace except from the src and e2e folders.
If we stop our server, the files will be removed again.
That was zero-config Bazel!

But what can we do if we need to tweak our build because we installed a new library or because we want to optimize our build. We could create the WORKSPACE and BUILD files manually or we can use the --leaveBazelFilesOnDisk flag so the generated files are not removed.

ng build --leaveBazelFilesOnDisk

After we finish building we'll be able to explore and/or modify the generated files.

Let's start with /src/BUILD.bazel. I'll split the file in sections to explain them easily.

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

At the very top, we have the visibility attribute. The visibility attribute will give access or not to the rules in this package from another package. Scopes of visibility can be set. For this example, we'll leave it as public.

load("@npm_angular_bazel//:index.bzl", "ng_module")
load("@npm_bazel_karma//:index.bzl", "ts_web_test_suite")
load("@build_bazel_rules_nodejs//:defs.bzl", "rollup_bundle", "history_server")
load("@build_bazel_rules_nodejs//internal/web_package:web_package.bzl", "web_package")
load("@npm_bazel_typescript//:index.bzl", "ts_devserver", "ts_library")
load("@io_bazel_rules_sass//:defs.bzl", "multi_sass_binary", "sass_binary")

Then all the imports that we'll be using with the load command. This will import a symbol from an extension.

After that, the rest is rules and its configurations.

sass_binary(
  name = "global_stylesheet",
  src = glob(["styles.css", "styles.scss"])[0],
  output_name = "global_stylesheet.css",
)


multi_sass_binary(
    name = "styles",
    srcs = glob(
      include = ["**/*.scss"],
      exclude = ["styles.scss"],
    ),
)

At the start of every rule, there's its name. This will let us reference it from other places and for example, including it as a dependency to another rule.

The first rule is named global_stylesheet, this means we can get a reference to its output by calling :global_stylesheet (from the same package). The source is either a .css or .scss file depending on our configuration. We also configure the output file.

Our second rule, styles, will transform all the .scss files, excluding the global style file.

If you (like me), prefer to use CSS we can get rid of many of this stuff. We'll do that in a moment.

Let's continue with the next rule. ng_module.

ng_module(
    name = "src",
    srcs = glob(
        include = ["**/*.ts"],
        exclude = [
            "**/*.spec.ts",
            "main.ts",
            "test.ts",
            "initialize_testbed.ts",
        ],
    ),
    assets = glob([
      "**/*.css",
      "**/*.html",
    ]) + ([":styles"] if len(glob(["**/*.scss"])) else []),
    deps = [
        "@npm//@angular/core",
        "@npm//@angular/platform-browser",
        "@npm//@angular/router",
        "@npm//@types",
        "@npm//rxjs",
    ],
)

First of all, we are naming the rule the same as the directory, this makes it easier later when referencing it. Naming your main rule the same as your package directory is a good practice. We are setting our sources (all .ts files, ignoring what is not part of the module), and our assets (.css and .html files, concatenated with the output of the styles rule that converts .scss files). Finally, we declare our dependencies, in this case, located in the npm workspace.

rollup_bundle(
    name = "bundle",
    entry_point = ":main.prod.ts",
    deps = [
        "//src",
        "@npm//@angular/router",
        "@npm//rxjs",
    ],
)

web_package(
    name = "prodapp",
    assets = [
        # do not sort
        "@npm//:node_modules/zone.js/dist/zone.min.js",
        ":bundle.min.js",
        ":global_stylesheet",
    ],
    data = [
        "favicon.ico",
    ],
    index_html = "index.html",
)

history_server(
    name = "prodserver",
    data = [":prodapp"],
    templated_args = ["src/prodapp"],
)

filegroup(
    name = "rxjs_umd_modules",
    srcs = [
        # do not sort
        "@npm//:node_modules/rxjs/bundles/rxjs.umd.js",
        ":rxjs_shims.js",
    ],
)

ts_devserver(
    name = "devserver",
    port = 4200,
    entry_module = "project/src/main.dev",
    serving_path = "/bundle.min.js",
    scripts = [
        "@npm//:node_modules/tslib/tslib.js",
        ":rxjs_umd_modules",
    ],
    static_files = [
        "@npm//:node_modules/zone.js/dist/zone.min.js",
        ":global_stylesheet",
    ],
    data = [
        "favicon.ico",
    ],
    index_html = "index.html",
    deps = [":src"],
)

ts_library(
    name = "test_lib",
    testonly = 1,
    srcs = glob(["**/*.spec.ts"]),
    deps = [
        ":src",
        "@npm//@angular/core",
        "@npm//@angular/router",
        "@npm//@types",
    ],
)

ts_library(
    name = "initialize_testbed",
    testonly = 1,
    srcs = [
        "initialize_testbed.ts",
    ],
    deps = [
        "@npm//@angular/core",
        "@npm//@angular/platform-browser-dynamic",
        "@npm//@types",
    ],
)

ts_web_test_suite(
    name = "test",
    srcs = [
        "@npm//:node_modules/tslib/tslib.js",
    ],
    runtime_deps = [
        ":initialize_testbed",
    ],
    # do not sort
    bootstrap = [
        "@npm//:node_modules/zone.js/dist/zone-testing-bundle.js",
        "@npm//:node_modules/reflect-metadata/Reflect.js",
    ],
    browsers = [
        "@io_bazel_rules_webtesting//browsers:chromium-local",
    ],
    deps = [
        ":rxjs_umd_modules",
        ":test_lib",
        "@npm//karma-jasmine",
    ],
)

The last section will setup and configure our bundler, the development server, the production build, and the tests.

When using Bazel, every dependency is declared, inputs and outputs are known. This will allow us to analyze what's going on and build a directed graph. We can query any package, rule or repository and its dependencies using the query command. The query command is very expressive in what you can request, and you can also get different output types. I recommend taking a look at the documentation. We'll be also using a tool to convert the query graph results to an image.

// get al the packages in the workspace
bazel query --output=graph ... | dot -Tpng > graph.png

workspace output graph

This looks a little too much information. Fortunately, we can select portions of our graph.

bazel query "kind(rule, allpaths(//src:devserver, //...))" --output=graph | dot -Tpng > devserver.png

dev server output graph

Let's start optimizing, and customizing our build, with what we have now. First of all, for this project, I decided that I won't be using any .scss file, so I can get rid of the styles rule. Remember to remove any reference to it as well.

I also want the app directory to be its own package, so I'll create a BUILD.bazel file in it.

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

load("@npm_angular_bazel//:index.bzl", "ng_module")

ng_module(
    name = "app",
    srcs = glob(
        include = ["**/*.ts"],
        exclude = ["**/*.spec.ts"],
    ),
    assets = glob([
      "**/*.css",
      "**/*.html",
    ]),
    deps = [
        "@npm//@angular/core",
        "@npm//@angular/platform-browser",
        "@npm//@angular/router",
        "@npm//@types",
        "@npm//rxjs",
    ],
)

ts_library(
    name = "test_lib",
    testonly = 1,
    srcs = ["app.component.spec.ts"],
    deps = [
        ":app",
        "@npm//@angular/core",
        "@npm//@angular/router",
        "@npm//@types",
    ],
)

We've declared a new rule inside this new package inside src/app named app. We've also created a rule to handle the tests inside this package.
Next, we've removed the references to the styles rule, removed it, and instead we're setting the styles.css as the source. Also, we added a reference to the tests inside the app package.

ng_module(
    name = "src",
    srcs = glob(
        include = ["**/*.ts"],
        exclude = [
            "**/*.spec.ts",
            "main.ts",
            "test.ts",
            "initialize_testbed.ts",
        ],
    ),
    assets = glob([
      "**/*.css",
      "**/*.html",
    ]),
    deps = [
        "@npm//@angular/core",
        "@npm//@angular/platform-browser",
        "@npm//@angular/router",
        "@npm//@types",
        "@npm//rxjs",
        "//src/app"
    ],
)

rollup_bundle(
    name = "bundle",
    entry_point = ":main.prod.ts",
    deps = [
        "//src",
        "@npm//@angular/router",
        "@npm//rxjs",
    ],
)

web_package(
    name = "prodapp",
    assets = [
        # do not sort
        "@npm//:node_modules/zone.js/dist/zone.min.js",
        ":bundle.min.js",
        "styles.css",
    ],
    data = [
        "favicon.ico",
    ],
    index_html = "index.html",
)

history_server(
    name = "prodserver",
    data = [":prodapp"],
    templated_args = ["src/prodapp"],
)

filegroup(
    name = "rxjs_umd_modules",
    srcs = [
        # do not sort
        "@npm//:node_modules/rxjs/bundles/rxjs.umd.js",
        ":rxjs_shims.js",
    ],
)

ts_devserver(
    name = "devserver",
    port = 4200,
    entry_module = "project/src/main.dev",
    serving_path = "/bundle.min.js",
    scripts = [
        "@npm//:node_modules/tslib/tslib.js",
        ":rxjs_umd_modules",
    ],
    static_files = [
        "@npm//:node_modules/zone.js/dist/zone.min.js",
        "styles.css",
    ],
    data = [
        "favicon.ico",
    ],
    index_html = "index.html",
    deps = [":src"],
)

ts_library(
    name = "test_lib",
    testonly = 1,
    srcs = glob(["**/*.spec.ts"]),
    deps = [
        ":src",
        "@npm//@angular/core",
        "@npm//@angular/router",
        "@npm//@types",
    ],
)

ts_library(
    name = "initialize_testbed",
    testonly = 1,
    srcs = [
        "initialize_testbed.ts",
    ],
    deps = [
        "@npm//@angular/core",
        "@npm//@angular/platform-browser-dynamic",
        "@npm//@types",
    ],
)

ts_web_test_suite(
    name = "test",
    srcs = [
        "@npm//:node_modules/tslib/tslib.js",
    ],
    runtime_deps = [
        ":initialize_testbed",
    ],
    # do not sort
    bootstrap = [
        "@npm//:node_modules/zone.js/dist/zone-testing-bundle.js",
        "@npm//:node_modules/reflect-metadata/Reflect.js",
    ],
    browsers = [
        "@io_bazel_rules_webtesting//browsers:chromium-local",
    ]`
    deps = [
        ":rxjs_umd_modules",
        ":test_lib",
        "//src/app:test_lib",
        "@npm//karma-jasmine",
    ],
)

If we analyze our dev server again.
dependencies graph updated

I believe most of the benefits when using Bazel start to be more evident once your workspace starts growing. The pattern becomes roughly the same for each module you start adding.
Here's an example where I added a core module, and a shared module used by other 2 modules (users, repositories). You can easily start exploring the tree of dependencies.

Extended graph example

Because Bazel is always AOT, be aware that when lazy loading a module, we must use the factory module.
For example:

const routes: Routes = [
  {
    path: 'users',
    loadChildren: () => import('./users/users.ngfactory').then(m => m.UsersModuleNgFactory)
  },
  {
    path: 'repositories',
    loadChildren: () =>
        import('./repositories/repositories.module.ngfactory').then(m => m.RepositoriesModuleNgFactory)
  }
]

If you look closely at the main.dev.ts and main.prod.ts we're also bootstrapping the factory module.

platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

Now that we have an idea on how to work with Bazel, let's talk about its benefits.
I think most of them will shine on large codebases with lots of tests. Fortunately, it's adoption can be incremental (One package that does everything) and you can start creating more specific packages and begin declaring it's dependencies.

You can make use also of remote build and caching. Because for a given set of inputs, the output is always the same, you can cache the results and share it. If your CI already run the tests, when you make changes locally you can use the cached results of the unaffected tests and run only the affected ones. The same goes for the builds, reducing build times.

We've started only with an Angular application, but Bazel allows to do a lot more and because it's language agnostic, you can start referencing also the backend (perhaps written in another language), and use Bazel as the build tool.

Even though Bazel extensible, there's been a lot of work on making the needed rules already available. So there might be a low chance of needing to write a custom one. In case you're wondering on how to do it, Rules are written using Starlark a dialect of Python.

Some of the features presented here are still part of Angular Labs and might change in the future.

References

Enjoy this article? Head on over to This Dot Labs and check us out! We are a tech consultancy that does all things javascript and front end. We specialize in open source software like, Angular, React and Vue.

Discussion

markdown guide
 

Thanks for this great introduction!

Some questions for you!

With standard CLI build, we can rely on different environment.ts files. For example: environment.staging.ts. angular.json allowed replacement of default environment.ts file with the environment specific one depending on configuration when running ng build --configuration=staging for example.

How this can be achieve when migrating to Bazel? Adding bazel to project basically deletes everything related to specific configurations and file replacements.

 

I have the same problem, any solution for this issue?

 

Wow. Thanks for the Bazel Intro 🎉

Once this is mandatory ... I already see so many hours of my life being wasted to

a) manage it
b) trying to extend while adding libraries/projects
c) finding the bugs that were added during a) / b) ...

Does this switch to bazel also means that angular.json would be obsolete?

I tried bazel on my projects (small-ish) and it felt that angular without bazel built faster than with bazel, but it could be just some weird issues (on a mac)

 

Does this switch to bazel also means that angular.json would be obsolete?

No, the angular.json is still mandatory. You have to keep it!

Actually, when you run ng add @angular/bazel, we update your angular.json to automatically switch from the older builder to the new Bazel builder, so you can keep running the usual ng build command. See:

github.com/bazelbuild/rules_nodejs...

 

Thank you very much! Excellent article!

Experience taught me to be skeptic when a new build tool is around the corner as it almost always means some extra work you're not spending on the tasks you originally intended. I can't count the hours I sunk into them. Nevertheless I'm looking forward to faster build times :)

 

What we usually tell developers is that if your Angular application is building fine and you are happy with the build (and tests) speed/perfs, you don't have to switch to Bazel. Also, in most cases, you won't need to learn about Bazel since all common tasks would be handled automatically for you. However, knowing Bazel basics might help, but this is not mandatory.

 

So, how to implemnts environtments dev, stag and prod on angular after add bazel. Before that, I'm used fileReplacement configuration on angular.json to change environment. How about bazel?

 

This is a good article...

I used Bazel with Angular application but facing many problems with the angular library. can you tell us how we gonna use Bazel with angular library
.

 
 

Please, would you tell me why this command isn't working,
bazel query --output=graph ... | dot -Tpng > graph.png

I can't get this part: dot -Tpng it's unknown for my mac.

 

You need graphviz installed in order to run the dot command.
Thanks for noticing it, I'll add this information.

 

Yes, that was the problem, i also realized the problem and i just install it.. it works, so it's used maybe for building the graph png image.
Thank you very much.