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.
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.
Vite does not build types. To build types you can either:
tsc
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.
@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
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 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',
})
},
}
}
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.