Cannot find package: ESM bare specifiers in config

Cannot find package 'plugins' is Node treating a relative path as a package name. Here is the ESM specifier rule, and the one-character fix.

A plugin path in proxy.config.json looked like a path. Node treated it as a package name:

Failed to load plugin: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'plugins' imported from /home/juan/Projects/git-proxy/

The file was sitting right there at plugins/git-proxy-plugin-samples/index.js. ls could see it. The loader could not. I hit this while wiring a sample into GitProxy during the plugin-system revamp, and the error is easy to misread as a missing dependency.

Why Node never opened the file

The config entry was this:

{
  "plugins": [
    "plugins/git-proxy-plugin-samples/index.js"
  ]
}

GitProxy hands that string to load-plugin, which resolves it through Node’s module loader. In ES modules, Node’s own terminology splits specifiers into two kinds. A bare specifier is anything that does not begin with ./, ../, /, or a URL scheme, and bare specifiers are package names. plugins/git-proxy-plugin-samples/index.js therefore means “the package plugins, subpath ./git-proxy-plugin-samples/index.js”. Node never tried to open a relative file. It went looking for a package called plugins and reported ERR_MODULE_NOT_FOUND when that package did not exist.

The word package in the error is the tell. If this had been a missing file, the message would have talked about a path. Talking about a package means the specifier was parsed as a name.

The one-character fix

Prefix it so the specifier is relative:

{
  "plugins": [
    "./plugins/git-proxy-plugin-samples/index.js"
  ]
}

That is resolved from the process working directory, which in the error above is the repository root. An absolute path works too if you would rather not depend on cwd.

The same rule applies anywhere you feed a user-supplied string into import() or a resolver that uses Node’s ESM rules: a config file, a CLI flag, a plugin list. CommonJS require() is more forgiving about extensionless relative paths, which is why the same string can work in a require call and fail the moment the loader is ESM.

What this is not

It is not a missing node_modules entry, and it is not the plugin’s peerDependencies range. Both of those produce similar-looking errors later, once the specifier has already resolved to a file. Here resolution never got that far.

It is also a different failure from a package subpath that the exports map does not declare. That one finds the package and then refuses a path inside it. This one never finds a package at all.

The takeaway

In ESM, a string that looks like a path is still a package name unless it starts with ./, ../, /, or a scheme. When the error says Cannot find package 'foo' and foo is the first segment of something you thought was a file path, add the ./. I wrote up the rest of loading a GitProxy plugin, including this exact config line, in how to write a GitProxy plugin.