Write nodejs/browser compatible libraries
Published: 2021-06-19 05:15:00

## Question

Compatibility problems are caused by the use of platform-specific functions, which can lead to the following situations

- Different modular specifications: specify when rollup is packaged
- Platform-specific code: For example, it contains adaptation codes for different platforms
- Platform-specific dependencies: For example, nodejs needs to fill in `fetch/FormData`
- Platform-specific type definitions: such as `Blob` in the browser and `Buffer` in nodejs

## Different modular specifications

This is a very common thing. There are already multiple specifications including cjs/amd/iife/umd/esm, so supporting them (or at least supporting mainstream cjs/esm) has also become a must thing. Fortunately, the packaging tool rollup provides corresponding configurations to support output files in different formats.

> [GitHub sample project](https://github.com/rxliuli/cross-platform-lib-demo/tree/master/apps/multiple-module-format)

Shaped like

```js
// rollup.config.js
export default defineConfig({
  input: 'src/index.ts',
  output: [
    { format: 'cjs', file: 'dist/index.js', sourcemap: true },
    { format: 'esm', file: 'dist/index.esm.js', sourcemap: true },
  ],
  plugins: [typescript()],
})
```

Then specify in package.json

```json
{
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts"
}
```

> Many libraries support cjs/esm, such as [rollup](https://github.com/rollup/rollup/blob/master/package.json), but there are also libraries that only support esm, such as [unified.js series](https://github.com/unifiedjs)

## Platform-limited code

-Package different export files through different entry files, and specify environment-related code through `browser`, such as `dist/browser.js`/`dist/node.js`: you need to pay attention to the packaging tool when using it (transfer the cost To users)
-Use code to determine the dynamic loading of the operating environment

| Comparison    | Different Exports                                                | Code Judgment                                                    |
| ------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- |
| Advantages    | More thorough code isolation                                     | Does not depend on packaging tool behavior                       |
|               | The final code only contains the code of the current environment |                                                                  |
| Disadvantages | Depends on the behavior of the user's packaging tool             | The code for judging the environment may not be accurate         |
|               |                                                                  | The final code contains all the codes, but is selectively loaded |

> axios combines the above two methods to achieve browser and nodejs support, but at the same time it leads to the shortcomings of the two methods and a little confusing behavior. Refer to [getDefaultAdapter](https://github.com/axios/axios/blob/e9965bfafc82d8b42765705061b9ebe2d5532493/dist/axios.js#L872-L882). For example, in the jsdom environment, it will be considered as a browser environment, please refer to [detect jest and use http adapter instead of XMLHTTPRequest](https://github.com/axios/axios/issues/1180)

### Pack different export files through different entry files

> [GitHub sample project](https://github.com/rxliuli/cross-platform-lib-demo/tree/master/apps/platform-specific-code-multiple-bundle)

```js
// rollup.config.js
export default defineConfig({
  input: ['src/index.ts', 'src/browser.ts'],
  output: [
    { dir: 'dist/cjs', format: 'cjs', sourcemap: true },
    { dir: 'dist/esm', format: 'esm', sourcemap: true },
  ],
  plugins: [typescript()],
})
```

```json
{
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/index.d.ts",
  "browser": {
    "dist/cjs/index.js": "dist/cjs/browser.js",
    "dist/esm/index.js": "dist/esm/browser.js"
  }
}
```

### Use code to determine the dynamic loading of the runtime environment

> [GitHub sample project](https://github.com/rxliuli/cross-platform-lib-demo/tree/master/apps/platform-specific-code-dynamic-judgment)

Basically, it is judged in the code and then `await import`

```ts
import { BaseAdapter } from './adapters/BaseAdapter'
import { Class } from 'type-fest'

export class Adapter implements BaseAdapter {
  private adapter?: BaseAdapter
  private async init() {
    if (this.adapter) {
      return
    }
    let Adapter: Class<BaseAdapter>
    if (typeof fetch === 'undefined') {
      Adapter = (await import('./adapters/NodeAdapter')).NodeAdapter
    } else {
      Adapter = (await import('./adapters/BrowserAdapter')).BrowserAdapter
    }
    this.adapter = new Adapter()
  }
  async get<T>(url: string): Promise<T> {
    await this.init()
    return this.adapter!.get(url)
  }
}
```

```js
// rollup.config.js
export default defineConfig({
  input: 'src/index.ts',
  output: { dir: 'dist', format: 'cjs', sourcemap: true },
  plugins: [typescript()],
})
```

> Note: vitejs cannot bundle this kind of package, because the nodejs native package does not exist in the browser environment, this is a known error, refer to: [Cannot use amplify-js in browser environment (breaking vite/snowpack/esbuild)](https://github.com/aws-amplify/amplify-js/issues/7499).

## Platform-specific dependencies

- Direct use of `import` as a dependency: it will explode in different environments (for example, `node-fetch` will explode in the browser)
- It is judged in the code that the dependency is dynamically introduced through `require` at runtime: it will cause it to be packaged and loaded even if it is not used
- Dynamically introduce dependencies through `import()` when judged in the code at runtime: it will lead to code segmentation, and dependencies are selectively loaded as separate files
- Package different export files through different entry files, such as `dist/browser.js`/`dist/node.js`: you need to pay attention when using it (transfer the cost to the user)
- Declare `peerDependencies` optional dependencies, let users fill in by themselves: pay attention when using (pass the cost to the user)

| Contrast                                 | require | import |
| ---------------------------------------- | ------- | ------ |
| Will it be loaded                        | Yes     | No     |
| Does the developer need to pay attention | No      | No     |
| Will it be loaded multiple times         | No      | Yes    |
| Is it synchronized                       | Yes     | No     |
| rollup support                           | yes     | yes    |

## In the code to determine the runtime, dynamically introduce dependencies through `require`

> [GitHub project example](https://github.com/rxliuli/cross-platform-lib-demo/tree/master/apps/platform-specific-lib-require)

```ts
// src/adapters/BaseAdapter.ts
import { BaseAdapter } from './BaseAdapter'

export class BrowserAdapter implements BaseAdapter {
  private static init() {
    if (typeof fetch === 'undefined') {
      const globalVar: any =
        (typeof globalThis !== 'undefined' && globalThis) ||
        (typeof self !== 'undefined' && self) ||
        (typeof global !== 'undefined' && global) ||
        {}
      // The key is the dynamic require here
      Reflect.set(globalVar, 'fetch', require('node-fetch').default)
    }
  }

  async get<T>(url: string): Promise<T> {
    BrowserAdapter.init()
    return (await fetch(url)).json()
  }
}
```

![1624018106300](https://blog.rxliuli.com/resource/1a7d05ea9c1e4978bcc540c2eb8cca6f.png)

## In the code to determine the runtime, dynamically introduce dependencies through `import()`

> [GitHub project example](https://github.com/rxliuli/cross-platform-lib-demo/tree/master/apps/platform-specific-lib-import)

```ts
// src/adapters/BaseAdapter.ts
import { BaseAdapter } from './BaseAdapter'

export class BrowserAdapter implements BaseAdapter {
  // Note that this has become an asynchronous function
  private static async init() {
    if (typeof fetch === 'undefined') {
      const globalVar: any =
        (typeof globalThis !== 'undefined' && globalThis) ||
        (typeof self !== 'undefined' && self) ||
        (typeof global !== 'undefined' && global) ||
        {}
      Reflect.set(globalVar, 'fetch', (await import('node-fetch')).default)
    }
  }

  async get<T>(url: string): Promise<T> {
    await BrowserAdapter.init()
    return (await fetch(url)).json()
  }
}
```

Pack the result

![1624018026889](https://blog.rxliuli.com/resource/a5134edf1c7b4742aed1a0914faa45ad.png)

### Some sub-problems encountered

- How to judge whether there are global variables

```ts
typeof fetch === 'undefined'
```

- How to write ployfill for global variables in different environments

```ts
const globalVar: any =
  (typeof globalThis !== 'undefined' && globalThis) ||
  (typeof self !== 'undefined' && self) ||
  (typeof global !== 'undefined' && global) ||
  {}
```

- `TypeError: Right-hand side of'instanceof' is not callable`: mainly axios will judge `FormData`, and `form-data` has a default export, so you need to use `(await import('form-data' )).default` (My generation always feels like digging a hole for myself)
![1622828175546](https://blog.rxliuli.com/resource/2085a6a480124ea8b0fdddf4877f75c7.png)

Users may encounter compatibility issues when using rollup packaging. In fact, they need to choose whether to inline the code or package them separately into a file. Refer to: <https://rollupjs.org/guide/en/#inlinedynamicimports>

Inline => outline

```js
// inline
export default {
  output: {
    file: 'dist/extension.js',
    format: 'cjs',
    sourcemap: true,
  },
}
```

```js
// Outreach
export default {
  output: {
    dir: 'dist',
    format: 'cjs',
    sourcemap: true,
  },
}
```

## Platform-limited type definition

The following solutions are essentially multiple bundles

- Mixed type definition. E.g. axios
- Pack different export documents and type definitions, and require users to specify the required documents by themselves. For example, load different functions through `module/node`/`module/browser` (in fact, it is very close to the plug-in system, it is nothing more than whether to separate multiple modules)
- Use the plug-in system to separate the adaptation codes of different environments into multiple sub-modules. E.g. remark.js community

| Comparison    | Multiple type definition files    | Mixed type definition      | Multi-module                                                                     |
| ------------- | --------------------------------- | -------------------------- | -------------------------------------------------------------------------------- |
| Advantages    | Clearer environmental designation | Unified entrance           | Clearer environmental designation                                                |
| Disadvantages | Need to choose by the user        | Type definition redundancy | Need to choose by the user                                                       |
|               | Redundant dependencies            |                            | Relatively troublesome to maintain (especially when the maintainer is not alone) |

### Pack different export documents and type definitions, and require users to specify the required documents by themselves

> [GitHub project example](https://github.com/rxliuli/cross-platform-lib-demo/tree/master/apps/platform-specific-type-definition-multiple-bundle)

It is mainly to make a layer of abstraction in the core code, and then extract the platform-specific code out and package it separately.

```ts
// src/index.ts
import { BaseAdapter } from './adapters/BaseAdapter'

export class Adapter<T> implements BaseAdapter<T> {
  upload: BaseAdapter<T>['upload']

  constructor(private base: BaseAdapter<T>) {
    this.upload = this.base.upload
  }
}
```

```js
// rollup.config.js

export default defineConfig([
  {
    input: 'src/index.ts',
    output: [
      { dir: 'dist/cjs', format: 'cjs', sourcemap: true },
      { dir: 'dist/esm', format: 'esm', sourcemap: true },
    ],
    plugins: [typescript()],
  },
  {
    input: ['src/adapters/BrowserAdapter.ts', 'src/adapters/NodeAdapter.ts'],
    output: [
      { dir: 'dist/cjs/adapters', format: 'cjs', sourcemap: true },
      { dir: 'dist/esm/adapters', format: 'esm', sourcemap: true },
    ],
    plugins: [typescript()],
  },
])
```

User example

```ts
import { Adapter } from 'platform-specific-type-definition-multiple-bundle'

import { BrowserAdapter } from 'platform-specific-type-definition-multiple-bundle/dist/esm/adapters/BrowserAdapter'
export async function browser() {
  const adapter = new Adapter(new BrowserAdapter())
  console.log('browser:', await adapter.upload(new Blob()))
}

// import {NodeAdapter} from 'platform-specific-type-definition-multiple-bundle/dist/esm/adapters/NodeAdapter'
// export async function node() {
// const adapter = new Adapter(new NodeAdapter())
// console.log('node:', await adapter.upload(new Buffer(10)))
//}
```

### Use the plug-in system to separate the adaptation code of different environments into multiple submodules

Simply put, if you want to spread runtime dependencies into different submodules (such as the `node-fetch` above), or your plug-in API is very powerful, then you can use some **official** adaptation code Separate into plug-in sub-modules.