Just had a revelation in how different tools work together thanks to hours of trying to build my Nx monorepo app where I was using:
- Bleeding-edge syntaxes in TS/JS like
using
andSymbol.dispose
. - Typescript to be on the safe side of JavaScript.
- And lastly ESM since I feel we're moving to that direction (at least tools like Vite are moving away from CommonJS (ref)).
Note
Here I use pnpm since I really do not like to be subjected to this fate again 😅.
Steps to configure it
- Let's create a directory for ourselves to do our bidding 😂:
mkdir huawei && cd huawei
. - Create a
package.json
withpnpm init
. -
Install these dev dependencies:
pnpm add -D @types/node typescript webpack webpack-cli
-
Now we need to add these
scripts
in ourpackage.json
:
"build:tsc": "rm -rf lib-esm && tsc", "build": "webpack --mode=production --node-env=production"
Do not forget to add
"type": "module"
to yourpackage.json
.-
Let's create our
tsconfig.json
:
{ "compilerOptions": { "allowSyntheticDefaultImports": true, "noImplicitAny": true, "module": "NodeNext", "target": "ES6", "experimentalDecorators": true, "sourceMap": true, "pretty": true, "outDir": "out-tsc", "esModuleInterop": true, "lib": ["esnext.disposable"], "skipLibCheck": true, "moduleResolution": "nodenext" } }
Here it is important to note that:
-
out-tsc
: that's wheretsc
will store the compiled code. We did separate tsc's output dir from webpack to be able to differentiate between whattsc
will generate and whatwebpack
generates.Not to mention that we wanna remove the output directory before build in both webpack and tsc.
Our target is
ES6
.-
Adding
"lib": ["esnext.disposable"]
to support disposable is crucial and we needed to add"esModuleInterop": true
to prevent some other issues (read this for a comprehensive explanation.).For some reason in Codesandbox I had to add
"DOM"
to mylib
too. But in local it is not necessary.
-
-
Now its time to write our
webpack.config.cjs
:
// @ts-check const { resolve } = require("path"); const isProduction = process.env.NODE_ENV == "production"; /**@type {import('webpack').Configuration} */ const config = { target: "node", entry: "./out-tsc/index.js", output: { path: resolve(__dirname, "dist"), clean: true, filename: "index.[contenthash].js", }, experiments: { outputModule: true, }, resolve: { extensions: [".tsx", ".ts", ".jsx", ".js"], extensionAlias: { ".js": [".js", ".ts"], ".cjs": [".cjs", ".cts"], ".mjs": [".mjs", ".mts"], }, }, }; module.exports = () => { if (isProduction) { config.mode = "production"; } else { config.mode = "development"; } return config; };
So a couple of things we need to discuss and explain why they are there:
- I guess
target: "node"
is obvious but for the sake of completeness I wanna say that we need it otherwise webpack would complain whenever we import something from nodejs. -
experiments
is what makes the difference. It is telling webpack to generate ESM modules rather than CommonJS. -
We do not need
ts-loader
, I tried to kinda use it but I was getting this error and had no idea why it was not first compiling it withtsc
as I instructed it and then bundle it:
ERROR in ./src/index.ts 4:6 Module parse failed: Unexpected token (4:6) You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders | import { random } from "./random.mjs"; | > using connect = new Connect() | | console.log(random()); webpack 5.96.1 compiled with 1 error in 173 ms ELIFECYCLE Command failed with exit code 1.
But if your know what I was doing wrong I appreciate it if you could fork my codesandbox, fix it, and share it here so we can look at what I was doing wrong.
-
resolve
is another important piece of this puzzle. We wanted to use.mts
extension. But then you need to import them as.mjs
. If you do not do this whentsc
compiles your code, it will not add.mjs
or.js
extension to your imports. Thus when you bundle it with webpack and try to run it you will get a "module not found error" (read more about it here):
ERROR in ./out-tsc/index.js 53:0-36 Module not found: Error: Can't resolve './connect' in '/tmp/test/out-tsc' Did you mean 'connect.js'? BREAKING CHANGE: The request './connect' failed to resolve only because it was resolved as fully specified (probably because the origin is strict EcmaScript Module, e. g. a module with javascript mimetype, a '*.mjs' file, or a '*.js' file where the package.json contains '"type": "module"'). The extension in the request is mandatory for it to be fully specified. Add the extension to the request. resolve './connect' in '/tmp/test/out-tsc' using description file: /tmp/test/package.json (relative path: ./out-tsc) using description file: /tmp/test/package.json (relative path: ./out-tsc/connect) /tmp/test/out-tsc/connect doesn't exist webpack 5.96.1 compiled with 1 error in 333 ms ELIFECYCLE Command failed with exit code 1.
I choose
CommonJS
for the webpack config file since I had some trouble with it being TS or JS.
- I guess
Codesandbox
https://codesandbox.io/p/sandbox/nodejs-webpack-esm-ts-4p4fpl
You can also find me on:
- Instagram: https://www.instagram.com/node.js.developers.kh/
- Facebook: https://www.facebook.com/kasirbarati
- X: https://x.com/kasir_barati
- YouTube: https://www.youtube.com/@kasir-barati
- GitHub: https://github.com/kasir-barati/
- Dev.to: https://dev.to/kasir-barati