Developing and Building Node.js Applications with Vite
Tags: vite, node
Published: 2023-06-19 07:04:01

## Context

As a modern tool for building web applications, Vite has been utilized by many developers for creating web applications (react, vue). Due to its ease of use and high performance, many web frameworks have even written official plugins (solid, astro), making it a successful challenger to Webpack in recent years. However, Vite has evolved to be more than just a tool for the web layer. Its surrounding ecosystem is flourishing, leading to the development of a series of peripheral tools.

## Motivation

Why is Vite suitable for developing Node.js applications?

Firstly, even without Vite, tools like vitest for unit testing, tsx/ts-node for running source code for debugging, and tsup/esbuild for bundling code into the final js to be executed are likely needed. Thus, if Vite is used, these tasks can be accomplished within the same ecosystem.

- vitest: A unit testing tool that supports ESM and TypeScript.
- vite-node: A tool for running TypeScript code that supports various Vite features, such as `?raw`.
- Vite: Bundles Node.js applications into the final JavaScript to be executed, with an option to bundle dependencies selectively.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r23qaaexkpakfcorp0l9.png)


## Usage

Vitest and vite-node are ready to use out of the box, so the focus here is on Vite's building aspects.

First, install the dependencies

```sh
pnpm i -D vite vite-node vitest
```

## vitest

Create a unit test file, for example, `src/__tests__/index.test.ts`

```ts
import { it } from 'vitest'

it('hello world', () => {
  expect(1 + 1).eq(2)
})
```

Run vitest with the following command

```sh
pnpm vitest src/__tests__/index.test.ts
```

## vite-node

It can replace the node command to run any file, offering more capabilities than the standard node command, including:

- Support for ts/tsx files and running esm/cjs modules.
- CJS polyfills in ESM, allowing direct use of `__dirname`, etc.
- Support for watch mode execution.
- Support for Vite's own features, such as `?raw`.
- Support for using Vite plugins.

For example, create a file `src/main.ts`

```ts
import { readFile } from 'fs/promises'

console.log(await readFile(__filename, 'utf-8'))
```

Then run it with vite-node

```sh
pnpm vite-node src/main.ts
```

## Vite

To build Node.js applications with Vite, it's necessary to modify some configurations and plugins to address a few key issues:

1. Implement CJS polyfills for ESM code, including `__dirname`, `__filename`, `require`, and `self`.
2. Correctly bundle `devDependencies` while excluding `node` and `dependencies`.
3. Provide an out-of-the-box default configuration.

Let's tackle these issues one by one.

### Polyfilling CJS Features During Build

In Node.js, global variables like `__dirname` are commonly used. Unfortunately, [ESM does not support these](https://nodejs.org/api/esm.html#no-__filename-or-__dirname). The recommended approach in Node.js is as follows:

```ts
import path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
```

You can use a Vite plugin to prepend some extra code to the beginning of the built code, automatically creating these variables.

Here's how to implement it:

1. Install `magic-string` to modify the code while keeping the source map intact.

   ```sh
   pnpm i -D magic-string
   ```

2. Then, add the polyfill code in the `renderChunk` hook.

   ```ts
   import MagicString from 'magic-string'
   import { Plugin } from 'vite'

   function shims(): Plugin {
     return {
       name: 'node-shims',
       renderChunk(code, chunk) {
         if (!chunk.fileName.endsWith('.js')) {
           return null;
         }
         const s = new MagicString(code);
         s.prepend(`
   import __path from 'path';
   import { fileURLToPath as __fileURLToPath } from 'url';
   import { createRequire as __createRequire } from 'module';
   
   const __getFilename = () => __fileURLToPath(import.meta.url);
   const __getDirname = () => __path.dirname(__getFilename());
   const __dirname = __getDirname();
   const __filename = __getFilename();
   const self = globalThis;
   const require = __createRequire(import.meta.url);
   `);
         return {
           code: s.toString(),
           map: s.generateMap({ hires: true }),
         };
       },
       apply: 'build',
     };
   }
   ```

By integrating this plugin into your Vite configuration, you'll automatically inject these shims into your JavaScript files during the build process, emulating Node.js's CommonJS environment within an ESM context. This solution streamlines the adaptation of existing Node.js codebases to the ESM module system.

### Correctly Bundling Dependencies

It's common in Node.js applications to include modules such as `fs`, which Vite will also try to bundle by default. Therefore, it's necessary to treat them as external dependencies. Fortunately, there is already a plugin called [rollup-plugin-node-externals](https://www.npmjs.com/package/rollup-plugin-node-externals) that can exclude Node.js and dependencies declared in the `dependencies` field of `package.json`. However, some minor compatibility adjustments are needed for it to work smoothly with Vite.

1. Install the dependency:

   ```sh
   pnpm i -D rollup-plugin-node-externals
   ```

2. Wrap it to make it compatible with Vite:

   ```ts
   import { nodeExternals } from 'rollup-plugin-node-externals'
   import { Plugin } from 'vite'

   function externals(): Plugin {
     return {
       ...nodeExternals({
         // Options here if needed
       }),
       name: 'node-externals',
       enforce: 'pre', // The key is to run it before Vite's default dependency resolution plugin
       apply: 'build',
     }
   }
   ```

### Adding Default Configuration

Given the frequency of Node.js projects, it's preferable not to repeat the configuration setup for each project. Instead, a convention-over-configuration approach is adopted, supplemented with the option for customization. Thus, a simple implementation of a shared Vite plugin for common configurations can be created as follows:

```ts
import path from 'path'
import { Plugin } from 'vite'

function config(options?: { entry?: string }): Plugin {
  const entry = options?.entry ?? 'src/main.ts'
  return {
    name: 'node-config',
    config() {
      return {
        build: {
          lib: {
            entry: path.resolve(entry),
            formats: ['es'],
            fileName: (format) => `${path.basename(entry, path.extname(entry))}.${format}.js`,
          },
          rollupOptions: {
            external: ['dependencies-to-exclude']
            // Additional Rollup options here
          },
        },
        resolve: {
          // Change default resolution to node rather than browser
          mainFields: ['module', 'jsnext:main', 'jsnext'],
          conditions: ['node'],
        },
      }
    },
    apply: 'build',
  }
}
```

This setup enables a default configuration tailored for Node.js projects, simplifying the development process. By specifying an entry file and the necessary adjustments for module resolution and build output, this plugin facilitates a straightforward build setup for a wide range of Node.js applications.

### Combining Plugins

Finally, we combine these plugins into one, creating a new plugin setup:

```ts
import { Plugin } from 'vite'

export function node(): Plugin[] {
  return [shims(), externals(), config()]
}
```

Then, use it in your `vite.config.ts` file:

```ts
import { defineConfig } from 'vite'
import { node } from './path/to/your/plugin'

export default defineConfig({
  plugins: [node()],
})
```

Now, you can build Node.js applications with Vite using the following command:

```sh
pnpm vite build
```

Enjoy everything Vite has to offer!

> A Vite plugin [@liuli-util/vite-plugin-node](https://dev.rxliuli.com/en/dev/vite-plugin-node) has been published, which is ready to use out of the box.

## Limitations

Alright, there are still some issues here, including:

- ~~Vite does not officially support building node applications, and its main goal is not that~~ -- At least it can be said that both vitest/nuxt depend on Vite at the node level.
- ~~vite-plugin-node still has many issues, such as not automatically polyfilling `__dirname` and the like~~ -- Implemented.
- ~~Vite's performance is still an order of magnitude worse compared to esbuild~~ -- The performance issues are not significant for most libraries now, and human perception can hardly notice it.
- ~~Building with zx fails to run due to incorrect configuration for chalk~~ -- specifically, failure to recognize the `node` field in `imports`. The maintainer's lack of interest suggests considering a switch to ansi-colors, as discussed in [this issue](https://github.com/chalk/chalk/issues/535).
- ~~Issues after building with koa-bodyparser~~ -- due to lack of proper ESM support. Awaiting the merge of [this pull request](https://github.com/koajs/bodyparser/pull/152).

No choice is perfect, but the decision to go all-in with Vite is deliberate.

## Future Goals

- [x] Support for multiple entry points.
- [x] Generation of type definitions.

Choosing to focus on Vite involves weighing its current limitations against its potential and making a strategic decision based on the overall benefits it offers, including its growing ecosystem and the ability to streamline development workflows for Node.js applications.