Title | ES Module Interoperability |
---|---|
Author | @bmeck |
Status | DRAFT |
Date | 2017-03-01 |
NOTE: DRAFT
status does not mean ESM will be implemented in Node
core. Instead that this is the standard, should Node core decide to implement
ESM. At which time this draft would be moved to ACCEPTED
.
Abbreviations:
ESM
- Ecma262 Modules (ES Modules)CJS
- Node Modules (a CommonJS variant)
The intent of this standard is to:
- implement interoperability for ESM and Node's existing CJS module system
- Allow a common module syntax for Browser and Server.
- Allow a common set of context variables for Browser and Server.
ECMA262 discusses the syntax and semantics of related syntax, and introduces ESM.
Dynamic Import introduces
import()
which will be available in all parsing goals.
-
[ModuleRecord] (https://tc39.github.io/ecma262/#sec-abstract-module-records)
- Defines the list of imports via
[[ImportEntry]]
. - Defines the list of exports via
[[ExportEntry]]
.
- Defines the list of imports via
-
[ModuleNamespace] (https://tc39.github.io/ecma262/#sec-module-namespace-objects)
- Represents a read-only static set of bindings to a module's exports.
-
- Creates a [SourceTextModuleRecord] (https://tc39.github.io/ecma262/#sec-source-text-module-records) from source code.
-
[HostResolveImportedModule] (https://tc39.github.io/ecma262/#sec-hostresolveimportedmodule)
- A hook for when an import is exactly performed. This returns a
ModuleRecord
. Used as a means to grab modules from Node's loader/cache.
- A hook for when an import is exactly performed. This returns a
ESM imports will be loaded asynchronously. This matches browser behavior. This means:
- If a new
import
is queued up, it will never evaluate synchronously. - Between module evaluation within the same graph there may be other work done. Order of module evaluation within a module graph will always be preserved.
- Multiple module graphs could be loading at the same time concurrently.
A new file type will be recognised, .mjs
, for ES modules. This file type will
be registered with IANA as an official file type, see
TC39 issue. There are no known
issues with browsers since they
do not determine MIME type using file extensions.
The .mjs
file extension will not be loadable via require()
. This means that,
once the Node resolution algorithm reaches file expansion, the path for
path + .mjs
would throw an error. In order to support loading ESM in CJS files
please use import()
.
The MIME used to identify .mjs
files should be a web compatible JavaScript MIME Type.
ES import
statements will perform non-exact searches on relative or
absolute paths, like require()
. This means that file extensions, and
index files will be searched. However, ESM import specifier resolution will be
done using URLs which match closer to the browser. Unlike browsers, only the
file:
protocol will be supported until network and security issues can be
researched for other protocols.
With import
being URL based encoding and decoding will automatically be
performed. This may affect file paths containing any of the following
characters: :
,?
,#
, or %
. Details of the parsing algorithm are at the
WHATWG URL Spec.
- paths with
:
face multiple variations of path mutation - paths with
%
in their path segments would be decoded - paths with
?
, or#
in their paths would face truncation of pathname
All behavior differing from the
type=module
path resolution algorithm
will be places in locations that would throw errors in the browser.
Notes:
- The CLI has a location URL of the process working directory.
- Paths are resolved to realpaths normally after all these steps.
- Apply the URL parser to
specifier
. If the result is not failure, return the result. - If
specifier
does start with the character U+002F SOLIDUS (/
), the two-character sequence U+002E FULL STOP, U+002F SOLIDUS (./
), or the three-character sequence U+002E FULL STOP, U+002E FULL STOP, U+002F SOLIDUS (../
)- Let
specifier
be the result of applying the URL parser tospecifier
with importing location's URL as the base URL. - Return the result of applying the path search algorithm to
specifier
.
- Let
- Return the result of applying the module search algorithm to
specifier
.
- If it does not throw an error, return the result of applying the file search
algorithm to
specifier
. - If it does not throw an error, return the result of applying the directory
search algorithm to
specifier
. - Throw an error.
- If the resource for
specifier
exists, returnspecifier
. - For each file extension
[".mjs", ".js", ".json", ".node"]
- Let
searchable
be a new URL fromspecifier
. - Append the file extension to the pathname of
searchable
. - If the resource for
searchable
exists, returnsearchable
.
- Let
- Throw an error.
- If it does not throw an error, return the result of applying the file search
algorithm to
specifier
. - If it does not throw an error, return the result of applying the index
search algorithm to
specifier
. - Throw an error.
- Let
searchable
be a new URL fromspecifier
. - If
searchable
does not have a trailing/
in its pathname append one. - Let
searchable
be the result of applying the URL parser to./index
withspecifier
as the base URL. - If it does not throw an error, return the result of applying the file search
algorithm to
searchable
. - Throw an error.
- Let
dir
be a new URL fromspecifier
. - If
dir
does not have a trailing/
in its pathname append one. - Let
searchable
be the result of applying the URL parser to./package.json
withdir
as the base URL. - If the resource for
searchable
exists and it contains a "main" field.- Let
main
be the result of applying the URL parser to themain
field withdir
as the base URL. - If it does not throw an error, return the result of applying the package
main search algorithm to
main
.
- Let
- If it does not throw an error, return the result of applying the index
search algorithm to
dir
. - Throw an error.
- Let
package
be a new URL from the directory containing the importing location. Ifpackage
is the same as the importing location, throw an error. - If
package
does not have a trailing/
in its pathname append one. - Let
searchable
be the result of applying the URL parser to./node_modules/${specifier}
withpackage
as the base URL. - If it does not throw an error, return the result of applying the file search
algorithm to
searchable
. - If it does not throw an error, return the result of applying the directory
search algorithm to
searchable
. - Let
parent
be the result of applying the URL parser to../
withpackage
as the base URL. - If it does not throw an error, return the result of applying the module
search algorithm to
specifier
with an importing location ofparent
. - Throw an error.
import 'file:///etc/config/app.json';
Parseable with the URL parser. No searching.
import './foo';
import './foo?search';
import './foo#hash';
import '../bar';
import '/baz';
Applies the URL parser to the specifiers with a base url of the importing location. Then performs the path search algorithm.
import 'baz';
import 'abc/123';
Performs the module search algorithm.
All of the following will not be supported by the import
statement:
$NODE_PATH
$HOME/.node_modules
$HOME/.node_libraries
$PREFIX/lib/node
Use local dependencies, and symbolic links as needed.
Although not recommended, and in fact discouraged, there is a way to support non-local dependencies. USE THIS AT YOUR OWN DISCRETION.
Symlinks of node_modules -> $HOME/.node_modules
, node_modules/foo/ -> $HOME/.node_modules/foo/
, etc. will continue to be supported.
Adding a parent directory with node_modules
symlinked will be an effective
strategy for recreating these functionalities. This will incur the known
problems with non-local dependencies, but now leaves the problems in the hands
of the user, allowing Node to give more clear insight to your modules by
reducing complexity.
Given:
/opt/local/myapp
Transform to:
/opt/local/non-local-deps/myapp
/opt/local/non-local-deps/node_modules -> $PREFIX/lib/node (etc.)
And nest as many times as needed.
Exact algorithm TBD.
In the case that an import
statement is unable to find a module, Node should
make a best effort to see if require
would have found the module and
print out where it was found, if NODE_PATH
was used, if HOME
was used, etc.
When a package.json
main is encountered, file extension searches are used to
provide a means to ship both ESM and CJS variants of packages. If we have two
entry points index.mjs
and index.js
setting "main":"./index"
in
package.json
will make Node pick up either, depending on what is supported.
Since main
in package.json
is entirely optional even inside of npm
packages, some people may prefer to exclude main entirely in the case of using
./index
as that is still in the Node module search algorithm.
ESM will not be bootstrapped with magic variables and will await upcoming specifications in order to provide such behaviors in a standard way. As such, the following variables are changed:
Variable | Exists | Value |
---|---|---|
this | y | undefined |
arguments | n | |
require | n | |
module | n | |
exports | n | |
__filename | n | |
__dirname | n |
Like normal scoping rules, if a variable does not exist in a scope, the outer scope is used to find the variable. Since ESM are always strict, errors may be thrown upon trying to use variables that do not exist globally when using ESM.
Efforts are ongoing to reserve a specifier
that will be compatible in both Browsers and Node. Tentatively it will be
js:context
and export a single {url}
value.
Although heavily advised against, you can have a CJS module sibling for your ESM that can export these things:
// expose.js
module.exports = {__dirname};
// use.mjs
import expose from './expose.js';
const {__dirname} = expose;
After any CJS finishes evaluation, it will be placed into the same cache as
ESM. The value of what is placed in the cache will reflect a single default
export pointing to the value of module.exports
at the time evaluation ended.
Essentially after any CJS completes evaluation:
- if there was an error, place the error in the ESM cache and return
- let
export
be the value ofmodule.exports
- if there was an error, place the error in the ESM cache and return
- create an ESM with
{default:module.exports}
as its namespace - place the ESM in the ESM cache
Note: step 4 is the only time the value of module.exports
is assigned to the
ESM.
module.exports
is a single value. As such it does not have the dictionary
like properties of ES module exports. In order to transform a CJS module into
ESM a default
export which will point to the value of module.exports
that
was snapshotted imediately after the CJS finished evaluation. Due to problems
in supporting named imports, they will not be enabled by default. Space is
intentionally left open to allow named properties to be supported through
future explorations.
Given:
// cjs.js
module.exports = {
default:'my-default',
thing:'stuff'
};
You will grab module.exports
when performing an ESM import of cjs.js
.
// es.mjs
import * as baz from './cjs.js';
// baz = {
// get default() {return module.exports;},
// }
import foo from './cjs.js';
// foo = module.exports;
import {default as bar} from './cjs.js';
// bar = module.exports
Given:
// cjs.js
module.exports = null;
You will grab module.exports
when performing an ES import.
// es.mjs
import foo from './cjs.js';
// foo = null;
import * as bar from './cjs.js';
// bar = {default:null};
Given:
// cjs.js
module.exports = function two() {
return 2;
};
You will grab module.exports
when performing an ESM import.
// es.mjs
import foo from './cjs.js';
foo(); // 2
import * as bar from './cjs.js';
bar.default(); // 2
bar(); // throws, bar is not a function
Given:
// cjs.js
module.exports = Promise.resolve(3);
You will grab module.exports
when performing an ES import.
// es.mjs
import foo from './cjs.js';
foo.then(console.log); // outputs 3
import * as bar from './cjs.js';
bar.default.then(console.log); // outputs 3
bar.then(console.log); // throws, bar is not a Promise
ES modules only export named values. A "default" export is an export that uses
the property named default
.
Given:
// es.mjs
let foo = {bar:'my-default'};
// note:
// this is a value
// it is not a binding like `export {foo}`
export default foo;
foo = null;
// cjs.js
const es_namespace = await import('./es');
// es_namespace ~= {
// get default() {
// return result_from_evaluating_foo;
// }
// }
console.log(es_namespace.default);
// {bar:'my-default'}
Given:
// es.mjs
export let foo = {bar:'my-default'};
export {foo as bar};
export function f() {};
export class c {};
// cjs.js
const es_namespace = await import('./es');
// es_namespace ~= {
// get foo() {return foo;}
// get bar() {return foo;}
// get f() {return f;}
// get c() {return c;}
// }
All of these gotchas relate to opt-in semantics and the fact that CommonJS is a dynamic loader while ES is a static loader.
No existing code will be affected.
The objects create by an ES module are [ModuleNamespace Objects][5].
These have [[Set]]
be a no-op and are read only views of the exports of an ES
module. Attempting to reassign any named export will not work, but assigning to
the properties of the exports follows normal rules. This also means that keys
cannot be added.
CJS modules have allowed mutation on imported modules. When ES modules are
integrating against CJS systems like Grunt, it may be necessary to mutate a
module.exports
.
Remember that module.exports
from CJS is directly available under default
for import
. This means that if you use:
import * as namespace from 'grunt';
According to ES *
grabs the namespace directly whose properties will be
read-only.
However, doing:
import grunt_default from 'grunt';
Grabs the default
which is exactly what module.exports
is, and all the
properties will be mutable.
Since we need a consistent time to snapshot the module.exports
of a CJS
module. We will execute it immediately after evaluation. Code such as:
// bad-cjs.js
module.exports = 123;
setTimeout(_ => module.exports = null);
Will not see module.exports
change to null
. All ES module import
s of the
module will always see 123
.
-
vm.Module
and ways to create custom ESM implementations such as those in jsdom. -
vm.ReflectiveModule
as a means to declare a list of exports and expose a reflection API to those exports. -
Providing an option to both
vm.Script
andvm.Module
to interceptimport()
. -
Loader hooks for:
- Rewriting the URL of an
import
request prior to loader resolution. - Way to insert Modules a module's local ESM cache.
- Way to insert Modules the global ESM cache.
- Rewriting the URL of an