Meaning of Code. HMR in Vite. Part 1

Meaning of Code. HMR in Vite. Part 1

Just like any other software engineer, I get hungry sometimes. So there I was, standing in line at the bakery, eyeing the freshly baked croissants and debating whether I deserved a double chocolate muffin. And then, as any self-respecting software engineer would, I asked myself: How does Vite implement HMR? What happens when the browser requests files? How do they get transformed?

Before I begin sorting out these important questions, that naturally arise in the bakeries, few heads ups from my side needed. I am not frontend engineer, although I do work with build tools, so take my words with grain of salt. Also due to rather large codebase, I demonstrate only crucial parts of the code and have no mercy to the parts I feel bring no value to the topic.

Having said these important words and mentioned Vite version that I reviewed, lets roll.


Mental model

I think it is important to establish some kind of mental modal of how HMR works, or at least how one would expect it to work, first. This should provide framework, that we can use and improve during code investigation.

First it is quite obvious that at some point Vite creates a web server which provides files for a browser. We should also see some kind of client side machinery, that handles all the stuff related to HMR, like updating data, components or other magic deeds that are done after files are changed. When we work on frontend project, we do not do anything special to make HMR work, which indicates, that this client side machinery is enabled by some code transformation and injection. Another aspect is a communication channel. Frontend application should receive file change notifications and most obvious channel to assume is a Web Socket channel. The last peace to glue these parts together is a mechanism, that watches file changes and notifies client about them.

Alright, armed with this mental model it is time to see how it all begins.


How it all begins

To understand how Vite starts it makes sense to first check root package.json file.

vite/package.json

...

"scripts": {  
  "build": "pnpm -r --filter='./packages/*' run build",  
  "dev": "pnpm -r --parallel --filter='./packages/*' run dev",  
},

...        

We can notice, that build and dev commands in scripts section use files from packages directory. Now, the packages directory has this structure:

- packages
  - create-vite
  - plugin-legacy
  - vite        

Making a guess that core functionality is located in vite directory we proceed with it. In the vite directory we see many more files including one more package.json. Checking it we see:

vite/packages/vite/package.json

...

"bin": {  
  "vite": "bin/vite.js"  
},

...        

bin section tells us which files are used to act as an app "binary" (more on that here). packages/vite/bin/vite.js seems like a great choice to continue.

packages/vite/bin/vite.js

...

function start() {  
  try {  
    module.enableCompileCache?.()  
  } catch {}  
  return import('../dist/node/cli.js')  
}  
  
if (profileIndex > 0) {  
  process.argv.splice(profileIndex, 1)  
  const next = process.argv[profileIndex]  
  if (next && !next.startsWith('-')) {  
    process.argv.splice(profileIndex, 1)  
  }  
  const inspector = await import('node:inspector').then((r) => r.default)  
  const session = (global.__vite_profile_session = new inspector.Session())  
  session.connect()  
  session.post('Profiler.enable', () => {  
    session.post('Profiler.start', start)  
  })  
} else {  
  start()
}

...        

The start function obviously is the one we should pay our attention to. The main thing that it does is just dynamically imports ../dist/node/cli.js file. There is no such file in source code, but it will be there if you build Vite. Checking Vite build config (vite/packages/vite/rollup.config.ts) we can tell that cli.js is just transpiled version of the file vite/packages/vite/src/node/cli.ts. Alright, then cli.ts it is. In the cli.ts we can see all available Vite CLI commands and options:

vite/packages/vite/src/node/cli.ts

import { cac } from 'cac'

...

const cli = cac('vite')

...

cli  
  .command('[root]', 'start dev server') // default command

...

cli  
  .command('build [root]', 'build for production')

...

cli  
  .command('optimize [root]', 'pre-bundle dependencies')

cli  
  .command('preview [root]', 'locally preview production build')

// ... etc. ...        

Here we see that Vite uses cac library to build cli tool. From all the commands declared in this file we are interested in is the first one - start dev server. It is quite obvious, because HMR feature is only important during development. Now, what is the action there?

vite/packages/vite/src/node/cli.ts

cli  
  .command('[root]', 'start dev server') // default command  
  ... 
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {  
  filterDuplicateOptions(options)  
  const { createServer } = await import('./server')  
  try {  
    const server = await createServer({  
      root,  
      base: options.base,  
      mode: options.mode,  
      configFile: options.config,  
      logLevel: options.logLevel,  
      clearScreen: options.clearScreen,  
      optimizeDeps: { force: options.force },  
      server: cleanGlobalCLIOptions(options),  
    })  
  
    if (!server.httpServer) {  
      throw new Error('HTTP server not available')  
    }  
  
    await server.listen()  
    
    ...
})        

Here createServer function creates Vite Dev Server and starts listening for http requests.

createServer is imported from vite/packages/vite/src/node/server/index.ts and boy oh boy, now we are talking, the function takes around 500 lines and I need all my mental capacity to filter out important parts. En garde!

To view or add a comment, sign in

More articles by Andrew Laminsky

Others also viewed

Explore content categories