Skip to content

Commit 1c74518

Browse files
devversionjelbourn
authored andcommitted
build: create custom bazel dev-server rule (#16937)
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 (bazel-contrib/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)
1 parent ce71a45 commit 1c74518

File tree

10 files changed

+898
-43
lines changed

10 files changed

+898
-43
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@firebase/app-types": "^0.3.2",
7676
"@octokit/rest": "^16.28.7",
7777
"@schematics/angular": "^8.2.1",
78+
"@types/browser-sync": "^2.26.1",
7879
"@types/chalk": "^0.4.31",
7980
"@types/fs-extra": "^4.0.3",
8081
"@types/glob": "^5.0.33",
@@ -90,8 +91,10 @@
9091
"@types/node": "^7.0.21",
9192
"@types/parse5": "^5.0.0",
9293
"@types/run-sequence": "^0.0.29",
94+
"@types/send": "^0.14.5",
9395
"autoprefixer": "^6.7.6",
9496
"axe-webdriverjs": "^1.1.1",
97+
"browser-sync": "^2.26.7",
9598
"chalk": "^1.1.3",
9699
"clang-format": "^1.2.4",
97100
"codelyzer": "^5.1.0",
@@ -141,6 +144,7 @@
141144
"run-sequence": "^1.2.2",
142145
"scss-bundle": "^2.0.1-beta.7",
143146
"selenium-webdriver": "^3.6.0",
147+
"send": "^0.17.1",
144148
"shelljs": "^0.8.3",
145149
"sorcery": "^0.10.0",
146150
"stylelint": "^10.1.0",

src/dev-app/BUILD.bazel

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package(default_visibility = ["//visibility:public"])
22

33
load("@io_bazel_rules_sass//:defs.bzl", "sass_binary")
4-
load("@npm_bazel_typescript//:defs.bzl", "ts_devserver")
54
load("//:packages.bzl", "MATERIAL_EXPERIMENTAL_SCSS_LIBS")
65
load("//tools:defaults.bzl", "ng_module")
6+
load("//tools/dev-server:index.bzl", "dev_server")
77

88
ng_module(
99
name = "dev-app",
@@ -90,13 +90,12 @@ sass_binary(
9090
] + MATERIAL_EXPERIMENTAL_SCSS_LIBS,
9191
)
9292

93-
ts_devserver(
93+
dev_server(
9494
name = "devserver",
95-
additional_root_paths = [
96-
"npm/node_modules",
97-
],
98-
port = 4200,
99-
static_files = [
95+
srcs = [
96+
"index.html",
97+
"system-config.js",
98+
"system-rxjs-operators.js",
10099
":theme",
101100
"//src/dev-app/icon:icon_demo_assets",
102101
"@npm//:node_modules/@material/animation/dist/mdc.animation.js",
@@ -137,9 +136,9 @@ ts_devserver(
137136
"@npm//:node_modules/systemjs/dist/system.js",
138137
"@npm//:node_modules/tslib/tslib.js",
139138
"@npm//:node_modules/zone.js/dist/zone.js",
140-
"index.html",
141-
"system-config.js",
142-
"system-rxjs-operators.js",
139+
],
140+
additional_root_paths = [
141+
"npm/node_modules",
143142
],
144143
deps = [
145144
":dev-app",

src/dev-app/index.html

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,6 @@
2424
<body>
2525
<dev-app>Loading...</dev-app>
2626
</body>
27-
<!--
28-
Sets up the live reloading script from ibazel if present. This is a workaround
29-
and will not work if the port changes (in case it is already used).
30-
TODO(devversion): replace once https://github.com/bazelbuild/rules_nodejs/issues/1036 is fixed.
31-
-->
32-
<script src="http://localhost:35729/livereload.js?snipver=1" async></script>
3327
<script src="core-js/client/core.js"></script>
3428
<script src="zone.js/dist/zone.js"></script>
3529
<script src="hammerjs/hammer.min.js"></script>

tools/dev-server/BUILD.bazel

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package(default_visibility = ["//visibility:public"])
2+
3+
load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
4+
load("//tools:defaults.bzl", "ts_library")
5+
6+
exports_files(["launcher_template.sh"])
7+
8+
ts_library(
9+
name = "dev-server_lib",
10+
srcs = [
11+
"dev-server.ts",
12+
"ibazel.ts",
13+
"main.ts",
14+
],
15+
deps = [
16+
"@npm//@types/browser-sync",
17+
"@npm//@types/minimist",
18+
"@npm//@types/node",
19+
"@npm//@types/send",
20+
"@npm//browser-sync",
21+
"@npm//minimist",
22+
"@npm//send",
23+
],
24+
)
25+
26+
nodejs_binary(
27+
name = "dev-server_bin",
28+
data = [
29+
":dev-server_lib",
30+
],
31+
entry_point = ":main.ts",
32+
)

tools/dev-server/dev-server.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as browserSync from 'browser-sync';
10+
import * as http from 'http';
11+
import * as path from 'path';
12+
import * as send from 'send';
13+
14+
/**
15+
* Dev Server implementation that uses browser-sync internally. This dev server
16+
* supports Bazel runfile resolution in order to make it work in a Bazel sandbox
17+
* environment and on Windows (with a runfile manifest file).
18+
*/
19+
export class DevServer {
20+
/** Instance of the browser-sync server. */
21+
server = browserSync.create();
22+
23+
/** Options of the browser-sync server. */
24+
options: browserSync.Options = {
25+
open: false,
26+
port: this.port,
27+
notify: false,
28+
ghostMode: false,
29+
server: true,
30+
middleware: (req, res) => this._bazelMiddleware(req, res),
31+
};
32+
33+
constructor(
34+
readonly port: number, private _rootPaths: string[],
35+
private _historyApiFallback: boolean = false) {}
36+
37+
/** Starts the server on the given port. */
38+
async start() {
39+
return new Promise((resolve, reject) => {
40+
this.server.init(this.options, (err) => {
41+
if (err) {
42+
reject(err);
43+
} else {
44+
resolve();
45+
}
46+
});
47+
});
48+
}
49+
50+
/** Reloads all browsers that currently visit a page from the server. */
51+
reload() {
52+
this.server.reload();
53+
}
54+
55+
/**
56+
* Middleware function used by BrowserSync. This function is responsible for
57+
* Bazel runfile resolution and HTML History API support.
58+
*/
59+
private _bazelMiddleware(req: http.IncomingMessage, res: http.ServerResponse) {
60+
if (!req.url) {
61+
res.end('No url specified. Error');
62+
return;
63+
}
64+
65+
// Implements the HTML history API fallback logic based on the requirements of the
66+
// "connect-history-api-fallback" package. See the conditions for a request being redirected
67+
// to the index: https://github.com/bripkens/connect-history-api-fallback#introduction
68+
if (this._historyApiFallback && req.method === 'GET' && !req.url.includes('.') &&
69+
req.headers.accept && req.headers.accept.includes('text/html')) {
70+
req.url = '/index.html';
71+
}
72+
73+
const resolvedPath = this._resolveUrlFromRunfiles(req.url);
74+
75+
if (resolvedPath === null) {
76+
res.statusCode = 404;
77+
res.end('Page not found');
78+
return;
79+
}
80+
81+
send(req, resolvedPath).pipe(res);
82+
}
83+
84+
/** Resolves a given URL from the runfiles using the corresponding manifest path. */
85+
private _resolveUrlFromRunfiles(url: string): string|null {
86+
// Remove the leading slash from the URL. Manifest paths never
87+
// start with a leading slash.
88+
const manifestPath = url.substring(1);
89+
for (let rootPath of this._rootPaths) {
90+
try {
91+
return require.resolve(path.posix.join(rootPath, manifestPath));
92+
} catch {
93+
}
94+
}
95+
return null;
96+
}
97+
}

tools/dev-server/ibazel.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {createInterface} from 'readline';
10+
import {DevServer} from './dev-server';
11+
12+
// ibazel will write this string after a successful build.
13+
const ibazelNotifySuccessMessage = 'IBAZEL_BUILD_COMPLETED SUCCESS';
14+
15+
/**
16+
* Sets up ibazel support for the specified devserver. ibazel communicates with
17+
* an executable over the "stdin" interface. Whenever a specific message is sent
18+
* over "stdin", the devserver can be reloaded.
19+
*/
20+
export function setupBazelWatcherSupport(server: DevServer) {
21+
// ibazel communicates via the stdin interface.
22+
const rl = createInterface({input: process.stdin, terminal: false});
23+
24+
rl.on('line', (chunk: string) => {
25+
if (chunk === ibazelNotifySuccessMessage) {
26+
server.reload();
27+
}
28+
});
29+
30+
rl.on('close', () => {
31+
// Give ibazel 5s to kill this process, otherwise we exit the process manually.
32+
setTimeout(() => {
33+
console.error('ibazel failed to stop the devserver after 5s.');
34+
process.exit(1);
35+
}, 5000);
36+
});
37+
}

tools/dev-server/index.bzl

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
load("@build_bazel_rules_nodejs//internal/common:sources_aspect.bzl", "sources_aspect")
2+
3+
"""Gets the workspace name of the given rule context."""
4+
5+
def _get_workspace_name(ctx):
6+
if ctx.label.workspace_root:
7+
# We need the workspace_name for the target being visited.
8+
# Starlark doesn't have this - instead they have a workspace_root
9+
# which looks like "external/repo_name" - so grab the second path segment.
10+
return ctx.label.workspace_root.split("/")[1]
11+
else:
12+
return ctx.workspace_name
13+
14+
"""Implementation of the dev server rule."""
15+
16+
def _dev_server_rule_impl(ctx):
17+
files = depset(ctx.files.srcs)
18+
19+
# List of files which are required for the devserver to run. This includes the
20+
# bazel runfile helpers (to resolve runfiles in bash) and the devserver binary
21+
# with its transitive runfiles (in order to be able to run the devserver).
22+
required_tools = ctx.files._bash_runfile_helpers + \
23+
ctx.files._dev_server_bin + \
24+
ctx.attr._dev_server_bin[DefaultInfo].files.to_list() + \
25+
ctx.attr._dev_server_bin[DefaultInfo].data_runfiles.files.to_list()
26+
27+
# Walk through all dependencies specified in the "deps" attribute. These labels need to be
28+
# unwrapped in case there are built using TypeScript-specific rules. This is because targets
29+
# built using "ts_library" or "ng_module" do not declare the generated JS files as default
30+
# rule output. The output aspect that is applied to the "deps" attribute, provides two struct
31+
# fields which resolve to the unwrapped JS output files.
32+
# https://github.com/bazelbuild/rules_nodejs/blob/e04c8c31f3cb859754ea5c5e97f331a3932b725d/internal/common/sources_aspect.bzl#L53-L55
33+
for d in ctx.attr.deps:
34+
if hasattr(d, "node_sources"):
35+
files = depset(transitive = [files, d.node_sources])
36+
elif hasattr(d, "files"):
37+
files = depset(transitive = [files, d.files])
38+
if hasattr(d, "dev_scripts"):
39+
files = depset(transitive = [files, d.dev_scripts])
40+
41+
workspace_name = _get_workspace_name(ctx)
42+
root_paths = ["", "/".join([workspace_name, ctx.label.package])] + ctx.attr.additional_root_paths
43+
44+
# We can't use "ctx.actions.args()" because there is no way to convert the args object
45+
# into a string representing the command line arguments. It looks like bazel has some
46+
# internal logic to compute the string representation of "ctx.actions.args()".
47+
args = '--root_paths="%s" ' % ",".join(root_paths)
48+
args += "--port=%s " % ctx.attr.port
49+
50+
if ctx.attr.historyApiFallback:
51+
args += "--historyApiFallback "
52+
53+
ctx.actions.expand_template(
54+
template = ctx.file._launcher_template,
55+
output = ctx.outputs.launcher,
56+
substitutions = {
57+
"TEMPLATED_args": args,
58+
},
59+
is_executable = True,
60+
)
61+
62+
return [
63+
DefaultInfo(runfiles = ctx.runfiles(
64+
files = files.to_list() + required_tools,
65+
collect_data = True,
66+
collect_default = True,
67+
)),
68+
]
69+
70+
dev_server_rule = rule(
71+
implementation = _dev_server_rule_impl,
72+
outputs = {
73+
"launcher": "%{name}.sh",
74+
},
75+
attrs = {
76+
"srcs": attr.label_list(allow_files = True, doc = """
77+
Sources that should be available to the dev-server. This attribute can be
78+
used for explicit files. This attribute only uses the files exposed by the
79+
DefaultInfo provider (i.e. TypeScript targets should be added to "deps").
80+
"""),
81+
"additional_root_paths": attr.string_list(doc = """
82+
Additionally paths to serve files from. The paths should be formatted
83+
as manifest paths (e.g. "my_workspace/src")
84+
"""),
85+
"historyApiFallback": attr.bool(
86+
default = True,
87+
doc = """
88+
Whether the devserver should fallback to "/index.html" for non-file requests.
89+
This is helpful for single page applications using the HTML history API.
90+
""",
91+
),
92+
"port": attr.int(
93+
default = 4200,
94+
doc = """The port that the devserver will listen on.""",
95+
),
96+
"deps": attr.label_list(
97+
allow_files = True,
98+
aspects = [sources_aspect],
99+
doc = """
100+
Dependencies that need to be available to the dev-server. This attribute can be
101+
used for TypeScript targets which provide multiple flavors of output.
102+
""",
103+
),
104+
"_bash_runfile_helpers": attr.label(default = Label("@bazel_tools//tools/bash/runfiles")),
105+
"_dev_server_bin": attr.label(
106+
default = Label("//tools/dev-server:dev-server_bin"),
107+
),
108+
"_launcher_template": attr.label(allow_single_file = True, default = Label("//tools/dev-server:launcher_template.sh")),
109+
},
110+
)
111+
112+
"""
113+
Creates a dev server that can depend on individual bazel targets. The server uses
114+
bazel runfile resolution in order to work with Bazel package paths. e.g. developers can
115+
request files through their manifest path: "my_workspace/src/dev-app/my-genfile".
116+
"""
117+
118+
def dev_server(name, testonly = False, tags = [], **kwargs):
119+
dev_server_rule(
120+
name = "%s_launcher" % name,
121+
visibility = ["//visibility:private"],
122+
tags = tags,
123+
**kwargs
124+
)
125+
126+
native.sh_binary(
127+
name = name,
128+
# The "ibazel_notify_changes" tag tells ibazel to not relaunch the executable on file
129+
# changes. Rather it will communicate with the server implementation through "stdin".
130+
tags = tags + ["ibazel_notify_changes"],
131+
srcs = ["%s_launcher.sh" % name],
132+
data = [":%s_launcher" % name],
133+
testonly = testonly,
134+
)

0 commit comments

Comments
 (0)