Vite+ Experiments I Didn't See Through
As we all know, Vite+ 1.0 shipped in September.
As the toolchain became more stable and ready for production, I started paying more attention to how these tools fit together, trying to find more value in having them share a config, dependencies, and a common workflow.
However, exploration is never entirely smooth sailing. Compared to the global vp CLI, which had been my main focus before, the project-level toolchain has more ecosystem compatibility concerns. How the package manager installs a package, how a config or function is loaded, whether something is a duplicated implementation: all become things you have to understand in detail.
So this article is a bit of a confession. I'll introduce some experiments I did recently: what problems I met, what ideas I had to improve the situation, and why most of them didn't work out.
Simplifying tsdown integration
Vite+ bundles its tools in several different ways. For Vite and Rolldown, it clones their repos and builds from source so it can integrate their code and share native bindings. For Oxlint and Oxfmt, npm dependencies were a practical choice at an early stage.
However, tsdown has a different approach. Like Vite, it is included in @voidzero-dev/vite-plus-core, but Vite+ bundles the published tsdown package from npm.
At first glance, this seems quite common for library publishing. However, bundling the published package is only one step. Vite+ also needs to redirect imports to its shared tools, handle native files, and preserve the dependencies that remain outside the bundle. This is where the integration becomes more work than just taking a package from npm.
Native addons are compiled binaries, so a JavaScript bundler cannot simply merge their code into its output. Those files still need to be shipped for the right platform, or replaced by a shared binding built separately.
The package metadata needs similar care. Bundling dependencies does not remove the peer requirements of tsdown or the packages bundled with it. We still need to decide which dependencies stay external, carry over the relevant peerDependencies and optional peer metadata, and check that the versions work together. When upstream changes these requirements, Vite+'s published package needs to reflect them too.
Simplifying how tsdown is shipped
That was the work I wanted to simplify. Why not use tsdown as a normal npm dependency and let the package manager preserve its package structure and resolve its dependencies?
The problem is that tsdown also needs Rolldown. Without more changes, that could bring another Rolldown package and its native binding alongside the copy Vite+ already provides, increasing the installation size. Bundling gives us control over those imports so tsdown can use Vite+'s shared tools, but it also makes us responsible for the dependency information around the bundle.
So there was a trade-off: a normal npm dependency could bring more copies and more size; bundling avoids some of that, but leaves more integration work for us to maintain. I did not find a replacement that solved both sides, so the existing approach had to stay. I'm still not quite happy with it, and I think there is room to improve it.
Reusing tsdown's parser
While looking into that integration, I found another layer: Vite+ keeps its own copy of tsdown's CAC option definitions in its pack CLI. Vite+ handles vp pack --help itself, so it needs those definitions even before a build starts.
Every time tsdown adds or changes an option, that copy needs to stay in sync. This work has been delegated to an agent that runs every day, but it is still another part that can go wrong during dependency updates. Since tsdown already knows its own options, I wanted to see whether we could reuse its parser.
But when I looked into it more closely, I found that the wrapper had several roles. It parses arguments and produces help, but also connects them to Vite+'s config loading. vp pack reads the pack field from vite.config.ts, while tsdown has its own config loading behavior. Calling the original CLI directly would not preserve that behavior.
Argument validation also belongs here. An option such as --target without a value needs to produce an error, and vp pack should only accept the commands and config options it supports. Reusing a parser is useful only if we can keep those behaviors without maintaining another copy of upstream code.
It is not a completely failed try, however. I still think some of this work could be shared, but it would need discussion with upstream about which parts of the CLI can be reused. For now, the wrapper stays, and I have put this part of the work on hold.
The dream of one dependency
Vite+ combines a lot of existing, mature tools from the community into one vite-plus package. It is expected to share their whole ecosystem, but what if that is not free?
For example, a plugin that declares vite in peerDependencies needs a Vite package to use. A meta-framework may even declare it as a direct dependency. If those dependencies resolve to another copy of Vite, we may have duplicated instances and extra downloads.
To keep dependencies consistent, Vite+ uses package overrides and aliases. For example, a pnpm project can have settings like these:
catalog:
'vite': 'npm:@voidzero-dev/vite-plus-core@1.4.0'
'vite-plus': '1.4.0'
overrides:
'vite@*': 'catalog:'
peerDependencyRules:
allowedVersions:
vite: '*'Honestly, every time I see this config, it bothers me a little. I don't think that is the best experience Vite+ could provide. Users expect one dependency that takes care of the toolchain, but they still end up with other dependency items and package-manager settings in their projects.
Could automatic peer installation help?
My first thought was about peerDependencies and package managers' automatic peer installs. If vite-plus declares Vite and related packages as peers, could a modern package manager install them in a place where other plugins could reuse them directly?
And it worked! In some cases. I got excited for a while. But after testing different dependency layouts, I found that a successful fresh install was only the beginning.
- Different package managers have different rules for installing peer dependencies automatically.
- A package with a direct Vite dependency can keep its own version. It does not have to use the one selected for Vite+. This is especially important for meta-frameworks.
- An existing lockfile may keep an old Vite version after
vite-plusis updated. Updating a package can still leave multiple instances. - Vite+ uses its own Vite build, so sharing a normal Vite release would require more upstream changes, and would also increase Vite+'s install size.
I tested these cases with small fake packages and a real React project. The fake packages made it easier to see how dependencies were resolved, but they could not prove that every real framework or workspace would behave well. In the direct-dependency cases, I could still end up with two copies until a dedupe or a fresh install.
Obviously, the peer dependency approach was not enough, though the goal is still attractive to me. One package can declare what it needs; it cannot decide how every other package in the project is installed. To make the promise reliable, there would need to be more changes than a few fields in Vite+'s package.json.
Sharing packages also means accepting their dependency structure
I tried several different approaches here, and the ones that sounded good to me all depended on keeping normal package relationships: Vite+ would depend on vite package directly, and plugins would share that package. But this becomes much harder when the tools are built, bundled and shipped together.
Vite+ also has a plan to bring more of the tools' Rust parts into a shared NAPI binding. Sharing native code could reduce what users need to download and install. And that plan is built on bundling their JavaScript code into one package.
Going back to separate upstream packages would also bring back their own bindings and dependency structure. That was a major blocker for me: a simpler dependency declaration is not enough if it means giving up the benefits of building the tools together. I don't want users to have to choose between those two things.
Flexibility is the core trade-off
If you are familiar with Vite+, you may know that the separation between the global and project-local CLIs is a central part of Vite+'s design. Users can use them together, but they can also choose either one independently. To me, that flexibility is part of what makes Vite+ useful: adopting the project toolchain does not require changing how you manage Node.js or install dependencies.
I want to keep that choice. Users should still be able to install vite-plus with their own package manager and use the tools from their project, without a global vp installation. So a solution to the dependency problem needs to work in that setup too.
But in that case, Vite+ is just one dependency being installed. It cannot decide how the user's package manager resolves the rest of the project. We have to express what we need through the config that package manager supports. This is why the aliases and overrides end up back in the user's project. I wanted to remove that extra setup, but I also wanted to preserve the freedom that makes it necessary. I couldn't have both, and that was a bit painful to accept.
This experiment left me feeling that the limit was not really in Vite+, but in what package managers allow us to express.
My thoughts on future package managers
At a time when so many tools are being implemented again, I feel package management is becoming another real bottleneck. We can make the tools faster and put them behind one command, but we still have limited ways to express how their packages should work together: which package can provide the API another one expects, or which dependency could be downloaded only when a feature is first used. These are the kinds of relationships I would like package managers to understand better.
When package manager authors look for a new direction, I would like to see more work on dependency management itself. Expanding into things that shouldn't be included in a package manager sometimes makes me feel that package managers are losing focus, while questions like these still have no good way to be expressed in manifest files.
I would rather see new dependency mechanisms for these new use cases. Toolchains are being combined and distributed in different ways, and package managers could give authors better ways to express those relationships. That seems like a more useful direction than adding more commands around the existing model.
Unified vp-plugin
After getting stuck on how the tools were installed and shipped, I turned to another part of the experience: how users configure them. Even if I could not simplify the dependency setup, could I make it easier to use the tools together?
Vite+ already brings the underlying tools' settings into vite.config.ts. If a framework needs a Vite plugin, a set of lint rules, and some pack settings, why should users configure all of them separately?
That was the idea behind vp-plugin: "One Plugin For All." A plugin could provide the different parts of a framework setup, while the user would only need to add it once.
There were a few things I wanted it to handle:
- Shared setup: let a plugin provide settings for Vite, linting, and packing together.
- Lazy loading: avoid loading a Vite plugin when the current command does not need it.
- Dependency rewriting: redirect imports to Vite+'s exports, so plugins could use the tools already included in Vite+.
I was really excited about this one. I built a definition API, Vite and pack support, and a React example. I also added build-time checks for static plugin imports and worked on rewriting imports in both JavaScript and declaration files.
Lint part failed first
However, the more I worked on the API, the less sure I was about "For All."
Lint config has a different mental model from other tools like Vite. A Vite plugin is mostly added to a list and then does its job. Lint behavior depends on file scopes, override order, and nested config. This becomes a real problem in monorepos.
Oxlint's nested config has quite different semantics: it never extends the root config. In a workspace, if a sub-package uses a framework plugin in its own config, and that plugin brings an Oxlint config with it, the sub-package gets a config of its own, and the rules from the root no longer apply there. So users would have to guess whether a plugin includes an Oxlint config, because that decides whether their root lint rules still cover the package.
Based on this and some other considerations, Vite+ chose to disable Oxlint's nested config entirely. That made the plugin even more confusing. It promises to set up what a project needs automatically, but in a sub-package config, its lint part is silently ignored (while commands like vp dev could still work). A promise like that only misleads users and costs them more time debugging.
At that point, the plugin is not really saving the user any thinking; it just makes an unreliable promise. So I dropped the lint part from vp-plugin, and left lint config to the user and to presets that are added explicitly. It was the first piece of "For All" I had to give up.
Do vp pack users really need this stuff?
Let's go back to the original idea: add a plugin at the root of the config, then let vp pack use it too.
That brings up another question. vp pack's users are often library authors, who usually have enough experience to know which plugins and options they need. Would saving a line of config be enough reason for them to use a new wrapper?
Especially when working with AI, explicit config often makes the purpose easier to understand. How can we assume an agent will know all the rules behind a new plugin wrapper, including when each part is loaded and how it changes the config?
The more I thought about it, the less sure I was that the convenience was worth the extra rules. That became one of the main reasons I put this idea on hold.
Bundling became the problem, again
Keeping dependencies working brings another problem: a plugin may have its own peer dependencies. If I bundle that plugin into a wrapper, I also need to preserve what it expects from the project. When several plugins have different peer requirements, merging all of them into one wrapper's package.json is not always straightforward.
One possible way around this is bundledDependencies, also spelled bundleDependencies. It tells npm to include selected installed dependencies in the package tarball when running npm pack. The dependency's files and package.json are shipped with the wrapper, so its package boundary can be kept instead of merging its code into the wrapper's JavaScript bundle.
My idea was to prepare these packages in a build directory, patch their imports to use Vite+'s exports, and publish the patched copies together. Keeping the original manifests could avoid flattening every plugin's peer declarations into one package.json. However, it would still need testing to make sure those peers resolve correctly, and changing a version declaration cannot make incompatible versions work together.
This would also mean taking care of upstream updates, patches, and the contents of the published tarball. Technically possible, yes, but I feel it is too hacky for the amount of convenience I was trying to provide. I started with the hope of making plugin setup easier, and ended up considering how to maintain a distribution of other people's plugins. That was the moment I realized I had gone too far.
Is the wrapper worth maintaining?
After setting aside the shared lint config, questioning the pack wrapper, and running into dependency problems again, lazy loading was the main benefit left. Avoiding an unnecessary plugin import could still be useful, but did that need a whole plugin system?
Making this work across the ecosystem could mean a wrapper package, or even a separate repo, for each Vite plugin. Each wrapper would need to follow upstream releases, keep its dependencies in sync, and test that the integration still works. A new plugin or update would then also need someone to maintain its Vite+ wrapper. I don't think that scales well.
I could keep exploring lazy loading as a smaller utility. Repackaging and maintaining upstream plugins just to provide it felt like too much work for the user-facing benefit.
This actually turns out to be the same problem I met in the previous two experiments. The wrapper can remove some work from the user's config, but I then need to maintain the differences between the tools and their packages inside that wrapper.
By the end of October, the Vite and pack API and the React example had been committed and pushed. So this was not just an idea without code. However, I was still questioning whether the whole plugin model was worth continuing. I could make more of it work, but I wanted a clearer reason for users to choose it first.
Single process vp check
vp check's implementation at the time was quite simple:
- Start a
vp fmtprocess and check the exit code to know if there are formatting issues. - Start a
vp lintprocess to finish type checking and linting. - If you use
--fix, lint fixers don't always follow your code formatter, so anothervp fmtcomes again.
One vp check command, up to three tool processes. Starting Node.js and a process itself takes time. More importantly, every vp lint and vp fmt needs to read the config again, which loads Vite and Rolldown every single time. As we all know, vite is a really large package; importing it alone took about 33 ms in one measurement on my machine (M5 Pro). This adds overhead to vp check, especially vp check --fix and commands that use it, such as vp staged.
At first, I only wanted to reduce repeated config loading. I thought loading the config once and passing the result to the other processes might be enough. After profiling, though, I was surprised to notice that the expensive part was largely importing Vite itself, rather than reading the user's config file. The question became: could we avoid importing Vite in each tool process?
At almost the same time, the Vite+ team received issue #2920, which reported an npm peer dependency conflict when running npx --package=vite-plus vp create. Looking into it drew my attention to a circular dependency relationship in the integration.
Vite+ depends on Oxlint and Oxfmt, but they also declared Vite+ as an optional peer so they could use its config loader. Vite+ called the tools, and the tools reached back into Vite+ to load the config. That relationship was therefore also part of the dependency graph npm had to resolve during an install or update.
I wanted to remove that need to reach back into Vite+. If the caller could provide the loader or the resolved config, Oxlint and Oxfmt would not need a Vite+ peer just to read the config. It seemed worth exploring alongside the performance work, since both led me to the same config-loading boundary.
Trying one process
My thought was simple: if these child processes cost so much, could we put the tools into the same process, where Node.js could reuse loaded modules and avoid some repeated config-loading work? I decided to try this more radical approach first, to measure how much repeated startup and importing were costing us before deciding what a cleaner implementation should look like.
I implemented this in PR #2966. The parent command still starts one child process, but that process runs all the planned steps. It receives the steps through stdin, since a long list of staged file paths can exceed command-line length limits.
The benchmark was quite encouraging, and honestly, seeing those numbers made me really happy:
| Command | Before | Prototype | Change |
|---|---|---|---|
vp check |
354 ms | 265 ms | -25% |
vp check --fix |
495 ms | 333 ms | -33% |
These numbers came from a small project with 20 TypeScript files. For Vite+'s own packages/cli/src, with type checking enabled, the time went from 775 ms to 693 ms. So the improvement depends on how much of the total time is spent loading tools rather than checking files.
However, the happiness didn't last long. I met a lot of problems while implementing this direction. Oxlint and Oxfmt's CLI entries were not designed to be called several times in one process. There was no public run() function that I could await and get an exit code from.
Instead, the runner had to do several things:
- Set
process.argvbefore importing each tool's CLI entry. - Import the formatter entry again for the second format pass.
- Use
beforeExitto detect when a step had finished, and handle Oxfmt callingprocess.exit(). - Read and reset
process.exitCodebetween steps. - Write markers to stdout and stderr, so the parent could separate each step's output and report its result.
It worked as a prototype, but there were too many assumptions about the tools' internal behavior. beforeExit tells us that the process has no more work keeping it alive; it is not an API saying "the formatter is done." A later change in either CLI could break those assumptions.
I don't think the prototype is the right long-term approach. However, the benchmark still suggests that reducing repeated startup and imports is worth exploring. Passing config between separate processes might be another way to keep some of the benefit. For now, I left the runner as an experimental PR rather than treating the performance numbers as enough reason to ship it.
Giving the tools a callable API
I also tried adding a local API in Oxc that could accept arguments and a Vite+ config loader. But the CLI entries I was working with were designed around running once and then letting the process exit. Exporting a function would not, by itself, make repeated calls a supported use case.
A long-running caller needs to know what happens to state and memory after each run, including failed runs. The next call must not inherit an old result or resources that should have been released. Memory and resource cleanup can be hard to reason about, especially when JavaScript and native code are involved.
I don't think Vite+ should take responsibility for managing those tools' internal state from the outside. If repeated calls are supported, the tools themselves need to define when a run is finished and what is safe to reuse. Otherwise, Vite+ would have to keep guessing which internal details it needs to reset after every call.
I don't think it is realistic to expect upstream to promise all of that just because my local prototype can run twice. It would be a larger commitment than exposing a run() function, and I was not ready to make Vite+'s performance work depend on it.
Why not directly await import('./vite.config.ts')?
There is still a question behind all of this: why do we need a vite instance to load a config file at all? Why not just use await import('./vite.config.ts')?
With the bundle config loader, Vite uses Rolldown to bundle the config before loading it. Native imports could avoid that step, but reading the file is only part of what we need:
- Vite plugins can change settings through their
confighooks. This is also what myvp-pluginidea relies on. Importing the file alone does not run those hooks. - The commands should agree on the resolved config. If linting reads the raw export while another command resolves it through Vite, the same file may produce different settings.
- Long-running tools need to reload config when it or its dependencies change. For a language server, this means picking up new settings without asking the user to restart it. A plain import is cached, so repeated imports alone do not provide that behavior.
There is progress in this direction. Vite plans to make the native config loader the default in Vite 9. This could remove the config bundling step, though native loading still has runtime requirements and does not replace plugin hooks or the rest of config resolution. Moreover, Vite+'s script-loading work in vpx and vite-plus/register may help us share more of the loading machinery too. I see these as reasons to revisit the experiment later, rather than proof that direct imports already solve the whole problem.
Successful Attempt: @liangmi/vp-config
(Funnily enough, this experiment is actually the earliest one among them. I hadn't even joined the Vite+ team when I had this idea 😆)
This is quite similar to vp-plugin: both are based on Vite+'s one config ability. Before I started using the Vite+ toolchain, I was already a big fan of Anthony Fu's development workflow, especially @antfu/eslint-config. I knew how much a good config preset could improve the development experience.
As I started to use Vite+ in my projects, I wasn't satisfied with the default linter and formatter settings. When I created a repo called oxlint-config, I asked myself: since the settings for different tools are in the same file, why can't I have a preset that makes all of them ready?
That is what @liangmi/vp-config became. Beyond linting and formatting, it also provides defaults for library bundling and tasks. For example, in CLI / TUI projects, I may want to minify the output without generating .d.ts files. Different kinds of projects can have different defaults, while still using the same preset.
Along the way, a few more interesting projects came out of this work. To make lint config easier to compose, including settings for different files in a monorepo, I developed oxlint-flat-config. It lets me write Oxlint config in ESLint-style flat config and then compose them into Oxlint's config format, which is an important layer of @liangmi/vp-config. To my surprise, this side project ended up better known than @liangmi/vp-config itself, and it became my first project to pass 45 GitHub stars, which made me really happy. Thanks to Anthony Fu for sharing it!!
Why this one worked
At first glance, vp-config and vp-plugin sound like the same idea: put several tools' settings into one thing the user adds once. So why did one work, while the other kept running into walls?
The first reason is that config is already explicit. The user calls the preset in their own vite.config.ts, can see where it goes, and can override anything after it. Nothing is loaded behind their back. The lint problems I met in vp-plugin do not disappear, but they are no longer hidden: the user can see exactly which config the preset belongs to, and decides what comes after it. The same goes for an agent reading the config.
Second, the idea had already been proven. Shared config presets like @antfu/eslint-config have been used for years, and people know what to expect from them: a set of defaults that you can override. I didn't need to invent a new model and then convince people it was predictable.
Third, it stays inside the boundaries and never tries to wrap the ecosystem. vp-config only produces config. It does not bundle anyone's plugin, rewrite imports, change how packages are installed, or replace how commands run. Even its cached tasks are normal tasks under vp run, not new commands. The package and process boundaries that troubled the other experiments are left untouched, so I never had to take over the work they were doing.
I really want to thank Anthony Fu for the inspiration along this journey. @liangmi/vp-config and oxlint-flat-config were hugely inspired by @antfu/eslint-config and his eslint-flat-config-utils. Adapting defaults to the project is a direction I still find very useful.
Looking back
Looking back, most of these experiments did not reach the result I actually hoped for. Some stopped at an upstream API, some ran into package-manager behavior, and some made me question whether the extra layer was worth maintaining at all. I'd be lying if I said that wasn't frustrating at times.
These attempts also made me pay more attention to boundaries. A package boundary carries dependency requirements. A process boundary gives each run its own state and lets the operating system clean up after it exits. When I remove one of those boundaries, I need to account for the work it was doing, even if the user's config or the final command becomes shorter.
I also learned to ask earlier what a wrapper actually saves. In vp-config, I had defaults I wanted to reuse in real projects. In vp-plugin, I spent much more time making the wrapper work before I was sure how much users would gain from it.
In the end, I still believe these are problems worth exploring. I just understand the details much better now than when I first thought, "couldn't we make this simpler?" Some ideas may become practical with changes in the upstream tools; others may only be useful in a smaller form.
Let's give it some time.
Thanks for reading, have a good day!
The first version of this article was written by me in mixed English and Chinese.
Since I'm not a native English speaker and am busy with schoolwork, I used several LLM tools, including Google Translate, to manage my time and make my writing sound more natural.