Skip to content

Commit fb7500b

Browse files
committed
doc: esm: avoiding bugs due to divergent specifier hazard
1 parent 6226dbd commit fb7500b

File tree

1 file changed

+266
-41
lines changed

1 file changed

+266
-41
lines changed

doc/api/esm.md

Lines changed: 266 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -219,46 +219,8 @@ 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.
222+
To define separate package entry points for use by `require` and by `import`,
223+
see [Conditional Exports][].
262224

263225
### Package Exports
264226

@@ -395,6 +357,269 @@ package in use in an application, which can cause a number of bugs.
395357
Other conditions such as `"browser"`, `"electron"`, `"deno"`, `"react-native"`
396358
etc. could be defined in other runtimes or tools.
397359

360+
### Dual CommonJS/ES Module Packages and the Divergent Specifier Hazard
361+
362+
Prior to the introduction of support for ES modules in Node.js, it was a common
363+
pattern for package authors to include both CommonJS and ES module JavaScript
364+
sources in their package, with `package.json` `"main"` specifying the CommonJS
365+
entry point and `package.json` `"module"` specifying the ES module entry point.
366+
This enabled Node.js to run the CommonJS entry point while build tools such as
367+
bundlers used the ES module entry point, since Node.js ignored (and still
368+
ignores) the top-level `"module"` field.
369+
370+
Node.js can now run ES module entry points, and using [conditional exports][] it
371+
is possible to define separate package entry points for CommonJS and ES module
372+
consumers. Unlike in the scenario where `"module"` is only used by bundlers, or
373+
ES module files are transpiled into CommonJS on the fly before evaluation by
374+
Node.js, the files referenced by the ES module entry point are evaluated as ES
375+
modules. When a specifier such as `'pkg'` resolves to different files when
376+
referenced via `require` and `import`, there is a risk of certain bugs that only
377+
occur under these conditions. This risk is the divergent specifier hazard, and
378+
it is only possible when a package entry point or exported path is defined via
379+
[conditional exports][] to point to different files for CommonJS and ES module
380+
consumers. For example:
381+
382+
<!-- eslint-skip -->
383+
```js
384+
// ./node_modules/pkg/package.json
385+
{
386+
"type": "module",
387+
"main": "./pkg.cjs",
388+
"exports": {
389+
".": {
390+
"module": "./pkg.mjs",
391+
"node": "./pkg.cjs"
392+
}
393+
}
394+
}
395+
```
396+
397+
In this example, `require('pkg')` always resolves to `pkg.cjs`, including in
398+
versions of Node.js where ES modules are unsupported. In Node.js where ES
399+
modules are supported, `import 'pkg'` references `pkg.mjs`.
400+
401+
The hazard is that the `pkg` created by `const pkg = require('pkg')` is not the
402+
same as the `pkg` created by `import pkg from 'pkg'`. An `instanceof` comparison
403+
of the two returns `false`, and properties added to one (like `pkg.foo = 3`) are
404+
not present on the other. This differs from how `import` and `require`
405+
statements work in all-CommonJS or all-ES module environments, respectively, and
406+
therefore is surprising to users.
407+
408+
Essentially, the `pkg` in each environment is a separate _singleton._ Whereas in
409+
one ES module file you can have `import a from 'pkg'` and in another you can
410+
write `import b from 'pkg'` and `a instanceof b` returns `true`, that would not
411+
be the case for `const b = require('pkg')`.
412+
413+
The ES module syntax that users have been writing for use in Node.js via Babel
414+
or [`esm`](https://github.com/standard-things/esm#readme) for the last several
415+
years does not behave this way, because Babel or `esm` have been transpiling
416+
everything into CommonJS before evaluation. In the previous example, `import a
417+
from 'pkg'` would be converted into `const a = require('pkg')` and then `a
418+
instanceof b` (where `b` comes from `const b = require('pkg')`) would return
419+
`true`.
420+
421+
Looked at another way, `import pkg from 'pkg'` is a shorthand for `import pkg
422+
from './node_modules/pkg/pkg.mjs'` and `const pkg = require('pkg')` is a
423+
shorthand for `const pkg = require('./node_modules/pkg/pkg.cjs')`. Because the
424+
file paths in the two statements are different, the two `pkg` singletons are
425+
different.
426+
427+
It’s not enough to refer to `'pkg'` using only `require` or only `import` within
428+
an application; all of that application’s dependencies also need to use the same
429+
method or the hazard is present. For example, if an application uses `pkg` and
430+
`pkg-plugin`, and the application references `pkg` via `import` and `pkg-plugin`
431+
references `pkg` via `require`, both versions of `pkg` are therefore loaded.
432+
433+
#### Preventing the Divergent Specifier Hazard
434+
435+
To avoid the bugs that can occur when the divergent specifier hazard is present,
436+
one approach is to simply prevent the conditions under which the hazard can
437+
occur. This can be achieved in the following ways:
438+
439+
1. Publish a package that doesn’t use [conditional exports][]; the package entry
440+
point and all exported paths would not resolve differently based on whether
441+
they’re referenced via `require` or `import`. This would therefore mean that
442+
if the export is CommonJS, it would lack the benefits of ES module syntax
443+
such as the ability to potentially run unmodified in browser environments;
444+
and if the export is an ES module, it would not be able to run in older
445+
versions of Node.js that lack support for ES module syntax.
446+
447+
1. Define separate exported paths for separate environments, for example:
448+
449+
```json
450+
{
451+
"type": "module",
452+
"main": "./pkg.cjs",
453+
"exports": {
454+
".": "./pkg.cjs",
455+
"./module": "./pkg.mjs"
456+
}
457+
}
458+
```
459+
460+
In this example, the package `"main"` would point to a CommonJS file, and
461+
thus the package would remain compatible with older versions of Node.js that
462+
lack support for ES modules; but the ES module version of the package is
463+
available for supported environments via `'pkg/module'`. Users would need to
464+
refer to the package’s documentation (or the `package.json "module"` field,
465+
if the package supports bundlers) to know to use `'pkg/module'` with
466+
`import`; using `'pkg'` with `import` would cause the CommonJS version to be
467+
loaded.
468+
469+
1. Change the package `"main"` entry point to point to an ES module file as part
470+
of a breaking change version bump. This version and above would only be
471+
usable on ES module-supporting versions of Node.js. If the package still
472+
contains a CommonJS version, it would be accessible via a path within the
473+
package, e.g. `require('pkg/commonjs')`; this is essentially the inverse of
474+
the previous approach. Package consumers who are using CommonJS-only versions
475+
of Node.js would need to update their code from `require('pkg')` to e.g.
476+
`require('pkg/commonjs')`.
477+
478+
#### Avoiding Bugs Caused by the Divergent Specifier Hazard
479+
480+
Package authors may wish to make available at the same specifier both the
481+
CommonJS and ES module versions of a dual CommonJS/ES module package. This would
482+
allow consumers to `require('pkg')` and `import 'pkg'` and receive the CommonJS
483+
and ES module versions, respectively. When authoring a package that provides
484+
this, care must be taken to avoid the bugs which may occur due to the divergent
485+
specifier hazard.
486+
487+
1. Write the package in CommonJS or transpile the package into CommonJS, and
488+
create an ES module wrapper file to provide support for named exports (for
489+
example `import { name } from 'pkg'` instead of `import pkg from 'pkg';
490+
pkg.name`). Using conditional exports, the ES module wrapper is used for
491+
`import` and the CommonJS entry point for `require`.
492+
493+
_pkg/package.json_
494+
```json
495+
{
496+
"exports": {
497+
".": {
498+
"module": "./wrapper.mjs",
499+
"node": "./index.cjs"
500+
}
501+
}
502+
}
503+
```
504+
505+
_pkg/index.cjs_
506+
```js
507+
exports.name = 'value';
508+
```
509+
510+
_pkg/wrapper.mjs_
511+
```js
512+
import cjsModule from './index.cjs';
513+
export const name = cjsModule.name;
514+
```
515+
516+
In this example, the `name` from `import { name } from 'pkg'` is the same
517+
singleton as the `name` from `const { name } = require('pkg')`. Therefore
518+
`instanceof` returns `true` when comparing the two `name`s and the hazard is
519+
avoided. This wrapper approach, however, means that mostly CommonJS files are
520+
loaded for the package, even in Node.js ES module environments; and a
521+
separate version of the package would need to be created for environments
522+
such as browsers that don’t support CommonJS.
523+
524+
1. If possible, write a package that is stateless or stores its state outside of
525+
the package. A package that is entirely static methods, for example, is
526+
stateless; if JavaScript’s `Math` were a package, it would be stateless as
527+
all of its methods (`max`, etc.) are static. JavaScript’s `Date` needs to be
528+
instantiated to contain state; if it were a package, it would be used like
529+
this:
530+
531+
```js
532+
import date from 'date';
533+
const someDate = new date();
534+
// someDate contains state; date does not
535+
```
536+
537+
Since the state is contained within an object instantiated from the package
538+
(`someDate` in this example) rather than the package itself, an application
539+
using this package would pass around references to the instantiated object
540+
when an object with that state is desired. In other words, this file would
541+
`export` `someDate`, and other files in the application would `import` that
542+
rather than the package `date`, unless those other files wanted to create new
543+
objects with separate states. Note also that `new` isn’t required; a
544+
package’s function can also return a new object, or modify a passed-in
545+
object, to keep the state external to the package.
546+
547+
1. Write a package where the state is isolated in one or more CommonJS files
548+
that are shared between the CommonJS and ES module versions of the package.
549+
This is essentially a combination of the previous two approaches. For
550+
example, if the CommonJS and ES module entry points are `index.cjs` and
551+
`index.mjs`, respectively:
552+
553+
_pkg/state.cjs_
554+
```js
555+
module.exports = {
556+
cache: []
557+
};
558+
```
559+
560+
_pkg/index.cjs_
561+
```js
562+
const state = require('./state.cjs');
563+
module.exports.state = state;
564+
```
565+
566+
_pkg/index.mjs_
567+
```js
568+
export * as state from './state.cjs';
569+
```
570+
571+
Even if `pkg` is used via both `require` and `import` in an application (for
572+
example, via `import` in application code and via `require` by a dependency)
573+
each reference of `pkg` will contain the same state; and modifying that
574+
state from either module system will apply to both.
575+
576+
A package utilizing this pattern would not be usable as is in browsers or
577+
other environments that lack support for CommonJS.
578+
579+
1. Write a package where state is stored globally. This is similar to the
580+
previous approach, but instead of isolating state within a shared CommonJS
581+
file it is attached to the global object, e.g.
582+
`globalThis[Symbol.for('[email protected]')]`. For example, if the CommonJS and ES
583+
module entry points are `index.cjs` and `index.mjs`, respectively:
584+
585+
_pkg/index.cjs_
586+
```js
587+
const state = globalThis[Symbol.for('[email protected]')];
588+
module.exports.state = state;
589+
```
590+
591+
_pkg/index.mjs_
592+
```js
593+
export const state = globalThis[Symbol.for('[email protected]')];
594+
```
595+
596+
Like the previous approach, if `pkg` is used via both `require` and `import`
597+
in an application (for example, via `import` in application code and via
598+
`require` by a dependency) each reference of `pkg` will contain the same
599+
state; and modifying that state from either module system will apply to both.
600+
601+
This has the disadvantage of polluting the global namespace, but it is
602+
compatible with non-CommonJS environments such as browsers.
603+
604+
For all approaches other than the first, an `instanceof` comparison would return
605+
`false` when comparing the CommonJS and ES module versions of such packages, or
606+
objects instantiated from each version; end users of such packages need to be
607+
aware of this and avoid comparing identity in mixed-module system environments,
608+
or check against both versions:
609+
610+
```js
611+
import { createRequire } from 'module';
612+
const require = createRequire(import.meta.url);
613+
614+
import pkgEsModule from 'pkg';
615+
const pkgCommonJs = require('pkg');
616+
617+
export const instanceofPkg = (instantiatedPkg) => {
618+
return instantiatedPkg instanceof pkgEsModule ||
619+
instantiatedPkg instanceof pkgCommonJs;
620+
};
621+
```
622+
398623
## <code>import</code> Specifiers
399624

400625
### Terminology
@@ -1074,7 +1299,7 @@ success!
10741299
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
10751300
[`module.createRequire()`]: modules.html#modules_module_createrequire_filename
10761301
[`module.syncBuiltinESMExports()`]: modules.html#modules_module_syncbuiltinesmexports
1077-
[package exports]: #esm_package_exports
1302+
[conditional exports]: #esm_conditional_exports
10781303
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook
10791304
[special scheme]: https://url.spec.whatwg.org/#special-scheme
10801305
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules

0 commit comments

Comments
 (0)