provide module, main and browser fields that satisfy esm, commonjs and bundlers - javascript

I have a number of published npm packages that I have upgraded to provide both commonjs and esm builds. Some of the packages might be for both node and the browser. All packages compiled with webpack or rollup. All are written in typescript and transpiled into a dist directory.
I create a commonjs index.js file that looks like this:
'use strict'
if (process.env.NODE_ENV === 'production') {
module.exports = require('./react-abortable-fetch.cjs.production.min.js')
} else {
module.exports = require('./react-abortable-fetch.cjs.development.js')
}
I set the package.json main field to the above index.js file.
I also generate a .esm.js file for each package and I set both browser and module fields to the esm.js file and set the type file to be module.
The end result is something like this:
"type": "module",
"main": "dist/index.js",
"browser": "dist/react-abortable-fetch.esm.js",
"module": "dist/react-abortable-fetch.esm.js",
"types": "dist/index.d.ts",
The problem with this approach is that only esm packages can consume it (unless I am wrong).
What is the best way to configure the package.json file so that packages that have not made the leap yet (and that is quite a few) can still consume the package?

The idea is to leverage Node.js conditional exports to customize the import behavior depending on how you import the module (require or import).
In order to have two different interfaces for CommonJS and ESM, you could either:
Transpile to both CommonJS and ESM (allows to accidentally use both import and require for your library in the same application which could cause unwanted and unpredictable behaviors)
Have the implementation as ESM and a CommonJS wrapper (not possible because you can't require an ECMAScript module when it has top-level await)
Have the implementation as CommonJS and an ESM wrapper (best actual solution)
So you have to set CommonJS as a TypeScript transpilation target, then create an ESM wrapper (preferably in a dedicated folder). Finally, map the CommonJS and the ESM entry points to the corresponding files in the package.json:
"exports": {
"require": "./index.js",
"import": "./esm/index.js"
}
I have published a minimal working project here: https://github.com/Guerric-P/demo-commonjs-esm
In order to use the libraries in TypeScript, you also need one .d.ts file aside every JavaScript file.

Related

Webpack 5 and ESM

I think I've read every thread on SO and every related page on the internet on this, everything has some variation of a problem
I want:
To use webpack to bundle my web app up
To use ES Modules within my source js and have them transpiled down for wider browser support
To use ES Modules within my webpack configuration
Node 14 allegedly supports ESM, so lets use that
Setup 1
I have "type": "module" in my package.json
then my webpack.config.js looks something like:
import { somethingUseful } from './src/js/useful-things.js';
export default (env, argv) => {
return {
// webpack config here
};
}
running > webpack (webpack-cli) I get:
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: D:\git\Useroo\webpack.config.js
require() of ES modules is not supported.
require() of webpack.config.js from C:\nvm\v14.14.0\node_modules\webpack-cli\lib\groups\resolveConfig.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename webpack.config.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from package.json.
OK, so lets do what the error message says
Setup 2a
If I remove "type": "module" from my package.json I get
webpack.config.js
import { somethingUseful } from './src/js/useful-things.js';
^^^^^^
SyntaxError: Cannot use import statement outside a module
right.... So lets try the other suggested alternative:
Setup 2b
module.exports = async (env, argv) => {
var somethingUseful = await import('./src/js/useful-things.js');
return {
// webpack config here
};
}
I get a segfault.
/c/Program Files/nodejs/webpack: line 14: 14272 Segmentation fault "$basedir/node" "$basedir/node_modules/webpack/bin/webpack.js" "$#"
webpack-cli now supports ES Modules. All that is required is
adding "type": "module" to your package.json
or
name your webpack config with the mjs extension: webpack.config.mjs
At the time of writing, webpack-cli just doesn't support ES6 modules, so you basically have to re-implement it yourself.
It's not that hard really, just annoying. You need something like this (simplified for brevity): Just RTFM here https://webpack.js.org/api/node/
import webpack from 'webpack';
import webpackConfig from './webpack.config.js';
var config = await webpackConfig(mode);
var compiler = webpack(config);
compiler.watch()
Webpack does not have native support for ESM config files, as the other answer states, but it does support automatically transpiling them. If your config file is named webpack(.whatever).babel.js, and you have babel properly installed, your config file will be quietly downleveled before use.
As far as I know, the only way to configure Babel in this case is to use a top level .babelrc in your project directory. Mine simply contains {"presets": ["#babel/preset-env"]}. It does mean that if I want to use Babel in the build, I have to configure it through e.g. plugin options, but it works for me.
ETA: user #bendwarn says in a deleted comment that as of Webpack CLI 4.5.0 you can name your config file webpack.config.mjs or just call it webpack.config.js if your package is type: "module" and it should work natively. I haven't tried it (and frankly that second one sounds like a terrible idea).

Grunt and ES6 modules in node - incompatible without the use of .mjs?

So, I'm dabbling a bit with Typescript and Grunt at the moment to see if it's worth it for me. The thing is: Typescript does not compile to *.mjs files but only regular *.js files. Node does support ES6 Modules but only if you either mark them as '*.jsm' files or by setting "type": "module". Setting this top-level field in package.json however has global scope for any *.js file in the same directory and any following ones.
This breaks the Gruntfile.js file as it seems since it uses CommonJS modules, see my very basic Gruntfile as example:
module.exports = function (grunt) {
grunt.initConfig({
ts: {
default: {tsconfig: "./tsconfig.json"}
}
})
grunt.loadNpmTasks("grunt-ts");
grunt.registerTask("default", ["ts"]);
}
Without expecting much success I naively changed the export syntax from module.exports = to export default which expectedly did no work since it didn't make much sense.
Questions
Is there any option to use Grunt with ES6 modules enabled in node?
Is there a proper way to tell TypeScript to compile to *.mjs files?
If you set "type": "module" in your package.json, you need to rename Gruntfile.js to Gruntfile.cjs, and run it with grunt --gruntfile Gruntfile.cjs.
The suggested approach with Babel running before Grunt makes Grunt a bit redundant. Since TypeScript does not yet support exporting ES6 modules to *.mjs files (and you have to use the *.mjs suffix in your import when node should still be running with its CommonJS system) and will probably never fully (see Design Meeting Notes 11/22/2019) I have to conclude that ES6 modules still have serious implications and issues. Changing the file extension is not enough since the extension-less imports fail with node. You'd need to go through every compiled file and change the import to specifically load *.mjs files.
However, the TypeScript Compiler can be set up in a way that it does understand ES6 module syntax and to compile to CommonJS (see TS handbook).
{
"compilerOptions": {
"module": "CommonJS",
// [...]
},
}
This way the TypeScript code be written with ES6 module syntax and the output can be CommonJS compatible without braking other code. As a bonus you can skip the Babel approach and grunt can run TS compiler.
You can using babel-node to compile first. Which will resolve ES6 export and import problem.
npm install --save-dev #babel/core #babel/node
npx babel-node server.js

Does ts-node support '#' style import? If so, how to set it up?

I'm creating a command-line script, using classes from the main express app.
Script resides in the folder:
bin/utils/
├── sync-buyers.ts
└── tsconfig.json
The main express app is in /app use uses import '#/foo/bar/thing.
This is set up in the tsconfig.json of the main app, as follows:
"paths": {
"#/*": ["*"],
"*": [
"node_modules/*",
"app/typings/*"
]
}
},
"include": ["app/**/*", "test/**/*"],
"exclude": ["app/**/*.test.ts", "/__tests__/", "/__mocks__/", "/__snapshots__/", "app/**/__mocks__/"],
"files": ["typings/global.d.ts"]
Script Execution
I'm testing to see if I can import from the main app, so I created a sayHello() function.
#!/usr/bin/env ts-node
/* tslint:disable */
import { sayHello } from '../../app/services/v2/oapp';
sayHello();
When I run it:
TSError: ⨯ Unable to compile TypeScript:
../../app/services/v2/oapp.ts(9,19): error TS2307: Cannot find module
'#/helpers/fetch'.
../../app/services/v2/oapp.ts(10,31): error TS2307: Cannot find module
'#/services/v2/buyer'.
../../app/services/v2/oapp.ts(11,51): error TS2307: Cannot find module
'#/http/HttpHeader'.
Summary:
Does ts-node support '#' style of import? If so, how do I set it up?
So the TypeScript paths configuration only applies to TypeScript's type resolution and checking, meaning that it will allow TypeScript to understand those imports for the purposes of type-checking only, but the code it generates won't automatically rewrite those imports to the correct locations.
There's two common approaches for solving this:
Update the Node resolver to understand the TypeScript paths config. The generated files will still refer to those paths by their #-name.
Most commonly, the tsconfig-paths module is used for this. You can require that module from the node command directly:
node -r tsconfig-paths/register main.js
Rewrite the generated files so that the #-names get replaced with the "real" local relative path locations.
There's a standalone module for this, tspath - you simply run tspath after compiling your TypeScript, and it updates the generated files with the correct paths.
If you're using Webpack, you can also use tsconfig-paths-webpack-plugin, which will take care of configuring Webpack's resolver to correctly locate those #-name paths.
And finally if you're using Babel, you might be interested in babel-plugin-module-resolver which does a similar thing for the Babel toolchain, however the downside here is it doesn't read the paths config from tsconfig.json, so you essentially have to duplicate your paths config in the alias config of this plugin.
Personally I'd recommend tsconfig-paths if this is a Node script or server that's compiled with tsc directly and tsconfig-paths-webpack-plugin if this is a frontend Webpack build.

How to choose 'module' instead of 'main' file in package.json

I have created some npm modules and compile them to:
commonJS (using exports.default =) and
esm (using export default)
I set up my package.json like so:
main: "index.cjs.js",
module: "index.esm.js"
When I npm install the package and I simple import it like:
import myPackage from 'my-package'
It will automatically choose the main file, not the module.
My question:
Is there a way to import the module file instead when doing import myPackage from 'my-package' in a JavaScript file?
Why I choose the commonJS file for "main":
I noticed that using Node, importing an esm file is not possible because of export default, it has to be commonJS. I have some simple helper JS functions like this and this, and I would want them to be usable to the widest audience. That's why I chose cjs for the "main" path in package.json.
Why I define a separate "module" in package.json:
Lots of famous libraries like Vue.js are already doing this. See further information on this Stackoverflow thread:
What is the "module" package.json field for?
Why do I want to be able to import the "module" file instead of the "main" file:
There is currently a bug in Rollup where it will not properly show JSDoc comments when coding after having imported a cjs file, but it does work when importing a es file.
The workaround I want to avoid:
Just set "main" to the esm file in package.json, right? But then all users who are using my packages in Node apps will not be able to use it anymore...
→ I'm really confused about all this as well, but I think I did enough research to make sense of all it. That being said, if anyone knows a better approach or any other advice, please do tell me in the comments down below!!
Just don't use extension for main file and have es6 and CommonJS version as two separate files with the same name and in the same directory, but with different extension, so:
index.js // transpiled CommonJS code for old nodejs
index.mjs // es6 module syntax
and in package.json:
{
"main": "index"
}
If node is launched with --experimental-modules flag, it would use *.mjs file, otherwise *.js.
Nodejs does not support "module" but does support the newer "exports" spec.
https://nodejs.org/api/packages.html#exports
https://github.com/nodejs/node/blob/v16.14.0/lib/internal/modules/esm/resolve.js#L910
"exports": {
"import": "./main-module.js",
"require": "./main-require.cjs"
},

Publish ES module (.mjs) to NPMJS, with backwards compatibility for Node <8.5.0 (Dual Package)

Up to Node v8.5.0, publishing a module written in ES6 to NPMJS was a straightforward process: transpile the ES6 code using a tool like Babel, and publish to NPMJS the resulting lib directory, while your GitHub repo contains the src files.
With v8.5.0, Node has released experimental support for native modules (export/import) via the --experimental-modules flag. It is now possible to publish purely-ES6 modules to NPMJS, and use them without any transpilation, as long as the files involved have an .mjs extension.
How can I publish an ES6 module (.mjs) so that it can also be used with older Node versions, which don't support ES native modules?
This is possible with 13.7.0+ using conditional exports (which as of 13.10.0+ are no longer experimental). It's not well documented or obvious how to do this in a completely backwards-compatible way, but here's the trick which I previously researched back when it was experiemental:
node_modules/mod/package.json
{
"main": "./lib.js",
"exports": {
".": [
{
"import": "./lib.mjs",
"require": "./lib.js",
"default": "./lib.js"
},
"./lib.js"
]
}
}
node_modules/mod/lib.js
exports.format = 'cjs';
node_modules/mod/lib.mjs
export const format = 'mjs';
Now it's possible to use both CommonJS:
main.js
const {format} = require('mod');
console.log(format);
$ node main.js
cjs
And ES Modules:
main.mjs
import {format} from 'mod';
console.log(format);
$ node main.mjs
mjs
Prior to this is was possible at one point to just use an extension-less main entry in package.json, but this feature was removed. See the revision history on this answer if interested.

Categories

Resources