Skip to content

Commit

Permalink
build: create custom bazel dev-server rule (#16937)
Browse files Browse the repository at this point in the history
Implements a custom bazel dev-server rule that can be exposed eventually. The reason
we need a custom dev-server implementation is that the "ts_devserver" is not flexible
and needs to be synced into google3 (causing slow syncing; and hestitancy to adding
new features. always the question of scope).

We need our own implemenation because we want:

* Live-reloading to work (bazelbuild/rules_nodejs#1036)
* HTML History API support (currently the ts_devserver always sends a 404 status code)
* Better host binding of the server (so that we can access the server on other devices)
* Flexibility & control (being able to do changes so that the dev-server fits our needs)
  • Loading branch information
devversion authored and jelbourn committed Sep 3, 2019
1 parent ce71a45 commit 1c74518
Show file tree
Hide file tree
Showing 10 changed files with 898 additions and 43 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@firebase/app-types": "^0.3.2",
"@octokit/rest": "^16.28.7",
"@schematics/angular": "^8.2.1",
"@types/browser-sync": "^2.26.1",
"@types/chalk": "^0.4.31",
"@types/fs-extra": "^4.0.3",
"@types/glob": "^5.0.33",
Expand All @@ -90,8 +91,10 @@
"@types/node": "^7.0.21",
"@types/parse5": "^5.0.0",
"@types/run-sequence": "^0.0.29",
"@types/send": "^0.14.5",
"autoprefixer": "^6.7.6",
"axe-webdriverjs": "^1.1.1",
"browser-sync": "^2.26.7",
"chalk": "^1.1.3",
"clang-format": "^1.2.4",
"codelyzer": "^5.1.0",
Expand Down Expand Up @@ -141,6 +144,7 @@
"run-sequence": "^1.2.2",
"scss-bundle": "^2.0.1-beta.7",
"selenium-webdriver": "^3.6.0",
"send": "^0.17.1",
"shelljs": "^0.8.3",
"sorcery": "^0.10.0",
"stylelint": "^10.1.0",
Expand Down
19 changes: 9 additions & 10 deletions src/dev-app/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package(default_visibility = ["//visibility:public"])

load("@io_bazel_rules_sass//:defs.bzl", "sass_binary")
load("@npm_bazel_typescript//:defs.bzl", "ts_devserver")
load("//:packages.bzl", "MATERIAL_EXPERIMENTAL_SCSS_LIBS")
load("//tools:defaults.bzl", "ng_module")
load("//tools/dev-server:index.bzl", "dev_server")

ng_module(
name = "dev-app",
Expand Down Expand Up @@ -90,13 +90,12 @@ sass_binary(
] + MATERIAL_EXPERIMENTAL_SCSS_LIBS,
)

ts_devserver(
dev_server(
name = "devserver",
additional_root_paths = [
"npm/node_modules",
],
port = 4200,
static_files = [
srcs = [
"index.html",
"system-config.js",
"system-rxjs-operators.js",
":theme",
"//src/dev-app/icon:icon_demo_assets",
"@npm//:node_modules/@material/animation/dist/mdc.animation.js",
Expand Down Expand Up @@ -137,9 +136,9 @@ ts_devserver(
"@npm//:node_modules/systemjs/dist/system.js",
"@npm//:node_modules/tslib/tslib.js",
"@npm//:node_modules/zone.js/dist/zone.js",
"index.html",
"system-config.js",
"system-rxjs-operators.js",
],
additional_root_paths = [
"npm/node_modules",
],
deps = [
":dev-app",
Expand Down
6 changes: 0 additions & 6 deletions src/dev-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@
<body>
<dev-app>Loading...</dev-app>
</body>
<!--
Sets up the live reloading script from ibazel if present. This is a workaround
and will not work if the port changes (in case it is already used).
TODO(devversion): replace once https://github.com/bazelbuild/rules_nodejs/issues/1036 is fixed.
-->
<script src="http://localhost:35729/livereload.js?snipver=1" async></script>
<script src="core-js/client/core.js"></script>
<script src="zone.js/dist/zone.js"></script>
<script src="hammerjs/hammer.min.js"></script>
Expand Down
32 changes: 32 additions & 0 deletions tools/dev-server/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package(default_visibility = ["//visibility:public"])

load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
load("//tools:defaults.bzl", "ts_library")

exports_files(["launcher_template.sh"])

ts_library(
name = "dev-server_lib",
srcs = [
"dev-server.ts",
"ibazel.ts",
"main.ts",
],
deps = [
"@npm//@types/browser-sync",
"@npm//@types/minimist",
"@npm//@types/node",
"@npm//@types/send",
"@npm//browser-sync",
"@npm//minimist",
"@npm//send",
],
)

nodejs_binary(
name = "dev-server_bin",
data = [
":dev-server_lib",
],
entry_point = ":main.ts",
)
97 changes: 97 additions & 0 deletions tools/dev-server/dev-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @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
*/

import * as browserSync from 'browser-sync';
import * as http from 'http';
import * as path from 'path';
import * as send from 'send';

/**
* Dev Server implementation that uses browser-sync internally. This dev server
* supports Bazel runfile resolution in order to make it work in a Bazel sandbox
* environment and on Windows (with a runfile manifest file).
*/
export class DevServer {
/** Instance of the browser-sync server. */
server = browserSync.create();

/** Options of the browser-sync server. */
options: browserSync.Options = {
open: false,
port: this.port,
notify: false,
ghostMode: false,
server: true,
middleware: (req, res) => this._bazelMiddleware(req, res),
};

constructor(
readonly port: number, private _rootPaths: string[],
private _historyApiFallback: boolean = false) {}

/** Starts the server on the given port. */
async start() {
return new Promise((resolve, reject) => {
this.server.init(this.options, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}

/** Reloads all browsers that currently visit a page from the server. */
reload() {
this.server.reload();
}

/**
* Middleware function used by BrowserSync. This function is responsible for
* Bazel runfile resolution and HTML History API support.
*/
private _bazelMiddleware(req: http.IncomingMessage, res: http.ServerResponse) {
if (!req.url) {
res.end('No url specified. Error');
return;
}

// Implements the HTML history API fallback logic based on the requirements of the
// "connect-history-api-fallback" package. See the conditions for a request being redirected
// to the index: https://github.com/bripkens/connect-history-api-fallback#introduction
if (this._historyApiFallback && req.method === 'GET' && !req.url.includes('.') &&
req.headers.accept && req.headers.accept.includes('text/html')) {
req.url = '/index.html';
}

const resolvedPath = this._resolveUrlFromRunfiles(req.url);

if (resolvedPath === null) {
res.statusCode = 404;
res.end('Page not found');
return;
}

send(req, resolvedPath).pipe(res);
}

/** Resolves a given URL from the runfiles using the corresponding manifest path. */
private _resolveUrlFromRunfiles(url: string): string|null {
// Remove the leading slash from the URL. Manifest paths never
// start with a leading slash.
const manifestPath = url.substring(1);
for (let rootPath of this._rootPaths) {
try {
return require.resolve(path.posix.join(rootPath, manifestPath));
} catch {
}
}
return null;
}
}
37 changes: 37 additions & 0 deletions tools/dev-server/ibazel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @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
*/

import {createInterface} from 'readline';
import {DevServer} from './dev-server';

// ibazel will write this string after a successful build.
const ibazelNotifySuccessMessage = 'IBAZEL_BUILD_COMPLETED SUCCESS';

/**
* Sets up ibazel support for the specified devserver. ibazel communicates with
* an executable over the "stdin" interface. Whenever a specific message is sent
* over "stdin", the devserver can be reloaded.
*/
export function setupBazelWatcherSupport(server: DevServer) {
// ibazel communicates via the stdin interface.
const rl = createInterface({input: process.stdin, terminal: false});

rl.on('line', (chunk: string) => {
if (chunk === ibazelNotifySuccessMessage) {
server.reload();
}
});

rl.on('close', () => {
// Give ibazel 5s to kill this process, otherwise we exit the process manually.
setTimeout(() => {
console.error('ibazel failed to stop the devserver after 5s.');
process.exit(1);
}, 5000);
});
}
134 changes: 134 additions & 0 deletions tools/dev-server/index.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
load("@build_bazel_rules_nodejs//internal/common:sources_aspect.bzl", "sources_aspect")

"""Gets the workspace name of the given rule context."""

def _get_workspace_name(ctx):
if ctx.label.workspace_root:
# We need the workspace_name for the target being visited.
# Starlark doesn't have this - instead they have a workspace_root
# which looks like "external/repo_name" - so grab the second path segment.
return ctx.label.workspace_root.split("/")[1]
else:
return ctx.workspace_name

"""Implementation of the dev server rule."""

def _dev_server_rule_impl(ctx):
files = depset(ctx.files.srcs)

# List of files which are required for the devserver to run. This includes the
# bazel runfile helpers (to resolve runfiles in bash) and the devserver binary
# with its transitive runfiles (in order to be able to run the devserver).
required_tools = ctx.files._bash_runfile_helpers + \
ctx.files._dev_server_bin + \
ctx.attr._dev_server_bin[DefaultInfo].files.to_list() + \
ctx.attr._dev_server_bin[DefaultInfo].data_runfiles.files.to_list()

# Walk through all dependencies specified in the "deps" attribute. These labels need to be
# unwrapped in case there are built using TypeScript-specific rules. This is because targets
# built using "ts_library" or "ng_module" do not declare the generated JS files as default
# rule output. The output aspect that is applied to the "deps" attribute, provides two struct
# fields which resolve to the unwrapped JS output files.
# https://github.com/bazelbuild/rules_nodejs/blob/e04c8c31f3cb859754ea5c5e97f331a3932b725d/internal/common/sources_aspect.bzl#L53-L55
for d in ctx.attr.deps:
if hasattr(d, "node_sources"):
files = depset(transitive = [files, d.node_sources])
elif hasattr(d, "files"):
files = depset(transitive = [files, d.files])
if hasattr(d, "dev_scripts"):
files = depset(transitive = [files, d.dev_scripts])

workspace_name = _get_workspace_name(ctx)
root_paths = ["", "/".join([workspace_name, ctx.label.package])] + ctx.attr.additional_root_paths

# We can't use "ctx.actions.args()" because there is no way to convert the args object
# into a string representing the command line arguments. It looks like bazel has some
# internal logic to compute the string representation of "ctx.actions.args()".
args = '--root_paths="%s" ' % ",".join(root_paths)
args += "--port=%s " % ctx.attr.port

if ctx.attr.historyApiFallback:
args += "--historyApiFallback "

ctx.actions.expand_template(
template = ctx.file._launcher_template,
output = ctx.outputs.launcher,
substitutions = {
"TEMPLATED_args": args,
},
is_executable = True,
)

return [
DefaultInfo(runfiles = ctx.runfiles(
files = files.to_list() + required_tools,
collect_data = True,
collect_default = True,
)),
]

dev_server_rule = rule(
implementation = _dev_server_rule_impl,
outputs = {
"launcher": "%{name}.sh",
},
attrs = {
"srcs": attr.label_list(allow_files = True, doc = """
Sources that should be available to the dev-server. This attribute can be
used for explicit files. This attribute only uses the files exposed by the
DefaultInfo provider (i.e. TypeScript targets should be added to "deps").
"""),
"additional_root_paths": attr.string_list(doc = """
Additionally paths to serve files from. The paths should be formatted
as manifest paths (e.g. "my_workspace/src")
"""),
"historyApiFallback": attr.bool(
default = True,
doc = """
Whether the devserver should fallback to "/index.html" for non-file requests.
This is helpful for single page applications using the HTML history API.
""",
),
"port": attr.int(
default = 4200,
doc = """The port that the devserver will listen on.""",
),
"deps": attr.label_list(
allow_files = True,
aspects = [sources_aspect],
doc = """
Dependencies that need to be available to the dev-server. This attribute can be
used for TypeScript targets which provide multiple flavors of output.
""",
),
"_bash_runfile_helpers": attr.label(default = Label("@bazel_tools//tools/bash/runfiles")),
"_dev_server_bin": attr.label(
default = Label("//tools/dev-server:dev-server_bin"),
),
"_launcher_template": attr.label(allow_single_file = True, default = Label("//tools/dev-server:launcher_template.sh")),
},
)

"""
Creates a dev server that can depend on individual bazel targets. The server uses
bazel runfile resolution in order to work with Bazel package paths. e.g. developers can
request files through their manifest path: "my_workspace/src/dev-app/my-genfile".
"""

def dev_server(name, testonly = False, tags = [], **kwargs):
dev_server_rule(
name = "%s_launcher" % name,
visibility = ["//visibility:private"],
tags = tags,
**kwargs
)

native.sh_binary(
name = name,
# The "ibazel_notify_changes" tag tells ibazel to not relaunch the executable on file
# changes. Rather it will communicate with the server implementation through "stdin".
tags = tags + ["ibazel_notify_changes"],
srcs = ["%s_launcher.sh" % name],
data = [":%s_launcher" % name],
testonly = testonly,
)
Loading

0 comments on commit 1c74518

Please sign in to comment.