loading...
This Dot

Fullstack development with Bazel

flakolefluk profile image Ignacio Le Fluk ・10 min read

One of the benefits of using Bazel is the possibility to use it across the whole stack, and establish relationships between different parts of your application. Backend and frontend do not have to live in isolation.
I want to start exploring full stack development using only JavaScript/TypeScript, because it will allow us to use the @bazel/bazel package.

We'll start by creating an Angular application with Bazel. Why? Because I want the CLI to create the workspace, and take care of the initial setup.

ng new fullstack --collection=@angular/bazel
cd fullstack

Then we'll continue by building our app, and keeping all the Bazel generated files.

ng build --leaveBazelFilesOnDisk

This initial setup was explained in a previous article. If you have questions about some of the terms used in this article, or why we need to do this please go to the first post in this series.

There are a couple of things I did that are not required, but affect my initial setup files.

I renamed the src folder to client, and because I'm using CSS only, I removed everything related to sass in my imports and rules.

My initial Bazel files and config files look like this. (I removed the comments for brevity)

WORKSPACE

workspace(
    name = "project",
    managed_directories = {"@npm": ["node_modules"]},
)

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

RULES_NODEJS_VERSION = "0.34.0"
RULES_NODEJS_SHA256 = "7c4a690268be97c96f04d505224ec4cb1ae53c2c2b68be495c9bd2634296a5cd"
http_archive(
    name = "build_bazel_rules_nodejs",
    sha256 = RULES_NODEJS_SHA256,
    url = "https://github.com/bazelbuild/rules_nodejs/releases/download/%s/rules_nodejs-%s.tar.gz" % (RULES_NODEJS_VERSION, RULES_NODEJS_VERSION),
)

load("@build_bazel_rules_nodejs//:defs.bzl", "check_bazel_version", "node_repositories",
    "npm_install")
check_bazel_version(
    message = """
You no longer need to install Bazel on your machine.
Your project should have a dependency on the @bazel/bazel package which supplies it.
Try running `yarn bazel` instead.
    (If you did run that, check that you've got a fresh `yarn install`)

""",
    minimum_bazel_version = "0.27.0",
)

node_repositories(
    node_repositories = {
        "10.16.0-darwin_amd64": ("node-v10.16.0-darwin-x64.tar.gz", "node-v10.16.0-darwin-x64", "6c009df1b724026d84ae9a838c5b382662e30f6c5563a0995532f2bece39fa9c"),
        "10.16.0-linux_amd64": ("node-v10.16.0-linux-x64.tar.xz", "node-v10.16.0-linux-x64", "1827f5b99084740234de0c506f4dd2202a696ed60f76059696747c34339b9d48"),
        "10.16.0-windows_amd64": ("node-v10.16.0-win-x64.zip", "node-v10.16.0-win-x64", "aa22cb357f0fb54ccbc06b19b60e37eefea5d7dd9940912675d3ed988bf9a059"),
    },
    node_version = "10.16.0",
)

npm_install(
    name = "npm",
    package_json = "//:package.json",
    package_lock_json = "//:package-lock.json",
)

load("@npm//:install_bazel_dependencies.bzl", "install_bazel_dependencies")
install_bazel_dependencies()

load("@npm_bazel_protractor//:package.bzl", "npm_bazel_protractor_dependencies")
npm_bazel_protractor_dependencies()

load("@npm_bazel_karma//:package.bzl", "rules_karma_dependencies")
rules_karma_dependencies()

load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories")
web_test_repositories()

load("@npm_bazel_karma//:browser_repositories.bzl", "browser_repositories")
browser_repositories()

load("@npm_bazel_typescript//:index.bzl", "ts_setup_workspace")
ts_setup_workspace()

BUILD.bazel

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

exports_files([
    "tsconfig.json",
])

client/BUILD.bazel

package(default_visibility = ["//visibility: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")


ng_module(
    name = "client",
    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",
    ],
)

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

web_package(
    name = "prodapp",
    assets = [
        "@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 = ["client/prodapp"],
)

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

ts_devserver(
    name = "devserver",
    port = 4200,
    entry_module = "project/client/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 = [":client"],
)

ts_library(
    name = "test_lib",
    testonly = 1,
    srcs = glob(["**/*.spec.ts"]),
    deps = [
        ":client",
        "@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",
    ],
    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",
    ],
)

angular.json

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "fullstack": {
      "projectType": "application",
      "schematics": {},
      "root": "",
      "sourceRoot": "client",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular/bazel:build",
          "options": {
            "targetLabel": "//client:prodapp",
            "bazelCommand": "build"
          },
          "configurations": {
            "production": {
              "targetLabel": "//client:prodapp"
            }
          }
        },
        "serve": {
          "builder": "@angular/bazel:build",
          "options": {
            "targetLabel": "//client:devserver",
            "bazelCommand": "run",
            "watch": true
          },
          "configurations": {
            "production": {
              "targetLabel": "//client:prodserver"
            }
          }
        },
        "extract-i18n": {
          "builder": "@angular-devkit/build-angular:extract-i18n",
          "options": {
            "browserTarget": "fullstack:build"
          }
        },
        "test": {
          "builder": "@angular/bazel:build",
          "options": {
            "bazelCommand": "test",
            "targetLabel": "//client:test"
          }
        },
        "lint": {
          "builder": "@angular-devkit/build-angular:tslint",
          "options": {
            "tsConfig": [
              "tsconfig.app.json",
              "tsconfig.spec.json",
              "e2e/tsconfig.json"
            ],
            "exclude": [
              "**/node_modules/**"
            ]
          }
        },
        "e2e": {
          "builder": "@angular/bazel:build",
          "options": {
            "bazelCommand": "test",
            "targetLabel": "//e2e:devserver_test"
          },
          "configurations": {
            "production": {
              "targetLabel": "//e2e:prodserver_test"
            }
          }
        }
      }
    }},
  "defaultProject": "fullstack"
}

Expect breaking changes while v1.0 is on its way. Imports and rules may change.

Let's test to ensure that everything is working.

ng serve

Successful Build

Now that our project is setup, let's add a server folder where our backend will live.

Folder structure/naming is not important at this point. Use whatever works for you.

I'll build the server with express, and test it with jasmine and supertest. I'll start by installing the required dependencies.

npm install express --save
npm install --save-dev @bazel/jasmine jasmine supertest

Notice that I installed the @bazel/jasmine package that will contain the required rules to run the tests.

We'll create a very basic express server with some dummy data to return. In this first iteration, I'll use Javascript only. We also need to add a BUILD.bazel file to make it an independent package for the build tool.

If we don't create a BUILD.bazel file inside the server directory, it will be part of the root package.

server/index.js

const app = require("./app");

const PORT = process.env.PORT || 3000;

app.listen(PORT, _ => {
  console.log(`server listening on port ${PORT}`);
});

app.js

const app = require("express")();

const { users } = require("./data");

// dev only
app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  next();
});

app.get('/', (req, res) => {
  res.json({ success: true });
});

app.get('/users', (req, res) => {
  res.json(users);
});

app.get('/users/:id', (req, res) => {
  const id = req.params.id;
  const user = users.find(u => u.id === parseInt(id, 10));
  if (!user) {
    return res.status(404).send('UserNotFound');
  }
  res.json(user);
});

module.exports = app;

data.js

const users = [
  { id: 1, name: "Greg", lastName: "Williams" },
  { id: 2, name: "Ann", lastName: "Roberts" }
];

module.exports = { users };

app.spec.js

const request = require("supertest");

const app = require("./app");
const { users } = require("./data");

it("should return all users", done => {
  request(app)
    .get("/users")
    .expect(200, users)
    .end((err, res) => {
      if (err) return done.fail(err);
      done();
    });
});

it("should return single user", done => {
  request(app)
    .get("/users/1")
    .expect(200, users[0])
    .end((err, res) => {
      if (err) return done.fail(err);
      done();
    });
});

it("should fail if a user with the given id is not found", done => {
  request(app)
    .get("/users/4")
    .expect(404)
    .expect(res => res.error.text === "UserNotFound")
    .end((err, res) => {
      if (err) return done.fail(err);
      done();
    });
});

server/BUILD.bazel

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

load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
load("@npm_bazel_jasmine//:index.bzl", "jasmine_node_test")

nodejs_binary(
    name = "server",
    entry_point = "index.js",
    node_modules = "@npm//:node_modules",
    data = [
        "index.js",
        "app.js",
    ]
)

jasmine_node_test(
    name = "test",
    srcs = glob(["*.spec.js"]),
    deps = [ "//server"], 
    data = [ "data.js"], 
    node_modules = "@npm//:node_modules",
)

Now that our server is set up, how do we run tests, or start it? In the previous tutorial, we made use of the Angular CLI commands to take care of it, but in our server, this is not possible.

We'll use the @bazel/bazel and @bazel/ibazel dependencies for this purpose.
The only difference between bazel and ibazel is that the latter is running in "watch" mode. It will track any changes, and will restart whatever task it's doing.

To use the locally installed npm packages, we can create a script in package.json, or we can use the whole path to the executable files.

To run the server in watch mode:

./node_modules/.bin/ibazel run //server

Server started in terminal

We can run the server tests using a similar command.

./node_modules/.bin/ibazel test //server:test

The ibazel test command accepts multiple rules or packages to test. This makes it valid to run the server and client tests in a single command.

./node_modules/.bin/ibazel test //server:test //client:test

Multiple tests passing in terminal

If we make a change in the server, the client tests will keep the cached version, because there's no server dependency declared. The same rule applies if we make changes on the client.

Cached tests results

One of the benefits of keeping the server and the client together is the possibility to share information between them. It's hard to keep track of changes made to the server responses in the front end, even if we create an interface in our client code. We won't know of a breaking change until e2e tests fail (or someone lets us know that we must update our code).

Let's see how we can benefit from sharing types. We'll update our server to use TypeScript. We don't have to change everything at once. We can add a rule that transpiles a section of our code and the output becomes an input of the js rule we had before. We are sharing dependencies with our Angular project so TypeScript is already there.

server/BUILD.bazel

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

load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
load("@npm_bazel_jasmine//:index.bzl", "jasmine_node_test")
load("@npm_bazel_typescript//:index.bzl", "ts_library")



ts_library(
    name = "app",
    srcs = ["app.ts", "data.ts"],
    deps = [
        "//models",
        "@npm//:node_modules",
        "@npm//@types",
    ],
)

nodejs_binary(
    name = "server",
    entry_point = "index.js",
    node_modules = "@npm//:node_modules",
    data = [
        "index.js",
        ":app",
    ]
)

jasmine_node_test(
    name = "test",
    srcs = glob(["*.spec.js"]),
    deps = [ "//server"], 
    node_modules = "@npm//:node_modules",
)

server/data.ts

import { User } from '../models/user';

export const users: User[] = [
  { id: 1, name: 'Greg', lastName: 'Williams' },
  { id: 2, name: 'Ann', lastName: 'Roberts' },
];

server/app.ts

import express = require('express');
const app = express();

import { users } from './data';

// dev only
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  next();
});

app.get('/', (req, res) => {
  res.json({ success: true });
});

app.get('/users', (req, res) => {
  res.json(users);
});

app.get('/users/:id', (req, res) => {
  const id = req.params.id;
  const user = users.find(u => u.id === parseInt(id, 10));
  if (!user) {
    return res.status(404).send('UserNotFound');
  }
  res.json(user);
});

module.exports = app;

We partially migrated our code to typescript, and it still works. You may have noticed a dependency on the //models package/rule.

This will be our shared types directory.

models/user.ts

export interface User {
    id: number;
    name: string;
    lastName: string;
}

models/BUILD.bazel

package(default_visibility = ["//visibility:public"])
load("@npm_bazel_typescript//:index.bzl", "ts_library")
ts_library(
    name = "models",
    srcs = ["user.ts"],
)

We will proceed now to connect our server with the Angular app. Let's create a service that gets the users, and then in our app component we will show them.

client/app/user.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { User } from '../../models/user';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class UserService {

  constructor(private http: HttpClient) { }

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('http://localhost:3000/users');
}

client/app/app.component.ts

import { Component, OnInit } from '@angular/core';
import { UserService } from './user.service';
import { User } from '../../models/user';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
  users: User[] = [];

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.getUsers().subscribe(users => {
      this.users = users;
    });
  }
}

client/app/app.component.html

<ul>
  <li *ngFor="let user of users">
    {{user.name}} {{user.lastName}}
  </li>
</ul>

If we start our server and our client now, our not-so-fancy app should display the users list returned from the server.

./node_modules/.bin/ibazel run //server
./node_modules/.bin/ibazel run //client

App displaying users list

If we decided to make a change on the User interface while working in the backend, it will immediately trigger a static analysis error on the front end. Let's assume we decided to change the name property to firstName.
We would have to change our server/data.ts to match the new interface. However, if we tried to build the client app, it would fail because types will not match.

Interface not matching

Going forward

This was a very simple example (One server, one app). But as soon as your app starts growing, you may find yourself using different languages or creating libraries that are used by your application. Maybe you'll have multiple apps using the same server.

With the backend and the frontend being orchestrated by Bazel, you can have a common way of managing everything. You can also start splitting packages into smaller packages that can have their own set of tests, or that can be bundled separately, and have cached artifacts that can be reused, making your builds and tests faster.

We worked on full-stack development with Bazel using JS and/or TS. But this is just the tip of the iceberg. If your backend is not written in any of these languages, you may install Bazel using these instructions. It uses the same commands we've seen so far.

You can also share type information between languages using Protocol Buffers. Types will be autogenerated for each language using Bazel (of course!) and the Protocol Buffer Rules. These autogenerated types can now be declared as dependencies of your client and server.

Expect changes to come until v1 is here. Meanwhile, keep experimenting with Bazel.

References

This Dot Inc. is a consulting company which contains two branches : the media stream and labs stream. This Dot Media is the portion responsible for keeping developers up to date with advancements in the web platform. In order to inform authors of new releases or changes made to frameworks/libraries, events are hosted, and videos, articles, & podcasts are published. Meanwhile, This Dot Labs provides teams with web platform expertise using methods such as mentoring and training.

Discussion

markdown guide
 

Remind me to come back to this?
Bazel - from my skimming - might be the build tool for me, but I've been having trouble finding he right combo of time+deep-dive material to work with.