Skip to content

Commit 5d70997

Browse files
mrmekuAlex EagleDan Muller
committed
feat(typescript): worker mode for ts_project (#2136)
* feat(typescript): worker mode for ts_project * chore: cleanup and declare protobufjs dependency * fix: do not pass --watch for non-worker mode * fix: do not test worker on windows * fix: log on standalone failures * chore: docs improvements Co-authored-by: Alex Eagle <alex.eagle@robinhood.com> Co-authored-by: Dan Muller <mrmeku@stairwell.com>
1 parent 42412c6 commit 5d70997

11 files changed

Lines changed: 292 additions & 8 deletions

File tree

examples/react_webpack/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ sass(
1414
)
1515

1616
ts_project(
17+
# Experimental: Start a tsc daemon to watch for changes to make recompiles faster.
18+
supports_workers = True,
1719
deps = [
1820
"@npm//@types",
1921
"@npm//csstype",
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
{
22
"compilerOptions": {
33
"jsx": "react",
4-
"lib": ["ES2015", "DOM"]
5-
}
6-
}
4+
"lib": [
5+
"ES2015",
6+
"DOM"
7+
]
8+
},
9+
// When using ts_project in worker mode, we run outside the Bazel sandbox (unless using --worker_sandboxing).
10+
// We list the files that should be part of this particular compilation to avoid TypeScript discovering others.
11+
"include": [
12+
"*.tsx",
13+
"*.ts"
14+
]
15+
}

internal/node/node.bzl

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _trim_package_node_modules(package_name):
3737
for n in package_name.split("/"):
3838
if n == "node_modules":
3939
break
40-
segments += [n]
40+
segments.append(n)
4141
return "/".join(segments)
4242

4343
def _compute_node_modules_root(ctx):
@@ -150,6 +150,9 @@ def _to_execroot_path(ctx, file):
150150

151151
return file.path
152152

153+
def _join(*elements):
154+
return "/".join([f for f in elements if f])
155+
153156
def _nodejs_binary_impl(ctx):
154157
node_modules_manifest = write_node_modules_manifest(ctx, link_workspace_root = ctx.attr.link_workspace_root)
155158
node_modules_depsets = []
@@ -250,7 +253,12 @@ fi
250253
expanded_args = [expand_location_into_runfiles(ctx, a, ctx.attr.data) for a in expanded_args]
251254

252255
# Next expand predefined variables & custom variables
253-
expanded_args = [ctx.expand_make_variables("templated_args", e, {}) for e in expanded_args]
256+
rule_dir = _join(ctx.bin_dir.path, ctx.label.workspace_root, ctx.label.package)
257+
additional_substitutions = {
258+
"@D": rule_dir,
259+
"RULEDIR": rule_dir,
260+
}
261+
expanded_args = [ctx.expand_make_variables("templated_args", e, additional_substitutions) for e in expanded_args]
254262

255263
substitutions = {
256264
# TODO: Split up results of multifile expansions into separate args and qoute them with

packages/typescript/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ pkg_npm(
9696
":npm_version_check",
9797
"//packages/typescript/internal:BUILD",
9898
"//packages/typescript/internal:ts_project_options_validator.js",
99+
"//packages/typescript/internal/worker",
99100
] + select({
100101
# FIXME: fix stardoc on Windows; //packages/typescript:index.md generation fails with:
101102
# ERROR: D:/b/62unjjin/external/npm_bazel_typescript/BUILD.bazel:36:1: Couldn't build file

packages/typescript/internal/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ filegroup(
6363
"build_defs.bzl",
6464
"ts_config.bzl",
6565
"ts_project.bzl",
66+
"//packages/typescript/internal/worker:package_contents",
6667
],
6768
visibility = ["//packages/typescript:__subpackages__"],
6869
)

packages/typescript/internal/ts_project.bzl

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
load("@build_bazel_rules_nodejs//:providers.bzl", "DeclarationInfo", "NpmPackageInfo", "declaration_info", "js_module_info", "run_node")
44
load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "module_mappings_aspect")
5+
load("@build_bazel_rules_nodejs//internal/node:node.bzl", "nodejs_binary")
56
load(":ts_config.bzl", "TsConfigInfo", "write_tsconfig")
67

78
_ValidOptionsInfo = provider()
@@ -13,6 +14,20 @@ _DEFAULT_TSC = (
1314
"//typescript/bin:tsc"
1415
)
1516

17+
_DEFAULT_TSC_BIN = (
18+
# BEGIN-INTERNAL
19+
"@npm" +
20+
# END-INTERNAL
21+
"//:node_modules/typescript/bin/tsc"
22+
)
23+
24+
_DEFAULT_TYPESCRIPT_MODULE = (
25+
# BEGIN-INTERNAL
26+
"@npm" +
27+
# END-INTERNAL
28+
"//typescript"
29+
)
30+
1631
_ATTRS = {
1732
"args": attr.string_list(),
1833
"declaration_dir": attr.string(),
@@ -33,7 +48,14 @@ _ATTRS = {
3348
# if you swap out the `compiler` attribute (like with ngtsc)
3449
# that compiler might allow more sources than tsc does.
3550
"srcs": attr.label_list(allow_files = True, mandatory = True),
36-
"tsc": attr.label(default = Label(_DEFAULT_TSC), executable = True, cfg = "host"),
51+
"supports_workers": attr.bool(
52+
doc = """Experimental! Use only with caution.
53+
54+
Allows you to enable the Bazel Worker strategy for this project.
55+
This requires that the tsc binary support it.""",
56+
default = False,
57+
),
58+
"tsc": attr.label(default = Label(_DEFAULT_TSC), executable = True, cfg = "target"),
3759
"tsconfig": attr.label(mandatory = True, allow_single_file = [".json"]),
3860
}
3961

@@ -56,6 +78,16 @@ def _join(*elements):
5678

5779
def _ts_project_impl(ctx):
5880
arguments = ctx.actions.args()
81+
execution_requirements = {}
82+
progress_prefix = "Compiling TypeScript project"
83+
84+
if ctx.attr.supports_workers:
85+
# Set to use a multiline param-file for worker mode
86+
arguments.use_param_file("@%s", use_always = True)
87+
arguments.set_param_file_format("multiline")
88+
execution_requirements["supports-workers"] = "1"
89+
execution_requirements["worker-key-mnemonic"] = "TsProject"
90+
progress_prefix = "Compiling TypeScript project (worker mode)"
5991

6092
generated_srcs = False
6193
for src in ctx.files.srcs:
@@ -164,7 +196,9 @@ def _ts_project_impl(ctx):
164196
arguments = [arguments],
165197
outputs = outputs,
166198
executable = "tsc",
167-
progress_message = "Compiling TypeScript project %s [tsc -p %s]" % (
199+
execution_requirements = execution_requirements,
200+
progress_message = "%s %s [tsc -p %s]" % (
201+
progress_prefix,
168202
ctx.label,
169203
ctx.file.tsconfig.short_path,
170204
),
@@ -289,7 +323,10 @@ def ts_project_macro(
289323
emit_declaration_only = False,
290324
ts_build_info_file = None,
291325
tsc = None,
326+
worker_tsc_bin = _DEFAULT_TSC_BIN,
327+
worker_typescript_module = _DEFAULT_TYPESCRIPT_MODULE,
292328
validate = True,
329+
supports_workers = False,
293330
declaration_dir = None,
294331
out_dir = None,
295332
root_dir = None,
@@ -450,8 +487,28 @@ def ts_project_macro(
450487
For example, `tsc = "@my_deps//typescript/bin:tsc"`
451488
Or you can pass a custom compiler binary instead.
452489
490+
worker_tsc_bin: Label of the TypeScript compiler binary to run when running in worker mode.
491+
492+
For example, `tsc = "@my_deps//node_modules/typescript/bin/tsc"`
493+
Or you can pass a custom compiler binary instead.
494+
495+
worker_typescript_module: Label of the package containing all data deps of worker_tsc_bin.
496+
497+
For example, `tsc = "@my_deps//typescript"`
498+
453499
validate: boolean; whether to check that the tsconfig settings match the attributes.
454500
501+
supports_workers: Experimental! Use only with caution.
502+
503+
Allows you to enable the Bazel Persistent Workers strategy for this project.
504+
See https://docs.bazel.build/versions/master/persistent-workers.html
505+
506+
This requires that the tsc binary support a `--watch` option.
507+
508+
NOTE: this does not work on Windows yet.
509+
We will silently fallback to non-worker mode on Windows regardless of the value of this attribute.
510+
Follow https://github.com/bazelbuild/rules_nodejs/issues/2277 for progress on this feature.
511+
455512
root_dir: a string specifying a subdirectory under the input package which should be consider the
456513
root directory of all the input files.
457514
Equivalent to the TypeScript --rootDir option.
@@ -556,6 +613,38 @@ def ts_project_macro(
556613
)
557614
extra_deps.append("_validate_%s_options" % name)
558615

616+
if supports_workers:
617+
tsc_worker = "%s_worker" % name
618+
protobufjs = (
619+
# BEGIN-INTERNAL
620+
"@npm" +
621+
# END-INTERNAL
622+
"//protobufjs"
623+
)
624+
nodejs_binary(
625+
name = tsc_worker,
626+
data = [
627+
Label("//packages/typescript/internal/worker:worker"),
628+
Label(worker_tsc_bin),
629+
Label(worker_typescript_module),
630+
Label(protobufjs),
631+
tsconfig,
632+
],
633+
entry_point = Label("//packages/typescript/internal/worker:worker_adapter"),
634+
templated_args = [
635+
"--nobazel_patch_module_resolver",
636+
"$(execpath {})".format(Label(worker_tsc_bin)),
637+
"--project",
638+
"$(execpath {})".format(tsconfig),
639+
# FIXME: should take out_dir into account
640+
"--outDir",
641+
"$(RULEDIR)",
642+
# FIXME: what about other settings like declaration_dir, root_dir, etc
643+
],
644+
)
645+
646+
tsc = ":" + tsc_worker
647+
559648
typings_out_dir = declaration_dir if declaration_dir else out_dir
560649
tsbuildinfo_path = ts_build_info_file if ts_build_info_file else name + ".tsbuildinfo"
561650

@@ -576,5 +665,9 @@ def ts_project_macro(
576665
buildinfo_out = tsbuildinfo_path if composite or incremental else None,
577666
tsc = tsc,
578667
link_workspace_root = link_workspace_root,
668+
supports_workers = select({
669+
"@bazel_tools//src/conditions:host_windows": False,
670+
"//conditions:default": supports_workers,
671+
}),
579672
**kwargs
580673
)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# BEGIN-INTERNAL
2+
3+
load("//internal/common:copy_to_bin.bzl", "copy_to_bin")
4+
load("//third_party/github.com/bazelbuild/bazel-skylib:rules/copy_file.bzl", "copy_file")
5+
6+
# Copy the proto file to a matching third_party/... nested directory
7+
# so the runtime require() statements still work
8+
_worker_proto_dir = "third_party/github.com/bazelbuild/bazel/src/main/protobuf"
9+
10+
genrule(
11+
name = "copy_worker_js",
12+
srcs = ["//packages/worker:npm_package"],
13+
outs = ["worker.js"],
14+
cmd = "cp $(execpath //packages/worker:npm_package)/index.js $@",
15+
visibility = ["//visibility:public"],
16+
)
17+
18+
copy_file(
19+
name = "copy_worker_proto",
20+
src = "@build_bazel_rules_typescript//%s:worker_protocol.proto" % _worker_proto_dir,
21+
out = "%s/worker_protocol.proto" % _worker_proto_dir,
22+
visibility = ["//visibility:public"],
23+
)
24+
25+
copy_to_bin(
26+
name = "worker_adapter",
27+
srcs = [
28+
"worker_adapter.js",
29+
],
30+
visibility = ["//visibility:public"],
31+
)
32+
33+
filegroup(
34+
name = "package_contents",
35+
srcs = [
36+
"BUILD.bazel",
37+
],
38+
visibility = ["//packages/typescript:__subpackages__"],
39+
)
40+
41+
# END-INTERNAL
42+
43+
exports_files([
44+
"worker_adapter.js",
45+
])
46+
47+
filegroup(
48+
name = "worker",
49+
srcs = [
50+
"third_party/github.com/bazelbuild/bazel/src/main/protobuf/worker_protocol.proto",
51+
"worker.js",
52+
"worker_adapter.js",
53+
],
54+
visibility = ["//visibility:public"],
55+
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @fileoverview wrapper program around the TypeScript compiler, tsc
3+
*
4+
* It intercepts the Bazel Persistent Worker protocol, using it to remote-control tsc running as a
5+
* child process. In between builds, the tsc process is stopped (akin to ctrl-z in a shell) and then
6+
* resumed (akin to `fg`) when the inputs have changed.
7+
*
8+
* See https://medium.com/@mmorearty/how-to-create-a-persistent-worker-for-bazel-7738bba2cabb
9+
* for more background (note, that is documenting a different implementation)
10+
*/
11+
const child_process = require('child_process');
12+
const MNEMONIC = 'TsProject';
13+
const worker = require('./worker');
14+
15+
const workerArg = process.argv.indexOf('--persistent_worker')
16+
if (workerArg > 0) {
17+
process.argv.splice(workerArg, 1, '--watch')
18+
19+
if (process.platform !== 'linux' && process.platform !== 'darwin') {
20+
throw new Error(`Worker mode is only supported on linux and darwin, not ${process.platform}.
21+
See https://github.com/bazelbuild/rules_nodejs/issues/2277`);
22+
}
23+
}
24+
25+
const [tscBin, ...tscArgs] = process.argv.slice(2);
26+
27+
const child = child_process.spawn(
28+
tscBin,
29+
tscArgs,
30+
{stdio: 'pipe'},
31+
);
32+
function awaitOneBuild() {
33+
child.kill('SIGCONT')
34+
35+
let buffer = [];
36+
return new Promise((res) => {
37+
function awaitBuild(s) {
38+
buffer.push(s);
39+
40+
if (s.includes('Watching for file changes.')) {
41+
child.kill('SIGSTOP')
42+
43+
const success = s.includes('Found 0 errors.');
44+
res(success);
45+
46+
child.stdout.removeListener('data', awaitBuild);
47+
48+
if (!success) {
49+
console.error(
50+
`\nError output from tsc worker:\n\n ${
51+
buffer.slice(1).map(s => s.toString()).join('').replace(/\n/g, '\n ')}`,
52+
)
53+
}
54+
55+
buffer = [];
56+
}
57+
};
58+
child.stdout.on('data', awaitBuild);
59+
});
60+
}
61+
62+
async function main() {
63+
// Bazel will pass a special argument to the program when it's running us as a worker
64+
if (workerArg > 0) {
65+
worker.log(`Running ${MNEMONIC} as a Bazel worker`);
66+
67+
worker.runWorkerLoop(awaitOneBuild);
68+
} else {
69+
// Running standalone so stdout is available as usual
70+
console.log(`Running ${MNEMONIC} as a standalone process`);
71+
console.error(
72+
`Started a new process to perform this action. Your build might be misconfigured, try
73+
--strategy=${MNEMONIC}=worker`);
74+
75+
const stdoutbuffer = [];
76+
child.stdout.on('data', data => stdoutbuffer.push(data));
77+
78+
const stderrbuffer = [];
79+
child.stderr.on('data', data => stderrbuffer.push(data));
80+
81+
child.on('exit', code => {
82+
if (code !== 0) {
83+
console.error(
84+
`\nstdout from tsc:\n\n ${
85+
stdoutbuffer.map(s => s.toString()).join('').replace(/\n/g, '\n ')}`,
86+
)
87+
console.error(
88+
`\nstderr from tsc:\n\n ${
89+
stderrbuffer.map(s => s.toString()).join('').replace(/\n/g, '\n ')}`,
90+
)
91+
}
92+
process.exit(code)
93+
});
94+
}
95+
}
96+
97+
if (require.main === module) {
98+
main();
99+
}

0 commit comments

Comments
 (0)