Skip to content

Commit 1023852

Browse files
committed
feat(builtin): add LinkablePackageInfo to pkg_npm, js_library & ts_library
Design doc: https://hackmd.io/9gqAG5WdSCazzNWyn5OSRw
1 parent 66db579 commit 1023852

9 files changed

Lines changed: 243 additions & 112 deletions

File tree

internal/js_library/js_library.bzl

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""js_library allows defining a set of javascript sources and assigning a module_name and module_root.
15+
"""js_library allows defining a set of javascript sources and assigning a package_name.
1616
1717
DO NOT USE - this is not fully designed, and exists only to enable testing within this repo.
1818
"""
1919

20+
load("//internal/providers:linkable_package_info.bzl", "LinkablePackageInfo")
21+
2022
_AMD_NAMES_DOC = """Mapping from require module names to global variables.
2123
This allows devmode JS sources to load unnamed UMD bundles from third-party libraries."""
2224

@@ -46,23 +48,66 @@ def write_amd_names_shim(actions, amd_names_shim, targets):
4648
amd_names_shim_content += "define(\"%s\", function() { return %s });\n" % n
4749
actions.write(amd_names_shim, amd_names_shim_content)
4850

49-
def _js_library(ctx):
50-
return [
51+
def _impl(ctx):
52+
if not ctx.files.srcs:
53+
fail("No srcs specified")
54+
55+
source_files = ctx.files.srcs[0].is_source
56+
for src in ctx.files.srcs:
57+
if src.is_source != source_files:
58+
fail("Mixing of source and generated files not allowed")
59+
60+
sources_depset = depset(ctx.files.srcs)
61+
62+
result = [
5163
DefaultInfo(
52-
files = depset(ctx.files.srcs),
64+
files = sources_depset,
5365
runfiles = ctx.runfiles(files = ctx.files.srcs),
5466
),
5567
AmdNamesInfo(names = ctx.attr.amd_names),
5668
]
5769

58-
js_library = rule(
59-
implementation = _js_library,
70+
if ctx.attr.package_name:
71+
if source_files:
72+
path = "/".join([p for p in [ctx.label.workspace_root, ctx.label.package] if p])
73+
else:
74+
path = "/".join([p for p in [ctx.bin_dir.path, ctx.label.workspace_root, ctx.label.package] if p])
75+
result.append(LinkablePackageInfo(
76+
package_name = ctx.attr.package_name,
77+
path = path,
78+
files = sources_depset,
79+
))
80+
81+
return result
82+
83+
_js_library = rule(
84+
implementation = _impl,
6085
attrs = {
61-
"srcs": attr.label_list(allow_files = [".js"]),
86+
"package_name": attr.string(),
87+
"srcs": attr.label_list(allow_files = True),
6288
"amd_names": attr.string_dict(doc = _AMD_NAMES_DOC),
63-
"module_from_src": attr.bool(),
64-
# Used to determine module mappings
89+
# module_name for legagy ts_library module_mapping support
90+
# TODO: remove once legacy module_mapping is removed
6591
"module_name": attr.string(),
66-
"module_root": attr.string(),
6792
},
6893
)
94+
95+
def js_library(
96+
name,
97+
srcs,
98+
amd_names = {},
99+
package_name = None,
100+
**kwargs):
101+
module_name = kwargs.pop("module_name", None)
102+
if module_name:
103+
fail("use package_name instead of module_name in target //%s:%s" % (native.package_name(), name))
104+
_js_library(
105+
name = name,
106+
srcs = srcs,
107+
amd_names = amd_names,
108+
package_name = package_name,
109+
# module_name for legagy ts_library module_mapping support
110+
# TODO: remove once legacy module_mapping is removed
111+
module_name = package_name,
112+
**kwargs
113+
)

internal/linker/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This means you need a workflow like `npm link` to symlink the package from the `
1010
Under Bazel, we have exactly this monorepo feature. But, we want users to have a better experience than lerna: they shouldn't need to run any tool other than `bazel test` or `bazel run` and they expect programs to work, even when they `require()` some local package from the monorepo.
1111

1212
To make this seamless, we run a linker as a separate program inside the Bazel action, right before node.
13-
It does essentially the same job as Lerna: make sure there is a `$PWD/node_modules` tree and that all the semantics from Bazel (such as `module_name`/`module_root` attributes) are mapped to the node module resolution algorithm, so that the node runtime behaves the same way as if the packages had been installed from npm.
13+
It does essentially the same job as Lerna: make sure there is a `$PWD/node_modules` tree and that all the semantics from Bazel (such as LinkablePackageInfo provider) are mapped to the node module resolution algorithm, so that the node runtime behaves the same way as if the packages had been installed from npm.
1414

1515
Note that the behavior of the linker depends on whether the package to link was declared as:
1616

internal/linker/index.js

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,16 @@ Include as much of the build output as you can without disclosing anything confi
127127
const fromManifest = runfiles.lookupDirectory(root);
128128
if (fromManifest)
129129
return fromManifest;
130-
// Account for Bazel --legacy_external_runfiles
131-
// which look like 'my_wksp/external/npm/node_modules'
132-
if (yield exists(path.join('external', root))) {
133-
log_verbose('found legacy_external_runfiles, switching root to', path.join('external', root));
130+
if (runfiles.execroot) {
131+
// Under execroot there is an external folder in the root which look
132+
// like 'my_wksp/external/npm/node_modules'
134133
return path.join('external', root);
135134
}
136-
// The repository should be layed out in the parent directory
137-
// since bazel sets our working directory to the repository where the build is happening
138-
return path.join('..', root);
135+
else {
136+
// Under runfiles, the repository should be layed out in the parent directory
137+
// since bazel sets our working directory to the repository where the build is happening
138+
return path.join('..', root);
139+
}
139140
});
140141
}
141142
class Runfiles {
@@ -424,26 +425,12 @@ Include as much of the build output as you can without disclosing anything confi
424425
const [modulesManifest] = args;
425426
let { bin, root, modules, workspace } = JSON.parse(fs.readFileSync(modulesManifest));
426427
modules = modules || {};
427-
log_verbose(`module manifest: workspace ${workspace}, bin ${bin}, root ${root} with first-party packages\n`, modules);
428+
log_verbose(`module manifest '${modulesManifest}': workspace ${workspace}, bin ${bin}, root ${root} with first-party packages\n`, modules);
428429
const rootDir = yield resolveRoot(root, runfiles);
429430
log_verbose('resolved root', root, 'to', rootDir);
430431
log_verbose('cwd', process.cwd());
431432
// Bazel starts actions with pwd=execroot/my_wksp
432433
const workspaceDir = path.resolve('.');
433-
// Convert from runfiles path
434-
// this_wksp/path/to/file OR other_wksp/path/to/file
435-
// to execroot path
436-
// path/to/file OR external/other_wksp/path/to/file
437-
function toWorkspaceDir(p) {
438-
if (p === workspace) {
439-
return '.';
440-
}
441-
// The manifest is written with forward slash on all platforms
442-
if (p.startsWith(workspace + '/')) {
443-
return p.substring(workspace.length + 1);
444-
}
445-
return path.join('external', p);
446-
}
447434
// Create the $pwd/node_modules directory that node will resolve from
448435
yield symlink(rootDir, 'node_modules');
449436
process.chdir(rootDir);
@@ -455,20 +442,44 @@ Include as much of the build output as you can without disclosing anything confi
455442
yield mkdirp(path.dirname(m.name));
456443
if (m.link) {
457444
const [root, modulePath] = m.link;
445+
const externalPrefix = 'external/';
458446
let target = '<package linking failed>';
459447
switch (root) {
460-
case 'bin':
461-
// If we are in the execroot then add the bin path to the target; otherwise
462-
// we are in runfiles and the bin path should be omitted.
463-
// FIXME(#1196)
464-
target = runfiles.execroot ? path.join(workspaceAbs, bin, toWorkspaceDir(modulePath)) :
465-
path.join(workspaceAbs, toWorkspaceDir(modulePath));
466-
break;
467-
case 'src':
468-
target = path.join(workspaceAbs, toWorkspaceDir(modulePath));
448+
case 'execroot':
449+
if (runfiles.execroot) {
450+
target = path.posix.join(workspaceAbs, modulePath);
451+
}
452+
else {
453+
// If under runfiles, convert from execroot path to runfiles path.
454+
// First strip the bin portion if it exists:
455+
let runfilesPath = modulePath;
456+
if (runfilesPath.startsWith(`${bin}/`)) {
457+
runfilesPath = runfilesPath.slice(bin.length + 1);
458+
}
459+
else if (runfilesPath === bin) {
460+
runfilesPath = '';
461+
}
462+
// Next replace `external/` with `../` if it exists:
463+
if (runfilesPath.startsWith(externalPrefix)) {
464+
runfilesPath = `../${runfilesPath.slice(externalPrefix.length)}`;
465+
}
466+
target = path.posix.join(workspaceAbs, runfilesPath);
467+
}
469468
break;
470469
case 'runfiles':
471-
target = runfiles.resolve(modulePath) || '<runfiles resolution failed>';
470+
// Transform execroot path to the runfiles manifest path so that
471+
// it can be resolved with runfiles.resolve()
472+
let runfilesPath = modulePath;
473+
if (runfilesPath.startsWith(`${bin}/`)) {
474+
runfilesPath = runfilesPath.slice(bin.length + 1);
475+
}
476+
if (runfilesPath.startsWith(externalPrefix)) {
477+
runfilesPath = runfilesPath.slice(externalPrefix.length);
478+
}
479+
else {
480+
runfilesPath = `${workspace}/${runfilesPath}`;
481+
}
482+
target = runfiles.resolve(runfilesPath) || '<runfiles resolution failed>';
472483
break;
473484
}
474485
yield symlink(target, m.name);

internal/linker/link_node_modules.bzl

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ linker, which uses the mappings to link a node_modules directory for
1010
runtimes to locate all the first-party packages.
1111
"""
1212

13+
load("//internal/providers:linkable_package_info.bzl", "LinkablePackageInfo")
1314
load("//internal/providers:npm_package_info.bzl", "NpmPackageInfo")
1415

1516
def _debug(vars, *args):
@@ -36,16 +37,21 @@ def add_arg(args, arg):
3637

3738
def _link_mapping(label, mappings, k, v):
3839
if k in mappings and mappings[k] != v:
40+
# Allow all other mappings to win over legacy "_tslibrary"
41+
if mappings[k][0] == "_tslibrary":
42+
return True
43+
elif v[0] == "_tslibrary":
44+
return False
3945
if v[1] == mappings[k][1]:
40-
# Allow "src" and "bin" to win over "runfiles"
46+
# Allow "execroot" to win over "runfiles"
4147
# For example,
4248
# mappings[k] = ["runfiles", "angular/packages/compiler"]
43-
# v = ["bin", "angular/packages/compiler"]
49+
# v = ["execroot", "angular/packages/compiler"]
4450
if mappings[k][0] == "runfiles":
4551
return True
4652
elif v[0] == "runfiles":
4753
return False
48-
fail(("conflicting mapping at %s: %s maps to both %s and %s" % (label, k, mappings[k], v)), "deps")
54+
fail(("conflicting mapping at '%s': '%s' maps to both %s and %s" % (label, k, mappings[k], v)), "deps")
4955
else:
5056
return True
5157

@@ -61,7 +67,7 @@ def write_node_modules_manifest(ctx, extra_data = []):
6167
# We always map the workspace to itself to support absolute require like
6268
# import from 'my_wksp/path/to/file'
6369
# and it's always a bin dependency, TODO: let user control via package.json
64-
ctx.workspace_name: ["bin", ctx.workspace_name],
70+
ctx.workspace_name: ["execroot", ctx.bin_dir.path],
6571
}
6672
node_modules_root = ""
6773

@@ -78,6 +84,10 @@ def write_node_modules_manifest(ctx, extra_data = []):
7884
# ...first-party packages to be linked into the node_modules tree
7985
for k, v in getattr(dep, _ASPECT_RESULT_NAME, {}).items():
8086
if _link_mapping(dep.label, mappings, k, v):
87+
# Special case for ts_library module_name for legacy behavior and for AMD name
88+
# work-around. Do not propogate tslibrary root type to runtime.
89+
if v[0] == "_tslibrary":
90+
v = ["execroot", v[1]]
8191
_debug(ctx.var, "Linking %s: %s" % (k, v))
8292
mappings[k] = v
8393

@@ -110,6 +120,7 @@ def _get_module_mappings(target, ctx):
110120
"""
111121
mappings = {}
112122

123+
# Propogate transitive mappings
113124
for name in _MODULE_MAPPINGS_DEPS_NAMES:
114125
for dep in getattr(ctx.rule.attr, name, []):
115126
for k, v in getattr(dep, _ASPECT_RESULT_NAME, {}).items():
@@ -124,29 +135,31 @@ def _get_module_mappings(target, ctx):
124135
v = ["runfiles", v[1]]
125136
if _link_mapping(target.label, mappings, k, v):
126137
mappings[k] = v
127-
_debug(ctx.var, "target %s propagating module mapping %s: %s" % (dep, k, v))
138+
_debug(ctx.var, "target %s propagating module mapping %s: %s" % (dep.label, k, v))
128139

129-
if not getattr(ctx.rule.attr, "module_name", None) and not getattr(ctx.rule.attr, "module_root", None):
140+
# Look for LinkablePackageInfo mapping in this node
141+
if not LinkablePackageInfo in target:
130142
# No mappings contributed here, short-circuit with the transitive ones we collected
131-
_debug(ctx.var, "No module_name or module_root attr for", target.label)
143+
_debug(ctx.var, "No LinkablePackageInfo for", target.label)
132144
return mappings
133145

134-
# When building a mapping for use at runtime, we need paths to be relative to
135-
# the runfiles directory. This requires the workspace_name to be prefixed on
136-
# each module root.
137-
workspace_name = target.label.workspace_name if target.label.workspace_name else ctx.workspace_name
138-
139-
mn = getattr(ctx.rule.attr, "module_name", target.label.name)
140-
mr = "%s/%s" % (workspace_name, target.label.package)
141-
142-
# since our module mapping is currently based on attribute names,
143-
# allow a special one to instruct the linker that the package has no output
144-
# directory and is therefore meant to be used as sources.
145-
# TODO: This belongs in a different mechanism like a package.json field.
146-
if getattr(ctx.rule.attr, "module_from_src", False):
147-
mr = ["src", mr]
148-
else:
149-
mr = ["bin", mr]
146+
linkable_package_info = target[LinkablePackageInfo]
147+
148+
mn = linkable_package_info.package_name
149+
mr = ["execroot", linkable_package_info.path]
150+
151+
# Special case for ts_library module_name for legacy behavior and for AMD name work-around
152+
# Tag the mapping as "_tslibrary" so it can be overriden by any other mapping if found.
153+
#
154+
# In short, ts_library module_name attribute results in both setting the AMD name (which
155+
# desired and necessary in devmode which outputs UMD) and in making a linkable mapping. Because
156+
# of this, you can get in the situation where a ts_library module_name and a downstream pkg_name
157+
# package_name conflict and result in duplicate mappings. This work-around will make this
158+
# situation work however it is not a recommended pattern since a ts_library can be a dep of a
159+
# pkg_npm but not vice-verse at the moment since ts_library cannot handle directory artifacts as
160+
# deps.
161+
if hasattr(linkable_package_info, "_tslibrary") and linkable_package_info._tslibrary:
162+
mr[0] = "_tslibrary"
150163

151164
if _link_mapping(target.label, mappings, mn, mr):
152165
_debug(ctx.var, "target %s adding module mapping %s: %s" % (target.label, mn, mr))

0 commit comments

Comments
 (0)