DEV Community

rxliuli
rxliuli

Posted on • Updated on

Developing and Building Node.js Applications with Vite

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

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

pnpm i -D vite vite-node vitest
Enter fullscreen mode Exit fullscreen mode

vitest

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

import { it } from 'vitest'

it('hello world', () => {
  expect(1 + 1).eq(2)
})
Enter fullscreen mode Exit fullscreen mode

Run vitest with the following command

pnpm vitest src/__tests__/index.test.ts
Enter fullscreen mode Exit fullscreen mode

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

import { readFile } from 'fs/promises'

console.log(await readFile(__filename, 'utf-8'))
Enter fullscreen mode Exit fullscreen mode

Then run it with vite-node

pnpm vite-node src/main.ts
Enter fullscreen mode Exit fullscreen mode

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. The recommended approach in Node.js is as follows:

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

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
Enter fullscreen mode Exit fullscreen mode

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.
   pnpm i -D magic-string
Enter fullscreen mode Exit fullscreen mode
  1. Then, add the polyfill code in the renderChunk hook.
   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',
     };
   }
Enter fullscreen mode Exit fullscreen mode

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 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:
   pnpm i -D rollup-plugin-node-externals
Enter fullscreen mode Exit fullscreen mode
  1. Wrap it to make it compatible with Vite:
   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',
     }
   }
Enter fullscreen mode Exit fullscreen mode

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:

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',
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

import { Plugin } from 'vite'

export function node(): Plugin[] {
  return [shims(), externals(), config()]
}
Enter fullscreen mode Exit fullscreen mode

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

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

export default defineConfig({
  plugins: [node()],
})
Enter fullscreen mode Exit fullscreen mode

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

pnpm vite build
Enter fullscreen mode Exit fullscreen mode

Enjoy everything Vite has to offer!

A Vite plugin @liuli-util/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.
  • Issues after building with koa-bodyparser -- due to lack of proper ESM support. Awaiting the merge of this pull request.

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.

Top comments (0)