angular/tools/esm-interop/esm-node-module-loader.mjs
Paul Gschwendtner c8e021ef4a build: support esbuild configurations using ESM dependencies (#48521)
We use `bazel/esbuild` in various places (e.g. for app bundling tests).
These tests rely on the Angular Compiler-CLI itself for e.g. linking
or the Terser configuration. Since everything in this repo is now
strict ESM, the ESBuild configs (which are already ESM-supported)
need to import from `//packages/compiler-cli`. We also need to be
able to leverage our existing ESM Bazel loader for this though as
otherwise resolution would fail.

Long-term we can remove this if everything in the compiler-cli
would use `.mjs` extensions and the import paths would also specify
an explicit extension. See: https://nodejs.org/api/esm.html#mandatory-file-extensions

PR Close #48521
2022-12-19 19:50:42 +00:00

144 lines
5.1 KiB
JavaScript

/**
* @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.io/license
*/
import fs from 'fs';
import path from 'path';
import {createRequire} from 'module';
import {pathToFileURL} from 'url';
import {resolve as resolveExports} from '../../third_party/github.com/lukeed/resolve.exports/index.mjs';
// The Bazel NodeJS rules patch `require` to support first-party
// mapped packages. We cannot replicate this logic without patching
// the Bazel rules, so instead we leverage the existing `require`
// patched function as it knows about first party mapped packages.
const requireFn = createRequire(import.meta.url);
const npmDepsWorkspace = process.env.NODE_MODULES_WORKSPACE_NAME ?? '';
const runfilesRoot = path.resolve(process.env.RUNFILES);
const nodeModulesPath = path.join(runfilesRoot, npmDepsWorkspace, 'node_modules');
/*
Custom module loader to support loading 1st-party and 3rd-party node
modules when the linker is disabled. This is required because `rules_nodejs`
only patches requires in cjs modules when the linker is disabled.
*/
export async function resolve(specifier, context, nextResolve) {
// Only activate this loader when explicitly enabled.
if (process.env.ESM_NODE_MODULE_LOADER_ENABLED !== 'true') {
return nextResolve(specifier, context);
}
if (!isNodeOrNpmPackageImport(specifier)) {
return nextResolve(specifier, context);
}
const packageImport = parsePackageImport(specifier);
const pathToNodeModule = path.join(nodeModulesPath, packageImport.packageName);
// If the module can be directly found in the `node_modules`, then we know it's
// a third-party package coming from NPM. In this case we properly respect ESM
// resolution by respecting the `exports`.
const npmModuleResult = fs.existsSync(pathToNodeModule)
? resolvePackageWithExportsSupport(pathToNodeModule, packageImport)
: null;
if (npmModuleResult !== null) {
return npmModuleResult;
}
// If the package does not exist on disk, then it may just be an invalid
// import, or the package is 1st-party one that is mapped within Bazel.
// We attempt to resolve it that way and return the path if there is a result.
const localMappingResult = tryResolveViaLocalMappings(specifier, packageImport);
if (localMappingResult !== null) {
return localMappingResult;
}
// Process built-in modules or unknown specifiers.
return nextResolve(specifier, context);
}
/** Gets whether the specifier refers to a module. */
function isNodeOrNpmPackageImport(specifier) {
return (
!specifier.startsWith('./') &&
!specifier.startsWith('../') &&
!specifier.startsWith('node:') &&
!specifier.startsWith('file:')
);
}
/**
* Attempts to resolve a specifier using the Bazel patched resolution,
* supporting first-party package mappings from `rules_nodejs`.
*/
function tryResolveViaLocalMappings(actualSpecifier) {
try {
const res = requireFn.resolve(actualSpecifier);
// Note: It may not resolve to a path if the specifier is a builtin
// module. In such cases we do not want to return it as result.
if (fs.existsSync(res)) {
return {url: pathToFileURL(res).href};
}
} catch {}
return null;
}
/** Parses the given specifier into its package and subpath. */
function parsePackageImport(specifier) {
const [, packageName, pathInPackage = ''] =
/^((?:@[^/]+\/)?[^/]+)(?:\/(.+))?$/.exec(specifier) ?? [];
if (!packageName) {
throw new Error(`Could not parse package name import statement '${specifier}'`);
}
return {packageName, pathInPackage, specifier};
}
/** Resolves an import to a module by respecting the `package.json` `exports`. */
function resolvePackageWithExportsSupport(pathToNodeModule, packageImport) {
const packageJson = JSON.parse(
fs.readFileSync(path.join(pathToNodeModule, 'package.json'), 'utf8')
);
const localResolvedPackagePath = resolvePackageLocalFilepath(
pathToNodeModule,
packageImport,
packageJson
);
if (fs.existsSync(localResolvedPackagePath)) {
return {url: pathToFileURL(localResolvedPackagePath).href};
}
return null;
}
/**
* Resolves the remaining package-local portion of an import. Leverages
* the `package.json` `exports` field information for resolution.
*/
function resolvePackageLocalFilepath(pathToNodeModule, packageImport, packageJson) {
if (packageJson.exports) {
return path.join(pathToNodeModule, resolveExports(packageJson, packageImport.specifier));
}
let pkgJsonDir = pathToNodeModule;
// If we couldn't resolve the subpath via `exports`, we check if the subpath
// already points to an explicit file, or respect deep `package.json` files.
if (packageImport.pathInPackage !== '') {
const fullPath = path.join(pathToNodeModule, packageImport.pathInPackage);
const deepPackageJsonPath = path.join(fullPath, 'package.json');
if (fs.existsSync(deepPackageJsonPath)) {
pkgJsonDir = fullPath;
packageJson = JSON.parse(fs.readFileSync(deepPackageJsonPath, 'utf8'));
} else {
return fullPath;
}
}
return path.join(pkgJsonDir, packageJson.module || packageJson.main || 'index.js');
}