diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index b17029f6c5e1..791647ecf940 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -30,6 +30,7 @@ import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; import type { LoadResultCache } from './load-result-cache'; import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin'; import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; +import { createBazelSandboxPlugin } from './sandbox-plugin-bazel'; import { createServerBundleMetadata } from './server-bundle-metadata-plugin'; import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; import { SERVER_GENERATED_EXTERNALS, getFeatureSupport, isZonelessApp } from './utils'; @@ -607,6 +608,31 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu } } + // Inject the Bazel sandbox plugin only when specifically enabled to be fully backward compatible. + // Most users will never need this and as such should not have it influence their builds. + if ( + process.env.ENABLE_BAZEL_SANDBOX_PLUGIN === 'true' || + process.env.ENABLE_BAZEL_SANDBOX_PLUGIN === '1' + ) { + const bindir = process.env.BAZEL_BINDIR; + const execroot = process.env.JS_BINARY__EXECROOT; + + let runfiles: string | undefined; + // If requested, remap paths to the runfiles tree in the sandbox instead of the bindir + // directly. This allows `js_binary` and `js_test` rules to invoke the Angular CLI against + // their runfiles. + if ( + process.env.BAZEL_SANDBOX_PLUGIN_REMAP_TO_RUNFILES === '1' || + process.env.BAZEL_SANDBOX_PLUGIN_REMAP_TO_RUNFILES === 'true' + ) { + runfiles = process.env.JS_BINARY__RUNFILES; + } + + if (bindir && execroot) { + plugins.push(createBazelSandboxPlugin({ bindir, execroot, runfiles })); + } + } + return { absWorkingDir: workspaceRoot, format: 'esm', diff --git a/packages/angular/build/src/tools/esbuild/sandbox-plugin-bazel.ts b/packages/angular/build/src/tools/esbuild/sandbox-plugin-bazel.ts new file mode 100644 index 000000000000..dcd80da8a431 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/sandbox-plugin-bazel.ts @@ -0,0 +1,177 @@ +/** + * @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.dev/license + */ + +/** + * Forked from https://github.com/aspect-build/rules_esbuild/blob/e4e49d3354cbf7087c47ac9c5f2e6fe7f5e398d3/esbuild/private/plugins/bazel-sandbox.js + */ + +import type { OnResolveResult, Plugin, PluginBuild, ResolveOptions } from 'esbuild'; +import { stat } from 'node:fs/promises'; +import path, { join } from 'node:path'; + +export interface CreateBazelSandboxPluginOptions { + bindir: string; + execroot: string; + runfiles?: string; +} + +// Under Bazel, esbuild will follow symlinks out of the sandbox when the sandbox is enabled. See https://github.com/aspect-build/rules_esbuild/issues/58. +// This plugin using a separate resolver to detect if the the resolution has left the execroot (which is the root of the sandbox +// when sandboxing is enabled) and patches the resolution back into the sandbox. +export function createBazelSandboxPlugin({ + bindir, + execroot, + runfiles, +}: CreateBazelSandboxPluginOptions): Plugin { + return { + name: 'bazel-sandbox', + setup(build) { + build.onResolve({ filter: /./ }, async ({ path: importPath, ...otherOptions }) => { + // NB: these lines are to prevent infinite recursion when we call `build.resolve`. + if (otherOptions.pluginData) { + if (otherOptions.pluginData.executedSandboxPlugin) { + return; + } + } else { + otherOptions.pluginData = {}; + } + otherOptions.pluginData.executedSandboxPlugin = true; + + return await resolveInExecroot({ + build, + bindir, + execroot, + runfiles, + importPath, + otherOptions, + }); + }); + }, + }; +} + +interface ResolveInExecrootOptions { + build: PluginBuild; + bindir: string; + execroot: string; + runfiles?: string; + importPath: string; + otherOptions: ResolveOptions; +} + +const EXTERNAL_PREFIX = 'external/'; + +function removeExternalPathPrefix(filePath: string): string { + // Normalize to relative path without leading slash. + if (filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + // Remove the EXTERNAL_PREFIX if present. + if (filePath.startsWith(EXTERNAL_PREFIX)) { + filePath = filePath.substring(EXTERNAL_PREFIX.length); + } + return filePath; +} + +async function resolveInExecroot({ + build, + bindir, + execroot, + runfiles, + importPath, + otherOptions, +}: ResolveInExecrootOptions): Promise { + const result = await build.resolve(importPath, otherOptions); + + if (result.errors && result.errors.length) { + // There was an error resolving, just return the error as-is. + return result; + } + + if ( + !result.path.startsWith('.') && + !result.path.startsWith('/') && + !result.path.startsWith('\\') + ) { + // Not a relative or absolute path. Likely a module resolution that is marked "external" + return result; + } + + // If esbuild attempts to leave the execroot, map the path back into the execroot. + if (!result.path.startsWith(execroot)) { + // If it tried to leave bazel-bin, error out completely. + if (!result.path.includes(bindir)) { + throw new Error( + `Error: esbuild resolved a path outside of BAZEL_BINDIR (${bindir}): ${result.path}`, + ); + } + // Get the path under the bindir for the file. This allows us to map into + // the execroot or the runfiles directory (if present). + // Example: + // bindir = bazel-out//bin + // result.path = /execroot/bazel-out//bin/external/repo+/path/file.ts + // binDirRelativePath = external/repo+/path/file.ts + const binDirRelativePath = result.path.substring( + result.path.indexOf(bindir) + bindir.length + 1, + ); + // We usually remap into the bindir. However, when sources are provided + // as `data` (runfiles), they will be in the runfiles root instead. The + // runfiles path is absolute and under the bindir, so we don't need to + // join anything to it. The execroot does not include the bindir, so there + // we add it again after previously removing it from the result path. + const remapBase = runfiles ?? path.join(execroot, bindir); + // The path relative to the remapBase also differs between runfiles and + // bindir, but only if the file is in an external repository. External + // repositories appear under `external/repo+` in the bindir, whereas they + // are directly under `repo+` in the runfiles tree. This difference needs + // to be accounted for by removing a potential `external/` prefix when + // mapping into runfiles. + const remapBaseRelativePath = runfiles + ? removeExternalPathPrefix(binDirRelativePath) + : binDirRelativePath; + // Join the paths back together. The results will look slightly different + // between runfiles and bindir, but this is intentional. + // Source path: + // /external/repo+/path/file.ts + // Example in bindir: + // /external/repo+/path/file.ts + // Example in runfiles: + // /path/bin.runfiles/repo+/path/file.ts + const correctedPath = join(remapBase, remapBaseRelativePath); + if (process.env.JS_BINARY__LOG_DEBUG) { + // eslint-disable-next-line no-console + console.error( + `DEBUG: [bazel-sandbox] correcting resolution ${result.path} that left the sandbox to ${correctedPath}.`, + ); + } + result.path = correctedPath; + + // Fall back to `.js` file if resolved `.ts` file does not exist in the changed path. + // + // It's possible that a `.ts` file exists outside the sandbox and esbuild resolves it. It's not + // guaranteed that the sandbox also contains the same file. One example might be that the build + // depend on a compiled version of the file and the sandbox will only contain the corresponding + // `.js` and `.d.ts` files. + if (result.path.endsWith('.ts')) { + try { + await stat(result.path); + } catch (e: unknown) { + const jsPath = result.path.slice(0, -3) + '.js'; + if (process.env.JS_BINARY__LOG_DEBUG) { + // eslint-disable-next-line no-console + console.error( + `DEBUG: [bazel-sandbox] corrected resolution ${result.path} does not exist in the sandbox, trying ${jsPath}.`, + ); + } + result.path = jsPath; + } + } + } + + return result; +}