Skip to content

Commit 119716e

Browse files
devversionzarend
authored andcommitted
build: generate linked ESM bundles of framework packages
As of APF v13, Angular packages come with partial compilation output. This means that for tests, or running the dev-app we want to run the linker to avoid JIT compilation of the FW code. This commit introduces a macro for generating linked ESM bundles of framework packages and their entry-points. These bundles can then be passed later to tests or the dev-app, so that it would not need to run the linker at all. This helps speeding up the development turnaround as the linker would not need to re-run *always*..
1 parent 43047c1 commit 119716e

File tree

8 files changed

+292
-17
lines changed

8 files changed

+292
-17
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
"@rollup/plugin-commonjs": "^20.0.0",
147147
"@rollup/plugin-node-resolve": "^13.0.5",
148148
"@schematics/angular": "13.0.0-next.7",
149+
"@types/babel__core": "^7.1.16",
149150
"@types/browser-sync": "^2.26.1",
150151
"@types/fs-extra": "^9.0.5",
151152
"@types/glob": "^7.1.3",

tools/angular/BUILD.bazel

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
2+
load("//tools/esbuild:index.bzl", "esbuild_config")
3+
load(":index.bzl", "create_angular_bundle_targets")
4+
5+
package(default_visibility = ["//visibility:public"])
6+
7+
js_library(
8+
name = "create_linker_esbuild_plugin",
9+
srcs = ["create_linker_esbuild_plugin.mjs"],
10+
deps = [
11+
"@npm//@angular/compiler-cli",
12+
"@npm//@babel/core",
13+
],
14+
)
15+
16+
esbuild_config(
17+
name = "esbuild_config",
18+
config_file = "esbuild.config.mjs",
19+
deps = [
20+
":create_linker_esbuild_plugin",
21+
],
22+
)
23+
24+
create_angular_bundle_targets()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 fs from 'fs';
10+
11+
/** Naively checks whether this node path resolves to an Angular declare invocation. */
12+
function isNgDeclareCallExpression(nodePath) {
13+
if (!nodePath.node.name.startsWith('ɵɵngDeclare')) {
14+
return false;
15+
}
16+
17+
// Expect the `ngDeclare` identifier to be used as part of a property access that
18+
// is invoked within a call expression. e.g. `i0.ɵɵngDeclare<>`.
19+
return nodePath.parentPath?.type === 'MemberExpression' &&
20+
nodePath.parentPath.parentPath?.type === 'CallExpression';
21+
}
22+
23+
/** Asserts that the given AST does not contain any Angular partial declaration. */
24+
async function assertNoPartialDeclaration(filePath, ast, traverseFn) {
25+
// Naively check if there are any Angular declarations left that haven't been linked.
26+
traverseFn(ast, {
27+
Identifier: (astPath) => {
28+
if (isNgDeclareCallExpression(astPath)) {
29+
throw astPath.buildCodeFrameError(
30+
`Found Angular declaration that has not been linked. ${filePath}`, Error);
31+
}
32+
}
33+
});
34+
}
35+
36+
/**
37+
* Creates an ESBuild plugin for running the Angular linker resolved sources.
38+
*
39+
* @param filter Mandatory file path filter for the ESBuild plugin to apply to. Read
40+
* more here: https://esbuild.github.io/plugins/#filters.
41+
* @param ensureNoPartialDeclaration Whether an additional check ensuring there are
42+
* no partial declarations should run.
43+
*/
44+
export async function createLinkerEsbuildPlugin(filter, ensureNoPartialDeclaration) {
45+
// Note: We load all dependencies asynchronously so that these large dependencies
46+
// do not slow-down bundling when the linker plugin is not actually created.
47+
const {NodeJSFileSystem, ConsoleLogger, LogLevel} = await import('@angular/compiler-cli');
48+
const {createEs2015LinkerPlugin} = await import('@angular/compiler-cli/linker/babel');
49+
const {default: babel} = await import('@babel/core');
50+
51+
const linkerBabelPlugin = createEs2015LinkerPlugin({
52+
fileSystem: new NodeJSFileSystem(),
53+
logger: new ConsoleLogger(LogLevel.warn),
54+
// We enable JIT mode as unit tests also will rely on the linked ESM files.
55+
linkerJitMode: true,
56+
});
57+
58+
return {
59+
name: 'ng-linker-esbuild',
60+
setup: (build) => {
61+
build.onLoad({filter}, async (args) => {
62+
const filePath = args.path;
63+
const content = await fs.promises.readFile(filePath, 'utf8');
64+
const {ast, code} = await babel.transformAsync(content, {
65+
filename: filePath,
66+
filenameRelative: filePath,
67+
plugins: [linkerBabelPlugin],
68+
sourceMaps: 'inline',
69+
ast: true,
70+
compact: false,
71+
});
72+
73+
if (ensureNoPartialDeclaration) {
74+
await assertNoPartialDeclaration(filePath, ast, babel.traverse);
75+
}
76+
77+
return {contents: code};
78+
});
79+
},
80+
};
81+
}

tools/angular/esbuild.config.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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 {createLinkerEsbuildPlugin} from './create_linker_esbuild_plugin.mjs';
10+
11+
export default {
12+
resolveExtensions: ['.mjs', '.js'],
13+
format: 'esm',
14+
plugins: [
15+
// Only run the linker on `fesm2020/` bundles. This should not have an effect on
16+
// the bundle output, but helps speeding up ESBuild when it visits other modules.
17+
await createLinkerEsbuildPlugin(/fesm2020/)
18+
]
19+
};

tools/angular/index.bzl

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
load("//:packages.bzl", "ANGULAR_PACKAGES")
2+
load("//tools/esbuild:index.bzl", "esbuild")
3+
load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "LinkerPackageMappingInfo")
4+
load("@build_bazel_rules_nodejs//:providers.bzl", "ExternalNpmPackageInfo", "JSModuleInfo")
5+
6+
"""
7+
Starlark file exposing a definition for generating Angular linker-processed ESM bundles
8+
for all entry-points the Angular framework packages expose.
9+
10+
These linker-processed ESM bundles are useful as they can be integrated into the
11+
spec bundling, or dev-app to avoid unnecessary re-linking of framework entry-points
12+
every time the bundler executes. This helps with the overall development turnaround and
13+
is more idiomatic as it allows caching of the Angular framework packages.
14+
"""
15+
16+
def _linker_mapping_impl(ctx):
17+
return [
18+
# Pass through the `ExternalNpmPackageInfo` which is needed for the linker
19+
# resolving dependencies which might be external. e.g. `rxjs` for `@angular/core`.
20+
ctx.attr.package[ExternalNpmPackageInfo],
21+
JSModuleInfo(
22+
direct_sources = depset(ctx.files.srcs),
23+
sources = depset(ctx.files.srcs),
24+
),
25+
LinkerPackageMappingInfo(
26+
mappings = {
27+
ctx.attr.module_name: "%s/%s" % (ctx.label.package, ctx.attr.subpath),
28+
},
29+
),
30+
]
31+
32+
_linker_mapping = rule(
33+
implementation = _linker_mapping_impl,
34+
attrs = {
35+
"package": attr.label(),
36+
"srcs": attr.label_list(allow_files = False),
37+
"subpath": attr.string(),
38+
"module_name": attr.string(),
39+
},
40+
)
41+
42+
def _get_target_name_base(pkg, entry_point):
43+
return "%s%s" % (pkg.name, "_%s" % entry_point if entry_point else "")
44+
45+
def _create_bundle_targets(pkg, entry_point, module_name):
46+
target_name_base = _get_target_name_base(pkg, entry_point)
47+
fesm_bundle_path = "fesm2020/%s.mjs" % (entry_point if entry_point else pkg.name)
48+
49+
esbuild(
50+
name = "%s_linked_bundle" % target_name_base,
51+
output = "%s/index.mjs" % target_name_base,
52+
platform = pkg.platform,
53+
entry_point = "@npm//:node_modules/@angular/%s/%s" % (pkg.name, fesm_bundle_path),
54+
config = "//tools/angular:esbuild_config",
55+
# List of dependencies which should never be bundled into these linker-processed bundles.
56+
external = ["rxjs", "@angular", "domino", "xhr2"],
57+
)
58+
59+
_linker_mapping(
60+
name = "%s_linked" % target_name_base,
61+
srcs = [":%s_linked_bundle" % target_name_base],
62+
package = "@npm//@angular/%s" % pkg.name,
63+
module_name = module_name,
64+
subpath = target_name_base,
65+
)
66+
67+
def create_angular_bundle_targets():
68+
for pkg in ANGULAR_PACKAGES:
69+
_create_bundle_targets(pkg, None, pkg.module_name)
70+
71+
for entry_point in pkg.entry_points:
72+
_create_bundle_targets(pkg, entry_point, "%s/%s" % (pkg.module_name, entry_point))
73+
74+
LINKER_PROCESSED_FW_PACKAGES = [
75+
"//tools/angular:%s_linked" % _get_target_name_base(pkg, entry_point)
76+
for pkg in ANGULAR_PACKAGES
77+
for entry_point in [None] + pkg.entry_points
78+
]

tools/esbuild/devmode-output.bzl

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "LinkerPackageMappingInfo", "module_mappings_aspect")
2+
load("@build_bazel_rules_nodejs//:providers.bzl", "ExternalNpmPackageInfo", "JSModuleInfo", "node_modules_aspect")
3+
4+
def _extract_devmode_sources_impl(ctx):
5+
"""Private rule that extracts devmode sources for all direct dependencies and re-exposes
6+
them as part of a single target. External node modules are passed-through as well."""
7+
8+
mappings = {}
9+
js_sources = []
10+
11+
for dep in ctx.attr.deps:
12+
if JSModuleInfo in dep:
13+
js_sources.append(dep[JSModuleInfo].sources)
14+
if ExternalNpmPackageInfo in dep:
15+
js_sources.append(dep[ExternalNpmPackageInfo].sources)
16+
if LinkerPackageMappingInfo in dep:
17+
mappings.update(dep[LinkerPackageMappingInfo].mappings)
18+
19+
return [
20+
LinkerPackageMappingInfo(mappings = mappings),
21+
JSModuleInfo(
22+
direct_sources = depset(transitive = js_sources),
23+
sources = depset(transitive = js_sources),
24+
),
25+
]
26+
27+
extract_devmode_sources = rule(
28+
implementation = _extract_devmode_sources_impl,
29+
attrs = {
30+
"deps": attr.label_list(mandatory = True, aspects = [module_mappings_aspect, node_modules_aspect]),
31+
},
32+
)
33+
34+
def extract_devmode_output_with_mappings(name, deps, testonly):
35+
"""Macro that extracts devmode ESM2020 sources from the given dependencies."""
36+
37+
extract_devmode_sources(
38+
name = "%s_sources" % name,
39+
testonly = testonly,
40+
deps = deps,
41+
)
42+
43+
return ["%s_sources" % name]

tools/esbuild/esbuild-amd-config.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
1+
import url from 'url';
2+
import path from 'path';
3+
4+
/** Path to the ESBuild configuration maintained by the user. */
5+
const userConfigExecPath = "TMPL_CONFIG_PATH"
6+
7+
/** User ESBuild config. Empty if none is loaded. */
8+
let userConfig = {};
9+
10+
if (userConfigExecPath !== '') {
11+
const userConfigPath = path.join(process.cwd(), userConfigExecPath);
12+
const userConfigUrl = url.pathToFileURL(userConfigPath);
13+
14+
// Load the user config, assuming it is set as `default` export.
15+
userConfig = (await import(userConfigUrl)).default;
16+
}
17+
118
export default {
19+
...userConfig,
220
globalName: "__exports",
21+
format: 'iife',
322
banner: {js: 'define("TMPL_MODULE_NAME", [], function() {'},
423
footer: {js: 'return __exports;})'},
524
};

tools/esbuild/index.bzl

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,52 @@
1-
load("@npm//@bazel/esbuild:index.bzl", "esbuild_config", _esbuild = "esbuild")
1+
load("@npm//@bazel/esbuild:index.bzl", _esbuild = "esbuild", _esbuild_config = "esbuild_config")
22
load("@npm//@angular/dev-infra-private/bazel:expand_template.bzl", "expand_template")
3+
load("//tools/esbuild:devmode-output.bzl", "extract_devmode_output_with_mappings")
34

4-
def esbuild(**kwargs):
5-
_esbuild(**kwargs)
5+
# Re-export of the actual esbuild definitions.
6+
esbuild_config = _esbuild_config
7+
8+
def esbuild(name, deps = [], mapping_targets = [], testonly = False, **kwargs):
9+
# Extract all JS module sources before passing to ESBuild. The ESBuild rule requests
10+
# both the devmode and prodmode unfortunately and this would slow-down the development
11+
# turnaround significantly. We only request the devmode sources which are ESM as well.
12+
devmode_targets = extract_devmode_output_with_mappings(name, deps, testonly)
13+
14+
_esbuild(
15+
name = name,
16+
deps = devmode_targets,
17+
testonly = testonly,
18+
**kwargs
19+
)
620

721
"""Generates an AMD bundle for the specified entry-point with the given AMD module name."""
822

9-
def esbuild_amd(name, entry_point, module_name, testonly, deps):
23+
def esbuild_amd(name, entry_point, module_name, testonly = False, config = None, deps = [], **kwargs):
1024
expand_template(
1125
name = "%s_config" % name,
1226
testonly = testonly,
1327
template = "//tools/esbuild:esbuild-amd-config.mjs",
1428
output_name = "%s_config.mjs" % name,
1529
substitutions = {
1630
"TMPL_MODULE_NAME": module_name,
31+
"TMPL_CONFIG_PATH": "$(execpath %s)" % config if config else "",
1732
},
33+
data = [config] if config else None,
1834
)
1935

20-
esbuild_config(
36+
_esbuild_config(
2137
name = "%s_config_lib" % name,
2238
testonly = testonly,
2339
config_file = "%s_config" % name,
40+
# Adds the user configuration and its deps as dependency of the AMD ESBuild config.
41+
# https://github.com/bazelbuild/rules_nodejs/blob/a892caf5a040bae5eeec174a3cf6250f02caf364/packages/esbuild/esbuild_config.bzl#L23.
42+
deps = [config, "%s_deps" % config] if config else None,
2443
)
2544

26-
_esbuild(
27-
name = "%s_bundle" % name,
45+
esbuild(
46+
name = name,
2847
testonly = testonly,
2948
deps = deps,
30-
minify = True,
31-
sourcemap = "inline",
32-
platform = "browser",
33-
target = "es2015",
3449
entry_point = entry_point,
3550
config = "%s_config_lib" % name,
36-
)
37-
38-
native.filegroup(
39-
name = name,
40-
testonly = testonly,
41-
srcs = ["%s_bundle" % name],
51+
**kwargs
4252
)

0 commit comments

Comments
 (0)