banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

How to Write a Fully Compatible NPM Library in 2024

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.

```ts export * from './components/client' export * from './components/shared' ``` ```tsx 'use client'
export const Client = () => null
```
```tsx export const Shared = () => 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.

image

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.

Environmentrc-librc-lib/clientrc-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.

image

Upon entering the types, we find such an error.

image

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.

Environmentrc-librc-lib/clientrc-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.

Environmentrc-librc-lib/clientrc-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:

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

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.