Recently, I created a library from rc-modal. Writing the code wasn't difficult; it was just a matter of encapsulating and abstracting the original code. However, packaging it and figuring out how users would utilize this package proved challenging.
First, we know that if an npm package is not ESM Pure, you need to consider compatibility with CJS/ESM, older and newer versions of Node (whether they respect the exports
field in package.json), and TypeScript projects with and without the bundler
feature enabled. Therefore, we need to ensure that users, regardless of their Node version, have no issues with module resolution, whether or not their project has type: module
enabled, and regardless of whether their TypeScript has "moduleResolution": "Bundler"
enabled.
Additionally, we need to ensure that the packaged output does not lose any "use client"
directives present in the source code.
So, in the following example, we will write such a library that needs to be fully compatible with all the scenarios mentioned above.
Initialization#
To write a library, first choose a bundler. Currently, common options include: rollup, esbuild, swc.
Rollup has the best compatibility and a relatively complete ecosystem. Here, we choose Vite as the bundler because its configuration is simpler compared to directly using Rollup.
First, initialize a project.
npm create vite@latest
# Choose react-ts
Then, we adjust the Vite configuration.
import { readFileSync } from 'fs'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const packageJson = JSON.parse(
readFileSync('./package.json', { encoding: 'utf-8' }),
)
const globals = {
...(packageJson?.dependencies || {}),
}
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/index.ts',
formats: ['cjs', 'es'],
},
rollupOptions: {
external: [
'react',
'react-dom',
'lodash',
'lodash-es',
'react/jsx-runtime',
...Object.keys(globals),
],
},
},
})
Next, in src
, we will write two simple components for export. Here, we define one as a Client Component and the other as a Shared Component. If you don't know what this is, that's fine; you just need to know what "use client" means.
export const Client = () => null
```
OK, now the code for this simple library is complete. Next, we need to consider how to be compatible with all the scenarios mentioned above.
Retaining "use client" directive#
After packaging according to the code above, the output will not contain the "use client" directive. Moreover, all modules will be bundled into a single file.
Use the Rollup plugin rollup-plugin-preserve-directives
to solve this problem.
npm i rollup-plugin-preserve-directives -D
Modify the Vite configuration.
import { preserveDirectives } from 'rollup-plugin-preserve-directives'
export default defineConfig({
// ...
build: {
lib: {
entry: 'src/index.ts',
entry: ['src/index.ts'],
formats: ['cjs', 'es'],
},
rollupOptions: {
external: [
'react',
'react-dom',
'lodash',
'lodash-es',
'react/jsx-runtime',
...Object.keys(globals),
],
output: {
preserveModules: true,
},
plugins: [preserveDirectives({})],
},
},
})
Now, after building, the dist
directory looks like this.
.
├── components
│ ├── client.cjs
│ ├── client.js
│ ├── shared.cjs
│ └── shared.js
├── index.cjs
└── index.js
And we can see that the output of client.js
is:
'use client'
const l = () => null
export { l as Client }
The "use client" directive has been retained.
Generating d.ts
#
This step is very important and serves as the foundation for later compatibility with older versions of Node.
Currently, our output has no types at all, and we need to generate d.ts
files. We can use the vite-plugin-dts
plugin for this.
npm i -D vite-plugin-dts
Modify the configuration:
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [react(), dts({})],
})
Now we have d.ts
files. Each output has a corresponding d.ts
file.
Now, how do we define the export fields in package.json
?
In Node versions that support the exports
field, we should define it like this. Assuming our current type: "module"
.
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
},
"./client": {
"import": {
"types": "./dist/components/client.d.ts",
"default": "./dist/components/client.js"
},
"require": {
"types": "./dist/components/client.d.ts",
"default": "./dist/components/client.cjs"
}
},
"./shared": {
"import": {
"types": "./dist/components/shared.d.ts",
"default": "./dist/components/shared.js"
},
"require": {
"types": "./dist/components/shared.d.ts",
"default": "./dist/components/shared.cjs"
}
}
}
}
Writing this is quite tedious. Each export item requires an import
and require
field, and within that, a types
field that must be first. If you accidentally write it incorrectly or miss it, it can cause issues.
So why can't we write it in this form?
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
That's because the types
field is only useful for ESM and not for CJS. Since CJS/ESM are separated, the types
field also needs to be separated.
Modify vite.config.ts
.
export default defineConfig({
plugins: [
react(),
dts({
beforeWriteFile: (filePath, content) => {
writeFileSync(filePath.replace('.d.ts', '.d.cts'), content)
return { filePath, content }
},
}),
],
})
Now each output has a corresponding d.cts
and d.ts
file.
.
├── components
│ ├── client.cjs
│ ├── client.d.cts
│ ├── client.d.ts
│ ├── client.js
│ ├── shared.cjs
│ ├── shared.d.cts
│ ├── shared.d.ts
│ └── shared.js
├── index.cjs
├── index.d.cts
├── index.d.ts
└── index.js
Now the exports
field only needs to be modified to:
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./client": {
"import": "./components/client.js",
"require": "./dist/components/client.cjs"
},
"./shared": {
"import": "./dist/components/shared.js",
"require": "./dist/components/shared.cjs"
}
}
}
Well, we don't even need the types
field anymore.
Let's add the usual definitions.
{
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts"
}
Now let's build it, then pack it and verify it on arethetypeswrong.
npm run build
npm pack
After uploading the taz
, we got the following results.
Environment | rc-lib | rc-lib/client | rc-lib/shared |
---|---|---|---|
node10 | ✅ | 💀 Resolution failed | 💀 Resolution failed |
node16 (from CJS) | ✅ | ✅ (CJS) | ✅ (CJS) |
node16 (from ESM) | 🥴 Internal resolution error (2) | ✅ (ESM) | ✅ (ESM) |
bundler | ✅ | ✅ | ✅ |
As we can see, in node10
, which does not support the exports
field, the non-index exports cannot be resolved.
Secondly, in Node 16, when tsconfig
has moduleResolution: "node16"
and the project is type: "module"
, the index cannot be correctly type inferred.
Let's first solve the latter issue.
Start a demo where the above issue occurs.
{
"type": "module",
"dependencies": {
"rc-lib": "workspace:*" // Here we use pnpm workspace to link this package.
}
}
{
"compilerOptions": {
"moduleResolution": "node16"
}
}
Create index.ts
import { Client } from 'rc-lib'
import { Share } from 'rc-lib/shared'
console.log(!!Client, !!Share)
At this point, using tsx index.ts
, the program runs smoothly and outputs true true
. This shows that Node has no issue resolving this module. However, the problem lies in TypeScript's type inference.
Upon entering the types, we find such an error.
The problem is identified; the export items do not have suffixes.
Now we modify the package's index.ts
.
export * from './components/client'
export * from './components/shared'
export * from './components/client.js'
export * from './components/shared.js'
After repackaging, we verify on the website.
Environment | rc-lib | rc-lib/client | rc-lib/shared |
---|---|---|---|
node10 | ✅ | 💀 Resolution failed | 💀 Resolution failed |
node16 (from CJS) | ✅ (CJS) | ✅ (CJS) | ✅ (CJS) |
node16 (from ESM) | ✅ (ESM) | ✅ (ESM) | ✅ (ESM) |
bundler | ✅ | ✅ | ✅ |
Now, aside from Node 10 not supporting exports
, we have resolved everything.
Solving the non-index export issue for older Node versions#
To solve this problem, we first need to know about typesVersions
.
typesVersions
is a field introduced in TypeScript 4.1 to address type inference issues for different versions of Node with different versions of TypeScript.
In Node 10, we can use this field to resolve the inference issues for export items.
{
"typesVersions": {
"*": {
"*": ["./dist/*", "./dist/components/*", "./*"]
}
}
}
Since the export items exist in dist
and dist/components
, we need to define two layers of directories, and the last root directory is also necessary based on testing.
Now, after packing, we verify compatibility again.
Environment | rc-lib | rc-lib/client | rc-lib/shared |
---|---|---|---|
node10 | ✅ | ✅ | ✅ |
node16 (from CJS) | ✅ (CJS) | ✅ (CJS) | ✅ (CJS) |
node16 (from ESM) | ✅ (ESM) | ✅ (ESM) | ✅ (ESM) |
bundler | ✅ | ✅ | ✅ |
Now we have fully compatible with all environments, mission accomplished.
The complete template of the above code is located at:
Github Repo not found
The embedded github repo could not be found…
This article was synchronized to xLog by Mix Space. The original link is https://innei.in/posts/tech/write-a-universally-compatible-js-library-with-type-support