Skip to content

Commit d043692

Browse files
GeoffreyBoothMylesBorins
authored andcommitted
doc: update divergent specifier hazard guidance
PR-URL: #30051 Reviewed-By: Guy Bedford <[email protected]> Reviewed-By: Jan Krems <[email protected]>
1 parent bd82adb commit d043692

File tree

1 file changed

+256
-48
lines changed

1 file changed

+256
-48
lines changed

doc/api/esm.md

+256-48
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)