Repository: nodejs/node
Pull request: esm: do not interpret `"main"` as a URL

As documented, its value is a path.

https://github.com/nodejs/node/blob/c42d8461b061136da2b3c04bd976d2e15b81969d/doc/api/packages.md#L1210-L1211

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: lib/internal/modules/esm/resolve.js
patch: @@ -22,6 +22,7 @@ const {
   StringPrototypeStartsWith,
   encodeURIComponent,
 } = primordials;
+const assert = require('internal/assert');
 const internalFS = require('internal/fs/utils');
 const { BuiltinModule } = require('internal/bootstrap/realm');
 const { realpathSync } = require('fs');
@@ -117,18 +118,17 @@ function emitInvalidSegmentDeprecation(target, request, match, pjsonUrl, interna
  * Emits a deprecation warning if the given URL is a module and
  * the package.json file does not define a "main" or "exports" field.
  * @param {URL} url - The URL of the module being resolved.
- * @param {URL} packageJSONUrl - The URL of the package.json file for the module.
+ * @param {string} path - The path of the module being resolved.
+ * @param {string} pkgPath - The path of the parent dir of the package.json file for the module.
  * @param {string | URL} [base] - The base URL for the module being resolved.
  * @param {string} [main] - The "main" field from the package.json file.
  */
-function emitLegacyIndexDeprecation(url, packageJSONUrl, base, main) {
+function emitLegacyIndexDeprecation(url, path, pkgPath, base, main) {
   if (process.noDeprecation) {
     return;
   }
   const format = defaultGetFormatWithoutErrors(url);
   if (format !== 'module') { return; }
-  const path = fileURLToPath(url);
-  const pkgPath = fileURLToPath(new URL('.', packageJSONUrl));
   const basePath = fileURLToPath(base);
   if (!main) {
     process.emitWarning(
@@ -196,20 +196,19 @@ const legacyMainResolveExtensionsIndexes = {
  * @returns {URL}
  */
 function legacyMainResolve(packageJSONUrl, packageConfig, base) {
-  const packageJsonUrlString = packageJSONUrl.href;
-
-  if (typeof packageJsonUrlString !== 'string') {
-    throw new ERR_INVALID_ARG_TYPE('packageJSONUrl', ['URL'], packageJSONUrl);
-  }
+  assert(isURL(packageJSONUrl));
+  const pkgPath = fileURLToPath(new URL('.', packageJSONUrl));
 
   const baseStringified = isURL(base) ? base.href : base;
 
-  const resolvedOption = FSLegacyMainResolve(packageJsonUrlString, packageConfig.main, baseStringified);
+  const resolvedOption = FSLegacyMainResolve(pkgPath, packageConfig.main, baseStringified);
 
-  const baseUrl = resolvedOption <= legacyMainResolveExtensionsIndexes.kResolvedByMainIndexNode ? `./${packageConfig.main}` : '';
-  const resolvedUrl = new URL(baseUrl + legacyMainResolveExtensions[resolvedOption], packageJSONUrl);
+  const maybeMain = resolvedOption <= legacyMainResolveExtensionsIndexes.kResolvedByMainIndexNode ?
+    packageConfig.main || './' : '';
+  const resolvedPath = resolve(pkgPath, maybeMain + legacyMainResolveExtensions[resolvedOption]);
+  const resolvedUrl = pathToFileURL(resolvedPath);
 
-  emitLegacyIndexDeprecation(resolvedUrl, packageJSONUrl, base, packageConfig.main);
+  emitLegacyIndexDeprecation(resolvedUrl, resolvedPath, pkgPath, base, packageConfig.main);
 
   return resolvedUrl;
 }
@@ -790,8 +789,8 @@ function packageResolve(specifier, base, conditions) {
   // ResolveSelf
   const packageConfig = packageJsonReader.getPackageScopeConfig(base);
   if (packageConfig.exists) {
-    const packageJSONUrl = pathToFileURL(packageConfig.pjsonPath);
     if (packageConfig.exports != null && packageConfig.name === packageName) {
+      const packageJSONUrl = pathToFileURL(packageConfig.pjsonPath);
       return packageExportsResolve(
         packageJSONUrl, packageSubpath, packageConfig, base, conditions);
     }

filename: src/node_file.cc
patch: @@ -3331,37 +3331,18 @@ void BindingData::LegacyMainResolve(const FunctionCallbackInfo<Value>& args) {
   Environment* env = Environment::GetCurrent(args);
   auto isolate = env->isolate();
 
-  Utf8Value utf8_package_json_url(isolate, args[0]);
-  auto package_json_url =
-      ada::parse<ada::url_aggregator>(utf8_package_json_url.ToStringView());
-
-  if (!package_json_url) {
-    THROW_ERR_INVALID_URL(isolate, "Invalid URL");
-    return;
-  }
+  auto utf8_package_path = Utf8Value(isolate, args[0]).ToString();
 
   std::string package_initial_file = "";
 
-  ada::result<ada::url_aggregator> file_path_url;
   std::optional<std::string> initial_file_path;
   std::string file_path;
 
   if (args.Length() >= 2 && args[1]->IsString()) {
     auto package_config_main = Utf8Value(isolate, args[1]).ToString();
 
-    file_path_url = ada::parse<ada::url_aggregator>(
-        std::string("./") + package_config_main, &package_json_url.value());
-
-    if (!file_path_url) {
-      THROW_ERR_INVALID_URL(isolate, "Invalid URL");
-      return;
-    }
-
-    initial_file_path = node::url::FileURLToPath(env, *file_path_url);
-    if (!initial_file_path.has_value()) {
-      return;
-    }
-
+    initial_file_path =
+        PathResolve(env, {utf8_package_path, package_config_main});
     FromNamespacedPath(&initial_file_path.value());
 
     package_initial_file = *initial_file_path;
@@ -3392,15 +3373,7 @@ void BindingData::LegacyMainResolve(const FunctionCallbackInfo<Value>& args) {
     }
   }
 
-  file_path_url =
-      ada::parse<ada::url_aggregator>("./index", &package_json_url.value());
-
-  if (!file_path_url) {
-    THROW_ERR_INVALID_URL(isolate, "Invalid URL");
-    return;
-  }
-
-  initial_file_path = node::url::FileURLToPath(env, *file_path_url);
+  initial_file_path = PathResolve(env, {utf8_package_path, "./index"});
   if (!initial_file_path.has_value()) {
     return;
   }

filename: test/es-module/test-cjs-legacyMainResolve.js
patch: @@ -82,7 +82,7 @@ describe('legacyMainResolve', () => {
           {},
           ''
         ),
-      { message: /instance of URL/, code: 'ERR_INVALID_ARG_TYPE' },
+      { code: 'ERR_INTERNAL_ASSERTION' },
     );
   });
 
@@ -166,4 +166,12 @@ describe('legacyMainResolve', () => {
       { message: /"base" argument must be/, code: 'ERR_INVALID_ARG_TYPE' },
     );
   });
+
+  it('should interpret main as a path, not a URL', () => {
+    const packageJsonUrl = fixtures.fileURL('/es-modules/legacy-main-resolver/package.json');
+    assert.deepStrictEqual(
+      legacyMainResolve(packageJsonUrl, { main: '../folder%25with percentage#/' }, packageJsonUrl),
+      fixtures.fileURL('/es-modules/folder%25with percentage#/index.js'),
+    );
+  });
 });