Follow-up blog post: “Hybrid npm packages (ESM and CommonJS)”
Node.js 12 (which was released on 2019-04-23) brings improved support for ECMAScript modules. It implements phase 2 of the plan that was released late last year. For now, this support is available behind the usual flag --experimental-modules
.
Read on to find out how exactly this new support for ECMAScript modules works.
Brief spoiler: The filename extension .mjs
will be more convenient, but .js
can also be enabled for ES modules.
package.prop
means property prop
of package.json
.Module specifiers are the strings that identify modules. They work slightly differently in CommonJS modules and ES modules. Before we look at the differences, we need to learn about the different categories of module specifiers.
In ES modules, we distinguish the following categories of specifiers. These categories originated with CommonJS modules.
Relative path: starts with a dot. Examples:
'./some/other/module.mjs'
'../../lib/counter.mjs'
Absolute path: starts with a slash. Example:
'/home/jane/file-tools.mjs'
URL: includes a protocol (technically, paths are URLs, too). Examples:
'https://example.com/some-module.mjs'
'file:///home/john/tmp/main.mjs'
Bare path: does not start with a dot, a slash or a protocol, and consists of a single filename without an extension. Examples:
'lodash'
'the-package'
Deep import path: starts with a bare path and has at least one slash. Example:
'the-package/dist/the-module.mjs'
This is how CommonJS handles module specifiers:
foo
as a module:
foo/index.js
foo/package.json
whose property "main"
points to a module file.node_modules
that is found:
X
does not refer to a file, the system tries the specifiers X.js
, X.json
and X.node
.Additionally, CommonJS modules have access to two special module-global variables:
__filename
: contains the path of the current module.__dirname
: contains the path of the parent directory of the current module.Source of this section: page “Modules” of the Node.js documentation.
file:
is supported as a protocol for URL specifiers.file:///
.node_modules
directory. The module that a bare path refers to, is specified via package.main
(similarly to CJS).node_modules
directory.package.main
(which can only be used for packages) nor index.*
work.All built-in Node.js modules are available via bare paths and have named ESM exports. For example:
import * as path from 'path';
import * as assert from 'assert';
assert.equal(
path.join('a/b/c', '../d'), 'a/b/d');
Node.js supports the following default filename extensions:
.mjs
for ES modules.cjs
for CommonJS modulesThe filename extension .js
stands for either ESM or CommonJS. Which one it is, depends on package.type
, which has two settings:
commonjs
(the default): files with the extension .js
or without an extension are parsed as CommonJS.
"type": "commonjs"
module
: files with the extension .js
or without an extension are parsed as ESM.
"type": "module"
To find the package.json
for a given file, Node.js searches in the same directory as the file, the parent directory, etc.
Not all source code that is executed by Node.js, comes from files. You can also send it code via stdin, --eval
and --print
. The command line option --input-type
lets you specify how such code is interpreted:
--input-type=commonjs
--input-type=module
At the moment, you have two options for importing a CommonJS module from an ES module.
Consider the following CommonJS module.
// common.cjs
module.exports = {
foo: 123,
};
The first option is to default-import it (support for named imports may be added in the future):
// es1.mjs
import * as assert from 'assert';
import common from './common.cjs'; // default import
assert.equal(common.foo, 123);
The second option is to use createRequire()
:
// es2.mjs
import * as assert from 'assert';
import {createRequire} from 'module';
const require = createRequire(import.meta.url);
const common = require('./common.cjs');
assert.equal(common.foo, 123);
If you want to import an ES module from a CommonJS module, you can use the import()
operator.
As an example, take the following ES module:
// es.mjs
export const bar = 'abc';
Here we import it from a CommonJS module:
// common.cjs
const assert = require('assert');
async function main() {
const es = await import('./es.mjs');
assert.equal(es.bar, 'abc');
}
main();
import.meta.url
Given that __filename
and __dirname
are not available in ES modules, we need an alternative. import.meta.url
is that alternative. It contains a file:
URL with an absolute path. For example:
'file:///Users/rauschma/my-module.mjs'
Important: Use url.fileURLToPath()
to extract the path – new URL().pathname
doesn’t always work properly:
import * as assert from 'assert';
import {fileURLToPath} from 'url';
//::::: Unix :::::
const urlStr1 = 'file:///tmp/with%20space.txt';
assert.equal(
new URL(urlStr1).pathname, '/tmp/with%20space.txt');
assert.equal(
fileURLToPath(urlStr1), '/tmp/with space.txt');
const urlStr2 = 'file:///home/thor/Mj%C3%B6lnir.txt';
assert.equal(
new URL(urlStr2).pathname, '/home/thor/Mj%C3%B6lnir.txt');
assert.equal(
fileURLToPath(urlStr2), '/home/thor/Mjölnir.txt');
//::::: Windows :::::
const urlStr3 = 'file:///C:/dir/';
assert.equal(
new URL(urlStr3).pathname, '/C:/dir/');
assert.equal(
fileURLToPath(urlStr3), 'C:\\dir\\');
The inverse of url.fileURLToPath()
is url.pathToFileURL()
: it converts a path to a file URL.
We’ll see an example of using import.meta.url
and url.fileURLToPath()
later in this post.
fs.promises
fs.promises
contains a promisified version of the fs
API and works as expected:
import {fileURLToPath} from 'url';
import {promises as fs} from 'fs';
async function main() {
// The path of the current module
const pathname = fileURLToPath(import.meta.url);
const str = await fs.readFile(pathname, {encoding: 'UTF-8'});
console.log(str);
}
main();
--experimental-json-modules
With the flag --experimental-json-modules
, Node.js loads .json
files as JSON.
Take, for example, the JSON file data.json
:
{
"first": "Jane",
"last": "Doe"
}
We can import it from an ES module as follows (if we use both the flag for ESM and for JSON modules):
import * as assert from 'assert';
import data from './data.json';
assert.deepEqual(
data,
{first: "Jane", last: "Doe"});
At the moment, with a bare path 'mylib'
, you have to decide between:
require('mylib')
import from 'mylib'
You can’t do both (deep import paths are a reasonable work-around). An effort to change that is ongoing. It will probably be done by making package.main
more powerful.
Until that feature is ready, the people working on it, have the following request:
“Please do not publish any ES module packages intended for use by Node.js until this is resolved.”
Starting with Node.js 12, you have the following options for using ES modules on Node.js:
esm
also supports older versions of Node.js.--experimental-modules
(documentation)The flag for ESM support will probably be removed in October 2019, when Node.js 12 reaches LTS status.
Acknowledgements – thanks for reviewing this blog post: