At Prosopo, we use Vite to build our applications. We have a monorepo structure with multiple packages that depend on each other. When we make changes to a package that our vite project depends on, Vite doesn't automatically rebuild the local package dependency as it's outside the module graph. This article explains how to get Vite to rebuild local dependencies in an NPM workspace.

The NPM Workspace Structure

Similar to this question, referencing Yarn workspaces, we have the following npm workspace structure:

package.json // workspace root
packages
    package-a (@prosopo/a)
        dist // built JS
        src
        package.json
        tsconfig.json
    package-b (@prosopo/b)
        dist
        src
        package.json
        tsconfig.json
    package-c (@prosopo/c)
        dist
        src
        package.json
        tsconfig.json
    ...

These packages are referenced in the workspace root package JSON as follows:

{
    "workspaces": ["packages/*"]
}

For this example, let's say that @prosopo/c depends on @prosopo/b, and @prosopo/b depends on @prosopo/a. They are linked via the references field in their respective tsconfig.json files.

In the @prosopo/c tsconfig.json, the references would look like this:

    "references": [
        {
            "path": "../package-b"
        }
    ]

Each package has its own build command in the package.json.

{
    "build": "tsc --build --verbose tsconfig.json"
}

The tsc command builds types as well as the JS, which are critical for the imports to work when developing with local packages.

Note

Vite does not build types. To build types you can either:

For the purpose of this demo, we'll assume that the types have already been built and we are only concerned with building the JavaScript on demand.

Vite Serve Command

@prosopo/c is a web application, for example, a React app. We run @prosopo/c with the following command from within the package-c folder:

> npx vite serve --mode=development --config vite.config.ts

  VITE v5.2.9  ready in 646 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

The Problem

Unfortunately, the above Vite command does not recognised the local workspace dependencies as being part of the project. When we make changes to @prosopo/a or @prosopo/b, Vite does not rebuild them. This means that we have to manually build these packages every time we make a change to them.

The packages are not added to the Vite module graph, so they are not watched for changes. This is because the packages are symlinked into the node_modules folder, and Vite does not follow symlinks by default. The answer to the Yarn workspaces question was to set preserveSymlinks to true in the Vite config. However, this does not work for us.

So, how do we get the @prosopo/c vite serve command to rebuild @prosopo/a and @prosopo/b when they change?

The Solution

The first part of the solution is to create a Vite plugin that adds files to the watch list on the buildStart event.

type FilePath = string

type ExternalFiles = Record<FilePath, TsConfigPath> // maps file paths to tsconfig paths

export const vitePluginWatchExternal = async (config: VitePluginWatchExternalOptions): Promise<Plugin<any>> => {

    // a helper function takes the workspace root and gets all local package files
    const externalFiles: ExternalFiles = await getExternalFileLists(config.workspaceRoot, config.fileTypes || FILE_TYPES)

    return {
        name: 'vite-plugin-watch-external',
        async buildStart() {
            Object.keys(externalFiles).map((file) => {
                this.addWatchFile(file)
            })
        },
        ...
    }
}

However, this is not enough to get Vite to rebuild these files. By adding files outwith the module graph, Vite will simply return a no modules matched message when they change. This is because the files are not part of the module graph.

We need to go a step further and listen to the handleHotUpdate event. This event is triggered when a file changes and Vite is about to send the update to the client. We can use this event to rebuild our newly watched files using esbuild, which is the default bundler for Vite.

export const vitePluginWatchExternal = async (config: VitePluginWatchExternalOptions): Promise<Plugin<any>> => {

    // a helper function takes the workspace root and gets all local package files
    const externalFiles: ExternalFiles = await getExternalFileLists(config.workspaceRoot, config.fileTypes || FILE_TYPES)

    return {

        ...

        async handleHotUpdate({ file, server }) {

            // our previously defined externalFiles object, set up when the plugin is created
            const tsconfigPath = externalFiles[file]

            if (!tsconfigPath) {
                log(`tsconfigPath not found for file ${file}`)
                return
            }
            // helper functions to load the tsconfig associated with the file
            const tsconfig = getTsConfigFollowExtends(tsconfigPath)

            // helper functions to get the file extension and loader
            const fileExtension = path.extname(file)
            const loader = getLoader(fileExtension)

            // helper functions to get the outdir and outfile for the file
            const outdir = getOutDir(file, tsconfig)
            const outfile = getOutFile(outdir, file, fileExtension)

            // build the result with esbuild using the loaded tsconfig, and the correct file paths and file extensions,
            // derived above
            const buildResult = await build({
                tsconfig: tsconfigPath,
                stdin: {
                    contents: fs.readFileSync(file, 'utf8'),
                    loader,
                    resolveDir: path.dirname(file),
                },
                outfile,
                platform: config.format === 'cjs' ? 'node' : 'neutral',
                format: config.format || 'esm',
            })

            // reload the client
            server.ws.send({
                type: 'full-reload',
            })
        },
    }
}

Video of it in action

Rebuilding local package dependencies in an npm workspace

That's It

With this plugin, Vite will now rebuild local dependencies when they change. This is particularly useful in a monorepo structure where you have multiple packages that depend on each other.

You can view the full code for the plugin in our Procaptcha repository and you can use the plugin yourself by installing from the npm registry.

We're always looking for ways to improve our development workflow at Prosopo. If you have any suggestions or questions, please feel free to reach out here.

Ready to ditch Google reCAPTCHA?
Start for free today. No credit card required.