Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit d043692

Browse filesBrowse files
GeoffreyBoothMylesBorins
authored andcommitted
doc: update divergent specifier hazard guidance
PR-URL: #30051 Reviewed-By: Guy Bedford <guybedford@gmail.com> Reviewed-By: Jan Krems <jan.krems@gmail.com>
1 parent bd82adb commit d043692
Copy full SHA for d043692

File tree

Expand file treeCollapse file tree

1 file changed

+256
-48
lines changed
Open diff view settings
Filter options
Expand file treeCollapse file tree

1 file changed

+256
-48
lines changed
Open diff view settings
Collapse file

‎doc/api/esm.md‎

Copy file name to clipboardExpand all lines: doc/api/esm.md
+256-48Lines changed: 256 additions & 48 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -219,50 +219,6 @@ The `"main"` field can point to exactly one file, regardless of whether the
219219
package is referenced via `require` (in a CommonJS context) or `import` (in an
220220
ES module context).
221221

222-
#### Compatibility with CommonJS-Only Versions of Node.js
223-
224-
Prior to the introduction of support for ES modules in Node.js, it was a common
225-
pattern for package authors to include both CommonJS and ES module JavaScript
226-
sources in their package, with `package.json` `"main"` specifying the CommonJS
227-
entry point and `package.json` `"module"` specifying the ES module entry point.
228-
This enabled Node.js to run the CommonJS entry point while build tools such as
229-
bundlers used the ES module entry point, since Node.js ignored (and still
230-
ignores) `"module"`.
231-
232-
Node.js can now run ES module entry points, but it remains impossible for a
233-
package to define separate CommonJS and ES module entry points. This is for good
234-
reason: the `pkg` variable created from `import pkg from 'pkg'` is not the same
235-
singleton as the `pkg` variable created from `const pkg = require('pkg')`, so if
236-
both are referenced within the same app (including dependencies), unexpected
237-
behavior might occur.
238-
239-
There are two general approaches to addressing this limitation while still
240-
publishing a package that contains both CommonJS and ES module sources:
241-
242-
1. Document a new ES module entry point that’s not the package `"main"`, e.g.
243-
`import pkg from 'pkg/module.mjs'` (or `import 'pkg/esm'`, if using [package
244-
exports][]). The package `"main"` would still point to a CommonJS file, and
245-
thus the package would remain compatible with older versions of Node.js that
246-
lack support for ES modules.
247-
248-
1. Switch the package `"main"` entry point to an ES module file as part of a
249-
breaking change version bump. This version and above would only be usable on
250-
ES module-supporting versions of Node.js. If the package still contains a
251-
CommonJS version, it would be accessible via a path within the package, e.g.
252-
`require('pkg/commonjs')`; this is essentially the inverse of the previous
253-
approach. Package consumers who are using CommonJS-only versions of Node.js
254-
would need to update their code from `require('pkg')` to e.g.
255-
`require('pkg/commonjs')`.
256-
257-
Of course, a package could also include only CommonJS or only ES module sources.
258-
An existing package could make a semver major bump to an ES module-only version,
259-
that would only be supported in ES module-supporting versions of Node.js (and
260-
other runtimes). New packages could be published containing only ES module
261-
sources, and would be compatible only with ES module-supporting runtimes.
262-
263-
To define separate package entry points for use by `require` and by `import`,
264-
see [Conditional Exports][].
265-
266222
### Package Exports
267223

268224
By default, all subpaths from a package can be imported (`import 'pkg/x.js'`).
@@ -422,9 +378,9 @@ can be written:
422378
}
423379
```
424380

425-
When using conditional exports, the rule is that all keys in the object mapping
426-
must not start with a `"."` otherwise they would be indistinguishable from
427-
exports subpaths.
381+
When using [Conditional Exports][], the rule is that all keys in the object
382+
mapping must not start with a `"."` otherwise they would be indistinguishable
383+
from exports subpaths.
428384

429385
<!-- eslint-skip -->
430386
```js
@@ -465,6 +421,257 @@ thrown:
465421
}
466422
```
467423

424+
### Dual CommonJS/ES Module Packages
425+
426+
_These patterns are currently experimental and only work under the
427+
`--experimental-conditional-exports` flag._
428+
429+
Prior to the introduction of support for ES modules in Node.js, it was a common
430+
pattern for package authors to include both CommonJS and ES module JavaScript
431+
sources in their package, with `package.json` `"main"` specifying the CommonJS
432+
entry point and `package.json` `"module"` specifying the ES module entry point.
433+
This enabled Node.js to run the CommonJS entry point while build tools such as
434+
bundlers used the ES module entry point, since Node.js ignored (and still
435+
ignores) the top-level `"module"` field.
436+
437+
Node.js can now run ES module entry points, and using [Conditional Exports][]
438+
with the `--experimental-conditional-exports` flag it is possible to define
439+
separate package entry points for CommonJS and ES module consumers. Unlike in
440+
the scenario where `"module"` is only used by bundlers, or ES module files are
441+
transpiled into CommonJS on the fly before evaluation by Node.js, the files
442+
referenced by the ES module entry point are evaluated as ES modules.
443+
444+
#### Divergent Specifier Hazard
445+
446+
When an application is using a package that provides both CommonJS and ES module
447+
sources, there is a risk of certain bugs if both versions of the package get
448+
loaded (for example, because one version is imported by the application and the
449+
other version is required by one of the application’s dependencies). Such a
450+
package might look like this:
451+
452+
<!-- eslint-skip -->
453+
```js
454+
// ./node_modules/pkg/package.json
455+
{
456+
"type": "module",
457+
"main": "./pkg.cjs",
458+
"exports": {
459+
"require": "./pkg.cjs",
460+
"default": "./pkg.mjs"
461+
}
462+
}
463+
```
464+
465+
In this example, `require('pkg')` always resolves to `pkg.cjs`, including in
466+
versions of Node.js where ES modules are unsupported. In Node.js where ES
467+
modules are supported, `import 'pkg'` references `pkg.mjs`.
468+
469+
The potential for bugs comes from the fact that the `pkg` created by `const pkg
470+
= require('pkg')` is not the same as the `pkg` created by `import pkg from
471+
'pkg'`. This is the “divergent specifier hazard,” where one specifer (`'pkg'`)
472+
resolves to separate files (`pkg.cjs` and `pkg.mjs`) in separate module systems,
473+
yet both versions might get loaded within an application because Node.js
474+
supports intermixing CommonJS and ES modules.
475+
476+
If the export is a constructor, an `instanceof` comparison of instances created
477+
by the two returns `false`, and if the export is an object, properties added to
478+
one (like `pkg.foo = 3`) are not present on the other. This differs from how
479+
`import` and `require` statements work in all-CommonJS or all-ES module
480+
environments, respectively, and therefore is surprising to users. It also
481+
differs from the behavior users are familiar with when using transpilation via
482+
tools like [Babel][] or [`esm`][].
483+
484+
Even if the user consistently uses either `require` or `import` to refer to
485+
`pkg`, if any dependencies of the application use the other method the hazard is
486+
still present.
487+
488+
The `--experimental-conditional-exports` flag should be set for modern Node.js
489+
for this behavior to work out. If it is not set, only the ES module version can
490+
be used in modern Node.js and the package will throw when accessed via
491+
`require()`.
492+
493+
#### Writing Dual Packages While Avoiding or Minimizing Hazards
494+
495+
First, the hazard described in the previous section occurs when a package
496+
contains both CommonJS and ES module sources and both sources are provided for
497+
use in Node.js, either via separate main entry points or exported paths. A
498+
package could instead be written where any version of Node.js receives only
499+
CommonJS sources, and any separate ES module sources the package may contain
500+
could be intended only for other environments such as browsers. Such a package
501+
would be usable by any version of Node.js, since `import` can refer to CommonJS
502+
files; but it would not provide any of the advantages of using ES module syntax.
503+
504+
A package could also switch from CommonJS to ES module syntax in a breaking
505+
change version bump. This has the obvious disadvantage that the newest version
506+
of the package would only be usable in ES module-supporting versions of Node.js.
507+
508+
Every pattern has tradeoffs, but there are two broad approaches that satisfy the
509+
following conditions:
510+
511+
1. The package is usable via both `require` and `import`.
512+
1. The package is usable in both current Node.js and older versions of Node.js
513+
that lack support for ES modules.
514+
1. The package main entry point, e.g. `'pkg'` can be used by both `require` to
515+
resolve to a CommonJS file and by `import` to resolve to an ES module file.
516+
(And likewise for exported paths, e.g. `'pkg/feature'`.)
517+
1. The package provides named exports, e.g. `import { name } from 'pkg'` rather
518+
than `import pkg from 'pkg'; pkg.name`.
519+
1. The package is potentially usable in other ES module environments such as
520+
browsers.
521+
1. The hazards described in the previous section are avoided or minimized.
522+
523+
##### Approach #1: Use an ES Module Wrapper
524+
525+
Write the package in CommonJS or transpile ES module sources into CommonJS, and
526+
create an ES module wrapper file that defines the named exports. Using
527+
[Conditional Exports][], the ES module wrapper is used for `import` and the
528+
CommonJS entry point for `require`.
529+
530+
<!-- eslint-skip -->
531+
```js
532+
// ./node_modules/pkg/package.json
533+
{
534+
"type": "module",
535+
"main": "./index.cjs",
536+
"exports": {
537+
"require": "./index.cjs",
538+
"default": "./wrapper.mjs"
539+
}
540+
}
541+
```
542+
543+
```js
544+
// ./node_modules/pkg/index.cjs
545+
exports.name = 'value';
546+
```
547+
548+
```js
549+
// ./node_modules/pkg/wrapper.mjs
550+
import cjsModule from './index.cjs';
551+
export const name = cjsModule.name;
552+
```
553+
554+
In this example, the `name` from `import { name } from 'pkg'` is the same
555+
singleton as the `name` from `const { name } = require('pkg')`. Therefore `===`
556+
returns `true` when comparing the two `name`s and the divergent specifier hazard
557+
is avoided.
558+
559+
If the module is not simply a list of named exports, but rather contains a
560+
unique function or object export like `module.exports = function () { ... }`,
561+
or if support in the wrapper for the `import pkg from 'pkg'` pattern is desired,
562+
then the wrapper would instead be written to export the default optionally
563+
along with any named exports as well:
564+
565+
```js
566+
import cjsModule from './index.cjs';
567+
export const name = cjsModule.name;
568+
export default cjsModule;
569+
```
570+
571+
This approach is appropriate for any of the following use cases:
572+
* The package is currently written in CommonJS and the author would prefer not
573+
to refactor it into ES module syntax, but wishes to provide named exports for
574+
ES module consumers.
575+
* The package has other packages that depend on it, and the end user might
576+
install both this package and those other packages. For example a `utilities`
577+
package is used directly in an application, and a `utilities-plus` package
578+
adds a few more functions to `utilities`. Because the wrapper exports
579+
underlying CommonJS files, it doesn’t matter if `utilities-plus` is written in
580+
CommonJS or ES module syntax; it will work either way.
581+
* The package stores internal state, and the package author would prefer not to
582+
refactor the package to isolate its state management. See the next section.
583+
584+
A variant of this approach would add an export, e.g. `"./module"`, to point to
585+
an all-ES module-syntax version the package. This could be used via `import
586+
'pkg/module'` by users who are certain that the CommonJS version will not be
587+
loaded anywhere in the application, such as by dependencies; or if the CommonJS
588+
version can be loaded but doesn’t affect the ES module version (for example,
589+
because the package is stateless).
590+
591+
##### Approach #2: Isolate State
592+
593+
The most straightforward `package.json` would be one that defines the separate
594+
CommonJS and ES module entry points directly:
595+
596+
<!-- eslint-skip -->
597+
```js
598+
// ./node_modules/pkg/package.json
599+
{
600+
"type": "module",
601+
"main": "./index.cjs",
602+
"exports": {
603+
"require": "./index.cjs",
604+
"default": "./index.mjs"
605+
}
606+
}
607+
```
608+
609+
This can be done if both the CommonJS and ES module versions of the package are
610+
equivalent, for example because one is the transpiled output of the other; and
611+
the package’s management of state is carefully isolated (or the package is
612+
stateless).
613+
614+
The reason that state is an issue is because both the CommonJS and ES module
615+
versions of the package may get used within an application; for example, the
616+
user’s application code could `import` the ES module version while a dependency
617+
`require`s the CommonJS version. If that were to occur, two copies of the
618+
package would be loaded in memory and therefore two separate states would be
619+
present. This would likely cause hard-to-troubleshoot bugs.
620+
621+
Aside from writing a stateless package (if JavaScript’s `Math` were a package,
622+
for example, it would be stateless as all of its methods are static), there are
623+
some ways to isolate state so that it’s shared between the potentially loaded
624+
CommonJS and ES module instances of the package:
625+
626+
1. If possible, contain all state within an instantiated object. JavaScript’s
627+
`Date`, for example, needs to be instantiated to contain state; if it were a
628+
package, it would be used like this:
629+
630+
```js
631+
import date from 'date';
632+
const someDate = new date();
633+
// someDate contains state; date does not
634+
```
635+
636+
The `new` keyword isn’t required; a package’s function can return a new
637+
object, or modify a passed-in object, to keep the state external to the
638+
package.
639+
640+
1. Isolate the state in one or more CommonJS files that are shared between the
641+
CommonJS and ES module versions of the package. For example, if the CommonJS
642+
and ES module entry points are `index.cjs` and `index.mjs`, respectively:
643+
644+
```js
645+
// ./node_modules/pkg/index.cjs
646+
const state = require('./state.cjs');
647+
module.exports.state = state;
648+
```
649+
650+
```js
651+
// ./node_modules/pkg/index.mjs
652+
export state from './state.cjs';
653+
```
654+
655+
Even if `pkg` is used via both `require` and `import` in an application (for
656+
example, via `import` in application code and via `require` by a dependency)
657+
each reference of `pkg` will contain the same state; and modifying that
658+
state from either module system will apply to both.
659+
660+
Any plugins that attach to the package’s singleton would need to separately
661+
attach to both the CommonJS and ES module singletons.
662+
663+
This approach is appropriate for any of the following use cases:
664+
* The package is currently written in ES module syntax and the package author
665+
wants that version to be used wherever such syntax is supported.
666+
* The package is stateless or its state can be isolated without too much
667+
difficulty.
668+
* The package is unlikely to have other public packages that depend on it, or if
669+
it does, the package is stateless or has state that need not be shared between
670+
dependencies or with the overall application.
671+
672+
Even with isolated state, there is still the cost of possible extra code
673+
execution between the CommonJS and ES module versions of a package.
674+
468675
## `import` Specifiers
469676

470677
### Terminology
@@ -1152,6 +1359,7 @@ $ node --experimental-modules --es-module-specifier-resolution=node index
11521359
success!
11531360
```
11541361
1362+
[Babel]: https://babeljs.io/
11551363
[CommonJS]: modules.html
11561364
[Conditional Exports]: #esm_conditional_exports
11571365
[ECMAScript-modules implementation]: https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md
@@ -1160,13 +1368,13 @@ success!
11601368
[Terminology]: #esm_terminology
11611369
[WHATWG JSON modules specification]: https://html.spec.whatwg.org/#creating-a-json-module-script
11621370
[`data:` URLs]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
1371+
[`esm`]: https://github.com/standard-things/esm#readme
11631372
[`export`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export
11641373
[`import()`]: #esm_import-expressions
11651374
[`import.meta.url`]: #esm_import_meta
11661375
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
11671376
[`module.createRequire()`]: modules.html#modules_module_createrequire_filename
11681377
[`module.syncBuiltinESMExports()`]: modules.html#modules_module_syncbuiltinesmexports
1169-
[package exports]: #esm_package_exports
11701378
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook
11711379
[special scheme]: https://url.spec.whatwg.org/#special-scheme
11721380
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.