Fullstack development with Bazel
Ignacio Le Fluk
Posted on November 1, 2019
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
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
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
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.
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
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.
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.
Posted on November 1, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.