Skip to content

Conflicting behavior with exports and "type": "module" #58057

Open
@Danielku15

Description

@Danielku15

Version

v22.14.0

Platform

Microsoft Windows NT 10.0.26100.0
x64

Subsystem

internal/modules/cjs/loader

What steps will reproduce the bug?

  1. Create a package which declares explicit exports with the respective loaders to be used. The type is set to module and the file extensions for require exports are js.
packages/pkg1/package.json
{
    "name": "pkg1",
    "version": "1.0.0",
    "private": true,
    "type": "module",
    "exports": {
        ".": {
            "require": "./main.js",
            "import": "./main.mjs"
        },
        "./module": {
            "require": "./module.js",
            "import": "./module.mjs"
        }
    }
}
  1. In the .js files use commonjs code (require)
packages/pkg1/main.js
const util = require('node:util');
module.exports = function() {
    console.log(util.styleText('green', 'Hello from pkg1/main (esm)'));
}
  1. Consume the package in a CJS project
packages/consumer/package.json
{
    "name": "consumer",
    "version": "1.0.0",
    "private": true,
    "scripts": {
        "test": "node test.js"
    },
    "dependencies": {
        "pkg1": "^1.0.0"
    }
}
packages/consumer/test.js
const pkg1 = require('pkg1');
const pkg1Module = require('pkg1/module');

pkg1();
pkg1Module();
  1. Run the test.js in node and get presented an error:
PS D:\Dev\node-cjs-loading\packages\consumer> npm run test

> consumer@1.0.0 test
> node test.js

file:///D:/Dev/node-cjs-loading/packages/pkg1/main.js:1
const util = require('node:util');
             ^

ReferenceError: require is not defined
    at file:///D:/Dev/node-cjs-loading/packages/pkg1/main.js:1:14
    at ModuleJobSync.runSync (node:internal/modules/esm/module_job:395:35)
    at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:360:47)
    at loadESMFromCJS (node:internal/modules/cjs/loader:1385:24)
    at Module._compile (node:internal/modules/cjs/loader:1536:5)
    at Object..js (node:internal/modules/cjs/loader:1706:10)
    at Module.load (node:internal/modules/cjs/loader:1289:32)
    at Function._load (node:internal/modules/cjs/loader:1108:12)
    at TracingChannel.traceSync (node:diagnostics_channel:322:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:220:24)

Node.js v22.14.0
npm ERR! Lifecycle script `test` failed with error: 
npm ERR! Error: command failed 
npm ERR!   in workspace: consumer@1.0.0 
npm ERR!   at location: D:\Dev\node-cjs-loading\packages\consumer 

How often does it reproduce? Is there a required condition?

Reproduces always.

What is the expected behavior? Why is that the expected behavior?

I would expect that node loads the main.js with the commonjs loader as it is and ignore the fact that .js maps to ESM from a "type: module" spec.

IMO the conditions of the conditional exports, need to also control which loader is used for the file. Its not just defining which file to use when resolving a module from CJS or ESM context.

What do you see instead?

Node.js only uses the exports for looking up the filename but then does a fallback to the default extension based loading. Due to type: module it treats it as ESM and wrongly loads it.

Additional information

I know my setup is a bit special so I want to share some insights why I have it like this, and why changing is tricky.

In reality my files are not "commonjs" but rather Universal Module Definition (UMD) files. My library is also used in classical browser script tag setups. This is for backwards compatibility with the current major version.

Changing to .cjs is a workaround that could work. But its has these impacts:

a) I considering the rename a breaking change for my library and I don't want to bump my major version just for this sake.
b) It could impact CDNs and Web Servers which might not have the extension registered as text/javascript and browsers triggering warning/errors on load due to wrong mimetypes. (e.g. jsdelivr serves it as application/node, or IIS 10 in doesn't have it either)
c) Its not the "correct" file extension for browser usage.

Another workaround is to remove type: module. But within my git repository I want to use primarily ESM (e.g. for scripts or configuration files). And this becomes problematic when using typescript configuration files. Some tools do not support something like a .mts breaking a variety of things.


I've looked through the following docs and generally I'd expect a .js to be loaded with the CJS loader if its declared accordingly in the exports section.

https://nodejs.org/api/packages.html#dual-commonjses-module-packages
https://nodejs.org/api/packages.html#conditional-exports
https://nodejs.org/api/packages.html#exports

Looking at the code I can also see that Node just resolves the plain path, but then ignores the "loader hint".

  1. File path resolve: https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js#L753
  2. Module load:
    https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js#L1286
  3. .js always using type from the package json: https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js#L1887

Node should remember the loader type from the conditional export and load the file accordingly, not use to some alternative/automatic loading behaviors than specified. That's why I report this behavior rather as a bug than an improvement request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions