diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 440a14d3aea2a2..53d1d485caeafa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -262,9 +262,13 @@ export default defineConfig({ link: '/guide/philosophy', }, { - text: 'Migration from v4', + text: 'Migration from v5', link: '/guide/migration', }, + { + text: 'Breaking Changes', + link: '/changes/', + }, ], }, { @@ -283,8 +287,8 @@ export default defineConfig({ link: '/guide/api-javascript', }, { - text: 'Vite Runtime API', - link: '/guide/api-vite-runtime', + text: 'Environment API', + link: '/guide/api-environment', }, { text: 'Config Reference', @@ -332,6 +336,45 @@ export default defineConfig({ ], }, ], + '/changes/': [ + { + text: 'Breaking Changes', + link: '/changes/', + }, + { + text: 'Current', + items: [], + }, + { + text: 'Future', + items: [ + { + text: 'this.environment in Hooks', + link: '/changes/this-environment-in-hooks', + }, + { + text: 'HMR hotUpdate Plugin Hook', + link: '/changes/hotupdate-hook', + }, + { + text: 'Move to per-environment APIs', + link: '/changes/per-environment-apis', + }, + { + text: 'SSR using ModuleRunner API', + link: '/changes/ssr-using-modulerunner', + }, + { + text: 'Shared plugins during build', + link: '/changes/shared-plugins-during-build', + }, + ], + }, + { + text: 'Past', + items: [], + }, + ], }, outline: { diff --git a/docs/blog/announcing-vite5-1.md b/docs/blog/announcing-vite5-1.md index 35ee61c172870e..bc9286bbbfaef7 100644 --- a/docs/blog/announcing-vite5-1.md +++ b/docs/blog/announcing-vite5-1.md @@ -56,7 +56,9 @@ The new API brings many benefits: The initial idea [was proposed by Pooya Parsa](https://github.com/nuxt/vite/pull/201) and implemented by [Anthony Fu](https://github.com/antfu) as the [vite-node](https://github.com/vitest-dev/vitest/tree/main/packages/vite-node#readme) package to [power Nuxt 3 Dev SSR](https://antfu.me/posts/dev-ssr-on-nuxt) and later also used as the base for [Vitest](https://vitest.dev). So the general idea of vite-node has been battle-tested for quite some time now. This is a new iteration of the API by [Vladimir Sheremet](https://github.com/sheremet-va), who had already re-implemented vite-node in Vitest and took the learnings to make the API even more powerful and flexible when adding it to Vite Core. The PR was one year in the makings, you can see the evolution and discussions with ecosystem maintainers [here](https://github.com/vitejs/vite/issues/12165). -Read more in the [Vite Runtime API guide](/guide/api-vite-runtime) and [give us feedback](https://github.com/vitejs/vite/discussions/15774). +::: info +The Vite Runtime API evolved into the Module Runner API, released in Vite 6 as part of the [Environment API](/guide/api-environment). +::: ## Features diff --git a/docs/changes/hotupdate-hook.md b/docs/changes/hotupdate-hook.md new file mode 100644 index 00000000000000..9a660c2d18fdf1 --- /dev/null +++ b/docs/changes/hotupdate-hook.md @@ -0,0 +1,122 @@ +# HMR `hotUpdate` Plugin Hook + +::: tip Feedback +Give us feedback at [Environment API feedback discussion](https://github.com/vitejs/vite/discussions/16358) +::: + +We're planning to deprecate the `handleHotUpdate` plugin hook in favor of [`hotUpdate` hook](/guide/api-environment#the-hotupdate-hook) to be [Environment API](/guide/api-environment.md) aware, and handle additional watch events with `create` and `delete`. + +Affected scope: `Vite Plugin Authors` + +::: warning Future Deprecation +`hotUpdate` was first introduced in `v6.0`. The deprecation of `handleHotUpdate` is planned for `v7.0`. We don't yet recommend moving away from `handleHotUpdate` yet. If you want to experiment and give us feedback, you can use the `future.removePluginHookHandleHotUpdate` to `"warn"` in your vite config. +::: + +## Motivation + +The [`handleHotUpdate` hook](/guide/api-plugin.md#handlehotupdate) allows to perform custom HMR update handling. A list of modules to be updated is passed in the `HmrContext` + +```ts +interface HmrContext { + file: string + timestamp: number + modules: Array + read: () => string | Promise + server: ViteDevServer +} +``` + +This hook is called once for all environments, and the passed modules have mixed information from the Client and SSR environments only. Once frameworks move to custom environments, a new hook that is called for each of them is needed. + +The new `hotUpdate` hook works in the same way as `handleHotUpdate` but it is called for each environment and receives a new `HotUpdateContext` instance: + +```ts +interface HotUpdateContext { + type: 'create' | 'update' | 'delete' + file: string + timestamp: number + modules: Array + read: () => string | Promise + server: ViteDevServer +} +``` + +The current dev environment can be accessed like in other Plugin hooks with `this.environment`. The `modules` list will now be module nodes from the current environment only. Each environment update can define different update strategies. + +This hook is also now called for additional watch events and not only for `'update'`. Use `type` to differentiate between them. + +## Migration Guide + +Filter and narrow down the affected module list so that the HMR is more accurate. + +```js +handleHotUpdate({ modules }) { + return modules.filter(condition) +} + +// Migrate to: + +hotUpdate({ modules }) { + return modules.filter(condition) +} +``` + +Return an empty array and perform a full reload: + +```js +handleHotUpdate({ server, modules, timestamp }) { + // Invalidate modules manually + const invalidatedModules = new Set() + for (const mod of modules) { + server.moduleGraph.invalidateModule( + mod, + invalidatedModules, + timestamp, + true + ) + } + server.ws.send({ type: 'full-reload' }) + return [] +} + +// Migrate to: + +hotUpdate({ modules, timestamp }) { + // Invalidate modules manually + const invalidatedModules = new Set() + for (const mod of modules) { + this.environment.moduleGraph.invalidateModule( + mod, + invalidatedModules, + timestamp, + true + ) + } + this.environment.hot.send({ type: 'full-reload' }) + return [] +} +``` + +Return an empty array and perform complete custom HMR handling by sending custom events to the client: + +```js +handleHotUpdate({ server }) { + server.ws.send({ + type: 'custom', + event: 'special-update', + data: {} + }) + return [] +} + +// Migrate to... + +hotUpdate() { + this.environment.hot.send({ + type: 'custom', + event: 'special-update', + data: {} + }) + return [] +} +``` diff --git a/docs/changes/index.md b/docs/changes/index.md new file mode 100644 index 00000000000000..7607ae2decc6ea --- /dev/null +++ b/docs/changes/index.md @@ -0,0 +1,27 @@ +# Breaking Changes + +List of breaking changes in Vite including API deprecations, removals, and changes. Most of the changes below can be opt-in using the [`future` option](/config/shared-options.html#future) in your Vite config. + +## Planned + +These changes are planned for the next major version of Vite. The deprecation or usage warnings will guide you where possible, and we're reaching out to framework, plugin authors, and users to apply these changes. + +- _No planned changes yet_ + +## Considering + +These changes are being considered and are often experimental APIs that intend to improve upon current usage patterns. As not all changes are listed here, please check out the [Experimental Label in Vite GitHub Discussions](https://github.com/vitejs/vite/discussions/categories/feedback?discussions_q=label%3Aexperimental+category%3AFeedback) for the full list. + +We don't recommend switching to these APIs yet. They are included in Vite to help us gather feedback. Please check these proposals and let us know how they work in your use case in each's linked GitHub Discussions. + +- [`this.environment` in Hooks](/changes/this-environment-in-hooks) +- [HMR `hotUpdate` Plugin Hook](/changes/hotupdate-hook) +- [Move to per-environment APIs](/changes/per-environment-apis) +- [SSR using `ModuleRunner` API](/changes/ssr-using-modulerunner) +- [Shared plugins during build](/changes/shared-plugins-during-build) + +## Past + +The changes below has been done or reverted. They are no longer relevant in the current major version. + +- _No past changes yet_ diff --git a/docs/changes/per-environment-apis.md b/docs/changes/per-environment-apis.md new file mode 100644 index 00000000000000..93ec216a6459e3 --- /dev/null +++ b/docs/changes/per-environment-apis.md @@ -0,0 +1,33 @@ +# Move to per-environment APIs + +::: tip Feedback +Give us feedback at [Environment API feedback discussion](https://github.com/vitejs/vite/discussions/16358) +::: + +Multiple APIs from ViteDevServer related to module graph has replaced with more isolated Environment APIs. + +- `server.moduleGraph` -> [`environment.moduleGraph`](/guide/api-environment#separate-module-graphs) +- `server.transformRequest` -> `environment.transformRequest` +- `server.warmupRequest` -> `environment.warmupRequest` + +Affect scope: `Vite Plugin Authors` + +::: warning Future Deprecation +The Environment instance was first introduced at `v6.0`. The deprecation of `server.moduleGraph` and other methods that are now in environments is planned for `v7.0`. We don't recommend moving away from server methods yet. To identify your usage, set these in your vite config. + +```ts +future: { + removeServerModuleGraph: 'warn', + removeServerTransformRequest: 'warn', +} +``` + +::: + +## Motivation + +// TODO: + +## Migration Guide + +// TODO: diff --git a/docs/changes/shared-plugins-during-build.md b/docs/changes/shared-plugins-during-build.md new file mode 100644 index 00000000000000..3c9fc99f698a61 --- /dev/null +++ b/docs/changes/shared-plugins-during-build.md @@ -0,0 +1,22 @@ +# Shared Plugins during Build + +::: tip Feedback +Give us feedback at [Environment API feedback discussion](https://github.com/vitejs/vite/discussions/16358) +::: + +// TODO: +See [Shared plugins during build](/guide/api-environment.md#shared-plugins-during-build). + +Affect scope: `Vite Plugin Authors` + +::: warning Future Default Change +`builder.sharedConfigBuild` was first introduce in `v6.0`. You can set it true to check how your plugins work with a shared config. We're looking for feedback about changing the default in a future major once the plugin ecosystem is ready. +::: + +## Motivation + +// TODO: + +## Migration Guide + +// TODO: diff --git a/docs/changes/ssr-using-modulerunner.md b/docs/changes/ssr-using-modulerunner.md new file mode 100644 index 00000000000000..130c20f63a198b --- /dev/null +++ b/docs/changes/ssr-using-modulerunner.md @@ -0,0 +1,21 @@ +# SSR using `ModuleRunner` API + +::: tip Feedback +Give us feedback at [Environment API feedback discussion](https://github.com/vitejs/vite/discussions/16358) +::: + +`server.ssrLoadModule` has been replaced by [Module Runner](/guide/api-environment#modulerunner). + +Affect scope: `Vite Plugin Authors` + +::: warning Future Deprecation +`ModuleRunner` was first introduce in `v6.0`. The deprecation of `server.ssrLoadModule` is planned for a future major. To identify your usage, set `future.removeSrLoadModule` to `"warn"` in your vite config. +::: + +## Motivation + +// TODO: + +## Migration Guide + +// TODO: diff --git a/docs/changes/this-environment-in-hooks.md b/docs/changes/this-environment-in-hooks.md new file mode 100644 index 00000000000000..2577ef3c306694 --- /dev/null +++ b/docs/changes/this-environment-in-hooks.md @@ -0,0 +1,43 @@ +# `this.environment` in Hooks + +::: tip Feedback +Give us feedback at [Environment API feedback discussion](https://github.com/vitejs/vite/discussions/16358) +::: + +Before Vite 6, only two environments were available: `client` and `ssr`. A single `options.ssr` plugin hook argument in `resolveId`, `load` and `transform` allowed plugin authors to differentiate between these two environments when processing modules in plugin hooks. In Vite 6, a Vite application can define any number of named environments as needed. We're introducing `this.environment` in the plugin context to interact with the environment of the current module in hooks. + +Affect scope: `Vite Plugin Authors` + +::: warning Future Deprecation +`this.environment` was introduced in `v6.0`. The deprecation of `options.ssr` is planned for `v7.0`. At that point we'll start recommending migrating your plugins to use the new API. To identify your usage, set `future.removePluginHookSsrArgument` to `"warn"` in your vite config. +::: + +## Motivation + +`this.environment` not only allow the plugin hook implementation to know the current environment name, it also gives access to the environment config options, module graph information, and transform pipeline (`environment.config`, `environment.moduleGraph`, `environment.transformRequest()`). Having the environment instance available in the context allows plugin authors to avoid the dependency of the whole dev server (typically cached at startup through the `configureServer` hook). + +## Migration Guide + +For the existing plugin to do a quick migration, replace the `options.ssr` argument with `this.environment.name !== 'client'` in the `resolveId`, `load` and `transform` hooks: + +```ts +import { Plugin } from 'vite' + +export function myPlugin(): Plugin { + return { + name: 'my-plugin', + resolveId(id, importer, options) { + const isSSR = options.ssr // [!code --] + const isSSR = this.environment.name !== 'client' // [!code ++] + + if (isSSR) { + // SSR specific logic + } else { + // Client specific logic + } + }, + } +} +``` + +For a more robust long term implementation, the plugin hook should handle for [multiple environments](/guide/api-environment.html#accessing-the-current-environment-in-hooks) using fine-grained environment options instead of relying on the environment name. diff --git a/docs/config/build-options.md b/docs/config/build-options.md index ee89fb8273704d..04698db56d7ef8 100644 --- a/docs/config/build-options.md +++ b/docs/config/build-options.md @@ -192,12 +192,19 @@ When set to `true`, the build will also generate an SSR manifest for determining Produce SSR-oriented build. The value can be a string to directly specify the SSR entry, or `true`, which requires specifying the SSR entry via `rollupOptions.input`. +## build.emitAssets + +- **Type:** `boolean` +- **Default:** `false` + +During non-client builds, static assets aren't emitted as it is assumed they would be emitted as part of the client build. This option allows frameworks to force emitting them in other environments build. It is responsibility of the framework to merge the assets with a post build step. + ## build.ssrEmitAssets - **Type:** `boolean` - **Default:** `false` -During the SSR build, static assets aren't emitted as it is assumed they would be emitted as part of the client build. This option allows frameworks to force emitting them in both the client and SSR build. It is responsibility of the framework to merge the assets with a post build step. +During the SSR build, static assets aren't emitted as it is assumed they would be emitted as part of the client build. This option allows frameworks to force emitting them in both the client and SSR build. It is responsibility of the framework to merge the assets with a post build step. This option will be replaced by `build.emitAssets` once Environment API is stable. ## build.minify diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index 9e2af19e6255c5..c08a867f75464b 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -490,3 +490,12 @@ Whether your application is a Single Page Application (SPA), a [Multi Page Appli - `'custom'`: don't include HTML middlewares Learn more in Vite's [SSR guide](/guide/ssr#vite-cli). Related: [`server.middlewareMode`](./server-options#server-middlewaremode). + +## future + +- **Type:** `Record` +- **Related:** [Breaking Changes](/changes/) + +Enable future breaking changes to prepare for a smooth migration to the next major version of Vite. The list may be updated, added, or removed at any time as new features are developed. + +See the [Breaking Changes](/changes/) page for details of the possible options. diff --git a/docs/guide/api-environment.md b/docs/guide/api-environment.md new file mode 100644 index 00000000000000..284af4005133ea --- /dev/null +++ b/docs/guide/api-environment.md @@ -0,0 +1,1009 @@ +# Environment API + +:::warning Low-level API +Initial work for this API was introduced in Vite 5.1 with the name "Vite Runtime API". This guide describes a revised API, renamed to Environment API. This API will be released in Vite 6. You can already test it in the latest `vite@6.0.0-alpha.x` version. + +Resources: + +- [Environment API PR](https://github.com/vitejs/vite/pull/16471) where the new API is implemented and reviewed. +- [Feedback discussion](https://github.com/vitejs/vite/discussions/16358) where we are gathering feedback about the new APIs. + +Feel free to send us PRs against the `v6/environment-api` branch to fix the issues you discover. Please share with us your feedback as you test the proposal. +::: + +Vite 6 formalizes the concept of Environments, introducing new APIs to create and configure them as well as accessing options and context utilities with a consistent API. Since Vite 2, there were two implicit Environments (`client` and `ssr`). Plugin Hooks received a `ssr` boolean in the last options parameter to identify the target environment for each processed module. Several APIs expected an optional last `ssr` parameter to properly associate modules to the correct environment (for example `server.moduleGraph.getModuleByUrl(url, { ssr })`). The `ssr` environment was configured using `config.ssr` that had a partial set of the options present in the client environment. During dev, both `client` and `ssr` environment were running concurrently with a single shared plugin pipeline. During build, each build got a new resolved config instance with a new set of plugins. + +The new Environment API not only makes these two default environment explicit, but allows users to create as many named environments as needed. There is a uniform way to configure environments (using `config.environments`) and the environment options and context utilities associated to a module being processed is accessible in plugin hooks using `this.environment`. APIs that previously expected a `ssr` boolean are now scoped to the proper environment (for example `environment.moduleGraph.getModuleByUrl(url)`). During dev, all environments are run concurrently as before. During build, for backward compatibility each build gets its own resolved config instance. But plugins or users can opt-in into a shared build pipeline. + +Even if there are big changes internally, and new opt-in APIs, there are no breaking changes from Vite 5. The initial goal of Vite 6 will be to move the ecosystem to the new major as smoothly as possible, delaying promoting the adoption of new APIs in plugins until there is enough users ready to consume the new versions of these plugins. + +## Using environments in the Vite server + +A single Vite dev server can be used to interact with different module execution environments concurrently. We'll use the word environment to refer to a configured Vite processing pipeline that can resolve ids, load, and process source code and is connected to a runtime where the code is executed. The transformed source code is called a module, and the relationships between the modules processed in each environment are kept in a module graph. The code for these modules is sent to the runtimes associated with each environment to be executed. When a module is evaluated, the runtime will request its imported modules triggering the processing of a section of the module graph. In a typical Vite app, environments will be used for the ES modules served to the client and for the server program that does SSR. An app can do SSR in a Node server, but also other JS runtimes like [Cloudflare's workerd](https://github.com/cloudflare/workerd). So we can have different types of environments on the same Vite server: browser environments, node environments, and workerd environments, to name a few. + +A Vite Module Runner allows running any code by processing it with Vite plugins first. It is different from `server.ssrLoadModule` because the runner implementation is decoupled from the server. This allows library and framework authors to implement their layer of communication between the Vite server and the runner. The browser communicates with its corresponding environment using the server Web Socket and through HTTP requests. The Node Module runner can directly do function calls to process modules as it is running in the same process. Other environments could run modules connecting to a JS runtime like workerd, or a Worker Thread as Vitest does. + +All these environments share Vite's HTTP server, middlewares, and Web Socket. The resolved config and plugins pipeline are also shared, but plugins can use `apply` so its hooks are only called for certain environments. The environment can also be accessed inside hooks for fine-grained control. + +![Vite Environments](../images/vite-environments.svg) + +A Vite dev server exposes two environments by default: a `client` environment and an `ssr` environment. The client environment is a browser environment by default, and the module runner is implemented by importing the virtual module `/@vite/client` to client apps. The SSR environment runs in the same Node runtime as the Vite server by default and allows application servers to be used to render requests during dev with full HMR support. We'll discuss later how frameworks and users can change the environment types for the default client and SSR environments, or register new environments (for example to have a separate module graph for [RSC](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components)). + +The available environments can be accessed using `server.environments`: + +```js +const environment = server.environments.client + +environment.transformRequest(url) + +console.log(server.environments.ssr.moduleGraph) +``` + +Most of the time, the current `environment` instance will be available as part of the context of the code being run so the need to access them through `server.environments` should be rare. For example, inside plugin hooks the environment is exposed as part of the `PluginContext`, so it can be accessed using `this.environment`. + +A dev environment is an instance of the `DevEnvironment` class: + +```ts +class DevEnvironment { + /** + * Unique identifier for the environment in a Vite server. + * By default Vite exposes 'client' and 'ssr' environments. + */ + name: string + /** + * Communication channel to send and receive messages from the + * associated module runner in the target runtime. + */ + hot: HotChannel | null + /** + * Graph of module nodes, with the imported relationship between + * processed modules and the cached result of the processed code. + */ + moduleGraph: EnvironmentModuleGraph + /** + * Resolved plugins for this environment, including the ones + * created using the per-environment `create` hook + */ + plugins: Plugin[] + /** + * Allows to resolve, load, and transform code through the + * environment plugins pipeline + */ + pluginContainer: EnvironmentPluginContainer + /** + * Resolved config options for this environment. Options at the server + * global scope are taken as defaults for all environments, and can + * be overridden (resolve conditions, external, optimizedDeps) + */ + config: ResolvedConfig & ResolvedDevEnvironmentOptions + + constructor(name, config, { hot, options }: DevEnvironmentSetup) + + /** + * Resolve the URL to an id, load it, and process the code using the + * plugins pipeline. The module graph is also updated. + */ + async transformRequest(url: string): TransformResult + + /** + * Register a request to be processed with low priority. This is useful + * to avoid waterfalls. The Vite server has information about the imported + * modules by other requests, so it can warmup the module graph so the + * modules are already processed when they are requested. + */ + async warmupRequest(url: string): void +} +``` + +With `TransformResult` being: + +```ts +interface TransformResult { + code: string + map: SourceMap | { mappings: '' } | null + etag?: string + deps?: string[] + dynamicDeps?: string[] +} +``` + +An environment instance in the Vite server lets you process a URL using the `environment.transformRequest(url)` method. This function will use the plugin pipeline to resolve the `url` to a module `id`, load it (reading the file from the file system or through a plugin that implements a virtual module), and then transform the code. While transforming the module, imports and other metadata will be recorded in the environment module graph by creating or updating the corresponding module node. When processing is done, the transform result is also stored in the module. + +But the environment instance can't execute the code itself, as the runtime where the module will be run could be different from the one the Vite server is running in. This is the case for the browser environment. When a html is loaded in the browser, its scripts are executed triggering the evaluation of the entire static module graph. Each imported URL generates a request to the Vite server to get the module code, which ends up handled by the Transform Middleware by calling `server.environments.client.transformRequest(url)`. The connection between the environment instance in the server and the module runner in the browser is carried out through HTTP in this case. + +:::info transformRequest naming +We are using `transformRequest(url)` and `warmupRequest(url)` in the current version of this proposal so it is easier to discuss and understand for users used to Vite's current API. Before releasing, we can take the opportunity to review these names too. For example, it could be named `environment.processModule(url)` or `environment.loadModule(url)` taking a page from Rollup's `context.load(id)` in plugin hooks. For the moment, we think keeping the current names and delaying this discussion is better. +::: + +:::info Running a module +The initial proposal had a `run` method that would allow consumers to invoke an import on the runner side by using the `transport` option. During our testing we found out that the API was not universal enough to start recommending it. We are open to implement a built-in layer for remote SSR implementation based on the frameworks feedback. In the meantime, Vite still exposes a [`RunnerTransport` API](#runnertransport) to hide the complexity of the runner RPC. +::: + +For the `ssr` environment running in Node by default, Vite creates a module runner that implements evaluation using `new AsyncFunction` running in the same JS runtime as the dev server. This runner is an instance of `ModuleRunner` that exposes: + +```ts +class ModuleRunner { + /** + * URL to execute. Accepts file path, server path, or id relative to the root. + * Returns an instantiated module (same as in ssrLoadModule) + */ + public async import(url: string): Promise> + /** + * Other ModuleRunner methods... + */ +``` + +:::info +In the v5.1 Runtime API, there were `executeUrl` and `executeEntryPoint` methods - they are now merged into a single `import` method. If you want to opt-out of the HMR support, create a runner with `hmr: false` flag. +::: + +The default SSR Node module runner is not exposed. You can use `createNodeEnvironment` API with `createServerModuleRunner` together to create a runner that runs code in the same thread, supports HMR and doesn't conflict with the SSR implementation (in case it's been overridden in the config). Given a Vite server configured in middleware mode as described by the [SSR setup guide](/guide/ssr#setting-up-the-dev-server), let's implement the SSR middleware using the environment API. Error handling is omitted. + +```js +import { + createServer, + createServerHotChannel, + createServerModuleRunner, + createNodeDevEnvironment, +} from 'vite' + +const server = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + environments: { + node: { + dev: { + // Default Vite SSR environment can be overridden in the config, so + // make sure you have a Node environment before the request is received. + createEnvironment(name, config) { + return createNodeDevEnvironment(name, config, { + hot: createServerHotChannel(), + }) + }, + }, + }, + }, +}) + +const runner = createServerModuleRunner(server.environments.node) + +app.use('*', async (req, res, next) => { + const url = req.originalUrl + + // 1. Read index.html + let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8') + + // 2. Apply Vite HTML transforms. This injects the Vite HMR client, + // and also applies HTML transforms from Vite plugins, e.g. global + // preambles from @vitejs/plugin-react + template = await server.transformIndexHtml(url, template) + + // 3. Load the server entry. import(url) automatically transforms + // ESM source code to be usable in Node.js! There is no bundling + // required, and provides full HMR support. + const { render } = await runner.import('/src/entry-server.js') + + // 4. render the app HTML. This assumes entry-server.js's exported + // `render` function calls appropriate framework SSR APIs, + // e.g. ReactDOMServer.renderToString() + const appHtml = await render(url) + + // 5. Inject the app-rendered HTML into the template. + const html = template.replace(``, appHtml) + + // 6. Send the rendered HTML back. + res.status(200).set({ 'Content-Type': 'text/html' }).end(html) +}) +``` + +## Environment agnostic SSR + +::: info +It isn't clear yet what APIs Vite should provide to cover the most common SSR use cases. We are thinking on releasing the Environment API without an official way to do environment agnostic SSR to let the ecosystem explore common patterns first. +::: + +## Separate module graphs + +Each environment has an isolated module graph. All module graphs have the same signature, so generic algorithms can be implemented to crawl or query the graph without depending on the environment. `hotUpdate` is a good example. When a file is modified, the module graph of each environment will be used to discover the affected modules and perform HMR for each environment independently. + +::: info +Vite v5 had a mixed Client and SSR module graph. Given an unprocessed or invalidated node, it isn't possible to know if it corresponds to the Client, SSR, or both environments. Module nodes have some properties prefixed, like `clientImportedModules` and `ssrImportedModules` (and `importedModules` that returns the union of both). `importers` contains all importers from both the Client and SSR environment for each module node. A module node also has `transformResult` and `ssrTransformResult`. A backward compatibility layer allows the ecosystem to migrate from the deprecated `server.moduleGraph`. +::: + +Each module is represented by a `EnvironmentModuleNode` instance. Modules may be registered in the graph without yet being processed (`transformResult` would be `null` in that case). `importers` and `importedModules` are also updated after the module is processed. + +```ts +class EnvironmentModuleNode { + environment: string + + url: string + id: string | null = null + file: string | null = null + + type: 'js' | 'css' + + importers = new Set() + importedModules = new Set() + importedBindings: Map> | null = null + + info?: ModuleInfo + meta?: Record + transformResult: TransformResult | null = null + + acceptedHmrDeps = new Set() + acceptedHmrExports: Set | null = null + isSelfAccepting?: boolean + lastHMRTimestamp = 0 + lastInvalidationTimestamp = 0 +} +``` + +`environment.moduleGraph` is an instance of `EnvironmentModuleGraph`: + +```ts +export class EnvironmentModuleGraph { + environment: string + + urlToModuleMap = new Map() + idToModuleMap = new Map() + etagToModuleMap = new Map() + fileToModulesMap = new Map>() + + constructor( + environment: string, + resolveId: (url: string) => Promise, + ) + + async getModuleByUrl( + rawUrl: string, + ): Promise + + getModulesByFile(file: string): Set | undefined + + onFileChange(file: string): void + + invalidateModule( + mod: EnvironmentModuleNode, + seen: Set = new Set(), + timestamp: number = Date.now(), + isHmr: boolean = false, + ): void + + invalidateAll(): void + + async ensureEntryFromUrl( + rawUrl: string, + setIsSelfAccepting = true, + ): Promise + + createFileOnlyEntry(file: string): EnvironmentModuleNode + + async resolveUrl(url: string): Promise + + updateModuleTransformResult( + mod: EnvironmentModuleNode, + result: TransformResult | null, + ): void + + getModuleByEtag(etag: string): EnvironmentModuleNode | undefined +} +``` + +## Creating new environments + +One of the goals of this feature is to provide a customizable API to process and run code. Users can create new environment types using the exposed primitives. + +```ts +import { DevEnvironment, RemoteEnvironmentTransport } from 'vite' + +function createWorkerdDevEnvironment(name: string, config: ResolvedConfig, context: DevEnvironmentContext) { + const hot = /* ... */ + const connection = /* ... */ + const transport = new RemoteEnvironmentTransport({ + send: (data) => connection.send(data), + onMessage: (listener) => connection.on('message', listener), + }) + + const workerdDevEnvironment = new DevEnvironment(name, config, { + options: { + resolve: { conditions: ['custom'] }, + ...context.options, + }, + hot, + runner: { + transport, + }, + }) + return workerdDevEnvironment +} +``` + +Then users can create a workerd environment to do SSR using: + +```js +const ssrEnvironment = createWorkerdEnvironment('ssr', config) +``` + +## Environment Configuration + +Environments are explicitly configured with the `environments` config option. + +```js +export default { + environments: { + client: { + resolve: { + conditions: [], // configure the Client environment + }, + }, + ssr: { + dev: { + optimizeDeps: {}, // configure the SSR environment + }, + }, + rsc: { + resolve: { + noExternal: true, // configure a custom environment + }, + }, + }, +} +``` + +All environment configs extend from user's root config, allowing users add defaults for all environments at the root level. This is quite useful for the common use case of configuring a Vite client only app, that can be done without going through `environments.client`. + +```js +export default { + resolve: { + conditions: [], // configure a default for all environments + }, +} +``` + +The `EnvironmentOptions` interface exposes all the per-environment options. There are `SharedEnvironmentOptions` that apply to both `build` and `dev`, like `resolve`. And there are `DevEnvironmentOptions` and `BuildEnvironmentOptions` for dev and build specific options (like `dev.optimizeDeps` or `build.outDir`). + +```ts +interface EnvironmentOptions extends SharedEnvironmentOptions { + dev: DevOptions + build: BuildOptions +} +``` + +As we explained, Environment specific options defined at the root level of user config are used for the default client environment (the `UserConfig` interface extends from the `EnvironmentOptions` interface). And environments can be configured explicitly using the `environments` record. The `client` and `ssr` environments are always present during dev, even if an empty object is set to `environments`. This allows backward compatibility with `server.ssrLoadModule(url)` and `server.moduleGraph`. During build, the `client` environment is always present, and the `ssr` environment is only present if it is explicitly configured (using `environments.ssr` or for backward compatibility `build.ssr`). + +```ts +interface UserConfig extends EnvironmentOptions { + environments: Record + // other options +} +``` + +::: info + +The `ssr` top level property has many options in common with `EnvironmentOptions`. This option was created for the same use case as `environments` but only allowed configuration of a small number of options. We're going to deprecate it in favour of a unified way to define environment configuration. + +::: + +## Custom environment instances + +To create custom dev or build environment instances, you can use the `dev.createEnvironment` or `build.createEnvironment` functions. + +```js +export default { + environments: { + rsc: { + dev: { + createEnvironment(name, config, { watcher }) { + // Called with 'rsc' and the resolved config during dev + return createNodeDevEnvironment(name, config, { + hot: customHotChannel(), + watcher + }) + } + }, + build: { + createEnvironment(name, config) { + // Called with 'rsc' and the resolved config during build + return createNodeBuildEnvironment(name, config) + } + outDir: '/dist/rsc', + }, + }, + }, +} +``` + +The environment will be accessible in middlewares or plugin hooks through `server.environments`. In plugin hooks, the environment instance is passed in the options so they can do conditions depending on the way they are configured. + +Environment providers like Workerd, can expose an environment provider for the most common case of using the same runtime for both dev and build environments. The default environment options can also be set so the user doesn't need to do it. + +```js +function createWorkedEnvironment(userConfig) { + return mergeConfig( + { + resolve: { + conditions: [ + /*...*/ + ], + }, + dev: { + createEnvironment(name, config, { watcher }) { + return createWorkerdDevEnvironment(name, config, { + hot: customHotChannel(), + watcher, + }) + }, + }, + build: { + createEnvironment(name, config) { + return createWorkerdBuildEnvironment(name, config) + }, + }, + }, + userConfig, + ) +} +``` + +Then the config file can be written as + +```js +import { createWorkerdEnvironment } from 'vite-environment-workerd' + +export default { + environments: { + ssr: createWorkerdEnvironment({ + build: { + outDir: '/dist/ssr', + }, + }), + rsc: createWorkerdEnvironment({ + build: { + outDir: '/dist/rsc', + }, + }), + ], +} +``` + +In this case we see how the `ssr` environment can be configured to use workerd as it's runtime. Additionally a new custom RSC environment is also defined, backed by a separate instance of the workerd runtime. + +## Plugins and environments + +### Accessing the current environment in hooks + +The Vite server has a shared plugin pipeline, but when a module is processed it is always done in the context of a given environment. The `environment` instance is available in the plugin context of `resolveId`, `load`, and `transform`. + +A plugin could use the `environment` instance to: + +- Only apply logic for certain environments. +- Change the way they work depending on the configuration for the environment, which can be accessed using `environment.config`. The vite core resolve plugin modifies the way it resolves ids based on `environment.config.resolve.conditions` for example. + +```ts + transform(code, id) { + console.log(this.environment.config.resolve.conditions) + } +``` + +### Registering new environments using hooks + +Plugins can add new environments in the `config` hook: + +```ts + config(config: UserConfig) { + config.environments.rsc ??= {} + } +``` + +An empty object is enough to register the environment, default values from the root level environment config. + +### Configuring environment using hooks + +While the `config` hook is running, the complete list of environments isn't yet known and the environments can be affected by both the default values from the root level environment config or explicitly through the `config.environments` record. +Plugins should set default values using the `config` hook. To configure each environment, they can use the new `configEnvironment` hook. This hook is called for each environment with its partially resolved config including resolution of final defaults. + +```ts + configEnvironment(name: string, options: EnvironmentOptions) { + if (name === 'rsc') { + options.resolve.conditions = // ... +``` + +### The `hotUpdate` hook + +- **Type:** `(this: { environment: DevEnvironment }, options: HotUpdateOptions) => Array | void | Promise | void>` +- **See also:** [HMR API](./api-hmr) + +The `hotUpdate` hook allows plugins to perform custom HMR update handling for a given environment. When a file changes, the HMR algorithm is run for each environment in series according to the order in `server.environments`, so the `hotUpdate` hook will be called multiple times. The hook receives a context object with the following signature: + +```ts +interface HotUpdateContext { + type: 'create' | 'update' | 'delete' + file: string + timestamp: number + modules: Array + read: () => string | Promise + server: ViteDevServer +} +``` + +- `this.environment` is the module execution environment where a file update is currently being processed. + +- `modules` is an array of modules in this environment that are affected by the changed file. It's an array because a single file may map to multiple served modules (e.g. Vue SFCs). + +- `read` is an async read function that returns the content of the file. This is provided because, on some systems, the file change callback may fire too fast before the editor finishes updating the file, and direct `fs.readFile` will return empty content. The read function passed in normalizes this behavior. + +The hook can choose to: + +- Filter and narrow down the affected module list so that the HMR is more accurate. + +- Return an empty array and perform a full reload: + + ```js + hotUpdate({ modules, timestamp }) { + if (this.environment.name !== 'client') + return + + // Invalidate modules manually + const invalidatedModules = new Set() + for (const mod of modules) { + this.environment.moduleGraph.invalidateModule( + mod, + invalidatedModules, + timestamp, + true + ) + } + this.environment.hot.send({ type: 'full-reload' }) + return [] + } + ``` + +- Return an empty array and perform complete custom HMR handling by sending custom events to the client: + + ```js + hotUpdate() { + if (this.environment.name !== 'client') + return + + this.environment.hot.send({ + type: 'custom', + event: 'special-update', + data: {} + }) + return [] + } + ``` + + Client code should register the corresponding handler using the [HMR API](./api-hmr) (this could be injected by the same plugin's `transform` hook): + + ```js + if (import.meta.hot) { + import.meta.hot.on('special-update', (data) => { + // perform custom update + }) + } + ``` + +### Per-environment Plugins + +A plugin can define what are the environments it should apply to with the `applyToEnvironment` function. + +```js +const UnoCssPlugin = () => { + // shared global state + return { + buildStart() { + // init per environment state with WeakMap, this.environment + }, + configureServer() { + // use global hooks normally + }, + applyToEnvironment(environment) { + // return true if this plugin should be active in this environment + // if the function isn't provided, the plugin is active in all environments + }, + resolveId(id, importer) { + // only called for environments this plugin apply to + }, + } +} +``` + +## `ModuleRunner` + +A module runner is instantiated in the target runtime. All APIs in the next section are imported from `vite/module-runner` unless stated otherwise. This export entry point is kept as lightweight as possible, only exporting the minimal needed to create module runners. + +**Type Signature:** + +```ts +export class ModuleRunner { + constructor( + public options: ModuleRunnerOptions, + public evaluator: ModuleEvaluator, + private debug?: ModuleRunnerDebugger, + ) {} + /** + * URL to execute. Accepts file path, server path, or id relative to the root. + */ + public async import(url: string): Promise + /** + * Clear all caches including HMR listeners. + */ + public clearCache(): void + /** + * Clears all caches, removes all HMR listeners, and resets source map support. + * This method doesn't stop the HMR connection. + */ + public async destroy(): Promise + /** + * Returns `true` if the runner has been destroyed by calling `destroy()` method. + */ + public isDestroyed(): boolean +} +``` + +The module evaluator in `ModuleRunner` is responsible for executing the code. Vite exports `ESModulesEvaluator` out of the box, it uses `new AsyncFunction` to evaluate the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation. + +Module runner exposes `import` method. When Vite server triggers `full-reload` HMR event, all affected modules will be re-executed. Be aware that Module Runner doesn't update `exports` object when this happens (it overrides it), you would need to run `import` or get the module from `moduleCache` again if you rely on having the latest `exports` object. + +**Example Usage:** + +```js +import { ModuleRunner, ESModulesEvaluator } from 'vite/module-runner' +import { root, fetchModule } from './rpc-implementation.js' + +const moduleRunner = new ModuleRunner( + { + root, + fetchModule, + // you can also provide hmr.connection to support HMR + }, + new ESModulesEvaluator(), +) + +await moduleRunner.import('/src/entry-point.js') +``` + +## `ModuleRunnerOptions` + +```ts +export interface ModuleRunnerOptions { + /** + * Root of the project + */ + root: string + /** + * A set of methods to communicate with the server. + */ + transport: RunnerTransport + /** + * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. + * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. + * You can provide an object to configure how file contents and source maps are resolved for files that were not processed by Vite. + */ + sourcemapInterceptor?: + | false + | 'node' + | 'prepareStackTrace' + | InterceptorOptions + /** + * Disable HMR or configure HMR options. + */ + hmr?: + | false + | { + /** + * Configure how HMR communicates between the client and the server. + */ + connection: ModuleRunnerHMRConnection + /** + * Configure HMR logger. + */ + logger?: false | HMRLogger + } + /** + * Custom module cache. If not provided, it creates a separate module cache for each module runner instance. + */ + moduleCache?: ModuleCacheMap +} +``` + +## `ModuleEvaluator` + +**Type Signature:** + +```ts +export interface ModuleEvaluator { + /** + * Evaluate code that was transformed by Vite. + * @param context Function context + * @param code Transformed code + * @param id ID that was used to fetch the module + */ + runInlinedModule( + context: ModuleRunnerContext, + code: string, + id: string, + ): Promise + /** + * evaluate externalized module. + * @param file File URL to the external module + */ + runExternalModule(file: string): Promise +} +``` + +Vite exports `ESModulesEvaluator` that implements this interface by default. It uses `new AsyncFunction` to evaluate code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically in the server node environment. If your runner implementation doesn't have this constraint, you should use `fetchModule` (exported from `vite`) directly. + +## RunnerTransport + +**Type Signature:** + +```ts +interface RunnerTransport { + /** + * A method to get the information about the module. + */ + fetchModule: FetchFunction +} +``` + +Transport object that communicates with the environment via an RPC or by directly calling the function. By default, you need to pass an object with `fetchModule` method - it can use any type of RPC inside of it, but Vite also exposes bidirectional transport interface via a `RemoteRunnerTransport` class to make the configuration easier. You need to couple it with the `RemoteEnvironmentTransport` instance on the server like in this example where module runner is created in the worker thread: + +::: code-group + +```ts [worker.js] +import { parentPort } from 'node:worker_threads' +import { fileURLToPath } from 'node:url' +import { + ESModulesEvaluator, + ModuleRunner, + RemoteRunnerTransport, +} from 'vite/module-runner' + +const runner = new ModuleRunner( + { + root: fileURLToPath(new URL('./', import.meta.url)), + transport: new RemoteRunnerTransport({ + send: (data) => parentPort.postMessage(data), + onMessage: (listener) => parentPort.on('message', listener), + timeout: 5000, + }), + }, + new ESModulesEvaluator(), +) +``` + +```ts [server.js] +import { BroadcastChannel } from 'node:worker_threads' +import { createServer, RemoteEnvironmentTransport, DevEnvironment } from 'vite' + +function createWorkerEnvironment(name, config, context) { + const worker = new Worker('./worker.js') + return new DevEnvironment(name, config, { + hot: /* custom hot channel */, + runner: { + transport: new RemoteEnvironmentTransport({ + send: (data) => worker.postMessage(data), + onMessage: (listener) => worker.on('message', listener), + }), + }, + }) +} + +await createServer({ + environments: { + worker: { + dev: { + createEnvironment: createWorkerEnvironment, + }, + }, + }, +}) +``` + +::: + +`RemoteRunnerTransport` and `RemoteEnvironmentTransport` are meant to be used together, but you don't have to use them at all. You can define your own function to communicate between the runner and the server. For example, if you connect to the environment via an HTTP request, you can call `fetch().json()` in `fetchModule` function: + +```ts +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' + +export const runner = new ModuleRunner( + { + root: fileURLToPath(new URL('./', import.meta.url)), + transport: { + async fetchModule(id, importer) { + const response = await fetch( + `http://my-vite-server/fetch?id=${id}&importer=${importer}`, + ) + return response.json() + }, + }, + }, + new ESModulesEvaluator(), +) + +await runner.import('/entry.js') +``` + +::: warning Acessing Module on the Server +We do not want to encourage communication between the server and the runner. One of the problems that was exposed with `vite.ssrLoadModule` is over-reliance on the server state inside the processed modules. This makes it harder to implement runtime-agnostic SSR since user environment might have no access to server APIs. For example, this code assumes that Vite server and user code can run in the same context: + +```ts +const vite = createServer() +const routes = collectRoutes() + +const { processRoutes } = await vite.ssrLoadModule('internal:routes-processor') +processRoutes(routes) +``` + +This makes it impossible to run user code in the same way it might run in production (for example, on the edge) because the server state and user state are coupled. So instead, we recommend using virtual modules to import the state and process it inside the user module: + +```ts +// this code runs on another machine or in another thread + +import { runner } from './ssr-module-runner.js' +import { processRoutes } from './routes-processor.js' + +const { routes } = await runner.import('virtual:ssr-routes') +processRoutes(routes) +``` + +Simple setups like in [SSR Guide](/guide/ssr) can still use `server.transformIndexHtml` directly if it's not expected that the server will run in a different process in production. However, if the server will run in an edge environment or a separate process, we recommend creating a virtual module to load HTML: + +```ts {13-21} +function vitePluginVirtualIndexHtml(): Plugin { + let server: ViteDevServer | undefined + return { + name: vitePluginVirtualIndexHtml.name, + configureServer(server_) { + server = server_ + }, + resolveId(source) { + return source === 'virtual:index-html' ? '\0' + source : undefined + }, + async load(id) { + if (id === '\0' + 'virtual:index-html') { + let html: string + if (server) { + this.addWatchFile('index.html') + html = await fs.promises.readFile('index.html', 'utf-8') + html = await server.transformIndexHtml('/', html) + } else { + html = await fs.promises.readFile('dist/client/index.html', 'utf-8') + } + return `export default ${JSON.stringify(html)}` + } + return + }, + } +} +``` + +Then in SSR entry point you can call `import('virtual:index-html')` to retrieve the processed HTML: + +```ts +import { render } from 'framework' + +// this example uses cloudflare syntax +export default { + async fetch() { + // during dev, it will return transformed HTML + // during build, it will bundle the basic index.html into a string + const { default: html } = await import('virtual:index-html') + return new Response(render(html), { + headers: { 'content-type': 'text/html' }, + }) + }, +} +``` + +This keeps the HTML processing server agnostic. + +::: + +## ModuleRunnerHMRConnection + +**Type Signature:** + +```ts +export interface ModuleRunnerHMRConnection { + /** + * Checked before sending messages to the client. + */ + isReady(): boolean + /** + * Send a message to the client. + */ + send(message: string): void + /** + * Configure how HMR is handled when this connection triggers an update. + * This method expects that the connection will start listening for HMR updates and call this callback when it's received. + */ + onUpdate(callback: (payload: HotPayload) => void): void +} +``` + +This interface defines how HMR communication is established. Vite exports `ServerHMRConnector` from the main entry point to support HMR during Vite SSR. The `isReady` and `send` methods are usually called when the custom event is triggered (like, `import.meta.hot.send("my-event")`). + +`onUpdate` is called only once when the new module runner is initiated. It passed down a method that should be called when connection triggers the HMR event. The implementation depends on the type of connection (as an example, it can be `WebSocket`/`EventEmitter`/`MessageChannel`), but it usually looks something like this: + +```js +function onUpdate(callback) { + this.connection.on('hmr', (event) => callback(event.data)) +} +``` + +The callback is queued and it will wait for the current update to be resolved before processing the next update. Unlike the browser implementation, HMR updates in a module runner will wait until all listeners (like, `vite:beforeUpdate`/`vite:beforeFullReload`) are finished before updating the modules. + +## Environments during build + +In the CLI, calling `vite build` and `vite build --ssr` will still build the client only and ssr only environments for backward compatibility. + +When `builder.entireApp` is `true` (or when calling `vite build --app`), `vite build` will opt-in into building the entire app instead. This would later on become the default in a future major. A `ViteBuilder` instance will be created (build-time equivalent to a `ViteDevServer`) to build all configured environments for production. By default the build of environments is run in series respecting the order of the `environments` record. A framework or user can further configure how the environments are built using: + +```js +export default { + builder: { + buildApp: async (builder) => { + const environments = Object.values(builder.environments) + return Promise.all( + environments.map((environment) => builder.build(environment)), + ) + }, + }, +} +``` + +### Environment in build hooks + +In the same way as during dev, plugin hooks also receive the environment instance during build, replacing the `ssr` boolean. +This also works for `renderChunk`, `generateBundle`, and other build only hooks. + +### Shared plugins during build + +Before Vite 6, the plugins pipelines worked in a different way during dev and build: + +- **During dev:** plugins are shared +- **During Build:** plugins are isolated for each environment (in different processes: `vite build` then `vite build --ssr`). + +This forced frameworks to share state between the `client` build and the `ssr` build through manifest files written to the file system. In Vite 6, we are now building all environments in a single process so the way the plugins pipeline and inter-environment communication can be aligned with dev. + +In a future major (Vite 7 or 8), we aim to have complete alignment: + +- **During both dev and build:** plugins are shared, with [per-environment filtering](#per-environment-plugins) + +There will also be a single `ResolvedConfig` instance shared during build, allowing for caching at entire app build process level in the same way as we have been doing with `WeakMap` during dev. + +For Vite 6, we need to do a smaller step to keep backward compatibility. Ecosystem plugins are currently using `config.build` instead of `environment.config.build` to access configuration, so we need to create a new `ResolvedConfig` per environment by default. A project can opt-in into sharing the full config and plugins pipeline setting `builder.sharedConfigBuild` to `true`. + +This option would only work of a small subset of projects at first, so plugin authors can opt-in for a particular plugin to be shared by setting the `sharedDuringBuild` flag to `true`. This allows for easily sharing state both for regular plugins: + +```js +function myPlugin() { + // Share state among all environments in dev and build + const sharedState = ... + return { + name: 'shared-plugin', + transform(code, id) { ... }, + + // Opt-in into a single instance for all environments + sharedDuringBuild: true, + } +} +``` + +## Backward Compatibility + +The current Vite server API are not yet deprecated and are backward compatible with Vite 5. The new Environment API is experimental. + +The `server.moduleGraph` returns a mixed view of the client and ssr module graphs. Backward compatible mixed module nodes will be returned from all its methods. The same scheme is used for the module nodes passed to `handleHotUpdate`. + +We don't recommend switching to Environment API yet. We are aiming for a good portion of the user base to adopt Vite 6 before so plugins don't need to maintain two versions. Checkout the future breaking changes section for information on future deprecations and upgrade path: + +- [`this.environment` in Hooks](/changes/this-environment-in-hooks) +- [HMR `hotUpdate` Plugin Hook](/changes/hotupdate-hook) +- [Move to per-environment APIs](/changes/per-environment-apis) +- [SSR using `ModuleRunner` API](/changes/ssr-using-modulerunner) +- [Shared plugins during build](/changes/shared-plugins-during-build) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md deleted file mode 100644 index 9aa579d268ddcf..00000000000000 --- a/docs/guide/api-vite-runtime.md +++ /dev/null @@ -1,236 +0,0 @@ -# Vite Runtime API - -:::warning Low-level API -This API was introduced in Vite 5.1 as an experimental feature. It was added to [gather feedback](https://github.com/vitejs/vite/discussions/15774). There will likely be breaking changes, so make sure to pin the Vite version to `~5.1.0` when using it. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. - -Currently, the API is being revised as the [Environment API](https://github.com/vitejs/vite/discussions/16358) which is released at `^6.0.0-alpha.0`. -::: - -The "Vite Runtime" is a tool that allows running any code by processing it with Vite plugins first. It is different from `server.ssrLoadModule` because the runtime implementation is decoupled from the server. This allows library and framework authors to implement their own layer of communication between the server and the runtime. - -One of the goals of this feature is to provide a customizable API to process and run the code. Vite provides enough tools to use Vite Runtime out of the box, but users can build upon it if their needs do not align with Vite's built-in implementation. - -All APIs can be imported from `vite/runtime` unless stated otherwise. - -## `ViteRuntime` - -**Type Signature:** - -```ts -export class ViteRuntime { - constructor( - public options: ViteRuntimeOptions, - public runner: ViteModuleRunner, - private debug?: ViteRuntimeDebugger, - ) {} - /** - * URL to execute. Accepts file path, server path, or id relative to the root. - */ - public async executeUrl(url: string): Promise - /** - * Entry point URL to execute. Accepts file path, server path or id relative to the root. - * In the case of a full reload triggered by HMR, this is the module that will be reloaded. - * If this method is called multiple times, all entry points will be reloaded one at a time. - */ - public async executeEntrypoint(url: string): Promise - /** - * Clear all caches including HMR listeners. - */ - public clearCache(): void - /** - * Clears all caches, removes all HMR listeners, and resets source map support. - * This method doesn't stop the HMR connection. - */ - public async destroy(): Promise - /** - * Returns `true` if the runtime has been destroyed by calling `destroy()` method. - */ - public isDestroyed(): boolean -} -``` - -::: tip Advanced Usage -If you are just migrating from `server.ssrLoadModule` and want to support HMR, consider using [`createViteRuntime`](#createviteruntime) instead. -::: - -The `ViteRuntime` class requires `root` and `fetchModule` options when initiated. Vite exposes `ssrFetchModule` on the [`server`](/guide/api-javascript) instance for easier integration with Vite SSR. Vite also exports `fetchModule` from its main entry point - it doesn't make any assumptions about how the code is running unlike `ssrFetchModule` that expects the code to run using `new Function`. This can be seen in source maps that these functions return. - -Runner in `ViteRuntime` is responsible for executing the code. Vite exports `ESModulesRunner` out of the box, it uses `new AsyncFunction` to run the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation. - -The two main methods that runtime exposes are `executeUrl` and `executeEntrypoint`. The only difference between them is that all modules executed by `executeEntrypoint` will be reexecuted if HMR triggers `full-reload` event. Be aware that Vite Runtime doesn't update `exports` object when this happens (it overrides it), you would need to run `executeUrl` or get the module from `moduleCache` again if you rely on having the latest `exports` object. - -**Example Usage:** - -```js -import { ViteRuntime, ESModulesRunner } from 'vite/runtime' -import { root, fetchModule } from './rpc-implementation.js' - -const runtime = new ViteRuntime( - { - root, - fetchModule, - // you can also provide hmr.connection to support HMR - }, - new ESModulesRunner(), -) - -await runtime.executeEntrypoint('/src/entry-point.js') -``` - -## `ViteRuntimeOptions` - -```ts -export interface ViteRuntimeOptions { - /** - * Root of the project - */ - root: string - /** - * A method to get the information about the module. - * For SSR, Vite exposes `server.ssrFetchModule` function that you can use here. - * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. - */ - fetchModule: FetchFunction - /** - * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. - * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. - * You can provide an object to configure how file contents and source maps are resolved for files that were not processed by Vite. - */ - sourcemapInterceptor?: - | false - | 'node' - | 'prepareStackTrace' - | InterceptorOptions - /** - * Disable HMR or configure HMR options. - */ - hmr?: - | false - | { - /** - * Configure how HMR communicates between the client and the server. - */ - connection: HMRRuntimeConnection - /** - * Configure HMR logger. - */ - logger?: false | HMRLogger - } - /** - * Custom module cache. If not provided, it creates a separate module cache for each ViteRuntime instance. - */ - moduleCache?: ModuleCacheMap -} -``` - -## `ViteModuleRunner` - -**Type Signature:** - -```ts -export interface ViteModuleRunner { - /** - * Run code that was transformed by Vite. - * @param context Function context - * @param code Transformed code - * @param id ID that was used to fetch the module - */ - runViteModule( - context: ViteRuntimeModuleContext, - code: string, - id: string, - ): Promise - /** - * Run externalized module. - * @param file File URL to the external module - */ - runExternalModule(file: string): Promise -} -``` - -Vite exports `ESModulesRunner` that implements this interface by default. It uses `new AsyncFunction` to run code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically by `server.ssrFetchModule`. If your runner implementation doesn't have this constraint, you should use `fetchModule` (exported from `vite`) directly. - -## HMRRuntimeConnection - -**Type Signature:** - -```ts -export interface HMRRuntimeConnection { - /** - * Checked before sending messages to the client. - */ - isReady(): boolean - /** - * Send message to the client. - */ - send(message: string): void - /** - * Configure how HMR is handled when this connection triggers an update. - * This method expects that connection will start listening for HMR updates and call this callback when it's received. - */ - onUpdate(callback: (payload: HMRPayload) => void): void -} -``` - -This interface defines how HMR communication is established. Vite exports `ServerHMRConnector` from the main entry point to support HMR during Vite SSR. The `isReady` and `send` methods are usually called when the custom event is triggered (like, `import.meta.hot.send("my-event")`). - -`onUpdate` is called only once when the new runtime is initiated. It passed down a method that should be called when connection triggers the HMR event. The implementation depends on the type of connection (as an example, it can be `WebSocket`/`EventEmitter`/`MessageChannel`), but it usually looks something like this: - -```js -function onUpdate(callback) { - this.connection.on('hmr', (event) => callback(event.data)) -} -``` - -The callback is queued and it will wait for the current update to be resolved before processing the next update. Unlike the browser implementation, HMR updates in Vite Runtime wait until all listeners (like, `vite:beforeUpdate`/`vite:beforeFullReload`) are finished before updating the modules. - -## `createViteRuntime` - -**Type Signature:** - -```ts -async function createViteRuntime( - server: ViteDevServer, - options?: MainThreadRuntimeOptions, -): Promise -``` - -**Example Usage:** - -```js -import { createServer } from 'vite' - -const __dirname = fileURLToPath(new URL('.', import.meta.url)) - -;(async () => { - const server = await createServer({ - root: __dirname, - }) - await server.listen() - - const runtime = await createViteRuntime(server) - await runtime.executeEntrypoint('/src/entry-point.js') -})() -``` - -This method serves as an easy replacement for `server.ssrLoadModule`. Unlike `ssrLoadModule`, `createViteRuntime` provides HMR support out of the box. You can pass down [`options`](#mainthreadruntimeoptions) to customize how SSR runtime behaves to suit your needs. - -## `MainThreadRuntimeOptions` - -```ts -export interface MainThreadRuntimeOptions - extends Omit { - /** - * Disable HMR or configure HMR logger. - */ - hmr?: - | false - | { - logger?: false | HMRLogger - } - /** - * Provide a custom module runner. This controls how the code is executed. - */ - runner?: ViteModuleRunner -} -``` diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 8256f0811ddf29..2860367e816c5b 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -69,6 +69,7 @@ vite build [root] | `-f, --filter ` | Filter debug logs (`string`) | | `-m, --mode ` | Set env mode (`string`) | | `-h, --help` | Display available CLI options | +| `--app` | Build all environments, same as `builder.entireApp` (`boolean`, experimental) | ## Others diff --git a/docs/guide/migration.md b/docs/guide/migration.md index ce22504271018a..4983099c55bcf1 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -1,246 +1,15 @@ -# Migration from v4 +# Migration from v5 -## Node.js Support +## Environment API -Vite no longer supports Node.js 14 / 16 / 17 / 19, which reached its EOL. Node.js 18 / 20+ is now required. +As part of the new experimental [Environment API](/guide/api-environment.md), a big internal refactoring was needed. Vite 6 strives to avoid breaking changes to ensure most projects can quickly upgrade to the new major. We'll wait until a big portion of the ecosystem has moved to stabilize and start recommending the use of the new APIs. There may be some edge cases but these should only affect low level usage by frameworks and tools. We have worked with maintainers in the ecosystem to mitigate these differences before the release. Please [open an issue](https://github.com/vitejs/vite/issues/new?assignees=&labels=pending+triage&projects=&template=bug_report.yml) if you spot a regression. -## Rollup 4 +Some internal APIs have been removed due to changes in Vite's implementation. If you were relying on one of them, please create a [feature request](https://github.com/vitejs/vite/issues/new?assignees=&labels=enhancement%3A+pending+triage&projects=&template=feature_request.yml). -Vite is now using Rollup 4 which also brings along its breaking changes, in particular: +## Vite Runtime API -- Import assertions (`assertions` prop) has been renamed to import attributes (`attributes` prop). -- Acorn plugins are no longer supported. -- For Vite plugins, `this.resolve` `skipSelf` option is now `true` by default. -- For Vite plugins, `this.parse` now only supports the `allowReturnOutsideFunction` option for now. +The experimental Vite Runtime API evolved into the Module Runner API, released in Vite 6 as part of the new experimental [Environment API](/guide/api-environment). Given that the feature was experimental the removal of the previous API introduced in Vite 5.1 isn't a breaking change, but users will need to update their use to the Module Runner equivalent as part of migrating to Vite 6. -Read the full breaking changes in [Rollup's release notes](https://github.com/rollup/rollup/releases/tag/v4.0.0) for build-related changes in [`build.rollupOptions`](/config/build-options.md#build-rollupoptions). +## Migration from v4 -If you are using TypeScript, make sure to set `moduleResolution: 'bundler'` (or `node16`/`nodenext`) as Rollup 4 requires it. Or you can set `skipLibCheck: true` instead. - -## Deprecate CJS Node API - -The CJS Node API of Vite is deprecated. When calling `require('vite')`, a deprecation warning is now logged. You should update your files or frameworks to import the ESM build of Vite instead. - -In a basic Vite project, make sure: - -1. The `vite.config.js` file content is using the ESM syntax. -2. The closest `package.json` file has `"type": "module"`, or use the `.mjs`/`.mts` extension, e.g. `vite.config.mjs` or `vite.config.mts`. - -For other projects, there are a few general approaches: - -- **Configure ESM as default, opt-in to CJS if needed:** Add `"type": "module"` in the project `package.json`. All `*.js` files are now interpreted as ESM and need to use the ESM syntax. You can rename a file with the `.cjs` extension to keep using CJS instead. -- **Keep CJS as default, opt-in to ESM if needed:** If the project `package.json` does not have `"type": "module"`, all `*.js` files are interpreted as CJS. You can rename a file with the `.mjs` extension to use ESM instead. -- **Dynamically import Vite:** If you need to keep using CJS, you can dynamically import Vite using `import('vite')` instead. This requires your code to be written in an `async` context, but should still be manageable as Vite's API is mostly asynchronous. - -See the [troubleshooting guide](/guide/troubleshooting.html#vite-cjs-node-api-deprecated) for more information. - -## Rework `define` and `import.meta.env.*` replacement strategy - -In Vite 4, the [`define`](/config/shared-options.md#define) and [`import.meta.env.*`](/guide/env-and-mode.md#env-variables) features use different replacement strategies in dev and build: - -- In dev, both features are injected as global variables to `globalThis` and `import.meta` respectively. -- In build, both features are statically replaced with a regex. - -This results in a dev and build inconsistency when trying to access the variables, and sometimes even caused failed builds. For example: - -```js -// vite.config.js -export default defineConfig({ - define: { - __APP_VERSION__: JSON.stringify('1.0.0'), - }, -}) -``` - -```js -const data = { __APP_VERSION__ } -// dev: { __APP_VERSION__: "1.0.0" } ✅ -// build: { "1.0.0" } ❌ - -const docs = 'I like import.meta.env.MODE' -// dev: "I like import.meta.env.MODE" ✅ -// build: "I like "production"" ❌ -``` - -Vite 5 fixes this by using `esbuild` to handle the replacements in builds, aligning with the dev behaviour. - -This change should not affect most setups, as it's already documented that `define` values should follow esbuild's syntax: - -> To be consistent with esbuild behavior, expressions must either be a JSON object (null, boolean, number, string, array, or object) or a single identifier. - -However, if you prefer to keep statically replacing values directly, you can use [`@rollup/plugin-replace`](https://github.com/rollup/plugins/tree/master/packages/replace). - -## General Changes - -### SSR externalized modules value now matches production - -In Vite 4, SSR externalized modules are wrapped with `.default` and `.__esModule` handling for better interoperability, but it doesn't match the production behaviour when loaded by the runtime environment (e.g. Node.js), causing hard-to-catch inconsistencies. By default, all direct project dependencies are SSR externalized. - -Vite 5 now removes the `.default` and `.__esModule` handling to match the production behaviour. In practice, this shouldn't affect properly-packaged dependencies, but if you encounter new issues loading modules, you can try these refactors: - -```js -// Before: -import { foo } from 'bar' - -// After: -import _bar from 'bar' -const { foo } = _bar -``` - -```js -// Before: -import foo from 'bar' - -// After: -import * as _foo from 'bar' -const foo = _foo.default -``` - -Note that these changes match the Node.js behaviour, so you can also run the imports in Node.js to test it out. If you prefer to stick with the previous behaviour, you can set `legacy.proxySsrExternalModules` to `true`. - -### `worker.plugins` is now a function - -In Vite 4, [`worker.plugins`](/config/worker-options.md#worker-plugins) accepted an array of plugins (`(Plugin | Plugin[])[]`). From Vite 5, it needs to be configured as a function that returns an array of plugins (`() => (Plugin | Plugin[])[]`). This change is required so parallel worker builds run more consistently and predictably. - -### Allow path containing `.` to fallback to index.html - -In Vite 4, accessing a path in dev containing `.` did not fallback to index.html even if [`appType`](/config/shared-options.md#apptype) is set to `'spa'` (default). From Vite 5, it will fallback to index.html. - -Note that the browser will no longer show a 404 error message in the console if you point the image path to a non-existent file (e.g. ``). - -### Align dev and preview HTML serving behaviour - -In Vite 4, the dev and preview servers serve HTML based on its directory structure and trailing slash differently. This causes inconsistencies when testing your built app. Vite 5 refactors into a single behaviour like below, given the following file structure: - -``` -├── index.html -├── file.html -└── dir - └── index.html -``` - -| Request | Before (dev) | Before (preview) | After (dev & preview) | -| ----------------- | ---------------------------- | ----------------- | ---------------------------- | -| `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | -| `/dir` | `/index.html` (SPA fallback) | `/dir/index.html` | `/index.html` (SPA fallback) | -| `/dir/` | `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | -| `/file.html` | `/file.html` | `/file.html` | `/file.html` | -| `/file` | `/index.html` (SPA fallback) | `/file.html` | `/file.html` | -| `/file/` | `/index.html` (SPA fallback) | `/file.html` | `/index.html` (SPA fallback) | - -### Manifest files are now generated in `.vite` directory by default - -In Vite 4, the manifest files ([`build.manifest`](/config/build-options.md#build-manifest) and [`build.ssrManifest`](/config/build-options.md#build-ssrmanifest)) were generated in the root of [`build.outDir`](/config/build-options.md#build-outdir) by default. - -From Vite 5, they will be generated in the `.vite` directory in the `build.outDir` by default. This change helps deconflict public files with the same manifest file names when they are copied to the `build.outDir`. - -### Corresponding CSS files are not listed as top level entry in manifest.json file - -In Vite 4, the corresponding CSS file for a JavaScript entry point was also listed as a top-level entry in the manifest file ([`build.manifest`](/config/build-options.md#build-manifest)). These entries were unintentionally added and only worked for simple cases. - -In Vite 5, corresponding CSS files can only be found within the JavaScript entry file section. -When injecting the JS file, the corresponding CSS files [should be injected](/guide/backend-integration.md#:~:text=%3C!%2D%2D%20if%20production%20%2D%2D%3E%0A%3Clink%20rel%3D%22stylesheet%22%20href%3D%22/assets/%7B%7B%20manifest%5B%27main.js%27%5D.css%20%7D%7D%22%20/%3E%0A%3Cscript%20type%3D%22module%22%20src%3D%22/assets/%7B%7B%20manifest%5B%27main.js%27%5D.file%20%7D%7D%22%3E%3C/script%3E). -When the CSS should be injected separately, it must be added as a separate entry point. - -### CLI shortcuts require an additional `Enter` press - -CLI shortcuts, like `r` to restart the dev server, now require an additional `Enter` press to trigger the shortcut. For example, `r + Enter` to restart the dev server. - -This change prevents Vite from swallowing and controlling OS-specific shortcuts, allowing better compatibility when combining the Vite dev server with other processes, and avoids the [previous caveats](https://github.com/vitejs/vite/pull/14342). - -### Update `experimentalDecorators` and `useDefineForClassFields` TypeScript behaviour - -Vite 5 uses esbuild 0.19 and removes the compatibility layer for esbuild 0.18, which changes how [`experimentalDecorators`](https://www.typescriptlang.org/tsconfig#experimentalDecorators) and [`useDefineForClassFields`](https://www.typescriptlang.org/tsconfig#useDefineForClassFields) are handled. - -- **`experimentalDecorators` is not enabled by default** - - You need to set `compilerOptions.experimentalDecorators` to `true` in `tsconfig.json` to use decorators. - -- **`useDefineForClassFields` defaults depend on the TypeScript `target` value** - - If `target` is not `ESNext` or `ES2022` or newer, or if there's no `tsconfig.json` file, `useDefineForClassFields` will default to `false` which can be problematic with the default `esbuild.target` value of `esnext`. It may transpile to [static initialization blocks](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Static_initialization_blocks#browser_compatibility) which may not be supported in your browser. - - As such, it is recommended to set `target` to `ESNext` or `ES2022` or newer, or set `useDefineForClassFields` to `true` explicitly when configuring `tsconfig.json`. - -```jsonc -{ - "compilerOptions": { - // Set true if you use decorators - "experimentalDecorators": true, - // Set true if you see parsing errors in your browser - "useDefineForClassFields": true, - }, -} -``` - -### Remove `--https` flag and `https: true` - -The `--https` flag sets `server.https: true` and `preview.https: true` internally. This config was meant to be used together with the automatic https certification generation feature which [was dropped in Vite 3](https://v3.vitejs.dev/guide/migration.html#automatic-https-certificate-generation). Hence, this config is no longer useful as it will start a Vite HTTPS server without a certificate. - -If you use [`@vitejs/plugin-basic-ssl`](https://github.com/vitejs/vite-plugin-basic-ssl) or [`vite-plugin-mkcert`](https://github.com/liuweiGL/vite-plugin-mkcert), they will already set the `https` config internally, so you can remove `--https`, `server.https: true`, and `preview.https: true` in your setup. - -### Remove `resolvePackageEntry` and `resolvePackageData` APIs - -The `resolvePackageEntry` and `resolvePackageData` APIs are removed as they exposed Vite's internals and blocked potential Vite 4.3 optimizations in the past. These APIs can be replaced with third-party packages, for example: - -- `resolvePackageEntry`: [`import.meta.resolve`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta/resolve) or the [`import-meta-resolve`](https://github.com/wooorm/import-meta-resolve) package. -- `resolvePackageData`: Same as above, and crawl up the package directory to get the root `package.json`. Or use the community [`vitefu`](https://github.com/svitejs/vitefu) package. - -```js -import { resolve } from 'import-meta-resolve' -import { findDepPkgJsonPath } from 'vitefu' -import fs from 'node:fs' - -const pkg = 'my-lib' -const basedir = process.cwd() - -// `resolvePackageEntry`: -const packageEntry = resolve(pkg, basedir) - -// `resolvePackageData`: -const packageJsonPath = findDepPkgJsonPath(pkg, basedir) -const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) -``` - -## Removed Deprecated APIs - -- Default exports of CSS files (e.g `import style from './foo.css'`): Use the `?inline` query instead -- `import.meta.globEager`: Use `import.meta.glob('*', { eager: true })` instead -- `ssr.format: 'cjs'` and `legacy.buildSsrCjsExternalHeuristics` ([#13816](https://github.com/vitejs/vite/discussions/13816)) -- `server.middlewareMode: 'ssr'` and `server.middlewareMode: 'html'`: Use [`appType`](/config/shared-options.md#apptype) + [`server.middlewareMode: true`](/config/server-options.md#server-middlewaremode) instead ([#8452](https://github.com/vitejs/vite/pull/8452)) - -## Advanced - -There are some changes which only affect plugin/tool creators. - -- [[#14119] refactor!: merge `PreviewServerForHook` into `PreviewServer` type](https://github.com/vitejs/vite/pull/14119) - - The `configurePreviewServer` hook now accepts the `PreviewServer` type instead of `PreviewServerForHook` type. -- [[#14818] refactor(preview)!: use base middleware](https://github.com/vitejs/vite/pull/14818) - - Middlewares added from the returned function in `configurePreviewServer` now does not have access to the `base` when comparing the `req.url` value. This aligns the behaviour with the dev server. You can check the `base` from the `configResolved` hook if needed. -- [[#14834] fix(types)!: expose httpServer with Http2SecureServer union](https://github.com/vitejs/vite/pull/14834) - - `http.Server | http2.Http2SecureServer` is now used instead of `http.Server` where appropriate. - -Also there are other breaking changes which only affect few users. - -- [[#14098] fix!: avoid rewriting this (reverts #5312)](https://github.com/vitejs/vite/pull/14098) - - Top level `this` was rewritten to `globalThis` by default when building. This behavior is now removed. -- [[#14231] feat!: add extension to internal virtual modules](https://github.com/vitejs/vite/pull/14231) - - Internal virtual modules' id now has an extension (`.js`). -- [[#14583] refactor!: remove exporting internal APIs](https://github.com/vitejs/vite/pull/14583) - - Removed accidentally exported internal APIs: `isDepsOptimizerEnabled` and `getDepOptimizationConfig` - - Removed exported internal types: `DepOptimizationResult`, `DepOptimizationProcessing`, and `DepsOptimizer` - - Renamed `ResolveWorkerOptions` type to `ResolvedWorkerOptions` -- [[#5657] fix: return 404 for resources requests outside the base path](https://github.com/vitejs/vite/pull/5657) - - In the past, Vite responded to requests outside the base path without `Accept: text/html`, as if they were requested with the base path. Vite no longer does that and responds with 404 instead. -- [[#14723] fix(resolve)!: remove special .mjs handling](https://github.com/vitejs/vite/pull/14723) - - In the past, when a library `"exports"` field maps to an `.mjs` file, Vite will still try to match the `"browser"` and `"module"` fields to fix compatibility with certain libraries. This behavior is now removed to align with the exports resolution algorithm. -- [[#14733] feat(resolve)!: remove `resolve.browserField`](https://github.com/vitejs/vite/pull/14733) - - `resolve.browserField` has been deprecated since Vite 3 in favour of an updated default of `['browser', 'module', 'jsnext:main', 'jsnext']` for [`resolve.mainFields`](/config/shared-options.md#resolve-mainfields). -- [[#14855] feat!: add isPreview to ConfigEnv and resolveConfig](https://github.com/vitejs/vite/pull/14855) - - Renamed `ssrBuild` to `isSsrBuild` in the `ConfigEnv` object. -- [[#14945] fix(css): correctly set manifest source name and emit CSS file](https://github.com/vitejs/vite/pull/14945) - - CSS file names are now generated based on the chunk name. - -## Migration from v3 - -Check the [Migration from v3 Guide](https://v4.vitejs.dev/guide/migration.html) in the Vite v4 docs first to see the needed changes to port your app to Vite v4, and then proceed with the changes on this page. +Check the [Migration from v4 Guide](https://v5.vitejs.dev/guide/migration.html) in the Vite v5 docs first to see the needed changes to port your app to Vite 5, and then proceed with the changes on this page. diff --git a/docs/images/vite-environments.svg b/docs/images/vite-environments.svg new file mode 100644 index 00000000000000..8f7c133b1d8bc2 --- /dev/null +++ b/docs/images/vite-environments.svg @@ -0,0 +1,40 @@ +Vite Dev ServerVite Dev ServerBrowser  +EnvironmentBrowser  +EnvironmentNode  +EnvironmentNode  +EnvironmentBrowser  +RuntimeBrowser  +RuntimeWorkerd  + RuntimeWorkerd  + RuntimeWorkerd  +EnvironmentWorkerd  +EnvironmentNode  +RuntimeNode  +RuntimeBrowser  +Module  +RunnerBrowser  +Module  +RunnerNode  +Module  +RunnerNode  +Module  +RunnerWorkerd  +Module  +RunnerWorkerd  +Module  +RunnerVitest JSDOM  +EnvironmentVitest JSDOM  +EnvironmentWorker  +Thread  +Module  +RunnerWorker  +Thread  +Module  +RunnerHTTP ServerHTTP ServerMiddlewaresMiddlewaresPlugins PipelinePlugins PipelineWeb SocketWeb Socket \ No newline at end of file diff --git a/docs/public/_redirects b/docs/public/_redirects index 1333c17f342027..16bba13babb1a4 100644 --- a/docs/public/_redirects +++ b/docs/public/_redirects @@ -1,2 +1,7 @@ # temporary, we'll flip this around some day https://vite.dev/* https://vitejs.dev/:splat 302! + +/guide/api-vite-runtime /guide/api-environment 302 +/guide/api-vite-runtime.html /guide/api-environment 302 +/guide/api-vite-environment /guide/api-environment 302 +/guide/api-vite-environment.html /guide/api-environment 302 diff --git a/package.json b/package.json index 3a8f9a4f592c2d..b1f8599315fe26 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.1", + "@type-challenges/utils": "^0.1.1", "@types/babel__core": "^7.20.5", "@types/babel__preset-env": "^7.9.7", "@types/convert-source-map": "^2.0.3", diff --git a/packages/vite/CHANGELOG.md b/packages/vite/CHANGELOG.md index b1959904e92f82..e995de5cee0add 100644 --- a/packages/vite/CHANGELOG.md +++ b/packages/vite/CHANGELOG.md @@ -217,8 +217,6 @@ See [5.3.0-beta.0 changelog](https://github.com/vitejs/vite/blob/v5.3.0-beta.0/p * docs: correct proxy shorthand example (#15938) ([abf766e](https://github.com/vitejs/vite/commit/abf766e939a0f02e5c08959bd101a6c72a29558b)), closes [#15938](https://github.com/vitejs/vite/issues/15938) * docs: deprecate server.hot (#16741) ([e7d38ab](https://github.com/vitejs/vite/commit/e7d38ab1c45b9d17f182f89d0c129932e2f994eb)), closes [#16741](https://github.com/vitejs/vite/issues/16741) - - ## 5.2.11 (2024-05-02) * feat: improve dynamic import variable failure error message (#16519) ([f8feeea](https://github.com/vitejs/vite/commit/f8feeea41c3f505d8491fa9b299c26deaad9106a)), closes [#16519](https://github.com/vitejs/vite/issues/16519) diff --git a/packages/vite/package.json b/packages/vite/package.json index 2200daad8f44b3..e9fc4939f18776 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -32,9 +32,9 @@ "./client": { "types": "./client.d.ts" }, - "./runtime": { - "types": "./dist/node/runtime.d.ts", - "import": "./dist/node/runtime.js" + "./module-runner": { + "types": "./dist/node/module-runner.d.ts", + "import": "./dist/node/module-runner.js" }, "./dist/client/*": "./dist/client/*", "./types/*": { @@ -44,8 +44,8 @@ }, "typesVersions": { "*": { - "runtime": [ - "dist/node/runtime.d.ts" + "module-runner": [ + "dist/node/module-runner.d.ts" ] } }, @@ -78,7 +78,7 @@ "build-types-temp": "tsc --emitDeclarationOnly --outDir temp -p src/node", "build-types-roll": "rollup --config rollup.dts.config.ts --configPlugin esbuild && rimraf temp", "build-types-check": "tsc --project tsconfig.check.json", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p src/node", "lint": "eslint --cache --ext .ts src/**", "format": "prettier --write --cache --parser typescript \"src/**/*.ts\"", "prepublishOnly": "npm run build" @@ -128,6 +128,7 @@ "micromatch": "^4.0.8", "mlly": "^1.7.1", "mrmime": "^2.0.0", + "nanoid": "^5.0.7", "open": "^8.4.2", "parse5": "^7.1.2", "pathe": "^1.1.2", diff --git a/packages/vite/rollup.config.ts b/packages/vite/rollup.config.ts index 9c8d773759ea83..ab979656ee822d 100644 --- a/packages/vite/rollup.config.ts +++ b/packages/vite/rollup.config.ts @@ -147,10 +147,10 @@ const nodeConfig = defineConfig({ ], }) -const runtimeConfig = defineConfig({ +const moduleRunnerConfig = defineConfig({ ...sharedNodeOptions, input: { - runtime: path.resolve(__dirname, 'src/runtime/index.ts'), + 'module-runner': path.resolve(__dirname, 'src/module-runner/index.ts'), }, external: [ 'fsevents', @@ -187,7 +187,7 @@ export default defineConfig([ envConfig, clientConfig, nodeConfig, - runtimeConfig, + moduleRunnerConfig, cjsConfig, ]) diff --git a/packages/vite/rollup.dts.config.ts b/packages/vite/rollup.dts.config.ts index 41b1da1458a31e..73379bffa70a41 100644 --- a/packages/vite/rollup.dts.config.ts +++ b/packages/vite/rollup.dts.config.ts @@ -25,7 +25,7 @@ const external = [ export default defineConfig({ input: { index: './temp/node/index.d.ts', - runtime: './temp/runtime/index.d.ts', + 'module-runner': './temp/module-runner/index.d.ts', }, output: { dir: './dist/node', @@ -48,6 +48,8 @@ const identifierWithTrailingDollarRE = /\b(\w+)\$\d+\b/g const identifierReplacements: Record> = { rollup: { Plugin$1: 'rollup.Plugin', + PluginContext$1: 'rollup.PluginContext', + TransformPluginContext$1: 'rollup.TransformPluginContext', TransformResult$2: 'rollup.TransformResult', }, esbuild: { @@ -91,10 +93,10 @@ function patchTypes(): Plugin { }, renderChunk(code, chunk) { if ( - chunk.fileName.startsWith('runtime') || + chunk.fileName.startsWith('module-runner') || chunk.fileName.startsWith('types.d-') ) { - validateRuntimeChunk.call(this, chunk) + validateRunnerChunk.call(this, chunk) } else { validateChunkImports.call(this, chunk) code = replaceConfusingTypeNames.call(this, code, chunk) @@ -107,9 +109,9 @@ function patchTypes(): Plugin { } /** - * Runtime chunk should only import local dependencies to stay lightweight + * Runner chunk should only import local dependencies to stay lightweight */ -function validateRuntimeChunk(this: PluginContext, chunk: RenderedChunk) { +function validateRunnerChunk(this: PluginContext, chunk: RenderedChunk) { for (const id of chunk.imports) { if ( !id.startsWith('./') && @@ -257,11 +259,15 @@ function removeInternal(s: MagicString, node: any): boolean { }) ) { // Examples: - // function a(foo: string, /* @internal */ bar: number) - // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - // strip trailing comma - const end = s.original[node.end] === ',' ? node.end + 1 : node.end - s.remove(node.leadingComments[0].start, end) + // function a(foo: string, /* @internal */ bar: number, baz: boolean) + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // type Enum = Foo | /* @internal */ Bar | Baz + // ^^^^^^^^^^^^^^^^^^^^^ + // strip trailing comma or pipe + const trailingRe = /\s*[,|]/y + trailingRe.lastIndex = node.end + const trailingStr = trailingRe.exec(s.original)?.[0] ?? '' + s.remove(node.leadingComments[0].start, node.end + trailingStr.length) return true } return false diff --git a/packages/vite/scripts/dev.ts b/packages/vite/scripts/dev.ts index 32d3d9ef8ed6ad..546f17f404108f 100644 --- a/packages/vite/scripts/dev.ts +++ b/packages/vite/scripts/dev.ts @@ -12,8 +12,8 @@ rmSync('dist', { force: true, recursive: true }) mkdirSync('dist/node', { recursive: true }) writeFileSync('dist/node/index.d.ts', "export * from '../../src/node/index.ts'") writeFileSync( - 'dist/node/runtime.d.ts', - "export * from '../../src/runtime/index.ts'", + 'dist/node/module-runner.d.ts', + "export * from '../../src/module-runner/index.ts'", ) const serverOptions: BuildOptions = { @@ -96,11 +96,11 @@ void watch({ }, ], }) -// runtimeConfig +// moduleRunnerConfig void watch({ ...serverOptions, - entryPoints: ['./src/runtime/index.ts'], - outfile: 'dist/node/runtime.js', + entryPoints: ['./src/module-runner/index.ts'], + outfile: 'dist/node/module-runner.js', format: 'esm', }) // cjsConfig diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index ef4d6ad78b9e50..23c2bf96bffa48 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,4 +1,4 @@ -import type { ErrorPayload, HMRPayload } from 'types/hmrPayload' +import type { ErrorPayload, HotPayload } from 'types/hmrPayload' import type { ViteHotContext } from 'types/hot' import type { InferCustomEventPayload } from 'types/customEvent' import { HMRClient, HMRContext } from '../shared/hmr' @@ -136,7 +136,10 @@ const debounceReload = (time: number) => { const pageReload = debounceReload(50) const hmrClient = new HMRClient( - console, + { + error: (err) => console.error('[vite]', err), + debug: (...msg) => console.debug('[vite]', ...msg), + }, { isReady: () => socket && socket.readyState === 1, send: (message) => socket.send(message), @@ -169,7 +172,7 @@ const hmrClient = new HMRClient( }, ) -async function handleMessage(payload: HMRPayload) { +async function handleMessage(payload: HotPayload) { switch (payload.type) { case 'connected': console.debug(`[vite] connected.`) diff --git a/packages/vite/src/runtime/constants.ts b/packages/vite/src/module-runner/constants.ts similarity index 100% rename from packages/vite/src/runtime/constants.ts rename to packages/vite/src/module-runner/constants.ts diff --git a/packages/vite/src/runtime/esmRunner.ts b/packages/vite/src/module-runner/esmEvaluator.ts similarity index 82% rename from packages/vite/src/runtime/esmRunner.ts rename to packages/vite/src/module-runner/esmEvaluator.ts index 5d4c481c39e85a..3e84f98f7646bd 100644 --- a/packages/vite/src/runtime/esmRunner.ts +++ b/packages/vite/src/module-runner/esmEvaluator.ts @@ -6,11 +6,11 @@ import { ssrImportMetaKey, ssrModuleExportsKey, } from './constants' -import type { ViteModuleRunner, ViteRuntimeModuleContext } from './types' +import type { ModuleEvaluator, ModuleRunnerContext } from './types' -export class ESModulesRunner implements ViteModuleRunner { - async runViteModule( - context: ViteRuntimeModuleContext, +export class ESModulesEvaluator implements ModuleEvaluator { + async runInlinedModule( + context: ModuleRunnerContext, code: string, ): Promise { // use AsyncFunction instead of vm module to support broader array of environments out of the box diff --git a/packages/vite/src/runtime/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts similarity index 51% rename from packages/vite/src/runtime/hmrHandler.ts rename to packages/vite/src/module-runner/hmrHandler.ts index b0b9fdd5fd6f32..eeed2f521cd55a 100644 --- a/packages/vite/src/runtime/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -1,24 +1,24 @@ -import type { HMRPayload } from 'types/hmrPayload' -import { unwrapId } from '../shared/utils' -import type { ViteRuntime } from './runtime' +import type { HotPayload } from 'types/hmrPayload' +import { slash, unwrapId } from '../shared/utils' +import type { ModuleRunner } from './runner' // updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example. export function createHMRHandler( - runtime: ViteRuntime, -): (payload: HMRPayload) => Promise { + runner: ModuleRunner, +): (payload: HotPayload) => Promise { const queue = new Queue() - return (payload) => queue.enqueue(() => handleHMRPayload(runtime, payload)) + return (payload) => queue.enqueue(() => handleHotPayload(runner, payload)) } -export async function handleHMRPayload( - runtime: ViteRuntime, - payload: HMRPayload, +export async function handleHotPayload( + runner: ModuleRunner, + payload: HotPayload, ): Promise { - const hmrClient = runtime.hmrClient - if (!hmrClient || runtime.isDestroyed()) return + const hmrClient = runner.hmrClient + if (!hmrClient || runner.isDestroyed()) return switch (payload.type) { case 'connected': - hmrClient.logger.debug(`[vite] connected.`) + hmrClient.logger.debug(`connected.`) hmrClient.messenger.flush() break case 'update': @@ -26,15 +26,13 @@ export async function handleHMRPayload( await Promise.all( payload.updates.map(async (update): Promise => { if (update.type === 'js-update') { - // runtime always caches modules by their full path without /@id/ prefix + // runner always caches modules by their full path without /@id/ prefix update.acceptedPath = unwrapId(update.acceptedPath) update.path = unwrapId(update.path) return hmrClient.queueUpdate(update) } - hmrClient.logger.error( - '[vite] css hmr is not supported in runtime mode.', - ) + hmrClient.logger.error('css hmr is not supported in runner mode.') }), ) await hmrClient.notifyListeners('vite:afterUpdate', payload) @@ -46,22 +44,20 @@ export async function handleHMRPayload( case 'full-reload': { const { triggeredBy } = payload const clearEntrypoints = triggeredBy - ? [...runtime.entrypoints].filter((entrypoint) => - runtime.moduleCache.isImported({ - importedId: triggeredBy, - importedBy: entrypoint, - }), + ? getModulesEntrypoints( + runner, + getModulesByFile(runner, slash(triggeredBy)), ) - : [...runtime.entrypoints] + : findAllEntrypoints(runner) - if (!clearEntrypoints.length) break + if (!clearEntrypoints.size) break - hmrClient.logger.debug(`[vite] program reload`) + hmrClient.logger.debug(`program reload`) await hmrClient.notifyListeners('vite:beforeFullReload', payload) - runtime.moduleCache.clear() + runner.moduleCache.clear() for (const id of clearEntrypoints) { - await runtime.executeUrl(id) + await runner.import(id) } break } @@ -73,7 +69,7 @@ export async function handleHMRPayload( await hmrClient.notifyListeners('vite:error', payload) const err = payload.err hmrClient.logger.error( - `[vite] Internal Server Error\n${err.message}\n${err.stack}`, + `Internal Server Error\n${err.message}\n${err.stack}`, ) break } @@ -123,3 +119,46 @@ class Queue { return true } } + +function getModulesByFile(runner: ModuleRunner, file: string) { + const modules: string[] = [] + for (const [id, mod] of runner.moduleCache.entries()) { + if (mod.meta && 'file' in mod.meta && mod.meta.file === file) { + modules.push(id) + } + } + return modules +} + +function getModulesEntrypoints( + runner: ModuleRunner, + modules: string[], + visited = new Set(), + entrypoints = new Set(), +) { + for (const moduleId of modules) { + if (visited.has(moduleId)) continue + visited.add(moduleId) + const module = runner.moduleCache.getByModuleId(moduleId) + if (module.importers && !module.importers.size) { + entrypoints.add(moduleId) + continue + } + for (const importer of module.importers || []) { + getModulesEntrypoints(runner, [importer], visited, entrypoints) + } + } + return entrypoints +} + +function findAllEntrypoints( + runner: ModuleRunner, + entrypoints = new Set(), +): Set { + for (const [id, mod] of runner.moduleCache.entries()) { + if (mod.importers && !mod.importers.size) { + entrypoints.add(id) + } + } + return entrypoints +} diff --git a/packages/vite/src/runtime/hmrLogger.ts b/packages/vite/src/module-runner/hmrLogger.ts similarity index 51% rename from packages/vite/src/runtime/hmrLogger.ts rename to packages/vite/src/module-runner/hmrLogger.ts index 57325298949e09..931a69d125d45b 100644 --- a/packages/vite/src/runtime/hmrLogger.ts +++ b/packages/vite/src/module-runner/hmrLogger.ts @@ -6,3 +6,8 @@ export const silentConsole: HMRLogger = { debug: noop, error: noop, } + +export const hmrLogger: HMRLogger = { + debug: (...msg) => console.log('[vite]', ...msg), + error: (error) => console.log('[vite]', error), +} diff --git a/packages/vite/src/runtime/index.ts b/packages/vite/src/module-runner/index.ts similarity index 51% rename from packages/vite/src/runtime/index.ts rename to packages/vite/src/module-runner/index.ts index ded7222e45d690..836b261a8b8687 100644 --- a/packages/vite/src/runtime/index.ts +++ b/packages/vite/src/module-runner/index.ts @@ -1,21 +1,25 @@ -// this file should re-export only things that don't rely on Node.js or other runtime features +// this file should re-export only things that don't rely on Node.js or other runner features export { ModuleCacheMap } from './moduleCache' -export { ViteRuntime } from './runtime' -export { ESModulesRunner } from './esmRunner' +export { ModuleRunner } from './runner' +export { ESModulesEvaluator } from './esmEvaluator' +export { RemoteRunnerTransport } from './runnerTransport' +export type { RunnerTransport } from './runnerTransport' export type { HMRLogger, HMRConnection } from '../shared/hmr' export type { - ViteModuleRunner, - ViteRuntimeModuleContext, + ModuleEvaluator, + ModuleRunnerContext, ModuleCache, FetchResult, FetchFunction, + FetchFunctionOptions, ResolvedResult, SSRImportMetadata, - HMRRuntimeConnection, - ViteRuntimeImportMeta, - ViteRuntimeOptions, + ModuleRunnerHMRConnection, + ModuleRunnerImportMeta, + ModuleRunnerOptions, + ModuleRunnerHmr, } from './types' export { ssrDynamicImportKey, diff --git a/packages/vite/src/runtime/moduleCache.ts b/packages/vite/src/module-runner/moduleCache.ts similarity index 81% rename from packages/vite/src/runtime/moduleCache.ts rename to packages/vite/src/module-runner/moduleCache.ts index 4f088d173f8859..f575f915e6a7f1 100644 --- a/packages/vite/src/runtime/moduleCache.ts +++ b/packages/vite/src/module-runner/moduleCache.ts @@ -4,7 +4,7 @@ import { decodeBase64 } from './utils' import { DecodedMap } from './sourcemap/decoder' import type { ModuleCache } from './types' -const VITE_RUNTIME_SOURCEMAPPING_REGEXP = new RegExp( +const MODULE_RUNNER_SOURCEMAPPING_REGEXP = new RegExp( `//# ${SOURCEMAPPING_URL}=data:application/json;base64,(.+)`, ) @@ -46,6 +46,7 @@ export class ModuleCacheMap extends Map { Object.assign(mod, { imports: new Set(), importers: new Set(), + timestamp: 0, }) } return mod @@ -63,8 +64,12 @@ export class ModuleCacheMap extends Map { return this.deleteByModuleId(this.normalize(fsPath)) } - invalidate(id: string): void { + invalidateUrl(id: string): void { const module = this.get(id) + this.invalidateModule(module) + } + + invalidateModule(module: ModuleCache): void { module.evaluated = false module.meta = undefined module.map = undefined @@ -77,43 +82,6 @@ export class ModuleCacheMap extends Map { module.imports?.clear() } - isImported( - { - importedId, - importedBy, - }: { - importedId: string - importedBy: string - }, - seen = new Set(), - ): boolean { - importedId = this.normalize(importedId) - importedBy = this.normalize(importedBy) - - if (importedBy === importedId) return true - - if (seen.has(importedId)) return false - seen.add(importedId) - - const fileModule = this.getByModuleId(importedId) - const importers = fileModule?.importers - - if (!importers) return false - - if (importers.has(importedBy)) return true - - for (const importer of importers) { - if ( - this.isImported({ - importedBy: importedBy, - importedId: importer, - }) - ) - return true - } - return false - } - /** * Invalidate modules that dependent on the given modules, up to the main entry */ @@ -127,7 +95,7 @@ export class ModuleCacheMap extends Map { invalidated.add(id) const mod = super.get(id) if (mod?.importers) this.invalidateDepTree(mod.importers, invalidated) - super.delete(id) + this.invalidateUrl(id) } return invalidated } @@ -156,7 +124,9 @@ export class ModuleCacheMap extends Map { const mod = this.get(moduleId) if (mod.map) return mod.map if (!mod.meta || !('code' in mod.meta)) return null - const mapString = VITE_RUNTIME_SOURCEMAPPING_REGEXP.exec(mod.meta.code)?.[1] + const mapString = MODULE_RUNNER_SOURCEMAPPING_REGEXP.exec( + mod.meta.code, + )?.[1] if (!mapString) return null const baseFile = mod.meta.file || moduleId.split('?')[0] mod.map = new DecodedMap(JSON.parse(decodeBase64(mapString)), baseFile) @@ -165,7 +135,7 @@ export class ModuleCacheMap extends Map { } // unique id that is not available as "$bare_import" like "test" -const prefixedBuiltins = new Set(['node:test']) +const prefixedBuiltins = new Set(['node:test', 'node:sqlite']) // transform file url to id // virtual:custom -> virtual:custom diff --git a/packages/vite/src/runtime/runtime.ts b/packages/vite/src/module-runner/runner.ts similarity index 53% rename from packages/vite/src/runtime/runtime.ts rename to packages/vite/src/module-runner/runner.ts index b7f08fed3d3a1b..c81a3baeb3fa4d 100644 --- a/packages/vite/src/runtime/runtime.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -1,26 +1,19 @@ import type { ViteHotContext } from 'types/hot' import { HMRClient, HMRContext } from '../shared/hmr' -import { - cleanUrl, - isPrimitive, - isWindows, - slash, - unwrapId, - wrapId, -} from '../shared/utils' +import { cleanUrl, isPrimitive, isWindows, unwrapId } from '../shared/utils' import { analyzeImportedModDifference } from '../shared/ssrTransform' import { ModuleCacheMap } from './moduleCache' import type { - FetchResult, ModuleCache, + ModuleEvaluator, + ModuleRunnerContext, + ModuleRunnerImportMeta, + ModuleRunnerOptions, ResolvedResult, SSRImportMetadata, - ViteModuleRunner, - ViteRuntimeImportMeta, - ViteRuntimeModuleContext, - ViteRuntimeOptions, } from './types' import { + normalizeAbsoluteUrl, posixDirname, posixPathToFileHref, posixResolve, @@ -33,92 +26,77 @@ import { ssrImportMetaKey, ssrModuleExportsKey, } from './constants' -import { silentConsole } from './hmrLogger' +import { hmrLogger, silentConsole } from './hmrLogger' import { createHMRHandler } from './hmrHandler' import { enableSourceMapSupport } from './sourcemap/index' +import type { RunnerTransport } from './runnerTransport' -interface ViteRuntimeDebugger { +interface ModuleRunnerDebugger { (formatter: unknown, ...args: unknown[]): void } -export class ViteRuntime { +export class ModuleRunner { /** * Holds the cache of modules * Keys of the map are ids */ public moduleCache: ModuleCacheMap public hmrClient?: HMRClient - public entrypoints = new Set() - private idToUrlMap = new Map() - private fileToIdMap = new Map() - private envProxy = new Proxy({} as any, { + private readonly urlToIdMap = new Map() + private readonly fileToIdMap = new Map() + private readonly envProxy = new Proxy({} as any, { get(_, p) { throw new Error( - `[vite-runtime] Dynamic access of "import.meta.env" is not supported. Please, use "import.meta.env.${String(p)}" instead.`, + `[module runner] Dynamic access of "import.meta.env" is not supported. Please, use "import.meta.env.${String(p)}" instead.`, ) }, }) + private readonly transport: RunnerTransport + private readonly resetSourceMapSupport?: () => void + private readonly root: string + private readonly moduleInfoCache = new Map>() - private _destroyed = false - private _resetSourceMapSupport?: () => void + private destroyed = false constructor( - public options: ViteRuntimeOptions, - public runner: ViteModuleRunner, - private debug?: ViteRuntimeDebugger, + public options: ModuleRunnerOptions, + public evaluator: ModuleEvaluator, + private debug?: ModuleRunnerDebugger, ) { + const root = this.options.root + this.root = root[root.length - 1] === '/' ? root : `${root}/` this.moduleCache = options.moduleCache ?? new ModuleCacheMap(options.root) + this.transport = options.transport if (typeof options.hmr === 'object') { this.hmrClient = new HMRClient( options.hmr.logger === false ? silentConsole - : options.hmr.logger || console, + : options.hmr.logger || hmrLogger, options.hmr.connection, - ({ acceptedPath, ssrInvalidates }) => { - this.moduleCache.invalidate(acceptedPath) - if (ssrInvalidates) { - this.invalidateFiles(ssrInvalidates) - } - return this.executeUrl(acceptedPath) - }, + ({ acceptedPath }) => this.import(acceptedPath), ) options.hmr.connection.onUpdate(createHMRHandler(this)) } if (options.sourcemapInterceptor !== false) { - this._resetSourceMapSupport = enableSourceMapSupport(this) + this.resetSourceMapSupport = enableSourceMapSupport(this) } } /** * URL to execute. Accepts file path, server path or id relative to the root. */ - public async executeUrl(url: string): Promise { - url = this.normalizeEntryUrl(url) + public async import(url: string): Promise { const fetchedModule = await this.cachedModule(url) return await this.cachedRequest(url, fetchedModule) } - /** - * Entrypoint URL to execute. Accepts file path, server path or id relative to the root. - * In the case of a full reload triggered by HMR, this is the module that will be reloaded. - * If this method is called multiple times, all entrypoints will be reloaded one at a time. - */ - public async executeEntrypoint(url: string): Promise { - url = this.normalizeEntryUrl(url) - const fetchedModule = await this.cachedModule(url) - return await this.cachedRequest(url, fetchedModule, [], { - entrypoint: true, - }) - } - /** * Clear all caches including HMR listeners. */ public clearCache(): void { this.moduleCache.clear() - this.idToUrlMap.clear() - this.entrypoints.clear() + this.urlToIdMap.clear() this.hmrClient?.clear() } @@ -127,56 +105,17 @@ export class ViteRuntime { * This method doesn't stop the HMR connection. */ public async destroy(): Promise { - this._resetSourceMapSupport?.() + this.resetSourceMapSupport?.() this.clearCache() this.hmrClient = undefined - this._destroyed = true + this.destroyed = true } /** * Returns `true` if the runtime has been destroyed by calling `destroy()` method. */ public isDestroyed(): boolean { - return this._destroyed - } - - private invalidateFiles(files: string[]) { - files.forEach((file) => { - const ids = this.fileToIdMap.get(file) - if (ids) { - ids.forEach((id) => this.moduleCache.invalidate(id)) - } - }) - } - - // we don't use moduleCache.normalize because this URL doesn't have to follow the same rules - // this URL is something that user passes down manually, and is later resolved by fetchModule - // moduleCache.normalize is used on resolved "file" property - private normalizeEntryUrl(url: string) { - // expect fetchModule to resolve relative module correctly - if (url[0] === '.') { - return url - } - // file:///C:/root/id.js -> C:/root/id.js - if (url.startsWith('file://')) { - // 8 is the length of "file:///" - url = url.slice(isWindows ? 8 : 7) - } - url = slash(url) - const _root = this.options.root - const root = _root[_root.length - 1] === '/' ? _root : `${_root}/` - // strip root from the URL because fetchModule prefers a public served url path - // packages/vite/src/node/server/moduleGraph.ts:17 - if (url.startsWith(root)) { - // /root/id.js -> /id.js - // C:/root/id.js -> /id.js - // 1 is to keep the leading slash - return url.slice(root.length - 1) - } - // if it's a server url (starts with a slash), keep it, otherwise assume a virtual module - // /id.js -> /id.js - // virtual:custom -> /@id/virtual:custom - return url[0] === '/' ? url : wrapId(url) + return this.destroyed } private processImport( @@ -193,21 +132,52 @@ export class ViteRuntime { return exports } + private isCircularModule(mod: Required) { + for (const importedFile of mod.imports) { + if (mod.importers.has(importedFile)) { + return true + } + } + return false + } + + private isCircularImport( + importers: Set, + moduleId: string, + visited = new Set(), + ) { + for (const importer of importers) { + if (visited.has(importer)) { + continue + } + visited.add(importer) + if (importer === moduleId) { + return true + } + const mod = this.moduleCache.getByModuleId( + importer, + ) as Required + if ( + mod.importers.size && + this.isCircularImport(mod.importers, moduleId, visited) + ) { + return true + } + } + return false + } + private async cachedRequest( id: string, - fetchedModule: ResolvedResult, + mod_: ModuleCache, callstack: string[] = [], metadata?: SSRImportMetadata, ): Promise { - const moduleId = fetchedModule.id - - if (metadata?.entrypoint) { - this.entrypoints.add(moduleId) - } + const mod = mod_ as Required + const meta = mod.meta! + const moduleId = meta.id - const mod = this.moduleCache.getByModuleId(moduleId) - - const { imports, importers } = mod as Required + const { importers } = mod const importee = callstack[callstack.length - 1] @@ -216,10 +186,10 @@ export class ViteRuntime { // check circular dependency if ( callstack.includes(moduleId) || - Array.from(imports.values()).some((i) => importers.has(i)) + this.isCircularModule(mod) || + this.isCircularImport(importers, moduleId) ) { - if (mod.exports) - return this.processImport(mod.exports, fetchedModule, metadata) + if (mod.exports) return this.processImport(mod.exports, meta, metadata) } let debugTimer: any @@ -232,7 +202,7 @@ export class ViteRuntime { .join('\n')}` this.debug!( - `[vite-runtime] module ${moduleId} takes over 2s to load.\n${getStack()}`, + `[module runner] module ${moduleId} takes over 2s to load.\n${getStack()}`, ) }, 2000) } @@ -240,47 +210,88 @@ export class ViteRuntime { try { // cached module if (mod.promise) - return this.processImport(await mod.promise, fetchedModule, metadata) + return this.processImport(await mod.promise, meta, metadata) - const promise = this.directRequest(id, fetchedModule, callstack) + const promise = this.directRequest(id, mod, callstack) mod.promise = promise mod.evaluated = false - return this.processImport(await promise, fetchedModule, metadata) + return this.processImport(await promise, meta, metadata) } finally { mod.evaluated = true if (debugTimer) clearTimeout(debugTimer) } } - private async cachedModule( - id: string, - importer?: string, - ): Promise { - if (this._destroyed) { - throw new Error(`[vite] Vite runtime has been destroyed.`) + private async cachedModule(url: string, importer?: string) { + url = normalizeAbsoluteUrl(url, this.root) + + const normalized = this.urlToIdMap.get(url) + let cachedModule = normalized && this.moduleCache.getByModuleId(normalized) + if (!cachedModule) { + cachedModule = this.moduleCache.getByModuleId(url) + } + + let cached = this.moduleInfoCache.get(url) + if (!cached) { + cached = this.getModuleInformation(url, importer, cachedModule).finally( + () => { + this.moduleInfoCache.delete(url) + }, + ) + this.moduleInfoCache.set(url, cached) + } else { + this.debug?.('[module runner] using cached module info for', url) + } + + return cached + } + + private async getModuleInformation( + url: string, + importer: string | undefined, + cachedModule: ModuleCache | undefined, + ): Promise { + if (this.destroyed) { + throw new Error(`Vite module runner has been destroyed.`) } - const normalized = this.idToUrlMap.get(id) - if (normalized) { - const mod = this.moduleCache.getByModuleId(normalized) - if (mod.meta) { - return mod.meta as ResolvedResult + + this.debug?.('[module runner] fetching', url) + + const isCached = !!(typeof cachedModule === 'object' && cachedModule.meta) + + const fetchedModule = // fast return for established externalized pattern + ( + url.startsWith('data:') + ? { externalize: url, type: 'builtin' } + : await this.transport.fetchModule(url, importer, { + cached: isCached, + }) + ) as ResolvedResult + + if ('cache' in fetchedModule) { + if (!cachedModule || !cachedModule.meta) { + throw new Error( + `Module "${url}" was mistakenly invalidated during fetch phase.`, + ) } + return cachedModule } - this.debug?.('[vite-runtime] fetching', id) - // fast return for established externalized patterns - const fetchedModule = id.startsWith('data:') - ? ({ externalize: id, type: 'builtin' } satisfies FetchResult) - : await this.options.fetchModule(id, importer) + // base moduleId on "file" and not on id // if `import(variable)` is called it's possible that it doesn't have an extension for example - // if we used id for that, it's possible to have a duplicated module - const idQuery = id.split('?')[1] + // if we used id for that, then a module will be duplicated + const idQuery = url.split('?')[1] const query = idQuery ? `?${idQuery}` : '' const file = 'file' in fetchedModule ? fetchedModule.file : undefined - const fullFile = file ? `${file}${query}` : id - const moduleId = this.moduleCache.normalize(fullFile) + const fileId = file ? `${file}${query}` : url + const moduleId = this.moduleCache.normalize(fileId) const mod = this.moduleCache.getByModuleId(moduleId) - ;(fetchedModule as ResolvedResult).id = moduleId + + if ('invalidate' in fetchedModule && fetchedModule.invalidate) { + this.moduleCache.invalidateModule(mod) + } + + fetchedModule.id = moduleId mod.meta = fetchedModule if (file) { @@ -289,27 +300,28 @@ export class ViteRuntime { this.fileToIdMap.set(file, fileModules) } - this.idToUrlMap.set(id, moduleId) - this.idToUrlMap.set(unwrapId(id), moduleId) - return fetchedModule as ResolvedResult + this.urlToIdMap.set(url, moduleId) + this.urlToIdMap.set(unwrapId(url), moduleId) + return mod } // override is allowed, consider this a public API protected async directRequest( id: string, - fetchResult: ResolvedResult, + mod: ModuleCache, _callstack: string[], ): Promise { + const fetchResult = mod.meta! const moduleId = fetchResult.id const callstack = [..._callstack, moduleId] - const mod = this.moduleCache.getByModuleId(moduleId) - const request = async (dep: string, metadata?: SSRImportMetadata) => { - const fetchedModule = await this.cachedModule(dep, moduleId) - const depMod = this.moduleCache.getByModuleId(fetchedModule.id) + const importer = ('file' in fetchResult && fetchResult.file) || moduleId + const fetchedModule = await this.cachedModule(dep, importer) + const resolvedId = fetchedModule.meta!.id + const depMod = this.moduleCache.getByModuleId(resolvedId) depMod.importers!.add(moduleId) - mod.imports!.add(fetchedModule.id) + mod.imports!.add(resolvedId) return this.cachedRequest(dep, fetchedModule, callstack, metadata) } @@ -325,8 +337,8 @@ export class ViteRuntime { if ('externalize' in fetchResult) { const { externalize } = fetchResult - this.debug?.('[vite-runtime] externalizing', externalize) - const exports = await this.runner.runExternalModule(externalize) + this.debug?.('[module runner] externalizing', externalize) + const exports = await this.evaluator.runExternalModule(externalize) mod.exports = exports return exports } @@ -336,7 +348,7 @@ export class ViteRuntime { if (code == null) { const importer = callstack[callstack.length - 2] throw new Error( - `[vite-runtime] Failed to load "${id}"${ + `[module runner] Failed to load "${id}"${ importer ? ` imported from ${importer}` : '' }`, ) @@ -347,19 +359,19 @@ export class ViteRuntime { const href = posixPathToFileHref(modulePath) const filename = modulePath const dirname = posixDirname(modulePath) - const meta: ViteRuntimeImportMeta = { + const meta: ModuleRunnerImportMeta = { filename: isWindows ? toWindowsPath(filename) : filename, dirname: isWindows ? toWindowsPath(dirname) : dirname, url: href, env: this.envProxy, resolve(id, parent) { throw new Error( - '[vite-runtime] "import.meta.resolve" is not supported.', + '[module runner] "import.meta.resolve" is not supported.', ) }, // should be replaced during transformation glob() { - throw new Error('[vite-runtime] "import.meta.glob" is not supported.') + throw new Error('[module runner] "import.meta.glob" is not supported.') }, } const exports = Object.create(null) @@ -377,9 +389,9 @@ export class ViteRuntime { enumerable: true, get: () => { if (!this.hmrClient) { - throw new Error(`[vite-runtime] HMR client was destroyed.`) + throw new Error(`[module runner] HMR client was destroyed.`) } - this.debug?.('[vite-runtime] creating hmr context for', moduleId) + this.debug?.('[module runner] creating hmr context for', moduleId) hotContext ||= new HMRContext(this.hmrClient, moduleId) return hotContext }, @@ -389,7 +401,7 @@ export class ViteRuntime { }) } - const context: ViteRuntimeModuleContext = { + const context: ModuleRunnerContext = { [ssrImportKey]: request, [ssrDynamicImportKey]: dynamicRequest, [ssrModuleExportsKey]: exports, @@ -397,9 +409,9 @@ export class ViteRuntime { [ssrImportMetaKey]: meta, } - this.debug?.('[vite-runtime] executing', href) + this.debug?.('[module runner] executing', href) - await this.runner.runViteModule(context, code, id) + await this.evaluator.runInlinedModule(context, code, id) return exports } diff --git a/packages/vite/src/module-runner/runnerTransport.ts b/packages/vite/src/module-runner/runnerTransport.ts new file mode 100644 index 00000000000000..4e45c45dea02d1 --- /dev/null +++ b/packages/vite/src/module-runner/runnerTransport.ts @@ -0,0 +1,73 @@ +import { nanoid } from 'nanoid/non-secure' +import type { FetchFunction, FetchResult } from './types' + +export interface RunnerTransport { + fetchModule: FetchFunction +} + +export class RemoteRunnerTransport implements RunnerTransport { + private rpcPromises = new Map< + string, + { + resolve: (data: any) => void + reject: (data: any) => void + timeoutId?: NodeJS.Timeout + } + >() + + constructor( + private readonly options: { + send: (data: any) => void + onMessage: (handler: (data: any) => void) => void + timeout?: number + }, + ) { + this.options.onMessage(async (data) => { + if (typeof data !== 'object' || !data || !data.__v) return + + const promise = this.rpcPromises.get(data.i) + if (!promise) return + + if (promise.timeoutId) clearTimeout(promise.timeoutId) + + this.rpcPromises.delete(data.i) + + if (data.e) { + promise.reject(data.e) + } else { + promise.resolve(data.r) + } + }) + } + + private resolve(method: string, ...args: any[]) { + const promiseId = nanoid() + this.options.send({ + __v: true, + m: method, + a: args, + i: promiseId, + }) + + return new Promise((resolve, reject) => { + const timeout = this.options.timeout ?? 60000 + let timeoutId + if (timeout > 0) { + timeoutId = setTimeout(() => { + this.rpcPromises.delete(promiseId) + reject( + new Error( + `${method}(${args.map((arg) => JSON.stringify(arg)).join(', ')}) timed out after ${timeout}ms`, + ), + ) + }, timeout) + timeoutId?.unref?.() + } + this.rpcPromises.set(promiseId, { resolve, reject, timeoutId }) + }) + } + + fetchModule(id: string, importer?: string): Promise { + return this.resolve('fetchModule', id, importer) + } +} diff --git a/packages/vite/src/runtime/sourcemap/decoder.ts b/packages/vite/src/module-runner/sourcemap/decoder.ts similarity index 100% rename from packages/vite/src/runtime/sourcemap/decoder.ts rename to packages/vite/src/module-runner/sourcemap/decoder.ts diff --git a/packages/vite/src/runtime/sourcemap/index.ts b/packages/vite/src/module-runner/sourcemap/index.ts similarity index 76% rename from packages/vite/src/runtime/sourcemap/index.ts rename to packages/vite/src/module-runner/sourcemap/index.ts index 648c5e52717fc2..0efc5ca2db97b5 100644 --- a/packages/vite/src/runtime/sourcemap/index.ts +++ b/packages/vite/src/module-runner/sourcemap/index.ts @@ -1,8 +1,8 @@ -import type { ViteRuntime } from '../runtime' +import type { ModuleRunner } from '../runner' import { interceptStackTrace } from './interceptor' -export function enableSourceMapSupport(runtime: ViteRuntime): () => void { - if (runtime.options.sourcemapInterceptor === 'node') { +export function enableSourceMapSupport(runner: ModuleRunner): () => void { + if (runner.options.sourcemapInterceptor === 'node') { if (typeof process === 'undefined') { throw new TypeError( `Cannot use "sourcemapInterceptor: 'node'" because global "process" variable is not available.`, @@ -20,9 +20,9 @@ export function enableSourceMapSupport(runtime: ViteRuntime): () => void { /* eslint-enable n/no-unsupported-features/node-builtins */ } return interceptStackTrace( - runtime, - typeof runtime.options.sourcemapInterceptor === 'object' - ? runtime.options.sourcemapInterceptor + runner, + typeof runner.options.sourcemapInterceptor === 'object' + ? runner.options.sourcemapInterceptor : undefined, ) } diff --git a/packages/vite/src/runtime/sourcemap/interceptor.ts b/packages/vite/src/module-runner/sourcemap/interceptor.ts similarity index 97% rename from packages/vite/src/runtime/sourcemap/interceptor.ts rename to packages/vite/src/module-runner/sourcemap/interceptor.ts index 9a50d5677fe085..d9787d81df22f8 100644 --- a/packages/vite/src/runtime/sourcemap/interceptor.ts +++ b/packages/vite/src/module-runner/sourcemap/interceptor.ts @@ -1,5 +1,5 @@ import type { OriginalMapping } from '@jridgewell/trace-mapping' -import type { ViteRuntime } from '../runtime' +import type { ModuleRunner } from '../runner' import { posixDirname, posixResolve } from '../utils' import type { ModuleCacheMap } from '../moduleCache' import { slash } from '../../shared/utils' @@ -45,8 +45,8 @@ const retrieveSourceMapFromHandlers = createExecHandlers( let overridden = false const originalPrepare = Error.prepareStackTrace -function resetInterceptor(runtime: ViteRuntime, options: InterceptorOptions) { - moduleGraphs.delete(runtime.moduleCache) +function resetInterceptor(runner: ModuleRunner, options: InterceptorOptions) { + moduleGraphs.delete(runner.moduleCache) if (options.retrieveFile) retrieveFileHandlers.delete(options.retrieveFile) if (options.retrieveSourceMap) retrieveSourceMapHandlers.delete(options.retrieveSourceMap) @@ -57,18 +57,18 @@ function resetInterceptor(runtime: ViteRuntime, options: InterceptorOptions) { } export function interceptStackTrace( - runtime: ViteRuntime, + runner: ModuleRunner, options: InterceptorOptions = {}, ): () => void { if (!overridden) { Error.prepareStackTrace = prepareStackTrace overridden = true } - moduleGraphs.add(runtime.moduleCache) + moduleGraphs.add(runner.moduleCache) if (options.retrieveFile) retrieveFileHandlers.add(options.retrieveFile) if (options.retrieveSourceMap) retrieveSourceMapHandlers.add(options.retrieveSourceMap) - return () => resetInterceptor(runtime, options) + return () => resetInterceptor(runner, options) } interface CallSite extends NodeJS.CallSite { @@ -101,7 +101,7 @@ function supportRelativeURL(file: string, url: string) { return protocol + posixResolve(startPath, url) } -function getRuntimeSourceMap(position: OriginalMapping): CachedMapEntry | null { +function getRunnerSourceMap(position: OriginalMapping): CachedMapEntry | null { for (const moduleCache of moduleGraphs) { const sourceMap = moduleCache.getSourceMap(position.source!) if (sourceMap) { @@ -172,7 +172,7 @@ function retrieveSourceMap(source: string) { function mapSourcePosition(position: OriginalMapping) { if (!position.source) return position - let sourceMap = getRuntimeSourceMap(position) + let sourceMap = getRunnerSourceMap(position) if (!sourceMap) sourceMap = sourceMapCache[position.source] if (!sourceMap) { // Call the (overrideable) retrieveSourceMap function to get the source map. diff --git a/packages/vite/src/runtime/tsconfig.json b/packages/vite/src/module-runner/tsconfig.json similarity index 78% rename from packages/vite/src/runtime/tsconfig.json rename to packages/vite/src/module-runner/tsconfig.json index b664c0ea7a093f..40840789f59fc8 100644 --- a/packages/vite/src/runtime/tsconfig.json +++ b/packages/vite/src/module-runner/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "include": ["./", "../node", "../dep-types", "../types"], - "exclude": ["**/__tests__"], + "exclude": ["**/__tests__", "**/__tests_dts__"], "compilerOptions": { "lib": ["ESNext", "DOM"], "stripInternal": true diff --git a/packages/vite/src/runtime/types.ts b/packages/vite/src/module-runner/types.ts similarity index 69% rename from packages/vite/src/runtime/types.ts rename to packages/vite/src/module-runner/types.ts index 730ed59630e26d..2e8423c77d790c 100644 --- a/packages/vite/src/runtime/types.ts +++ b/packages/vite/src/module-runner/types.ts @@ -1,9 +1,9 @@ import type { ViteHotContext } from 'types/hot' -import type { HMRPayload } from 'types/hmrPayload' +import type { HotPayload } from 'types/hmrPayload' import type { HMRConnection, HMRLogger } from '../shared/hmr' import type { DefineImportMetadata, - SSRImportBaseMetadata, + SSRImportMetadata, } from '../shared/ssrTransform' import type { ModuleCacheMap } from './moduleCache' import type { @@ -15,28 +15,26 @@ import type { } from './constants' import type { DecodedMap } from './sourcemap/decoder' import type { InterceptorOptions } from './sourcemap/interceptor' +import type { RunnerTransport } from './runnerTransport' -export type { DefineImportMetadata } -export interface SSRImportMetadata extends SSRImportBaseMetadata { - entrypoint?: boolean -} +export type { DefineImportMetadata, SSRImportMetadata } -export interface HMRRuntimeConnection extends HMRConnection { +export interface ModuleRunnerHMRConnection extends HMRConnection { /** * Configure how HMR is handled when this connection triggers an update. * This method expects that connection will start listening for HMR updates and call this callback when it's received. */ - onUpdate(callback: (payload: HMRPayload) => void): void + onUpdate(callback: (payload: HotPayload) => void): void } -export interface ViteRuntimeImportMeta extends ImportMeta { +export interface ModuleRunnerImportMeta extends ImportMeta { url: string env: ImportMetaEnv hot?: ViteHotContext [key: string]: any } -export interface ViteRuntimeModuleContext { +export interface ModuleRunnerContext { [ssrModuleExportsKey]: Record [ssrImportKey]: (id: string, metadata?: DefineImportMetadata) => Promise [ssrDynamicImportKey]: ( @@ -44,18 +42,18 @@ export interface ViteRuntimeModuleContext { options?: ImportCallOptions, ) => Promise [ssrExportAllKey]: (obj: any) => void - [ssrImportMetaKey]: ViteRuntimeImportMeta + [ssrImportMetaKey]: ModuleRunnerImportMeta } -export interface ViteModuleRunner { +export interface ModuleEvaluator { /** * Run code that was transformed by Vite. * @param context Function context * @param code Transformed code * @param id ID that was used to fetch the module */ - runViteModule( - context: ViteRuntimeModuleContext, + runInlinedModule( + context: ModuleRunnerContext, code: string, id: string, ): Promise @@ -71,7 +69,7 @@ export interface ModuleCache { exports?: any evaluated?: boolean map?: DecodedMap - meta?: FetchResult + meta?: ResolvedResult /** * Module ids that imports this module */ @@ -79,13 +77,24 @@ export interface ModuleCache { imports?: Set } -export type FetchResult = ExternalFetchResult | ViteFetchResult +export type FetchResult = + | CachedFetchResult + | ExternalFetchResult + | ViteFetchResult + +export interface CachedFetchResult { + /** + * If module cached in the runner, we can just confirm + * it wasn't invalidated on the server side. + */ + cache: true +} export interface ExternalFetchResult { /** * The path to the externalized module starting with file://, * by default this will be imported via a dynamic "import" - * instead of being transformed by vite and loaded with vite runtime + * instead of being transformed by vite and loaded with vite runner */ externalize: string /** @@ -97,44 +106,56 @@ export interface ExternalFetchResult { export interface ViteFetchResult { /** - * Code that will be evaluated by vite runtime + * Code that will be evaluated by vite runner * by default this will be wrapped in an async function */ code: string /** * File path of the module on disk. * This will be resolved as import.meta.url/filename + * Will be equal to `null` for virtual modules */ file: string | null + /** + * Invalidate module on the client side. + */ + invalidate: boolean } export type ResolvedResult = (ExternalFetchResult | ViteFetchResult) & { id: string } -/** - * @experimental - */ export type FetchFunction = ( id: string, importer?: string, + options?: FetchFunctionOptions, ) => Promise -export interface ViteRuntimeOptions { +export interface FetchFunctionOptions { + cached?: boolean +} + +export interface ModuleRunnerHmr { /** - * Root of the project + * Configure how HMR communicates between the client and the server. */ - root: string + connection: ModuleRunnerHMRConnection /** - * A method to get the information about the module. - * For SSR, Vite exposes `server.ssrFetchModule` function that you can use here. - * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. + * Configure HMR logger. */ - fetchModule: FetchFunction + logger?: false | HMRLogger +} + +export interface ModuleRunnerOptions { /** - * Custom environment variables available on `import.meta.env`. This doesn't modify the actual `process.env`. + * Root of the project */ - environmentVariables?: Record + root: string + /** + * A set of methods to communicate with the server. + */ + transport: RunnerTransport /** * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. @@ -148,20 +169,9 @@ export interface ViteRuntimeOptions { /** * Disable HMR or configure HMR options. */ - hmr?: - | false - | { - /** - * Configure how HMR communicates between the client and the server. - */ - connection: HMRRuntimeConnection - /** - * Configure HMR logger. - */ - logger?: false | HMRLogger - } - /** - * Custom module cache. If not provided, creates a separate module cache for each ViteRuntime instance. + hmr?: false | ModuleRunnerHmr + /** + * Custom module cache. If not provided, creates a separate module cache for each ModuleRunner instance. */ moduleCache?: ModuleCacheMap } diff --git a/packages/vite/src/runtime/utils.ts b/packages/vite/src/module-runner/utils.ts similarity index 78% rename from packages/vite/src/runtime/utils.ts rename to packages/vite/src/module-runner/utils.ts index 12e06a3ebb1882..78affa0d61d0eb 100644 --- a/packages/vite/src/runtime/utils.ts +++ b/packages/vite/src/module-runner/utils.ts @@ -1,5 +1,26 @@ import * as pathe from 'pathe' -import { isWindows } from '../shared/utils' +import { isWindows, slash } from '../shared/utils' + +export function normalizeAbsoluteUrl(url: string, root: string): string { + url = slash(url) + + // file:///C:/root/id.js -> C:/root/id.js + if (url.startsWith('file://')) { + // 8 is the length of "file:///" + url = url.slice(isWindows ? 8 : 7) + } + + // strip root from the URL because fetchModule prefers a public served url path + // packages/vite/src/node/server/moduleGraph.ts:17 + if (url.startsWith(root)) { + // /root/id.js -> /id.js + // C:/root/id.js -> /id.js + // 1 is to keep the leading slash + url = url.slice(root.length - 1) + } + + return url +} export const decodeBase64 = typeof atob !== 'undefined' @@ -34,7 +55,6 @@ function encodePathChars(filepath: string) { export const posixDirname = pathe.dirname export const posixResolve = pathe.resolve -export const normalizeString = pathe.normalizeString export function posixPathToFileHref(posixPath: string): string { let resolved = posixResolve(posixPath) diff --git a/packages/vite/src/node/__tests__/build.spec.ts b/packages/vite/src/node/__tests__/build.spec.ts index 2dad85578812cc..7bb289da592a97 100644 --- a/packages/vite/src/node/__tests__/build.spec.ts +++ b/packages/vite/src/node/__tests__/build.spec.ts @@ -4,7 +4,12 @@ import colors from 'picocolors' import { describe, expect, test, vi } from 'vitest' import type { OutputChunk, OutputOptions, RollupOutput } from 'rollup' import type { LibraryFormats, LibraryOptions } from '../build' -import { build, resolveBuildOutputs, resolveLibFilename } from '../build' +import { + build, + createBuilder, + resolveBuildOutputs, + resolveLibFilename, +} from '../build' import type { Logger } from '../logger' import { createLogger } from '../logger' @@ -576,6 +581,126 @@ describe('resolveBuildOutputs', () => { ), ) }) + + test('ssrEmitAssets', async () => { + const result = await build({ + root: resolve(__dirname, 'fixtures/emit-assets'), + logLevel: 'silent', + build: { + ssr: true, + ssrEmitAssets: true, + rollupOptions: { + input: { + index: '/entry', + }, + }, + }, + }) + expect(result).toMatchObject({ + output: [ + { + fileName: 'index.mjs', + }, + { + fileName: expect.stringMatching(/assets\/index-\w*\.css/), + }, + ], + }) + }) + + test('emitAssets', async () => { + const builder = await createBuilder({ + root: resolve(__dirname, 'fixtures/emit-assets'), + environments: { + ssr: { + build: { + ssr: true, + emitAssets: true, + rollupOptions: { + input: { + index: '/entry', + }, + }, + }, + }, + }, + }) + const result = await builder.build(builder.environments.ssr) + expect(result).toMatchObject({ + output: [ + { + fileName: 'index.mjs', + }, + { + fileName: expect.stringMatching(/assets\/index-\w*\.css/), + }, + ], + }) + }) + + test('ssr builtin', async () => { + const builder = await createBuilder({ + root: resolve(__dirname, 'fixtures/dynamic-import'), + environments: { + ssr: { + build: { + ssr: true, + rollupOptions: { + input: { + index: '/entry', + }, + }, + }, + }, + }, + }) + const result = await builder.build(builder.environments.ssr) + expect((result as RollupOutput).output[0].code).not.toContain('preload') + }) + + test('ssr custom', async () => { + const builder = await createBuilder({ + root: resolve(__dirname, 'fixtures/dynamic-import'), + environments: { + custom: { + build: { + ssr: true, + rollupOptions: { + input: { + index: '/entry', + }, + }, + }, + }, + }, + }) + const result = await builder.build(builder.environments.custom) + expect((result as RollupOutput).output[0].code).not.toContain('preload') + }) +}) + +test('default sharedConfigBuild true on build api', async () => { + let counter = 0 + await build({ + root: resolve(__dirname, 'fixtures/emit-assets'), + build: { + ssr: true, + rollupOptions: { + input: { + index: '/entry', + }, + }, + }, + plugins: [ + { + name: 'test-plugin', + config() { + counter++ + }, + }, + ], + }) + expect(counter).toBe(1) }) /** diff --git a/packages/vite/src/node/__tests__/dev.spec.ts b/packages/vite/src/node/__tests__/dev.spec.ts index 1ade6c0adde9ea..346bebd2aac42e 100644 --- a/packages/vite/src/node/__tests__/dev.spec.ts +++ b/packages/vite/src/node/__tests__/dev.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest' import { resolveConfig } from '..' -describe('resolveBuildOptions in dev', () => { +describe('resolveBuildEnvironmentOptions in dev', () => { test('build.rollupOptions should not have input in lib', async () => { const config = await resolveConfig( { diff --git a/packages/vite/src/node/ssr/__tests__/ssrExternal.spec.ts b/packages/vite/src/node/__tests__/external.spec.ts similarity index 52% rename from packages/vite/src/node/ssr/__tests__/ssrExternal.spec.ts rename to packages/vite/src/node/__tests__/external.spec.ts index 68e753af703ce2..a4c78519fd91ef 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrExternal.spec.ts +++ b/packages/vite/src/node/__tests__/external.spec.ts @@ -1,29 +1,30 @@ import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'vitest' -import type { SSROptions } from '..' -import { resolveConfig } from '../../config' -import { createIsConfiguredAsSsrExternal } from '../ssrExternal' +import { resolveConfig } from '../config' +import { createIsConfiguredAsExternal } from '../external' +import { PartialEnvironment } from '../baseEnvironment' -describe('createIsConfiguredAsSsrExternal', () => { +describe('createIsConfiguredAsExternal', () => { test('default', async () => { const isExternal = await createIsExternal() expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(false) }) test('force external', async () => { - const isExternal = await createIsExternal({ external: true }) + const isExternal = await createIsExternal(true) expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(true) }) }) -async function createIsExternal(ssrConfig?: SSROptions) { +async function createIsExternal(external?: true) { const resolvedConfig = await resolveConfig( { configFile: false, root: fileURLToPath(new URL('./', import.meta.url)), - ssr: ssrConfig, + resolve: { external }, }, 'serve', ) - return createIsConfiguredAsSsrExternal(resolvedConfig) + const environment = new PartialEnvironment('ssr', resolvedConfig) + return createIsConfiguredAsExternal(environment) } diff --git a/packages/vite/src/node/ssr/__tests__/fixtures/cjs-ssr-dep/index.js b/packages/vite/src/node/__tests__/fixtures/cjs-ssr-dep/index.js similarity index 100% rename from packages/vite/src/node/ssr/__tests__/fixtures/cjs-ssr-dep/index.js rename to packages/vite/src/node/__tests__/fixtures/cjs-ssr-dep/index.js diff --git a/packages/vite/src/node/ssr/__tests__/fixtures/cjs-ssr-dep/package.json b/packages/vite/src/node/__tests__/fixtures/cjs-ssr-dep/package.json similarity index 100% rename from packages/vite/src/node/ssr/__tests__/fixtures/cjs-ssr-dep/package.json rename to packages/vite/src/node/__tests__/fixtures/cjs-ssr-dep/package.json diff --git a/packages/vite/src/node/__tests__/fixtures/dynamic-import/dep.mjs b/packages/vite/src/node/__tests__/fixtures/dynamic-import/dep.mjs new file mode 100644 index 00000000000000..76805196e3d27d --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/dynamic-import/dep.mjs @@ -0,0 +1 @@ +export const hello = 'hello' diff --git a/packages/vite/src/node/__tests__/fixtures/dynamic-import/entry.mjs b/packages/vite/src/node/__tests__/fixtures/dynamic-import/entry.mjs new file mode 100644 index 00000000000000..997d636183c8a9 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/dynamic-import/entry.mjs @@ -0,0 +1,4 @@ +export async function main() { + const mod = await import('./dep.mjs') + console.log(mod) +} diff --git a/packages/vite/src/node/__tests__/fixtures/emit-assets/css-module.module.css b/packages/vite/src/node/__tests__/fixtures/emit-assets/css-module.module.css new file mode 100644 index 00000000000000..ccb357b951b97a --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/emit-assets/css-module.module.css @@ -0,0 +1,6 @@ +.css-module { + background: rgb(200, 250, 250); + padding: 20px; + width: 200px; + border: 1px solid gray; +} diff --git a/packages/vite/src/node/__tests__/fixtures/emit-assets/css-normal.css b/packages/vite/src/node/__tests__/fixtures/emit-assets/css-normal.css new file mode 100644 index 00000000000000..b71240fe326ac3 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/emit-assets/css-normal.css @@ -0,0 +1,6 @@ +#css-normal { + background: rgb(250, 250, 200); + padding: 20px; + width: 200px; + border: 1px solid gray; +} diff --git a/packages/vite/src/node/__tests__/fixtures/emit-assets/entry.mjs b/packages/vite/src/node/__tests__/fixtures/emit-assets/entry.mjs new file mode 100644 index 00000000000000..f62bad6569e92e --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/emit-assets/entry.mjs @@ -0,0 +1,6 @@ +import './css-normal.css' +import cssModule from './css-module.module.css' + +export default function Page() { + console.log(cssModule) +} diff --git a/packages/vite/src/node/__tests__/fixtures/environment-alias/test.client.js b/packages/vite/src/node/__tests__/fixtures/environment-alias/test.client.js new file mode 100644 index 00000000000000..aa2ed1e401a266 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/environment-alias/test.client.js @@ -0,0 +1 @@ +export const msg = `[success] (client) alias to mod path` diff --git a/packages/vite/src/node/__tests__/fixtures/environment-alias/test.rsc.js b/packages/vite/src/node/__tests__/fixtures/environment-alias/test.rsc.js new file mode 100644 index 00000000000000..68cfb5abf049d6 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/environment-alias/test.rsc.js @@ -0,0 +1 @@ +export const msg = `[success] (rsc) alias to mod path` diff --git a/packages/vite/src/node/__tests__/fixtures/environment-alias/test.ssr.js b/packages/vite/src/node/__tests__/fixtures/environment-alias/test.ssr.js new file mode 100644 index 00000000000000..3518331b84db03 --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/environment-alias/test.ssr.js @@ -0,0 +1 @@ +export const msg = `[success] (ssr) alias to mod path` diff --git a/packages/vite/src/node/ssr/__tests__/package.json b/packages/vite/src/node/__tests__/package.json similarity index 100% rename from packages/vite/src/node/ssr/__tests__/package.json rename to packages/vite/src/node/__tests__/package.json diff --git a/packages/vite/src/node/__tests__/plugins/css.spec.ts b/packages/vite/src/node/__tests__/plugins/css.spec.ts index e1c435211c9593..7076bb915c386c 100644 --- a/packages/vite/src/node/__tests__/plugins/css.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/css.spec.ts @@ -11,6 +11,7 @@ import { hoistAtRules, preprocessCSS, } from '../../plugins/css' +import { PartialEnvironment } from '../../baseEnvironment' describe('search css url function', () => { test('some spaces before it', () => { @@ -216,6 +217,8 @@ async function createCssPluginTransform( inlineConfig: InlineConfig = {}, ) { const config = await resolveConfig(inlineConfig, 'serve') + const environment = new PartialEnvironment('client', config) + const { transform, buildStart } = cssPlugin(config) // @ts-expect-error buildStart is function @@ -236,6 +239,7 @@ async function createCssPluginTransform( addWatchFile() { return }, + environment, }, code, id, diff --git a/packages/vite/src/node/__tests__/plugins/define.spec.ts b/packages/vite/src/node/__tests__/plugins/define.spec.ts index b0b42678ed1130..166cabac83376f 100644 --- a/packages/vite/src/node/__tests__/plugins/define.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/define.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { definePlugin } from '../../plugins/define' import { resolveConfig } from '../../config' +import { PartialEnvironment } from '../../baseEnvironment' async function createDefinePluginTransform( define: Record = {}, @@ -12,9 +13,15 @@ async function createDefinePluginTransform( build ? 'build' : 'serve', ) const instance = definePlugin(config) + const environment = new PartialEnvironment(ssr ? 'ssr' : 'client', config) + return async (code: string) => { // @ts-expect-error transform should exist - const result = await instance.transform.call({}, code, 'foo.ts', { ssr }) + const result = await instance.transform.call( + { environment }, + code, + 'foo.ts', + ) return result?.code || result } } diff --git a/packages/vite/src/node/__tests_dts__/plugin.ts b/packages/vite/src/node/__tests_dts__/plugin.ts new file mode 100644 index 00000000000000..5b4ebeb82895c8 --- /dev/null +++ b/packages/vite/src/node/__tests_dts__/plugin.ts @@ -0,0 +1,38 @@ +/** + * This is a development only file for testing types. + */ +import type { Plugin as RollupPlugin } from 'rollup' +import type { Equal, ExpectExtends, ExpectTrue } from '@type-challenges/utils' +import type { Plugin, PluginContextExtension } from '../plugin' +import type { ROLLUP_HOOKS } from '../constants' +import type { + GetHookContextMap, + NonNeverKeys, + RollupPluginHooks, +} from '../typeUtils' + +type EnvironmentPluginHooksContext = GetHookContextMap +type EnvironmentPluginHooksContextMatched = { + [K in keyof EnvironmentPluginHooksContext]: EnvironmentPluginHooksContext[K] extends PluginContextExtension + ? never + : false +} + +type HooksMissingExtension = NonNeverKeys +type HooksMissingInConstants = Exclude< + RollupPluginHooks, + (typeof ROLLUP_HOOKS)[number] +> + +export type cases = [ + // Ensure environment plugin hooks are superset of rollup plugin hooks + ExpectTrue>, + + // Ensure all Rollup hooks have Vite's plugin context extension + ExpectTrue>, + + // Ensure the `ROLLUP_HOOKS` constant is up-to-date + ExpectTrue>, +] + +export {} diff --git a/packages/vite/src/node/baseEnvironment.ts b/packages/vite/src/node/baseEnvironment.ts new file mode 100644 index 00000000000000..3408db1e1ddc70 --- /dev/null +++ b/packages/vite/src/node/baseEnvironment.ts @@ -0,0 +1,145 @@ +import colors from 'picocolors' +import type { Logger } from './logger' +import type { ResolvedConfig, ResolvedEnvironmentOptions } from './config' +import type { Plugin } from './plugin' + +const environmentColors = [ + colors.blue, + colors.magenta, + colors.green, + colors.gray, +] + +export class PartialEnvironment { + name: string + getTopLevelConfig(): ResolvedConfig { + return this._topLevelConfig + } + + config: ResolvedConfig & ResolvedEnvironmentOptions + + /** + * @deprecated use environment.config instead + **/ + get options(): ResolvedEnvironmentOptions { + return this._options + } + + logger: Logger + + /** + * @internal + */ + _options: ResolvedEnvironmentOptions + /** + * @internal + */ + _topLevelConfig: ResolvedConfig + + constructor( + name: string, + topLevelConfig: ResolvedConfig, + options: ResolvedEnvironmentOptions = topLevelConfig.environments[name], + ) { + this.name = name + this._topLevelConfig = topLevelConfig + this._options = options + this.config = new Proxy( + options as ResolvedConfig & ResolvedEnvironmentOptions, + { + get: (target, prop: keyof ResolvedConfig) => { + if (prop === 'logger') { + return this.logger + } + if (prop in target) { + return this._options[prop as keyof ResolvedEnvironmentOptions] + } + return this._topLevelConfig[prop] + }, + }, + ) + const environment = colors.dim(`(${this.name})`) + const colorIndex = + [...this.name].reduce((acc, c) => acc + c.charCodeAt(0), 0) % + environmentColors.length + const infoColor = environmentColors[colorIndex || 0] + this.logger = { + get hasWarned() { + return topLevelConfig.logger.hasWarned + }, + info(msg, opts) { + return topLevelConfig.logger.info(msg, { + ...opts, + environment: infoColor(environment), + }) + }, + warn(msg, opts) { + return topLevelConfig.logger.warn(msg, { + ...opts, + environment: colors.yellow(environment), + }) + }, + warnOnce(msg, opts) { + return topLevelConfig.logger.warnOnce(msg, { + ...opts, + environment: colors.yellow(environment), + }) + }, + error(msg, opts) { + return topLevelConfig.logger.error(msg, { + ...opts, + environment: colors.red(environment), + }) + }, + clearScreen(type) { + return topLevelConfig.logger.clearScreen(type) + }, + hasErrorLogged(error) { + return topLevelConfig.logger.hasErrorLogged(error) + }, + } + } +} + +export class BaseEnvironment extends PartialEnvironment { + get plugins(): Plugin[] { + if (!this._plugins) + throw new Error( + `${this.name} environment.plugins called before initialized`, + ) + return this._plugins + } + + /** + * @internal + */ + _plugins: Plugin[] | undefined + /** + * @internal + */ + _initiated: boolean = false + + constructor( + name: string, + config: ResolvedConfig, + options: ResolvedEnvironmentOptions = config.environments[name], + ) { + super(name, config, options) + } +} + +/** + * This class discourages users from inversely checking the `mode` + * to determine the type of environment, e.g. + * + * ```js + * const isDev = environment.mode !== 'build' // bad + * const isDev = environment.mode === 'dev' // good + * ``` + * + * You should also not check against `"unknown"` specfically. It's + * a placeholder for more possible environment types. + */ +export class UnknownEnvironment extends BaseEnvironment { + mode = 'unknown' as const +} diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index 8bcacb834680e8..feaf641b25d416 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -8,7 +8,6 @@ import type { LoggingFunction, ModuleFormat, OutputOptions, - Plugin, RollupBuild, RollupError, RollupLog, @@ -25,10 +24,17 @@ import { withTrailingSlash } from '../shared/utils' import { DEFAULT_ASSETS_INLINE_LIMIT, ESBUILD_MODULES_TARGET, + ROLLUP_HOOKS, VERSION, } from './constants' -import type { InlineConfig, ResolvedConfig } from './config' -import { resolveConfig } from './config' +import type { + EnvironmentOptions, + InlineConfig, + ResolvedConfig, + ResolvedEnvironmentOptions, +} from './config' +import { getDefaultResolvedEnvironmentOptions, resolveConfig } from './config' +import type { PartialEnvironment } from './baseEnvironment' import { buildReporterPlugin } from './plugins/reporter' import { buildEsbuildPlugin } from './plugins/esbuild' import { type TerserOptions, terserPlugin } from './plugins/terser' @@ -43,12 +49,13 @@ import { partialEncodeURIPath, requireResolveFromRootWithFallback, } from './utils' +import { resolveEnvironmentPlugins } from './plugin' import { manifestPlugin } from './plugins/manifest' import type { Logger } from './logger' import { dataURIPlugin } from './plugins/dataUri' import { buildImportAnalysisPlugin } from './plugins/importAnalysisBuild' import { ssrManifestPlugin } from './ssr/ssrManifestPlugin' -import { loadFallbackPlugin } from './plugins/loadFallback' +import { buildLoadFallbackPlugin } from './plugins/loadFallback' import { findNearestPackageData } from './packages' import type { PackageCache } from './packages' import { @@ -60,8 +67,11 @@ import { completeSystemWrapPlugin } from './plugins/completeSystemWrap' import { mergeConfig } from './publicUtils' import { webWorkerPostPlugin } from './plugins/worker' import { getHookHandler } from './plugins' +import { BaseEnvironment } from './baseEnvironment' +import type { Plugin, PluginContext } from './plugin' +import type { RollupPluginHooks } from './typeUtils' -export interface BuildOptions { +export interface BuildEnvironmentOptions { /** * Compatibility transform target. The transform is performed with esbuild * and the lowest supported target is es2015/es6. Note this only handles @@ -230,6 +240,11 @@ export interface BuildOptions { * @default false */ ssrEmitAssets?: boolean + /** + * Emit assets during build. Frameworks can set environments.ssr.build.emitAssets + * By default, it is true for the client and false for other environments. + */ + emitAssets?: boolean /** * Set to false to disable reporting compressed chunk sizes. * Can slightly improve build speed. @@ -247,8 +262,17 @@ export interface BuildOptions { * @default null */ watch?: WatcherOptions | null + /** + * create the Build Environment instance + */ + createEnvironment?: ( + name: string, + config: ResolvedConfig, + ) => Promise | BuildEnvironment } +export type BuildOptions = BuildEnvironmentOptions + export interface LibraryOptions { /** * Path of library entry @@ -301,78 +325,84 @@ export type ResolveModulePreloadDependenciesFn = ( }, ) => string[] +export interface ResolvedBuildEnvironmentOptions + extends Required> { + modulePreload: false | ResolvedModulePreloadOptions +} + export interface ResolvedBuildOptions extends Required> { modulePreload: false | ResolvedModulePreloadOptions } -export function resolveBuildOptions( - raw: BuildOptions | undefined, +export function resolveBuildEnvironmentOptions( + raw: BuildEnvironmentOptions, logger: Logger, root: string, -): ResolvedBuildOptions { + consumer: 'client' | 'server' | undefined, +): ResolvedBuildEnvironmentOptions { const deprecatedPolyfillModulePreload = raw?.polyfillModulePreload - if (raw) { - const { polyfillModulePreload, ...rest } = raw - raw = rest - if (deprecatedPolyfillModulePreload !== undefined) { - logger.warn( - 'polyfillModulePreload is deprecated. Use modulePreload.polyfill instead.', - ) - } - if ( - deprecatedPolyfillModulePreload === false && - raw.modulePreload === undefined - ) { - raw.modulePreload = { polyfill: false } - } + const { polyfillModulePreload, ...rest } = raw + raw = rest + if (deprecatedPolyfillModulePreload !== undefined) { + logger.warn( + 'polyfillModulePreload is deprecated. Use modulePreload.polyfill instead.', + ) + } + if ( + deprecatedPolyfillModulePreload === false && + raw.modulePreload === undefined + ) { + raw.modulePreload = { polyfill: false } } - const modulePreload = raw?.modulePreload + const modulePreload = raw.modulePreload const defaultModulePreload = { polyfill: true, } - const defaultBuildOptions: BuildOptions = { + const defaultBuildEnvironmentOptions: BuildEnvironmentOptions = { outDir: 'dist', assetsDir: 'assets', assetsInlineLimit: DEFAULT_ASSETS_INLINE_LIMIT, - cssCodeSplit: !raw?.lib, + cssCodeSplit: !raw.lib, sourcemap: false, rollupOptions: {}, - minify: raw?.ssr ? false : 'esbuild', + minify: raw.ssr ? false : 'esbuild', terserOptions: {}, write: true, emptyOutDir: null, copyPublicDir: true, manifest: false, lib: false, - ssr: false, + ssr: consumer === 'server', ssrManifest: false, ssrEmitAssets: false, + emitAssets: consumer === 'client', reportCompressedSize: true, chunkSizeWarningLimit: 500, watch: null, + createEnvironment: (name, config) => new BuildEnvironment(name, config), } - const userBuildOptions = raw - ? mergeConfig(defaultBuildOptions, raw) - : defaultBuildOptions + const userBuildEnvironmentOptions = raw + ? mergeConfig(defaultBuildEnvironmentOptions, raw) + : defaultBuildEnvironmentOptions // @ts-expect-error Fallback options instead of merging - const resolved: ResolvedBuildOptions = { + const resolved: ResolvedBuildEnvironmentOptions = { target: 'modules', cssTarget: false, - ...userBuildOptions, + ...userBuildEnvironmentOptions, commonjsOptions: { include: [/node_modules/], extensions: ['.js', '.cjs'], - ...userBuildOptions.commonjsOptions, + ...userBuildEnvironmentOptions.commonjsOptions, }, dynamicImportVarsOptions: { warnOnError: true, exclude: [/node_modules/], - ...userBuildOptions.dynamicImportVarsOptions, + ...userBuildEnvironmentOptions.dynamicImportVarsOptions, }, // Resolve to false | object modulePreload: @@ -428,60 +458,115 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ pre: Plugin[] post: Plugin[] }> { - const options = config.build - const { commonjsOptions } = options + const { commonjsOptions } = config.build const usePluginCommonjs = - !Array.isArray(commonjsOptions?.include) || - commonjsOptions?.include.length !== 0 - const rollupOptionsPlugins = options.rollupOptions.plugins + !Array.isArray(commonjsOptions.include) || + commonjsOptions.include.length !== 0 return { pre: [ completeSystemWrapPlugin(), - ...(usePluginCommonjs ? [commonjsPlugin(options.commonjsOptions)] : []), + /** + * environment.config.build.commonjsOptions isn't currently supported + * when builder.sharedConfigBuild or builder.sharedPlugins enabled. + * To do it, we could inject one commonjs plugin per environment with + * an applyToEnvironment hook. + */ + ...(usePluginCommonjs ? [commonjsPlugin(commonjsOptions)] : []), dataURIPlugin(), - ...((await asyncFlatten(arraify(rollupOptionsPlugins))).filter( - Boolean, - ) as Plugin[]), + /** + * environment.config.build.rollupOptions.plugins isn't supported + * when builder.sharedConfigBuild or builder.sharedPlugins is enabled. + * To do it, we should add all these plugins to the global pipeline, each with + * an applyToEnvironment hook. It is similar to letting the user add per + * environment plugins giving them a environment.config.plugins option that + * we decided against. + * For backward compatibility, we are still injecting the rollup plugins + * defined in the default root build options. + */ + ...(( + await asyncFlatten(arraify(config.build.rollupOptions.plugins)) + ).filter(Boolean) as Plugin[]), ...(config.isWorker ? [webWorkerPostPlugin()] : []), ], post: [ buildImportAnalysisPlugin(config), ...(config.esbuild !== false ? [buildEsbuildPlugin(config)] : []), - ...(options.minify ? [terserPlugin(config)] : []), + terserPlugin(config), ...(!config.isWorker - ? [ - ...(options.manifest ? [manifestPlugin(config)] : []), - ...(options.ssrManifest ? [ssrManifestPlugin(config)] : []), - buildReporterPlugin(config), - ] + ? [manifestPlugin(), ssrManifestPlugin(), buildReporterPlugin(config)] : []), - loadFallbackPlugin(), + buildLoadFallbackPlugin(), ], } } /** - * Bundles the app for production. + * Bundles a single environment for production. * Returns a Promise containing the build result. */ export async function build( inlineConfig: InlineConfig = {}, ): Promise { - const config = await resolveConfig( + const patchConfig = (resolved: ResolvedConfig) => { + // Until the ecosystem updates to use `environment.config.build` instead of `config.build`, + // we need to make override `config.build` for the current environment. + // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later + // remove the default values that shouldn't be used at all once the config is resolved + const environmentName = resolved.build.ssr ? 'ssr' : 'client' + ;(resolved.build as ResolvedBuildOptions) = { + ...resolved.environments[environmentName].build, + } + } + const config = await resolveConfigToBuild(inlineConfig, patchConfig) + return buildWithResolvedConfig(config) +} + +/** + * @internal used to implement `vite build` for backward compatibility + */ +export async function buildWithResolvedConfig( + config: ResolvedConfig, +): Promise { + const environmentName = config.build.ssr ? 'ssr' : 'client' + const environment = await config.environments[ + environmentName + ].build.createEnvironment(environmentName, config) + await environment.init() + return buildEnvironment(environment) +} + +export function resolveConfigToBuild( + inlineConfig: InlineConfig = {}, + patchConfig?: (config: ResolvedConfig) => void, + patchPlugins?: (resolvedPlugins: Plugin[]) => void, +): Promise { + return resolveConfig( inlineConfig, 'build', 'production', 'production', + false, + patchConfig, + patchPlugins, ) - const options = config.build - const { root, logger, packageCache } = config - const ssr = !!options.ssr +} + +/** + * Build an App environment, or a App library (if libraryOptions is provided) + **/ +export async function buildEnvironment( + environment: BuildEnvironment, +): Promise { + const { root, packageCache } = environment.config + const options = environment.config.build const libOptions = options.lib + const { logger } = environment + const ssr = environment.config.consumer === 'server' logger.info( colors.cyan( `vite v${VERSION} ${colors.green( - `building ${ssr ? `SSR bundle ` : ``}for ${config.mode}...`, + `building ${ssr ? `SSR bundle ` : ``}for ${environment.config.mode}...`, )}`, ), ) @@ -509,7 +594,7 @@ export async function build( `Please specify a dedicated SSR entry.`, ) } - if (config.build.cssCodeSplit === false) { + if (options.cssCodeSplit === false) { const inputs = typeof input === 'string' ? [input] @@ -525,10 +610,10 @@ export async function build( const outDir = resolve(options.outDir) - // inject ssr arg to plugin load/transform hooks - const plugins = ( - ssr ? config.plugins.map((p) => injectSsrFlagToHooks(p)) : config.plugins - ) as Plugin[] + // inject environment and ssr arg to plugin load/transform hooks + const plugins = environment.plugins.map((p) => + injectEnvironmentToHooks(environment, p), + ) const rollupOptions: RollupOptions = { preserveEntrySignatures: ssr @@ -536,13 +621,14 @@ export async function build( : libOptions ? 'strict' : false, - cache: config.build.watch ? undefined : false, + cache: options.watch ? undefined : false, ...options.rollupOptions, + output: options.rollupOptions.output, input, plugins, external: options.rollupOptions?.external, onwarn(warning, warn) { - onRollupWarning(warning, warn, config) + onRollupWarning(warning, warn, environment) }, } @@ -633,12 +719,9 @@ export async function build( ) } - const ssrNodeBuild = ssr && config.ssr.target === 'node' - const ssrWorkerBuild = ssr && config.ssr.target === 'webworker' - const format = output.format || 'es' const jsExt = - ssrNodeBuild || libOptions + !environment.config.webCompatible || libOptions ? resolveOutputJsExtension( format, findNearestPackageData(root, packageCache)?.data.type, @@ -678,7 +761,8 @@ export async function build( inlineDynamicImports: output.format === 'umd' || output.format === 'iife' || - (ssrWorkerBuild && + (environment.config.consumer === 'server' && + environment.config.webCompatible && (typeof input === 'string' || Object.keys(input).length === 1)), ...output, } @@ -713,14 +797,14 @@ export async function build( ) // watch file changes with rollup - if (config.build.watch) { + if (options.watch) { logger.info(colors.cyan(`\nwatching for file changes...`)) const resolvedChokidarOptions = resolveChokidarOptions( - config.build.watch.chokidar, + options.watch.chokidar, resolvedOutDirs, emptyOutDir, - config.cacheDir, + environment.config.cacheDir, ) const { watch } = await import('rollup') @@ -728,7 +812,7 @@ export async function build( ...rollupOptions, output: normalizedOutputs, watch: { - ...config.build.watch, + ...options.watch, chokidar: resolvedChokidarOptions, }, }) @@ -737,7 +821,7 @@ export async function build( if (event.code === 'BUNDLE_START') { logger.info(colors.cyan(`\nbuild started...`)) if (options.write) { - prepareOutDir(resolvedOutDirs, emptyOutDir, config) + prepareOutDir(resolvedOutDirs, emptyOutDir, environment) } } else if (event.code === 'BUNDLE_END') { event.result.close() @@ -756,7 +840,7 @@ export async function build( bundle = await rollup(rollupOptions) if (options.write) { - prepareOutDir(resolvedOutDirs, emptyOutDir, config) + prepareOutDir(resolvedOutDirs, emptyOutDir, environment) } const res: RollupOutput[] = [] @@ -785,8 +869,9 @@ export async function build( function prepareOutDir( outDirs: Set, emptyOutDir: boolean | null, - config: ResolvedConfig, + environment: BuildEnvironment, ) { + const { publicDir } = environment.config const outDirsArray = [...outDirs] for (const outDir of outDirs) { if (emptyOutDir !== false && fs.existsSync(outDir)) { @@ -807,24 +892,24 @@ function prepareOutDir( emptyDir(outDir, [...skipDirs, '.git']) } if ( - config.build.copyPublicDir && - config.publicDir && - fs.existsSync(config.publicDir) + environment.config.build.copyPublicDir && + publicDir && + fs.existsSync(publicDir) ) { - if (!areSeparateFolders(outDir, config.publicDir)) { - config.logger.warn( + if (!areSeparateFolders(outDir, publicDir)) { + environment.logger.warn( colors.yellow( `\n${colors.bold( `(!)`, )} The public directory feature may not work correctly. outDir ${colors.white( colors.dim(outDir), )} and publicDir ${colors.white( - colors.dim(config.publicDir), + colors.dim(publicDir), )} are not separate folders.\n`, ), ) } - copyDir(config.publicDir, outDir) + copyDir(publicDir, outDir) } } } @@ -951,7 +1036,7 @@ function clearLine() { export function onRollupWarning( warning: RollupLog, warn: LoggingFunction, - config: ResolvedConfig, + environment: BuildEnvironment, ): void { const viteWarn: LoggingFunction = (warnLog) => { let warning: string | RollupLog @@ -991,7 +1076,7 @@ export function onRollupWarning( } if (warning.code === 'PLUGIN_WARNING') { - config.logger.warn( + environment.logger.warn( `${colors.bold( colors.yellow(`[plugin:${warning.plugin}]`), )} ${colors.yellow(warning.message)}`, @@ -1004,7 +1089,7 @@ export function onRollupWarning( } clearLine() - const userOnWarn = config.build.rollupOptions?.onwarn + const userOnWarn = environment.config.build.rollupOptions?.onwarn if (userOnWarn) { userOnWarn(warning, viteWarn) } else { @@ -1035,22 +1120,50 @@ function isExternal(id: string, test: string | RegExp) { } } -function injectSsrFlagToHooks(plugin: Plugin): Plugin { +export function injectEnvironmentToHooks( + environment: BuildEnvironment, + plugin: Plugin, +): Plugin { const { resolveId, load, transform } = plugin - return { - ...plugin, - resolveId: wrapSsrResolveId(resolveId), - load: wrapSsrLoad(load), - transform: wrapSsrTransform(transform), + + const clone = { ...plugin } + + for (const hook of Object.keys(clone) as RollupPluginHooks[]) { + switch (hook) { + case 'resolveId': + clone[hook] = wrapEnvironmentResolveId(environment, resolveId) + break + case 'load': + clone[hook] = wrapEnvironmentLoad(environment, load) + break + case 'transform': + clone[hook] = wrapEnvironmentTransform(environment, transform) + break + default: + if (ROLLUP_HOOKS.includes(hook)) { + ;(clone as any)[hook] = wrapEnvironmentHook(environment, clone[hook]) + } + break + } } + + return clone } -function wrapSsrResolveId(hook?: Plugin['resolveId']): Plugin['resolveId'] { +function wrapEnvironmentResolveId( + environment: BuildEnvironment, + hook?: Plugin['resolveId'], +): Plugin['resolveId'] { if (!hook) return const fn = getHookHandler(hook) const handler: Plugin['resolveId'] = function (id, importer, options) { - return fn.call(this, id, importer, injectSsrFlag(options)) + return fn.call( + injectEnvironmentInContext(this, environment), + id, + importer, + injectSsrFlag(options, environment), + ) } if ('handler' in hook) { @@ -1063,13 +1176,19 @@ function wrapSsrResolveId(hook?: Plugin['resolveId']): Plugin['resolveId'] { } } -function wrapSsrLoad(hook?: Plugin['load']): Plugin['load'] { +function wrapEnvironmentLoad( + environment: BuildEnvironment, + hook?: Plugin['load'], +): Plugin['load'] { if (!hook) return const fn = getHookHandler(hook) const handler: Plugin['load'] = function (id, ...args) { - // @ts-expect-error: Receiving options param to be future-proof if Rollup adds it - return fn.call(this, id, injectSsrFlag(args[0])) + return fn.call( + injectEnvironmentInContext(this, environment), + id, + injectSsrFlag(args[0], environment), + ) } if ('handler' in hook) { @@ -1082,13 +1201,20 @@ function wrapSsrLoad(hook?: Plugin['load']): Plugin['load'] { } } -function wrapSsrTransform(hook?: Plugin['transform']): Plugin['transform'] { +function wrapEnvironmentTransform( + environment: BuildEnvironment, + hook?: Plugin['transform'], +): Plugin['transform'] { if (!hook) return const fn = getHookHandler(hook) const handler: Plugin['transform'] = function (code, importer, ...args) { - // @ts-expect-error: Receiving options param to be future-proof if Rollup adds it - return fn.call(this, code, importer, injectSsrFlag(args[0])) + return fn.call( + injectEnvironmentInContext(this, environment), + code, + importer, + injectSsrFlag(args[0], environment), + ) } if ('handler' in hook) { @@ -1101,10 +1227,48 @@ function wrapSsrTransform(hook?: Plugin['transform']): Plugin['transform'] { } } +function wrapEnvironmentHook( + environment: BuildEnvironment, + hook?: Plugin[HookName], +): Plugin[HookName] { + if (!hook) return + + const fn = getHookHandler(hook) + if (typeof fn !== 'function') return hook + + const handler: Plugin[HookName] = function ( + this: PluginContext, + ...args: any[] + ) { + return fn.call(injectEnvironmentInContext(this, environment), ...args) + } + + if ('handler' in hook) { + return { + ...hook, + handler, + } as Plugin[HookName] + } else { + return handler + } +} + +function injectEnvironmentInContext( + context: Context, + environment: BuildEnvironment, +) { + context.environment ??= environment + return context +} + function injectSsrFlag>( options?: T, -): T & { ssr: boolean } { - return { ...(options ?? {}), ssr: true } as T & { ssr: boolean } + environment?: BuildEnvironment, +): T & { ssr?: boolean } { + const ssr = environment ? environment.config.consumer === 'server' : true + return { ...(options ?? {}), ssr } as T & { + ssr?: boolean + } } /* @@ -1192,24 +1356,26 @@ export type RenderBuiltAssetUrl = ( ) => string | { relative?: boolean; runtime?: string } | undefined export function toOutputFilePathInJS( + environment: PartialEnvironment, filename: string, type: 'asset' | 'public', hostId: string, hostType: 'js' | 'css' | 'html', - config: ResolvedConfig, toRelative: ( filename: string, hostType: string, ) => string | { runtime: string }, ): string | { runtime: string } { - const { renderBuiltUrl } = config.experimental - let relative = config.base === '' || config.base === './' + const { experimental, base, decodedBase } = environment.config + const ssr = environment.config.consumer === 'server' // was !!environment.config.build.ssr + const { renderBuiltUrl } = experimental + let relative = base === '' || base === './' if (renderBuiltUrl) { const result = renderBuiltUrl(filename, { hostId, hostType, type, - ssr: !!config.build.ssr, + ssr, }) if (typeof result === 'object') { if (result.runtime) { @@ -1222,10 +1388,10 @@ export function toOutputFilePathInJS( return result } } - if (relative && !config.build.ssr) { + if (relative && !ssr) { return toRelative(filename, hostId) } - return joinUrlSegments(config.decodedBase, filename) + return joinUrlSegments(decodedBase, filename) } export function createToImportMetaURLBasedRelativeRuntime( @@ -1290,3 +1456,161 @@ function areSeparateFolders(a: string, b: string) { !nb.startsWith(withTrailingSlash(na)) ) } + +export class BuildEnvironment extends BaseEnvironment { + mode = 'build' as const + + constructor( + name: string, + config: ResolvedConfig, + setup?: { + options?: EnvironmentOptions + }, + ) { + let options = + config.environments[name] ?? getDefaultResolvedEnvironmentOptions(config) + if (setup?.options) { + options = mergeConfig( + options, + setup?.options, + ) as ResolvedEnvironmentOptions + } + super(name, config, options) + } + + // TODO: This could be sync, discuss if applyToEnvironment should support async + async init(): Promise { + if (this._initiated) { + return + } + this._initiated = true + this._plugins = resolveEnvironmentPlugins(this) + } +} + +export interface ViteBuilder { + environments: Record + config: ResolvedConfig + buildApp(): Promise + build( + environment: BuildEnvironment, + ): Promise +} + +export interface BuilderOptions { + sharedConfigBuild?: boolean + sharedPlugins?: boolean + entireApp?: boolean + buildApp?: (builder: ViteBuilder) => Promise +} + +async function defaultBuildApp(builder: ViteBuilder): Promise { + for (const environment of Object.values(builder.environments)) { + await builder.build(environment) + } +} + +export function resolveBuilderOptions( + options: BuilderOptions = {}, +): ResolvedBuilderOptions { + return { + sharedConfigBuild: options.sharedConfigBuild ?? false, + sharedPlugins: options.sharedPlugins ?? false, + entireApp: options.entireApp ?? false, + buildApp: options.buildApp ?? defaultBuildApp, + } +} + +export type ResolvedBuilderOptions = Required + +/** + * Creates a ViteBuilder to orchestrate building multiple environments. + */ +export async function createBuilder( + inlineConfig: InlineConfig = {}, +): Promise { + const config = await resolveConfigToBuild(inlineConfig) + return createBuilderWithResolvedConfig(inlineConfig, config) +} + +/** + * Used to implement the `vite build` command without resolving the config twice + * @internal + */ +export async function createBuilderWithResolvedConfig( + inlineConfig: InlineConfig, + config: ResolvedConfig, +): Promise { + const environments: Record = {} + + const builder: ViteBuilder = { + environments, + config, + async buildApp() { + return config.builder.buildApp(builder) + }, + async build(environment: BuildEnvironment) { + return buildEnvironment(environment) + }, + } + + for (const environmentName of Object.keys(config.environments)) { + // We need to resolve the config again so we can properly merge options + // and get a new set of plugins for each build environment. The ecosystem + // expects plugins to be run for the same environment once they are created + // and to process a single bundle at a time (contrary to dev mode where + // plugins are built to handle multiple environments concurrently). + let environmentConfig = config + if (!config.builder.sharedConfigBuild) { + const patchConfig = (resolved: ResolvedConfig) => { + // Until the ecosystem updates to use `environment.config.build` instead of `config.build`, + // we need to make override `config.build` for the current environment. + // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later + // remove the default values that shouldn't be used at all once the config is resolved + ;(resolved.build as ResolvedBuildOptions) = { + ...resolved.environments[environmentName].build, + } + } + const patchPlugins = (resolvedPlugins: Plugin[]) => { + // Force opt-in shared plugins + const environmentPlugins = [...resolvedPlugins] + let validMixedPlugins = true + for (let i = 0; i < environmentPlugins.length; i++) { + const environmentPlugin = environmentPlugins[i] + const sharedPlugin = config.plugins[i] + if ( + config.builder.sharedPlugins || + environmentPlugin.sharedDuringBuild + ) { + if (environmentPlugin.name !== sharedPlugin.name) { + validMixedPlugins = false + break + } + environmentPlugins[i] = sharedPlugin + } + } + if (validMixedPlugins) { + for (let i = 0; i < environmentPlugins.length; i++) { + resolvedPlugins[i] = environmentPlugins[i] + } + } + } + environmentConfig = await resolveConfigToBuild( + inlineConfig, + patchConfig, + patchPlugins, + ) + } + + const environment = await environmentConfig.build.createEnvironment( + environmentName, + environmentConfig, + ) + + await environment.init() + + environments[environmentName] = environment + } + + return builder +} diff --git a/packages/vite/src/node/cli.ts b/packages/vite/src/node/cli.ts index f0fa2092110175..f829e18e9ba387 100644 --- a/packages/vite/src/node/cli.ts +++ b/packages/vite/src/node/cli.ts @@ -4,12 +4,13 @@ import { performance } from 'node:perf_hooks' import { cac } from 'cac' import colors from 'picocolors' import { VERSION } from './constants' -import type { BuildOptions } from './build' +import type { BuildEnvironmentOptions, ResolvedBuildOptions } from './build' import type { ServerOptions } from './server' import type { CLIShortcut } from './shortcuts' import type { LogLevel } from './logger' import { createLogger } from './logger' import { resolveConfig } from './config' +import type { InlineConfig, ResolvedConfig } from './config' const cli = cac('vite') @@ -31,6 +32,10 @@ interface GlobalCLIOptions { force?: boolean } +interface BuilderCLIOptions { + app?: boolean +} + let profileSession = global.__vite_profile_session let profileCount = 0 @@ -70,7 +75,7 @@ const filterDuplicateOptions = (options: T) => { /** * removing global flags before passing as command specific sub-configs */ -function cleanOptions( +function cleanGlobalCLIOptions( options: Options, ): Omit { const ret = { ...options } @@ -102,6 +107,17 @@ function cleanOptions( return ret } +/** + * removing builder flags before passing as command specific sub-configs + */ +function cleanBuilderCLIOptions( + options: Options, +): Omit { + const ret = { ...options } + delete ret.app + return ret +} + /** * host may be a number (like 0), should convert to string */ @@ -161,7 +177,7 @@ cli logLevel: options.logLevel, clearScreen: options.clearScreen, optimizeDeps: { force: options.force }, - server: cleanOptions(options), + server: cleanGlobalCLIOptions(options), }) if (!server.httpServer) { @@ -263,31 +279,69 @@ cli `[boolean] force empty outDir when it's outside of root`, ) .option('-w, --watch', `[boolean] rebuilds when modules have changed on disk`) - .action(async (root: string, options: BuildOptions & GlobalCLIOptions) => { - filterDuplicateOptions(options) - const { build } = await import('./build') - const buildOptions: BuildOptions = cleanOptions(options) + .option('--app', `[boolean] same as builder.entireApp`) + .action( + async ( + root: string, + options: BuildEnvironmentOptions & BuilderCLIOptions & GlobalCLIOptions, + ) => { + filterDuplicateOptions(options) + const build = await import('./build') - try { - await build({ - root, - base: options.base, - mode: options.mode, - configFile: options.config, - logLevel: options.logLevel, - clearScreen: options.clearScreen, - build: buildOptions, - }) - } catch (e) { - createLogger(options.logLevel).error( - colors.red(`error during build:\n${e.stack}`), - { error: e }, + const buildOptions: BuildEnvironmentOptions = cleanGlobalCLIOptions( + cleanBuilderCLIOptions(options), ) - process.exit(1) - } finally { - stopProfiler((message) => createLogger(options.logLevel).info(message)) - } - }) + + try { + const inlineConfig: InlineConfig = { + root, + base: options.base, + mode: options.mode, + configFile: options.config, + logLevel: options.logLevel, + clearScreen: options.clearScreen, + build: buildOptions, + ...(options.app ? { builder: { entireApp: true } } : {}), + } + const patchConfig = (resolved: ResolvedConfig) => { + if (resolved.builder.entireApp) { + return + } + // Until the ecosystem updates to use `environment.config.build` instead of `config.build`, + // we need to make override `config.build` for the current environment. + // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later + // remove the default values that shouldn't be used at all once the config is resolved + const environmentName = resolved.build.ssr ? 'ssr' : 'client' + ;(resolved.build as ResolvedBuildOptions) = { + ...resolved.environments[environmentName].build, + } + } + const config = await build.resolveConfigToBuild( + inlineConfig, + patchConfig, + ) + + if (config.builder.entireApp) { + const builder = await build.createBuilderWithResolvedConfig( + inlineConfig, + config, + ) + await builder.buildApp() + } else { + // Single environment (client or ssr) build or library mode build + await build.buildWithResolvedConfig(config) + } + } catch (e) { + createLogger(options.logLevel).error( + colors.red(`error during build:\n${e.stack}`), + { error: e }, + ) + process.exit(1) + } finally { + stopProfiler((message) => createLogger(options.logLevel).info(message)) + } + }, + ) // optimize cli diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index e38e5b5959809b..a96c6a179b1b01 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -7,9 +7,10 @@ import { performance } from 'node:perf_hooks' import { createRequire } from 'node:module' import colors from 'picocolors' import type { Alias, AliasOptions } from 'dep-types/alias' -import aliasPlugin from '@rollup/plugin-alias' import { build } from 'esbuild' import type { RollupOptions } from 'rollup' +import picomatch from 'picomatch' +import type { AnymatchFn } from '../types/anymatch' import { withTrailingSlash } from '../shared/utils' import { CLIENT_ENTRY, @@ -20,15 +21,27 @@ import { ENV_ENTRY, FS_PREFIX, } from './constants' -import type { HookHandler, Plugin, PluginWithRequiredHook } from './plugin' import type { - BuildOptions, + HookHandler, + Plugin, + PluginOption, + PluginWithRequiredHook, +} from './plugin' +import type { + BuildEnvironmentOptions, + BuilderOptions, RenderBuiltAssetUrl, + ResolvedBuildEnvironmentOptions, ResolvedBuildOptions, + ResolvedBuilderOptions, } from './build' -import { resolveBuildOptions } from './build' +import { resolveBuildEnvironmentOptions, resolveBuilderOptions } from './build' import type { ResolvedServerOptions, ServerOptions } from './server' import { resolveServerOptions } from './server' +import { DevEnvironment } from './server/environment' +import { createNodeDevEnvironment } from './server/environments/nodeEnvironment' +import { createServerHotChannel } from './server/hmr' +import type { WebSocketServer } from './server/ws' import type { PreviewOptions, ResolvedPreviewOptions } from './preview' import { resolvePreviewOptions } from './preview' import { @@ -43,6 +56,7 @@ import { isBuiltin, isExternalUrl, isFilePathESM, + isInNodeModules, isNodeBuiltin, isObject, isParentDirectory, @@ -51,7 +65,6 @@ import { normalizeAlias, normalizePath, } from './utils' -import { getFsUtils } from './fsUtils' import { createPluginHookUtils, getHookHandler, @@ -59,19 +72,23 @@ import { resolvePlugins, } from './plugins' import type { ESBuildOptions } from './plugins/esbuild' -import type { InternalResolveOptions, ResolveOptions } from './plugins/resolve' -import { resolvePlugin, tryNodeResolve } from './plugins/resolve' +import type { + EnvironmentResolveOptions, + InternalResolveOptions, + ResolveOptions, +} from './plugins/resolve' +import { tryNodeResolve } from './plugins/resolve' import type { LogLevel, Logger } from './logger' import { createLogger } from './logger' -import type { DepOptimizationConfig, DepOptimizationOptions } from './optimizer' +import type { DepOptimizationOptions } from './optimizer' import type { JsonOptions } from './plugins/json' -import type { PluginContainer } from './server/pluginContainer' -import { createPluginContainer } from './server/pluginContainer' import type { PackageCache } from './packages' import { findNearestPackageData } from './packages' import { loadEnv, resolveEnvPrefix } from './env' import type { ResolvedSSROptions, SSROptions } from './ssr' import { resolveSSROptions } from './ssr' +import { PartialEnvironment } from './baseEnvironment' +import { createIdResolver } from './idResolver' const debug = createDebugger('vite:config') const promisifiedRealpath = promisify(fs.realpath) @@ -120,15 +137,153 @@ export function defineConfig(config: UserConfigExport): UserConfigExport { return config } -export type PluginOption = - | Plugin - | false - | null - | undefined - | PluginOption[] - | Promise +export interface CreateDevEnvironmentContext { + ws: WebSocketServer +} + +export interface DevEnvironmentOptions { + /** + * Files to be pre-transformed. Supports glob patterns. + */ + warmup?: string[] + /** + * Pre-transform known direct imports + * defaults to true for the client environment, false for the rest + */ + preTransformRequests?: boolean + /** + * Enables sourcemaps during dev + * @default { js: true } + * @experimental + */ + sourcemap?: boolean | { js?: boolean; css?: boolean } + /** + * Whether or not to ignore-list source files in the dev server sourcemap, used to populate + * the [`x_google_ignoreList` source map extension](https://developer.chrome.com/blog/devtools-better-angular-debugging/#the-x_google_ignorelist-source-map-extension). + * + * By default, it excludes all paths containing `node_modules`. You can pass `false` to + * disable this behavior, or, for full control, a function that takes the source path and + * sourcemap path and returns whether to ignore the source path. + */ + sourcemapIgnoreList?: + | false + | ((sourcePath: string, sourcemapPath: string) => boolean) + + /** + * Optimize deps config + */ + optimizeDeps?: DepOptimizationOptions + + /** + * create the Dev Environment instance + */ + createEnvironment?: ( + name: string, + config: ResolvedConfig, + context: CreateDevEnvironmentContext, + ) => Promise | DevEnvironment -export interface UserConfig { + /** + * For environments that support a full-reload, like the client, we can short-circuit when + * restarting the server throwing early to stop processing current files. We avoided this for + * SSR requests. Maybe this is no longer needed. + * @experimental + */ + recoverable?: boolean + + /** + * For environments associated with a module runner. + * By default it is true for the client environment and false for non-client environments. + * This option can also be used instead of the removed config.experimental.skipSsrTransform. + */ + moduleRunnerTransform?: boolean +} + +function defaultCreateClientDevEnvironment( + name: string, + config: ResolvedConfig, + context: CreateDevEnvironmentContext, +) { + return new DevEnvironment(name, config, { + hot: context.ws, + }) +} + +function defaultCreateSsrDevEnvironment( + name: string, + config: ResolvedConfig, +): DevEnvironment { + return createNodeDevEnvironment(name, config, { + hot: createServerHotChannel(), + }) +} + +function defaultCreateDevEnvironment(name: string, config: ResolvedConfig) { + return new DevEnvironment(name, config, { + hot: false, + }) +} + +export type ResolvedDevEnvironmentOptions = Required + +type AllResolveOptions = ResolveOptions & { + alias?: AliasOptions +} + +type ResolvedAllResolveOptions = Required & { alias: Alias[] } + +export interface SharedEnvironmentOptions { + /** + * Define global variable replacements. + * Entries will be defined on `window` during dev and replaced during build. + */ + define?: Record + /** + * Configure resolver + */ + resolve?: EnvironmentResolveOptions + /** + * Define if this environment is used for Server Side Rendering + * @default 'server' if it isn't the client environment + */ + consumer?: 'client' | 'server' + /** + * Runtime Compatibility + * Temporal options, we should remove these in favor of fine-grained control + */ + webCompatible?: boolean // was ssr.target === 'webworker' +} + +export interface EnvironmentOptions extends SharedEnvironmentOptions { + /** + * Dev specific options + */ + dev?: DevEnvironmentOptions + /** + * Build specific options + */ + build?: BuildEnvironmentOptions +} + +export type ResolvedResolveOptions = Required + +export type ResolvedEnvironmentOptions = { + define?: Record + resolve: ResolvedResolveOptions + consumer: 'client' | 'server' + webCompatible: boolean + dev: ResolvedDevEnvironmentOptions + build: ResolvedBuildEnvironmentOptions +} + +export type DefaultEnvironmentOptions = Omit< + EnvironmentOptions, + 'consumer' | 'webCompatible' | 'resolve' +> & { + resolve?: AllResolveOptions +} + +export interface UserConfig extends DefaultEnvironmentOptions { /** * Project root directory. Can be an absolute path, or a path relative from * the location of the config file itself. @@ -164,19 +319,10 @@ export interface UserConfig { * each command, and can be overridden by the command line --mode option. */ mode?: string - /** - * Define global variable replacements. - * Entries will be defined on `window` during dev and replaced during build. - */ - define?: Record /** * Array of vite plugins to use. */ plugins?: PluginOption[] - /** - * Configure resolver - */ - resolve?: ResolveOptions & { alias?: AliasOptions } /** * HTML related options */ @@ -199,25 +345,17 @@ export interface UserConfig { */ assetsInclude?: string | RegExp | (string | RegExp)[] /** - * Server specific options, e.g. host, port, https... + * Builder specific options */ - server?: ServerOptions + builder?: BuilderOptions /** - * Build specific options + * Server specific options, e.g. host, port, https... */ - build?: BuildOptions + server?: ServerOptions /** * Preview specific options, e.g. host, port, https... */ preview?: PreviewOptions - /** - * Dep optimization options - */ - optimizeDeps?: DepOptimizationOptions - /** - * SSR specific options - */ - ssr?: SSROptions /** * Experimental features * @@ -226,6 +364,10 @@ export interface UserConfig { * @experimental */ experimental?: ExperimentalOptions + /** + * Options to opt-in to future behavior + */ + future?: FutureOptions /** * Legacy options * @@ -280,6 +422,20 @@ export interface UserConfig { 'plugins' | 'input' | 'onwarn' | 'preserveEntrySignatures' > } + /** + * Dep optimization options + */ + optimizeDeps?: DepOptimizationOptions + /** + * SSR specific options + * We could make SSROptions be a EnvironmentOptions if we can abstract + * external/noExternal for environments in general. + */ + ssr?: SSROptions + /** + * Environment overrides + */ + environments?: Record /** * Whether your application is a Single Page Application (SPA), * a Multi-Page Application (MPA), or Custom Application (SSR @@ -298,6 +454,17 @@ export interface HTMLOptions { cspNonce?: string } +export interface FutureOptions { + removePluginHookHandleHotUpdate?: 'warn' + removePluginHookSsrArgument?: 'warn' + + removeServerModuleGraph?: 'warn' + removeServerHot?: 'warn' + removeServerTransformRequest?: 'warn' + + removeSsrLoadModule?: 'warn' +} + export interface ExperimentalOptions { /** * Append fake `&lang.(ext)` when queries are specified, to preserve the file extension for following plugins to process. @@ -345,7 +512,9 @@ export interface LegacyOptions { export interface ResolvedWorkerOptions { format: 'es' | 'iife' - plugins: (bundleChain: string[]) => Promise + plugins: ( + bundleChain: string[], + ) => Promise<{ plugins: Plugin[]; config: ResolvedConfig }> rollupOptions: RollupOptions } @@ -357,7 +526,14 @@ export interface InlineConfig extends UserConfig { export type ResolvedConfig = Readonly< Omit< UserConfig, - 'plugins' | 'css' | 'assetsInclude' | 'optimizeDeps' | 'worker' | 'build' + | 'plugins' + | 'css' + | 'assetsInclude' + | 'optimizeDeps' + | 'worker' + | 'build' + | 'dev' + | 'environments' > & { configFile: string | undefined configFileDependencies: string[] @@ -388,6 +564,8 @@ export type ResolvedConfig = Readonly< css: ResolvedCSSOptions esbuild: ESBuildOptions | false server: ResolvedServerOptions + dev: ResolvedDevEnvironmentOptions + builder: ResolvedBuilderOptions build: ResolvedBuildOptions preview: ResolvedPreviewOptions ssr: ResolvedSSROptions @@ -400,9 +578,114 @@ export type ResolvedConfig = Readonly< worker: ResolvedWorkerOptions appType: AppType experimental: ExperimentalOptions + environments: Record + /** @internal */ + fsDenyGlob: AnymatchFn + /** @internal */ + safeModulePaths: Set } & PluginHookUtils > +export function resolveDevEnvironmentOptions( + dev: DevEnvironmentOptions | undefined, + preserverSymlinks: boolean, + environmentName: string | undefined, + consumer: 'client' | 'server' | undefined, + // Backward compatibility + skipSsrTransform?: boolean, +): ResolvedDevEnvironmentOptions { + return { + sourcemap: dev?.sourcemap ?? { js: true }, + sourcemapIgnoreList: + dev?.sourcemapIgnoreList === false + ? () => false + : dev?.sourcemapIgnoreList || isInNodeModules, + preTransformRequests: dev?.preTransformRequests ?? consumer === 'client', + warmup: dev?.warmup ?? [], + optimizeDeps: resolveDepOptimizationOptions( + dev?.optimizeDeps, + preserverSymlinks, + consumer, + ), + createEnvironment: + dev?.createEnvironment ?? + (environmentName === 'client' + ? defaultCreateClientDevEnvironment + : environmentName === 'ssr' + ? defaultCreateSsrDevEnvironment + : defaultCreateDevEnvironment), + recoverable: dev?.recoverable ?? consumer === 'client', + moduleRunnerTransform: + dev?.moduleRunnerTransform ?? + (skipSsrTransform !== undefined && consumer === 'server' + ? skipSsrTransform + : consumer === 'server'), + } +} + +function resolveEnvironmentOptions( + options: EnvironmentOptions, + resolvedRoot: string, + alias: Alias[], + preserveSymlinks: boolean, + logger: Logger, + environmentName: string, + // Backward compatibility + skipSsrTransform?: boolean, +): ResolvedEnvironmentOptions { + const resolve = resolveEnvironmentResolveOptions( + options.resolve, + alias, + preserveSymlinks, + logger, + ) + const isClientEnvironment = environmentName === 'client' + const consumer = + (options.consumer ?? isClientEnvironment) ? 'client' : 'server' + return { + resolve, + consumer, + webCompatible: options.webCompatible ?? consumer === 'client', + dev: resolveDevEnvironmentOptions( + options.dev, + resolve.preserveSymlinks, + environmentName, + consumer, + skipSsrTransform, + ), + build: resolveBuildEnvironmentOptions( + options.build ?? {}, + logger, + resolvedRoot, + consumer, + ), + } +} + +export function getDefaultEnvironmentOptions( + config: UserConfig, +): EnvironmentOptions { + return { + define: config.define, + resolve: config.resolve, + dev: config.dev, + build: config.build, + } +} + +export function getDefaultResolvedEnvironmentOptions( + config: ResolvedConfig, +): ResolvedEnvironmentOptions { + return { + define: config.define, + resolve: config.resolve, + consumer: 'server', + webCompatible: false, + dev: config.dev, + build: config.build, + } +} + export interface PluginHookUtils { getSortedPlugins: ( hookName: K, @@ -447,12 +730,104 @@ function checkBadCharactersInPath(path: string, logger: Logger): void { } } +const clientAlias = [ + { + find: /^\/?@vite\/env/, + replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)), + }, + { + find: /^\/?@vite\/client/, + replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)), + }, +] + +/** + * alias and preserveSymlinks are not per-environment options, but they are + * included in the resolved environment options for convenience. + */ +function resolveEnvironmentResolveOptions( + resolve: EnvironmentResolveOptions | undefined, + alias: Alias[], + preserveSymlinks: boolean, + logger: Logger, +): ResolvedAllResolveOptions { + const resolvedResolve: ResolvedAllResolveOptions = { + mainFields: resolve?.mainFields ?? DEFAULT_MAIN_FIELDS, + conditions: resolve?.conditions ?? [], + externalConditions: resolve?.externalConditions ?? [], + external: resolve?.external ?? [], + noExternal: resolve?.noExternal ?? [], + extensions: resolve?.extensions ?? DEFAULT_EXTENSIONS, + dedupe: resolve?.dedupe ?? [], + preserveSymlinks, + alias, + } + + if ( + // @ts-expect-error removed field + resolve?.browserField === false && + resolvedResolve.mainFields.includes('browser') + ) { + logger.warn( + colors.yellow( + `\`resolve.browserField\` is set to false, but the option is removed in favour of ` + + `the 'browser' string in \`resolve.mainFields\`. You may want to update \`resolve.mainFields\` ` + + `to remove the 'browser' string and preserve the previous browser behaviour.`, + ), + ) + } + return resolvedResolve +} + +function resolveResolveOptions( + resolve: AllResolveOptions | undefined, + logger: Logger, +): ResolvedAllResolveOptions { + // resolve alias with internal client alias + const alias = normalizeAlias(mergeAlias(clientAlias, resolve?.alias || [])) + const preserveSymlinks = resolve?.preserveSymlinks ?? false + return resolveEnvironmentResolveOptions( + resolve, + alias, + preserveSymlinks, + logger, + ) +} + +// TODO: Introduce ResolvedDepOptimizationOptions +function resolveDepOptimizationOptions( + optimizeDeps: DepOptimizationOptions | undefined, + preserveSymlinks: boolean, + consumer: 'client' | 'server' | undefined, +): DepOptimizationOptions { + optimizeDeps ??= {} + return { + include: optimizeDeps.include ?? [], + exclude: optimizeDeps.exclude ?? [], + needsInterop: optimizeDeps.needsInterop ?? [], + extensions: optimizeDeps.extensions ?? [], + noDiscovery: optimizeDeps.noDiscovery ?? consumer !== 'client', + holdUntilCrawlEnd: optimizeDeps.holdUntilCrawlEnd ?? true, + esbuildOptions: { + preserveSymlinks, + ...optimizeDeps.esbuildOptions, + }, + disabled: optimizeDeps.disabled, + entries: optimizeDeps.entries, + force: optimizeDeps.force ?? false, + } +} + export async function resolveConfig( inlineConfig: InlineConfig, command: 'build' | 'serve', defaultMode = 'development', defaultNodeEnv = 'development', isPreview = false, + /** @internal */ + patchConfig: ((config: ResolvedConfig) => void) | undefined = undefined, + /** @internal */ + patchPlugins: ((resolvedPlugins: Plugin[]) => void) | undefined = undefined, ): Promise { let config = inlineConfig let configFileDependencies: string[] = [] @@ -506,17 +881,37 @@ export async function resolveConfig( } // resolve plugins - const rawUserPlugins = ( + const rawPlugins = ( (await asyncFlatten(config.plugins || [])) as Plugin[] ).filter(filterPlugin) - const [prePlugins, normalPlugins, postPlugins] = - sortUserPlugins(rawUserPlugins) + const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawPlugins) + + const isBuild = command === 'build' // run config hooks const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins] config = await runConfigHook(config, userPlugins, configEnv) + // Ensure default client and ssr environments + // If there are present, ensure order { client, ssr, ...custom } + config.environments ??= {} + if ( + !config.environments.ssr && + (!isBuild || config.ssr || config.build?.ssr) + ) { + // During dev, the ssr environment is always available even if it isn't configure + // There is no perf hit, because the optimizer is initialized only if ssrLoadModule + // is called. + // During build, we only build the ssr environment if it is configured + // through the deprecated ssr top level options or if it is explicitly defined + // in the environments config + config.environments = { ssr: {}, ...config.environments } + } + if (!config.environments.client) { + config.environments = { client: {}, ...config.environments } + } + // Define logger const logger = createLogger(config.logLevel, { allowClearScreen: config.clearScreen, @@ -530,45 +925,131 @@ export async function resolveConfig( checkBadCharactersInPath(resolvedRoot, logger) - const clientAlias = [ - { - find: /^\/?@vite\/env/, - replacement: path.posix.join(FS_PREFIX, normalizePath(ENV_ENTRY)), - }, - { - find: /^\/?@vite\/client/, - replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)), - }, - ] - - // resolve alias with internal client alias - const resolvedAlias = normalizeAlias( - mergeAlias(clientAlias, config.resolve?.alias || []), + // Backward compatibility: merge optimizeDeps into environments.client.dev.optimizeDeps as defaults + const configEnvironmentsClient = config.environments!.client! + configEnvironmentsClient.dev ??= {} + configEnvironmentsClient.dev.optimizeDeps = mergeConfig( + config.optimizeDeps ?? {}, + configEnvironmentsClient.dev.optimizeDeps ?? {}, ) - const resolveOptions: ResolvedConfig['resolve'] = { - mainFields: config.resolve?.mainFields ?? DEFAULT_MAIN_FIELDS, - conditions: config.resolve?.conditions ?? [], - extensions: config.resolve?.extensions ?? DEFAULT_EXTENSIONS, - dedupe: config.resolve?.dedupe ?? [], - preserveSymlinks: config.resolve?.preserveSymlinks ?? false, - alias: resolvedAlias, + const deprecatedSsrOptimizeDepsConfig = config.ssr?.optimizeDeps ?? {} + let configEnvironmentsSsr = config.environments!.ssr + + // Backward compatibility: server.warmup.clientFiles/ssrFiles -> environment.dev.warmup + const warmupOptions = config.server?.warmup + if (warmupOptions?.clientFiles) { + configEnvironmentsClient.dev.warmup = warmupOptions?.clientFiles + } + if (warmupOptions?.ssrFiles) { + configEnvironmentsSsr ??= {} + configEnvironmentsSsr.dev ??= {} + configEnvironmentsSsr.dev.warmup = warmupOptions?.ssrFiles + } + + // Backward compatibility: merge ssr into environments.ssr.config as defaults + if (configEnvironmentsSsr) { + configEnvironmentsSsr.dev ??= {} + configEnvironmentsSsr.dev.optimizeDeps = mergeConfig( + deprecatedSsrOptimizeDepsConfig, + configEnvironmentsSsr.dev.optimizeDeps ?? {}, + ) + + configEnvironmentsSsr.resolve ??= {} + configEnvironmentsSsr.resolve.conditions ??= config.ssr?.resolve?.conditions + configEnvironmentsSsr.resolve.externalConditions ??= + config.ssr?.resolve?.externalConditions + configEnvironmentsSsr.resolve.external ??= config.ssr?.external + configEnvironmentsSsr.resolve.noExternal ??= config.ssr?.noExternal + + if (config.ssr?.target === 'webworker') { + configEnvironmentsSsr.webCompatible = true + } } + if (config.build?.ssrEmitAssets !== undefined) { + configEnvironmentsSsr ??= {} + configEnvironmentsSsr.build ??= {} + configEnvironmentsSsr.build.emitAssets = config.build.ssrEmitAssets + } + + // The client and ssr environment configs can't be removed by the user in the config hook if ( - // @ts-expect-error removed field - config.resolve?.browserField === false && - resolveOptions.mainFields.includes('browser') + !config.environments || + !config.environments.client || + (!config.environments.ssr && !isBuild) ) { - logger.warn( - colors.yellow( - `\`resolve.browserField\` is set to false, but the option is removed in favour of ` + - `the 'browser' string in \`resolve.mainFields\`. You may want to update \`resolve.mainFields\` ` + - `to remove the 'browser' string and preserve the previous browser behaviour.`, - ), + throw new Error( + 'Required environments configuration were stripped out in the config hook', + ) + } + + // Merge default environment config values + const defaultEnvironmentOptions = getDefaultEnvironmentOptions(config) + for (const name of Object.keys(config.environments)) { + config.environments[name] = mergeConfig( + defaultEnvironmentOptions, + config.environments[name], + ) + } + + await runConfigEnvironmentHook(config.environments, userPlugins, configEnv) + + const resolvedDefaultResolve = resolveResolveOptions(config.resolve, logger) + + const resolvedEnvironments: Record = {} + for (const environmentName of Object.keys(config.environments)) { + resolvedEnvironments[environmentName] = resolveEnvironmentOptions( + config.environments[environmentName], + resolvedRoot, + resolvedDefaultResolve.alias, + resolvedDefaultResolve.preserveSymlinks, + logger, + environmentName, + config.experimental?.skipSsrTransform, ) } + // Backward compatibility: merge environments.client.dev.optimizeDeps back into optimizeDeps + // The same object is assigned back for backward compatibility. The ecosystem is modifying + // optimizeDeps in the ResolvedConfig hook, so these changes will be reflected on the + // client environment. + const backwardCompatibleOptimizeDeps = + resolvedEnvironments.client.dev.optimizeDeps + + const resolvedDevEnvironmentOptions = resolveDevEnvironmentOptions( + config.dev, + resolvedDefaultResolve.preserveSymlinks, + // default environment options + undefined, + undefined, + ) + + const resolvedBuildOptions = resolveBuildEnvironmentOptions( + config.build ?? {}, + logger, + resolvedRoot, + undefined, + ) + + // Backward compatibility: merge config.environments.ssr back into config.ssr + // so ecosystem SSR plugins continue to work if only environments.ssr is configured + const patchedConfigSsr = { + ...config.ssr, + external: resolvedEnvironments.ssr?.resolve.external, + noExternal: resolvedEnvironments.ssr?.resolve.noExternal, + optimizeDeps: resolvedEnvironments.ssr?.dev?.optimizeDeps, + resolve: { + ...config.ssr?.resolve, + conditions: resolvedEnvironments.ssr?.resolve.conditions, + externalConditions: resolvedEnvironments.ssr?.resolve.externalConditions, + }, + } + const ssr = resolveSSROptions( + patchedConfigSsr, + resolvedDefaultResolve.preserveSymlinks, + ) + // load .env files const envDir = config.envDir ? normalizePath(path.resolve(resolvedRoot, config.envDir)) @@ -597,7 +1078,6 @@ export async function resolveConfig( const isProduction = process.env.NODE_ENV === 'production' // resolve public base url - const isBuild = command === 'build' const relativeBaseShortcut = config.base === '' || config.base === './' // During dev, we ignore relative base and fallback to '/' @@ -609,12 +1089,6 @@ export async function resolveConfig( : './' : (resolveBaseUrl(config.base, isBuild, logger) ?? '/') - const resolvedBuildOptions = resolveBuildOptions( - config.build, - logger, - resolvedRoot, - ) - // resolve cache directory const pkgDir = findNearestPackageData(resolvedRoot, packageCache)?.dir const cacheDir = normalizePath( @@ -631,52 +1105,6 @@ export async function resolveConfig( ? createFilter(config.assetsInclude) : () => false - // create an internal resolver to be used in special scenarios, e.g. - // optimizer & handling css @imports - const createResolver: ResolvedConfig['createResolver'] = (options) => { - let aliasContainer: PluginContainer | undefined - let resolverContainer: PluginContainer | undefined - return async (id, importer, aliasOnly, ssr) => { - let container: PluginContainer - if (aliasOnly) { - container = - aliasContainer || - (aliasContainer = await createPluginContainer({ - ...resolved, - plugins: [aliasPlugin({ entries: resolved.resolve.alias })], - })) - } else { - container = - resolverContainer || - (resolverContainer = await createPluginContainer({ - ...resolved, - plugins: [ - aliasPlugin({ entries: resolved.resolve.alias }), - resolvePlugin({ - ...resolved.resolve, - root: resolvedRoot, - isProduction, - isBuild: command === 'build', - ssrConfig: resolved.ssr, - asSrc: true, - preferRelative: false, - tryIndex: true, - ...options, - idOnly: true, - fsUtils: getFsUtils(resolved), - }), - ], - })) - } - return ( - await container.resolveId(id, importer, { - ssr, - scan: options?.scan, - }) - )?.id - } - } - const { publicDir } = config const resolvedPublicDir = publicDir !== false && publicDir !== '' @@ -689,9 +1117,8 @@ export async function resolveConfig( : '' const server = resolveServerOptions(resolvedRoot, config.server, logger) - const ssr = resolveSSROptions(config.ssr, resolveOptions.preserveSymlinks) - const optimizeDeps = config.optimizeDeps || {} + const builder = resolveBuilderOptions(config.builder) const BASE_URL = resolvedBase @@ -742,12 +1169,12 @@ export async function resolveConfig( mainConfig: resolved, bundleChain, } - const resolvedWorkerPlugins = await resolvePlugins( + const resolvedWorkerPlugins = (await resolvePlugins( workerResolved, workerPrePlugins, workerNormalPlugins, workerPostPlugins, - ) + )) as Plugin[] // run configResolved hooks await Promise.all( @@ -756,7 +1183,7 @@ export async function resolveConfig( .map((hook) => hook(workerResolved)), ) - return resolvedWorkerPlugins + return { plugins: resolvedWorkerPlugins, config: workerResolved } } const resolvedWorkerOptions: ResolvedWorkerOptions = { @@ -777,17 +1204,15 @@ export async function resolveConfig( base, decodedBase: decodeURI(base), rawBase: resolvedBase, - resolve: resolveOptions, publicDir: resolvedPublicDir, cacheDir, command, mode, - ssr, isWorker: false, mainConfig: null, bundleChain: [], isProduction, - plugins: userPlugins, + plugins: userPlugins, // placeholder to be replaced css: resolveCSSOptions(config.css), esbuild: config.esbuild === false @@ -797,7 +1222,7 @@ export async function resolveConfig( ...config.esbuild, }, server, - build: resolvedBuildOptions, + builder, preview: resolvePreviewOptions(config.preview, server), envDir, env: { @@ -812,15 +1237,6 @@ export async function resolveConfig( }, logger, packageCache, - createResolver, - optimizeDeps: { - holdUntilCrawlEnd: true, - ...optimizeDeps, - esbuildOptions: { - preserveSymlinks: resolveOptions.preserveSymlinks, - ...optimizeDeps.esbuildOptions, - }, - }, worker: resolvedWorkerOptions, appType: config.appType ?? 'spa', experimental: { @@ -828,19 +1244,83 @@ export async function resolveConfig( hmrPartialAccept: false, ...config.experimental, }, + future: config.future, + + // Backward compatibility, users should use environment.config.dev.optimizeDeps + optimizeDeps: backwardCompatibleOptimizeDeps, + ssr, + + resolve: resolvedDefaultResolve, + dev: resolvedDevEnvironmentOptions, + build: resolvedBuildOptions, + + environments: resolvedEnvironments, + getSortedPlugins: undefined!, getSortedPluginHooks: undefined!, + + /** + * createResolver is deprecated. It only works for the client and ssr + * environments. The `aliasOnly` option is also not being used any more + * Plugins should move to createIdResolver(environment) instead. + * create an internal resolver to be used in special scenarios, e.g. + * optimizer & handling css @imports + */ + createResolver(options) { + const resolve = createIdResolver(this, options) + const clientEnvironment = new PartialEnvironment('client', this) + let ssrEnvironment: PartialEnvironment | undefined + return async (id, importer, aliasOnly, ssr) => { + if (ssr) { + ssrEnvironment ??= new PartialEnvironment('ssr', this) + } + return await resolve( + ssr ? ssrEnvironment! : clientEnvironment, + id, + importer, + aliasOnly, + ) + } + }, + fsDenyGlob: picomatch( + // matchBase: true does not work as it's documented + // https://github.com/micromatch/picomatch/issues/89 + // convert patterns without `/` on our side for now + server.fs.deny.map((pattern) => + pattern.includes('/') ? pattern : `**/${pattern}`, + ), + { + matchBase: false, + nocase: true, + dot: true, + }, + ), + safeModulePaths: new Set(), } resolved = { ...config, ...resolved, } - ;(resolved.plugins as Plugin[]) = await resolvePlugins( + + // Backward compatibility hook, modify the resolved config before it is used + // to create internal plugins. For example, `config.build.ssr`. Once we rework + // internal plugins to use environment.config, we can remove the dual + // patchConfig/patchPlugins and have a single patchConfig before configResolved + // gets called + patchConfig?.(resolved) + + const resolvedPlugins = await resolvePlugins( resolved, prePlugins, normalPlugins, postPlugins, ) + + // Backward compatibility hook used in builder, opt-in to shared plugins during build + patchPlugins?.(resolvedPlugins) + ;(resolved.plugins as Plugin[]) = resolvedPlugins + + // TODO: Deprecate config.getSortedPlugins and config.getSortedPluginHooks Object.assign(resolved, createPluginHookUtils(resolved.plugins)) // call configResolved hooks @@ -857,6 +1337,13 @@ export async function resolveConfig( 'ssr.', ) + // For backward compat, set ssr environment build.emitAssets with the same value as build.ssrEmitAssets that might be changed in configResolved hook + // https://github.com/vikejs/vike/blob/953614cea7b418fcc0309b5c918491889fdec90a/vike/node/plugin/plugins/buildConfig.ts#L67 + if (resolved.environments.ssr) { + resolved.environments.ssr.build.emitAssets = + resolved.build.ssrEmitAssets || resolved.build.emitAssets + } + debug?.(`using resolved config: %O`, { ...resolved, plugins: resolved.plugins.map((p) => p.name), @@ -868,20 +1355,6 @@ export async function resolveConfig( // validate config - if ( - config.build?.terserOptions && - config.build.minify && - config.build.minify !== 'terser' - ) { - logger.warn( - colors.yellow( - `build.terserOptions is specified but build.minify is not set to use Terser. ` + - `Note Vite now defaults to use esbuild for minification. If you still ` + - `prefer Terser, set build.minify to "terser".`, - ), - ) - } - // Check if all assetFileNames have the same reference. // If not, display a warn for user. const outputOption = config.build?.rollupOptions?.output ?? [] @@ -1103,26 +1576,25 @@ async function bundleConfigFile( importer: string, isRequire: boolean, ) => { - return tryNodeResolve( - id, - importer, - { - root: path.dirname(fileName), - isBuild: true, - isProduction: true, - preferRelative: false, - tryIndex: true, - mainFields: [], - conditions: [], - overrideConditions: ['node'], - dedupe: [], - extensions: DEFAULT_EXTENSIONS, - preserveSymlinks: false, - packageCache, - isRequire, - }, - false, - )?.id + return tryNodeResolve(id, importer, { + root: path.dirname(fileName), + isBuild: true, + isProduction: true, + preferRelative: false, + tryIndex: true, + mainFields: [], + conditions: [], + externalConditions: [], + external: [], + noExternal: [], + overrideConditions: ['node'], + dedupe: [], + extensions: DEFAULT_EXTENSIONS, + preserveSymlinks: false, + packageCache, + isRequire, + webCompatible: false, + })?.id } // externalize bare imports @@ -1292,23 +1764,29 @@ async function runConfigHook( return conf } -export function getDepOptimizationConfig( - config: ResolvedConfig, - ssr: boolean, -): DepOptimizationConfig { - return ssr ? config.ssr.optimizeDeps : config.optimizeDeps -} -export function isDepsOptimizerEnabled( - config: ResolvedConfig, - ssr: boolean, -): boolean { - const optimizeDeps = getDepOptimizationConfig(config, ssr) - return !(optimizeDeps.noDiscovery && !optimizeDeps.include?.length) +async function runConfigEnvironmentHook( + environments: Record, + plugins: Plugin[], + configEnv: ConfigEnv, +): Promise { + const environmentNames = Object.keys(environments) + for (const p of getSortedPluginsByHook('configEnvironment', plugins)) { + const hook = p.configEnvironment + const handler = getHookHandler(hook) + if (handler) { + for (const name of environmentNames) { + const res = await handler(name, environments[name], configEnv) + if (res) { + environments[name] = mergeConfig(environments[name], res) + } + } + } + } } function optimizeDepsDisabledBackwardCompatibility( resolved: ResolvedConfig, - optimizeDeps: DepOptimizationConfig, + optimizeDeps: DepOptimizationOptions, optimizeDepsPath: string = '', ) { const optimizeDepsDisabled = optimizeDeps.disabled diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 1532c4612930eb..a547793f0239e5 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -1,11 +1,40 @@ import path, { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import type { RollupPluginHooks } from './typeUtils' const { version } = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url)).toString(), ) +export const ROLLUP_HOOKS = [ + 'buildStart', + 'buildEnd', + 'renderStart', + 'renderError', + 'renderChunk', + 'writeBundle', + 'generateBundle', + 'banner', + 'footer', + 'augmentChunkHash', + 'outputOptions', + 'renderDynamicImport', + 'resolveFileUrl', + 'resolveImportMeta', + 'intro', + 'outro', + 'closeBundle', + 'closeWatcher', + 'load', + 'moduleParsed', + 'watchChange', + 'resolveDynamicImport', + 'resolveId', + 'shouldTransformCachedModule', + 'transform', +] satisfies RollupPluginHooks[] + export const VERSION = version as string export const DEFAULT_MAIN_FIELDS = [ diff --git a/packages/vite/src/node/deprecations.ts b/packages/vite/src/node/deprecations.ts new file mode 100644 index 00000000000000..568240c5545b65 --- /dev/null +++ b/packages/vite/src/node/deprecations.ts @@ -0,0 +1,90 @@ +import colors from 'picocolors' +import type { FutureOptions, ResolvedConfig } from './config' + +// TODO: switch to production docs URL +const docsURL = 'https://deploy-preview-16471--vite-docs-main.netlify.app' + +const deprecationCode = { + removePluginHookSsrArgument: 'changes/this-environment-in-hooks', + removePluginHookHandleHotUpdate: 'changes/hotupdate-hook', + + removeServerModuleGraph: 'changes/per-environment-apis', + removeServerHot: 'changes/per-environment-apis', + removeServerTransformRequest: 'changes/per-environment-apis', + + removeSsrLoadModule: 'changes/ssr-using-modulerunner', +} satisfies Record + +const deprecationMessages = { + removePluginHookSsrArgument: + "Plugin hook `options.ssr` is replaced with `this.environment.config.consumer === 'server'`.", + removePluginHookHandleHotUpdate: + 'Plugin hook `handleHotUpdate()` is replaced with `hotUpdate()`.', + + removeServerModuleGraph: + 'The `server.moduleGraph` is replaced with `this.environment.moduleGraph`.', + removeServerHot: 'The `server.hot` is replaced with `this.environment.hot`.', + removeServerTransformRequest: + 'The `server.transformRequest` is replaced with `this.environment.transformRequest`.', + + removeSsrLoadModule: + 'The `server.ssrLoadModule` is replaced with Environment Runner.', +} satisfies Record + +let _ignoreDeprecationWarnings = false + +// Later we could have a `warnDeprecation` utils when the deprecation is landed +/** + * Warn about future deprecations. + */ +export function warnFutureDeprecation( + config: ResolvedConfig, + type: keyof FutureOptions, + extraMessage?: string, + stacktrace = true, +): void { + if ( + _ignoreDeprecationWarnings || + !config.future || + config.future[type] !== 'warn' + ) + return + + let msg = `[vite future] ${deprecationMessages[type]}` + if (extraMessage) { + msg += ` ${extraMessage}` + } + msg = colors.yellow(msg) + + const docs = `${docsURL}/changes/${deprecationCode[type].toLowerCase()}` + msg += + colors.gray(`\n ${stacktrace ? '├' : '└'}─── `) + + colors.underline(docs) + + '\n' + + if (stacktrace) { + const stack = new Error().stack + if (stack) { + let stacks = stack + .split('\n') + .slice(3) + .filter((i) => !i.includes('/node_modules/vite/dist/')) + if (stacks.length === 0) { + stacks.push('No stack trace found.') + } + stacks = stacks.map( + (i, idx) => ` ${idx === stacks.length - 1 ? '└' : '│'} ${i.trim()}`, + ) + msg += colors.dim(stacks.join('\n')) + '\n' + } + } + config.logger.warnOnce(msg) +} + +export function ignoreDeprecationWarnings(fn: () => T): T { + const before = _ignoreDeprecationWarnings + _ignoreDeprecationWarnings = true + const ret = fn() + _ignoreDeprecationWarnings = before + return ret +} diff --git a/packages/vite/src/node/environment.ts b/packages/vite/src/node/environment.ts new file mode 100644 index 00000000000000..f7533675d23562 --- /dev/null +++ b/packages/vite/src/node/environment.ts @@ -0,0 +1,31 @@ +import type { DevEnvironment } from './server/environment' +import type { BuildEnvironment } from './build' +import type { ScanEnvironment } from './optimizer/scan' +import type { UnknownEnvironment } from './baseEnvironment' +import type { PluginContext } from './plugin' + +export type Environment = + | DevEnvironment + | BuildEnvironment + | /** @internal */ ScanEnvironment + | UnknownEnvironment + +/** + * Creates a function that hides the complexities of a WeakMap with an initial value + * to implement object metadata. Used by plugins to implement cross hooks per + * environment metadata + */ +export function usePerEnvironmentState( + initial: (environment: Environment) => State, +): (context: PluginContext) => State { + const stateMap = new WeakMap() + return function (context: PluginContext) { + const { environment } = context + let state = stateMap.get(environment) + if (!state) { + state = initial(environment) + stateMap.set(environment, state) + } + return state + } +} diff --git a/packages/vite/src/node/ssr/ssrExternal.ts b/packages/vite/src/node/external.ts similarity index 57% rename from packages/vite/src/node/ssr/ssrExternal.ts rename to packages/vite/src/node/external.ts index 5681e000502a5f..6e386dcccac736 100644 --- a/packages/vite/src/node/ssr/ssrExternal.ts +++ b/packages/vite/src/node/external.ts @@ -1,53 +1,74 @@ import path from 'node:path' -import type { InternalResolveOptions } from '../plugins/resolve' -import { tryNodeResolve } from '../plugins/resolve' +import type { InternalResolveOptions } from './plugins/resolve' +import { tryNodeResolve } from './plugins/resolve' import { bareImportRE, createDebugger, createFilter, getNpmPackageName, isBuiltin, -} from '../utils' -import type { ResolvedConfig } from '..' +} from './utils' +import type { Environment } from './environment' +import type { PartialEnvironment } from './baseEnvironment' -const debug = createDebugger('vite:ssr-external') +const debug = createDebugger('vite:external') -const isSsrExternalCache = new WeakMap< - ResolvedConfig, +const isExternalCache = new WeakMap< + Environment, (id: string, importer?: string) => boolean | undefined >() -export function shouldExternalizeForSSR( +export function shouldExternalize( + environment: Environment, id: string, importer: string | undefined, - config: ResolvedConfig, ): boolean | undefined { - let isSsrExternal = isSsrExternalCache.get(config) - if (!isSsrExternal) { - isSsrExternal = createIsSsrExternal(config) - isSsrExternalCache.set(config, isSsrExternal) + let isExternal = isExternalCache.get(environment) + if (!isExternal) { + isExternal = createIsExternal(environment) + isExternalCache.set(environment, isExternal) } - return isSsrExternal(id, importer) + return isExternal(id, importer) } -export function createIsConfiguredAsSsrExternal( - config: ResolvedConfig, +const isConfiguredAsExternalCache = new WeakMap< + Environment, + (id: string, importer?: string) => boolean +>() + +export function isConfiguredAsExternal( + environment: Environment, + id: string, + importer?: string, +): boolean { + let isExternal = isConfiguredAsExternalCache.get(environment) + if (!isExternal) { + isExternal = createIsConfiguredAsExternal(environment) + isConfiguredAsExternalCache.set(environment, isExternal) + } + return isExternal(id, importer) +} + +export function createIsConfiguredAsExternal( + environment: PartialEnvironment, ): (id: string, importer?: string) => boolean { - const { ssr, root } = config - const noExternal = ssr?.noExternal + const { config } = environment + const { root, resolve, webCompatible } = config + const { external, noExternal } = resolve const noExternalFilter = - noExternal !== 'undefined' && typeof noExternal !== 'boolean' && + !(Array.isArray(noExternal) && noExternal.length === 0) && createFilter(undefined, noExternal, { resolve: false }) - const targetConditions = config.ssr.resolve?.externalConditions || [] + const targetConditions = resolve.externalConditions || [] const resolveOptions: InternalResolveOptions = { - ...config.resolve, + ...resolve, root, isProduction: false, isBuild: true, conditions: targetConditions, + webCompatible, } const isExternalizable = ( @@ -65,7 +86,6 @@ export function createIsConfiguredAsSsrExternal( // unresolvable from root (which would be unresolvable from output bundles also) config.command === 'build' ? undefined : importer, resolveOptions, - ssr?.target === 'webworker', undefined, true, // try to externalize, will return undefined or an object without @@ -89,9 +109,9 @@ export function createIsConfiguredAsSsrExternal( return (id: string, importer?: string) => { if ( // If this id is defined as external, force it as external - // Note that individual package entries are allowed in ssr.external - ssr.external !== true && - ssr.external?.includes(id) + // Note that individual package entries are allowed in `external` + external !== true && + external.includes(id) ) { return true } @@ -102,8 +122,8 @@ export function createIsConfiguredAsSsrExternal( if ( // A package name in ssr.external externalizes every // externalizable package entry - ssr.external !== true && - ssr.external?.includes(pkgName) + external !== true && + external.includes(pkgName) ) { return isExternalizable(id, importer, true) } @@ -113,28 +133,28 @@ export function createIsConfiguredAsSsrExternal( if (noExternalFilter && !noExternalFilter(pkgName)) { return false } - // If `ssr.external: true`, all will be externalized by default, regardless if + // If external is true, all will be externalized by default, regardless if // it's a linked package - return isExternalizable(id, importer, ssr.external === true) + return isExternalizable(id, importer, external === true) } } -function createIsSsrExternal( - config: ResolvedConfig, +function createIsExternal( + environment: Environment, ): (id: string, importer?: string) => boolean | undefined { const processedIds = new Map() - const isConfiguredAsExternal = createIsConfiguredAsSsrExternal(config) + const isConfiguredAsExternal = createIsConfiguredAsExternal(environment) return (id: string, importer?: string) => { if (processedIds.has(id)) { return processedIds.get(id) } - let external = false + let isExternal = false if (id[0] !== '.' && !path.isAbsolute(id)) { - external = isBuiltin(id) || isConfiguredAsExternal(id, importer) + isExternal = isBuiltin(id) || isConfiguredAsExternal(id, importer) } - processedIds.set(id, external) - return external + processedIds.set(id, isExternal) + return isExternal } } diff --git a/packages/vite/src/node/idResolver.ts b/packages/vite/src/node/idResolver.ts new file mode 100644 index 00000000000000..5923e985a844cc --- /dev/null +++ b/packages/vite/src/node/idResolver.ts @@ -0,0 +1,110 @@ +import type { PartialResolvedId } from 'rollup' +import aliasPlugin from '@rollup/plugin-alias' +import type { ResolvedConfig } from './config' +import type { EnvironmentPluginContainer } from './server/pluginContainer' +import { createEnvironmentPluginContainer } from './server/pluginContainer' +import { resolvePlugin } from './plugins/resolve' +import type { InternalResolveOptions } from './plugins/resolve' +import { getFsUtils } from './fsUtils' +import type { Environment } from './environment' +import type { PartialEnvironment } from './baseEnvironment' + +export type ResolveIdFn = ( + environment: PartialEnvironment, + id: string, + importer?: string, + aliasOnly?: boolean, +) => Promise + +/** + * Some projects like Astro were overriding config.createResolver to add a custom + * alias plugin. For the client and ssr environments, we root through it to avoid + * breaking changes for now. + */ +export function createBackCompatIdResolver( + config: ResolvedConfig, + options?: Partial, +): ResolveIdFn { + const compatResolve = config.createResolver(options) + let resolve: ResolveIdFn + return async (environment, id, importer, aliasOnly) => { + if (environment.name === 'client' || environment.name === 'ssr') { + return compatResolve(id, importer, aliasOnly, environment.name === 'ssr') + } + resolve ??= createIdResolver(config, options) + return resolve(environment, id, importer, aliasOnly) + } +} + +/** + * Create an internal resolver to be used in special scenarios, e.g. + * optimizer and handling css @imports + */ +export function createIdResolver( + config: ResolvedConfig, + options?: Partial, +): ResolveIdFn { + const scan = options?.scan + + const pluginContainerMap = new Map< + PartialEnvironment, + EnvironmentPluginContainer + >() + async function resolve( + environment: PartialEnvironment, + id: string, + importer?: string, + ): Promise { + let pluginContainer = pluginContainerMap.get(environment) + if (!pluginContainer) { + pluginContainer = await createEnvironmentPluginContainer( + environment as Environment, + [ + aliasPlugin({ entries: environment.config.resolve.alias }), + resolvePlugin({ + root: config.root, + isProduction: config.isProduction, + isBuild: config.command === 'build', + asSrc: true, + preferRelative: false, + tryIndex: true, + ...options, + fsUtils: getFsUtils(config), + // Ignore sideEffects and other computations as we only need the id + idOnly: true, + }), + ], + ) + pluginContainerMap.set(environment, pluginContainer) + } + return await pluginContainer.resolveId(id, importer, { scan }) + } + + const aliasOnlyPluginContainerMap = new Map< + PartialEnvironment, + EnvironmentPluginContainer + >() + async function resolveAlias( + environment: PartialEnvironment, + id: string, + importer?: string, + ): Promise { + let pluginContainer = aliasOnlyPluginContainerMap.get(environment) + if (!pluginContainer) { + pluginContainer = await createEnvironmentPluginContainer( + environment as Environment, + [aliasPlugin({ entries: environment.config.resolve.alias })], + ) + aliasOnlyPluginContainerMap.set(environment, pluginContainer) + } + return await pluginContainer.resolveId(id, importer, { scan }) + } + + return async (environment, id, importer, aliasOnly) => { + const resolveFn = aliasOnly ? resolveAlias : resolve + // aliasPlugin and resolvePlugin are implemented to function with a Environment only, + // we cast it as PluginEnvironment to be able to use the pluginContainer + const resolved = await resolveFn(environment, id, importer) + return resolved?.id + } +} diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 3d17f8737379d6..e0570de1ba6f12 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -10,13 +10,30 @@ export { } from './config' export { createServer } from './server' export { preview } from './preview' -export { build } from './build' +export { build, createBuilder } from './build' + export { optimizeDeps } from './optimizer' +export { createIdResolver } from './idResolver' + export { formatPostcssSourceMap, preprocessCSS } from './plugins/css' export { transformWithEsbuild } from './plugins/esbuild' export { buildErrorMessage } from './server/middlewares/error' -export { fetchModule } from './ssr/fetchModule' -export type { FetchModuleOptions } from './ssr/fetchModule' + +export { RemoteEnvironmentTransport } from './server/environmentTransport' +export { createNodeDevEnvironment } from './server/environments/nodeEnvironment' +export { + DevEnvironment, + type DevEnvironmentContext, +} from './server/environment' +export { BuildEnvironment } from './build' + +export { fetchModule, type FetchModuleOptions } from './ssr/fetchModule' +export { createServerModuleRunner } from './ssr/runtime/serverModuleRunner' +export { createServerHotChannel } from './server/hmr' +export { ServerHMRConnector } from './ssr/runtime/serverHmrConnector' +export { ssrTransform as moduleRunnerTransform } from './ssr/ssrTransform' +export type { ModuleRunnerTransformOptions } from './ssr/ssrTransform' + export * from './publicUtils' // additional types @@ -28,7 +45,6 @@ export type { InlineConfig, LegacyOptions, PluginHookUtils, - PluginOption, ResolveFn, ResolvedWorkerOptions, ResolvedConfig, @@ -37,7 +53,11 @@ export type { UserConfigFn, UserConfigFnObject, UserConfigFnPromise, + DevEnvironmentOptions, + ResolvedDevEnvironmentOptions, } from './config' +export type { Plugin, PluginOption, HookHandler } from './plugin' +export type { Environment } from './environment' export type { FilterPattern } from './utils' export type { CorsOptions, CorsOrigin, CommonServerOptions } from './http' export type { @@ -50,11 +70,15 @@ export type { HttpServer, } from './server' export type { + ViteBuilder, + BuilderOptions, BuildOptions, + BuildEnvironmentOptions, LibraryOptions, LibraryFormats, RenderBuiltAssetUrl, ResolvedBuildOptions, + ResolvedBuildEnvironmentOptions, ModulePreloadOptions, ResolvedModulePreloadOptions, ResolveModulePreloadDependenciesFn, @@ -74,11 +98,10 @@ export type { } from './optimizer' export type { ResolvedSSROptions, - SsrDepOptimizationOptions, + SsrDepOptimizationConfig, SSROptions, SSRTarget, } from './ssr' -export type { Plugin, HookHandler } from './plugin' export type { Logger, LogOptions, @@ -114,31 +137,38 @@ export type { WebSocketCustomListener, } from './server/ws' export type { PluginContainer } from './server/pluginContainer' -export type { ModuleGraph, ModuleNode, ResolvedUrl } from './server/moduleGraph' +export type { + EnvironmentModuleGraph, + EnvironmentModuleNode, + ResolvedUrl, +} from './server/moduleGraph' export type { SendOptions } from './server/send' export type { ProxyOptions } from './server/middlewares/proxy' export type { TransformOptions, TransformResult, } from './server/transformRequest' -export type { HmrOptions, HmrContext } from './server/hmr' - export type { + HmrOptions, + HmrContext, + HotUpdateOptions, HMRBroadcaster, - HMRChannel, - ServerHMRChannel, HMRBroadcasterClient, + ServerHMRChannel, + HMRChannel, + HotChannel, + ServerHotChannel, + HotChannelClient, } from './server/hmr' -export type { FetchFunction } from '../runtime/index' -export { createViteRuntime } from './ssr/runtime/mainThreadRuntime' -export type { MainThreadRuntimeOptions } from './ssr/runtime/mainThreadRuntime' -export { ServerHMRConnector } from './ssr/runtime/serverHmrConnector' +export type { FetchFunction, FetchResult } from 'vite/module-runner' +export type { ServerModuleRunnerOptions } from './ssr/runtime/serverModuleRunner' export type { BindCLIShortcutsOptions, CLIShortcut } from './shortcuts' export type { HMRPayload, + HotPayload, ConnectedPayload, UpdatePayload, Update, @@ -181,3 +211,6 @@ export type { RollupCommonJSOptions } from 'dep-types/commonjs' export type { RollupDynamicImportVarsOptions } from 'dep-types/dynamicImportVars' export type { Matcher, AnymatchPattern, AnymatchFn } from 'dep-types/anymatch' export type { LightningCSSOptions } from 'dep-types/lightningcss' + +// Backward compatibility +export type { ModuleGraph, ModuleNode } from './server/mixedModuleGraph' diff --git a/packages/vite/src/node/logger.ts b/packages/vite/src/node/logger.ts index 7928c954bdca9e..a9491c477f054a 100644 --- a/packages/vite/src/node/logger.ts +++ b/packages/vite/src/node/logger.ts @@ -20,6 +20,7 @@ export interface Logger { export interface LogOptions { clear?: boolean timestamp?: boolean + environment?: string } export interface LogErrorOptions extends LogOptions { @@ -88,7 +89,8 @@ export function createLogger( } else { tag = colors.red(colors.bold(prefix)) } - return `${colors.dim(getTimeFormatter().format(new Date()))} ${tag} ${msg}` + const environment = options.environment ? options.environment + ' ' : '' + return `${colors.dim(getTimeFormatter().format(new Date()))} ${tag} ${environment}${msg}` } else { return msg } diff --git a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts index 1f4c4dab16748d..4fc5a1d53b1560 100644 --- a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts +++ b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts @@ -2,8 +2,6 @@ import path from 'node:path' import type { ImportKind, Plugin } from 'esbuild' import { KNOWN_ASSET_TYPES } from '../constants' import type { PackageCache } from '../packages' -import { getDepOptimizationConfig } from '../config' -import type { ResolvedConfig } from '../config' import { escapeRegex, flattenId, @@ -14,6 +12,8 @@ import { } from '../utils' import { browserExternalId, optionalPeerDepId } from '../plugins/resolve' import { isCSSRequest, isModuleCSSRequest } from '../plugins/css' +import type { Environment } from '../environment' +import { createBackCompatIdResolver } from '../idResolver' const externalWithConversionNamespace = 'vite:dep-pre-bundle:external-conversion' @@ -48,12 +48,12 @@ const externalTypes = [ ] export function esbuildDepPlugin( + environment: Environment, qualified: Record, external: string[], - config: ResolvedConfig, - ssr: boolean, ): Plugin { - const { extensions } = getDepOptimizationConfig(config, ssr) + const { isProduction } = environment.config + const { extensions } = environment.config.dev.optimizeDeps // remove optimizable extensions from `externalTypes` list const allExternalTypes = extensions @@ -66,19 +66,22 @@ export function esbuildDepPlugin( const cjsPackageCache: PackageCache = new Map() // default resolver which prefers ESM - const _resolve = config.createResolver({ + const _resolve = createBackCompatIdResolver(environment.getTopLevelConfig(), { asSrc: false, scan: true, packageCache: esmPackageCache, }) // cjs resolver that prefers Node - const _resolveRequire = config.createResolver({ - asSrc: false, - isRequire: true, - scan: true, - packageCache: cjsPackageCache, - }) + const _resolveRequire = createBackCompatIdResolver( + environment.getTopLevelConfig(), + { + asSrc: false, + isRequire: true, + scan: true, + packageCache: cjsPackageCache, + }, + ) const resolve = ( id: string, @@ -96,7 +99,7 @@ export function esbuildDepPlugin( _importer = importer in qualified ? qualified[importer] : importer } const resolver = kind.startsWith('require') ? _resolveRequire : _resolve - return resolver(id, _importer, undefined, ssr) + return resolver(environment, id, _importer) } const resolveResult = (id: string, resolved: string) => { @@ -112,7 +115,7 @@ export function esbuildDepPlugin( namespace: 'optional-peer-dep', } } - if (ssr && isBuiltin(resolved)) { + if (environment.config.consumer === 'server' && isBuiltin(resolved)) { return } if (isExternalUrl(resolved)) { @@ -217,7 +220,7 @@ export function esbuildDepPlugin( if (!importer) { if ((entry = resolveEntry(id))) return entry // check if this is aliased to an entry - also return entry namespace - const aliased = await _resolve(id, undefined, true) + const aliased = await _resolve(environment, id, undefined, true) if (aliased && (entry = resolveEntry(aliased))) { return entry } @@ -234,7 +237,7 @@ export function esbuildDepPlugin( build.onLoad( { filter: /.*/, namespace: 'browser-external' }, ({ path }) => { - if (config.isProduction) { + if (isProduction) { return { contents: 'module.exports = {}', } @@ -277,7 +280,7 @@ module.exports = Object.create(new Proxy({}, { build.onLoad( { filter: /.*/, namespace: 'optional-peer-dep' }, ({ path }) => { - if (config.isProduction) { + if (isProduction) { return { contents: 'module.exports = {}', } diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index e62d78fdf1b956..fbbf73ee627060 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -8,7 +8,6 @@ import type { BuildContext, BuildOptions as EsbuildBuildOptions } from 'esbuild' import esbuild, { build } from 'esbuild' import { init, parse } from 'es-module-lexer' import glob from 'fast-glob' -import { getDepOptimizationConfig } from '../config' import type { ResolvedConfig } from '../config' import { createDebugger, @@ -28,14 +27,10 @@ import { } from '../plugins/esbuild' import { ESBUILD_MODULES_TARGET, METADATA_FILENAME } from '../constants' import { isWindows } from '../../shared/utils' +import type { Environment } from '../environment' import { esbuildCjsExternalPlugin, esbuildDepPlugin } from './esbuildDepPlugin' -import { scanImports } from './scan' +import { ScanEnvironment, scanImports } from './scan' import { createOptimizeDepsIncludeResolver, expandGlobIds } from './resolve' -export { - initDepsOptimizer, - initDevSsrDepsOptimizer, - getDepsOptimizer, -} from './optimizer' const debug = createDebugger('vite:deps') @@ -51,6 +46,8 @@ export type ExportsData = { } export interface DepsOptimizer { + init: () => Promise + metadata: DepOptimizationMetadata scanProcessing?: Promise registerMissingImport: (id: string, resolved: string) => OptimizedDepInfo @@ -166,6 +163,16 @@ export type DepOptimizationOptions = DepOptimizationConfig & { force?: boolean } +export function isDepOptimizationDisabled( + optimizeDeps: DepOptimizationOptions, +): boolean { + return ( + optimizeDeps.disabled === true || + optimizeDeps.disabled === 'dev' || + (!!optimizeDeps.noDiscovery && !optimizeDeps.include?.length) + ) +} + export interface DepOptimizationResult { metadata: DepOptimizationMetadata /** @@ -240,17 +247,19 @@ export interface DepOptimizationMetadata { * Scan and optimize dependencies within a project. * Used by Vite CLI when running `vite optimize`. */ + export async function optimizeDeps( config: ResolvedConfig, force = config.optimizeDeps.force, asCommand = false, ): Promise { const log = asCommand ? config.logger.info : debug - const ssr = false + + const environment = new ScanEnvironment('client', config) + await environment.init() const cachedMetadata = await loadCachedDepOptimizationMetadata( - config, - ssr, + environment, force, asCommand, ) @@ -258,30 +267,28 @@ export async function optimizeDeps( return cachedMetadata } - const deps = await discoverProjectDependencies(config).result + const deps = await discoverProjectDependencies(environment).result - await addManuallyIncludedOptimizeDeps(deps, config, ssr) + await addManuallyIncludedOptimizeDeps(environment, deps) const depsString = depsLogString(Object.keys(deps)) log?.(colors.green(`Optimizing dependencies:\n ${depsString}`)) - const depsInfo = toDiscoveredDependencies(config, deps, ssr) + const depsInfo = toDiscoveredDependencies(environment, deps) - const result = await runOptimizeDeps(config, depsInfo, ssr).result + const result = await runOptimizeDeps(environment, depsInfo).result await result.commit() return result.metadata } -export async function optimizeServerSsrDeps( - config: ResolvedConfig, +export async function optimizeExplicitEnvironmentDeps( + environment: Environment, ): Promise { - const ssr = true const cachedMetadata = await loadCachedDepOptimizationMetadata( - config, - ssr, - config.optimizeDeps.force, + environment, + environment.config.dev.optimizeDeps.force ?? false, false, ) if (cachedMetadata) { @@ -290,11 +297,11 @@ export async function optimizeServerSsrDeps( const deps: Record = {} - await addManuallyIncludedOptimizeDeps(deps, config, ssr) + await addManuallyIncludedOptimizeDeps(environment, deps) - const depsInfo = toDiscoveredDependencies(config, deps, ssr) + const depsInfo = toDiscoveredDependencies(environment, deps) - const result = await runOptimizeDeps(config, depsInfo, ssr).result + const result = await runOptimizeDeps(environment, depsInfo).result await result.commit() @@ -302,11 +309,10 @@ export async function optimizeServerSsrDeps( } export function initDepsOptimizerMetadata( - config: ResolvedConfig, - ssr: boolean, + environment: Environment, timestamp?: string, ): DepOptimizationMetadata { - const { lockfileHash, configHash, hash } = getDepHash(config, ssr) + const { lockfileHash, configHash, hash } = getDepHash(environment) return { hash, lockfileHash, @@ -336,20 +342,22 @@ let firstLoadCachedDepOptimizationMetadata = true * if it exists and pre-bundling isn't forced */ export async function loadCachedDepOptimizationMetadata( - config: ResolvedConfig, - ssr: boolean, - force = config.optimizeDeps.force, + environment: Environment, + force = environment.config.optimizeDeps?.force ?? false, asCommand = false, ): Promise { - const log = asCommand ? config.logger.info : debug + const log = asCommand ? environment.logger.info : debug if (firstLoadCachedDepOptimizationMetadata) { firstLoadCachedDepOptimizationMetadata = false // Fire up a clean up of stale processing deps dirs if older process exited early - setTimeout(() => cleanupDepsCacheStaleDirs(config), 0) + setTimeout( + () => cleanupDepsCacheStaleDirs(environment.getTopLevelConfig()), + 0, + ) } - const depsCacheDir = getDepsCacheDir(config, ssr) + const depsCacheDir = getDepsCacheDir(environment) if (!force) { let cachedMetadata: DepOptimizationMetadata | undefined @@ -362,12 +370,12 @@ export async function loadCachedDepOptimizationMetadata( } catch (e) {} // hash is consistent, no need to re-bundle if (cachedMetadata) { - if (cachedMetadata.lockfileHash !== getLockfileHash(config, ssr)) { - config.logger.info( + if (cachedMetadata.lockfileHash !== getLockfileHash(environment)) { + environment.logger.info( 'Re-optimizing dependencies because lockfile has changed', ) - } else if (cachedMetadata.configHash !== getConfigHash(config, ssr)) { - config.logger.info( + } else if (cachedMetadata.configHash !== getConfigHash(environment)) { + environment.logger.info( 'Re-optimizing dependencies because vite config has changed', ) } else { @@ -378,7 +386,7 @@ export async function loadCachedDepOptimizationMetadata( } } } else { - config.logger.info('Forced re-optimization of dependencies') + environment.logger.info('Forced re-optimization of dependencies') } // Start with a fresh cache @@ -390,11 +398,11 @@ export async function loadCachedDepOptimizationMetadata( * Initial optimizeDeps at server start. Perform a fast scan using esbuild to * find deps to pre-bundle and include user hard-coded dependencies */ -export function discoverProjectDependencies(config: ResolvedConfig): { +export function discoverProjectDependencies(environment: ScanEnvironment): { cancel: () => Promise result: Promise> } { - const { cancel, result } = scanImports(config) + const { cancel, result } = scanImports(environment) return { cancel, @@ -419,13 +427,12 @@ export function discoverProjectDependencies(config: ResolvedConfig): { } export function toDiscoveredDependencies( - config: ResolvedConfig, + environment: Environment, deps: Record, - ssr: boolean, timestamp?: string, ): Record { const browserHash = getOptimizedBrowserHash( - getDepHash(config, ssr).hash, + getDepHash(environment).hash, deps, timestamp, ) @@ -434,10 +441,10 @@ export function toDiscoveredDependencies( const src = deps[id] discovered[id] = { id, - file: getOptimizedDepPath(id, config, ssr), + file: getOptimizedDepPath(environment, id), src, browserHash: browserHash, - exportsData: extractExportsData(src, config, ssr), + exportsData: extractExportsData(environment, src), } } return discovered @@ -452,22 +459,16 @@ export function depsLogString(qualifiedIds: string[]): string { * the metadata and start the server without waiting for the optimizeDeps processing to be completed */ export function runOptimizeDeps( - resolvedConfig: ResolvedConfig, + environment: Environment, depsInfo: Record, - ssr: boolean, ): { cancel: () => Promise result: Promise } { const optimizerContext = { cancelled: false } - const config: ResolvedConfig = { - ...resolvedConfig, - command: 'build', - } - - const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr) - const processingCacheDir = getProcessingDepsCacheDir(resolvedConfig, ssr) + const depsCacheDir = getDepsCacheDir(environment) + const processingCacheDir = getProcessingDepsCacheDir(environment) // Create a temporary directory so we don't need to delete optimized deps // until they have been processed. This also avoids leaving the deps cache @@ -482,7 +483,7 @@ export function runOptimizeDeps( `{\n "type": "module"\n}\n`, ) - const metadata = initDepsOptimizerMetadata(config, ssr) + const metadata = initDepsOptimizerMetadata(environment) metadata.browserHash = getOptimizedBrowserHash( metadata.hash, @@ -594,9 +595,8 @@ export function runOptimizeDeps( const start = performance.now() const preparedRun = prepareEsbuildOptimizerRun( - resolvedConfig, + environment, depsInfo, - ssr, processingCacheDir, optimizerContext, ) @@ -604,7 +604,9 @@ export function runOptimizeDeps( const runResult = preparedRun.then(({ context, idToExports }) => { function disposeContext() { return context?.dispose().catch((e) => { - config.logger.error('Failed to dispose esbuild context', { error: e }) + environment.logger.error('Failed to dispose esbuild context', { + error: e, + }) }) } if (!context || optimizerContext.cancelled) { @@ -644,8 +646,7 @@ export function runOptimizeDeps( // After bundling we have more information and can warn the user about legacy packages // that require manual configuration needsInterop: needsInterop( - config, - ssr, + environment, id, idToExports[id], output, @@ -658,7 +659,7 @@ export function runOptimizeDeps( const id = path .relative(processingCacheDirOutputPath, o) .replace(jsExtensionRE, '') - const file = getOptimizedDepPath(id, resolvedConfig, ssr) + const file = getOptimizedDepPath(environment, id) if ( !findOptimizedDepInfoInRecord( metadata.optimized, @@ -711,20 +712,14 @@ export function runOptimizeDeps( } async function prepareEsbuildOptimizerRun( - resolvedConfig: ResolvedConfig, + environment: Environment, depsInfo: Record, - ssr: boolean, processingCacheDir: string, optimizerContext: { cancelled: boolean }, ): Promise<{ context?: BuildContext idToExports: Record }> { - const config: ResolvedConfig = { - ...resolvedConfig, - command: 'build', - } - // esbuild generates nested directory output with lowest common ancestor base // this is unpredictable and makes it difficult to analyze entry / output // mapping. So what we do here is: @@ -734,7 +729,7 @@ async function prepareEsbuildOptimizerRun( const flatIdDeps: Record = {} const idToExports: Record = {} - const optimizeDeps = getDepOptimizationConfig(config, ssr) + const { optimizeDeps } = environment.config.dev const { plugins: pluginsFromConfig = [], ...esbuildOptions } = optimizeDeps?.esbuildOptions ?? {} @@ -743,7 +738,7 @@ async function prepareEsbuildOptimizerRun( Object.keys(depsInfo).map(async (id) => { const src = depsInfo[id].src! const exportsData = await (depsInfo[id].exportsData ?? - extractExportsData(src, config, ssr)) + extractExportsData(environment, src)) if (exportsData.jsxLoader && !esbuildOptions.loader?.['.js']) { // Ensure that optimization won't fail by defaulting '.js' to the JSX parser. // This is useful for packages such as Gatsby. @@ -761,11 +756,12 @@ async function prepareEsbuildOptimizerRun( if (optimizerContext.cancelled) return { context: undefined, idToExports } const define = { - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || config.mode), + 'process.env.NODE_ENV': JSON.stringify( + process.env.NODE_ENV || environment.config.mode, + ), } - const platform = - ssr && config.ssr?.target !== 'webworker' ? 'node' : 'browser' + const platform = environment.config.webCompatible ? 'browser' : 'node' const external = [...(optimizeDeps?.exclude ?? [])] @@ -773,7 +769,7 @@ async function prepareEsbuildOptimizerRun( if (external.length) { plugins.push(esbuildCjsExternalPlugin(external, platform)) } - plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr)) + plugins.push(esbuildDepPlugin(environment, flatIdDeps, external)) const context = await esbuild.context({ absWorkingDir: process.cwd(), @@ -812,20 +808,17 @@ async function prepareEsbuildOptimizerRun( } export async function addManuallyIncludedOptimizeDeps( + environment: Environment, deps: Record, - config: ResolvedConfig, - ssr: boolean, ): Promise { - const { logger } = config - const optimizeDeps = getDepOptimizationConfig(config, ssr) + const { logger } = environment + const { optimizeDeps } = environment.config.dev const optimizeDepsInclude = optimizeDeps?.include ?? [] if (optimizeDepsInclude.length) { const unableToOptimize = (id: string, msg: string) => { if (optimizeDepsInclude.includes(id)) { logger.warn( - `${msg}: ${colors.cyan(id)}, present in '${ - ssr ? 'ssr.' : '' - }optimizeDeps.include'`, + `${msg}: ${colors.cyan(id)}, present in ${environment.name} 'optimizeDeps.include'`, ) } } @@ -834,13 +827,13 @@ export async function addManuallyIncludedOptimizeDeps( for (let i = 0; i < includes.length; i++) { const id = includes[i] if (glob.isDynamicPattern(id)) { - const globIds = expandGlobIds(id, config) + const globIds = expandGlobIds(id, environment.getTopLevelConfig()) includes.splice(i, 1, ...globIds) i += globIds.length - 1 } } - const resolve = createOptimizeDepsIncludeResolver(config, ssr) + const resolve = createOptimizeDepsIncludeResolver(environment) for (const id of includes) { // normalize 'foo >bar` as 'foo > bar' to prevent same id being added // and for pretty printing @@ -875,26 +868,27 @@ export function depsFromOptimizedDepInfo( } export function getOptimizedDepPath( + environment: Environment, id: string, - config: ResolvedConfig, - ssr: boolean, ): string { return normalizePath( - path.resolve(getDepsCacheDir(config, ssr), flattenId(id) + '.js'), + path.resolve(getDepsCacheDir(environment), flattenId(id) + '.js'), ) } -function getDepsCacheSuffix(ssr: boolean): string { - return ssr ? '_ssr' : '' +function getDepsCacheSuffix(environment: Environment): string { + return environment.name === 'client' ? '' : `_${environment.name}` } -export function getDepsCacheDir(config: ResolvedConfig, ssr: boolean): string { - return getDepsCacheDirPrefix(config) + getDepsCacheSuffix(ssr) +export function getDepsCacheDir(environment: Environment): string { + return getDepsCacheDirPrefix(environment) + getDepsCacheSuffix(environment) } -function getProcessingDepsCacheDir(config: ResolvedConfig, ssr: boolean) { +function getProcessingDepsCacheDir(environment: Environment) { return ( - getDepsCacheDirPrefix(config) + getDepsCacheSuffix(ssr) + getTempSuffix() + getDepsCacheDirPrefix(environment) + + getDepsCacheSuffix(environment) + + getTempSuffix() ) } @@ -909,22 +903,22 @@ function getTempSuffix() { ) } -function getDepsCacheDirPrefix(config: ResolvedConfig): string { - return normalizePath(path.resolve(config.cacheDir, 'deps')) +function getDepsCacheDirPrefix(environment: Environment): string { + return normalizePath(path.resolve(environment.config.cacheDir, 'deps')) } export function createIsOptimizedDepFile( - config: ResolvedConfig, + environment: Environment, ): (id: string) => boolean { - const depsCacheDirPrefix = getDepsCacheDirPrefix(config) + const depsCacheDirPrefix = getDepsCacheDirPrefix(environment) return (id) => id.startsWith(depsCacheDirPrefix) } export function createIsOptimizedDepUrl( - config: ResolvedConfig, + environment: Environment, ): (url: string) => boolean { - const { root } = config - const depsCacheDir = getDepsCacheDirPrefix(config) + const { root } = environment.config + const depsCacheDir = getDepsCacheDirPrefix(environment) // determine the url prefix of files inside cache directory const depsCacheDirRelative = normalizePath(path.relative(root, depsCacheDir)) @@ -1060,13 +1054,12 @@ function esbuildOutputFromId( } export async function extractExportsData( + environment: Environment, filePath: string, - config: ResolvedConfig, - ssr: boolean, ): Promise { await init - const optimizeDeps = getDepOptimizationConfig(config, ssr) + const { optimizeDeps } = environment.config.dev const esbuildOptions = optimizeDeps?.esbuildOptions ?? {} if (optimizeDeps.extensions?.some((ext) => filePath.endsWith(ext))) { @@ -1114,13 +1107,12 @@ export async function extractExportsData( } function needsInterop( - config: ResolvedConfig, - ssr: boolean, + environment: Environment, id: string, exportsData: ExportsData, output?: { exports: string[] }, ): boolean { - if (getDepOptimizationConfig(config, ssr)?.needsInterop?.includes(id)) { + if (environment.config.dev.optimizeDeps?.needsInterop?.includes(id)) { return true } const { hasModuleSyntax, exports } = exportsData @@ -1160,10 +1152,11 @@ const lockfileFormats = [ }) const lockfileNames = lockfileFormats.map((l) => l.name) -function getConfigHash(config: ResolvedConfig, ssr: boolean): string { +function getConfigHash(environment: Environment): string { // Take config into account // only a subset of config options that can affect dep optimization - const optimizeDeps = getDepOptimizationConfig(config, ssr) + const { config } = environment + const { optimizeDeps } = config.dev const content = JSON.stringify( { mode: process.env.NODE_ENV || config.mode, @@ -1194,8 +1187,8 @@ function getConfigHash(config: ResolvedConfig, ssr: boolean): string { return getHash(content) } -function getLockfileHash(config: ResolvedConfig, ssr: boolean): string { - const lockfilePath = lookupFile(config.root, lockfileNames) +function getLockfileHash(environment: Environment): string { + const lockfilePath = lookupFile(environment.config.root, lockfileNames) let content = lockfilePath ? fs.readFileSync(lockfilePath, 'utf-8') : '' if (lockfilePath) { const lockfileName = path.basename(lockfilePath) @@ -1214,12 +1207,13 @@ function getLockfileHash(config: ResolvedConfig, ssr: boolean): string { return getHash(content) } -function getDepHash( - config: ResolvedConfig, - ssr: boolean, -): { lockfileHash: string; configHash: string; hash: string } { - const lockfileHash = getLockfileHash(config, ssr) - const configHash = getConfigHash(config, ssr) +function getDepHash(environment: Environment): { + lockfileHash: string + configHash: string + hash: string +} { + const lockfileHash = getLockfileHash(environment) + const configHash = getConfigHash(environment) const hash = getHash(lockfileHash + configHash) return { hash, @@ -1265,17 +1259,15 @@ function findOptimizedDepInfoInRecord( } export async function optimizedDepNeedsInterop( + environment: Environment, metadata: DepOptimizationMetadata, file: string, - config: ResolvedConfig, - ssr: boolean, ): Promise { const depInfo = optimizedDepInfoFromFile(metadata, file) if (depInfo?.src && depInfo.needsInterop === undefined) { - depInfo.exportsData ??= extractExportsData(depInfo.src, config, ssr) + depInfo.exportsData ??= extractExportsData(environment, depInfo.src) depInfo.needsInterop = needsInterop( - config, - ssr, + environment, depInfo.id, await depInfo.exportsData, ) diff --git a/packages/vite/src/node/optimizer/optimizer.ts b/packages/vite/src/node/optimizer/optimizer.ts index 3f76e480a45e75..9458ac652413e7 100644 --- a/packages/vite/src/node/optimizer/optimizer.ts +++ b/packages/vite/src/node/optimizer/optimizer.ts @@ -1,8 +1,8 @@ import colors from 'picocolors' import { createDebugger, getHash, promiseWithResolvers } from '../utils' import type { PromiseWithResolvers } from '../utils' -import { getDepOptimizationConfig } from '../config' -import type { ResolvedConfig, ViteDevServer } from '..' +import type { DevEnvironment } from '../server/environment' +import { devToScanEnvironment } from './scan' import { addManuallyIncludedOptimizeDeps, addOptimizedDepInfo, @@ -15,11 +15,16 @@ import { getOptimizedDepPath, initDepsOptimizerMetadata, loadCachedDepOptimizationMetadata, - optimizeServerSsrDeps, + optimizeExplicitEnvironmentDeps, runOptimizeDeps, toDiscoveredDependencies, -} from '.' -import type { DepOptimizationResult, DepsOptimizer, OptimizedDepInfo } from '.' +} from './index' +import type { + DepOptimizationMetadata, + DepOptimizationResult, + DepsOptimizer, + OptimizedDepInfo, +} from './index' const debug = createDebugger('vite:deps') @@ -29,88 +34,38 @@ const debug = createDebugger('vite:deps') */ const debounceMs = 100 -const depsOptimizerMap = new WeakMap() -const devSsrDepsOptimizerMap = new WeakMap() - -export function getDepsOptimizer( - config: ResolvedConfig, - ssr?: boolean, -): DepsOptimizer | undefined { - return (ssr ? devSsrDepsOptimizerMap : depsOptimizerMap).get(config) -} - -export async function initDepsOptimizer( - config: ResolvedConfig, - server: ViteDevServer, -): Promise { - if (!getDepsOptimizer(config, false)) { - await createDepsOptimizer(config, server) - } -} - -let creatingDevSsrOptimizer: Promise | undefined -export async function initDevSsrDepsOptimizer( - config: ResolvedConfig, - server: ViteDevServer, -): Promise { - if (getDepsOptimizer(config, true)) { - // ssr - return - } - if (creatingDevSsrOptimizer) { - return creatingDevSsrOptimizer - } - creatingDevSsrOptimizer = (async function () { - // Important: scanning needs to be done before starting the SSR dev optimizer - // If ssrLoadModule is called before server.listen(), the main deps optimizer - // will not be yet created - const ssr = false - if (!getDepsOptimizer(config, ssr)) { - await initDepsOptimizer(config, server) - } - await getDepsOptimizer(config, ssr)!.scanProcessing - - await createDevSsrDepsOptimizer(config) - creatingDevSsrOptimizer = undefined - })() - return await creatingDevSsrOptimizer -} - -async function createDepsOptimizer( - config: ResolvedConfig, - server: ViteDevServer, -): Promise { - const { logger } = config - const ssr = false +export function createDepsOptimizer( + environment: DevEnvironment, +): DepsOptimizer { + const { logger } = environment const sessionTimestamp = Date.now().toString() - const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr) - let debounceProcessingHandle: NodeJS.Timeout | undefined let closed = false - let metadata = - cachedMetadata || initDepsOptimizerMetadata(config, ssr, sessionTimestamp) - - const options = getDepOptimizationConfig(config, ssr) + const options = environment.config.dev.optimizeDeps const { noDiscovery, holdUntilCrawlEnd } = options + let metadata: DepOptimizationMetadata = initDepsOptimizerMetadata( + environment, + sessionTimestamp, + ) + const depsOptimizer: DepsOptimizer = { + init, metadata, registerMissingImport, run: () => debouncedProcessing(0), - isOptimizedDepFile: createIsOptimizedDepFile(config), - isOptimizedDepUrl: createIsOptimizedDepUrl(config), + isOptimizedDepFile: createIsOptimizedDepFile(environment), + isOptimizedDepUrl: createIsOptimizedDepUrl(environment), getOptimizedDepId: (depInfo: OptimizedDepInfo) => `${depInfo.file}?v=${depInfo.browserHash}`, close, options, } - depsOptimizerMap.set(config, depsOptimizer) - let newDepsDiscovered = false let newDepsToLog: string[] = [] @@ -159,7 +114,7 @@ async function createDepsOptimizer( let enqueuedRerun: (() => void) | undefined let currentlyProcessing = false - let firstRunCalled = !!cachedMetadata + let firstRunCalled = false let warnAboutMissedDependencies = false // If this is a cold run, we wait for static imports discovered @@ -167,10 +122,6 @@ async function createDepsOptimizer( // On warm start or after the first optimization is run, we use a simpler // debounce strategy each time a new dep is discovered. let waitingForCrawlEnd = false - if (!cachedMetadata) { - server._onCrawlEnd(onCrawlEnd) - waitingForCrawlEnd = true - } let optimizationResult: | { @@ -195,96 +146,113 @@ async function createDepsOptimizer( ]) } - if (!cachedMetadata) { - // Enter processing state until crawl of static imports ends - currentlyProcessing = true + let inited = false + async function init() { + if (inited) return + inited = true - // Initialize discovered deps with manually added optimizeDeps.include info + const cachedMetadata = await loadCachedDepOptimizationMetadata(environment) - const manuallyIncludedDeps: Record = {} - await addManuallyIncludedOptimizeDeps(manuallyIncludedDeps, config, ssr) + firstRunCalled = !!cachedMetadata - const manuallyIncludedDepsInfo = toDiscoveredDependencies( - config, - manuallyIncludedDeps, - ssr, - sessionTimestamp, - ) + metadata = depsOptimizer.metadata = + cachedMetadata || initDepsOptimizerMetadata(environment, sessionTimestamp) - for (const depInfo of Object.values(manuallyIncludedDepsInfo)) { - addOptimizedDepInfo(metadata, 'discovered', { - ...depInfo, - processing: depOptimizationProcessing.promise, - }) - newDepsDiscovered = true - } + if (!cachedMetadata) { + environment._onCrawlEnd(onCrawlEnd) + waitingForCrawlEnd = true - if (noDiscovery) { - // We don't need to scan for dependencies or wait for the static crawl to end - // Run the first optimization run immediately - runOptimizer() - } else { - // Important, the scanner is dev only - depsOptimizer.scanProcessing = new Promise((resolve) => { - // Runs in the background in case blocking high priority tasks - ;(async () => { - try { - debug?.(colors.green(`scanning for dependencies...`)) - - discover = discoverProjectDependencies(config) - const deps = await discover.result - discover = undefined - - const manuallyIncluded = Object.keys(manuallyIncludedDepsInfo) - discoveredDepsWhileScanning.push( - ...Object.keys(metadata.discovered).filter( - (dep) => !deps[dep] && !manuallyIncluded.includes(dep), - ), - ) + // Enter processing state until crawl of static imports ends + currentlyProcessing = true + + // Initialize discovered deps with manually added optimizeDeps.include info + + const manuallyIncludedDeps: Record = {} + await addManuallyIncludedOptimizeDeps(environment, manuallyIncludedDeps) - // Add these dependencies to the discovered list, as these are currently - // used by the preAliasPlugin to support aliased and optimized deps. - // This is also used by the CJS externalization heuristics in legacy mode - for (const id of Object.keys(deps)) { - if (!metadata.discovered[id]) { - addMissingDep(id, deps[id]) + const manuallyIncludedDepsInfo = toDiscoveredDependencies( + environment, + manuallyIncludedDeps, + sessionTimestamp, + ) + + for (const depInfo of Object.values(manuallyIncludedDepsInfo)) { + addOptimizedDepInfo(metadata, 'discovered', { + ...depInfo, + processing: depOptimizationProcessing.promise, + }) + newDepsDiscovered = true + } + + if (noDiscovery) { + // We don't need to scan for dependencies or wait for the static crawl to end + // Run the first optimization run immediately + runOptimizer() + } else { + // Important, the scanner is dev only + depsOptimizer.scanProcessing = new Promise((resolve) => { + // Runs in the background in case blocking high priority tasks + ;(async () => { + try { + debug?.(colors.green(`scanning for dependencies...`)) + + discover = discoverProjectDependencies( + devToScanEnvironment(environment), + ) + const deps = await discover.result + discover = undefined + + const manuallyIncluded = Object.keys(manuallyIncludedDepsInfo) + discoveredDepsWhileScanning.push( + ...Object.keys(metadata.discovered).filter( + (dep) => !deps[dep] && !manuallyIncluded.includes(dep), + ), + ) + + // Add these dependencies to the discovered list, as these are currently + // used by the preAliasPlugin to support aliased and optimized deps. + // This is also used by the CJS externalization heuristics in legacy mode + for (const id of Object.keys(deps)) { + if (!metadata.discovered[id]) { + addMissingDep(id, deps[id]) + } } - } - const knownDeps = prepareKnownDeps() - startNextDiscoveredBatch() - - // For dev, we run the scanner and the first optimization - // run on the background - optimizationResult = runOptimizeDeps(config, knownDeps, ssr) - - // If the holdUntilCrawlEnd stratey is used, we wait until crawling has - // ended to decide if we send this result to the browser or we need to - // do another optimize step - if (!holdUntilCrawlEnd) { - // If not, we release the result to the browser as soon as the scanner - // is done. If the scanner missed any dependency, and a new dependency - // is discovered while crawling static imports, then there will be a - // full-page reload if new common chunks are generated between the old - // and new optimized deps. - optimizationResult.result.then((result) => { - // Check if the crawling of static imports has already finished. In that - // case, the result is handled by the onCrawlEnd callback - if (!waitingForCrawlEnd) return - - optimizationResult = undefined // signal that we'll be using the result - - runOptimizer(result) - }) + const knownDeps = prepareKnownDeps() + startNextDiscoveredBatch() + + // For dev, we run the scanner and the first optimization + // run on the background + optimizationResult = runOptimizeDeps(environment, knownDeps) + + // If the holdUntilCrawlEnd stratey is used, we wait until crawling has + // ended to decide if we send this result to the browser or we need to + // do another optimize step + if (!holdUntilCrawlEnd) { + // If not, we release the result to the browser as soon as the scanner + // is done. If the scanner missed any dependency, and a new dependency + // is discovered while crawling static imports, then there will be a + // full-page reload if new common chunks are generated between the old + // and new optimized deps. + optimizationResult.result.then((result) => { + // Check if the crawling of static imports has already finished. In that + // case, the result is handled by the onCrawlEnd callback + if (!waitingForCrawlEnd) return + + optimizationResult = undefined // signal that we'll be using the result + + runOptimizer(result) + }) + } + } catch (e) { + logger.error(e.stack || e.message) + } finally { + resolve() + depsOptimizer.scanProcessing = undefined } - } catch (e) { - logger.error(e.stack || e.message) - } finally { - resolve() - depsOptimizer.scanProcessing = undefined - } - })() - }) + })() + }) + } } } @@ -303,6 +271,7 @@ async function createDepsOptimizer( function prepareKnownDeps() { const knownDeps: Record = {} // Clone optimized info objects, fileHash, browserHash may be changed for them + const metadata = depsOptimizer.metadata! for (const dep of Object.keys(metadata.optimized)) { knownDeps[dep] = { ...metadata.optimized[dep] } } @@ -351,7 +320,7 @@ async function createDepsOptimizer( const knownDeps = prepareKnownDeps() startNextDiscoveredBatch() - optimizationResult = runOptimizeDeps(config, knownDeps, ssr) + optimizationResult = runOptimizeDeps(environment, knownDeps) processingResult = await optimizationResult.result optimizationResult = undefined } @@ -537,9 +506,9 @@ async function createDepsOptimizer( // Cached transform results have stale imports (resolved to // old locations) so they need to be invalidated before the page is // reloaded. - server.moduleGraph.invalidateAll() + environment.moduleGraph.invalidateAll() - server.hot.send({ + environment.hot.send({ type: 'full-reload', path: '*', }) @@ -607,7 +576,7 @@ async function createDepsOptimizer( return addOptimizedDepInfo(metadata, 'discovered', { id, - file: getOptimizedDepPath(id, config, ssr), + file: getOptimizedDepPath(environment, id), src: resolved, // Adding a browserHash to this missing dependency that is unique to // the current state of known + missing deps. If its optimizeDeps run @@ -621,7 +590,7 @@ async function createDepsOptimizer( // loading of this pre-bundled dep needs to await for its processing // promise to be resolved processing: depOptimizationProcessing.promise, - exportsData: extractExportsData(resolved, config, ssr), + exportsData: extractExportsData(environment, resolved), }) } @@ -657,7 +626,7 @@ async function createDepsOptimizer( // It normally should be over by the time crawling of user code ended await depsOptimizer.scanProcessing - if (optimizationResult && !config.optimizeDeps.noDiscovery) { + if (optimizationResult && !options.noDiscovery) { // In the holdUntilCrawlEnd strategy, we don't release the result of the // post-scanner optimize step to the browser until we reach this point // If there are new dependencies, we do another optimize run, if not, we @@ -754,33 +723,43 @@ async function createDepsOptimizer( debouncedProcessing(0) } } -} -async function createDevSsrDepsOptimizer( - config: ResolvedConfig, -): Promise { - const metadata = await optimizeServerSsrDeps(config) + return depsOptimizer +} +export function createExplicitDepsOptimizer( + environment: DevEnvironment, +): DepsOptimizer { const depsOptimizer = { - metadata, - isOptimizedDepFile: createIsOptimizedDepFile(config), - isOptimizedDepUrl: createIsOptimizedDepUrl(config), + metadata: initDepsOptimizerMetadata(environment), + isOptimizedDepFile: createIsOptimizedDepFile(environment), + isOptimizedDepUrl: createIsOptimizedDepUrl(environment), getOptimizedDepId: (depInfo: OptimizedDepInfo) => `${depInfo.file}?v=${depInfo.browserHash}`, registerMissingImport: () => { throw new Error( - 'Vite Internal Error: registerMissingImport is not supported in dev SSR', + `Vite Internal Error: registerMissingImport is not supported in dev ${environment.name}`, ) }, + init, // noop, there is no scanning during dev SSR // the optimizer blocks the server start run: () => {}, close: async () => {}, - options: config.ssr.optimizeDeps, + options: environment.config.dev.optimizeDeps, } - devSsrDepsOptimizerMap.set(config, depsOptimizer) + + let inited = false + async function init() { + if (inited) return + inited = true + + depsOptimizer.metadata = await optimizeExplicitEnvironmentDeps(environment) + } + + return depsOptimizer } function findInteropMismatches( diff --git a/packages/vite/src/node/optimizer/resolve.ts b/packages/vite/src/node/optimizer/resolve.ts index 822b19e1898bad..43cf52cd41467f 100644 --- a/packages/vite/src/node/optimizer/resolve.ts +++ b/packages/vite/src/node/optimizer/resolve.ts @@ -5,22 +5,23 @@ import type { ResolvedConfig } from '../config' import { escapeRegex, getNpmPackageName } from '../utils' import { resolvePackageData } from '../packages' import { slash } from '../../shared/utils' +import type { Environment } from '../environment' +import { createBackCompatIdResolver } from '../idResolver' export function createOptimizeDepsIncludeResolver( - config: ResolvedConfig, - ssr: boolean, + environment: Environment, ): (id: string) => Promise { - const resolve = config.createResolver({ + const topLevelConfig = environment.getTopLevelConfig() + const resolve = createBackCompatIdResolver(topLevelConfig, { asSrc: false, scan: true, - ssrOptimizeCheck: ssr, - ssrConfig: config.ssr, + ssrOptimizeCheck: environment.config.consumer === 'server', packageCache: new Map(), }) return async (id: string) => { const lastArrowIndex = id.lastIndexOf('>') if (lastArrowIndex === -1) { - return await resolve(id, undefined, undefined, ssr) + return await resolve(environment, id, undefined) } // split nested selected id by last '>', for example: // 'foo > bar > baz' => 'foo > bar' & 'baz' @@ -28,14 +29,13 @@ export function createOptimizeDepsIncludeResolver( const nestedPath = id.substring(lastArrowIndex + 1).trim() const basedir = nestedResolveBasedir( nestedRoot, - config.root, - config.resolve.preserveSymlinks, + topLevelConfig.root, + topLevelConfig.resolve.preserveSymlinks, ) return await resolve( + environment, nestedPath, path.resolve(basedir, 'package.json'), - undefined, - ssr, ) } } diff --git a/packages/vite/src/node/optimizer/scan.ts b/packages/vite/src/node/optimizer/scan.ts index c47f38e18a8b32..de0c627d90cb59 100644 --- a/packages/vite/src/node/optimizer/scan.ts +++ b/packages/vite/src/node/optimizer/scan.ts @@ -11,8 +11,8 @@ import type { Plugin, } from 'esbuild' import esbuild, { formatMessages, transform } from 'esbuild' +import type { PartialResolvedId } from 'rollup' import colors from 'picocolors' -import type { ResolvedConfig } from '..' import { CSS_LANGS_RE, JS_TYPES_RE, @@ -34,13 +34,81 @@ import { virtualModulePrefix, virtualModuleRE, } from '../utils' -import type { PluginContainer } from '../server/pluginContainer' -import { createPluginContainer } from '../server/pluginContainer' +import { resolveEnvironmentPlugins } from '../plugin' +import type { EnvironmentPluginContainer } from '../server/pluginContainer' +import { createEnvironmentPluginContainer } from '../server/pluginContainer' +import { BaseEnvironment } from '../baseEnvironment' +import type { DevEnvironment } from '../server/environment' import { transformGlobImport } from '../plugins/importMetaGlob' import { cleanUrl } from '../../shared/utils' import { loadTsconfigJsonForFile } from '../plugins/esbuild' -type ResolveIdOptions = Parameters[2] +export class ScanEnvironment extends BaseEnvironment { + mode = 'scan' as const + + get pluginContainer(): EnvironmentPluginContainer { + if (!this._pluginContainer) + throw new Error( + `${this.name} environment.pluginContainer called before initialized`, + ) + return this._pluginContainer + } + /** + * @internal + */ + _pluginContainer: EnvironmentPluginContainer | undefined + + async init(): Promise { + if (this._initiated) { + return + } + this._initiated = true + this._plugins = resolveEnvironmentPlugins(this) + this._pluginContainer = await createEnvironmentPluginContainer( + this, + this.plugins, + ) + await this._pluginContainer.buildStart() + } +} + +// Restrict access to the module graph and the server while scanning +export function devToScanEnvironment( + environment: DevEnvironment, +): ScanEnvironment { + return { + mode: 'scan', + get name() { + return environment.name + }, + getTopLevelConfig() { + return environment.getTopLevelConfig() + }, + /** + * @deprecated use environment.config instead + **/ + get options() { + return environment.options + }, + get config() { + return environment.config + }, + get logger() { + return environment.logger + }, + get pluginContainer() { + return environment.pluginContainer + }, + get plugins() { + return environment.plugins + }, + } as unknown as ScanEnvironment +} + +type ResolveIdOptions = Omit< + Parameters[2], + 'environment' +> const debug = createDebugger('vite:deps') @@ -57,7 +125,7 @@ const htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/ export const importsRE = /(? Promise result: Promise<{ deps: Record @@ -71,16 +139,16 @@ export function scanImports(config: ResolvedConfig): { const missing: Record = {} let entries: string[] + const { config } = environment const scanContext = { cancelled: false } - const esbuildContext: Promise = computeEntries( - config, + environment, ).then((computedEntries) => { entries = computedEntries if (!entries.length) { - if (!config.optimizeDeps.entries && !config.optimizeDeps.include) { - config.logger.warn( + if (!config.optimizeDeps.entries && !config.dev.optimizeDeps.include) { + environment.logger.warn( colors.yellow( '(!) Could not auto-determine entry point from rollupOptions or html files ' + 'and there are no explicit optimizeDeps.include patterns. ' + @@ -97,14 +165,22 @@ export function scanImports(config: ResolvedConfig): { .map((entry) => `\n ${colors.dim(entry)}`) .join('')}`, ) - return prepareEsbuildScanner(config, entries, deps, missing, scanContext) + return prepareEsbuildScanner( + environment, + entries, + deps, + missing, + scanContext, + ) }) const result = esbuildContext .then((context) => { function disposeContext() { return context?.dispose().catch((e) => { - config.logger.error('Failed to dispose esbuild context', { error: e }) + environment.logger.error('Failed to dispose esbuild context', { + error: e, + }) }) } if (!context || scanContext?.cancelled) { @@ -168,16 +244,16 @@ export function scanImports(config: ResolvedConfig): { } } -async function computeEntries(config: ResolvedConfig) { +async function computeEntries(environment: ScanEnvironment) { let entries: string[] = [] - const explicitEntryPatterns = config.optimizeDeps.entries - const buildInput = config.build.rollupOptions?.input + const explicitEntryPatterns = environment.config.dev.optimizeDeps.entries + const buildInput = environment.config.build.rollupOptions?.input if (explicitEntryPatterns) { - entries = await globEntries(explicitEntryPatterns, config) + entries = await globEntries(explicitEntryPatterns, environment) } else if (buildInput) { - const resolvePath = (p: string) => path.resolve(config.root, p) + const resolvePath = (p: string) => path.resolve(environment.config.root, p) if (typeof buildInput === 'string') { entries = [resolvePath(buildInput)] } else if (Array.isArray(buildInput)) { @@ -188,14 +264,14 @@ async function computeEntries(config: ResolvedConfig) { throw new Error('invalid rollupOptions.input value.') } } else { - entries = await globEntries('**/*.html', config) + entries = await globEntries('**/*.html', environment) } // Non-supported entry file types and virtual files should not be scanned for // dependencies. entries = entries.filter( (entry) => - isScannable(entry, config.optimizeDeps.extensions) && + isScannable(entry, environment.config.dev.optimizeDeps.extensions) && fs.existsSync(entry), ) @@ -203,20 +279,18 @@ async function computeEntries(config: ResolvedConfig) { } async function prepareEsbuildScanner( - config: ResolvedConfig, + environment: ScanEnvironment, entries: string[], deps: Record, missing: Record, scanContext?: { cancelled: boolean }, ): Promise { - const container = await createPluginContainer(config) - if (scanContext?.cancelled) return - const plugin = esbuildScanPlugin(config, container, deps, missing, entries) + const plugin = esbuildScanPlugin(environment, deps, missing, entries) const { plugins = [], ...esbuildOptions } = - config.optimizeDeps?.esbuildOptions ?? {} + environment.config.dev.optimizeDeps.esbuildOptions ?? {} // The plugin pipeline automatically loads the closest tsconfig.json. // But esbuild doesn't support reading tsconfig.json if the plugin has resolved the path (https://github.com/evanw/esbuild/issues/2265). @@ -226,7 +300,7 @@ async function prepareEsbuildScanner( let tsconfigRaw = esbuildOptions.tsconfigRaw if (!tsconfigRaw && !esbuildOptions.tsconfig) { const tsconfigResult = await loadTsconfigJsonForFile( - path.join(config.root, '_dummy.js'), + path.join(environment.config.root, '_dummy.js'), ) if (tsconfigResult.compilerOptions?.experimentalDecorators) { tsconfigRaw = { compilerOptions: { experimentalDecorators: true } } @@ -256,20 +330,20 @@ function orderedDependencies(deps: Record) { return Object.fromEntries(depsList) } -function globEntries(pattern: string | string[], config: ResolvedConfig) { +function globEntries(pattern: string | string[], environment: ScanEnvironment) { const resolvedPatterns = arraify(pattern) if (resolvedPatterns.every((str) => !glob.isDynamicPattern(str))) { return resolvedPatterns.map((p) => - normalizePath(path.resolve(config.root, p)), + normalizePath(path.resolve(environment.config.root, p)), ) } return glob(pattern, { - cwd: config.root, + cwd: environment.config.root, ignore: [ '**/node_modules/**', - `**/${config.build.outDir}/**`, + `**/${environment.config.build.outDir}/**`, // if there aren't explicit entries, also ignore other common folders - ...(config.optimizeDeps.entries + ...(environment.config.dev.optimizeDeps.entries ? [] : [`**/__tests__/**`, `**/coverage/**`]), ], @@ -287,24 +361,18 @@ const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i const contextRE = /\bcontext\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i function esbuildScanPlugin( - config: ResolvedConfig, - container: PluginContainer, + environment: ScanEnvironment, depImports: Record, missing: Record, entries: string[], ): Plugin { const seen = new Map() - - const resolve = async ( + async function resolveId( id: string, importer?: string, options?: ResolveIdOptions, - ) => { - const key = id + (importer && path.dirname(importer)) - if (seen.has(key)) { - return seen.get(key) - } - const resolved = await container.resolveId( + ): Promise { + return environment.pluginContainer.resolveId( id, importer && normalizePath(importer), { @@ -312,14 +380,26 @@ function esbuildScanPlugin( scan: true, }, ) + } + const resolve = async ( + id: string, + importer?: string, + options?: ResolveIdOptions, + ) => { + const key = id + (importer && path.dirname(importer)) + if (seen.has(key)) { + return seen.get(key) + } + const resolved = await resolveId(id, importer, options) const res = resolved?.id seen.set(key, res) return res } - const include = config.optimizeDeps?.include + const optimizeDepsOptions = environment.config.dev.optimizeDeps + const include = optimizeDepsOptions.include const exclude = [ - ...(config.optimizeDeps?.exclude || []), + ...(optimizeDepsOptions.exclude ?? []), '@vite/client', '@vite/env', ] @@ -347,7 +427,7 @@ function esbuildScanPlugin( const result = await transformGlobImport( transpiledContents, id, - config.root, + environment.config.root, resolve, ) @@ -393,7 +473,7 @@ function esbuildScanPlugin( // bare import resolve, and recorded as optimization dep. if ( isInNodeModules(resolved) && - isOptimizable(resolved, config.optimizeDeps) + isOptimizable(resolved, optimizeDepsOptions) ) return return { @@ -547,11 +627,11 @@ function esbuildScanPlugin( } if (isInNodeModules(resolved) || include?.includes(id)) { // dependency or forced included, externalize and stop crawling - if (isOptimizable(resolved, config.optimizeDeps)) { + if (isOptimizable(resolved, optimizeDepsOptions)) { depImports[id] = resolved } return externalUnlessEntry({ path: id }) - } else if (isScannable(resolved, config.optimizeDeps.extensions)) { + } else if (isScannable(resolved, optimizeDepsOptions.extensions)) { const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined // linked package, keep crawling return { @@ -612,7 +692,7 @@ function esbuildScanPlugin( if (resolved) { if ( shouldExternalizeDep(resolved, id) || - !isScannable(resolved, config.optimizeDeps.extensions) + !isScannable(resolved, optimizeDepsOptions.extensions) ) { return externalUnlessEntry({ path: id }) } @@ -637,13 +717,14 @@ function esbuildScanPlugin( let ext = path.extname(id).slice(1) if (ext === 'mjs') ext = 'js' + const esbuildConfig = environment.config.esbuild let contents = await fsp.readFile(id, 'utf-8') - if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) { - contents = config.esbuild.jsxInject + `\n` + contents + if (ext.endsWith('x') && esbuildConfig && esbuildConfig.jsxInject) { + contents = esbuildConfig.jsxInject + `\n` + contents } const loader = - config.optimizeDeps?.esbuildOptions?.loader?.[`.${ext}`] || + optimizeDepsOptions.esbuildOptions?.loader?.[`.${ext}`] ?? (ext as Loader) if (contents.includes('import.meta.glob')) { diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index 8ef2f228115477..c181ff9f9e9c84 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -2,18 +2,25 @@ import type { CustomPluginOptions, LoadResult, ObjectHook, - PluginContext, ResolveIdResult, Plugin as RollupPlugin, - TransformPluginContext, + PluginContext as RollupPluginContext, + TransformPluginContext as RollupTransformPluginContext, TransformResult, } from 'rollup' -export type { PluginContext } from 'rollup' -import type { ConfigEnv, ResolvedConfig, UserConfig } from './config' -import type { ServerHook } from './server' +import type { + ConfigEnv, + EnvironmentOptions, + ResolvedConfig, + UserConfig, +} from './config' +import type { ServerHook, ViteDevServer } from './server' import type { IndexHtmlTransform } from './plugins/html' -import type { ModuleNode } from './server/moduleGraph' -import type { HmrContext } from './server/hmr' +import type { EnvironmentModuleNode } from './server/moduleGraph' +import type { ModuleNode } from './server/mixedModuleGraph' +import type { HmrContext, HotUpdateOptions } from './server/hmr' +import type { DevEnvironment } from './server/environment' +import type { Environment } from './environment' import type { PreviewServerHook } from './preview' /** @@ -36,8 +43,137 @@ import type { PreviewServerHook } from './preview' * * If a plugin should be applied only for server or build, a function format * config file can be used to conditional determine the plugins to use. + * + * The current environment can be accessed from the context for the all non-global + * hooks (it is not available in config, configResolved, configureServer, etc). + * It can be a dev, build, or scan environment. + * Plugins can use this.environment.mode === 'dev' to guard for dev specific APIs. + */ + +export interface PluginContextExtension { + /** + * Vite-specific environment instance + */ + environment: Environment +} + +export interface HotUpdatePluginContext { + environment: DevEnvironment +} + +export interface PluginContext + extends RollupPluginContext, + PluginContextExtension {} + +export interface ResolveIdPluginContext + extends RollupPluginContext, + PluginContextExtension {} + +export interface TransformPluginContext + extends RollupTransformPluginContext, + PluginContextExtension {} + +// Argument Rollup types to have the PluginContextExtension +declare module 'rollup' { + export interface PluginContext extends PluginContextExtension {} +} + +/** + * There are two types of plugins in Vite. App plugins and environment plugins. + * Environment Plugins are defined by a constructor function that will be called + * once per each environment allowing users to have completely different plugins + * for each of them. The constructor gets the resolved environment after the server + * and builder has already been created simplifying config access and cache + * management for for environment specific plugins. + * Environment Plugins are closer to regular rollup plugins. They can't define + * app level hooks (like config, configResolved, configureServer, etc). */ export interface Plugin extends RollupPlugin { + /** + * Perform custom handling of HMR updates. + * The handler receives an options containing changed filename, timestamp, a + * list of modules affected by the file change, and the dev server instance. + * + * - The hook can return a filtered list of modules to narrow down the update. + * e.g. for a Vue SFC, we can narrow down the part to update by comparing + * the descriptors. + * + * - The hook can also return an empty array and then perform custom updates + * by sending a custom hmr payload via environment.hot.send(). + * + * - If the hook doesn't return a value, the hmr update will be performed as + * normal. + */ + hotUpdate?: ObjectHook< + ( + this: HotUpdatePluginContext, + options: HotUpdateOptions, + ) => + | Array + | void + | Promise | void> + > + + /** + * extend hooks with ssr flag + */ + resolveId?: ObjectHook< + ( + this: ResolveIdPluginContext, + source: string, + importer: string | undefined, + options: { + attributes: Record + custom?: CustomPluginOptions + ssr?: boolean + /** + * @internal + */ + scan?: boolean + isEntry: boolean + }, + ) => Promise | ResolveIdResult + > + load?: ObjectHook< + ( + this: PluginContext, + id: string, + options?: { + ssr?: boolean + /** + * @internal + */ + html?: boolean + }, + ) => Promise | LoadResult + > + transform?: ObjectHook< + ( + this: TransformPluginContext, + code: string, + id: string, + options?: { + ssr?: boolean + }, + ) => Promise | TransformResult + > + /** + * Opt-in this plugin into the shared plugins pipeline. + * For backward-compatibility, plugins are re-recreated for each environment + * during `vite build --app` + * We have an opt-in per plugin, and a general `builder.sharedPlugins` + * In a future major, we'll flip the default to be shared by default + * @experimental + */ + sharedDuringBuild?: boolean + /** + * Opt-in this plugin into per-environment buildStart and buildEnd during dev. + * For backward-compatibility, the buildStart hook is called only once during + * dev, for the client environment. Plugins can opt-in to be called + * per-environment, aligning with the build hook behavior. + * @experimental + */ + perEnvironmentStartEndDuringDev?: boolean /** * Enforce plugin invocation tier similar to webpack loaders. Hooks ordering * is still subject to the `order` property in the hook object. @@ -59,6 +195,11 @@ export interface Plugin extends RollupPlugin { | 'serve' | 'build' | ((this: void, config: UserConfig, env: ConfigEnv) => boolean) + /** + * Define environments where this plugin should be active + * By default, the plugin is active in all environments + */ + applyToEnvironment?: (environment: Environment) => boolean /** * Modify vite config before it's resolved. The hook can either mutate the * passed-in config directly, or return a partial config object that will be @@ -78,6 +219,28 @@ export interface Plugin extends RollupPlugin { | void | Promise | null | void> > + /** + * Modify environment configs before it's resolved. The hook can either mutate the + * passed-in environment config directly, or return a partial config object that will be + * deeply merged into existing config. + * This hook is called for each environment with a partially resolved environment config + * that already accounts for the default environment config values set at the root level. + * If plugins need to modify the config of a given environment, they should do it in this + * hook instead of the config hook. Leaving the config hook only for modifying the root + * default environment config. + */ + configEnvironment?: ObjectHook< + ( + this: void, + name: string, + config: EnvironmentOptions, + env: ConfigEnv, + ) => + | EnvironmentOptions + | null + | void + | Promise + > /** * Use this hook to read and store the final resolved vite config. */ @@ -120,6 +283,7 @@ export interface Plugin extends RollupPlugin { * `{ order: 'pre', handler: hook }` */ transformIndexHtml?: IndexHtmlTransform + /** * Perform custom handling of HMR updates. * The handler receives a context containing changed filename, timestamp, a @@ -141,42 +305,6 @@ export interface Plugin extends RollupPlugin { ctx: HmrContext, ) => Array | void | Promise | void> > - - /** - * extend hooks with ssr flag - */ - resolveId?: ObjectHook< - ( - this: PluginContext, - source: string, - importer: string | undefined, - options: { - attributes: Record - custom?: CustomPluginOptions - ssr?: boolean - /** - * @internal - */ - scan?: boolean - isEntry: boolean - }, - ) => Promise | ResolveIdResult - > - load?: ObjectHook< - ( - this: PluginContext, - id: string, - options?: { ssr?: boolean }, - ) => Promise | LoadResult - > - transform?: ObjectHook< - ( - this: TransformPluginContext, - code: string, - id: string, - options?: { ssr?: boolean }, - ) => Promise | TransformResult - > } export type HookHandler = T extends ObjectHook ? H : T @@ -184,3 +312,18 @@ export type HookHandler = T extends ObjectHook ? H : T export type PluginWithRequiredHook = Plugin & { [P in K]: NonNullable } + +type Thenable = T | Promise + +type FalsyPlugin = false | null | undefined + +export type PluginOption = Thenable + +export function resolveEnvironmentPlugins(environment: Environment): Plugin[] { + return environment + .getTopLevelConfig() + .plugins.filter( + (plugin) => + !plugin.applyToEnvironment || plugin.applyToEnvironment(environment), + ) +} diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index 22bf21a8f1a80d..4da87390cf20ad 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -3,18 +3,14 @@ import { parse as parseUrl } from 'node:url' import fsp from 'node:fs/promises' import { Buffer } from 'node:buffer' import * as mrmime from 'mrmime' -import type { - NormalizedOutputOptions, - PluginContext, - RenderedChunk, -} from 'rollup' +import type { NormalizedOutputOptions, RenderedChunk } from 'rollup' import MagicString from 'magic-string' import colors from 'picocolors' import { createToImportMetaURLBasedRelativeRuntime, toOutputFilePathInJS, } from '../build' -import type { Plugin } from '../plugin' +import type { Plugin, PluginContext } from '../plugin' import type { ResolvedConfig } from '../config' import { checkPublicFile } from '../publicDir' import { @@ -29,15 +25,15 @@ import { urlRE, } from '../utils' import { DEFAULT_ASSETS_INLINE_LIMIT, FS_PREFIX } from '../constants' -import type { ModuleGraph } from '../server/moduleGraph' import { cleanUrl, withTrailingSlash } from '../../shared/utils' +import type { Environment } from '../environment' // referenceId is base64url but replaces - with $ export const assetUrlRE = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g const jsSourceMapRE = /\.[cm]?js\.map$/ -const assetCache = new WeakMap>() +const assetCache = new WeakMap>() // chunk.name is the basename for the asset ignoring the directory structure // For the manifest, we need to preserve the original file path and isEntry @@ -46,8 +42,8 @@ export interface GeneratedAssetMeta { originalFileName: string isEntry?: boolean } -export const generatedAssets = new WeakMap< - ResolvedConfig, +export const generatedAssetsMap = new WeakMap< + Environment, Map >() @@ -62,15 +58,15 @@ export function registerCustomMime(): void { } export function renderAssetUrlInJS( - ctx: PluginContext, - config: ResolvedConfig, + pluginContext: PluginContext, chunk: RenderedChunk, opts: NormalizedOutputOptions, code: string, ): MagicString | undefined { + const { environment } = pluginContext const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( opts.format, - config.isWorker, + environment.config.isWorker, ) let match: RegExpExecArray | null @@ -88,15 +84,15 @@ export function renderAssetUrlInJS( while ((match = assetUrlRE.exec(code))) { s ||= new MagicString(code) const [full, referenceId, postfix = ''] = match - const file = ctx.getFileName(referenceId) + const file = pluginContext.getFileName(referenceId) chunk.viteMetadata!.importedAssets.add(cleanUrl(file)) const filename = file + postfix const replacement = toOutputFilePathInJS( + environment, filename, 'asset', chunk.fileName, 'js', - config, toRelativeRuntime, ) const replacementString = @@ -108,18 +104,20 @@ export function renderAssetUrlInJS( // Replace __VITE_PUBLIC_ASSET__5aA0Ddc0__ with absolute paths - const publicAssetUrlMap = publicAssetUrlCache.get(config)! + const publicAssetUrlMap = publicAssetUrlCache.get( + environment.getTopLevelConfig(), + )! publicAssetUrlRE.lastIndex = 0 while ((match = publicAssetUrlRE.exec(code))) { s ||= new MagicString(code) const [full, hash] = match const publicUrl = publicAssetUrlMap.get(hash)!.slice(1) const replacement = toOutputFilePathInJS( + environment, publicUrl, 'public', chunk.fileName, 'js', - config, toRelativeRuntime, ) const replacementString = @@ -138,18 +136,14 @@ export function renderAssetUrlInJS( export function assetPlugin(config: ResolvedConfig): Plugin { registerCustomMime() - let moduleGraph: ModuleGraph | undefined - return { name: 'vite:asset', - buildStart() { - assetCache.set(config, new Map()) - generatedAssets.set(config, new Map()) - }, + perEnvironmentStartEndDuringDev: true, - configureServer(server) { - moduleGraph = server.moduleGraph + buildStart() { + assetCache.set(this.environment, new Map()) + generatedAssetsMap.set(this.environment, new Map()) }, resolveId(id) { @@ -186,14 +180,14 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } id = removeUrlQuery(id) - let url = await fileToUrl(id, config, this) + let url = await fileToUrl(this, id) // Inherit HMR timestamp if this asset was invalidated - if (moduleGraph) { - const mod = moduleGraph.getModuleById(id) - if (mod && mod.lastHMRTimestamp > 0) { - url = injectQuery(url, `t=${mod.lastHMRTimestamp}`) - } + const environment = this.environment + const mod = + environment.mode === 'dev' && environment.moduleGraph.getModuleById(id) + if (mod && mod.lastHMRTimestamp > 0) { + url = injectQuery(url, `t=${mod.lastHMRTimestamp}`) } return { @@ -209,12 +203,12 @@ export function assetPlugin(config: ResolvedConfig): Plugin { }, renderChunk(code, chunk, opts) { - const s = renderAssetUrlInJS(this, config, chunk, opts, code) + const s = renderAssetUrlInJS(this, chunk, opts, code) if (s) { return { code: s.toString(), - map: config.build.sourcemap + map: this.environment.config.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null, } @@ -241,8 +235,7 @@ export function assetPlugin(config: ResolvedConfig): Plugin { // do not emit assets for SSR build if ( config.command === 'build' && - config.build.ssr && - !config.build.ssrEmitAssets + !this.environment.config.build.emitAssets ) { for (const file in bundle) { if ( @@ -259,14 +252,14 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } export async function fileToUrl( + pluginContext: PluginContext, id: string, - config: ResolvedConfig, - ctx: PluginContext, ): Promise { - if (config.command === 'serve') { - return fileToDevUrl(id, config) + const { environment } = pluginContext + if (environment.config.command === 'serve') { + return fileToDevUrl(id, environment.getTopLevelConfig()) } else { - return fileToBuiltUrl(id, config, ctx) + return fileToBuiltUrl(pluginContext, id) } } @@ -341,17 +334,18 @@ function isGitLfsPlaceholder(content: Buffer): boolean { * and returns the resolved public URL */ async function fileToBuiltUrl( - id: string, - config: ResolvedConfig, pluginContext: PluginContext, + id: string, skipPublicCheck = false, forceInline?: boolean, ): Promise { - if (!skipPublicCheck && checkPublicFile(id, config)) { - return publicFileToBuiltUrl(id, config) + const environment = pluginContext.environment + const topLevelConfig = environment.getTopLevelConfig() + if (!skipPublicCheck && checkPublicFile(id, topLevelConfig)) { + return publicFileToBuiltUrl(id, topLevelConfig) } - const cache = assetCache.get(config)! + const cache = assetCache.get(environment)! const cached = cache.get(id) if (cached) { return cached @@ -361,9 +355,9 @@ async function fileToBuiltUrl( const content = await fsp.readFile(file) let url: string - if (shouldInline(config, file, id, content, pluginContext, forceInline)) { - if (config.build.lib && isGitLfsPlaceholder(content)) { - config.logger.warn( + if (shouldInline(pluginContext, file, id, content, forceInline)) { + if (environment.config.build.lib && isGitLfsPlaceholder(content)) { + environment.logger.warn( colors.yellow(`Inlined file ${id} was not downloaded via Git LFS`), ) } @@ -380,7 +374,9 @@ async function fileToBuiltUrl( const { search, hash } = parseUrl(id) const postfix = (search || '') + (hash || '') - const originalFileName = normalizePath(path.relative(config.root, file)) + const originalFileName = normalizePath( + path.relative(environment.config.root, file), + ) const referenceId = pluginContext.emitFile({ type: 'asset', // Ignore directory structure for asset file names @@ -388,7 +384,7 @@ async function fileToBuiltUrl( originalFileName, source: content, }) - generatedAssets.get(config)!.set(referenceId, { originalFileName }) + generatedAssetsMap.get(environment)!.set(referenceId, { originalFileName }) url = `__VITE_ASSET__${referenceId}__${postfix ? `$_${postfix}__` : ``}` } @@ -398,23 +394,22 @@ async function fileToBuiltUrl( } export async function urlToBuiltUrl( + pluginContext: PluginContext, url: string, importer: string, - config: ResolvedConfig, - pluginContext: PluginContext, forceInline?: boolean, ): Promise { - if (checkPublicFile(url, config)) { - return publicFileToBuiltUrl(url, config) + const topLevelConfig = pluginContext.environment.getTopLevelConfig() + if (checkPublicFile(url, topLevelConfig)) { + return publicFileToBuiltUrl(url, topLevelConfig) } const file = url[0] === '/' - ? path.join(config.root, url) + ? path.join(topLevelConfig.root, url) : path.join(path.dirname(importer), url) return fileToBuiltUrl( - file, - config, pluginContext, + file, // skip public check since we just did it above true, forceInline, @@ -422,23 +417,24 @@ export async function urlToBuiltUrl( } const shouldInline = ( - config: ResolvedConfig, + pluginContext: PluginContext, file: string, id: string, content: Buffer, - pluginContext: PluginContext, forceInline: boolean | undefined, ): boolean => { - if (config.build.lib) return true + const environment = pluginContext.environment + const { assetsInlineLimit } = environment.config.build + if (environment.config.build.lib) return true if (pluginContext.getModuleInfo(id)?.isEntry) return false if (forceInline !== undefined) return forceInline let limit: number - if (typeof config.build.assetsInlineLimit === 'function') { - const userShouldInline = config.build.assetsInlineLimit(file, content) + if (typeof assetsInlineLimit === 'function') { + const userShouldInline = assetsInlineLimit(file, content) if (userShouldInline != null) return userShouldInline limit = DEFAULT_ASSETS_INLINE_LIMIT } else { - limit = Number(config.build.assetsInlineLimit) + limit = Number(assetsInlineLimit) } if (file.endsWith('.html')) return false // Don't inline SVG with fragments, as they are meant to be reused diff --git a/packages/vite/src/node/plugins/assetImportMetaUrl.ts b/packages/vite/src/node/plugins/assetImportMetaUrl.ts index 588f6e08b07c3d..1b2473137c5431 100644 --- a/packages/vite/src/node/plugins/assetImportMetaUrl.ts +++ b/packages/vite/src/node/plugins/assetImportMetaUrl.ts @@ -3,10 +3,11 @@ import MagicString from 'magic-string' import { stripLiteral } from 'strip-literal' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' -import type { ResolveFn } from '../' import { injectQuery, isParentDirectory, transformStableResult } from '../utils' import { CLIENT_ENTRY } from '../constants' import { slash } from '../../shared/utils' +import { createBackCompatIdResolver } from '../idResolver' +import type { ResolveIdFn } from '../idResolver' import { fileToUrl } from './asset' import { preloadHelperId } from './importAnalysisBuild' import type { InternalResolveOptions } from './resolve' @@ -25,7 +26,7 @@ import { hasViteIgnoreRE } from './importAnalysis' */ export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin { const { publicDir } = config - let assetResolver: ResolveFn + let assetResolver: ResolveIdFn const fsResolveOptions: InternalResolveOptions = { ...config.resolve, @@ -33,15 +34,15 @@ export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin { isProduction: config.isProduction, isBuild: config.command === 'build', packageCache: config.packageCache, - ssrConfig: config.ssr, asSrc: true, } return { name: 'vite:asset-import-meta-url', - async transform(code, id, options) { + async transform(code, id) { + const { environment } = this if ( - !options?.ssr && + environment.config.consumer === 'client' && id !== preloadHelperId && id !== CLIENT_ENTRY && code.includes('new URL') && @@ -106,13 +107,13 @@ export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin { file = slash(path.resolve(path.dirname(id), url)) file = tryFsResolve(file, fsResolveOptions) ?? file } else { - assetResolver ??= config.createResolver({ + assetResolver ??= createBackCompatIdResolver(config, { extensions: [], mainFields: [], tryIndex: false, preferRelative: true, }) - file = await assetResolver(url, id) + file = await assetResolver(environment, url, id) file ??= url[0] === '/' ? slash(path.join(publicDir, url)) @@ -126,9 +127,9 @@ export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin { try { if (publicDir && isParentDirectory(publicDir, file)) { const publicPath = '/' + path.posix.relative(publicDir, file) - builtUrl = await fileToUrl(publicPath, config, this) + builtUrl = await fileToUrl(this, publicPath) } else { - builtUrl = await fileToUrl(file, config, this) + builtUrl = await fileToUrl(this, file) } } catch { // do nothing, we'll log a warning after this diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index c66f3877eca822..9ad7c47ca91c02 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -90,25 +90,22 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { } }, async transform(code, id, options) { + // TODO: Remove options?.ssr, Vitest currently hijacks this plugin + const ssr = options?.ssr ?? this.environment.config.consumer === 'server' if (id === normalizedClientEntry || id === normalizedEnvEntry) { return injectConfigValues(code) - } else if (!options?.ssr && code.includes('process.env.NODE_ENV')) { + } else if (!ssr && code.includes('process.env.NODE_ENV')) { // replace process.env.NODE_ENV instead of defining a global // for it to avoid shimming a `process` object during dev, // avoiding inconsistencies between dev and build const nodeEnv = config.define?.['process.env.NODE_ENV'] || JSON.stringify(process.env.NODE_ENV || config.mode) - return await replaceDefine( - code, - id, - { - 'process.env.NODE_ENV': nodeEnv, - 'global.process.env.NODE_ENV': nodeEnv, - 'globalThis.process.env.NODE_ENV': nodeEnv, - }, - config, - ) + return await replaceDefine(this.environment, code, id, { + 'process.env.NODE_ENV': nodeEnv, + 'global.process.env.NODE_ENV': nodeEnv, + 'globalThis.process.env.NODE_ENV': nodeEnv, + }) } }, } diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 1f98267c3059d3..c822968afd8954 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -28,8 +28,7 @@ import { formatMessages, transform } from 'esbuild' import type { RawSourceMap } from '@ampproject/remapping' import { WorkerWithFallback } from 'artichokie' import { getCodeWithSourcemap, injectSourcesContent } from '../server/sourcemap' -import type { ModuleNode } from '../server/moduleGraph' -import type { ResolveFn, ViteDevServer } from '../' +import type { EnvironmentModuleNode } from '../server/moduleGraph' import { createToImportMetaURLBasedRelativeRuntime, resolveUserExternal, @@ -70,13 +69,17 @@ import { } from '../utils' import type { Logger } from '../logger' import { cleanUrl, slash } from '../../shared/utils' +import { createBackCompatIdResolver } from '../idResolver' +import type { ResolveIdFn } from '../idResolver' +import { PartialEnvironment } from '../baseEnvironment' import type { TransformPluginContext } from '../server/pluginContainer' +import type { DevEnvironment } from '..' import { addToHTMLProxyTransformResult } from './html' import { assetUrlRE, fileToDevUrl, fileToUrl, - generatedAssets, + generatedAssetsMap, publicAssetUrlCache, publicAssetUrlRE, publicFileToBuiltUrl, @@ -258,7 +261,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { const isBuild = config.command === 'build' let moduleCache: Map> - const resolveUrl = config.createResolver({ + const idResolver = createBackCompatIdResolver(config, { preferRelative: true, tryIndex: false, extensions: [], @@ -321,6 +324,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { }, async transform(raw, id) { + const { environment } = this if ( !isCSSRequest(id) || commonjsProxyRE.test(id) || @@ -328,6 +332,9 @@ export function cssPlugin(config: ResolvedConfig): Plugin { ) { return } + const resolveUrl = (url: string, importer?: string) => + idResolver(environment, url, importer) + const urlReplacer: CssUrlReplacer = async (url, importer) => { const decodedUrl = decodeURI(url) if (checkPublicFile(decodedUrl, config)) { @@ -341,7 +348,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { let resolved = await resolveUrl(id, importer) if (resolved) { if (fragment) resolved += '#' + fragment - return fileToUrl(resolved, config, this) + return fileToUrl(this, resolved) } if (config.command === 'build') { const isExternal = config.build.rollupOptions.external @@ -369,9 +376,9 @@ export function cssPlugin(config: ResolvedConfig): Plugin { deps, map, } = await compileCSS( + environment, id, raw, - config, preprocessorWorkerController!, urlReplacer, ) @@ -555,6 +562,8 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { }, async renderChunk(code, chunk, opts) { + const generatedAssets = generatedAssetsMap.get(this.environment)! + let chunkCSS = '' // the chunk is empty if it's a dynamic entry chunk that only contains a CSS import const isJsChunkEmpty = code === '' && !chunk.isEntry @@ -713,16 +722,16 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { originalFileName, source: content, }) - generatedAssets.get(config)!.set(referenceId, { originalFileName }) + generatedAssets.set(referenceId, { originalFileName }) const filename = this.getFileName(referenceId) chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) const replacement = toOutputFilePathInJS( + this.environment, filename, 'asset', chunk.fileName, 'js', - config, toRelativeRuntime, ) const replacementString = @@ -771,9 +780,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { originalFileName, source: chunkCSS, }) - generatedAssets - .get(config)! - .set(referenceId, { originalFileName, isEntry }) + generatedAssets.set(referenceId, { originalFileName, isEntry }) chunk.viteMetadata!.importedCss.add(this.getFileName(referenceId)) } else if (!config.build.ssr) { // legacy build and inline css @@ -787,13 +794,8 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { chunkCSS = await finalizeCss(chunkCSS, true, config) let cssString = JSON.stringify(chunkCSS) cssString = - renderAssetUrlInJS( - this, - config, - chunk, - opts, - cssString, - )?.toString() || cssString + renderAssetUrlInJS(this, chunk, opts, cssString)?.toString() || + cssString const style = `__vite_style__` const injectCode = `var ${style} = document.createElement('style');` + @@ -959,15 +961,9 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { } export function cssAnalysisPlugin(config: ResolvedConfig): Plugin { - let server: ViteDevServer - return { name: 'vite:css-analysis', - configureServer(_server) { - server = _server - }, - async transform(_, id, options) { if ( !isCSSRequest(id) || @@ -977,8 +973,7 @@ export function cssAnalysisPlugin(config: ResolvedConfig): Plugin { return } - const ssr = options?.ssr === true - const { moduleGraph } = server + const { moduleGraph } = this.environment as DevEnvironment const thisModule = moduleGraph.getModuleById(id) // Handle CSS @import dependency HMR and other added modules via this.addWatchFile. @@ -995,14 +990,13 @@ export function cssAnalysisPlugin(config: ResolvedConfig): Plugin { if (pluginImports) { // record deps in the module graph so edits to @import css can trigger // main import to hot update - const depModules = new Set() + const depModules = new Set() for (const file of pluginImports) { depModules.add( isCSSRequest(file) ? moduleGraph.createFileOnlyEntry(file) : await moduleGraph.ensureEntryFromUrl( fileToDevUrl(file, config, /* skipBase */ true), - ssr, ), ) } @@ -1015,7 +1009,6 @@ export function cssAnalysisPlugin(config: ResolvedConfig): Plugin { new Set(), null, isSelfAccepting, - ssr, ) } else { thisModule.isSelfAccepting = isSelfAccepting @@ -1061,32 +1054,29 @@ export function getEmptyChunkReplacer( } interface CSSAtImportResolvers { - css: ResolveFn - sass: ResolveFn - less: ResolveFn + css: ResolveIdFn + sass: ResolveIdFn + less: ResolveIdFn } function createCSSResolvers(config: ResolvedConfig): CSSAtImportResolvers { - let cssResolve: ResolveFn | undefined - let sassResolve: ResolveFn | undefined - let lessResolve: ResolveFn | undefined + let cssResolve: ResolveIdFn | undefined + let sassResolve: ResolveIdFn | undefined + let lessResolve: ResolveIdFn | undefined return { get css() { - return ( - cssResolve || - (cssResolve = config.createResolver({ - extensions: ['.css'], - mainFields: ['style'], - conditions: ['style'], - tryIndex: false, - preferRelative: true, - })) - ) + return (cssResolve ??= createBackCompatIdResolver(config, { + extensions: ['.css'], + mainFields: ['style'], + conditions: ['style'], + tryIndex: false, + preferRelative: true, + })) }, get sass() { if (!sassResolve) { - const resolver = config.createResolver({ + const resolver = createBackCompatIdResolver(config, { extensions: ['.scss', '.sass', '.css'], mainFields: ['sass', 'style'], conditions: ['sass', 'style'], @@ -1095,8 +1085,8 @@ function createCSSResolvers(config: ResolvedConfig): CSSAtImportResolvers { preferRelative: true, }) sassResolve = async (...args) => { - if (args[0].startsWith('file://')) { - args[0] = fileURLToPath(args[0]) + if (args[1].startsWith('file://')) { + args[1] = fileURLToPath(args[1]) } return resolver(...args) } @@ -1105,16 +1095,13 @@ function createCSSResolvers(config: ResolvedConfig): CSSAtImportResolvers { }, get less() { - return ( - lessResolve || - (lessResolve = config.createResolver({ - extensions: ['.less', '.css'], - mainFields: ['less', 'style'], - conditions: ['less', 'style'], - tryIndex: false, - preferRelative: true, - })) - ) + return (lessResolve ??= createBackCompatIdResolver(config, { + extensions: ['.less', '.css'], + mainFields: ['less', 'style'], + conditions: ['less', 'style'], + tryIndex: false, + preferRelative: true, + })) }, } } @@ -1126,14 +1113,17 @@ function getCssResolversKeys( } async function compileCSSPreprocessors( + environment: PartialEnvironment, id: string, lang: PreprocessLang, code: string, - config: ResolvedConfig, workerController: PreprocessorWorkerController, ): Promise<{ code: string; map?: ExistingRawSourceMap; deps?: Set }> { + const { config } = environment const { preprocessorOptions, devSourcemap } = config.css ?? {} - const atImportResolvers = getAtImportResolvers(config) + const atImportResolvers = getAtImportResolvers( + environment.getTopLevelConfig(), + ) const preProcessor = workerController[lang] let opts = (preprocessorOptions && preprocessorOptions[lang]) || {} @@ -1161,6 +1151,7 @@ async function compileCSSPreprocessors( opts.enableSourcemap = devSourcemap ?? false const preprocessResult = await preProcessor( + environment, code, config.root, opts, @@ -1206,9 +1197,9 @@ function getAtImportResolvers(config: ResolvedConfig) { } async function compileCSS( + environment: PartialEnvironment, id: string, code: string, - config: ResolvedConfig, workerController: PreprocessorWorkerController, urlReplacer?: CssUrlReplacer, ): Promise<{ @@ -1218,8 +1209,9 @@ async function compileCSS( modules?: Record deps?: Set }> { + const { config } = environment if (config.css?.transformer === 'lightningcss') { - return compileLightningCSS(id, code, config, urlReplacer) + return compileLightningCSS(id, code, environment, urlReplacer) } const { modules: modulesOptions, devSourcemap } = config.css || {} @@ -1229,7 +1221,9 @@ async function compileCSS( const needInlineImport = code.includes('@import') const hasUrl = cssUrlRE.test(code) || cssImageSetRE.test(code) const lang = CSS_LANGS_RE.exec(id)?.[1] as CssLang | undefined - const postcssConfig = await resolvePostcssConfig(config) + const postcssConfig = await resolvePostcssConfig( + environment.getTopLevelConfig(), + ) // 1. plain css that needs no processing if ( @@ -1249,10 +1243,10 @@ async function compileCSS( let preprocessorMap: ExistingRawSourceMap | undefined if (isPreProcessor(lang)) { const preprocessorResult = await compileCSSPreprocessors( + environment, id, lang, code, - config, workerController, ) code = preprocessorResult.code @@ -1261,7 +1255,9 @@ async function compileCSS( } // 3. postcss - const atImportResolvers = getAtImportResolvers(config) + const atImportResolvers = getAtImportResolvers( + environment.getTopLevelConfig(), + ) const postcssOptions = (postcssConfig && postcssConfig.options) || {} const postcssPlugins = @@ -1271,12 +1267,16 @@ async function compileCSS( postcssPlugins.unshift( (await importPostcssImport()).default({ async resolve(id, basedir) { - const publicFile = checkPublicFile(id, config) + const publicFile = checkPublicFile( + id, + environment.getTopLevelConfig(), + ) if (publicFile) { return publicFile } const resolved = await atImportResolvers.css( + environment, id, path.join(basedir, '*'), ) @@ -1289,7 +1289,7 @@ async function compileCSS( // but we've shimmed to remove the `resolve` dep to cut on bundle size. // warn here to provide a better error message. if (!path.isAbsolute(id)) { - config.logger.error( + environment.logger.error( colors.red( `Unable to resolve \`@import "${id}"\` from ${basedir}`, ), @@ -1303,10 +1303,10 @@ async function compileCSS( const lang = CSS_LANGS_RE.exec(id)?.[1] as CssLang | undefined if (isPreProcessor(lang)) { const result = await compileCSSPreprocessors( + environment, id, lang, code, - config, workerController, ) result.deps?.forEach((dep) => deps.add(dep)) @@ -1326,7 +1326,7 @@ async function compileCSS( postcssPlugins.push( UrlRewritePostcssPlugin({ replacer: urlReplacer, - logger: config.logger, + logger: environment.logger, }), ) } @@ -1348,7 +1348,11 @@ async function compileCSS( }, async resolve(id: string, importer: string) { for (const key of getCssResolversKeys(atImportResolvers)) { - const resolved = await atImportResolvers[key](id, importer) + const resolved = await atImportResolvers[key]( + environment, + id, + importer, + ) if (resolved) { return path.resolve(resolved) } @@ -1426,7 +1430,7 @@ async function compileCSS( } : undefined, )}` - config.logger.warn(colors.yellow(msg)) + environment.logger.warn(colors.yellow(msg)) } } } catch (e) { @@ -1517,7 +1521,17 @@ export async function preprocessCSS( workerController = alwaysFakeWorkerWorkerControllerCache } - return await compileCSS(filename, code, config, workerController) + // `preprocessCSS` is hardcoded to use the client environment. + // Since CSS is usually only consumed by the client, and the server builds need to match + // the client asset chunk name to deduplicate the link reference, this may be fine in most + // cases. We should revisit in the future if there's a case to preprocess CSS based on a + // different environment instance. + const environment: PartialEnvironment = new PartialEnvironment( + 'client', + config, + ) + + return await compileCSS(environment, filename, code, workerController) } export async function formatPostcssSourceMap( @@ -1960,6 +1974,7 @@ type StylusStylePreprocessorOptions = StylePreprocessorOptions & { type StylePreprocessor = { process: ( + environment: PartialEnvironment, source: string, root: string, options: StylePreprocessorOptions, @@ -1970,6 +1985,7 @@ type StylePreprocessor = { type SassStylePreprocessor = { process: ( + environment: PartialEnvironment, source: string, root: string, options: SassStylePreprocessorOptions, @@ -1980,6 +1996,7 @@ type SassStylePreprocessor = { type StylusStylePreprocessor = { process: ( + environment: PartialEnvironment, source: string, root: string, options: StylusStylePreprocessorOptions, @@ -2095,6 +2112,7 @@ function fixScssBugImportValue( // #region Sass // .scss/.sass processor const makeScssWorker = ( + environment: PartialEnvironment, resolvers: CSSAtImportResolvers, alias: Alias[], maxWorkers: number | undefined, @@ -2106,10 +2124,11 @@ const makeScssWorker = ( filename: string, ) => { importer = cleanScssBugUrl(importer) - const resolved = await resolvers.sass(url, importer) + const resolved = await resolvers.sass(environment, url, importer) if (resolved) { try { const data = await rebaseUrls( + environment, resolved, filename, alias, @@ -2205,6 +2224,7 @@ const makeScssWorker = ( } const makeModernScssWorker = ( + environment: PartialEnvironment, resolvers: CSSAtImportResolvers, alias: Alias[], maxWorkers: number | undefined, @@ -2214,12 +2234,19 @@ const makeModernScssWorker = ( importer: string, ): Promise => { importer = cleanScssBugUrl(importer) - const resolved = await resolvers.sass(url, importer) + const resolved = await resolvers.sass(environment, url, importer) return resolved ?? null } const internalLoad = async (file: string, rootFile: string) => { - const result = await rebaseUrls(file, rootFile, alias, '$', resolvers.sass) + const result = await rebaseUrls( + environment, + file, + rootFile, + alias, + '$', + resolvers.sass, + ) if (result.contents) { return result.contents } @@ -2310,6 +2337,7 @@ const makeModernScssWorker = ( // however sharing code between two is hard because // makeModernScssWorker above needs function inlined for worker. const makeModernCompilerScssWorker = ( + environment: PartialEnvironment, resolvers: CSSAtImportResolvers, alias: Alias[], _maxWorkers: number | undefined, @@ -2333,7 +2361,11 @@ const makeModernCompilerScssWorker = ( const importer = context.containingUrl ? fileURLToPath(context.containingUrl) : options.filename - const resolved = await resolvers.sass(url, cleanScssBugUrl(importer)) + const resolved = await resolvers.sass( + environment, + url, + cleanScssBugUrl(importer), + ) return resolved ? pathToFileURL(resolved) : null }, async load(canonicalUrl) { @@ -2345,6 +2377,7 @@ const makeModernCompilerScssWorker = ( syntax = 'css' } const result = await rebaseUrls( + environment, fileURLToPath(canonicalUrl), options.filename, alias, @@ -2398,7 +2431,7 @@ const scssProcessor = ( worker.stop() } }, - async process(source, root, options, resolvers) { + async process(environment, source, root, options, resolvers) { const sassPackage = loadSassPackage(root) // TODO: change default in v6 // options.api ?? sassPackage.name === "sass-embedded" ? "modern-compiler" : "modern"; @@ -2408,10 +2441,21 @@ const scssProcessor = ( workerMap.set( options.alias, api === 'modern-compiler' - ? makeModernCompilerScssWorker(resolvers, options.alias, maxWorkers) + ? makeModernCompilerScssWorker( + environment, + resolvers, + options.alias, + maxWorkers, + ) : api === 'modern' - ? makeModernScssWorker(resolvers, options.alias, maxWorkers) + ? makeModernScssWorker( + environment, + resolvers, + options.alias, + maxWorkers, + ) : makeScssWorker( + environment, resolvers, options.alias, maxWorkers, @@ -2472,11 +2516,12 @@ const scssProcessor = ( * root file as base. */ async function rebaseUrls( + environment: PartialEnvironment, file: string, rootFile: string, alias: Alias[], variablePrefix: string, - resolver: ResolveFn, + resolver: ResolveIdFn, ): Promise<{ file: string; contents?: string }> { file = path.resolve(file) // ensure os-specific flashes // in the same dir, no need to rebase @@ -2511,7 +2556,8 @@ async function rebaseUrls( return url } } - const absolute = (await resolver(url, file)) || path.resolve(fileDir, url) + const absolute = + (await resolver(environment, url, file)) || path.resolve(fileDir, url) const relative = path.relative(rootDir, absolute) return normalizePath(relative) } @@ -2538,6 +2584,7 @@ async function rebaseUrls( // #region Less // .less const makeLessWorker = ( + environment: PartialEnvironment, resolvers: CSSAtImportResolvers, alias: Alias[], maxWorkers: number | undefined, @@ -2547,10 +2594,15 @@ const makeLessWorker = ( dir: string, rootFile: string, ) => { - const resolved = await resolvers.less(filename, path.join(dir, '*')) + const resolved = await resolvers.less( + environment, + filename, + path.join(dir, '*'), + ) if (!resolved) return undefined const result = await rebaseUrls( + environment, resolved, rootFile, alias, @@ -2668,13 +2720,13 @@ const lessProcessor = (maxWorkers: number | undefined): StylePreprocessor => { worker.stop() } }, - async process(source, root, options, resolvers) { + async process(environment, source, root, options, resolvers) { const lessPath = loadPreprocessorPath(PreprocessLang.less, root) if (!workerMap.has(options.alias)) { workerMap.set( options.alias, - makeLessWorker(resolvers, options.alias, maxWorkers), + makeLessWorker(environment, resolvers, options.alias, maxWorkers), ) } const worker = workerMap.get(options.alias)! @@ -2790,7 +2842,7 @@ const stylProcessor = ( worker.stop() } }, - async process(source, root, options, resolvers) { + async process(environment, source, root, options, resolvers) { const stylusPath = loadPreprocessorPath(PreprocessLang.stylus, root) if (!workerMap.has(options.alias)) { @@ -2899,12 +2951,14 @@ const createPreprocessorWorkerController = (maxWorkers: number | undefined) => { const styl = stylProcessor(maxWorkers) const sassProcess: StylePreprocessor['process'] = ( + environment, source, root, options, resolvers, ) => { return scss.process( + environment, source, root, { ...options, indentedSyntax: true, syntax: 'indented' }, @@ -2954,9 +3008,10 @@ const importLightningCSS = createCachedImport(() => import('lightningcss')) async function compileLightningCSS( id: string, src: string, - config: ResolvedConfig, + environment: PartialEnvironment, urlReplacer?: CssUrlReplacer, ): ReturnType { + const { config } = environment const deps = new Set() // Relative path is needed to get stable hash when using CSS modules const filename = cleanUrl(path.relative(config.root, id)) @@ -2988,15 +3043,17 @@ async function compileLightningCSS( return fs.readFileSync(toAbsolute(filePath), 'utf-8') }, async resolve(id, from) { - const publicFile = checkPublicFile(id, config) + const publicFile = checkPublicFile( + id, + environment.getTopLevelConfig(), + ) if (publicFile) { return publicFile } - const resolved = await getAtImportResolvers(config).css( - id, - toAbsolute(from), - ) + const resolved = await getAtImportResolvers( + environment.getTopLevelConfig(), + ).css(environment, id, toAbsolute(from)) if (resolved) { deps.add(resolved) diff --git a/packages/vite/src/node/plugins/define.ts b/packages/vite/src/node/plugins/define.ts index a880fb0236f082..39d7b6bf8f3f5e 100644 --- a/packages/vite/src/node/plugins/define.ts +++ b/packages/vite/src/node/plugins/define.ts @@ -3,6 +3,7 @@ import { TraceMap, decodedMap, encodedMap } from '@jridgewell/trace-mapping' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' import { escapeRegex } from '../utils' +import type { Environment } from '../environment' import { isCSSRequest } from './css' import { isHTMLRequest } from './html' @@ -56,8 +57,8 @@ export function definePlugin(config: ResolvedConfig): Plugin { } } - function generatePattern(ssr: boolean) { - const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker' + function generatePattern(environment: Environment) { + const replaceProcessEnv = environment.config.webCompatible const define: Record = { ...(replaceProcessEnv ? processEnv : {}), @@ -67,6 +68,8 @@ export function definePlugin(config: ResolvedConfig): Plugin { } // Additional define fixes based on `ssr` value + const ssr = environment.config.consumer === 'server' + if ('import.meta.env.SSR' in define) { define['import.meta.env.SSR'] = ssr + '' } @@ -95,15 +98,24 @@ export function definePlugin(config: ResolvedConfig): Plugin { return [define, pattern, importMetaEnvVal] as const } - const defaultPattern = generatePattern(false) - const ssrPattern = generatePattern(true) + const patternsCache = new WeakMap< + Environment, + readonly [Record, RegExp | null, string] + >() + function getPattern(environment: Environment) { + let pattern = patternsCache.get(environment) + if (!pattern) { + pattern = generatePattern(environment) + patternsCache.set(environment, pattern) + } + return pattern + } return { name: 'vite:define', - async transform(code, id, options) { - const ssr = options?.ssr === true - if (!ssr && !isBuild) { + async transform(code, id) { + if (this.environment.config.consumer === 'client' && !isBuild) { // for dev we inject actual global defines in the vite client to // avoid the transform cost. see the `clientInjection` and // `importAnalysis` plugin. @@ -120,9 +132,7 @@ export function definePlugin(config: ResolvedConfig): Plugin { return } - let [define, pattern, importMetaEnvVal] = ssr - ? ssrPattern - : defaultPattern + let [define, pattern, importMetaEnvVal] = getPattern(this.environment) if (!pattern) return // Check if our code needs any replacements before running esbuild @@ -145,7 +155,7 @@ export function definePlugin(config: ResolvedConfig): Plugin { } } - const result = await replaceDefine(code, id, define, config) + const result = await replaceDefine(this.environment, code, id, define) if (hasDefineImportMetaEnv) { // Replace `import.meta.env.*` with undefined @@ -172,12 +182,12 @@ export function definePlugin(config: ResolvedConfig): Plugin { } export async function replaceDefine( + environment: Environment, code: string, id: string, define: Record, - config: ResolvedConfig, ): Promise<{ code: string; map: string | null }> { - const esbuildOptions = config.esbuild || {} + const esbuildOptions = environment.config.esbuild || {} const result = await transform(code, { loader: 'js', @@ -185,7 +195,10 @@ export async function replaceDefine( platform: 'neutral', define, sourcefile: id, - sourcemap: config.command === 'build' ? !!config.build.sourcemap : true, + sourcemap: + environment.config.command === 'build' + ? !!environment.config.build.sourcemap + : true, }) // remove esbuild's source entries diff --git a/packages/vite/src/node/plugins/dynamicImportVars.ts b/packages/vite/src/node/plugins/dynamicImportVars.ts index 8c55632a78f234..e6dcb756bc035a 100644 --- a/packages/vite/src/node/plugins/dynamicImportVars.ts +++ b/packages/vite/src/node/plugins/dynamicImportVars.ts @@ -7,6 +7,7 @@ import { dynamicImportToGlob } from '@rollup/plugin-dynamic-import-vars' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' import { CLIENT_ENTRY } from '../constants' +import { createBackCompatIdResolver } from '../idResolver' import { createFilter, normalizePath, @@ -16,6 +17,8 @@ import { transformStableResult, urlRE, } from '../utils' +import type { Environment } from '../environment' +import { usePerEnvironmentState } from '../environment' import { toAbsoluteGlob } from './importMetaGlob' import { hasViteIgnoreRE } from './importAnalysis' import { workerOrSharedWorkerRE } from './worker' @@ -161,14 +164,17 @@ export async function transformDynamicImport( } export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { - const resolve = config.createResolver({ + const resolve = createBackCompatIdResolver(config, { preferRelative: true, tryIndex: false, extensions: [], }) - const { include, exclude, warnOnError } = - config.build.dynamicImportVarsOptions - const filter = createFilter(include, exclude) + + const getFilter = usePerEnvironmentState((environment: Environment) => { + const { include, exclude } = + environment.config.build.dynamicImportVarsOptions + return createFilter(include, exclude) + }) return { name: 'vite:dynamic-import-vars', @@ -186,8 +192,9 @@ export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { }, async transform(source, importer) { + const { environment } = this if ( - !filter(importer) || + !getFilter(this)(importer) || importer === CLIENT_ENTRY || !hasDynamicImportRE.test(source) ) { @@ -234,11 +241,11 @@ export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { result = await transformDynamicImport( source.slice(start, end), importer, - resolve, + (id, importer) => resolve(environment, id, importer), config.root, ) } catch (error) { - if (warnOnError) { + if (environment.config.build.dynamicImportVarsOptions.warnOnError) { this.warn(error) } else { this.error(error) diff --git a/packages/vite/src/node/plugins/esbuild.ts b/packages/vite/src/node/plugins/esbuild.ts index fda6ca02a8baa7..f810b55c89c33a 100644 --- a/packages/vite/src/node/plugins/esbuild.ts +++ b/packages/vite/src/node/plugins/esbuild.ts @@ -41,6 +41,8 @@ export const defaultEsbuildSupported = { 'import-meta': true, } +// TODO: rework to avoid caching the server for this module. +// If two servers are created in the same process, they will interfere with each other. let server: ViteDevServer export interface ESBuildOptions extends TransformOptions { diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index f451a83833abd0..c056752ea6326d 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -415,7 +415,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { !namedOutput.includes(removeLeadingSlash(url)) // Allow for absolute references as named output can't be an absolute path ) { try { - return await urlToBuiltUrl(url, id, config, this, shouldInline) + return await urlToBuiltUrl(this, url, id, shouldInline) } catch (e) { if (e.code !== 'ENOENT') { throw e @@ -650,7 +650,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { s.update( start, end, - partialEncodeURIPath(await urlToBuiltUrl(url, id, config, this)), + partialEncodeURIPath(await urlToBuiltUrl(this, url, id)), ) } } @@ -677,7 +677,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { processedHtml.set(id, s.toString()) // inject module preload polyfill only when configured and needed - const { modulePreload } = config.build + const { modulePreload } = this.environment.config.build if ( modulePreload !== false && modulePreload.polyfill && @@ -840,8 +840,8 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { toScriptTag(chunk, toOutputAssetFilePath, isAsync), ) } else { + const { modulePreload } = this.environment.config.build assetTags = [toScriptTag(chunk, toOutputAssetFilePath, isAsync)] - const { modulePreload } = config.build if (modulePreload !== false) { const resolveDependencies = typeof modulePreload === 'object' && @@ -866,7 +866,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { } // inject css link when cssCodeSplit is false - if (!config.build.cssCodeSplit) { + if (!this.environment.config.build.cssCodeSplit) { const cssChunk = Object.values(bundle).find( (chunk) => chunk.type === 'asset' && chunk.name === 'style.css', ) as OutputAsset | undefined diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 8027bae9af2fdc..050a99783cca23 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -12,7 +12,7 @@ import { parseAst } from 'rollup/parseAst' import type { StaticImport } from 'mlly' import { ESM_STATIC_IMPORT_RE, parseStaticImport } from 'mlly' import { makeLegalIdentifier } from '@rollup/pluginutils' -import type { ViteDevServer } from '..' +import type { PartialResolvedId } from 'rollup' import { CLIENT_DIR, CLIENT_PUBLIC_PATH, @@ -52,11 +52,11 @@ import { } from '../utils' import { getFsUtils } from '../fsUtils' import { checkPublicFile } from '../publicDir' -import { getDepOptimizationConfig } from '../config' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' -import { shouldExternalizeForSSR } from '../ssr/ssrExternal' -import { getDepsOptimizer, optimizedDepNeedsInterop } from '../optimizer' +import type { DevEnvironment } from '../server/environment' +import { shouldExternalize } from '../external' +import { optimizedDepNeedsInterop } from '../optimizer' import { cleanUrl, unwrapId, @@ -98,6 +98,46 @@ export function isExplicitImportRequired(url: string): boolean { return !isJSRequest(url) && !isCSSRequest(url) } +export function normalizeResolvedIdToUrl( + environment: DevEnvironment, + url: string, + resolved: PartialResolvedId, +): string { + const root = environment.config.root + const depsOptimizer = environment.depsOptimizer + const fsUtils = getFsUtils(environment.getTopLevelConfig()) + + // normalize all imports into resolved URLs + // e.g. `import 'foo'` -> `import '/@fs/.../node_modules/foo/index.js'` + if (resolved.id.startsWith(withTrailingSlash(root))) { + // in root: infer short absolute path from root + url = resolved.id.slice(root.length) + } else if ( + depsOptimizer?.isOptimizedDepFile(resolved.id) || + // vite-plugin-react isn't following the leading \0 virtual module convention. + // This is a temporary hack to avoid expensive fs checks for React apps. + // We'll remove this as soon we're able to fix the react plugins. + (resolved.id !== '/@react-refresh' && + path.isAbsolute(resolved.id) && + fsUtils.existsSync(cleanUrl(resolved.id))) + ) { + // an optimized deps may not yet exists in the filesystem, or + // a regular file exists but is out of root: rewrite to absolute /@fs/ paths + url = path.posix.join(FS_PREFIX, resolved.id) + } else { + url = resolved.id + } + + // if the resolved id is not a valid browser import specifier, + // prefix it to make it valid. We will strip this before feeding it + // back into the transform pipeline + if (url[0] !== '.' && url[0] !== '/') { + url = wrapId(resolved.id) + } + + return url +} + function extractImportedBindings( id: string, source: string, @@ -151,7 +191,7 @@ function extractImportedBindings( } /** - * Server-only plugin that lexes, resolves, rewrites and analyzes url imports. + * Dev-only plugin that lexes, resolves, rewrites and analyzes url imports. * * - Imports are resolved to ensure they exist on disk * @@ -181,11 +221,9 @@ function extractImportedBindings( */ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const { root, base } = config - const fsUtils = getFsUtils(config) const clientPublicPath = path.posix.join(base, CLIENT_PUBLIC_PATH) const enablePartialAccept = config.experimental?.hmrPartialAccept const matchAlias = getAliasPatternMatcher(config.resolve.alias) - let server: ViteDevServer let _env: string | undefined let _ssrEnv: string | undefined @@ -216,18 +254,10 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { return { name: 'vite:import-analysis', - configureServer(_server) { - server = _server - }, - - async transform(source, importer, options) { - // In a real app `server` is always defined, but it is undefined when - // running src/node/server/__tests__/pluginContainer.spec.ts - if (!server) { - return null - } - - const ssr = options?.ssr === true + async transform(source, importer) { + const environment = this.environment as DevEnvironment + const ssr = environment.config.consumer === 'server' + const moduleGraph = environment.moduleGraph if (canSkipImportAnalysis(importer)) { debug?.(colors.dim(`[skipped] ${prettifyUrl(importer, root)}`)) @@ -250,12 +280,11 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { this.error(message, showCodeFrame ? e.idx : undefined) } - const depsOptimizer = getDepsOptimizer(config, ssr) + const depsOptimizer = environment.depsOptimizer - const { moduleGraph } = server // since we are already in the transform phase of the importer, it must // have been loaded so its entry is guaranteed in the module graph. - const importerModule = moduleGraph.getModuleById(importer)! + const importerModule = moduleGraph.getModuleById(importer) if (!importerModule) { // This request is no longer valid. It could happen for optimized deps // requests. A full reload is going to request this id again. @@ -299,20 +328,20 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { let importerFile = importer - const optimizeDeps = getDepOptimizationConfig(config, ssr) - if (moduleListContains(optimizeDeps?.exclude, url)) { - if (depsOptimizer) { - await depsOptimizer.scanProcessing - - // if the dependency encountered in the optimized file was excluded from the optimization - // the dependency needs to be resolved starting from the original source location of the optimized file - // because starting from node_modules/.vite will not find the dependency if it was not hoisted - // (that is, if it is under node_modules directory in the package source of the optimized file) - for (const optimizedModule of depsOptimizer.metadata.depInfoList) { - if (!optimizedModule.src) continue // Ignore chunks - if (optimizedModule.file === importerModule.file) { - importerFile = optimizedModule.src - } + if ( + depsOptimizer && + moduleListContains(depsOptimizer.options.exclude, url) + ) { + await depsOptimizer.scanProcessing + + // if the dependency encountered in the optimized file was excluded from the optimization + // the dependency needs to be resolved starting from the original source location of the optimized file + // because starting from node_modules/.vite will not find the dependency if it was not hoisted + // (that is, if it is under node_modules directory in the package source of the optimized file) + for (const optimizedModule of depsOptimizer.metadata.depInfoList) { + if (!optimizedModule.src) continue // Ignore chunks + if (optimizedModule.file === importerModule.file) { + importerFile = optimizedModule.src } } } @@ -342,36 +371,10 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const isRelative = url[0] === '.' const isSelfImport = !isRelative && cleanUrl(url) === cleanUrl(importer) - // normalize all imports into resolved URLs - // e.g. `import 'foo'` -> `import '/@fs/.../node_modules/foo/index.js'` - if (resolved.id.startsWith(withTrailingSlash(root))) { - // in root: infer short absolute path from root - url = resolved.id.slice(root.length) - } else if ( - depsOptimizer?.isOptimizedDepFile(resolved.id) || - // vite-plugin-react isn't following the leading \0 virtual module convention. - // This is a temporary hack to avoid expensive fs checks for React apps. - // We'll remove this as soon we're able to fix the react plugins. - (resolved.id !== '/@react-refresh' && - path.isAbsolute(resolved.id) && - fsUtils.existsSync(cleanUrl(resolved.id))) - ) { - // an optimized deps may not yet exists in the filesystem, or - // a regular file exists but is out of root: rewrite to absolute /@fs/ paths - url = path.posix.join(FS_PREFIX, resolved.id) - } else { - url = resolved.id - } - - // if the resolved id is not a valid browser import specifier, - // prefix it to make it valid. We will strip this before feeding it - // back into the transform pipeline - if (url[0] !== '.' && url[0] !== '/') { - url = wrapId(resolved.id) - } + url = normalizeResolvedIdToUrl(environment, url, resolved) - // make the URL browser-valid if not SSR - if (!ssr) { + // make the URL browser-valid + if (environment.config.consumer === 'client') { // mark non-js/css imports with `?import` if (isExplicitImportRequired(url)) { url = injectQuery(url, 'import') @@ -398,7 +401,6 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // We use an internal function to avoid resolving the url again const depModule = await moduleGraph._ensureEntryFromUrl( unwrapId(url), - ssr, canSkipImportAnalysis(url) || forceSkipImportAnalysis, resolved, ) @@ -413,7 +415,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { } // prepend base - url = joinUrlSegments(base, url) + if (!ssr) url = joinUrlSegments(base, url) } return [url, resolved.id] @@ -505,7 +507,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { } // skip ssr external if (ssr && !matchAlias(specifier)) { - if (shouldExternalizeForSSR(specifier, importer, config)) { + if (shouldExternalize(environment, specifier, importer)) { return } if (isBuiltin(specifier)) { @@ -543,9 +545,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // record as safe modules // safeModulesPath should not include the base prefix. // See https://github.com/vitejs/vite/issues/9438#issuecomment-1465270409 - server?.moduleGraph.safeModulesPath.add( - fsPathFromUrl(stripBase(url, base)), - ) + config.safeModulePaths.add(fsPathFromUrl(stripBase(url, base))) if (url !== specifier) { let rewriteDone = false @@ -561,10 +561,9 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const file = cleanUrl(resolvedId) // Remove ?v={hash} const needsInterop = await optimizedDepNeedsInterop( + environment, depsOptimizer.metadata, file, - config, - ssr, ) if (needsInterop === undefined) { @@ -638,13 +637,13 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { if ( !isDynamicImport && isLocalImport && - config.server.preTransformRequests + environment.config.dev.preTransformRequests ) { // pre-transform known direct imports // These requests will also be registered in transformRequest to be awaited // by the deps optimizer const url = removeImportQuery(hmrUrl) - server.warmupRequest(url, { ssr }) + environment.warmupRequest(url) } } else if (!importer.startsWith(withTrailingSlash(clientDir))) { if (!isInNodeModules(importer)) { @@ -745,10 +744,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // normalize and rewrite accepted urls const normalizedAcceptedUrls = new Set() for (const { url, start, end } of acceptedUrls) { - const [normalized] = await moduleGraph.resolveUrl( - toAbsoluteUrl(url), - ssr, - ) + const [normalized] = await moduleGraph.resolveUrl(toAbsoluteUrl(url)) normalizedAcceptedUrls.add(normalized) str().overwrite(start, end, JSON.stringify(normalized), { contentOnly: true, @@ -793,11 +789,10 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { normalizedAcceptedUrls, isPartiallySelfAccepting ? acceptedExports : null, isSelfAccepting, - ssr, staticImportedUrls, ) if (hasHMR && prunedImports) { - handlePrunedModules(prunedImports, server) + handlePrunedModules(prunedImports, environment) } } diff --git a/packages/vite/src/node/plugins/importAnalysisBuild.ts b/packages/vite/src/node/plugins/importAnalysisBuild.ts index 3922cee10de128..f38e343db0bd53 100644 --- a/packages/vite/src/node/plugins/importAnalysisBuild.ts +++ b/packages/vite/src/node/plugins/importAnalysisBuild.ts @@ -169,30 +169,6 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { const renderBuiltUrl = config.experimental.renderBuiltUrl const isRelativeBase = config.base === './' || config.base === '' - const { modulePreload } = config.build - const scriptRel = - modulePreload && modulePreload.polyfill - ? `'modulepreload'` - : `(${detectScriptRel.toString()})()` - - // There are two different cases for the preload list format in __vitePreload - // - // __vitePreload(() => import(asyncChunk), [ ...deps... ]) - // - // This is maintained to keep backwards compatibility as some users developed plugins - // using regex over this list to workaround the fact that module preload wasn't - // configurable. - const assetsURL = - renderBuiltUrl || isRelativeBase - ? // If `experimental.renderBuiltUrl` is used, the dependencies might be relative to the current chunk. - // If relative base is used, the dependencies are relative to the current chunk. - // The importerUrl is passed as third parameter to __vitePreload in this case - `function(dep, importerUrl) { return new URL(dep, importerUrl).href }` - : // If the base isn't relative, then the deps are relative to the projects `outDir` and the base - // is appended inside __vitePreload too. - `function(dep) { return ${JSON.stringify(config.base)}+dep }` - const preloadCode = `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = ${preload.toString()}` - return { name: 'vite:build-import-analysis', resolveId(id) { @@ -203,6 +179,30 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { load(id) { if (id === preloadHelperId) { + const { modulePreload } = this.environment.config.build + + const scriptRel = + modulePreload && modulePreload.polyfill + ? `'modulepreload'` + : `(${detectScriptRel.toString()})()` + + // There are two different cases for the preload list format in __vitePreload + // + // __vitePreload(() => import(asyncChunk), [ ...deps... ]) + // + // This is maintained to keep backwards compatibility as some users developed plugins + // using regex over this list to workaround the fact that module preload wasn't + // configurable. + const assetsURL = + renderBuiltUrl || isRelativeBase + ? // If `experimental.renderBuiltUrl` is used, the dependencies might be relative to the current chunk. + // If relative base is used, the dependencies are relative to the current chunk. + // The importerUrl is passed as third parameter to __vitePreload in this case + `function(dep, importerUrl) { return new URL(dep, importerUrl).href }` + : // If the base isn't relative, then the deps are relative to the projects `outDir` and the base + // is appended inside __vitePreload too. + `function(dep) { return ${JSON.stringify(config.base)}+dep }` + const preloadCode = `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = ${preload.toString()}` return preloadCode } }, @@ -359,7 +359,7 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { if (s) { return { code: s.toString(), - map: config.build.sourcemap + map: this.environment.config.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null, } @@ -371,7 +371,7 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { if (code.indexOf(isModernFlag) > -1) { const re = new RegExp(isModernFlag, 'g') const isModern = String(format === 'es') - if (config.build.sourcemap) { + if (this.environment.config.build.sourcemap) { const s = new MagicString(code) let match: RegExpExecArray | null while ((match = re.exec(code))) { @@ -451,6 +451,8 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { } return } + const buildSourcemap = this.environment.config.build.sourcemap + const { modulePreload } = this.environment.config.build for (const file in bundle) { const chunk = bundle[file] @@ -602,11 +604,11 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { if (renderBuiltUrl) { renderedDeps = depsArray.map((dep) => { const replacement = toOutputFilePathInJS( + this.environment, dep, 'asset', chunk.fileName, 'js', - config, toRelativePath, ) @@ -675,7 +677,7 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { if (s.hasChanged()) { chunk.code = s.toString() - if (config.build.sourcemap && chunk.map) { + if (buildSourcemap && chunk.map) { const nextMap = s.generateMap({ source: chunk.fileName, hires: 'boundary', @@ -687,13 +689,13 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin { map.toUrl = () => genSourceMapUrl(map) chunk.map = map - if (config.build.sourcemap === 'inline') { + if (buildSourcemap === 'inline') { chunk.code = chunk.code.replace( convertSourceMap.mapFileCommentRegex, '', ) chunk.code += `\n//# sourceMappingURL=${genSourceMapUrl(map)}` - } else if (config.build.sourcemap) { + } else if (buildSourcemap) { const mapAsset = bundle[chunk.fileName + '.map'] if (mapAsset && mapAsset.type === 'asset') { mapAsset.source = map.toString() diff --git a/packages/vite/src/node/plugins/importMetaGlob.ts b/packages/vite/src/node/plugins/importMetaGlob.ts index d596d39d1a62e9..f010f093446c03 100644 --- a/packages/vite/src/node/plugins/importMetaGlob.ts +++ b/packages/vite/src/node/plugins/importMetaGlob.ts @@ -17,12 +17,12 @@ import { stringifyQuery } from 'ufo' import type { GeneralImportGlobOptions } from 'types/importGlob' import { parseAstAsync } from 'rollup/parseAst' import type { Plugin } from '../plugin' -import type { ViteDevServer } from '../server' -import type { ModuleNode } from '../server/moduleGraph' +import type { EnvironmentModuleNode } from '../server/moduleGraph' import type { ResolvedConfig } from '../config' import { evalValue, normalizePath, transformStableResult } from '../utils' import type { Logger } from '../logger' import { slash } from '../../shared/utils' +import type { Environment } from '../environment' const { isMatch, scan } = micromatch @@ -40,38 +40,16 @@ interface ParsedGeneralImportGlobOptions extends GeneralImportGlobOptions { query?: string } -export function getAffectedGlobModules( - file: string, - server: ViteDevServer, -): ModuleNode[] { - const modules: ModuleNode[] = [] - for (const [id, allGlobs] of server._importGlobMap!) { - // (glob1 || glob2) && !glob3 && !glob4... - if ( - allGlobs.some( - ({ affirmed, negated }) => - (!affirmed.length || affirmed.some((glob) => isMatch(file, glob))) && - (!negated.length || negated.every((glob) => isMatch(file, glob))), - ) - ) { - const mod = server.moduleGraph.getModuleById(id) - if (mod) modules.push(mod) - } - } - modules.forEach((i) => { - if (i?.file) server.moduleGraph.onFileChange(i.file) - }) - return modules -} - export function importGlobPlugin(config: ResolvedConfig): Plugin { - let server: ViteDevServer | undefined + const importGlobMaps = new Map< + Environment, + Map + >() return { name: 'vite:import-glob', - configureServer(_server) { - server = _server - server._importGlobMap.clear() + buildStart() { + importGlobMaps.clear() }, async transform(code, id) { if (!code.includes('import.meta.glob')) return @@ -85,24 +63,49 @@ export function importGlobPlugin(config: ResolvedConfig): Plugin { config.logger, ) if (result) { - if (server) { - const allGlobs = result.matches.map((i) => i.globsResolved) - server._importGlobMap.set( - id, - allGlobs.map((globs) => { - const affirmed: string[] = [] - const negated: string[] = [] - - for (const glob of globs) { - ;(glob[0] === '!' ? negated : affirmed).push(glob) - } - return { affirmed, negated } - }), - ) + const allGlobs = result.matches.map((i) => i.globsResolved) + if (!importGlobMaps.has(this.environment)) { + importGlobMaps.set(this.environment, new Map()) } + importGlobMaps.get(this.environment)!.set( + id, + allGlobs.map((globs) => { + const affirmed: string[] = [] + const negated: string[] = [] + + for (const glob of globs) { + ;(glob[0] === '!' ? negated : affirmed).push(glob) + } + return { affirmed, negated } + }), + ) + return transformStableResult(result.s, id, config) } }, + hotUpdate({ type, file, modules: oldModules }) { + if (type === 'update') return + + const importGlobMap = importGlobMaps.get(this.environment) + if (!importGlobMap) return + + const modules: EnvironmentModuleNode[] = [] + for (const [id, allGlobs] of importGlobMap) { + // (glob1 || glob2) && !glob3 && !glob4... + if ( + allGlobs.some( + ({ affirmed, negated }) => + (!affirmed.length || + affirmed.some((glob) => isMatch(file, glob))) && + (!negated.length || negated.every((glob) => isMatch(file, glob))), + ) + ) { + const mod = this.environment.moduleGraph.getModuleById(id) + if (mod) modules.push(mod) + } + } + return modules.length > 0 ? [...oldModules, ...modules] : undefined + }, } } diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index fc230c686641b1..af0215d415871e 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -1,10 +1,8 @@ import aliasPlugin, { type ResolverFunction } from '@rollup/plugin-alias' import type { ObjectHook } from 'rollup' import type { PluginHookUtils, ResolvedConfig } from '../config' -import { isDepsOptimizerEnabled } from '../config' +import { isDepOptimizationDisabled } from '../optimizer' import type { HookHandler, Plugin, PluginWithRequiredHook } from '../plugin' -import { getDepsOptimizer } from '../optimizer' -import { shouldExternalizeForSSR } from '../ssr/ssrExternal' import { watchPackageDataPlugin } from '../packages' import { getFsUtils } from '../fsUtils' import { jsonPlugin } from './json' @@ -39,12 +37,14 @@ export async function resolvePlugins( ? await (await import('../build')).resolveBuildPlugins(config) : { pre: [], post: [] } const { modulePreload } = config.build - const depsOptimizerEnabled = + const depOptimizationEnabled = !isBuild && - (isDepsOptimizerEnabled(config, false) || - isDepsOptimizerEnabled(config, true)) + Object.values(config.environments).some( + (environment) => !isDepOptimizationDisabled(environment.dev.optimizeDeps), + ) + return [ - depsOptimizerEnabled ? optimizedDepsPlugin(config) : null, + depOptimizationEnabled ? optimizedDepsPlugin(config) : null, isBuild ? metadataPlugin() : null, !isWorker ? watchPackageDataPlugin(config.packageCache) : null, preAliasPlugin(config), @@ -52,27 +52,25 @@ export async function resolvePlugins( entries: config.resolve.alias, customResolver: viteAliasCustomResolver, }), + ...prePlugins, + modulePreload !== false && modulePreload.polyfill ? modulePreloadPolyfillPlugin(config) : null, - resolvePlugin({ - ...config.resolve, - root: config.root, - isProduction: config.isProduction, - isBuild, - packageCache: config.packageCache, - ssrConfig: config.ssr, - asSrc: true, - fsUtils: getFsUtils(config), - getDepsOptimizer: isBuild - ? undefined - : (ssr: boolean) => getDepsOptimizer(config, ssr), - shouldExternalize: - isBuild && config.build.ssr - ? (id, importer) => shouldExternalizeForSSR(id, importer, config) - : undefined, - }), + resolvePlugin( + { + root: config.root, + isProduction: config.isProduction, + isBuild, + packageCache: config.packageCache, + asSrc: true, + fsUtils: getFsUtils(config), + optimizeDeps: true, + externalize: isBuild && !!config.build.ssr, // TODO: should we do this for all environments? + }, + config.environments, + ), htmlInlineProxyPlugin(config), cssPlugin(config), config.esbuild !== false ? esbuildPlugin(config) : null, @@ -86,7 +84,9 @@ export async function resolvePlugins( wasmHelperPlugin(config), webWorkerPlugin(config), assetPlugin(config), + ...normalPlugins, + wasmFallbackPlugin(), definePlugin(config), cssPostPlugin(config), @@ -96,8 +96,11 @@ export async function resolvePlugins( ...buildPlugins.pre, dynamicImportVarsPlugin(config), importGlobPlugin(config), + ...postPlugins, + ...buildPlugins.post, + // internal server-only plugins are always applied after everything else ...(isBuild ? [] diff --git a/packages/vite/src/node/plugins/loadFallback.ts b/packages/vite/src/node/plugins/loadFallback.ts index 7d56797e48e681..b774b042fc5626 100644 --- a/packages/vite/src/node/plugins/loadFallback.ts +++ b/packages/vite/src/node/plugins/loadFallback.ts @@ -1,11 +1,11 @@ import fsp from 'node:fs/promises' -import type { Plugin } from '..' import { cleanUrl } from '../../shared/utils' +import type { Plugin } from '../plugin' /** * A plugin to provide build load fallback for arbitrary request with queries. */ -export function loadFallbackPlugin(): Plugin { +export function buildLoadFallbackPlugin(): Plugin { return { name: 'vite:load-fallback', async load(id) { diff --git a/packages/vite/src/node/plugins/manifest.ts b/packages/vite/src/node/plugins/manifest.ts index b51349744671c9..12acfd4c81f732 100644 --- a/packages/vite/src/node/plugins/manifest.ts +++ b/packages/vite/src/node/plugins/manifest.ts @@ -5,10 +5,10 @@ import type { OutputChunk, RenderedChunk, } from 'rollup' -import type { ResolvedConfig } from '..' import type { Plugin } from '../plugin' import { normalizePath, sortObjectKeys } from '../utils' -import { generatedAssets } from './asset' +import { usePerEnvironmentState } from '../environment' +import { generatedAssetsMap } from './asset' const endsWithJSRE = /\.[cm]?js$/ @@ -26,21 +26,38 @@ export interface ManifestChunk { dynamicImports?: string[] } -export function manifestPlugin(config: ResolvedConfig): Plugin { - const manifest: Manifest = {} - - let outputCount: number +export function manifestPlugin(): Plugin { + const getState = usePerEnvironmentState(() => { + return { + manifest: {} as Manifest, + outputCount: 0, + reset() { + this.outputCount = 0 + }, + } + }) return { name: 'vite:manifest', + perEnvironmentStartEndDuringDev: true, + + applyToEnvironment(environment) { + return !!environment.config.build.manifest + }, + buildStart() { - outputCount = 0 + getState(this).reset() }, generateBundle({ format }, bundle) { + const state = getState(this) + const { manifest } = state + const { root } = this.environment.config + const buildOptions = this.environment.config.build + function getChunkName(chunk: OutputChunk) { - return getChunkOriginalFileName(chunk, config.root, format) + return getChunkOriginalFileName(chunk, root, format) } function getInternalImports(imports: string[]): string[] { @@ -110,7 +127,7 @@ export function manifestPlugin(config: ResolvedConfig): Plugin { return manifestChunk } - const assets = generatedAssets.get(config)! + const assets = generatedAssetsMap.get(this.environment)! const entryCssAssetFileNames = new Set() for (const [id, asset] of assets.entries()) { if (asset.isEntry) { @@ -158,14 +175,14 @@ export function manifestPlugin(config: ResolvedConfig): Plugin { } } - outputCount++ - const output = config.build.rollupOptions?.output + state.outputCount++ + const output = buildOptions.rollupOptions?.output const outputLength = Array.isArray(output) ? output.length : 1 - if (outputCount >= outputLength) { + if (state.outputCount >= outputLength) { this.emitFile({ fileName: - typeof config.build.manifest === 'string' - ? config.build.manifest + typeof buildOptions.manifest === 'string' + ? buildOptions.manifest : '.vite/manifest.json', type: 'asset', source: JSON.stringify(sortObjectKeys(manifest), undefined, 2), diff --git a/packages/vite/src/node/plugins/optimizedDeps.ts b/packages/vite/src/node/plugins/optimizedDeps.ts index fe51d5b4471a87..f3cdd039c213e8 100644 --- a/packages/vite/src/node/plugins/optimizedDeps.ts +++ b/packages/vite/src/node/plugins/optimizedDeps.ts @@ -1,10 +1,10 @@ import fsp from 'node:fs/promises' import colors from 'picocolors' -import type { ResolvedConfig } from '..' +import type { DevEnvironment, ResolvedConfig } from '..' import type { Plugin } from '../plugin' import { DEP_VERSION_RE } from '../constants' import { createDebugger } from '../utils' -import { getDepsOptimizer, optimizedDepInfoFromFile } from '../optimizer' +import { optimizedDepInfoFromFile } from '../optimizer' import { cleanUrl } from '../../shared/utils' export const ERR_OPTIMIZE_DEPS_PROCESSING_ERROR = @@ -19,8 +19,9 @@ export function optimizedDepsPlugin(config: ResolvedConfig): Plugin { return { name: 'vite:optimized-deps', - resolveId(id, source, { ssr }) { - if (getDepsOptimizer(config, ssr)?.isOptimizedDepFile(id)) { + resolveId(id) { + const environment = this.environment as DevEnvironment + if (environment.depsOptimizer?.isOptimizedDepFile(id)) { return id } }, @@ -29,9 +30,9 @@ export function optimizedDepsPlugin(config: ResolvedConfig): Plugin { // The logic to register an id to wait until it is processed // is in importAnalysis, see call to delayDepsOptimizerUntil - async load(id, options) { - const ssr = options?.ssr === true - const depsOptimizer = getDepsOptimizer(config, ssr) + async load(id) { + const environment = this.environment as DevEnvironment + const depsOptimizer = environment.depsOptimizer if (depsOptimizer?.isOptimizedDepFile(id)) { const metadata = depsOptimizer.metadata const file = cleanUrl(id) diff --git a/packages/vite/src/node/plugins/preAlias.ts b/packages/vite/src/node/plugins/preAlias.ts index eaefdb7e6eb65d..c29cb66b4d777c 100644 --- a/packages/vite/src/node/plugins/preAlias.ts +++ b/packages/vite/src/node/plugins/preAlias.ts @@ -6,7 +6,7 @@ import type { ResolvedConfig, } from '..' import type { Plugin } from '../plugin' -import { createIsConfiguredAsSsrExternal } from '../ssr/ssrExternal' +import { isConfiguredAsExternal } from '../external' import { bareImportRE, isInNodeModules, @@ -14,7 +14,6 @@ import { moduleListContains, } from '../utils' import { getFsUtils } from '../fsUtils' -import { getDepsOptimizer } from '../optimizer' import { cleanUrl, withTrailingSlash } from '../../shared/utils' import { tryOptimizedResolve } from './resolve' @@ -23,14 +22,15 @@ import { tryOptimizedResolve } from './resolve' */ export function preAliasPlugin(config: ResolvedConfig): Plugin { const findPatterns = getAliasPatterns(config.resolve.alias) - const isConfiguredAsExternal = createIsConfiguredAsSsrExternal(config) const isBuild = config.command === 'build' const fsUtils = getFsUtils(config) return { name: 'vite:pre-alias', async resolveId(id, importer, options) { + const { environment } = this const ssr = options?.ssr === true - const depsOptimizer = !isBuild && getDepsOptimizer(config, ssr) + const depsOptimizer = + environment.mode === 'dev' ? environment.depsOptimizer : undefined if ( importer && depsOptimizer && @@ -69,7 +69,11 @@ export function preAliasPlugin(config: ResolvedConfig): Plugin { (isInNodeModules(resolvedId) || optimizeDeps.include?.includes(id)) && isOptimizable(resolvedId, optimizeDeps) && - !(isBuild && ssr && isConfiguredAsExternal(id, importer)) && + !( + isBuild && + ssr && + isConfiguredAsExternal(environment, id, importer) + ) && (!ssr || optimizeAliasReplacementForSSR(resolvedId, optimizeDeps)) ) { // aliased dep has not yet been optimized diff --git a/packages/vite/src/node/plugins/reporter.ts b/packages/vite/src/node/plugins/reporter.ts index 285d9baa7daba2..c502e300674c30 100644 --- a/packages/vite/src/node/plugins/reporter.ts +++ b/packages/vite/src/node/plugins/reporter.ts @@ -2,8 +2,11 @@ import path from 'node:path' import { gzip } from 'node:zlib' import { promisify } from 'node:util' import colors from 'picocolors' -import type { Plugin } from 'rollup' +import type { OutputBundle } from 'rollup' +import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' +import type { Environment } from '../environment' +import { usePerEnvironmentState } from '../environment' import { isDefined, isInNodeModules, normalizePath } from '../utils' import { LogLevels } from '../logger' import { withTrailingSlash } from '../../shared/utils' @@ -25,7 +28,6 @@ const COMPRESSIBLE_ASSETS_RE = /\.(?:html|json|svg|txt|xml|xhtml)$/ export function buildReporterPlugin(config: ResolvedConfig): Plugin { const compress = promisify(gzip) - const chunkLimit = config.build.chunkSizeWarningLimit const numberFormatter = new Intl.NumberFormat('en', { maximumFractionDigits: 2, @@ -37,85 +39,259 @@ export function buildReporterPlugin(config: ResolvedConfig): Plugin { const tty = process.stdout.isTTY && !process.env.CI const shouldLogInfo = LogLevels[config.logLevel || 'info'] >= LogLevels.info - let hasTransformed = false - let hasRenderedChunk = false - let hasCompressChunk = false - let transformedCount = 0 - let chunkCount = 0 - let compressedCount = 0 - async function getCompressedSize( - code: string | Uint8Array, - ): Promise { - if (config.build.ssr || !config.build.reportCompressedSize) { - return null + const modulesReporter = usePerEnvironmentState((environment: Environment) => { + let hasTransformed = false + let transformedCount = 0 + + const logTransform = throttle((id: string) => { + writeLine( + `transforming (${transformedCount}) ${colors.dim( + path.relative(config.root, id), + )}`, + ) + }) + + return { + reset() { + transformedCount = 0 + }, + register(id: string) { + transformedCount++ + if (shouldLogInfo) { + if (!tty) { + if (!hasTransformed) { + config.logger.info(`transforming...`) + } + } else { + if (id.includes(`?`)) return + logTransform(id) + } + hasTransformed = true + } + }, + log() { + if (shouldLogInfo) { + if (tty) { + clearLine() + } + environment.logger.info( + `${colors.green(`✓`)} ${transformedCount} modules transformed.`, + ) + } + }, } - if (shouldLogInfo && !hasCompressChunk) { - if (!tty) { - config.logger.info('computing gzip size...') - } else { - writeLine('computing gzip size (0)...') + }) + + const chunksReporter = usePerEnvironmentState((environment: Environment) => { + let hasRenderedChunk = false + let hasCompressChunk = false + let chunkCount = 0 + let compressedCount = 0 + + async function getCompressedSize( + code: string | Uint8Array, + ): Promise { + if ( + environment.config.build.ssr || + !environment.config.build.reportCompressedSize + ) { + return null } - hasCompressChunk = true - } - const compressed = await compress( - typeof code === 'string' ? code : Buffer.from(code), - ) - compressedCount++ - if (shouldLogInfo && tty) { - writeLine(`computing gzip size (${compressedCount})...`) + if (shouldLogInfo && !hasCompressChunk) { + if (!tty) { + config.logger.info('computing gzip size...') + } else { + writeLine('computing gzip size (0)...') + } + hasCompressChunk = true + } + const compressed = await compress( + typeof code === 'string' ? code : Buffer.from(code), + ) + compressedCount++ + if (shouldLogInfo && tty) { + writeLine(`computing gzip size (${compressedCount})...`) + } + return compressed.length } - return compressed.length - } - const logTransform = throttle((id: string) => { - writeLine( - `transforming (${transformedCount}) ${colors.dim( - path.relative(config.root, id), - )}`, - ) + return { + reset() { + chunkCount = 0 + compressedCount = 0 + }, + register() { + chunkCount++ + if (shouldLogInfo) { + if (!tty) { + if (!hasRenderedChunk) { + environment.logger.info('rendering chunks...') + } + } else { + writeLine(`rendering chunks (${chunkCount})...`) + } + hasRenderedChunk = true + } + }, + async log(output: OutputBundle, outDir?: string) { + const chunkLimit = environment.config.build.chunkSizeWarningLimit + + let hasLargeChunks = false + + if (shouldLogInfo) { + const entries = ( + await Promise.all( + Object.values(output).map( + async (chunk): Promise => { + if (chunk.type === 'chunk') { + return { + name: chunk.fileName, + group: 'JS', + size: chunk.code.length, + compressedSize: await getCompressedSize(chunk.code), + mapSize: chunk.map ? chunk.map.toString().length : null, + } + } else { + if (chunk.fileName.endsWith('.map')) return null + const isCSS = chunk.fileName.endsWith('.css') + const isCompressible = + isCSS || COMPRESSIBLE_ASSETS_RE.test(chunk.fileName) + return { + name: chunk.fileName, + group: isCSS ? 'CSS' : 'Assets', + size: chunk.source.length, + mapSize: null, // Rollup doesn't support CSS maps? + compressedSize: isCompressible + ? await getCompressedSize(chunk.source) + : null, + } + } + }, + ), + ) + ).filter(isDefined) + if (tty) clearLine() + + let longest = 0 + let biggestSize = 0 + let biggestMap = 0 + let biggestCompressSize = 0 + for (const entry of entries) { + if (entry.name.length > longest) longest = entry.name.length + if (entry.size > biggestSize) biggestSize = entry.size + if (entry.mapSize && entry.mapSize > biggestMap) { + biggestMap = entry.mapSize + } + if ( + entry.compressedSize && + entry.compressedSize > biggestCompressSize + ) { + biggestCompressSize = entry.compressedSize + } + } + + const sizePad = displaySize(biggestSize).length + const mapPad = displaySize(biggestMap).length + const compressPad = displaySize(biggestCompressSize).length + + const relativeOutDir = normalizePath( + path.relative( + config.root, + path.resolve( + config.root, + outDir ?? environment.config.build.outDir, + ), + ), + ) + const assetsDir = path.join(environment.config.build.assetsDir, '/') + + for (const group of groups) { + const filtered = entries.filter((e) => e.group === group.name) + if (!filtered.length) continue + for (const entry of filtered.sort((a, z) => a.size - z.size)) { + const isLarge = + group.name === 'JS' && entry.size / 1000 > chunkLimit + if (isLarge) hasLargeChunks = true + const sizeColor = isLarge ? colors.yellow : colors.dim + let log = colors.dim(withTrailingSlash(relativeOutDir)) + log += + !config.build.lib && + entry.name.startsWith(withTrailingSlash(assetsDir)) + ? colors.dim(assetsDir) + + group.color( + entry.name + .slice(assetsDir.length) + .padEnd(longest + 2 - assetsDir.length), + ) + : group.color(entry.name.padEnd(longest + 2)) + log += colors.bold( + sizeColor(displaySize(entry.size).padStart(sizePad)), + ) + if (entry.compressedSize) { + log += colors.dim( + ` │ gzip: ${displaySize(entry.compressedSize).padStart( + compressPad, + )}`, + ) + } + if (entry.mapSize) { + log += colors.dim( + ` │ map: ${displaySize(entry.mapSize).padStart(mapPad)}`, + ) + } + config.logger.info(log) + } + } + } else { + hasLargeChunks = Object.values(output).some((chunk) => { + return ( + chunk.type === 'chunk' && chunk.code.length / 1000 > chunkLimit + ) + }) + } + + if ( + hasLargeChunks && + environment.config.build.minify && + !config.build.lib && + !environment.config.build.ssr + ) { + environment.logger.warn( + colors.yellow( + `\n(!) Some chunks are larger than ${chunkLimit} kB after minification. Consider:\n` + + `- Using dynamic import() to code-split the application\n` + + `- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks\n` + + `- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.`, + ), + ) + } + }, + } }) return { name: 'vite:reporter', + sharedDuringBuild: true, + perEnvironmentStartEndDuringDev: true, transform(_, id) { - transformedCount++ - if (shouldLogInfo) { - if (!tty) { - if (!hasTransformed) { - config.logger.info(`transforming...`) - } - } else { - if (id.includes(`?`)) return - logTransform(id) - } - hasTransformed = true - } - return null + modulesReporter(this).register(id) }, buildStart() { - transformedCount = 0 + modulesReporter(this).reset() }, buildEnd() { - if (shouldLogInfo) { - if (tty) { - clearLine() - } - config.logger.info( - `${colors.green(`✓`)} ${transformedCount} modules transformed.`, - ) - } + modulesReporter(this).log() }, renderStart() { - chunkCount = 0 - compressedCount = 0 + chunksReporter(this).reset() }, - renderChunk(code, chunk, options) { + renderChunk(_, chunk, options) { if (!options.inlineDynamicImports) { for (const id of chunk.moduleIds) { const module = this.getModuleInfo(id) @@ -146,149 +322,15 @@ export function buildReporterPlugin(config: ResolvedConfig): Plugin { } } - chunkCount++ - if (shouldLogInfo) { - if (!tty) { - if (!hasRenderedChunk) { - config.logger.info('rendering chunks...') - } - } else { - writeLine(`rendering chunks (${chunkCount})...`) - } - hasRenderedChunk = true - } - return null + chunksReporter(this).register() }, generateBundle() { if (shouldLogInfo && tty) clearLine() }, - async writeBundle({ dir: outDir }, output) { - let hasLargeChunks = false - - if (shouldLogInfo) { - const entries = ( - await Promise.all( - Object.values(output).map( - async (chunk): Promise => { - if (chunk.type === 'chunk') { - return { - name: chunk.fileName, - group: 'JS', - size: chunk.code.length, - compressedSize: await getCompressedSize(chunk.code), - mapSize: chunk.map ? chunk.map.toString().length : null, - } - } else { - if (chunk.fileName.endsWith('.map')) return null - const isCSS = chunk.fileName.endsWith('.css') - const isCompressible = - isCSS || COMPRESSIBLE_ASSETS_RE.test(chunk.fileName) - return { - name: chunk.fileName, - group: isCSS ? 'CSS' : 'Assets', - size: chunk.source.length, - mapSize: null, // Rollup doesn't support CSS maps? - compressedSize: isCompressible - ? await getCompressedSize(chunk.source) - : null, - } - } - }, - ), - ) - ).filter(isDefined) - if (tty) clearLine() - - let longest = 0 - let biggestSize = 0 - let biggestMap = 0 - let biggestCompressSize = 0 - for (const entry of entries) { - if (entry.name.length > longest) longest = entry.name.length - if (entry.size > biggestSize) biggestSize = entry.size - if (entry.mapSize && entry.mapSize > biggestMap) { - biggestMap = entry.mapSize - } - if ( - entry.compressedSize && - entry.compressedSize > biggestCompressSize - ) { - biggestCompressSize = entry.compressedSize - } - } - - const sizePad = displaySize(biggestSize).length - const mapPad = displaySize(biggestMap).length - const compressPad = displaySize(biggestCompressSize).length - - const relativeOutDir = normalizePath( - path.relative( - config.root, - path.resolve(config.root, outDir ?? config.build.outDir), - ), - ) - const assetsDir = path.join(config.build.assetsDir, '/') - - for (const group of groups) { - const filtered = entries.filter((e) => e.group === group.name) - if (!filtered.length) continue - for (const entry of filtered.sort((a, z) => a.size - z.size)) { - const isLarge = - group.name === 'JS' && entry.size / 1000 > chunkLimit - if (isLarge) hasLargeChunks = true - const sizeColor = isLarge ? colors.yellow : colors.dim - let log = colors.dim(withTrailingSlash(relativeOutDir)) - log += - !config.build.lib && - entry.name.startsWith(withTrailingSlash(assetsDir)) - ? colors.dim(assetsDir) + - group.color( - entry.name - .slice(assetsDir.length) - .padEnd(longest + 2 - assetsDir.length), - ) - : group.color(entry.name.padEnd(longest + 2)) - log += colors.bold( - sizeColor(displaySize(entry.size).padStart(sizePad)), - ) - if (entry.compressedSize) { - log += colors.dim( - ` │ gzip: ${displaySize(entry.compressedSize).padStart( - compressPad, - )}`, - ) - } - if (entry.mapSize) { - log += colors.dim( - ` │ map: ${displaySize(entry.mapSize).padStart(mapPad)}`, - ) - } - config.logger.info(log) - } - } - } else { - hasLargeChunks = Object.values(output).some((chunk) => { - return chunk.type === 'chunk' && chunk.code.length / 1000 > chunkLimit - }) - } - - if ( - hasLargeChunks && - config.build.minify && - !config.build.lib && - !config.build.ssr - ) { - config.logger.warn( - colors.yellow( - `\n(!) Some chunks are larger than ${chunkLimit} kB after minification. Consider:\n` + - `- Using dynamic import() to code-split the application\n` + - `- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks\n` + - `- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.`, - ), - ) - } + async writeBundle({ dir }, output) { + await chunksReporter(this).log(output, dir) }, } } diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index c9facf7cfc5f8e..2abacbf9d2caea 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -35,12 +35,14 @@ import { safeRealpathSync, tryStatSync, } from '../utils' +import type { ResolvedEnvironmentOptions } from '../config' import { optimizedDepInfoFromFile, optimizedDepInfoFromId } from '../optimizer' import type { DepsOptimizer } from '../optimizer' -import type { SSROptions } from '..' +import type { DepOptimizationOptions, SSROptions } from '..' import type { PackageCache, PackageData } from '../packages' import type { FsUtils } from '../fsUtils' import { commonFsUtils } from '../fsUtils' +import { shouldExternalize } from '../external' import { findNearestMainPackageData, findNearestPackageData, @@ -73,28 +75,38 @@ const debug = createDebugger('vite:resolve-details', { onlyWhenFocused: true, }) -export interface ResolveOptions { +export interface EnvironmentResolveOptions { /** * @default ['browser', 'module', 'jsnext:main', 'jsnext'] */ mainFields?: string[] conditions?: string[] + externalConditions?: string[] /** * @default ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'] */ extensions?: string[] dedupe?: string[] + /** + * external/noExternal logic, this only works for certain environments + * Previously this was ssr.external/ssr.noExternal + * TODO: better abstraction that works for the client environment too? + */ + noExternal?: string | RegExp | (string | RegExp)[] | true + external?: string[] | true +} + +export interface ResolveOptions extends EnvironmentResolveOptions { /** * @default false */ preserveSymlinks?: boolean } -export interface InternalResolveOptions extends Required { +interface ResolvePluginOptions { root: string isBuild: boolean isProduction: boolean - ssrConfig?: SSROptions packageCache?: PackageCache fsUtils?: FsUtils /** @@ -107,6 +119,7 @@ export interface InternalResolveOptions extends Required { tryPrefix?: string preferRelative?: boolean isRequire?: boolean + webCompatible?: boolean // #3040 // when the importer is a ts module, // if the specifier requests a non-existent `.js/jsx/mjs/cjs` file, @@ -117,8 +130,31 @@ export interface InternalResolveOptions extends Required { scan?: boolean // Appends ?__vite_skip_optimization to the resolved id if shouldn't be optimized ssrOptimizeCheck?: boolean - // Resolve using esbuild deps optimization + + /** + * Optimize deps during dev, defaults to false // TODO: Review default + * @internal + */ + optimizeDeps?: boolean + + /** + * externalize using external/noExternal, defaults to false // TODO: Review default + * @internal + */ + externalize?: boolean + + /** + * Previous deps optimizer logic + * @internal + * @deprecated + */ getDepsOptimizer?: (ssr: boolean) => DepsOptimizer | undefined + + /** + * Externalize logic for SSR builds + * @internal + * @deprecated + */ shouldExternalize?: (id: string, importer?: string) => boolean | undefined /** @@ -127,22 +163,34 @@ export interface InternalResolveOptions extends Required { * @internal */ idOnly?: boolean + + /** + * @deprecated environment.config are used instead + */ + ssrConfig?: SSROptions } -export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { - const { - root, - isProduction, - asSrc, - ssrConfig, - preferRelative = false, - } = resolveOptions - - const { - target: ssrTarget, - noExternal: ssrNoExternal, - external: ssrExternal, - } = ssrConfig ?? {} +export interface InternalResolveOptions + extends Required, + ResolvePluginOptions {} + +// Defined ResolveOptions are used to overwrite the values for all environments +// It is used when creating custom resolvers (for CSS, scanning, etc) +export interface ResolvePluginOptionsWithOverrides + extends ResolveOptions, + ResolvePluginOptions {} + +export function resolvePlugin( + resolveOptions: ResolvePluginOptionsWithOverrides, + /** + * @internal + * config.createResolver creates a pluginContainer before environments are created. + * The resolve plugin is especial as it works without environments to enable this use case. + * It only needs access to the resolve options. + */ + environmentsOptions?: Record, +): Plugin { + const { root, isProduction, asSrc, preferRelative = false } = resolveOptions // In unix systems, absolute paths inside root first needs to be checked as an // absolute URL (/root/root/path-to-file) resulting in failed checks before falling @@ -166,39 +214,41 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { const ssr = resolveOpts?.ssr === true - // We need to delay depsOptimizer until here instead of passing it as an option - // the resolvePlugin because the optimizer is created on server listen during dev - const depsOptimizer = resolveOptions.getDepsOptimizer?.(ssr) + // The resolve plugin is used for createIdResolver and the depsOptimizer should be + // disabled in that case, so deps optimization is opt-in when creating the plugin. + const depsOptimizer = + resolveOptions.optimizeDeps && this.environment.mode === 'dev' + ? this.environment.depsOptimizer + : undefined if (id.startsWith(browserExternalId)) { return id } - const targetWeb = !ssr || ssrTarget === 'webworker' - // this is passed by @rollup/plugin-commonjs const isRequire: boolean = resolveOpts?.custom?.['node-resolve']?.isRequire ?? false - // end user can configure different conditions for ssr and client. - // falls back to client conditions if no ssr conditions supplied - const ssrConditions = - resolveOptions.ssrConfig?.resolve?.conditions || - resolveOptions.conditions - + const environmentName = this.environment.name ?? (ssr ? 'ssr' : 'client') + const currentEnvironmentOptions = + this.environment.config || environmentsOptions?.[environmentName] + const environmentResolveOptions = currentEnvironmentOptions?.resolve + if (!environmentResolveOptions) { + throw new Error( + `Missing ResolveOptions for ${environmentName} environment`, + ) + } const options: InternalResolveOptions = { isRequire, - ...resolveOptions, + ...environmentResolveOptions, + webCompatible: currentEnvironmentOptions.webCompatible, + ...resolveOptions, // plugin options + resolve options overrides scan: resolveOpts?.scan ?? resolveOptions.scan, - conditions: ssr ? ssrConditions : resolveOptions.conditions, } - const resolvedImports = resolveSubpathImports( - id, - importer, - options, - targetWeb, - ) + const depsOptimizerOptions = this.environment.config.dev.optimizeDeps + + const resolvedImports = resolveSubpathImports(id, importer, options) if (resolvedImports) { id = resolvedImports @@ -238,7 +288,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { // always return here even if res doesn't exist since /@fs/ is explicit // if the file doesn't exist it should be a 404. debug?.(`[@fs] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return ensureVersionQuery(res, id, options, ssr, depsOptimizer) } // URL @@ -251,7 +301,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { const fsPath = path.resolve(root, id.slice(1)) if ((res = tryFsResolve(fsPath, options))) { debug?.(`[url] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return ensureVersionQuery(res, id, options, ssr, depsOptimizer) } } @@ -270,10 +320,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { if (depsOptimizer?.isOptimizedDepFile(normalizedFsPath)) { // Optimized files could not yet exist in disk, resolve to the full path // Inject the current browserHash version if the path doesn't have one - if ( - !resolveOptions.isBuild && - !DEP_VERSION_RE.test(normalizedFsPath) - ) { + if (!options.isBuild && !DEP_VERSION_RE.test(normalizedFsPath)) { const browserHash = optimizedDepInfoFromFile( depsOptimizer.metadata, normalizedFsPath, @@ -286,15 +333,22 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { } if ( - targetWeb && + options.webCompatible && options.mainFields.includes('browser') && - (res = tryResolveBrowserMapping(fsPath, importer, options, true)) + (res = tryResolveBrowserMapping( + fsPath, + importer, + options, + true, + undefined, + depsOptimizerOptions, + )) ) { return res } if ((res = tryFsResolve(fsPath, options))) { - res = ensureVersionQuery(res, id, options, depsOptimizer) + res = ensureVersionQuery(res, id, options, ssr, depsOptimizer) debug?.(`[relative] ${colors.cyan(id)} -> ${colors.dim(res)}`) // If this isn't a script imported from a .html file, include side effects @@ -326,7 +380,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { const fsPath = path.resolve(basedir, id) if ((res = tryFsResolve(fsPath, options))) { debug?.(`[drive-relative] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return ensureVersionQuery(res, id, options, ssr, depsOptimizer) } } @@ -336,7 +390,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { (res = tryFsResolve(id, options)) ) { debug?.(`[fs] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return ensureVersionQuery(res, id, options, ssr, depsOptimizer) } // external @@ -352,7 +406,9 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { // bare package imports, perform node resolve if (bareImportRE.test(id)) { - const external = options.shouldExternalize?.(id, importer) + const external = + options.externalize && + shouldExternalize(this.environment, id, importer) if ( !external && asSrc && @@ -370,7 +426,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { } if ( - targetWeb && + options.webCompatible && options.mainFields.includes('browser') && (res = tryResolveBrowserMapping( id, @@ -378,6 +434,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { options, false, external, + depsOptimizerOptions, )) ) { return res @@ -388,25 +445,26 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { id, importer, options, - targetWeb, depsOptimizer, ssr, external, + undefined, + depsOptimizerOptions, )) ) { return res } // node built-ins. - // externalize if building for SSR, otherwise redirect to empty module + // externalize if building for a node compatible environment, otherwise redirect to empty module if (isBuiltin(id)) { - if (ssr) { + if (currentEnvironmentOptions.consumer === 'server') { if ( - targetWeb && - ssrNoExternal === true && + options.webCompatible && + options.noExternal === true && // if both noExternal and external are true, noExternal will take the higher priority and bundle it. // only if the id is explicitly listed in external, we will externalize it and skip this error. - (ssrExternal === true || !ssrExternal?.includes(id)) + (options.external === true || !options.external.includes(id)) ) { let message = `Cannot bundle Node.js built-in "${id}"` if (importer) { @@ -415,7 +473,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { importer, )}"` } - message += `. Consider disabling ssr.noExternal or remove the built-in dependency.` + message += `. Consider disabling environments.${environmentName}.noExternal or remove the built-in dependency.` this.error(message) } @@ -474,7 +532,6 @@ function resolveSubpathImports( id: string, importer: string | undefined, options: InternalResolveOptions, - targetWeb: boolean, ) { if (!importer || !id.startsWith(subpathImportsPrefix)) return const basedir = path.dirname(importer) @@ -488,7 +545,6 @@ function resolveSubpathImports( pkgData.data, idWithoutPostfix, options, - targetWeb, 'imports', ) @@ -507,9 +563,11 @@ function ensureVersionQuery( resolved: string, id: string, options: InternalResolveOptions, + ssr: boolean, depsOptimizer?: DepsOptimizer, ): string { if ( + !ssr && !options.isBuild && !options.scan && depsOptimizer && @@ -665,7 +723,7 @@ function tryCleanFsResolve( } // path points to a node package const pkg = loadPackageData(pkgPath) - return resolvePackageEntry(dirPath, pkg, targetWeb, options) + return resolvePackageEntry(dirPath, pkg, options) } } catch (e) { // This check is best effort, so if an entry is not found, skip error for now @@ -708,11 +766,11 @@ export function tryNodeResolve( id: string, importer: string | null | undefined, options: InternalResolveOptionsWithOverrideConditions, - targetWeb: boolean, depsOptimizer?: DepsOptimizer, ssr: boolean = false, externalize?: boolean, allowLinkedExternal: boolean = true, + depsOptimizerOptions?: DepOptimizationOptions, ): PartialResolvedId | undefined { const { root, dedupe, isBuild, preserveSymlinks, packageCache } = options @@ -780,14 +838,14 @@ export function tryNodeResolve( let resolved: string | undefined try { - resolved = resolveId(unresolvedId, pkg, targetWeb, options) + resolved = resolveId(unresolvedId, pkg, options) } catch (err) { if (!options.tryEsmOnly) { throw err } } if (!resolved && options.tryEsmOnly) { - resolved = resolveId(unresolvedId, pkg, targetWeb, { + resolved = resolveId(unresolvedId, pkg, { ...options, isRequire: false, mainFields: DEFAULT_MAIN_FIELDS, @@ -861,8 +919,8 @@ export function tryNodeResolve( let include = depsOptimizer?.options.include if (options.ssrOptimizeCheck) { // we don't have the depsOptimizer - exclude = options.ssrConfig?.optimizeDeps?.exclude - include = options.ssrConfig?.optimizeDeps?.include + exclude = depsOptimizerOptions?.exclude + include = depsOptimizerOptions?.include } const skipOptimization = @@ -978,12 +1036,11 @@ export async function tryOptimizedResolve( export function resolvePackageEntry( id: string, { dir, data, setResolvedCache, getResolvedCache }: PackageData, - targetWeb: boolean, options: InternalResolveOptions, ): string | undefined { const { file: idWithoutPostfix, postfix } = splitFileAndPostfix(id) - const cached = getResolvedCache('.', targetWeb) + const cached = getResolvedCache('.', !!options.webCompatible) if (cached) { return cached + postfix } @@ -994,20 +1051,14 @@ export function resolvePackageEntry( // resolve exports field with highest priority // using https://github.com/lukeed/resolve.exports if (data.exports) { - entryPoint = resolveExportsOrImports( - data, - '.', - options, - targetWeb, - 'exports', - ) + entryPoint = resolveExportsOrImports(data, '.', options, 'exports') } // fallback to mainFields if still not resolved if (!entryPoint) { for (const field of options.mainFields) { if (field === 'browser') { - if (targetWeb) { + if (options.webCompatible) { entryPoint = tryResolveBrowserEntry(dir, data, options) if (entryPoint) { break @@ -1040,7 +1091,7 @@ export function resolvePackageEntry( // resolve object browser field in package.json const { browser: browserField } = data if ( - targetWeb && + options.webCompatible && options.mainFields.includes('browser') && isObject(browserField) ) { @@ -1062,7 +1113,7 @@ export function resolvePackageEntry( resolvedEntryPoint, )}${postfix !== '' ? ` (postfix: ${postfix})` : ''}`, ) - setResolvedCache('.', resolvedEntryPoint, targetWeb) + setResolvedCache('.', resolvedEntryPoint, !!options.webCompatible) return resolvedEntryPoint + postfix } } @@ -1086,7 +1137,6 @@ function resolveExportsOrImports( pkg: PackageData['data'], key: string, options: InternalResolveOptionsWithOverrideConditions, - targetWeb: boolean, type: 'imports' | 'exports', ) { const additionalConditions = new Set( @@ -1110,7 +1160,7 @@ function resolveExportsOrImports( const fn = type === 'imports' ? imports : exports const result = fn(pkg, key, { - browser: targetWeb && !additionalConditions.has('node'), + browser: options.webCompatible && !additionalConditions.has('node'), require: options.isRequire && !additionalConditions.has('import'), conditions, }) @@ -1127,10 +1177,9 @@ function resolveDeepImport( dir, data, }: PackageData, - targetWeb: boolean, options: InternalResolveOptions, ): string | undefined { - const cache = getResolvedCache(id, targetWeb) + const cache = getResolvedCache(id, !!options.webCompatible) if (cache) { return cache } @@ -1143,13 +1192,7 @@ function resolveDeepImport( if (isObject(exportsField) && !Array.isArray(exportsField)) { // resolve without postfix (see #7098) const { file, postfix } = splitFileAndPostfix(relativeId) - const exportsId = resolveExportsOrImports( - data, - file, - options, - targetWeb, - 'exports', - ) + const exportsId = resolveExportsOrImports(data, file, options, 'exports') if (exportsId !== undefined) { relativeId = exportsId + postfix } else { @@ -1166,7 +1209,7 @@ function resolveDeepImport( ) } } else if ( - targetWeb && + options.webCompatible && options.mainFields.includes('browser') && isObject(browserField) ) { @@ -1185,13 +1228,13 @@ function resolveDeepImport( path.join(dir, relativeId), options, !exportsField, // try index only if no exports field - targetWeb, + !!options.webCompatible, ) if (resolved) { debug?.( `[node/deep-import] ${colors.cyan(id)} -> ${colors.dim(resolved)}`, ) - setResolvedCache(id, resolved, targetWeb) + setResolvedCache(id, resolved, !!options.webCompatible) return resolved } } @@ -1203,6 +1246,7 @@ function tryResolveBrowserMapping( options: InternalResolveOptions, isFilePath: boolean, externalize?: boolean, + depsOptimizerOptions?: DepOptimizationOptions, ) { let res: string | undefined const pkg = @@ -1214,7 +1258,16 @@ function tryResolveBrowserMapping( if (browserMappedPath) { if ( (res = bareImportRE.test(browserMappedPath) - ? tryNodeResolve(browserMappedPath, importer, options, true)?.id + ? tryNodeResolve( + browserMappedPath, + importer, + options, + undefined, + undefined, + undefined, + undefined, + depsOptimizerOptions, + )?.id : tryFsResolve(path.join(pkg.dir, browserMappedPath), options)) ) { debug?.(`[browser mapped] ${colors.cyan(id)} -> ${colors.dim(res)}`) diff --git a/packages/vite/src/node/plugins/terser.ts b/packages/vite/src/node/plugins/terser.ts index 90c29b26c7501e..9c254dee1043af 100644 --- a/packages/vite/src/node/plugins/terser.ts +++ b/packages/vite/src/node/plugins/terser.ts @@ -59,6 +59,12 @@ export function terserPlugin(config: ResolvedConfig): Plugin { return { name: 'vite:terser', + applyToEnvironment(environment) { + // We also need the plugin even if minify isn't 'terser' as we force + // terser in plugin-legacy + return !!environment.config.build.minify + }, + async renderChunk(code, _chunk, outputOptions) { // This plugin is included for any non-false value of config.build.minify, // so that normal chunks can use the preferred minifier, and legacy chunks diff --git a/packages/vite/src/node/plugins/wasm.ts b/packages/vite/src/node/plugins/wasm.ts index 407ea5f0009a9e..d3e9c272c89b50 100644 --- a/packages/vite/src/node/plugins/wasm.ts +++ b/packages/vite/src/node/plugins/wasm.ts @@ -66,7 +66,7 @@ export const wasmHelperPlugin = (config: ResolvedConfig): Plugin => { return } - const url = await fileToUrl(id, config, this) + const url = await fileToUrl(this, id) return ` import initWasm from "${wasmHelperId}" diff --git a/packages/vite/src/node/plugins/worker.ts b/packages/vite/src/node/plugins/worker.ts index a5ac3853b667d4..4625a82eab2657 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -3,7 +3,6 @@ import MagicString from 'magic-string' import type { OutputChunk } from 'rollup' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' -import type { ViteDevServer } from '../server' import { ENV_ENTRY, ENV_PUBLIC_PATH } from '../constants' import { encodeURIPath, @@ -13,7 +12,9 @@ import { urlRE, } from '../utils' import { + BuildEnvironment, createToImportMetaURLBasedRelativeRuntime, + injectEnvironmentToHooks, onRollupWarning, toOutputFilePathInJS, } from '../build' @@ -72,12 +73,17 @@ async function bundleWorkerEntry( // bundle the file as entry to support imports const { rollup } = await import('rollup') const { plugins, rollupOptions, format } = config.worker + const { plugins: resolvedPlugins, config: workerConfig } = + await plugins(newBundleChain) + const workerEnvironment = new BuildEnvironment('client', workerConfig) // TODO: should this be 'worker'? const bundle = await rollup({ ...rollupOptions, input, - plugins: await plugins(newBundleChain), + plugins: resolvedPlugins.map((p) => + injectEnvironmentToHooks(workerEnvironment, p), + ), onwarn(warning, warn) { - onRollupWarning(warning, warn, config) + onRollupWarning(warning, warn, workerEnvironment) }, preserveEntrySignatures: false, }) @@ -210,16 +216,11 @@ export function webWorkerPostPlugin(): Plugin { export function webWorkerPlugin(config: ResolvedConfig): Plugin { const isBuild = config.command === 'build' - let server: ViteDevServer const isWorker = config.isWorker return { name: 'vite:worker', - configureServer(_server) { - server = _server - }, - buildStart() { if (isWorker) { return @@ -243,7 +244,7 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { } }, - async transform(raw, id) { + async transform(raw, id, options) { const workerFileMatch = workerFileRE.exec(id) if (workerFileMatch) { // if import worker by worker constructor will have query.type @@ -262,11 +263,13 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { } else if (workerType === 'ignore') { if (isBuild) { injectEnv = '' - } else if (server) { + } else { // dynamic worker type we can't know how import the env // so we copy /@vite/env code of server transform result into file header - const { moduleGraph } = server - const module = moduleGraph.getModuleById(ENV_ENTRY) + const environment = this.environment + const moduleGraph = + environment.mode === 'dev' ? environment.moduleGraph : undefined + const module = moduleGraph?.getModuleById(ENV_ENTRY) injectEnv = module?.transformResult?.code || '' } } @@ -361,7 +364,7 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { urlCode = JSON.stringify(await workerFileToUrl(config, id)) } } else { - let url = await fileToUrl(cleanUrl(id), config, this) + let url = await fileToUrl(this, cleanUrl(id)) url = injectQuery(url, `${WORKER_FILE_ID}&type=${workerType}`) urlCode = JSON.stringify(url) } @@ -390,7 +393,7 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { return ( s && { code: s.toString(), - map: config.build.sourcemap + map: this.environment.config.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null, } @@ -400,7 +403,7 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { if (workerAssetUrlRE.test(code)) { const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( outputOptions.format, - config.isWorker, + this.environment.config.isWorker, ) let match: RegExpExecArray | null @@ -415,11 +418,11 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { const [full, hash] = match const filename = fileNameHash.get(hash)! const replacement = toOutputFilePathInJS( + this.environment, filename, 'asset', chunk.fileName, 'js', - config, toRelativeRuntime, ) const replacementString = diff --git a/packages/vite/src/node/plugins/workerImportMetaUrl.ts b/packages/vite/src/node/plugins/workerImportMetaUrl.ts index acf1c87a480478..8d8d316a4ec214 100644 --- a/packages/vite/src/node/plugins/workerImportMetaUrl.ts +++ b/packages/vite/src/node/plugins/workerImportMetaUrl.ts @@ -5,7 +5,8 @@ import { stripLiteral } from 'strip-literal' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' import { evalValue, injectQuery, transformStableResult } from '../utils' -import type { ResolveFn } from '..' +import { createBackCompatIdResolver } from '../idResolver' +import type { ResolveIdFn } from '../idResolver' import { cleanUrl, slash } from '../../shared/utils' import type { WorkerType } from './worker' import { WORKER_FILE_ID, workerFileToUrl } from './worker' @@ -105,7 +106,7 @@ function isIncludeWorkerImportMetaUrl(code: string): boolean { export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { const isBuild = config.command === 'build' - let workerResolver: ResolveFn + let workerResolver: ResolveIdFn const fsResolveOptions: InternalResolveOptions = { ...config.resolve, @@ -113,7 +114,6 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { isProduction: config.isProduction, isBuild: config.command === 'build', packageCache: config.packageCache, - ssrConfig: config.ssr, asSrc: true, } @@ -126,8 +126,11 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { } }, - async transform(code, id, options) { - if (!options?.ssr && isIncludeWorkerImportMetaUrl(code)) { + async transform(code, id) { + if ( + this.environment.config.consumer === 'client' && + isIncludeWorkerImportMetaUrl(code) + ) { let s: MagicString | undefined const cleanString = stripLiteral(code) const workerImportMetaUrlRE = @@ -156,12 +159,12 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { file = path.resolve(path.dirname(id), url) file = tryFsResolve(file, fsResolveOptions) ?? file } else { - workerResolver ??= config.createResolver({ + workerResolver ??= createBackCompatIdResolver(config, { extensions: [], tryIndex: false, preferRelative: true, }) - file = await workerResolver(url, id) + file = await workerResolver(this.environment, url, id) file ??= url[0] === '/' ? slash(path.join(config.publicDir, url)) @@ -179,7 +182,7 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { if (isBuild) { builtUrl = await workerFileToUrl(config, file) } else { - builtUrl = await fileToUrl(cleanUrl(file), config, this) + builtUrl = await fileToUrl(this, cleanUrl(file)) builtUrl = injectQuery( builtUrl, `${WORKER_FILE_ID}&type=${workerType}`, diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index 38192abea7626d..8ea36ab2df2369 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -117,7 +117,9 @@ export async function preview( true, ) - const distDir = path.resolve(config.root, config.build.outDir) + const clientOutDir = + config.environments.client.build.outDir ?? config.build.outDir + const distDir = path.resolve(config.root, clientOutDir) if ( !fs.existsSync(distDir) && // error if no plugins implement `configurePreviewServer` @@ -128,7 +130,7 @@ export async function preview( process.argv[2] === 'preview' ) { throw new Error( - `The directory "${config.build.outDir}" does not exist. Did you build your project?`, + `The directory "${clientOutDir}" does not exist. Did you build your project?`, ) } diff --git a/packages/vite/src/node/publicUtils.ts b/packages/vite/src/node/publicUtils.ts index 318c904047b2c0..a9cad7a5106db6 100644 --- a/packages/vite/src/node/publicUtils.ts +++ b/packages/vite/src/node/publicUtils.ts @@ -20,5 +20,9 @@ export { export { send } from './server/send' export { createLogger } from './logger' export { searchForWorkspaceRoot } from './server/searchRoot' -export { isFileServingAllowed } from './server/middlewares/static' + +export { + isFileServingAllowed, + isFileLoadingAllowed, +} from './server/middlewares/static' export { loadEnv, resolveEnvPrefix } from './env' diff --git a/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts b/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts index 2285d2fa4fa8b9..6efcb02c4f8eac 100644 --- a/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts +++ b/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest' -import { ModuleGraph } from '../moduleGraph' +import { EnvironmentModuleGraph } from '../moduleGraph' +import type { ModuleNode } from '../mixedModuleGraph' +import { ModuleGraph } from '../mixedModuleGraph' describe('moduleGraph', () => { describe('invalidateModule', () => { - it('removes an ssrError', async () => { - const moduleGraph = new ModuleGraph(async (url) => ({ id: url })) + it('removes an ssr error', async () => { + const moduleGraph = new EnvironmentModuleGraph('ssr', async (url) => ({ + id: url, + })) const entryUrl = '/x.js' const entryModule = await moduleGraph.ensureEntryFromUrl(entryUrl, false) @@ -16,7 +20,7 @@ describe('moduleGraph', () => { }) it('ensureEntryFromUrl should based on resolvedId', async () => { - const moduleGraph = new ModuleGraph(async (url) => { + const moduleGraph = new EnvironmentModuleGraph('client', async (url) => { if (url === '/xx.js') { return { id: '/x.js' } } else { @@ -30,5 +34,75 @@ describe('moduleGraph', () => { const mod2 = await moduleGraph.ensureEntryFromUrl('/xx.js', false) expect(mod2.meta).to.equal(meta) }) + + it('ensure backward compatibility', async () => { + const clientModuleGraph = new EnvironmentModuleGraph( + 'client', + async (url) => ({ id: url }), + ) + const ssrModuleGraph = new EnvironmentModuleGraph('ssr', async (url) => ({ + id: url, + })) + const moduleGraph = new ModuleGraph({ + client: () => clientModuleGraph, + ssr: () => ssrModuleGraph, + }) + + const addBrowserModule = (url: string) => + clientModuleGraph.ensureEntryFromUrl(url) + const getBrowserModule = (url: string) => + clientModuleGraph.getModuleById(url) + + const addServerModule = (url: string) => + ssrModuleGraph.ensureEntryFromUrl(url) + const getServerModule = (url: string) => ssrModuleGraph.getModuleById(url) + + const clientModule1 = await addBrowserModule('/1.js') + const ssrModule1 = await addServerModule('/1.js') + const module1 = moduleGraph.getModuleById('/1.js')! + expect(module1._clientModule).toBe(clientModule1) + expect(module1._ssrModule).toBe(ssrModule1) + + const module2b = await moduleGraph.ensureEntryFromUrl('/b/2.js') + const module2s = await moduleGraph.ensureEntryFromUrl('/s/2.js') + expect(module2b._clientModule).toBe(getBrowserModule('/b/2.js')) + expect(module2s._ssrModule).toBe(getServerModule('/s/2.js')) + + const importersUrls = ['/1/a.js', '/1/b.js', '/1/c.js'] + ;(await Promise.all(importersUrls.map(addBrowserModule))).forEach((mod) => + clientModule1.importers.add(mod), + ) + ;(await Promise.all(importersUrls.map(addServerModule))).forEach((mod) => + ssrModule1.importers.add(mod), + ) + + expect(module1.importers.size).toBe(importersUrls.length) + + const clientModule1importersValues = [...clientModule1.importers] + const ssrModule1importersValues = [...ssrModule1.importers] + + const module1importers = module1.importers + const module1importersValues = [...module1importers.values()] + expect(module1importersValues.length).toBe(importersUrls.length) + expect(module1importersValues[1]._clientModule).toBe( + clientModule1importersValues[1], + ) + expect(module1importersValues[1]._ssrModule).toBe( + ssrModule1importersValues[1], + ) + + const module1importersFromForEach: ModuleNode[] = [] + module1.importers.forEach((imp) => { + moduleGraph.invalidateModule(imp) + module1importersFromForEach.push(imp) + }) + expect(module1importersFromForEach.length).toBe(importersUrls.length) + expect(module1importersFromForEach[1]._clientModule).toBe( + clientModule1importersValues[1], + ) + expect(module1importersFromForEach[1]._ssrModule).toBe( + ssrModule1importersValues[1], + ) + }) }) }) diff --git a/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts b/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts index 070dedd2acb463..5dce96bd36384f 100644 --- a/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts +++ b/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts @@ -1,20 +1,11 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { UserConfig } from '../../config' import { resolveConfig } from '../../config' import type { Plugin } from '../../plugin' -import { ModuleGraph } from '../moduleGraph' -import type { PluginContainer } from '../pluginContainer' -import { createPluginContainer } from '../pluginContainer' - -let resolveId: (id: string) => any -let moduleGraph: ModuleGraph +import { DevEnvironment } from '../environment' describe('plugin container', () => { describe('getModuleInfo', () => { - beforeEach(() => { - moduleGraph = new ModuleGraph((id) => resolveId(id)) - }) - it('can pass metadata between hooks', async () => { const entryUrl = '/x.js' @@ -52,18 +43,21 @@ describe('plugin container', () => { }, } - const container = await getPluginContainer({ + const environment = await getDevEnvironment({ plugins: [plugin], }) - const entryModule = await moduleGraph.ensureEntryFromUrl(entryUrl, false) + const entryModule = await environment.moduleGraph.ensureEntryFromUrl( + entryUrl, + false, + ) expect(entryModule.meta).toEqual({ x: 1 }) - const loadResult: any = await container.load(entryUrl) + const loadResult: any = await environment.pluginContainer.load(entryUrl) expect(loadResult?.meta).toEqual({ x: 2 }) - await container.transform(loadResult.code, entryUrl) - await container.close() + await environment.pluginContainer.transform(loadResult.code, entryUrl) + await environment.pluginContainer.close() expect(metaArray).toEqual([{ x: 1 }, { x: 2 }, { x: 3 }]) }) @@ -91,12 +85,12 @@ describe('plugin container', () => { }, } - const container = await getPluginContainer({ + const environment = await getDevEnvironment({ plugins: [plugin1, plugin2], }) - await moduleGraph.ensureEntryFromUrl(entryUrl, false) - await container.load(entryUrl) + await environment.moduleGraph.ensureEntryFromUrl(entryUrl, false) + await environment.pluginContainer.load(entryUrl) expect.assertions(1) }) @@ -137,22 +131,18 @@ describe('plugin container', () => { }, } - const container = await getPluginContainer({ + const environment = await getDevEnvironment({ plugins: [plugin1, plugin2], }) - await moduleGraph.ensureEntryFromUrl(entryUrl, false) - await container.load(entryUrl) + await environment.moduleGraph.ensureEntryFromUrl(entryUrl, false) + await environment.pluginContainer.load(entryUrl) expect.assertions(2) }) }) describe('load', () => { - beforeEach(() => { - moduleGraph = new ModuleGraph((id) => resolveId(id)) - }) - it('can resolve a secondary module', async () => { const entryUrl = '/x.js' @@ -176,12 +166,15 @@ describe('plugin container', () => { }, } - const container = await getPluginContainer({ + const environment = await getDevEnvironment({ plugins: [plugin], }) - await moduleGraph.ensureEntryFromUrl(entryUrl, false) - const loadResult: any = await container.load(entryUrl) - const result: any = await container.transform(loadResult.code, entryUrl) + await environment.moduleGraph.ensureEntryFromUrl(entryUrl, false) + const loadResult: any = await environment.pluginContainer.load(entryUrl) + const result: any = await environment.pluginContainer.transform( + loadResult.code, + entryUrl, + ) expect(result.code).equals('2') }) @@ -208,20 +201,23 @@ describe('plugin container', () => { }, } - const container = await getPluginContainer({ + const environment = await getDevEnvironment({ plugins: [plugin], }) - await moduleGraph.ensureEntryFromUrl(entryUrl, false) - const loadResult: any = await container.load(entryUrl) - const result: any = await container.transform(loadResult.code, entryUrl) + await environment.moduleGraph.ensureEntryFromUrl(entryUrl, false) + const loadResult: any = await environment.pluginContainer.load(entryUrl) + const result: any = await environment.pluginContainer.transform( + loadResult.code, + entryUrl, + ) expect(result.code).equals('3') }) }) }) -async function getPluginContainer( +async function getDevEnvironment( inlineConfig?: UserConfig, -): Promise { +): Promise { const config = await resolveConfig( { configFile: false, ...inlineConfig }, 'serve', @@ -230,7 +226,8 @@ async function getPluginContainer( // @ts-expect-error This plugin requires a ViteDevServer instance. config.plugins = config.plugins.filter((p) => !p.name.includes('pre-alias')) - resolveId = (id) => container.resolveId(id) - const container = await createPluginContainer(config, moduleGraph) - return container + const environment = new DevEnvironment('client', config, { hot: false }) + await environment.init() + + return environment } diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts new file mode 100644 index 00000000000000..e2221eb8bba153 --- /dev/null +++ b/packages/vite/src/node/server/environment.ts @@ -0,0 +1,363 @@ +import type { FetchFunctionOptions, FetchResult } from 'vite/module-runner' +import type { FSWatcher } from 'dep-types/chokidar' +import colors from 'picocolors' +import { BaseEnvironment } from '../baseEnvironment' +import { ERR_OUTDATED_OPTIMIZED_DEP } from '../plugins/optimizedDeps' +import type { + EnvironmentOptions, + ResolvedConfig, + ResolvedEnvironmentOptions, +} from '../config' +import { getDefaultResolvedEnvironmentOptions } from '../config' +import { mergeConfig, promiseWithResolvers } from '../utils' +import type { FetchModuleOptions } from '../ssr/fetchModule' +import { fetchModule } from '../ssr/fetchModule' +import type { DepsOptimizer } from '../optimizer' +import { isDepOptimizationDisabled } from '../optimizer' +import { + createDepsOptimizer, + createExplicitDepsOptimizer, +} from '../optimizer/optimizer' +import { resolveEnvironmentPlugins } from '../plugin' +import { EnvironmentModuleGraph } from './moduleGraph' +import type { EnvironmentModuleNode } from './moduleGraph' +import type { HotChannel } from './hmr' +import { createNoopHotChannel, getShortName, updateModules } from './hmr' +import type { TransformResult } from './transformRequest' +import { transformRequest } from './transformRequest' +import type { EnvironmentPluginContainer } from './pluginContainer' +import { + ERR_CLOSED_SERVER, + createEnvironmentPluginContainer, +} from './pluginContainer' +import type { RemoteEnvironmentTransport } from './environmentTransport' + +export interface DevEnvironmentContext { + hot: false | HotChannel + options?: EnvironmentOptions + runner?: FetchModuleOptions & { + transport?: RemoteEnvironmentTransport + } + depsOptimizer?: DepsOptimizer +} + +export class DevEnvironment extends BaseEnvironment { + mode = 'dev' as const + moduleGraph: EnvironmentModuleGraph + + depsOptimizer?: DepsOptimizer + /** + * @internal + */ + _ssrRunnerOptions: FetchModuleOptions | undefined + + get pluginContainer(): EnvironmentPluginContainer { + if (!this._pluginContainer) + throw new Error( + `${this.name} environment.pluginContainer called before initialized`, + ) + return this._pluginContainer + } + /** + * @internal + */ + _pluginContainer: EnvironmentPluginContainer | undefined + + /** + * @internal + */ + _closing: boolean = false + /** + * @internal + */ + _pendingRequests: Map< + string, + { + request: Promise + timestamp: number + abort: () => void + } + > + /** + * @internal + */ + _onCrawlEndCallbacks: (() => void)[] + /** + * @internal + */ + _crawlEndFinder: CrawlEndFinder + + /** + * Hot channel for this environment. If not provided or disabled, + * it will be a noop channel that does nothing. + * + * @example + * environment.hot.send({ type: 'full-reload' }) + */ + hot: HotChannel + constructor( + name: string, + config: ResolvedConfig, + context: DevEnvironmentContext, + ) { + let options = + config.environments[name] ?? getDefaultResolvedEnvironmentOptions(config) + if (context.options) { + options = mergeConfig( + options, + context.options, + ) as ResolvedEnvironmentOptions + } + super(name, config, options) + + this._pendingRequests = new Map() + + this.moduleGraph = new EnvironmentModuleGraph(name, (url: string) => + this.pluginContainer!.resolveId(url, undefined), + ) + + this.hot = context.hot || createNoopHotChannel() + + this._onCrawlEndCallbacks = [] + this._crawlEndFinder = setupOnCrawlEnd(() => { + this._onCrawlEndCallbacks.forEach((cb) => cb()) + }) + + this._ssrRunnerOptions = context.runner ?? {} + context.runner?.transport?.register(this) + + this.hot.on('vite:invalidate', async ({ path, message }) => { + invalidateModule(this, { + path, + message, + }) + }) + + const { optimizeDeps } = this.config.dev + if (context.depsOptimizer) { + this.depsOptimizer = context.depsOptimizer + } else if (isDepOptimizationDisabled(optimizeDeps)) { + this.depsOptimizer = undefined + } else { + // We only support auto-discovery for the client environment, for all other + // environments `noDiscovery` has no effect and a simpler explicit deps + // optimizer is used that only optimizes explicitly included dependencies + // so it doesn't need to reload the environment. Now that we have proper HMR + // and full reload for general environments, we can enable auto-discovery for + // them in the future + this.depsOptimizer = ( + optimizeDeps.noDiscovery || options.consumer !== 'client' + ? createExplicitDepsOptimizer + : createDepsOptimizer + )(this) + } + } + + async init(options?: { watcher?: FSWatcher }): Promise { + if (this._initiated) { + return + } + this._initiated = true + this._plugins = resolveEnvironmentPlugins(this) + this._pluginContainer = await createEnvironmentPluginContainer( + this, + this._plugins, + options?.watcher, + ) + } + + fetchModule( + id: string, + importer?: string, + options?: FetchFunctionOptions, + ): Promise { + return fetchModule(this, id, importer, { + ...this._ssrRunnerOptions, + ...options, + }) + } + + async reloadModule(module: EnvironmentModuleNode): Promise { + if (this.config.server.hmr !== false && module.file) { + updateModules(this, module.file, [module], Date.now()) + } + } + + transformRequest(url: string): Promise { + return transformRequest(this, url) + } + + async warmupRequest(url: string): Promise { + try { + await this.transformRequest(url) + } catch (e) { + if ( + e?.code === ERR_OUTDATED_OPTIMIZED_DEP || + e?.code === ERR_CLOSED_SERVER + ) { + // these are expected errors + return + } + // Unexpected error, log the issue but avoid an unhandled exception + this.logger.error(`Pre-transform error: ${e.message}`, { + error: e, + timestamp: true, + }) + } + } + + async close(): Promise { + this._closing = true + + this._crawlEndFinder?.cancel() + await Promise.allSettled([ + this.pluginContainer.close(), + this.depsOptimizer?.close(), + (async () => { + while (this._pendingRequests.size > 0) { + await Promise.allSettled( + [...this._pendingRequests.values()].map( + (pending) => pending.request, + ), + ) + } + })(), + ]) + } + + /** + * Calling `await environment.waitForRequestsIdle(id)` will wait until all static imports + * are processed after the first transformRequest call. If called from a load or transform + * plugin hook, the id needs to be passed as a parameter to avoid deadlocks. + * Calling this function after the first static imports section of the module graph has been + * processed will resolve immediately. + * @experimental + */ + waitForRequestsIdle(ignoredId?: string): Promise { + return this._crawlEndFinder.waitForRequestsIdle(ignoredId) + } + + /** + * @internal + */ + _registerRequestProcessing(id: string, done: () => Promise): void { + this._crawlEndFinder.registerRequestProcessing(id, done) + } + /** + * @internal + * TODO: use waitForRequestsIdle in the optimizer instead of this function + */ + _onCrawlEnd(cb: () => void): void { + this._onCrawlEndCallbacks.push(cb) + } +} + +function invalidateModule( + environment: DevEnvironment, + m: { + path: string + message?: string + }, +) { + const mod = environment.moduleGraph.urlToModuleMap.get(m.path) + if ( + mod && + mod.isSelfAccepting && + mod.lastHMRTimestamp > 0 && + !mod.lastHMRInvalidationReceived + ) { + mod.lastHMRInvalidationReceived = true + environment.logger.info( + colors.yellow(`hmr invalidate `) + + colors.dim(m.path) + + (m.message ? ` ${m.message}` : ''), + { timestamp: true }, + ) + const file = getShortName(mod.file!, environment.config.root) + updateModules( + environment, + file, + [...mod.importers], + mod.lastHMRTimestamp, + true, + ) + } +} + +const callCrawlEndIfIdleAfterMs = 50 + +interface CrawlEndFinder { + registerRequestProcessing: (id: string, done: () => Promise) => void + waitForRequestsIdle: (ignoredId?: string) => Promise + cancel: () => void +} + +function setupOnCrawlEnd(onCrawlEnd: () => void): CrawlEndFinder { + const registeredIds = new Set() + const seenIds = new Set() + const onCrawlEndPromiseWithResolvers = promiseWithResolvers() + + let timeoutHandle: NodeJS.Timeout | undefined + + let cancelled = false + function cancel() { + cancelled = true + } + + let crawlEndCalled = false + function callOnCrawlEnd() { + if (!cancelled && !crawlEndCalled) { + crawlEndCalled = true + onCrawlEnd() + } + onCrawlEndPromiseWithResolvers.resolve() + } + + function registerRequestProcessing( + id: string, + done: () => Promise, + ): void { + if (!seenIds.has(id)) { + seenIds.add(id) + registeredIds.add(id) + done() + .catch(() => {}) + .finally(() => markIdAsDone(id)) + } + } + + function waitForRequestsIdle(ignoredId?: string): Promise { + if (ignoredId) { + seenIds.add(ignoredId) + markIdAsDone(ignoredId) + } else { + checkIfCrawlEndAfterTimeout() + } + return onCrawlEndPromiseWithResolvers.promise + } + + function markIdAsDone(id: string): void { + registeredIds.delete(id) + checkIfCrawlEndAfterTimeout() + } + + function checkIfCrawlEndAfterTimeout() { + if (cancelled || registeredIds.size > 0) return + + if (timeoutHandle) clearTimeout(timeoutHandle) + timeoutHandle = setTimeout( + callOnCrawlEndWhenIdle, + callCrawlEndIfIdleAfterMs, + ) + } + async function callOnCrawlEndWhenIdle() { + if (cancelled || registeredIds.size > 0) return + callOnCrawlEnd() + } + + return { + registerRequestProcessing, + waitForRequestsIdle, + cancel, + } +} diff --git a/packages/vite/src/node/server/environmentTransport.ts b/packages/vite/src/node/server/environmentTransport.ts new file mode 100644 index 00000000000000..4340c144adc615 --- /dev/null +++ b/packages/vite/src/node/server/environmentTransport.ts @@ -0,0 +1,38 @@ +import type { DevEnvironment } from './environment' + +export class RemoteEnvironmentTransport { + constructor( + private readonly options: { + send: (data: any) => void + onMessage: (handler: (data: any) => void) => void + }, + ) {} + + register(environment: DevEnvironment): void { + this.options.onMessage(async (data) => { + if (typeof data !== 'object' || !data || !data.__v) return + + const method = data.m as 'fetchModule' + const parameters = data.a as [string, string] + + try { + const result = await environment[method](...parameters) + this.options.send({ + __v: true, + r: result, + i: data.i, + }) + } catch (error) { + this.options.send({ + __v: true, + e: { + name: error.name, + message: error.message, + stack: error.stack, + }, + i: data.i, + }) + } + }) + } +} diff --git a/packages/vite/src/node/server/environments/nodeEnvironment.ts b/packages/vite/src/node/server/environments/nodeEnvironment.ts new file mode 100644 index 00000000000000..14c295bcdcd1a5 --- /dev/null +++ b/packages/vite/src/node/server/environments/nodeEnvironment.ts @@ -0,0 +1,30 @@ +import type { ResolvedConfig } from '../../config' +import type { DevEnvironmentContext } from '../environment' +import { DevEnvironment } from '../environment' +import { asyncFunctionDeclarationPaddingLineCount } from '../../../shared/utils' + +export function createNodeDevEnvironment( + name: string, + config: ResolvedConfig, + context: DevEnvironmentContext, +): DevEnvironment { + if (context.hot == null) { + throw new Error( + '`hot` is a required option. Either explicitly opt out of HMR by setting `hot: false` or provide a hot channel.', + ) + } + + return new DevEnvironment(name, config, { + ...context, + runner: { + processSourceMap(map) { + // this assumes that "new AsyncFunction" is used to create the module + return Object.assign({}, map, { + mappings: + ';'.repeat(asyncFunctionDeclarationPaddingLineCount) + map.mappings, + }) + }, + ...context.runner, + }, + }) +} diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 0c659a1d507e20..d0d2dfe1b43736 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -2,17 +2,26 @@ import fsp from 'node:fs/promises' import path from 'node:path' import { EventEmitter } from 'node:events' import colors from 'picocolors' -import type { CustomPayload, HMRPayload, Update } from 'types/hmrPayload' +import type { CustomPayload, HotPayload, Update } from 'types/hmrPayload' import type { RollupError } from 'rollup' import { CLIENT_DIR } from '../constants' import { createDebugger, normalizePath } from '../utils' import type { InferCustomEventPayload, ViteDevServer } from '..' +import { getHookHandler } from '../plugins' import { isCSSRequest } from '../plugins/css' -import { getAffectedGlobModules } from '../plugins/importMetaGlob' import { isExplicitImportRequired } from '../plugins/importAnalysis' import { getEnvFilesForMode } from '../env' +import type { Environment } from '../environment' import { withTrailingSlash, wrapId } from '../../shared/utils' -import type { ModuleNode } from './moduleGraph' +import type { Plugin } from '../plugin' +import { + ignoreDeprecationWarnings, + warnFutureDeprecation, +} from '../deprecations' +import type { EnvironmentModuleNode } from './moduleGraph' +import type { ModuleNode } from './mixedModuleGraph' +import type { DevEnvironment } from './environment' +import { prepareError } from './middlewares/error' import type { HttpServer } from '.' import { restartServerWithUrls } from '.' @@ -31,8 +40,20 @@ export interface HmrOptions { timeout?: number overlay?: boolean server?: HttpServer - /** @internal */ - channels?: HMRChannel[] +} + +export interface HotUpdateOptions { + type: 'create' | 'update' | 'delete' + file: string + timestamp: number + modules: Array + read: () => string | Promise + server: ViteDevServer + + /** + * @deprecated use this.environment in the hotUpdate hook instead + **/ + environment: DevEnvironment } export interface HmrContext { @@ -44,31 +65,29 @@ export interface HmrContext { } interface PropagationBoundary { - boundary: ModuleNode - acceptedVia: ModuleNode + boundary: EnvironmentModuleNode + acceptedVia: EnvironmentModuleNode isWithinCircularImport: boolean } -export interface HMRBroadcasterClient { +export interface HotChannelClient { /** * Send event to the client */ - send(payload: HMRPayload): void + send(payload: HotPayload): void /** * Send custom event */ send(event: string, payload?: CustomPayload['data']): void } +/** @deprecated use `HotChannelClient` instead */ +export type HMRBroadcasterClient = HotChannelClient -export interface HMRChannel { - /** - * Unique channel name - */ - name: string +export interface HotChannel { /** * Broadcast events to all clients */ - send(payload: HMRPayload): void + send(payload: HotPayload): void /** * Send custom event */ @@ -80,7 +99,7 @@ export interface HMRChannel { event: T, listener: ( data: InferCustomEventPayload, - client: HMRBroadcasterClient, + client: HotChannelClient, ...args: any[] ) => void, ): void @@ -98,18 +117,8 @@ export interface HMRChannel { */ close(): void } - -export interface HMRBroadcaster extends Omit { - /** - * All registered channels. Always has websocket channel. - */ - readonly channels: HMRChannel[] - /** - * Add a new third-party channel. - */ - addChannel(connection: HMRChannel): HMRBroadcaster - close(): Promise -} +/** @deprecated use `HotChannel` instead */ +export type HMRChannel = HotChannel export function getShortName(file: string, root: string): string { return file.startsWith(withTrailingSlash(root)) @@ -117,12 +126,54 @@ export function getShortName(file: string, root: string): string { : file } +export function getSortedPluginsByHotUpdateHook( + plugins: readonly Plugin[], +): Plugin[] { + const sortedPlugins: Plugin[] = [] + // Use indexes to track and insert the ordered plugins directly in the + // resulting array to avoid creating 3 extra temporary arrays per hook + let pre = 0, + normal = 0, + post = 0 + for (const plugin of plugins) { + const hook = plugin['hotUpdate'] ?? plugin['handleHotUpdate'] + if (hook) { + if (typeof hook === 'object') { + if (hook.order === 'pre') { + sortedPlugins.splice(pre++, 0, plugin) + continue + } + if (hook.order === 'post') { + sortedPlugins.splice(pre + normal + post++, 0, plugin) + continue + } + } + sortedPlugins.splice(pre + normal++, 0, plugin) + } + } + + return sortedPlugins +} + +const sortedHotUpdatePluginsCache = new WeakMap() +function getSortedHotUpdatePlugins(environment: Environment): Plugin[] { + let sortedPlugins = sortedHotUpdatePluginsCache.get(environment) as Plugin[] + if (!sortedPlugins) { + sortedPlugins = getSortedPluginsByHotUpdateHook(environment.plugins) + sortedHotUpdatePluginsCache.set(environment, sortedPlugins) + } + return sortedPlugins +} + export async function handleHMRUpdate( type: 'create' | 'delete' | 'update', file: string, server: ViteDevServer, ): Promise { - const { hot, config, moduleGraph } = server + const { config } = server + const mixedModuleGraph = ignoreDeprecationWarnings(() => server.moduleGraph) + + const environments = Object.values(server.environments) const shortFile = getShortName(file, config.root) const isConfig = file === config.configFile @@ -156,80 +207,230 @@ export async function handleHMRUpdate( // (dev only) the client itself cannot be hot updated. if (file.startsWith(withTrailingSlash(normalizedClientDir))) { - hot.send({ - type: 'full-reload', - path: '*', - triggeredBy: path.resolve(config.root, file), - }) + environments.forEach(({ hot }) => + hot.send({ + type: 'full-reload', + path: '*', + triggeredBy: path.resolve(config.root, file), + }), + ) return } - const mods = new Set(moduleGraph.getModulesByFile(file)) - if (type === 'create') { - for (const mod of moduleGraph._hasResolveFailedErrorModules) { - mods.add(mod) - } - } - if (type === 'create' || type === 'delete') { - for (const mod of getAffectedGlobModules(file, server)) { - mods.add(mod) - } - } - - // check if any plugin wants to perform custom HMR handling const timestamp = Date.now() - const hmrContext: HmrContext = { + const contextMeta = { + type, file, timestamp, - modules: [...mods], read: () => readModifiedFile(file), server, } + const hotMap = new Map< + Environment, + { options: HotUpdateOptions; error?: Error } + >() + + for (const environment of Object.values(server.environments)) { + const mods = new Set(environment.moduleGraph.getModulesByFile(file)) + if (type === 'create') { + for (const mod of environment.moduleGraph._hasResolveFailedErrorModules) { + mods.add(mod) + } + } + const options = { + ...contextMeta, + modules: [...mods], + // later on hotUpdate will be called for each runtime with a new HotUpdateContext + environment, + } + hotMap.set(environment, { options }) + } - if (type === 'update') { - for (const hook of config.getSortedPluginHooks('handleHotUpdate')) { - const filteredModules = await hook(hmrContext) - if (filteredModules) { - hmrContext.modules = filteredModules + const mixedMods = new Set(mixedModuleGraph.getModulesByFile(file)) + + const mixedHmrContext: HmrContext = { + ...contextMeta, + modules: [...mixedMods], + } + + const clientEnvironment = server.environments.client + const ssrEnvironment = server.environments.ssr + const clientContext = { environment: clientEnvironment } + const clientHotUpdateOptions = hotMap.get(clientEnvironment)!.options + const ssrHotUpdateOptions = hotMap.get(ssrEnvironment)?.options + try { + for (const plugin of getSortedHotUpdatePlugins( + server.environments.client, + )) { + if (plugin.hotUpdate) { + const filteredModules = await getHookHandler(plugin.hotUpdate).call( + clientContext, + clientHotUpdateOptions, + ) + if (filteredModules) { + clientHotUpdateOptions.modules = filteredModules + // Invalidate the hmrContext to force compat modules to be updated + mixedHmrContext.modules = mixedHmrContext.modules.filter( + (mixedMod) => + filteredModules.some((mod) => mixedMod.id === mod.id) || + ssrHotUpdateOptions?.modules.some( + (ssrMod) => ssrMod.id === mixedMod.id, + ), + ) + mixedHmrContext.modules.push( + ...filteredModules + .filter( + (mod) => + !mixedHmrContext.modules.some( + (mixedMod) => mixedMod.id === mod.id, + ), + ) + .map((mod) => + mixedModuleGraph.getBackwardCompatibleModuleNode(mod), + ), + ) + } + } else if (type === 'update') { + warnFutureDeprecation( + config, + 'removePluginHookHandleHotUpdate', + `Used in plugin "${plugin.name}".`, + false, + ) + // later on, we'll need: if (runtime === 'client') + // Backward compatibility with mixed client and ssr moduleGraph + const filteredModules = await getHookHandler(plugin.handleHotUpdate!)( + mixedHmrContext, + ) + if (filteredModules) { + mixedHmrContext.modules = filteredModules + clientHotUpdateOptions.modules = + clientHotUpdateOptions.modules.filter((mod) => + filteredModules.some((mixedMod) => mod.id === mixedMod.id), + ) + clientHotUpdateOptions.modules.push( + ...(filteredModules + .filter( + (mixedMod) => + !clientHotUpdateOptions.modules.some( + (mod) => mod.id === mixedMod.id, + ), + ) + .map((mixedMod) => mixedMod._clientModule) + .filter(Boolean) as EnvironmentModuleNode[]), + ) + if (ssrHotUpdateOptions) { + ssrHotUpdateOptions.modules = ssrHotUpdateOptions.modules.filter( + (mod) => + filteredModules.some((mixedMod) => mod.id === mixedMod.id), + ) + ssrHotUpdateOptions.modules.push( + ...(filteredModules + .filter( + (mixedMod) => + !ssrHotUpdateOptions.modules.some( + (mod) => mod.id === mixedMod.id, + ), + ) + .map((mixedMod) => mixedMod._ssrModule) + .filter(Boolean) as EnvironmentModuleNode[]), + ) + } + } } } + } catch (error) { + hotMap.get(server.environments.client)!.error = error } - if (!hmrContext.modules.length) { - // html file cannot be hot updated - if (file.endsWith('.html')) { - config.logger.info(colors.green(`page reload `) + colors.dim(shortFile), { - clear: true, - timestamp: true, - }) - hot.send({ - type: 'full-reload', - path: config.server.middlewareMode - ? '*' - : '/' + normalizePath(path.relative(config.root, file)), + for (const environment of Object.values(server.environments)) { + if (environment.name === 'client') continue + const hot = hotMap.get(environment)! + const environmentThis = { environment } + try { + for (const plugin of getSortedHotUpdatePlugins(environment)) { + if (plugin.hotUpdate) { + const filteredModules = await getHookHandler(plugin.hotUpdate).call( + environmentThis, + hot.options, + ) + if (filteredModules) { + hot.options.modules = filteredModules + } + } + } + } catch (error) { + hot.error = error + } + } + + async function hmr(environment: DevEnvironment) { + try { + const { options, error } = hotMap.get(environment)! + if (error) { + throw error + } + if (!options.modules.length) { + // html file cannot be hot updated + if (file.endsWith('.html')) { + environment.logger.info( + colors.green(`page reload `) + colors.dim(shortFile), + { + clear: true, + timestamp: true, + }, + ) + environment.hot.send({ + type: 'full-reload', + path: config.server.middlewareMode + ? '*' + : '/' + normalizePath(path.relative(config.root, file)), + }) + } else { + // loaded but not in the module graph, probably not js + debugHmr?.( + `(${environment.name}) [no modules matched] ${colors.dim(shortFile)}`, + ) + } + return + } + + updateModules(environment, shortFile, options.modules, timestamp) + } catch (err) { + environment.hot.send({ + type: 'error', + err: prepareError(err), }) - } else { - // loaded but not in the module graph, probably not js - debugHmr?.(`[no modules matched] ${colors.dim(shortFile)}`) } - return } - updateModules(shortFile, hmrContext.modules, timestamp, server) + const hotUpdateEnvironments = + server.config.server.hotUpdateEnvironments ?? + ((server, hmr) => { + // Run HMR in parallel for all environments by default + return Promise.all( + Object.values(server.environments).map((environment) => + hmr(environment), + ), + ) + }) + + await hotUpdateEnvironments(server, hmr) } type HasDeadEnd = boolean export function updateModules( + environment: DevEnvironment, file: string, - modules: ModuleNode[], + modules: EnvironmentModuleNode[], timestamp: number, - { config, hot, moduleGraph }: ViteDevServer, afterInvalidation?: boolean, ): void { + const { hot } = environment const updates: Update[] = [] - const invalidatedModules = new Set() - const traversedModules = new Set() + const invalidatedModules = new Set() + const traversedModules = new Set() // Modules could be empty if a root module is invalidated via import.meta.hot.invalidate() let needFullReload: HasDeadEnd = modules.length === 0 @@ -237,7 +438,12 @@ export function updateModules( const boundaries: PropagationBoundary[] = [] const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries) - moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true) + environment.moduleGraph.invalidateModule( + mod, + invalidatedModules, + timestamp, + true, + ) if (needFullReload) { continue @@ -260,9 +466,6 @@ export function updateModules( ? isExplicitImportRequired(acceptedVia.url) : false, isWithinCircularImport, - // browser modules are invalidated by changing ?t= query, - // but in ssr we control the module system, so we can directly remove them form cache - ssrInvalidates: getSSRInvalidatedImporters(acceptedVia), }), ), ) @@ -273,13 +476,13 @@ export function updateModules( typeof needFullReload === 'string' ? colors.dim(` (${needFullReload})`) : '' - config.logger.info( + environment.logger.info( colors.green(`page reload `) + colors.dim(file) + reason, { clear: !afterInvalidation, timestamp: true }, ) hot.send({ type: 'full-reload', - triggeredBy: path.resolve(config.root, file), + triggeredBy: path.resolve(environment.config.root, file), }) return } @@ -289,7 +492,7 @@ export function updateModules( return } - config.logger.info( + environment.logger.info( colors.green(`hmr update `) + colors.dim([...new Set(updates.map((u) => u.path))].join(', ')), { clear: !afterInvalidation, timestamp: true }, @@ -300,32 +503,6 @@ export function updateModules( }) } -function populateSSRImporters( - module: ModuleNode, - timestamp: number, - seen: Set = new Set(), -) { - module.ssrImportedModules.forEach((importer) => { - if (seen.has(importer)) { - return - } - if ( - importer.lastHMRTimestamp === timestamp || - importer.lastInvalidationTimestamp === timestamp - ) { - seen.add(importer) - populateSSRImporters(importer, timestamp, seen) - } - }) - return seen -} - -function getSSRInvalidatedImporters(module: ModuleNode) { - return [...populateSSRImporters(module, module.lastHMRTimestamp)].map( - (m) => m.file!, - ) -} - function areAllImportsAccepted( importedBindings: Set, acceptedExports: Set, @@ -339,10 +516,10 @@ function areAllImportsAccepted( } function propagateUpdate( - node: ModuleNode, - traversedModules: Set, + node: EnvironmentModuleNode, + traversedModules: Set, boundaries: PropagationBoundary[], - currentChain: ModuleNode[] = [node], + currentChain: EnvironmentModuleNode[] = [node], ): HasDeadEnd { if (traversedModules.has(node)) { return false @@ -454,10 +631,10 @@ function propagateUpdate( * @param traversedModules The set of modules that have traversed */ function isNodeWithinCircularImports( - node: ModuleNode, - nodeChain: ModuleNode[], - currentChain: ModuleNode[] = [node], - traversedModules = new Set(), + node: EnvironmentModuleNode, + nodeChain: EnvironmentModuleNode[], + currentChain: EnvironmentModuleNode[] = [node], + traversedModules = new Set(), ): boolean { // To help visualize how each parameters work, imagine this import graph: // @@ -527,8 +704,8 @@ function isNodeWithinCircularImports( } export function handlePrunedModules( - mods: Set, - { hot }: ViteDevServer, + mods: Set, + { hot }: DevEnvironment, ): void { // update the disposed modules' hmr timestamp // since if it's re-imported, it should re-apply side effects @@ -715,70 +892,22 @@ async function readModifiedFile(file: string): Promise { } } -export function createHMRBroadcaster(): HMRBroadcaster { - const channels: HMRChannel[] = [] - const readyChannels = new WeakSet() - const broadcaster: HMRBroadcaster = { - get channels() { - return [...channels] - }, - addChannel(channel) { - if (channels.some((c) => c.name === channel.name)) { - throw new Error(`HMR channel "${channel.name}" is already defined.`) - } - channels.push(channel) - return broadcaster - }, - on(event: string, listener: (...args: any[]) => any) { - // emit connection event only when all channels are ready - if (event === 'connection') { - // make a copy so we don't wait for channels that might be added after this is triggered - const channels = this.channels - channels.forEach((channel) => - channel.on('connection', () => { - readyChannels.add(channel) - if (channels.every((c) => readyChannels.has(c))) { - listener() - } - }), - ) - return - } - channels.forEach((channel) => channel.on(event, listener)) - return - }, - off(event, listener) { - channels.forEach((channel) => channel.off(event, listener)) - return - }, - send(...args: any[]) { - channels.forEach((channel) => channel.send(...(args as [any]))) - }, - listen() { - channels.forEach((channel) => channel.listen()) - }, - close() { - return Promise.all(channels.map((channel) => channel.close())) - }, - } - return broadcaster -} - -export interface ServerHMRChannel extends HMRChannel { +export interface ServerHotChannel extends HotChannel { api: { innerEmitter: EventEmitter outsideEmitter: EventEmitter } } +/** @deprecated use `ServerHotChannel` instead */ +export type ServerHMRChannel = ServerHotChannel -export function createServerHMRChannel(): ServerHMRChannel { +export function createServerHotChannel(): ServerHotChannel { const innerEmitter = new EventEmitter() const outsideEmitter = new EventEmitter() return { - name: 'ssr', send(...args: any[]) { - let payload: HMRPayload + let payload: HotPayload if (typeof args[0] === 'string') { payload = { type: 'custom', @@ -795,7 +924,7 @@ export function createServerHMRChannel(): ServerHMRChannel { }, on: ((event: string, listener: () => unknown) => { innerEmitter.on(event, listener) - }) as ServerHMRChannel['on'], + }) as ServerHotChannel['on'], close() { innerEmitter.removeAllListeners() outsideEmitter.removeAllListeners() @@ -809,3 +938,49 @@ export function createServerHMRChannel(): ServerHMRChannel { }, } } + +export function createNoopHotChannel(): HotChannel { + function noop() { + // noop + } + + return { + send: noop, + on: noop, + off: noop, + listen: noop, + close: noop, + } +} + +/** @deprecated use `environment.hot` instead */ +export interface HotBroadcaster extends HotChannel { + readonly channels: HotChannel[] + /** + * A noop. + * @deprecated + */ + addChannel(channel: HotChannel): HotBroadcaster + close(): Promise +} +/** @deprecated use `environment.hot` instead */ +export type HMRBroadcaster = HotBroadcaster + +export function createDeprecatedHotBroadcaster(ws: HotChannel): HotBroadcaster { + const broadcaster: HotBroadcaster = { + on: ws.on, + off: ws.off, + listen: ws.listen, + send: ws.send, + get channels() { + return [ws] + }, + addChannel() { + return broadcaster + }, + close() { + return Promise.all(broadcaster.channels.map((channel) => channel.close())) + }, + } + return broadcaster +} diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index fa75408fafbaff..26340de8c0197b 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -14,8 +14,7 @@ import type { FSWatcher, WatchOptions } from 'dep-types/chokidar' import type { Connect } from 'dep-types/connect' import launchEditorMiddleware from 'launch-editor-middleware' import type { SourceMap } from 'rollup' -import picomatch from 'picomatch' -import type { Matcher } from 'picomatch' +import type { ModuleRunner } from 'vite/module-runner' import type { CommonServerOptions } from '../http' import { httpServerStart, @@ -24,7 +23,7 @@ import { setClientErrorHandler, } from '../http' import type { InlineConfig, ResolvedConfig } from '../config' -import { isDepsOptimizerEnabled, resolveConfig } from '../config' +import { resolveConfig } from '../config' import { diffDnsOrderChange, isInNodeModules, @@ -32,7 +31,6 @@ import { isParentDirectory, mergeConfig, normalizePath, - promiseWithResolvers, resolveHostname, resolveServerUrls, setupSIGTERMListener, @@ -43,12 +41,12 @@ import { ssrLoadModule } from '../ssr/ssrModuleLoader' import { ssrFixStacktrace, ssrRewriteStacktrace } from '../ssr/ssrStacktrace' import { ssrTransform } from '../ssr/ssrTransform' import { ERR_OUTDATED_OPTIMIZED_DEP } from '../plugins/optimizedDeps' -import { getDepsOptimizer, initDepsOptimizer } from '../optimizer' import { bindCLIShortcuts } from '../shortcuts' import type { BindCLIShortcutsOptions } from '../shortcuts' import { CLIENT_DIR, DEFAULT_DEV_PORT } from '../constants' import type { Logger } from '../logger' import { printServerUrls } from '../logger' +import { warnFutureDeprecation } from '../deprecations' import { createNoopWatcher, getResolvedOutDirs, @@ -57,8 +55,6 @@ import { } from '../watch' import { initPublicFiles } from '../publicDir' import { getEnvFilesForMode } from '../env' -import type { FetchResult } from '../../runtime/types' -import { ssrFetchModule } from '../ssr/ssrFetchModule' import type { PluginContainer } from './pluginContainer' import { ERR_CLOSED_SERVER, createPluginContainer } from './pluginContainer' import type { WebSocketServer } from './ws' @@ -80,15 +76,13 @@ import { serveStaticMiddleware, } from './middlewares/static' import { timeMiddleware } from './middlewares/time' -import type { ModuleNode } from './moduleGraph' -import { ModuleGraph } from './moduleGraph' +import { ModuleGraph } from './mixedModuleGraph' +import type { ModuleNode } from './mixedModuleGraph' import { notFoundMiddleware } from './middlewares/notFound' -import { errorMiddleware, prepareError } from './middlewares/error' -import type { HMRBroadcaster, HmrOptions } from './hmr' +import { errorMiddleware } from './middlewares/error' +import type { HmrOptions, HotBroadcaster } from './hmr' import { - createHMRBroadcaster, - createServerHMRChannel, - getShortName, + createDeprecatedHotBroadcaster, handleHMRUpdate, updateModules, } from './hmr' @@ -97,6 +91,7 @@ import type { TransformOptions, TransformResult } from './transformRequest' import { transformRequest } from './transformRequest' import { searchForWorkspaceRoot } from './searchRoot' import { warmupFiles } from './warmup' +import type { DevEnvironment } from './environment' export interface ServerOptions extends CommonServerOptions { /** @@ -167,6 +162,21 @@ export interface ServerOptions extends CommonServerOptions { sourcemapIgnoreList?: | false | ((sourcePath: string, sourcemapPath: string) => boolean) + /** + * Backward compatibility. The buildStart and buildEnd hooks were called only once for all + * environments. This option enables per-environment buildStart and buildEnd hooks. + * @default false + * @experimental + */ + perEnvironmentBuildStartEnd?: boolean + /** + * Run HMR tasks, by default the HMR propagation is done in parallel for all environments + * @experimental + */ + hotUpdateEnvironments?: ( + server: ViteDevServer, + hmr: (environment: DevEnvironment) => Promise, + ) => Promise } export interface ResolvedServerOptions @@ -256,13 +266,16 @@ export interface ViteDevServer { * * Always sends a message to at least a WebSocket client. Any third party can * add a channel to the broadcaster to process messages - * @deprecated will be replaced with the environment api in v6. */ - hot: HMRBroadcaster + hot: HotBroadcaster /** * Rollup plugin container that can run plugin hooks on a given file */ pluginContainer: PluginContainer + /** + * Module execution environments attached to the Vite server. + */ + environments: Record<'client' | 'ssr' | (string & {}), DevEnvironment> /** * Module graph that tracks the import relationships, url to file mapping * and hmr state. @@ -311,11 +324,6 @@ export interface ViteDevServer { url: string, opts?: { fixStacktrace?: boolean }, ): Promise> - /** - * Fetch information about the module for Vite SSR runtime. - * @experimental - */ - ssrFetchModule(id: string, importer?: string): Promise /** * Returns a fixed version of the given stack */ @@ -351,7 +359,6 @@ export interface ViteDevServer { * @param forceOptimize - force the optimizer to re-bundle, same as --force cli flag */ restart(forceOptimize?: boolean): Promise - /** * Open browser */ @@ -361,23 +368,17 @@ export interface ViteDevServer { * are processed. If called from a load or transform plugin hook, the id needs to be * passed as a parameter to avoid deadlocks. Calling this function after the first * static imports section of the module graph has been processed will resolve immediately. - * @experimental */ waitForRequestsIdle: (ignoredId?: string) => Promise - /** - * @internal - */ - _registerRequestProcessing: (id: string, done: () => Promise) => void - /** - * @internal - */ - _onCrawlEnd(cb: () => void): void /** * @internal */ _setInternalServer(server: ViteDevServer): void /** + * Left for backward compatibility with VitePress, HMR may not work in some cases + * but the there will not be a hard error. * @internal + * @deprecated this map is not used anymore */ _importGlobMap: Map /** @@ -388,21 +389,6 @@ export interface ViteDevServer { * @internal */ _forceOptimizeOnRestart: boolean - /** - * @internal - */ - _pendingRequests: Map< - string, - { - request: Promise - timestamp: number - abort: () => void - } - > - /** - * @internal - */ - _fsDenyGlob: Matcher /** * @internal */ @@ -415,6 +401,10 @@ export interface ViteDevServer { * @internal */ _configServerPort?: number | undefined + /** + * @internal + */ + _ssrCompatModuleRunner?: ModuleRunner } export interface ResolvedServerUrls { @@ -466,12 +456,6 @@ export async function _createServer( : await resolveHttpServer(serverConfig, middlewares, httpsOptions) const ws = createWebSocketServer(httpServer, config, httpsOptions) - const hot = createHMRBroadcaster() - .addChannel(ws) - .addChannel(createServerHMRChannel()) - if (typeof config.server.hmr === 'object' && config.server.hmr.channels) { - config.server.hmr.channels.forEach((channel) => hot.addChannel(channel)) - } const publicFiles = await initPublicFilesPromise const { publicDir } = config @@ -497,38 +481,54 @@ export async function _createServer( ) as FSWatcher) : createNoopWatcher(resolvedWatchOptions) - const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) => - container.resolveId(url, undefined, { ssr }), - ) + const environments: Record = {} - const container = await createPluginContainer(config, moduleGraph, watcher) - const closeHttpServer = createServerCloseFn(httpServer) + for (const [name, environmentOptions] of Object.entries( + config.environments, + )) { + environments[name] = await environmentOptions.dev.createEnvironment( + name, + config, + { + ws, + }, + ) + } - const devHtmlTransformFn = createDevHtmlTransformFn(config) + for (const environment of Object.values(environments)) { + await environment.init({ watcher }) + } + + // Backward compatibility - const onCrawlEndCallbacks: (() => void)[] = [] - const crawlEndFinder = setupOnCrawlEnd(() => { - onCrawlEndCallbacks.forEach((cb) => cb()) + let moduleGraph = new ModuleGraph({ + client: () => environments.client.moduleGraph, + ssr: () => environments.ssr.moduleGraph, }) - function waitForRequestsIdle(ignoredId?: string): Promise { - return crawlEndFinder.waitForRequestsIdle(ignoredId) - } - function _registerRequestProcessing(id: string, done: () => Promise) { - crawlEndFinder.registerRequestProcessing(id, done) - } - function _onCrawlEnd(cb: () => void) { - onCrawlEndCallbacks.push(cb) - } + const pluginContainer = createPluginContainer(environments) + + const closeHttpServer = createServerCloseFn(httpServer) + + const devHtmlTransformFn = createDevHtmlTransformFn(config) let server: ViteDevServer = { config, middlewares, httpServer, watcher, - pluginContainer: container, ws, - hot, - moduleGraph, + hot: createDeprecatedHotBroadcaster(ws), + + environments, + pluginContainer, + get moduleGraph() { + warnFutureDeprecation(config, 'removeServerModuleGraph') + return moduleGraph + }, + set moduleGraph(graph) { + moduleGraph = graph + }, + resolvedUrls: null, // will be set on listen ssrTransform( code: string, @@ -538,12 +538,24 @@ export async function _createServer( ) { return ssrTransform(code, inMap, url, originalCode, server.config) }, + // environment.transformRequest and .warmupRequest don't take an options param for now, + // so the logic and error handling needs to be duplicated here. + // The only param in options that could be important is `html`, but we may remove it as + // that is part of the internal control flow for the vite dev server to be able to bail + // out and do the html fallback transformRequest(url, options) { - return transformRequest(url, server, options) + warnFutureDeprecation( + config, + 'removeServerTransformRequest', + 'server.transformRequest() is deprecated. Use environment.transformRequest() instead.', + ) + const environment = server.environments[options?.ssr ? 'ssr' : 'client'] + return transformRequest(environment, url, options) }, async warmupRequest(url, options) { try { - await transformRequest(url, server, options) + const environment = server.environments[options?.ssr ? 'ssr' : 'client'] + await transformRequest(environment, url, options) } catch (e) { if ( e?.code === ERR_OUTDATED_OPTIMIZED_DEP || @@ -563,20 +575,25 @@ export async function _createServer( return devHtmlTransformFn(server, url, html, originalUrl) }, async ssrLoadModule(url, opts?: { fixStacktrace?: boolean }) { + warnFutureDeprecation(config, 'removeSsrLoadModule') return ssrLoadModule(url, server, opts?.fixStacktrace) }, - async ssrFetchModule(url: string, importer?: string) { - return ssrFetchModule(server, url, importer) - }, ssrFixStacktrace(e) { - ssrFixStacktrace(e, moduleGraph) + ssrFixStacktrace(e, server.environments.ssr.moduleGraph) }, ssrRewriteStacktrace(stack: string) { - return ssrRewriteStacktrace(stack, moduleGraph) + return ssrRewriteStacktrace(stack, server.environments.ssr.moduleGraph) }, async reloadModule(module) { if (serverConfig.hmr !== false && module.file) { - updateModules(module.file, [module], Date.now(), server) + // TODO: Should we also update the node moduleGraph for backward compatibility? + const environmentModule = (module._clientModule ?? module._ssrModule)! + updateModules( + environments[environmentModule.environment]!, + module.file, + [environmentModule], + Date.now(), + ) } }, async listen(port?: number, isRestart?: boolean) { @@ -640,28 +657,17 @@ export async function _createServer( if (!middlewareMode) { teardownSIGTERMListener(closeServerAndExit) } + await Promise.allSettled([ watcher.close(), - hot.close(), - container.close(), - crawlEndFinder?.cancel(), - getDepsOptimizer(server.config)?.close(), - getDepsOptimizer(server.config, true)?.close(), + ws.close(), + Promise.allSettled( + Object.values(server.environments).map((environment) => + environment.close(), + ), + ), closeHttpServer(), ]) - // Await pending requests. We throw early in transformRequest - // and in hooks if the server is closing for non-ssr requests, - // so the import analysis plugin stops pre-transforming static - // imports and this block is resolved sooner. - // During SSR, we let pending requests finish to avoid exposing - // the server closed error to the users. - while (server._pendingRequests.size > 0) { - await Promise.allSettled( - [...server._pendingRequests.values()].map( - (pending) => pending.request, - ), - ) - } server.resolvedUrls = null }, printUrls() { @@ -693,32 +699,18 @@ export async function _createServer( return server._restartPromise }, - waitForRequestsIdle, - _registerRequestProcessing, - _onCrawlEnd, + waitForRequestsIdle(ignoredId?: string): Promise { + return environments.client.waitForRequestsIdle(ignoredId) + }, _setInternalServer(_server: ViteDevServer) { // Rebind internal the server variable so functions reference the user // server instance after a restart server = _server }, - _restartPromise: null, _importGlobMap: new Map(), + _restartPromise: null, _forceOptimizeOnRestart: false, - _pendingRequests: new Map(), - _fsDenyGlob: picomatch( - // matchBase: true does not work as it's documented - // https://github.com/micromatch/picomatch/issues/89 - // convert patterns without `/` on our side for now - config.server.fs.deny.map((pattern) => - pattern.includes('/') ? pattern : `**/${pattern}`, - ), - { - matchBase: false, - nocase: true, - dot: true, - }, - ), _shortcutsOptions: undefined, } @@ -750,45 +742,51 @@ export async function _createServer( file: string, ) => { if (serverConfig.hmr !== false) { - try { - await handleHMRUpdate(type, file, server) - } catch (err) { - hot.send({ - type: 'error', - err: prepareError(err), - }) - } + await handleHMRUpdate(type, file, server) } } const onFileAddUnlink = async (file: string, isUnlink: boolean) => { file = normalizePath(file) - await container.watchChange(file, { event: isUnlink ? 'delete' : 'create' }) + + await pluginContainer.watchChange(file, { + event: isUnlink ? 'delete' : 'create', + }) if (publicDir && publicFiles) { if (file.startsWith(publicDir)) { const path = file.slice(publicDir.length) publicFiles[isUnlink ? 'delete' : 'add'](path) if (!isUnlink) { - const moduleWithSamePath = await moduleGraph.getModuleByUrl(path) + const clientModuleGraph = server.environments.client.moduleGraph + const moduleWithSamePath = + await clientModuleGraph.getModuleByUrl(path) const etag = moduleWithSamePath?.transformResult?.etag if (etag) { // The public file should win on the next request over a module with the // same path. Prevent the transform etag fast path from serving the module - moduleGraph.etagToModuleMap.delete(etag) + clientModuleGraph.etagToModuleMap.delete(etag) } } } } - if (isUnlink) moduleGraph.onFileDelete(file) + if (isUnlink) { + // invalidate module graph cache on file change + for (const environment of Object.values(server.environments)) { + environment.moduleGraph.onFileDelete(file) + } + } await onHMRUpdate(isUnlink ? 'delete' : 'create', file) } watcher.on('change', async (file) => { file = normalizePath(file) - await container.watchChange(file, { event: 'update' }) + + await pluginContainer.watchChange(file, { event: 'update' }) // invalidate module graph cache on file change - moduleGraph.onFileChange(file) + for (const environment of Object.values(server.environments)) { + environment.moduleGraph.onFileChange(file) + } await onHMRUpdate('update', file) }) @@ -801,32 +799,6 @@ export async function _createServer( onFileAddUnlink(file, true) }) - hot.on('vite:invalidate', async ({ path, message }) => { - const mod = moduleGraph.urlToModuleMap.get(path) - if ( - mod && - mod.isSelfAccepting && - mod.lastHMRTimestamp > 0 && - !mod.lastHMRInvalidationReceived - ) { - mod.lastHMRInvalidationReceived = true - config.logger.info( - colors.yellow(`hmr invalidate `) + - colors.dim(path) + - (message ? ` ${message}` : ''), - { timestamp: true }, - ) - const file = getShortName(mod.file!, config.root) - updateModules( - file, - [...mod.importers], - mod.lastHMRTimestamp, - server, - true, - ) - } - }) - if (!middlewareMode && httpServer) { httpServer.once('listening', () => { // update actual port since this may be different from initial value @@ -932,11 +904,18 @@ export async function _createServer( if (initingServer) return initingServer initingServer = (async function () { - await container.buildStart({}) - // start deps optimizer after all container plugins are ready - if (isDepsOptimizerEnabled(config, false)) { - await initDepsOptimizer(config, server) - } + // For backward compatibility, we call buildStart for the client + // environment when initing the server. For other environments + // buildStart will be called when the first request is transformed + await environments.client.pluginContainer.buildStart() + + await Promise.all( + Object.values(server.environments).map((environment) => + environment.depsOptimizer?.init(), + ), + ) + + // TODO: move warmup call inside environment init() warmupFiles(server) initingServer = undefined serverInited = true @@ -950,7 +929,7 @@ export async function _createServer( httpServer.listen = (async (port: number, ...args: any[]) => { try { // ensure ws server started - hot.listen() + Object.values(environments).forEach((e) => e.hot.listen()) await initServer() } catch (e) { httpServer.emit('error', e) @@ -960,7 +939,7 @@ export async function _createServer( }) as any } else { if (options.hotListen) { - hot.listen() + Object.values(environments).forEach((e) => e.hot.listen()) } await initServer() } @@ -1047,6 +1026,7 @@ export function resolveServerOptions( ): ResolvedServerOptions { const server: ResolvedServerOptions = { preTransformRequests: true, + perEnvironmentBuildStartEnd: false, ...(raw as Omit), sourcemapIgnoreList: raw?.sourcemapIgnoreList === false @@ -1129,7 +1109,7 @@ async function restartServer(server: ViteDevServer) { // server instance and set the user instance to be used in the new server. // This allows us to keep the same server instance for the user. { - let newServer = null + let newServer: ViteDevServer | null = null try { // delay ws server listen newServer = await _createServer(inlineConfig, { hotListen: false }) @@ -1165,7 +1145,7 @@ async function restartServer(server: ViteDevServer) { if (!middlewareMode) { await server.listen(port, true) } else { - server.hot.listen() + server.ws.listen() } logger.info('server restarted.', { timestamp: true }) @@ -1204,81 +1184,3 @@ export async function restartServerWithUrls( server.printUrls() } } - -const callCrawlEndIfIdleAfterMs = 50 - -interface CrawlEndFinder { - registerRequestProcessing: (id: string, done: () => Promise) => void - waitForRequestsIdle: (ignoredId?: string) => Promise - cancel: () => void -} - -function setupOnCrawlEnd(onCrawlEnd: () => void): CrawlEndFinder { - const registeredIds = new Set() - const seenIds = new Set() - const onCrawlEndPromiseWithResolvers = promiseWithResolvers() - - let timeoutHandle: NodeJS.Timeout | undefined - - let cancelled = false - function cancel() { - cancelled = true - } - - let crawlEndCalled = false - function callOnCrawlEnd() { - if (!cancelled && !crawlEndCalled) { - crawlEndCalled = true - onCrawlEnd() - } - onCrawlEndPromiseWithResolvers.resolve() - } - - function registerRequestProcessing( - id: string, - done: () => Promise, - ): void { - if (!seenIds.has(id)) { - seenIds.add(id) - registeredIds.add(id) - done() - .catch(() => {}) - .finally(() => markIdAsDone(id)) - } - } - - function waitForRequestsIdle(ignoredId?: string): Promise { - if (ignoredId) { - seenIds.add(ignoredId) - markIdAsDone(ignoredId) - } else { - checkIfCrawlEndAfterTimeout() - } - return onCrawlEndPromiseWithResolvers.promise - } - - function markIdAsDone(id: string): void { - registeredIds.delete(id) - checkIfCrawlEndAfterTimeout() - } - - function checkIfCrawlEndAfterTimeout() { - if (cancelled || registeredIds.size > 0) return - - if (timeoutHandle) clearTimeout(timeoutHandle) - timeoutHandle = setTimeout( - callOnCrawlEndWhenIdle, - callCrawlEndIfIdleAfterMs, - ) - } - async function callOnCrawlEndWhenIdle() { - if (cancelled || registeredIds.size > 0) return - callOnCrawlEnd() - } - - return { - registerRequestProcessing, - waitForRequestsIdle, - cancel, - } -} diff --git a/packages/vite/src/node/server/middlewares/error.ts b/packages/vite/src/node/server/middlewares/error.ts index 9b99e1427524d1..2ef608ce8b8670 100644 --- a/packages/vite/src/node/server/middlewares/error.ts +++ b/packages/vite/src/node/server/middlewares/error.ts @@ -53,7 +53,7 @@ export function logError(server: ViteDevServer, err: RollupError): void { error: err, }) - server.hot.send({ + server.environments.client.hot.send({ type: 'error', err: prepareError(err), }) diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index f47811371c7f33..2da4efee960a58 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -130,8 +130,8 @@ const processNodeUrl = ( ): string => { // prefix with base (dev only, base is never relative) const replacer = (url: string) => { - if (server?.moduleGraph) { - const mod = server.moduleGraph.urlToModuleMap.get(url) + if (server) { + const mod = server.environments.client.moduleGraph.urlToModuleMap.get(url) if (mod && mod.lastHMRTimestamp > 0) { url = injectQuery(url, `t=${mod.lastHMRTimestamp}`) } @@ -188,7 +188,7 @@ const devHtmlHook: IndexHtmlTransformHook = async ( html, { path: htmlPath, filename, server, originalUrl }, ) => { - const { config, moduleGraph, watcher } = server! + const { config, watcher } = server! const base = config.base || '/' const decodedBase = config.decodedBase || '/' @@ -250,9 +250,10 @@ const devHtmlHook: IndexHtmlTransformHook = async ( const modulePath = `${proxyModuleUrl}?html-proxy&index=${inlineModuleIndex}.${ext}` // invalidate the module so the newly cached contents will be served - const module = server?.moduleGraph.getModuleById(modulePath) + const clientModuleGraph = server?.environments.client.moduleGraph + const module = clientModuleGraph?.getModuleById(modulePath) if (module) { - server?.moduleGraph.invalidateModule(module) + clientModuleGraph!.invalidateModule(module) } s.update( node.sourceCodeLocation!.startOffset, @@ -358,10 +359,16 @@ const devHtmlHook: IndexHtmlTransformHook = async ( const url = `${proxyModulePath}?html-proxy&direct&index=${index}.css` // ensure module in graph after successful load - const mod = await moduleGraph.ensureEntryFromUrl(url, false) + const mod = + await server!.environments.client.moduleGraph.ensureEntryFromUrl( + url, + false, + ) ensureWatchedFile(watcher, mod.file, config.root) - const result = await server!.pluginContainer.transform(code, mod.id!) + const result = await server!.pluginContainer.transform(code, mod.id!, { + environment: server!.environments.client, + }) let content = '' if (result) { if (result.map && 'version' in result.map) { @@ -383,10 +390,16 @@ const devHtmlHook: IndexHtmlTransformHook = async ( // will transform with css plugin and cache result with css-post plugin const url = `${proxyModulePath}?html-proxy&inline-css&style-attr&index=${index}.css` - const mod = await moduleGraph.ensureEntryFromUrl(url, false) + const mod = + await server!.environments.client.moduleGraph.ensureEntryFromUrl( + url, + false, + ) ensureWatchedFile(watcher, mod.file, config.root) - await server?.pluginContainer.transform(code, mod.id!) + await server?.pluginContainer.transform(code, mod.id!, { + environment: server!.environments.client, + }) const hash = getHash(cleanUrl(mod.id!)) const result = htmlProxyResult.get(`${hash}_${index}`) diff --git a/packages/vite/src/node/server/middlewares/static.ts b/packages/vite/src/node/server/middlewares/static.ts index dac16e071b8217..33e0bd161e977d 100644 --- a/packages/vite/src/node/server/middlewares/static.ts +++ b/packages/vite/src/node/server/middlewares/static.ts @@ -4,7 +4,8 @@ import type { Options } from 'sirv' import sirv from 'sirv' import type { Connect } from 'dep-types/connect' import escapeHtml from 'escape-html' -import type { ViteDevServer } from '../..' +import type { ViteDevServer } from '../../server' +import type { ResolvedConfig } from '../../config' import { FS_PREFIX } from '../../constants' import { fsPathFromId, @@ -210,24 +211,50 @@ export function serveRawFsMiddleware( /** * Check if the url is allowed to be served, via the `server.fs` config. */ +export function isFileServingAllowed( + config: ResolvedConfig, + url: string, +): boolean +/** + * @deprecated Use the `isFileServingAllowed(config, url)` signature instead. + */ export function isFileServingAllowed( url: string, server: ViteDevServer, +): boolean +export function isFileServingAllowed( + configOrUrl: ResolvedConfig | string, + urlOrServer: string | ViteDevServer, ): boolean { - if (!server.config.server.fs.strict) return true + const config = ( + typeof urlOrServer === 'string' ? configOrUrl : urlOrServer.config + ) as ResolvedConfig + const url = ( + typeof urlOrServer === 'string' ? urlOrServer : configOrUrl + ) as string - const file = fsPathFromUrl(url) + if (!config.server.fs.strict) return true + const filePath = fsPathFromUrl(url) + return isFileLoadingAllowed(config, filePath) +} - if (server._fsDenyGlob(file)) return false +function isUriInFilePath(uri: string, filePath: string) { + return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath) +} - if (server.moduleGraph.safeModulesPath.has(file)) return true +export function isFileLoadingAllowed( + config: ResolvedConfig, + filePath: string, +): boolean { + const { fs } = config.server - if ( - server.config.server.fs.allow.some( - (uri) => isSameFileUri(uri, file) || isParentDirectory(uri, file), - ) - ) - return true + if (!fs.strict) return true + + if (config.fsDenyGlob(filePath)) return false + + if (config.safeModulePaths.has(filePath)) return true + + if (fs.allow.some((uri) => isUriInFilePath(uri, filePath))) return true return false } diff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts index 12a440d4c10774..59a53420793350 100644 --- a/packages/vite/src/node/server/middlewares/transform.ts +++ b/packages/vite/src/node/server/middlewares/transform.ts @@ -32,7 +32,6 @@ import { ERR_OUTDATED_OPTIMIZED_DEP, } from '../../plugins/optimizedDeps' import { ERR_CLOSED_SERVER } from '../pluginContainer' -import { getDepsOptimizer } from '../../optimizer' import { cleanUrl, unwrapId, withTrailingSlash } from '../../../shared/utils' import { NULL_BYTE_PLACEHOLDER } from '../../../shared/constants' @@ -48,10 +47,12 @@ export function cachedTransformMiddleware( ): Connect.NextHandleFunction { // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return function viteCachedTransformMiddleware(req, res, next) { + const environment = server.environments.client + // check if we can return 304 early const ifNoneMatch = req.headers['if-none-match'] if (ifNoneMatch) { - const moduleByEtag = server.moduleGraph.getModuleByEtag(ifNoneMatch) + const moduleByEtag = environment.moduleGraph.getModuleByEtag(ifNoneMatch) if (moduleByEtag?.transformResult?.etag === ifNoneMatch) { // For CSS requests, if the same CSS file is imported in a module, // the browser sends the request for the direct CSS request with the etag @@ -80,6 +81,8 @@ export function transformMiddleware( const publicPath = `${publicDir.slice(root.length)}/` return async function viteTransformMiddleware(req, res, next) { + const environment = server.environments.client + if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) { return next() } @@ -100,7 +103,7 @@ export function transformMiddleware( const isSourceMap = withoutQuery.endsWith('.map') // since we generate source map references, handle those requests here if (isSourceMap) { - const depsOptimizer = getDepsOptimizer(server.config, false) // non-ssr + const depsOptimizer = environment.depsOptimizer if (depsOptimizer?.isOptimizedDepUrl(url)) { // If the browser is requesting a source map for an optimized dep, it // means that the dependency has already been pre-bundled and loaded @@ -142,7 +145,7 @@ export function transformMiddleware( } else { const originalUrl = url.replace(/\.map($|\?)/, '$1') const map = ( - await server.moduleGraph.getModuleByUrl(originalUrl, false) + await environment.moduleGraph.getModuleByUrl(originalUrl) )?.transformResult?.map if (map) { return send(req, res, JSON.stringify(map), 'json', { @@ -185,8 +188,8 @@ export function transformMiddleware( const ifNoneMatch = req.headers['if-none-match'] if ( ifNoneMatch && - (await server.moduleGraph.getModuleByUrl(url, false)) - ?.transformResult?.etag === ifNoneMatch + (await environment.moduleGraph.getModuleByUrl(url))?.transformResult + ?.etag === ifNoneMatch ) { debugCache?.(`[304] ${prettifyUrl(url, server.config.root)}`) res.statusCode = 304 @@ -195,11 +198,11 @@ export function transformMiddleware( } // resolve, load and transform using the plugin container - const result = await transformRequest(url, server, { + const result = await transformRequest(environment, url, { html: req.headers.accept?.includes('text/html'), }) if (result) { - const depsOptimizer = getDepsOptimizer(server.config, false) // non-ssr + const depsOptimizer = environment.depsOptimizer const type = isDirectCSSRequest(url) ? 'css' : 'js' const isDep = DEP_VERSION_RE.test(url) || depsOptimizer?.isOptimizedDepUrl(url) diff --git a/packages/vite/src/node/server/mixedModuleGraph.ts b/packages/vite/src/node/server/mixedModuleGraph.ts new file mode 100644 index 00000000000000..b943331d47a9a3 --- /dev/null +++ b/packages/vite/src/node/server/mixedModuleGraph.ts @@ -0,0 +1,659 @@ +import type { ModuleInfo } from 'rollup' +import type { TransformResult } from './transformRequest' +import type { + EnvironmentModuleGraph, + EnvironmentModuleNode, + ResolvedUrl, +} from './moduleGraph' + +/** + * Backward compatible ModuleNode and ModuleGraph with mixed nodes from both the client and ssr environments + * It would be good to take the types names for the new EnvironmentModuleNode and EnvironmentModuleGraph but we can't + * do that at this point without breaking to much code in the ecosystem. + * We are going to deprecate these types and we can try to use them back in the future. + */ + +export class ModuleNode { + _moduleGraph: ModuleGraph + _clientModule: EnvironmentModuleNode | undefined + _ssrModule: EnvironmentModuleNode | undefined + constructor( + moduleGraph: ModuleGraph, + clientModule?: EnvironmentModuleNode, + ssrModule?: EnvironmentModuleNode, + ) { + this._moduleGraph = moduleGraph + this._clientModule = clientModule + this._ssrModule = ssrModule + } + _get( + prop: T, + ): EnvironmentModuleNode[T] { + return (this._clientModule?.[prop] ?? this._ssrModule?.[prop])! + } + _set( + prop: T, + value: EnvironmentModuleNode[T], + ): void { + if (this._clientModule) { + this._clientModule[prop] = value + } + if (this._ssrModule) { + this._ssrModule[prop] = value + } + } + + _wrapModuleSet( + prop: ModuleSetNames, + module: EnvironmentModuleNode | undefined, + ): Set { + if (!module) { + return new Set() + } + return createBackwardCompatibleModuleSet(this._moduleGraph, prop, module) + } + _getModuleSetUnion(prop: 'importedModules' | 'importers'): Set { + // A good approximation to the previous logic that returned the union of + // the importedModules and importers from both the browser and server + const importedModules = new Set() + const ids = new Set() + if (this._clientModule) { + for (const mod of this._clientModule[prop]) { + if (mod.id) ids.add(mod.id) + importedModules.add( + this._moduleGraph.getBackwardCompatibleModuleNode(mod), + ) + } + } + if (this._ssrModule) { + for (const mod of this._ssrModule[prop]) { + if (mod.id && !ids.has(mod.id)) { + importedModules.add( + this._moduleGraph.getBackwardCompatibleModuleNode(mod), + ) + } + } + } + return importedModules + } + get url(): string { + return this._get('url') + } + set url(value: string) { + this._set('url', value) + } + get id(): string | null { + return this._get('id') + } + set id(value: string | null) { + this._set('id', value) + } + get file(): string | null { + return this._get('file') + } + set file(value: string | null) { + this._set('file', value) + } + get type(): 'js' | 'css' { + return this._get('type') + } + get info(): ModuleInfo | undefined { + return this._get('info') + } + get meta(): Record | undefined { + return this._get('meta') + } + get importers(): Set { + return this._getModuleSetUnion('importers') + } + get clientImportedModules(): Set { + return this._wrapModuleSet('importedModules', this._clientModule) + } + get ssrImportedModules(): Set { + return this._wrapModuleSet('importedModules', this._ssrModule) + } + get importedModules(): Set { + return this._getModuleSetUnion('importedModules') + } + get acceptedHmrDeps(): Set { + return this._wrapModuleSet('acceptedHmrDeps', this._clientModule) + } + get acceptedHmrExports(): Set | null { + return this._clientModule?.acceptedHmrExports ?? null + } + get importedBindings(): Map> | null { + return this._clientModule?.importedBindings ?? null + } + get isSelfAccepting(): boolean | undefined { + return this._clientModule?.isSelfAccepting + } + get transformResult(): TransformResult | null { + return this._clientModule?.transformResult ?? null + } + set transformResult(value: TransformResult | null) { + if (this._clientModule) { + this._clientModule.transformResult = value + } + } + get ssrTransformResult(): TransformResult | null { + return this._ssrModule?.transformResult ?? null + } + set ssrTransformResult(value: TransformResult | null) { + if (this._ssrModule) { + this._ssrModule.transformResult = value + } + } + get ssrModule(): Record | null { + return this._ssrModule?.ssrModule ?? null + } + get ssrError(): Error | null { + return this._ssrModule?.ssrError ?? null + } + get lastHMRTimestamp(): number { + return Math.max( + this._clientModule?.lastHMRTimestamp ?? 0, + this._ssrModule?.lastHMRTimestamp ?? 0, + ) + } + set lastHMRTimestamp(value: number) { + if (this._clientModule) { + this._clientModule.lastHMRTimestamp = value + } + if (this._ssrModule) { + this._ssrModule.lastHMRTimestamp = value + } + } + get lastInvalidationTimestamp(): number { + return Math.max( + this._clientModule?.lastInvalidationTimestamp ?? 0, + this._ssrModule?.lastInvalidationTimestamp ?? 0, + ) + } + get invalidationState(): TransformResult | 'HARD_INVALIDATED' | undefined { + return this._clientModule?.invalidationState + } + get ssrInvalidationState(): TransformResult | 'HARD_INVALIDATED' | undefined { + return this._ssrModule?.invalidationState + } +} + +function mapIterator( + iterable: IterableIterator, + transform: (value: T) => K, +): IterableIterator { + return { + [Symbol.iterator](): IterableIterator { + return this + }, + next(): IteratorResult { + const r = iterable.next() + return r.done + ? r + : { + value: transform(r.value), + done: false, + } + }, + } +} + +export class ModuleGraph { + /** @internal */ + _moduleGraphs: { + client: () => EnvironmentModuleGraph + ssr: () => EnvironmentModuleGraph + } + + /** @internal */ + get _client(): EnvironmentModuleGraph { + return this._moduleGraphs.client() + } + + /** @internal */ + get _ssr(): EnvironmentModuleGraph { + return this._moduleGraphs.ssr() + } + + urlToModuleMap: Map + idToModuleMap: Map + etagToModuleMap: Map + + fileToModulesMap: Map> + + constructor(moduleGraphs: { + client: () => EnvironmentModuleGraph + ssr: () => EnvironmentModuleGraph + }) { + this._moduleGraphs = moduleGraphs + + const getModuleMapUnion = + (prop: 'urlToModuleMap' | 'idToModuleMap') => () => { + // A good approximation to the previous logic that returned the union of + // the importedModules and importers from both the browser and server + if (this._ssr[prop].size === 0) { + return this._client[prop] + } + const map = new Map(this._client[prop]) + for (const [key, module] of this._ssr[prop]) { + if (!map.has(key)) { + map.set(key, module) + } + } + return map + } + + this.urlToModuleMap = createBackwardCompatibleModuleMap( + this, + 'urlToModuleMap', + getModuleMapUnion('urlToModuleMap'), + ) + this.idToModuleMap = createBackwardCompatibleModuleMap( + this, + 'idToModuleMap', + getModuleMapUnion('idToModuleMap'), + ) + this.etagToModuleMap = createBackwardCompatibleModuleMap( + this, + 'etagToModuleMap', + () => this._client.etagToModuleMap, + ) + this.fileToModulesMap = createBackwardCompatibleFileToModulesMap(this) + } + + getModuleById(id: string): ModuleNode | undefined { + const clientModule = this._client.getModuleById(id) + const ssrModule = this._ssr.getModuleById(id) + if (!clientModule && !ssrModule) { + return + } + return this.getBackwardCompatibleModuleNodeDual(clientModule, ssrModule) + } + + async getModuleByUrl( + url: string, + ssr?: boolean, + ): Promise { + // In the mixed graph, the ssr flag was used to resolve the id. + const [clientModule, ssrModule] = await Promise.all([ + this._client.getModuleByUrl(url), + this._ssr.getModuleByUrl(url), + ]) + if (!clientModule && !ssrModule) { + return + } + return this.getBackwardCompatibleModuleNodeDual(clientModule, ssrModule) + } + + getModulesByFile(file: string): Set | undefined { + // Until Vite 5.1.x, the moduleGraph contained modules from both the browser and server + // We maintain backwards compatibility by returning a Set of module proxies assuming + // that the modules for a certain file are the same in both the browser and server + const clientModules = this._client.getModulesByFile(file) + const ssrModules = this._ssr.getModulesByFile(file) + if (!clientModules && !ssrModules) { + return undefined + } + const result = new Set() + if (clientModules) { + for (const mod of clientModules) { + result.add(this.getBackwardCompatibleBrowserModuleNode(mod)!) + } + } + if (ssrModules) { + for (const mod of ssrModules) { + if (!this._client.getModuleById(mod.id!)) { + result.add(this.getBackwardCompatibleBrowserModuleNode(mod)!) + } + } + } + return result + } + + onFileChange(file: string): void { + this._client.onFileChange(file) + this._ssr.onFileChange(file) + } + + onFileDelete(file: string): void { + this._client.onFileDelete(file) + this._ssr.onFileDelete(file) + } + + /** @internal */ + _getModuleGraph(environment: string): EnvironmentModuleGraph { + switch (environment) { + case 'client': + return this._client + case 'ssr': + return this._ssr + default: + throw new Error(`Invalid module node environment ${environment}`) + } + } + + invalidateModule( + mod: ModuleNode, + seen: Set = new Set(), + timestamp: number = Date.now(), + isHmr: boolean = false, + /** @internal */ + softInvalidate = false, + ): void { + if (mod._clientModule) { + this._client.invalidateModule( + mod._clientModule, + new Set( + [...seen].map((mod) => mod._clientModule).filter(Boolean), + ) as Set, + timestamp, + isHmr, + softInvalidate, + ) + } + if (mod._ssrModule) { + // TODO: Maybe this isn't needed? + this._ssr.invalidateModule( + mod._ssrModule, + new Set( + [...seen].map((mod) => mod._ssrModule).filter(Boolean), + ) as Set, + timestamp, + isHmr, + softInvalidate, + ) + } + } + + invalidateAll(): void { + this._client.invalidateAll() + this._ssr.invalidateAll() + } + + /* TODO: It seems there isn't usage of this method in the ecosystem + Waiting to check if we really need this for backwards compatibility + async updateModuleInfo( + module: ModuleNode, + importedModules: Set, + importedBindings: Map> | null, + acceptedModules: Set, + acceptedExports: Set | null, + isSelfAccepting: boolean, + ssr?: boolean, + staticImportedUrls?: Set, // internal + ): Promise | undefined> { + // Not implemented + } + */ + + async ensureEntryFromUrl( + rawUrl: string, + ssr?: boolean, + setIsSelfAccepting = true, + ): Promise { + const module = await (ssr ? this._ssr : this._client).ensureEntryFromUrl( + rawUrl, + setIsSelfAccepting, + ) + return this.getBackwardCompatibleModuleNode(module)! + } + + createFileOnlyEntry(file: string): ModuleNode { + const clientModule = this._client.createFileOnlyEntry(file) + const ssrModule = this._ssr.createFileOnlyEntry(file) + return this.getBackwardCompatibleModuleNodeDual(clientModule, ssrModule)! + } + + async resolveUrl(url: string, ssr?: boolean): Promise { + return ssr ? this._ssr.resolveUrl(url) : this._client.resolveUrl(url) + } + + updateModuleTransformResult( + mod: ModuleNode, + result: TransformResult | null, + ssr?: boolean, + ): void { + const environment = ssr ? 'ssr' : 'client' + this._getModuleGraph(environment).updateModuleTransformResult( + (environment === 'client' ? mod._clientModule : mod._ssrModule)!, + result, + ) + } + + getModuleByEtag(etag: string): ModuleNode | undefined { + const mod = this._client.etagToModuleMap.get(etag) + return mod && this.getBackwardCompatibleBrowserModuleNode(mod) + } + + getBackwardCompatibleBrowserModuleNode( + clientModule: EnvironmentModuleNode, + ): ModuleNode { + return this.getBackwardCompatibleModuleNodeDual( + clientModule, + clientModule.id ? this._ssr.getModuleById(clientModule.id) : undefined, + ) + } + + getBackwardCompatibleServerModuleNode( + ssrModule: EnvironmentModuleNode, + ): ModuleNode { + return this.getBackwardCompatibleModuleNodeDual( + ssrModule.id ? this._client.getModuleById(ssrModule.id) : undefined, + ssrModule, + ) + } + + getBackwardCompatibleModuleNode(mod: EnvironmentModuleNode): ModuleNode { + return mod.environment === 'client' + ? this.getBackwardCompatibleBrowserModuleNode(mod) + : this.getBackwardCompatibleServerModuleNode(mod) + } + + getBackwardCompatibleModuleNodeDual( + clientModule?: EnvironmentModuleNode, + ssrModule?: EnvironmentModuleNode, + ): ModuleNode { + // ... + return new ModuleNode(this, clientModule, ssrModule) + } +} + +type ModuleSetNames = 'acceptedHmrDeps' | 'importedModules' + +function createBackwardCompatibleModuleSet( + moduleGraph: ModuleGraph, + prop: ModuleSetNames, + module: EnvironmentModuleNode, +): Set { + return { + [Symbol.iterator]() { + return this.keys() + }, + has(key) { + if (!key.id) { + return false + } + const keyModule = moduleGraph + ._getModuleGraph(module.environment) + .getModuleById(key.id) + return keyModule !== undefined && module[prop].has(keyModule) + }, + values() { + return this.keys() + }, + keys() { + return mapIterator(module[prop].keys(), (mod) => + moduleGraph.getBackwardCompatibleModuleNode(mod), + ) + }, + get size() { + return module[prop].size + }, + forEach(callback, thisArg) { + return module[prop].forEach((mod) => { + const backwardCompatibleMod = + moduleGraph.getBackwardCompatibleModuleNode(mod) + callback.call( + thisArg, + backwardCompatibleMod, + backwardCompatibleMod, + this, + ) + }) + }, + // There are several methods missing. We can implement them if downstream + // projects are relying on them: add, clear, delete, difference, intersection, + // sDisjointFrom, isSubsetOf, isSupersetOf, symmetricDifference, union + } as Set +} + +function createBackwardCompatibleModuleMap( + moduleGraph: ModuleGraph, + prop: 'urlToModuleMap' | 'idToModuleMap' | 'etagToModuleMap', + getModuleMap: () => Map, +): Map { + return { + [Symbol.iterator]() { + return this.entries() + }, + get(key) { + const clientModule = moduleGraph._client[prop].get(key) + const ssrModule = moduleGraph._ssr[prop].get(key) + if (!clientModule && !ssrModule) { + return + } + return moduleGraph.getBackwardCompatibleModuleNodeDual( + clientModule, + ssrModule, + ) + }, + set(key, mod) { + const clientModule = mod._clientModule + if (clientModule) { + moduleGraph._client[prop].set(key, clientModule) + } + const ssrModule = mod._ssrModule + if (ssrModule) { + moduleGraph._ssr[prop].set(key, ssrModule) + } + }, + keys() { + return getModuleMap().keys() + }, + values() { + return mapIterator(getModuleMap().values(), (mod) => + moduleGraph.getBackwardCompatibleModuleNode(mod), + ) + }, + entries() { + return mapIterator(getModuleMap().entries(), ([key, mod]) => [ + key, + moduleGraph.getBackwardCompatibleModuleNode(mod), + ]) + }, + get size() { + return getModuleMap().size + }, + forEach(callback, thisArg) { + return getModuleMap().forEach((mod, key) => { + const backwardCompatibleMod = + moduleGraph.getBackwardCompatibleModuleNode(mod) + callback.call(thisArg, backwardCompatibleMod, key, this) + }) + }, + } as Map +} + +function createBackwardCompatibleFileToModulesMap( + moduleGraph: ModuleGraph, +): Map> { + const getFileToModulesMap = (): Map> => { + // A good approximation to the previous logic that returned the union of + // the importedModules and importers from both the browser and server + if (!moduleGraph._ssr.fileToModulesMap.size) { + return moduleGraph._client.fileToModulesMap + } + const map = new Map(moduleGraph._client.fileToModulesMap) + for (const [key, modules] of moduleGraph._ssr.fileToModulesMap) { + const modulesSet = map.get(key) + if (!modulesSet) { + map.set(key, modules) + } else { + for (const ssrModule of modules) { + let hasModule = false + for (const clientModule of modulesSet) { + hasModule ||= clientModule.id === ssrModule.id + if (hasModule) { + break + } + } + if (!hasModule) { + modulesSet.add(ssrModule) + } + } + } + } + return map + } + const getBackwardCompatibleModules = ( + modules: Set, + ): Set => + new Set( + [...modules].map((mod) => + moduleGraph.getBackwardCompatibleModuleNode(mod), + ), + ) + + return { + [Symbol.iterator]() { + return this.entries() + }, + get(key) { + const clientModules = moduleGraph._client.fileToModulesMap.get(key) + const ssrModules = moduleGraph._ssr.fileToModulesMap.get(key) + if (!clientModules && !ssrModules) { + return + } + const modules = clientModules ?? new Set() + if (ssrModules) { + for (const ssrModule of ssrModules) { + if (ssrModule.id) { + let found = false + for (const mod of modules) { + found ||= mod.id === ssrModule.id + if (found) { + break + } + } + if (!found) { + modules?.add(ssrModule) + } + } + } + } + return getBackwardCompatibleModules(modules) + }, + keys() { + return getFileToModulesMap().keys() + }, + values() { + return mapIterator( + getFileToModulesMap().values(), + getBackwardCompatibleModules, + ) + }, + entries() { + return mapIterator(getFileToModulesMap().entries(), ([key, modules]) => [ + key, + getBackwardCompatibleModules(modules), + ]) + }, + get size() { + return getFileToModulesMap().size + }, + forEach(callback, thisArg) { + return getFileToModulesMap().forEach((modules, key) => { + callback.call(thisArg, getBackwardCompatibleModules(modules), key, this) + }) + }, + } as Map> +} diff --git a/packages/vite/src/node/server/moduleGraph.ts b/packages/vite/src/node/server/moduleGraph.ts index 442ece308dbaff..d55807b9668d7d 100644 --- a/packages/vite/src/node/server/moduleGraph.ts +++ b/packages/vite/src/node/server/moduleGraph.ts @@ -10,7 +10,8 @@ import { FS_PREFIX } from '../constants' import { cleanUrl } from '../../shared/utils' import type { TransformResult } from './transformRequest' -export class ModuleNode { +export class EnvironmentModuleNode { + environment: string /** * Public served url path, starts with / */ @@ -23,17 +24,21 @@ export class ModuleNode { type: 'js' | 'css' info?: ModuleInfo meta?: Record - importers = new Set() - clientImportedModules = new Set() - ssrImportedModules = new Set() - acceptedHmrDeps = new Set() + importers = new Set() + + importedModules = new Set() + + acceptedHmrDeps = new Set() acceptedHmrExports: Set | null = null importedBindings: Map> | null = null isSelfAccepting?: boolean transformResult: TransformResult | null = null - ssrTransformResult: TransformResult | null = null + + // ssrModule and ssrError are no longer needed. They are on the module runner module cache. + // Once `ssrLoadModule` is re-implemented on top of the new APIs, we can delete these. ssrModule: Record | null = null ssrError: Error | null = null + lastHMRTimestamp = 0 /** * `import.meta.hot.invalidate` is called by the client. @@ -54,10 +59,6 @@ export class ModuleNode { * @internal */ invalidationState: TransformResult | 'HARD_INVALIDATED' | undefined - /** - * @internal - */ - ssrInvalidationState: TransformResult | 'HARD_INVALIDATED' | undefined /** * The module urls that are statically imported in the code. This information is separated * out from `importedModules` as only importers that statically import the module can be @@ -69,21 +70,14 @@ export class ModuleNode { /** * @param setIsSelfAccepting - set `false` to set `isSelfAccepting` later. e.g. #7870 */ - constructor(url: string, setIsSelfAccepting = true) { + constructor(url: string, environment: string, setIsSelfAccepting = true) { + this.environment = environment this.url = url this.type = isDirectCSSRequest(url) ? 'css' : 'js' if (setIsSelfAccepting) { this.isSelfAccepting = false } } - - get importedModules(): Set { - const importedModules = new Set(this.clientImportedModules) - for (const module of this.ssrImportedModules) { - importedModules.add(module) - } - return importedModules - } } export type ResolvedUrl = [ @@ -92,66 +86,65 @@ export type ResolvedUrl = [ meta: object | null | undefined, ] -export class ModuleGraph { - urlToModuleMap = new Map() - idToModuleMap = new Map() - etagToModuleMap = new Map() +export class EnvironmentModuleGraph { + environment: string + + urlToModuleMap = new Map() + idToModuleMap = new Map() + etagToModuleMap = new Map() // a single file may corresponds to multiple modules with different queries - fileToModulesMap = new Map>() - safeModulesPath = new Set() + fileToModulesMap = new Map>() /** * @internal */ _unresolvedUrlToModuleMap = new Map< string, - Promise | ModuleNode + Promise | EnvironmentModuleNode >() + /** * @internal */ - _ssrUnresolvedUrlToModuleMap = new Map< - string, - Promise | ModuleNode - >() + _resolveId: (url: string) => Promise /** @internal */ - _hasResolveFailedErrorModules = new Set() + _hasResolveFailedErrorModules = new Set() constructor( - private resolveId: ( - url: string, - ssr: boolean, - ) => Promise, - ) {} + environment: string, + resolveId: (url: string) => Promise, + ) { + this.environment = environment + this._resolveId = resolveId + } async getModuleByUrl( rawUrl: string, - ssr?: boolean, - ): Promise { + ): Promise { // Quick path, if we already have a module for this rawUrl (even without extension) rawUrl = removeImportQuery(removeTimestampQuery(rawUrl)) - const mod = this._getUnresolvedUrlToModule(rawUrl, ssr) + const mod = this._getUnresolvedUrlToModule(rawUrl) if (mod) { return mod } - const [url] = await this._resolveUrl(rawUrl, ssr) + const [url] = await this._resolveUrl(rawUrl) return this.urlToModuleMap.get(url) } - getModuleById(id: string): ModuleNode | undefined { + getModuleById(id: string): EnvironmentModuleNode | undefined { return this.idToModuleMap.get(removeTimestampQuery(id)) } - getModulesByFile(file: string): Set | undefined { + getModulesByFile(file: string): Set | undefined { return this.fileToModulesMap.get(file) } onFileChange(file: string): void { const mods = this.getModulesByFile(file) if (mods) { - const seen = new Set() + const seen = new Set() mods.forEach((mod) => { this.invalidateModule(mod, seen) }) @@ -170,15 +163,14 @@ export class ModuleGraph { } invalidateModule( - mod: ModuleNode, - seen: Set = new Set(), + mod: EnvironmentModuleNode, + seen: Set = new Set(), timestamp: number = Date.now(), isHmr: boolean = false, /** @internal */ softInvalidate = false, ): void { const prevInvalidationState = mod.invalidationState - const prevSsrInvalidationState = mod.ssrInvalidationState // Handle soft invalidation before the `seen` check, as consecutive soft/hard invalidations can // cause the final soft invalidation state to be different. @@ -186,20 +178,14 @@ export class ModuleGraph { // import timestamps only in `transformRequest`. If there's no previous `transformResult`, hard invalidate it. if (softInvalidate) { mod.invalidationState ??= mod.transformResult ?? 'HARD_INVALIDATED' - mod.ssrInvalidationState ??= mod.ssrTransformResult ?? 'HARD_INVALIDATED' } // If hard invalidated, further soft invalidations have no effect until it's reset to `undefined` else { mod.invalidationState = 'HARD_INVALIDATED' - mod.ssrInvalidationState = 'HARD_INVALIDATED' } // Skip updating the module if it was already invalidated before and the invalidation state has not changed - if ( - seen.has(mod) && - prevInvalidationState === mod.invalidationState && - prevSsrInvalidationState === mod.ssrInvalidationState - ) { + if (seen.has(mod) && prevInvalidationState === mod.invalidationState) { return } seen.add(mod) @@ -219,7 +205,7 @@ export class ModuleGraph { if (etag) this.etagToModuleMap.delete(etag) mod.transformResult = null - mod.ssrTransformResult = null + mod.ssrModule = null mod.ssrError = null @@ -246,7 +232,7 @@ export class ModuleGraph { invalidateAll(): void { const timestamp = Date.now() - const seen = new Set() + const seen = new Set() this.idToModuleMap.forEach((mod) => { this.invalidateModule(mod, seen, timestamp) }) @@ -261,19 +247,18 @@ export class ModuleGraph { * This is only used for soft invalidations so `undefined` is fine but may cause more runtime processing. */ async updateModuleInfo( - mod: ModuleNode, - importedModules: Set, + mod: EnvironmentModuleNode, + importedModules: Set, importedBindings: Map> | null, - acceptedModules: Set, + acceptedModules: Set, acceptedExports: Set | null, isSelfAccepting: boolean, - ssr?: boolean, /** @internal */ staticImportedUrls?: Set, - ): Promise | undefined> { + ): Promise | undefined> { mod.isSelfAccepting = isSelfAccepting - const prevImports = ssr ? mod.ssrImportedModules : mod.clientImportedModules - let noLongerImported: Set | undefined + const prevImports = mod.importedModules + let noLongerImported: Set | undefined let resolvePromises = [] let resolveResults = new Array(importedModules.size) @@ -283,7 +268,7 @@ export class ModuleGraph { const nextIndex = index++ if (typeof imported === 'string') { resolvePromises.push( - this.ensureEntryFromUrl(imported, ssr).then((dep) => { + this.ensureEntryFromUrl(imported).then((dep) => { dep.importers.add(mod) resolveResults[nextIndex] = dep }), @@ -299,18 +284,11 @@ export class ModuleGraph { } const nextImports = new Set(resolveResults) - if (ssr) { - mod.ssrImportedModules = nextImports - } else { - mod.clientImportedModules = nextImports - } + mod.importedModules = nextImports // remove the importer from deps that were imported but no longer are. prevImports.forEach((dep) => { - if ( - !mod.clientImportedModules.has(dep) && - !mod.ssrImportedModules.has(dep) - ) { + if (!mod.importedModules.has(dep)) { dep.importers.delete(mod) if (!dep.importers.size) { // dependency no longer imported @@ -327,7 +305,7 @@ export class ModuleGraph { const nextIndex = index++ if (typeof accepted === 'string') { resolvePromises.push( - this.ensureEntryFromUrl(accepted, ssr).then((dep) => { + this.ensureEntryFromUrl(accepted).then((dep) => { resolveResults[nextIndex] = dep }), ) @@ -351,10 +329,9 @@ export class ModuleGraph { async ensureEntryFromUrl( rawUrl: string, - ssr?: boolean, setIsSelfAccepting = true, - ): Promise { - return this._ensureEntryFromUrl(rawUrl, ssr, setIsSelfAccepting) + ): Promise { + return this._ensureEntryFromUrl(rawUrl, setIsSelfAccepting) } /** @@ -362,26 +339,25 @@ export class ModuleGraph { */ async _ensureEntryFromUrl( rawUrl: string, - ssr?: boolean, setIsSelfAccepting = true, // Optimization, avoid resolving the same url twice if the caller already did it resolved?: PartialResolvedId, - ): Promise { + ): Promise { // Quick path, if we already have a module for this rawUrl (even without extension) rawUrl = removeImportQuery(removeTimestampQuery(rawUrl)) - let mod = this._getUnresolvedUrlToModule(rawUrl, ssr) + let mod = this._getUnresolvedUrlToModule(rawUrl) if (mod) { return mod } const modPromise = (async () => { - const [url, resolvedId, meta] = await this._resolveUrl( - rawUrl, - ssr, - resolved, - ) + const [url, resolvedId, meta] = await this._resolveUrl(rawUrl, resolved) mod = this.idToModuleMap.get(resolvedId) if (!mod) { - mod = new ModuleNode(url, setIsSelfAccepting) + mod = new EnvironmentModuleNode( + url, + this.environment, + setIsSelfAccepting, + ) if (meta) mod.meta = meta this.urlToModuleMap.set(url, mod) mod.id = resolvedId @@ -399,13 +375,13 @@ export class ModuleGraph { else if (!this.urlToModuleMap.has(url)) { this.urlToModuleMap.set(url, mod) } - this._setUnresolvedUrlToModule(rawUrl, mod, ssr) + this._setUnresolvedUrlToModule(rawUrl, mod) return mod })() // Also register the clean url to the module, so that we can short-circuit // resolving the same url twice - this._setUnresolvedUrlToModule(rawUrl, modPromise, ssr) + this._setUnresolvedUrlToModule(rawUrl, modPromise) return modPromise } @@ -413,7 +389,7 @@ export class ModuleGraph { // url because they are inlined into the main css import. But they still // need to be represented in the module graph so that they can trigger // hmr in the importing css file. - createFileOnlyEntry(file: string): ModuleNode { + createFileOnlyEntry(file: string): EnvironmentModuleNode { file = normalizePath(file) let fileMappedModules = this.fileToModulesMap.get(file) if (!fileMappedModules) { @@ -428,7 +404,7 @@ export class ModuleGraph { } } - const mod = new ModuleNode(url) + const mod = new EnvironmentModuleNode(url, this.environment) mod.file = file fileMappedModules.add(mod) return mod @@ -438,33 +414,29 @@ export class ModuleGraph { // 1. remove the HMR timestamp query (?t=xxxx) and the ?import query // 2. resolve its extension so that urls with or without extension all map to // the same module - async resolveUrl(url: string, ssr?: boolean): Promise { + async resolveUrl(url: string): Promise { url = removeImportQuery(removeTimestampQuery(url)) - const mod = await this._getUnresolvedUrlToModule(url, ssr) + const mod = await this._getUnresolvedUrlToModule(url) if (mod?.id) { return [mod.url, mod.id, mod.meta] } - return this._resolveUrl(url, ssr) + return this._resolveUrl(url) } updateModuleTransformResult( - mod: ModuleNode, + mod: EnvironmentModuleNode, result: TransformResult | null, - ssr: boolean, ): void { - if (ssr) { - mod.ssrTransformResult = result - } else { + if (this.environment === 'client') { const prevEtag = mod.transformResult?.etag if (prevEtag) this.etagToModuleMap.delete(prevEtag) - - mod.transformResult = result - if (result?.etag) this.etagToModuleMap.set(result.etag, mod) } + + mod.transformResult = result } - getModuleByEtag(etag: string): ModuleNode | undefined { + getModuleByEtag(etag: string): EnvironmentModuleNode | undefined { return this.etagToModuleMap.get(etag) } @@ -473,24 +445,17 @@ export class ModuleGraph { */ _getUnresolvedUrlToModule( url: string, - ssr?: boolean, - ): Promise | ModuleNode | undefined { - return ( - ssr ? this._ssrUnresolvedUrlToModuleMap : this._unresolvedUrlToModuleMap - ).get(url) + ): Promise | EnvironmentModuleNode | undefined { + return this._unresolvedUrlToModuleMap.get(url) } /** * @internal */ _setUnresolvedUrlToModule( url: string, - mod: Promise | ModuleNode, - ssr?: boolean, + mod: Promise | EnvironmentModuleNode, ): void { - ;(ssr - ? this._ssrUnresolvedUrlToModuleMap - : this._unresolvedUrlToModuleMap - ).set(url, mod) + this._unresolvedUrlToModuleMap.set(url, mod) } /** @@ -498,10 +463,9 @@ export class ModuleGraph { */ async _resolveUrl( url: string, - ssr?: boolean, alreadyResolved?: PartialResolvedId, ): Promise { - const resolved = alreadyResolved ?? (await this.resolveId(url, !!ssr)) + const resolved = alreadyResolved ?? (await this._resolveId(url)) const resolvedId = resolved?.id || url if ( url !== resolvedId && diff --git a/packages/vite/src/node/server/pluginContainer.ts b/packages/vite/src/node/server/pluginContainer.ts index 3251790d169864..bca2df727008b3 100644 --- a/packages/vite/src/node/server/pluginContainer.ts +++ b/packages/vite/src/node/server/pluginContainer.ts @@ -77,11 +77,16 @@ import { timeFrom, } from '../utils' import { FS_PREFIX } from '../constants' -import type { PluginHookUtils, ResolvedConfig } from '../config' import { createPluginHookUtils, getHookHandler } from '../plugins' import { cleanUrl, unwrapId } from '../../shared/utils' +import type { PluginHookUtils } from '../config' +import type { Environment } from '../environment' +import type { DevEnvironment } from './environment' import { buildErrorMessage } from './middlewares/error' -import type { ModuleGraph, ModuleNode } from './moduleGraph' +import type { + EnvironmentModuleGraph, + EnvironmentModuleNode, +} from './moduleGraph' const noop = () => {} @@ -120,43 +125,55 @@ export interface PluginContainerOptions { writeFile?: (name: string, source: string | Uint8Array) => void } -export async function createPluginContainer( - config: ResolvedConfig, - moduleGraph?: ModuleGraph, +/** + * Create a plugin container with a set of plugins. We pass them as a parameter + * instead of using environment.plugins to allow the creation of different + * pipelines working with the same environment (used for createIdResolver). + */ +export async function createEnvironmentPluginContainer( + environment: Environment, + plugins: Plugin[], watcher?: FSWatcher, -): Promise { - const container = new PluginContainer(config, moduleGraph, watcher) +): Promise { + const container = new EnvironmentPluginContainer( + environment, + plugins, + watcher, + ) await container.resolveRollupOptions() return container } -class PluginContainer { +class EnvironmentPluginContainer { private _pluginContextMap = new Map() - private _pluginContextMapSsr = new Map() private _resolvedRollupOptions?: InputOptions private _processesing = new Set>() private _seenResolves: Record = {} - private _closed = false + // _addedFiles from the `load()` hook gets saved here so it can be reused in the `transform()` hook private _moduleNodeToLoadAddedImports = new WeakMap< - ModuleNode, + EnvironmentModuleNode, Set | null >() getSortedPluginHooks: PluginHookUtils['getSortedPluginHooks'] getSortedPlugins: PluginHookUtils['getSortedPlugins'] + moduleGraph: EnvironmentModuleGraph | undefined watchFiles = new Set() minimalContext: MinimalPluginContext + private _started = false + private _buildStartPromise: Promise | undefined + private _closed = false + /** - * @internal use `createPluginContainer` instead + * @internal use `createEnvironmentPluginContainer` instead */ constructor( - public config: ResolvedConfig, - public moduleGraph?: ModuleGraph, + public environment: Environment, + public plugins: Plugin[], public watcher?: FSWatcher, - public plugins = config.plugins, ) { this.minimalContext = { meta: { @@ -172,6 +189,8 @@ class PluginContainer { const utils = createPluginHookUtils(plugins) this.getSortedPlugins = utils.getSortedPlugins this.getSortedPluginHooks = utils.getSortedPluginHooks + this.moduleGraph = + environment.mode === 'dev' ? environment.moduleGraph : undefined } private _updateModuleLoadAddedImports( @@ -236,7 +255,7 @@ class PluginContainer { async resolveRollupOptions(): Promise { if (!this._resolvedRollupOptions) { - let options = this.config.build.rollupOptions + let options = this.environment.config.build.rollupOptions for (const optionsHook of this.getSortedPluginHooks('options')) { if (this._closed) { throwClosedServerError() @@ -251,13 +270,11 @@ class PluginContainer { return this._resolvedRollupOptions } - private _getPluginContext(plugin: Plugin, ssr: boolean) { - const map = ssr ? this._pluginContextMapSsr : this._pluginContextMap - if (!map.has(plugin)) { - const ctx = new PluginContext(plugin, this, ssr) - map.set(plugin, ctx) + private _getPluginContext(plugin: Plugin) { + if (!this._pluginContextMap.has(plugin)) { + this._pluginContextMap.set(plugin, new PluginContext(plugin, this)) } - return map.get(plugin)! + return this._pluginContextMap.get(plugin)! } // parallel, ignores returns @@ -265,12 +282,14 @@ class PluginContainer { hookName: H, context: (plugin: Plugin) => ThisType, args: (plugin: Plugin) => Parameters, + condition?: (plugin: Plugin) => boolean, ): Promise { const parallelPromises: Promise[] = [] for (const plugin of this.getSortedPlugins(hookName)) { // Don't throw here if closed, so buildEnd and closeBundle hooks can finish running const hook = plugin[hookName] if (!hook) continue + if (condition && !condition(plugin)) continue const handler: Function = getHookHandler(hook) if ((hook as { sequential?: boolean }).sequential) { @@ -285,23 +304,37 @@ class PluginContainer { } async buildStart(_options?: InputOptions): Promise { - await this.handleHookPromise( + if (this._started) { + if (this._buildStartPromise) { + await this._buildStartPromise + } + return + } + this._started = true + this._buildStartPromise = this.handleHookPromise( this.hookParallel( 'buildStart', - (plugin) => this._getPluginContext(plugin, false), + (plugin) => this._getPluginContext(plugin), () => [this.options as NormalizedInputOptions], + (plugin) => + this.environment.name === 'client' || + plugin.perEnvironmentStartEndDuringDev === true, ), - ) + ) as Promise + await this._buildStartPromise + this._buildStartPromise = undefined } async resolveId( rawId: string, - importer: string | undefined = join(this.config.root, 'index.html'), + importer: string | undefined = join( + this.environment.config.root, + 'index.html', + ), options?: { attributes?: Record custom?: CustomPluginOptions skip?: Set - ssr?: boolean /** * @internal */ @@ -309,17 +342,21 @@ class PluginContainer { isEntry?: boolean }, ): Promise { + if (!this._started) { + this.buildStart() + await this._buildStartPromise + } const skip = options?.skip - const ssr = options?.ssr const scan = !!options?.scan - const ctx = new ResolveIdContext(this, !!ssr, skip, scan) + const ssr = this.environment.config.consumer === 'server' + const ctx = new ResolveIdContext(this, skip, scan) const resolveStart = debugResolve ? performance.now() : 0 let id: string | null = null const partial: Partial = {} - for (const plugin of this.getSortedPlugins('resolveId')) { - if (this._closed && !ssr) throwClosedServerError() + if (this._closed && this.environment.config.dev.recoverable) + throwClosedServerError() if (!plugin.resolveId) continue if (skip?.has(plugin)) continue @@ -348,7 +385,7 @@ class PluginContainer { debugPluginResolve?.( timeFrom(pluginResolveStart), plugin.name, - prettifyUrl(id, this.config.root), + prettifyUrl(id, this.environment.config.root), ) // resolveId() is hookFirst - first non-null result is returned. @@ -376,22 +413,18 @@ class PluginContainer { } } - async load( - id: string, - options?: { - ssr?: boolean - }, - ): Promise { - const ssr = options?.ssr - const ctx = new LoadPluginContext(this, !!ssr) - + async load(id: string): Promise { + const ssr = this.environment.config.consumer === 'server' + const options = { ssr } + const ctx = new LoadPluginContext(this) for (const plugin of this.getSortedPlugins('load')) { - if (this._closed && !ssr) throwClosedServerError() + if (this._closed && this.environment.config.dev.recoverable) + throwClosedServerError() if (!plugin.load) continue ctx._plugin = plugin const handler = getHookHandler(plugin.load) const result = await this.handleHookPromise( - handler.call(ctx as any, id, { ssr }), + handler.call(ctx as any, id, options), ) if (result != null) { if (isObject(result)) { @@ -409,34 +442,28 @@ class PluginContainer { code: string, id: string, options?: { - ssr?: boolean inMap?: SourceDescription['map'] }, ): Promise<{ code: string; map: SourceMap | { mappings: '' } | null }> { + const ssr = this.environment.config.consumer === 'server' + const optionsWithSSR = options ? { ...options, ssr } : { ssr } const inMap = options?.inMap - const ssr = options?.ssr - const ctx = new TransformPluginContext( - this, - id, - code, - inMap as SourceMap, - !!ssr, - ) + const ctx = new TransformPluginContext(this, id, code, inMap as SourceMap) ctx._addedImports = this._getAddedImports(id) for (const plugin of this.getSortedPlugins('transform')) { - if (this._closed && !ssr) throwClosedServerError() + if (this._closed && this.environment.config.dev.recoverable) + throwClosedServerError() if (!plugin.transform) continue ctx._updateActiveInfo(plugin, id, code) - const start = debugPluginTransform ? performance.now() : 0 let result: TransformResult | string | undefined const handler = getHookHandler(plugin.transform) try { result = await this.handleHookPromise( - handler.call(ctx as any, code, id, { ssr }), + handler.call(ctx as any, code, id, optionsWithSSR), ) } catch (e) { ctx.error(e) @@ -445,7 +472,7 @@ class PluginContainer { debugPluginTransform?.( timeFrom(start), plugin.name, - prettifyUrl(id, this.config.root), + prettifyUrl(id, this.environment.config.root), ) if (isObject(result)) { if (result.code !== undefined) { @@ -475,7 +502,7 @@ class PluginContainer { ): Promise { await this.hookParallel( 'watchChange', - (plugin) => this._getPluginContext(plugin, false), + (plugin) => this._getPluginContext(plugin), () => [id, change], ) } @@ -486,41 +513,41 @@ class PluginContainer { await Promise.allSettled(Array.from(this._processesing)) await this.hookParallel( 'buildEnd', - (plugin) => this._getPluginContext(plugin, false), + (plugin) => this._getPluginContext(plugin), () => [], + (plugin) => + this.environment.name === 'client' || + plugin.perEnvironmentStartEndDuringDev !== true, ) await this.hookParallel( 'closeBundle', - (plugin) => this._getPluginContext(plugin, false), + (plugin) => this._getPluginContext(plugin), () => [], ) } } class PluginContext implements Omit { - protected _scan = false - protected _resolveSkips?: Set - protected _activeId: string | null = null - protected _activeCode: string | null = null - + ssr = false + _scan = false + _activeId: string | null = null + _activeCode: string | null = null + _resolveSkips?: Set meta: RollupPluginContext['meta'] + environment: Environment constructor( public _plugin: Plugin, - public _container: PluginContainer, - public ssr: boolean, + public _container: EnvironmentPluginContainer, ) { + this.environment = this._container.environment this.meta = this._container.minimalContext.meta } - parse(code: string, opts: any): ReturnType { + parse(code: string, opts: any) { return rollupParseAst(code, opts) } - getModuleInfo(id: string): ModuleInfo | null { - return this._container.getModuleInfo(id) - } - async resolve( id: string, importer?: string, @@ -530,7 +557,7 @@ class PluginContext implements Omit { isEntry?: boolean skipSelf?: boolean }, - ): ReturnType { + ) { let skip: Set | undefined if (options?.skipSelf !== false && this._plugin) { skip = new Set(this._resolveSkips) @@ -541,7 +568,6 @@ class PluginContext implements Omit { custom: options?.custom, isEntry: !!options?.isEntry, skip, - ssr: this.ssr, scan: this._scan, }) if (typeof out === 'string') out = { id: out } @@ -555,20 +581,15 @@ class PluginContext implements Omit { } & Partial>, ): Promise { // We may not have added this to our module graph yet, so ensure it exists - await this._container.moduleGraph?.ensureEntryFromUrl( - unwrapId(options.id), - this.ssr, - ) + await this._container.moduleGraph?.ensureEntryFromUrl(unwrapId(options.id)) // Not all options passed to this function make sense in the context of loading individual files, // but we can at least update the module info properties we support this._updateModuleInfo(options.id, options) - const loadResult = await this._container.load(options.id, { - ssr: this.ssr, - }) + const loadResult = await this._container.load(options.id) const code = typeof loadResult === 'object' ? loadResult?.code : loadResult if (code != null) { - await this._container.transform(code, options.id, { ssr: this.ssr }) + await this._container.transform(code, options.id) } const moduleInfo = this.getModuleInfo(options.id) @@ -579,7 +600,11 @@ class PluginContext implements Omit { return moduleInfo } - _updateModuleInfo(id: string, { meta }: { meta?: object | null }): void { + getModuleInfo(id: string): ModuleInfo | null { + return this._container.getModuleInfo(id) + } + + _updateModuleInfo(id: string, { meta }: { meta?: object | null }) { if (meta) { const moduleInfo = this.getModuleInfo(id) if (moduleInfo) { @@ -600,7 +625,7 @@ class PluginContext implements Omit { ensureWatchedFile( this._container.watcher, id, - this._container.config.root, + this.environment.config.root, ) } @@ -632,7 +657,7 @@ class PluginContext implements Omit { [colors.yellow(`warning: ${err.message}`)], false, ) - this._container.config.logger.warn(msg, { + this.environment.logger.warn(msg, { clear: true, timestamp: true, }) @@ -671,7 +696,7 @@ class PluginContext implements Omit { try { errLocation = numberToPos(this._activeCode, pos) } catch (err2) { - this._container.config.logger.error( + this.environment.logger.error( colors.red( `Error in error handler:\n${err2.stack || err2.message}\n`, ), @@ -753,7 +778,7 @@ class PluginContext implements Omit { } _warnIncompatibleMethod(method: string): void { - this._container.config.logger.warn( + this.environment.logger.warn( colors.cyan(`[plugin:${this._plugin.name}] `) + colors.yellow( `context method ${colors.bold( @@ -766,12 +791,11 @@ class PluginContext implements Omit { class ResolveIdContext extends PluginContext { constructor( - container: PluginContainer, - ssr: boolean, + container: EnvironmentPluginContainer, skip: Set | undefined, scan: boolean, ) { - super(null!, container, ssr) + super(null!, container) this._resolveSkips = skip this._scan = scan } @@ -780,8 +804,8 @@ class ResolveIdContext extends PluginContext { class LoadPluginContext extends PluginContext { _addedImports: Set | null = null - constructor(container: PluginContainer, ssr: boolean) { - super(null!, container, ssr) + constructor(container: EnvironmentPluginContainer) { + super(null!, container) } override addWatchFile(id: string): void { @@ -804,13 +828,12 @@ class TransformPluginContext combinedMap: SourceMap | { mappings: '' } | null = null constructor( - container: PluginContainer, + container: EnvironmentPluginContainer, id: string, code: string, - inMap: SourceMap | string | undefined, - ssr: boolean, + inMap?: SourceMap | string, ) { - super(container, ssr) + super(container) this.filename = id this.originalCode = code @@ -906,10 +929,126 @@ class TransformPluginContext } } -// We only expose the types but not the implementations export type { - PluginContainer, - PluginContext, + EnvironmentPluginContainer, TransformPluginContext, TransformResult, } + +// Backward compatibility +class PluginContainer { + constructor(private environments: Record) {} + + // Backward compatibility + // Users should call pluginContainer.resolveId (and load/transform) passing the environment they want to work with + // But there is code that is going to call it without passing an environment, or with the ssr flag to get the ssr environment + private _getEnvironment(options?: { + ssr?: boolean + environment?: Environment + }) { + return options?.environment + ? options.environment + : this.environments?.[options?.ssr ? 'ssr' : 'client'] + } + + private _getPluginContainer(options?: { + ssr?: boolean + environment?: Environment + }) { + return (this._getEnvironment(options) as DevEnvironment).pluginContainer + } + + getModuleInfo(id: string): ModuleInfo | null { + return ( + ( + this.environments.client as DevEnvironment + ).pluginContainer.getModuleInfo(id) || + (this.environments.ssr as DevEnvironment).pluginContainer.getModuleInfo( + id, + ) + ) + } + + get options(): InputOptions { + return (this.environments.client as DevEnvironment).pluginContainer.options + } + + // For backward compatibility, buildStart and watchChange are called only for the client environment + // buildStart is called per environment for a plugin with the perEnvironmentStartEndDuring dev flag + + async buildStart(_options?: InputOptions): Promise { + ;(this.environments.client as DevEnvironment).pluginContainer.buildStart( + _options, + ) + } + + async watchChange( + id: string, + change: { event: 'create' | 'update' | 'delete' }, + ): Promise { + ;(this.environments.client as DevEnvironment).pluginContainer.watchChange( + id, + change, + ) + } + + async resolveId( + rawId: string, + importer?: string, + options?: { + attributes?: Record + custom?: CustomPluginOptions + skip?: Set + ssr?: boolean + /** + * @internal + */ + scan?: boolean + isEntry?: boolean + }, + ): Promise { + return this._getPluginContainer(options).resolveId(rawId, importer, options) + } + + async load( + id: string, + options?: { + ssr?: boolean + }, + ): Promise { + return this._getPluginContainer(options).load(id) + } + + async transform( + code: string, + id: string, + options?: { + ssr?: boolean + environment?: Environment + inMap?: SourceDescription['map'] + }, + ): Promise<{ code: string; map: SourceMap | { mappings: '' } | null }> { + return this._getPluginContainer(options).transform(code, id, options) + } + + async close(): Promise { + // noop, close will be called for each environment + } +} + +/** + * server.pluginContainer compatibility + * + * The default environment is in buildStart, buildEnd, watchChange, and closeBundle hooks, + * which are called once for all environments, or when no environment is passed in other hooks. + * The ssrEnvironment is needed for backward compatibility when the ssr flag is passed without + * an environment. The defaultEnvironment in the main pluginContainer in the server should be + * the client environment for backward compatibility. + **/ +export function createPluginContainer( + environments: Record, +): PluginContainer { + return new PluginContainer(environments) +} + +export type { PluginContainer } diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index dc98c1795daf26..666521867a1149 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -6,7 +6,7 @@ import MagicString from 'magic-string' import { init, parse as parseImports } from 'es-module-lexer' import type { PartialResolvedId, SourceDescription, SourceMap } from 'rollup' import colors from 'picocolors' -import type { ModuleNode, ViteDevServer } from '..' +import type { EnvironmentModuleNode } from '../server/moduleGraph' import { createDebugger, ensureWatchedFile, @@ -18,17 +18,17 @@ import { stripBase, timeFrom, } from '../utils' +import { ssrTransform } from '../ssr/ssrTransform' import { checkPublicFile } from '../publicDir' -import { isDepsOptimizerEnabled } from '../config' -import { getDepsOptimizer, initDevSsrDepsOptimizer } from '../optimizer' import { cleanUrl, unwrapId } from '../../shared/utils' import { applySourcemapIgnoreList, extractSourcemapFromFile, injectSourcesContent, } from './sourcemap' -import { isFileServingAllowed } from './middlewares/static' +import { isFileLoadingAllowed } from './middlewares/static' import { throwClosedServerError } from './pluginContainer' +import type { DevEnvironment } from './environment' export const ERR_LOAD_URL = 'ERR_LOAD_URL' export const ERR_LOAD_PUBLIC_URL = 'ERR_LOAD_PUBLIC_URL' @@ -40,24 +40,45 @@ const debugCache = createDebugger('vite:cache') export interface TransformResult { code: string map: SourceMap | { mappings: '' } | null + ssr?: boolean etag?: string deps?: string[] dynamicDeps?: string[] } export interface TransformOptions { + /** + * @deprecated inferred from environment + */ ssr?: boolean + /** + * @internal + */ html?: boolean } +// TODO: This function could be moved to the DevEnvironment class. +// It was already using private fields from the server before, and it now does +// the same with environment._closing, environment._pendingRequests and +// environment._registerRequestProcessing. Maybe it makes sense to keep it in +// separate file to preserve the history or keep the DevEnvironment class cleaner, +// but conceptually this is: `environment.transformRequest(url, options)` + export function transformRequest( + environment: DevEnvironment, url: string, - server: ViteDevServer, options: TransformOptions = {}, ): Promise { - if (server._restartPromise && !options.ssr) throwClosedServerError() + // Backward compatibility when only `ssr` is passed + if (!options?.ssr) { + // Backward compatibility + options = { ...options, ssr: environment.config.consumer === 'server' } + } - const cacheKey = (options.ssr ? 'ssr:' : options.html ? 'html:' : '') + url + if (environment._closing && environment.config.dev.recoverable) + throwClosedServerError() + + const cacheKey = `${options.html ? 'html:' : ''}${url}` // This module may get invalidated while we are processing it. For example // when a full page reload is needed after the re-processing of pre-bundled @@ -81,10 +102,10 @@ export function transformRequest( // last time this module is invalidated const timestamp = Date.now() - const pending = server._pendingRequests.get(cacheKey) + const pending = environment._pendingRequests.get(cacheKey) if (pending) { - return server.moduleGraph - .getModuleByUrl(removeTimestampQuery(url), options.ssr) + return environment.moduleGraph + .getModuleByUrl(removeTimestampQuery(url)) .then((module) => { if (!module || pending.timestamp > module.lastInvalidationTimestamp) { // The pending request is still valid, we can safely reuse its result @@ -97,24 +118,24 @@ export function transformRequest( // First request has been invalidated, abort it to clear the cache, // then perform a new doTransform. pending.abort() - return transformRequest(url, server, options) + return transformRequest(environment, url, options) } }) } - const request = doTransform(url, server, options, timestamp) + const request = doTransform(environment, url, options, timestamp) // Avoid clearing the cache of future requests if aborted let cleared = false const clearCache = () => { if (!cleared) { - server._pendingRequests.delete(cacheKey) + environment._pendingRequests.delete(cacheKey) cleared = true } } // Cache the request and clear it once processing is done - server._pendingRequests.set(cacheKey, { + environment._pendingRequests.set(cacheKey, { request, timestamp, abort: clearCache, @@ -124,28 +145,22 @@ export function transformRequest( } async function doTransform( + environment: DevEnvironment, url: string, - server: ViteDevServer, options: TransformOptions, timestamp: number, ) { url = removeTimestampQuery(url) - const { config, pluginContainer } = server - const ssr = !!options.ssr - - if (ssr && isDepsOptimizerEnabled(config, true)) { - await initDevSsrDepsOptimizer(config, server) - } + const { pluginContainer } = environment - let module = await server.moduleGraph.getModuleByUrl(url, ssr) + let module = await environment.moduleGraph.getModuleByUrl(url) if (module) { // try use cache from url const cached = await getCachedTransformResult( + environment, url, module, - server, - ssr, timestamp, ) if (cached) return cached @@ -153,71 +168,63 @@ async function doTransform( const resolved = module ? undefined - : ((await pluginContainer.resolveId(url, undefined, { ssr })) ?? undefined) + : ((await pluginContainer.resolveId(url, undefined)) ?? undefined) // resolve const id = module?.id ?? resolved?.id ?? url - module ??= server.moduleGraph.getModuleById(id) + module ??= environment.moduleGraph.getModuleById(id) if (module) { // if a different url maps to an existing loaded id, make sure we relate this url to the id - await server.moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved) + await environment.moduleGraph._ensureEntryFromUrl(url, undefined, resolved) // try use cache from id const cached = await getCachedTransformResult( + environment, url, module, - server, - ssr, timestamp, ) if (cached) return cached } const result = loadAndTransform( + environment, id, url, - server, options, timestamp, module, resolved, ) - if (!ssr) { - // Only register client requests, server.waitForRequestsIdle should - // have been called server.waitForClientRequestsIdle. We can rename - // it as part of the environment API work - const depsOptimizer = getDepsOptimizer(config, ssr) - if (!depsOptimizer?.isOptimizedDepFile(id)) { - server._registerRequestProcessing(id, () => result) - } + const { depsOptimizer } = environment + if (!depsOptimizer?.isOptimizedDepFile(id)) { + environment._registerRequestProcessing(id, () => result) } return result } async function getCachedTransformResult( + environment: DevEnvironment, url: string, - module: ModuleNode, - server: ViteDevServer, - ssr: boolean, + module: EnvironmentModuleNode, timestamp: number, ) { - const prettyUrl = debugCache ? prettifyUrl(url, server.config.root) : '' + const prettyUrl = debugCache ? prettifyUrl(url, environment.config.root) : '' // tries to handle soft invalidation of the module if available, // returns a boolean true is successful, or false if no handling is needed const softInvalidatedTransformResult = module && - (await handleModuleSoftInvalidation(module, ssr, timestamp, server)) + (await handleModuleSoftInvalidation(environment, module, timestamp)) if (softInvalidatedTransformResult) { debugCache?.(`[memory-hmr] ${prettyUrl}`) return softInvalidatedTransformResult } // check if we have a fresh cache - const cached = - module && (ssr ? module.ssrTransformResult : module.transformResult) + const cached = module?.transformResult if (cached) { debugCache?.(`[memory] ${prettyUrl}`) return cached @@ -225,29 +232,30 @@ async function getCachedTransformResult( } async function loadAndTransform( + environment: DevEnvironment, id: string, url: string, - server: ViteDevServer, options: TransformOptions, timestamp: number, - mod?: ModuleNode, + mod?: EnvironmentModuleNode, resolved?: PartialResolvedId, ) { - const { config, pluginContainer, moduleGraph } = server - const { logger } = config + const { config, pluginContainer, logger } = environment const prettyUrl = debugLoad || debugTransform ? prettifyUrl(url, config.root) : '' - const ssr = !!options.ssr - const file = cleanUrl(id) + const moduleGraph = environment.moduleGraph let code: string | null = null let map: SourceDescription['map'] = null // load const loadStart = debugLoad ? performance.now() : 0 - const loadResult = await pluginContainer.load(id, { ssr }) + const loadResult = await pluginContainer.load(id) + if (loadResult == null) { + const file = cleanUrl(id) + // if this is an html request and there is no load result, skip ahead to // SPA fallback. if (options.html && !id.endsWith('.html')) { @@ -258,7 +266,10 @@ async function loadAndTransform( // as string // only try the fallback if access is allowed, skip for out of root url // like /service-worker.js or /api/users - if (options.ssr || isFileServingAllowed(file, server)) { + if ( + environment.config.consumer === 'server' || + isFileLoadingAllowed(environment.getTopLevelConfig(), file) + ) { try { code = await fsp.readFile(file, 'utf-8') debugLoad?.(`${timeFrom(loadStart)} [fs] ${prettyUrl}`) @@ -270,8 +281,12 @@ async function loadAndTransform( throw e } } - if (code != null) { - ensureWatchedFile(server.watcher, file, config.root) + if (code != null && environment.pluginContainer.watcher) { + ensureWatchedFile( + environment.pluginContainer.watcher, + file, + config.root, + ) } } if (code) { @@ -297,7 +312,7 @@ async function loadAndTransform( } } if (code == null) { - const isPublicFile = checkPublicFile(url, config) + const isPublicFile = checkPublicFile(url, environment.getTopLevelConfig()) let publicDirName = path.relative(config.root, config.publicDir) if (publicDirName[0] !== '.') publicDirName = '/' + publicDirName const msg = isPublicFile @@ -306,10 +321,8 @@ async function loadAndTransform( `should not be imported from source code. It can only be referenced ` + `via HTML tags.` : `Does the file exist?` - const importerMod: ModuleNode | undefined = server.moduleGraph.idToModuleMap - .get(id) - ?.importers.values() - .next().value + const importerMod: EnvironmentModuleNode | undefined = + moduleGraph.idToModuleMap.get(id)?.importers.values().next().value const importer = importerMod?.file || importerMod?.url const err: any = new Error( `Failed to load url ${url} (resolved id: ${id})${ @@ -320,16 +333,16 @@ async function loadAndTransform( throw err } - if (server._restartPromise && !ssr) throwClosedServerError() + if (environment._closing && environment.config.dev.recoverable) + throwClosedServerError() // ensure module in graph after successful load - mod ??= await moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved) + mod ??= await moduleGraph._ensureEntryFromUrl(url, undefined, resolved) // transform const transformStart = debugTransform ? performance.now() : 0 const transformResult = await pluginContainer.transform(code, id, { inMap: map, - ssr, }) const originalCode = code if ( @@ -392,21 +405,27 @@ async function loadAndTransform( } } - if (server._restartPromise && !ssr) throwClosedServerError() + if (environment._closing && environment.config.dev.recoverable) + throwClosedServerError() - const result = - ssr && !server.config.experimental.skipSsrTransform - ? await server.ssrTransform(code, normalizedMap, url, originalCode) - : ({ - code, - map: normalizedMap, - etag: getEtag(code, { weak: true }), - } satisfies TransformResult) + const result = environment.config.dev.moduleRunnerTransform + ? await ssrTransform( + code, + normalizedMap, + url, + originalCode, + environment.getTopLevelConfig(), + ) + : ({ + code, + map: normalizedMap, + etag: getEtag(code, { weak: true }), + } satisfies TransformResult) // Only cache the result if the module wasn't invalidated while it was // being processed, so it is re-processed next time if it is stale if (timestamp > mod.lastInvalidationTimestamp) - moduleGraph.updateModuleTransformResult(mod, result, ssr) + moduleGraph.updateModuleTransformResult(mod, result) return result } @@ -419,21 +438,19 @@ async function loadAndTransform( * - SSR: We don't need to change anything as `ssrLoadModule` controls it */ async function handleModuleSoftInvalidation( - mod: ModuleNode, - ssr: boolean, + environment: DevEnvironment, + mod: EnvironmentModuleNode, timestamp: number, - server: ViteDevServer, ) { - const transformResult = ssr ? mod.ssrInvalidationState : mod.invalidationState + const transformResult = mod.invalidationState // Reset invalidation state - if (ssr) mod.ssrInvalidationState = undefined - else mod.invalidationState = undefined + mod.invalidationState = undefined // Skip if not soft-invalidated if (!transformResult || transformResult === 'HARD_INVALIDATED') return - if (ssr ? mod.ssrTransformResult : mod.transformResult) { + if (mod.transformResult) { throw new Error( `Internal server error: Soft-invalidated module "${mod.url}" should not have existing transform result`, ) @@ -441,10 +458,10 @@ async function handleModuleSoftInvalidation( let result: TransformResult // For SSR soft-invalidation, no transformation is needed - if (ssr) { + if (transformResult.ssr) { result = transformResult } - // For client soft-invalidation, we need to transform each imports with new timestamps if available + // We need to transform each imports with new timestamps if available else { await init const source = transformResult.code @@ -463,9 +480,12 @@ async function handleModuleSoftInvalidation( const urlWithoutTimestamp = removeTimestampQuery(rawUrl) // hmrUrl must be derived the same way as importAnalysis const hmrUrl = unwrapId( - stripBase(removeImportQuery(urlWithoutTimestamp), server.config.base), + stripBase( + removeImportQuery(urlWithoutTimestamp), + environment.config.base, + ), ) - for (const importedMod of mod.clientImportedModules) { + for (const importedMod of mod.importedModules) { if (importedMod.url !== hmrUrl) continue if (importedMod.lastHMRTimestamp > 0) { const replacedUrl = injectQuery( @@ -477,9 +497,9 @@ async function handleModuleSoftInvalidation( s.overwrite(start, end, replacedUrl) } - if (imp.d === -1 && server.config.server.preTransformRequests) { + if (imp.d === -1 && environment.config.dev.preTransformRequests) { // pre-transform known direct imports - server.warmupRequest(hmrUrl, { ssr }) + environment.warmupRequest(hmrUrl) } break @@ -499,7 +519,7 @@ async function handleModuleSoftInvalidation( // Only cache the result if the module wasn't invalidated while it was // being processed, so it is re-processed next time if it is stale if (timestamp > mod.lastInvalidationTimestamp) - server.moduleGraph.updateModuleTransformResult(mod, result, ssr) + environment.moduleGraph.updateModuleTransformResult(mod, result) return result } diff --git a/packages/vite/src/node/server/warmup.ts b/packages/vite/src/node/server/warmup.ts index 33af7bb2a599a3..9ef04c95af0610 100644 --- a/packages/vite/src/node/server/warmup.ts +++ b/packages/vite/src/node/server/warmup.ts @@ -5,28 +5,24 @@ import colors from 'picocolors' import { FS_PREFIX } from '../constants' import { normalizePath } from '../utils' import type { ViteDevServer } from '../index' +import type { DevEnvironment } from './environment' export function warmupFiles(server: ViteDevServer): void { - const options = server.config.server.warmup - const root = server.config.root - - if (options?.clientFiles?.length) { - mapFiles(options.clientFiles, root).then((files) => { - for (const file of files) { - warmupFile(server, file, false) - } - }) - } - if (options?.ssrFiles?.length) { - mapFiles(options.ssrFiles, root).then((files) => { + const { root } = server.config + for (const environment of Object.values(server.environments)) { + mapFiles(environment.config.dev.warmup, root).then((files) => { for (const file of files) { - warmupFile(server, file, true) + warmupFile(server, environment, file) } }) } } -async function warmupFile(server: ViteDevServer, file: string, ssr: boolean) { +async function warmupFile( + server: ViteDevServer, + environment: DevEnvironment, + file: string, +) { // transform html with the `transformIndexHtml` hook as Vite internals would // pre-transform the imported JS modules linked. this may cause `transformIndexHtml` // plugins to be executed twice, but that's probably fine. @@ -38,7 +34,7 @@ async function warmupFile(server: ViteDevServer, file: string, ssr: boolean) { await server.transformIndexHtml(url, html) } catch (e) { // Unexpected error, log the issue but avoid an unhandled exception - server.config.logger.error( + environment.logger.error( `Pre-transform error (${colors.cyan(file)}): ${e.message}`, { error: e, @@ -51,7 +47,7 @@ async function warmupFile(server: ViteDevServer, file: string, ssr: boolean) { // for other files, pass it through `transformRequest` with warmup else { const url = fileToUrl(file, server.config.root) - await server.warmupRequest(url, { ssr }) + await environment.warmupRequest(url) } } diff --git a/packages/vite/src/node/server/ws.ts b/packages/vite/src/node/server/ws.ts index d0bffcdce4f8a0..86e579cce032a0 100644 --- a/packages/vite/src/node/server/ws.ts +++ b/packages/vite/src/node/server/ws.ts @@ -9,11 +9,11 @@ import colors from 'picocolors' import type { WebSocket as WebSocketRaw } from 'ws' import { WebSocketServer as WebSocketServerRaw_ } from 'ws' import type { WebSocket as WebSocketTypes } from 'dep-types/ws' -import type { CustomPayload, ErrorPayload, HMRPayload } from 'types/hmrPayload' +import type { ErrorPayload, HotPayload } from 'types/hmrPayload' import type { InferCustomEventPayload } from 'types/customEvent' -import type { ResolvedConfig } from '..' +import type { HotChannelClient, ResolvedConfig } from '..' import { isObject } from '../utils' -import type { HMRChannel } from './hmr' +import type { HotChannel } from './hmr' import type { HttpServer } from '.' /* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version @@ -31,7 +31,7 @@ export type WebSocketCustomListener = ( client: WebSocketClient, ) => void -export interface WebSocketServer extends HMRChannel { +export interface WebSocketServer extends HotChannel { /** * Listen on port and host */ @@ -61,15 +61,7 @@ export interface WebSocketServer extends HMRChannel { } } -export interface WebSocketClient { - /** - * Send event to the client - */ - send(payload: HMRPayload): void - /** - * Send custom event - */ - send(event: string, payload?: CustomPayload['data']): void +export interface WebSocketClient extends HotChannelClient { /** * The raw WebSocket instance * @advanced @@ -96,7 +88,6 @@ export function createWebSocketServer( ): WebSocketServer { if (config.server.ws === false) { return { - name: 'ws', get clients() { return new Set() }, @@ -218,7 +209,7 @@ export function createWebSocketServer( if (!clientsMap.has(socket)) { clientsMap.set(socket, { send: (...args) => { - let payload: HMRPayload + let payload: HotPayload if (typeof args[0] === 'string') { payload = { type: 'custom', @@ -243,7 +234,6 @@ export function createWebSocketServer( let bufferedError: ErrorPayload | null = null return { - name: 'ws', listen: () => { wsHttpServer?.listen(port, host) }, @@ -269,7 +259,7 @@ export function createWebSocketServer( }, send(...args: any[]) { - let payload: HMRPayload + let payload: HotPayload if (typeof args[0] === 'string') { payload = { type: 'custom', diff --git a/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts b/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts index a7ae119f0714f8..b5bae3ae1745f7 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts +++ b/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts @@ -63,6 +63,110 @@ test('import.meta.filename/dirname returns same value with Node', async () => { expect(viteValue.filename).toBe(filename) }) +test('virtual module invalidation simple', async () => { + const server = await createServer({ + configFile: false, + root, + logLevel: 'silent', + optimizeDeps: { + noDiscovery: true, + }, + plugins: [ + { + name: 'virtual-test', + resolveId(id) { + if (id === 'virtual:test') { + return '\0virtual:test' + } + }, + load(id) { + if (id === '\0virtual:test') { + return ` + globalThis.__virtual_test_state ??= 0; + globalThis.__virtual_test_state++; + export default globalThis.__virtual_test_state; + ` + } + }, + }, + ], + }) + await server.pluginContainer.buildStart({}) + + const mod1 = await server.ssrLoadModule('virtual:test') + expect(mod1.default).toEqual(1) + const mod2 = await server.ssrLoadModule('virtual:test') + expect(mod2.default).toEqual(1) + + const modNode = server.moduleGraph.getModuleById('\0virtual:test') + server.moduleGraph.invalidateModule(modNode!) + + const mod3 = await server.ssrLoadModule('virtual:test') + expect(mod3.default).toEqual(2) +}) + +test('virtual module invalidation nested', async () => { + const server = await createServer({ + configFile: false, + root, + logLevel: 'silent', + optimizeDeps: { + noDiscovery: true, + }, + plugins: [ + { + name: 'test-virtual', + resolveId(id) { + if (id === 'virtual:test') { + return '\0virtual:test' + } + }, + load(id) { + if (id === '\0virtual:test') { + return ` + import testDep from "virtual:test-dep"; + export default testDep; + ` + } + }, + }, + { + name: 'test-virtual-dep', + resolveId(id) { + if (id === 'virtual:test-dep') { + return '\0virtual:test-dep' + } + }, + load(id) { + if (id === '\0virtual:test-dep') { + return ` + globalThis.__virtual_test_state2 ??= 0; + globalThis.__virtual_test_state2++; + export default globalThis.__virtual_test_state2; + ` + } + }, + }, + ], + }) + await server.pluginContainer.buildStart({}) + + const mod1 = await server.ssrLoadModule('virtual:test') + expect(mod1.default).toEqual(1) + const mod2 = await server.ssrLoadModule('virtual:test') + expect(mod2.default).toEqual(1) + + server.moduleGraph.invalidateModule( + server.moduleGraph.getModuleById('\0virtual:test')!, + ) + server.moduleGraph.invalidateModule( + server.moduleGraph.getModuleById('\0virtual:test-dep')!, + ) + + const mod3 = await server.ssrLoadModule('virtual:test') + expect(mod3.default).toEqual(2) +}) + test('can export global', async () => { const server = await createDevServer() const mod = await server.ssrLoadModule('/fixtures/global/export.js') diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 60c0cb0a416b57..c938c256dd4b11 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -1,27 +1,29 @@ import { pathToFileURL } from 'node:url' -import type { ModuleNode, TransformResult, ViteDevServer } from '..' -import type { InternalResolveOptionsWithOverrideConditions } from '../plugins/resolve' +import type { FetchResult } from 'vite/module-runner' +import type { EnvironmentModuleNode, TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' -import type { FetchResult } from '../../runtime/types' import { unwrapId } from '../../shared/utils' import { + MODULE_RUNNER_SOURCEMAPPING_SOURCE, SOURCEMAPPING_URL, - VITE_RUNTIME_SOURCEMAPPING_SOURCE, } from '../../shared/constants' import { genSourceMapUrl } from '../server/sourcemap' +import type { DevEnvironment } from '../server/environment' +import { normalizeResolvedIdToUrl } from '../plugins/importAnalysis' export interface FetchModuleOptions { + cached?: boolean inlineSourceMap?: boolean processSourceMap?>(map: T): T } /** - * Fetch module information for Vite runtime. + * Fetch module information for Vite runner. * @experimental */ export async function fetchModule( - server: ViteDevServer, + environment: DevEnvironment, url: string, importer?: string, options: FetchModuleOptions = {}, @@ -35,34 +37,37 @@ export async function fetchModule( return { externalize: url, type: 'network' } } - if (url[0] !== '.' && url[0] !== '/') { - const { - isProduction, - resolve: { dedupe, preserveSymlinks }, - root, - ssr, - } = server.config - const overrideConditions = ssr.resolve?.externalConditions || [] - - const resolveOptions: InternalResolveOptionsWithOverrideConditions = { - mainFields: ['main'], - conditions: [], - overrideConditions: [...overrideConditions, 'production', 'development'], - extensions: ['.js', '.cjs', '.json'], - dedupe, - preserveSymlinks, - isBuild: false, - isProduction, - root, - ssrConfig: ssr, - packageCache: server.config.packageCache, - } + // if there is no importer, the file is an entry point + // entry points are always internalized + if (importer && url[0] !== '.' && url[0] !== '/') { + const { isProduction, root } = environment.config + const { externalConditions, dedupe, preserveSymlinks } = + environment.config.resolve const resolved = tryNodeResolve( url, importer, - { ...resolveOptions, tryEsmOnly: true }, - false, + { + mainFields: ['main'], + conditions: [], + externalConditions, + external: [], + noExternal: [], + overrideConditions: [ + ...externalConditions, + 'production', + 'development', + ], + extensions: ['.js', '.cjs', '.json'], + dedupe, + preserveSymlinks, + isBuild: false, + isProduction, + root, + packageCache: environment.config.packageCache, + tryEsmOnly: true, + webCompatible: environment.config.webCompatible, + }, undefined, true, ) @@ -74,15 +79,33 @@ export async function fetchModule( throw err } const file = pathToFileURL(resolved.id).toString() - const type = isFilePathESM(resolved.id, server.config.packageCache) + const type = isFilePathESM(resolved.id, environment.config.packageCache) ? 'module' : 'commonjs' return { externalize: file, type } } + // this is an entry point module, very high chance it's not resolved yet + // for example: runner.import('./some-file') or runner.import('/some-file') + if (!importer) { + const resolved = await environment.pluginContainer.resolveId(url) + if (!resolved) { + throw new Error(`[vite] cannot find entry point module '${url}'.`) + } + url = normalizeResolvedIdToUrl(environment, url, resolved) + } + url = unwrapId(url) - let result = await server.transformRequest(url, { ssr: true }) + let mod = await environment.moduleGraph.getModuleByUrl(url) + const cached = !!mod?.transformResult + + // if url is already cached, we can just confirm it's also cached on the server + if (options.cached && cached) { + return { cache: true } + } + + let result = await environment.transformRequest(url) if (!result) { throw new Error( @@ -93,7 +116,7 @@ export async function fetchModule( } // module entry should be created by transformRequest - const mod = await server.moduleGraph.getModuleByUrl(url, true) + mod ??= await environment.moduleGraph.getModuleByUrl(url) if (!mod) { throw new Error( @@ -111,7 +134,11 @@ export async function fetchModule( if (result.code[0] === '#') result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length)) - return { code: result.code, file: mod.file } + return { + code: result.code, + file: mod.file, + invalidate: !cached, + } } const OTHER_SOURCE_MAP_REGEXP = new RegExp( @@ -120,7 +147,7 @@ const OTHER_SOURCE_MAP_REGEXP = new RegExp( ) function inlineSourceMap( - mod: ModuleNode, + mod: EnvironmentModuleNode, result: TransformResult, processSourceMap?: FetchModuleOptions['processSourceMap'], ) { @@ -130,7 +157,7 @@ function inlineSourceMap( if ( !map || !('version' in map) || - code.includes(VITE_RUNTIME_SOURCEMAPPING_SOURCE) + code.includes(MODULE_RUNNER_SOURCEMAPPING_SOURCE) ) return result @@ -142,7 +169,7 @@ function inlineSourceMap( const sourceMap = processSourceMap?.(map) || map result.code = `${code.trimEnd()}\n//# sourceURL=${ mod.id - }\n${VITE_RUNTIME_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` + }\n${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` return result } diff --git a/packages/vite/src/node/ssr/index.ts b/packages/vite/src/node/ssr/index.ts index 3847e69544b2c0..975aa26aaa3976 100644 --- a/packages/vite/src/node/ssr/index.ts +++ b/packages/vite/src/node/ssr/index.ts @@ -2,7 +2,7 @@ import type { DepOptimizationConfig } from '../optimizer' export type SSRTarget = 'node' | 'webworker' -export type SsrDepOptimizationOptions = DepOptimizationConfig +export type SsrDepOptimizationConfig = DepOptimizationConfig export interface SSROptions { noExternal?: string | RegExp | (string | RegExp)[] | true @@ -11,6 +11,7 @@ export interface SSROptions { /** * Define the target for the ssr build. The browser field in package.json * is ignored for node but used if webworker is the target + * This option may be replaced by the experimental `environmentOptions.webCompatible` * @default 'node' */ target?: SSRTarget @@ -23,7 +24,7 @@ export interface SSROptions { * explicit no external CJS dependencies are optimized by default * @experimental */ - optimizeDeps?: SsrDepOptimizationOptions + optimizeDeps?: SsrDepOptimizationConfig resolve?: { /** @@ -46,7 +47,7 @@ export interface SSROptions { export interface ResolvedSSROptions extends SSROptions { target: SSRTarget - optimizeDeps: SsrDepOptimizationOptions + optimizeDeps: SsrDepOptimizationConfig } export function resolveSSROptions( diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/action.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/action.js new file mode 100644 index 00000000000000..15f7c23c2af730 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/action.js @@ -0,0 +1 @@ +export function someAction() {} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/entry-cyclic.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/entry-cyclic.js new file mode 100644 index 00000000000000..207f75296100d3 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/entry-cyclic.js @@ -0,0 +1,3 @@ +export default async function main() { + await import("./entry.js"); +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/entry.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/entry.js new file mode 100644 index 00000000000000..b8d413ef235e88 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic/entry.js @@ -0,0 +1,8 @@ +export async function setupCyclic() { + const mod = await import("./entry-cyclic.js"); + await mod.default(); +} + +export async function importAction(id) { + return await import(/* @vite-ignore */ id); +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/default-string.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/default-string.ts new file mode 100644 index 00000000000000..6b473bf83e5380 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/default-string.ts @@ -0,0 +1,3 @@ +const str: string = 'hello world' + +export default str diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js index b46e31ccb40e2e..457a1b723bf7ce 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js @@ -1,14 +1,16 @@ -import * as staticModule from './basic' +import path from 'node:path' +import * as staticModule from './simple' export const initialize = async () => { - const nameRelative = './basic' - const nameAbsolute = '/fixtures/basic' - const nameAbsoluteExtension = '/fixtures/basic.js' + const nameRelative = './simple' + const nameAbsolute = '/fixtures/simple' + const nameAbsoluteExtension = '/fixtures/simple.js' return { - dynamicProcessed: await import('./basic'), + dynamicProcessed: await import('./simple'), dynamicRelative: await import(nameRelative), dynamicAbsolute: await import(nameAbsolute), dynamicAbsoluteExtension: await import(nameAbsoluteExtension), + dynamicAbsoluteFull: await import(path.join(import.meta.dirname, "simple.js")), static: staticModule, } } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs new file mode 100644 index 00000000000000..bc617d0300e69d --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/worker.mjs @@ -0,0 +1,35 @@ +// @ts-check + +import { BroadcastChannel, parentPort } from 'node:worker_threads' +import { fileURLToPath } from 'node:url' +import { ESModulesEvaluator, ModuleRunner, RemoteRunnerTransport } from 'vite/module-runner' + +if (!parentPort) { + throw new Error('File "worker.js" must be run in a worker thread') +} + +const runner = new ModuleRunner( + { + root: fileURLToPath(new URL('./', import.meta.url)), + transport: new RemoteRunnerTransport({ + onMessage: listener => { + parentPort?.on('message', listener) + }, + send: message => { + parentPort?.postMessage(message) + } + }) + }, + new ESModulesEvaluator(), +) + +const channel = new BroadcastChannel('vite-worker') +channel.onmessage = async (message) => { + try { + const mod = await runner.import(message.data.id) + channel.postMessage({ result: mod.default }) + } catch (e) { + channel.postMessage({ error: e.stack }) + } +} +parentPort.postMessage('ready') \ No newline at end of file diff --git a/packages/vite/src/node/ssr/runtime/__tests__/package.json b/packages/vite/src/node/ssr/runtime/__tests__/package.json index 89fe86abc39d19..40a971f043f8a9 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/package.json +++ b/packages/vite/src/node/ssr/runtime/__tests__/package.json @@ -2,6 +2,9 @@ "name": "@vitejs/unit-runtime", "private": true, "version": "0.0.0", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, "dependencies": { "@vitejs/cjs-external": "link:./fixtures/cjs-external", "@vitejs/esm-external": "link:./fixtures/esm-external", diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts index ccc822f543cefc..997df1f12095b7 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts @@ -1,39 +1,39 @@ import { describe, expect } from 'vitest' -import { createViteRuntimeTester } from './utils' +import { createModuleRunnerTester } from './utils' describe( - 'vite-runtime hmr works as expected', + 'module runner hmr works as expected', async () => { - const it = await createViteRuntimeTester({ + const it = await createModuleRunnerTester({ server: { // override watch options because it's disabled by default watch: {}, }, }) - it('hmr options are defined', async ({ runtime }) => { - expect(runtime.hmrClient).toBeDefined() + it('hmr options are defined', async ({ runner }) => { + expect(runner.hmrClient).toBeDefined() - const mod = await runtime.executeUrl('/fixtures/hmr.js') + const mod = await runner.import('/fixtures/hmr.js') expect(mod).toHaveProperty('hmr') expect(mod.hmr).toHaveProperty('accept') }) - it('correctly populates hmr client', async ({ runtime }) => { - const mod = await runtime.executeUrl('/fixtures/d') + it('correctly populates hmr client', async ({ runner }) => { + const mod = await runner.import('/fixtures/d') expect(mod.d).toBe('a') const fixtureC = '/fixtures/c.ts' const fixtureD = '/fixtures/d.ts' - expect(runtime.hmrClient!.hotModulesMap.size).toBe(2) - expect(runtime.hmrClient!.dataMap.size).toBe(2) - expect(runtime.hmrClient!.ctxToListenersMap.size).toBe(2) + expect(runner.hmrClient!.hotModulesMap.size).toBe(2) + expect(runner.hmrClient!.dataMap.size).toBe(2) + expect(runner.hmrClient!.ctxToListenersMap.size).toBe(2) for (const fixture of [fixtureC, fixtureD]) { - expect(runtime.hmrClient!.hotModulesMap.has(fixture)).toBe(true) - expect(runtime.hmrClient!.dataMap.has(fixture)).toBe(true) - expect(runtime.hmrClient!.ctxToListenersMap.has(fixture)).toBe(true) + expect(runner.hmrClient!.hotModulesMap.has(fixture)).toBe(true) + expect(runner.hmrClient!.dataMap.has(fixture)).toBe(true) + expect(runner.hmrClient!.ctxToListenersMap.has(fixture)).toBe(true) } }) }, diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts index ea2816756c927f..d4cf03c756c565 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts @@ -1,8 +1,8 @@ import { describe, expect } from 'vitest' -import { createViteRuntimeTester } from './utils' +import { createModuleRunnerTester } from './utils' -describe('vite-runtime hmr works as expected', async () => { - const it = await createViteRuntimeTester({ +describe('module runner hmr works as expected', async () => { + const it = await createModuleRunnerTester({ server: { // override watch options because it's disabled by default watch: {}, @@ -10,10 +10,10 @@ describe('vite-runtime hmr works as expected', async () => { }, }) - it("hmr client is not defined if it's disabled", async ({ runtime }) => { - expect(runtime.hmrClient).toBeUndefined() + it("hmr client is not defined if it's disabled", async ({ runner }) => { + expect(runner.hmrClient).toBeUndefined() - const mod = await runtime.executeUrl('/fixtures/hmr.js') + const mod = await runner.import('/fixtures/hmr.js') expect(mod).toHaveProperty('hmr') expect(mod.hmr).toBeUndefined() }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index bcf06bb91d4005..909af83cff2ba6 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -3,42 +3,42 @@ import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect } from 'vitest' import { isWindows } from '../../../../shared/utils' -import { createViteRuntimeTester } from './utils' +import { createModuleRunnerTester } from './utils' const _URL = URL -describe('vite-runtime initialization', async () => { - const it = await createViteRuntimeTester() +describe('module runner initialization', async () => { + const it = await createModuleRunnerTester() - it('correctly runs ssr code', async ({ runtime }) => { - const mod = await runtime.executeUrl('/fixtures/simple.js') + it('correctly runs ssr code', async ({ runner }) => { + const mod = await runner.import('/fixtures/simple.js') expect(mod.test).toEqual('I am initialized') // loads the same module if id is a file url const fileUrl = new _URL('./fixtures/simple.js', import.meta.url) - const mod2 = await runtime.executeUrl(fileUrl.toString()) + const mod2 = await runner.import(fileUrl.toString()) expect(mod).toBe(mod2) // loads the same module if id is a file path const filePath = fileURLToPath(fileUrl) - const mod3 = await runtime.executeUrl(filePath) + const mod3 = await runner.import(filePath) expect(mod).toBe(mod3) }) - it('can load virtual modules as an entry point', async ({ runtime }) => { - const mod = await runtime.executeEntrypoint('virtual:test') + it('can load virtual modules as an entry point', async ({ runner }) => { + const mod = await runner.import('virtual:test') expect(mod.msg).toBe('virtual') }) - it('css is loaded correctly', async ({ runtime }) => { - const css = await runtime.executeUrl('/fixtures/test.css') + it('css is loaded correctly', async ({ runner }) => { + const css = await runner.import('/fixtures/test.css') expect(css.default).toMatchInlineSnapshot(` ".test { color: red; } " `) - const module = await runtime.executeUrl('/fixtures/test.module.css') + const module = await runner.import('/fixtures/test.module.css') expect(module).toMatchObject({ default: { test: expect.stringMatching(/^_test_/), @@ -47,8 +47,8 @@ describe('vite-runtime initialization', async () => { }) }) - it('assets are loaded correctly', async ({ runtime }) => { - const assets = await runtime.executeUrl('/fixtures/assets.js') + it('assets are loaded correctly', async ({ runner }) => { + const assets = await runner.import('/fixtures/assets.js') expect(assets).toMatchObject({ mov: '/fixtures/assets/placeholder.mov', txt: '/fixtures/assets/placeholder.txt', @@ -57,17 +57,17 @@ describe('vite-runtime initialization', async () => { }) }) - it('ids with Vite queries are loaded correctly', async ({ runtime }) => { - const raw = await runtime.executeUrl('/fixtures/simple.js?raw') + it('ids with Vite queries are loaded correctly', async ({ runner }) => { + const raw = await runner.import('/fixtures/simple.js?raw') expect(raw.default).toMatchInlineSnapshot(` "export const test = 'I am initialized' import.meta.hot?.accept() " `) - const url = await runtime.executeUrl('/fixtures/simple.js?url') + const url = await runner.import('/fixtures/simple.js?url') expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) - const inline = await runtime.executeUrl('/fixtures/test.css?inline') + const inline = await runner.import('/fixtures/test.css?inline') expect(inline.default).toMatchInlineSnapshot(` ".test { color: red; @@ -77,16 +77,16 @@ describe('vite-runtime initialization', async () => { }) it('modules with query strings are treated as different modules', async ({ - runtime, + runner, }) => { - const modSimple = await runtime.executeUrl('/fixtures/simple.js') - const modUrl = await runtime.executeUrl('/fixtures/simple.js?url') + const modSimple = await runner.import('/fixtures/simple.js') + const modUrl = await runner.import('/fixtures/simple.js?url') expect(modSimple).not.toBe(modUrl) expect(modUrl.default).toBe('/fixtures/simple.js') }) - it('exports is not modifiable', async ({ runtime }) => { - const mod = await runtime.executeUrl('/fixtures/simple.js') + it('exports is not modifiable', async ({ runner }) => { + const mod = await runner.import('/fixtures/simple.js') expect(Object.isSealed(mod)).toBe(true) expect(() => { mod.test = 'I am modified' @@ -110,11 +110,11 @@ describe('vite-runtime initialization', async () => { ) }) - it('throws the same error', async ({ runtime }) => { + it('throws the same error', async ({ runner }) => { expect.assertions(3) const s = Symbol() try { - await runtime.executeUrl('/fixtures/has-error.js') + await runner.import('/fixtures/has-error.js') } catch (e) { expect(e[s]).toBeUndefined() e[s] = true @@ -122,16 +122,15 @@ describe('vite-runtime initialization', async () => { } try { - await runtime.executeUrl('/fixtures/has-error.js') + await runner.import('/fixtures/has-error.js') } catch (e) { expect(e[s]).toBe(true) } }) - it('importing external cjs library checks exports', async ({ runtime }) => { - await expect(() => - runtime.executeUrl('/fixtures/cjs-external-non-existing.js'), - ).rejects.toThrowErrorMatchingInlineSnapshot(` + it('importing external cjs library checks exports', async ({ runner }) => { + await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) + .rejects.toThrowErrorMatchingInlineSnapshot(` [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. CommonJS modules can always be imported via the default export, for example using: @@ -141,45 +140,46 @@ describe('vite-runtime initialization', async () => { `) // subsequent imports of the same external package should not throw if imports are correct await expect( - runtime.executeUrl('/fixtures/cjs-external-existing.js'), + runner.import('/fixtures/cjs-external-existing.js'), ).resolves.toMatchObject({ result: 'world', }) }) - it('importing external esm library checks exports', async ({ runtime }) => { + it('importing external esm library checks exports', async ({ runner }) => { await expect(() => - runtime.executeUrl('/fixtures/esm-external-non-existing.js'), + runner.import('/fixtures/esm-external-non-existing.js'), ).rejects.toThrowErrorMatchingInlineSnapshot( `[SyntaxError: [vite] The requested module '@vitejs/esm-external' does not provide an export named 'nonExisting']`, ) // subsequent imports of the same external package should not throw if imports are correct await expect( - runtime.executeUrl('/fixtures/esm-external-existing.js'), + runner.import('/fixtures/esm-external-existing.js'), ).resolves.toMatchObject({ result: 'world', }) }) - it("dynamic import doesn't produce duplicates", async ({ runtime }) => { - const mod = await runtime.executeUrl('/fixtures/dynamic-import.js') + it("dynamic import doesn't produce duplicates", async ({ runner }) => { + const mod = await runner.import('/fixtures/dynamic-import.js') const modules = await mod.initialize() - // toBe checks that objects are actually the same, not just structually - // using toEqual here would be a mistake because it chesk the structural difference + // toBe checks that objects are actually the same, not just structurally + // using toEqual here would be a mistake because it check the structural difference expect(modules.static).toBe(modules.dynamicProcessed) expect(modules.static).toBe(modules.dynamicRelative) expect(modules.static).toBe(modules.dynamicAbsolute) expect(modules.static).toBe(modules.dynamicAbsoluteExtension) + expect(modules.static).toBe(modules.dynamicAbsoluteFull) }) - it('correctly imports a virtual module', async ({ runtime }) => { - const mod = await runtime.executeUrl('/fixtures/virtual.js') + it('correctly imports a virtual module', async ({ runner }) => { + const mod = await runner.import('/fixtures/virtual.js') expect(mod.msg0).toBe('virtual0') expect(mod.msg).toBe('virtual') }) - it('importing package from node_modules', async ({ runtime }) => { - const mod = (await runtime.executeUrl( + it('importing package from node_modules', async ({ runner }) => { + const mod = (await runner.import( '/fixtures/installed.js', )) as typeof import('tinyspy') const fn = mod.spy() @@ -187,17 +187,14 @@ describe('vite-runtime initialization', async () => { expect(fn.called).toBe(true) }) - it('importing native node package', async ({ runtime }) => { - const mod = await runtime.executeUrl('/fixtures/native.js') + it('importing native node package', async ({ runner }) => { + const mod = await runner.import('/fixtures/native.js') expect(mod.readdirSync).toBe(readdirSync) expect(mod.existsSync).toBe(existsSync) }) - it('correctly resolves module url', async ({ runtime, server }) => { - const { meta } = - await runtime.executeUrl( - '/fixtures/basic', - ) + it('correctly resolves module url', async ({ runner, server }) => { + const { meta } = await runner.import('/fixtures/basic') const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() expect(meta.url).toBe(basicUrl) @@ -221,4 +218,16 @@ describe('vite-runtime initialization', async () => { expect(posix.join(root, './fixtures')).toBe(dirname) } }) + + it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ + runner, + }) => { + // entry.js ⇔ entry-cyclic.js + // ⇓ + // action.js + const mod = await runner.import('/fixtures/cyclic/entry') + await mod.setupCyclic() + const action = await mod.importAction('/fixtures/cyclic/action') + expect(action).toBeDefined() + }) }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts index fd8973235af0b6..ece2fc12242753 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -1,9 +1,9 @@ import { describe, expect } from 'vitest' -import type { ViteRuntime } from 'vite/runtime' -import { createViteRuntimeTester, editFile, resolvePath } from './utils' +import type { ModuleRunner } from 'vite/module-runner' +import { createModuleRunnerTester, editFile, resolvePath } from './utils' -describe('vite-runtime initialization', async () => { - const it = await createViteRuntimeTester( +describe('module runner initialization', async () => { + const it = await createModuleRunnerTester( {}, { sourcemapInterceptor: 'prepareStackTrace', @@ -18,32 +18,32 @@ describe('vite-runtime initialization', async () => { return err } } - const serializeStack = (runtime: ViteRuntime, err: Error) => { - return err.stack!.split('\n')[1].replace(runtime.options.root, '') + const serializeStack = (runner: ModuleRunner, err: Error) => { + return err.stack!.split('\n')[1].replace(runner.options.root, '') } - const serializeStackDeep = (runtime: ViteRuntime, err: Error) => { + const serializeStackDeep = (runtime: ModuleRunner, err: Error) => { return err .stack!.split('\n') .map((s) => s.replace(runtime.options.root, '')) } it('source maps are correctly applied to stack traces', async ({ - runtime, + runner, server, }) => { expect.assertions(3) const topLevelError = await getError(() => - runtime.executeUrl('/fixtures/has-error.js'), + runner.import('/fixtures/has-error.js'), ) - expect(serializeStack(runtime, topLevelError)).toBe( + expect(serializeStack(runner, topLevelError)).toBe( ' at /fixtures/has-error.js:2:7', ) const methodError = await getError(async () => { - const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + const mod = await runner.import('/fixtures/throws-error-method.ts') mod.throwError() }) - expect(serializeStack(runtime, methodError)).toBe( + expect(serializeStack(runner, methodError)).toBe( ' at Module.throwError (/fixtures/throws-error-method.ts:6:9)', ) @@ -52,25 +52,25 @@ describe('vite-runtime initialization', async () => { resolvePath(import.meta.url, './fixtures/throws-error-method.ts'), (code) => '\n\n\n\n\n' + code + '\n', ) - runtime.moduleCache.clear() - server.moduleGraph.invalidateAll() + runner.moduleCache.clear() + server.environments.ssr.moduleGraph.invalidateAll() const methodErrorNew = await getError(async () => { - const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + const mod = await runner.import('/fixtures/throws-error-method.ts') mod.throwError() }) - expect(serializeStack(runtime, methodErrorNew)).toBe( + expect(serializeStack(runner, methodErrorNew)).toBe( ' at Module.throwError (/fixtures/throws-error-method.ts:11:9)', ) }) - it('deep stacktrace', async ({ runtime }) => { + it('deep stacktrace', async ({ runner }) => { const methodError = await getError(async () => { - const mod = await runtime.executeUrl('/fixtures/has-error-deep.ts') + const mod = await runner.import('/fixtures/has-error-deep.ts') mod.main() }) - expect(serializeStackDeep(runtime, methodError).slice(0, 3)).toEqual([ + expect(serializeStackDeep(runner, methodError).slice(0, 3)).toEqual([ 'Error: crash', ' at crash (/fixtures/has-error-deep.ts:2:9)', ' at Module.main (/fixtures/has-error-deep.ts:6:3)', diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts new file mode 100644 index 00000000000000..d756d57273bbf4 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts @@ -0,0 +1,68 @@ +import { BroadcastChannel, Worker } from 'node:worker_threads' +import { describe, expect, it, onTestFinished } from 'vitest' +import { DevEnvironment } from '../../../server/environment' +import { createServer } from '../../../server' +import { RemoteEnvironmentTransport } from '../../..' + +describe('running module runner inside a worker', () => { + it('correctly runs ssr code', async () => { + expect.assertions(1) + const worker = new Worker( + new URL('./fixtures/worker.mjs', import.meta.url), + { + stdout: true, + }, + ) + await new Promise((resolve, reject) => { + worker.on('message', () => resolve()) + worker.on('error', reject) + }) + const server = await createServer({ + root: __dirname, + logLevel: 'error', + server: { + middlewareMode: true, + watch: null, + hmr: { + port: 9609, + }, + }, + environments: { + worker: { + dev: { + createEnvironment: (name, config) => { + return new DevEnvironment(name, config, { + runner: { + transport: new RemoteEnvironmentTransport({ + send: (data) => worker.postMessage(data), + onMessage: (handler) => worker.on('message', handler), + }), + }, + hot: false, + }) + }, + }, + }, + }, + }) + onTestFinished(() => { + server.close() + worker.terminate() + }) + const channel = new BroadcastChannel('vite-worker') + return new Promise((resolve, reject) => { + channel.onmessage = (event) => { + try { + expect((event as MessageEvent).data).toEqual({ + result: 'hello world', + }) + } catch (e) { + reject(e) + } finally { + resolve() + } + } + channel.postMessage({ id: './fixtures/default-string.ts' }) + }) + }) +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 5d8c06d4bc3a46..567077d1288864 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -3,21 +3,23 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { TestAPI } from 'vitest' import { afterEach, beforeEach, test } from 'vitest' -import type { ViteRuntime } from 'vite/runtime' -import type { MainThreadRuntimeOptions } from '../mainThreadRuntime' +import type { ModuleRunner } from 'vite/module-runner' +import type { ServerModuleRunnerOptions } from '../serverModuleRunner' import type { ViteDevServer } from '../../../server' import type { InlineConfig } from '../../../config' import { createServer } from '../../../server' -import { createViteRuntime } from '../mainThreadRuntime' +import { createServerModuleRunner } from '../serverModuleRunner' +import type { DevEnvironment } from '../../../server/environment' interface TestClient { server: ViteDevServer - runtime: ViteRuntime + runner: ModuleRunner + environment: DevEnvironment } -export async function createViteRuntimeTester( +export async function createModuleRunnerTester( config: InlineConfig = {}, - runtimeConfig: MainThreadRuntimeOptions = {}, + runnerConfig: ServerModuleRunnerOptions = {}, ): Promise> { function waitForWatcher(server: ViteDevServer) { return new Promise((resolve) => { @@ -71,13 +73,14 @@ export async function createViteRuntimeTester( ], ...config, }) - t.runtime = await createViteRuntime(t.server, { + t.environment = t.server.environments.ssr + t.runner = createServerModuleRunner(t.environment, { hmr: { logger: false, }, // don't override by default so Vitest source maps are correct sourcemapInterceptor: false, - ...runtimeConfig, + ...runnerConfig, }) if (config.server?.watch) { await waitForWatcher(t.server) @@ -85,7 +88,7 @@ export async function createViteRuntimeTester( }) afterEach(async (t) => { - await t.runtime.destroy() + await t.runner.destroy() await t.server.close() }) diff --git a/packages/vite/src/node/ssr/runtime/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/mainThreadRuntime.ts deleted file mode 100644 index cbb8e3d8edfbdd..00000000000000 --- a/packages/vite/src/node/ssr/runtime/mainThreadRuntime.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { ESModulesRunner, ViteRuntime } from 'vite/runtime' -import type { ViteModuleRunner, ViteRuntimeOptions } from 'vite/runtime' -import type { ViteDevServer } from '../../server' -import type { HMRLogger } from '../../../shared/hmr' -import { ServerHMRConnector } from './serverHmrConnector' - -/** - * @experimental - */ -export interface MainThreadRuntimeOptions - extends Omit { - /** - * Disable HMR or configure HMR logger. - */ - hmr?: - | false - | { - logger?: false | HMRLogger - } - /** - * Provide a custom module runner. This controls how the code is executed. - */ - runner?: ViteModuleRunner -} - -function createHMROptions( - server: ViteDevServer, - options: MainThreadRuntimeOptions, -) { - if (server.config.server.hmr === false || options.hmr === false) { - return false - } - const connection = new ServerHMRConnector(server) - return { - connection, - logger: options.hmr?.logger, - } -} - -const prepareStackTrace = { - retrieveFile(id: string) { - if (existsSync(id)) { - return readFileSync(id, 'utf-8') - } - }, -} - -function resolveSourceMapOptions(options: MainThreadRuntimeOptions) { - if (options.sourcemapInterceptor != null) { - if (options.sourcemapInterceptor === 'prepareStackTrace') { - return prepareStackTrace - } - if (typeof options.sourcemapInterceptor === 'object') { - return { ...prepareStackTrace, ...options.sourcemapInterceptor } - } - return options.sourcemapInterceptor - } - if (typeof process !== 'undefined' && 'setSourceMapsEnabled' in process) { - return 'node' - } - return prepareStackTrace -} - -/** - * Create an instance of the Vite SSR runtime that support HMR. - * @experimental - */ -export async function createViteRuntime( - server: ViteDevServer, - options: MainThreadRuntimeOptions = {}, -): Promise { - const hmr = createHMROptions(server, options) - return new ViteRuntime( - { - ...options, - root: server.config.root, - fetchModule: server.ssrFetchModule, - hmr, - sourcemapInterceptor: resolveSourceMapOptions(options), - }, - options.runner || new ESModulesRunner(), - ) -} diff --git a/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts b/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts index b8bed32a8733c2..00ada7d14e6c83 100644 --- a/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts +++ b/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts @@ -1,13 +1,12 @@ -import type { CustomPayload, HMRPayload } from 'types/hmrPayload' -import type { HMRRuntimeConnection } from 'vite/runtime' -import type { ViteDevServer } from '../../server' -import type { HMRBroadcasterClient, ServerHMRChannel } from '../../server/hmr' +import type { CustomPayload, HotPayload } from 'types/hmrPayload' +import type { ModuleRunnerHMRConnection } from 'vite/module-runner' +import type { HotChannelClient, ServerHotChannel } from '../../server/hmr' -class ServerHMRBroadcasterClient implements HMRBroadcasterClient { - constructor(private readonly hmrChannel: ServerHMRChannel) {} +class ServerHMRBroadcasterClient implements HotChannelClient { + constructor(private readonly hotChannel: ServerHotChannel) {} send(...args: any[]) { - let payload: HMRPayload + let payload: HotPayload if (typeof args[0] === 'string') { payload = { type: 'custom', @@ -22,7 +21,7 @@ class ServerHMRBroadcasterClient implements HMRBroadcasterClient { 'Cannot send non-custom events from the client to the server.', ) } - this.hmrChannel.send(payload) + this.hotChannel.send(payload) } } @@ -30,27 +29,18 @@ class ServerHMRBroadcasterClient implements HMRBroadcasterClient { * The connector class to establish HMR communication between the server and the Vite runtime. * @experimental */ -export class ServerHMRConnector implements HMRRuntimeConnection { - private handlers: ((payload: HMRPayload) => void)[] = [] - private hmrChannel: ServerHMRChannel +export class ServerHMRConnector implements ModuleRunnerHMRConnection { + private handlers: ((payload: HotPayload) => void)[] = [] private hmrClient: ServerHMRBroadcasterClient private connected = false - constructor(server: ViteDevServer) { - const hmrChannel = server.hot?.channels.find( - (c) => c.name === 'ssr', - ) as ServerHMRChannel - if (!hmrChannel) { - throw new Error( - "Your version of Vite doesn't support HMR during SSR. Please, use Vite 5.1 or higher.", - ) - } - this.hmrClient = new ServerHMRBroadcasterClient(hmrChannel) - hmrChannel.api.outsideEmitter.on('send', (payload: HMRPayload) => { + constructor(private hotChannel: ServerHotChannel) { + this.hmrClient = new ServerHMRBroadcasterClient(hotChannel) + hotChannel.api.outsideEmitter.on('send', (payload: HotPayload) => { this.handlers.forEach((listener) => listener(payload)) }) - this.hmrChannel = hmrChannel + this.hotChannel = hotChannel } isReady(): boolean { @@ -59,14 +49,14 @@ export class ServerHMRConnector implements HMRRuntimeConnection { send(message: string): void { const payload = JSON.parse(message) as CustomPayload - this.hmrChannel.api.innerEmitter.emit( + this.hotChannel.api.innerEmitter.emit( payload.event, payload.data, this.hmrClient, ) } - onUpdate(handler: (payload: HMRPayload) => void): void { + onUpdate(handler: (payload: HotPayload) => void): void { this.handlers.push(handler) handler({ type: 'connected' }) this.connected = true diff --git a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts new file mode 100644 index 00000000000000..931bd07b80a5c7 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts @@ -0,0 +1,103 @@ +import { existsSync, readFileSync } from 'node:fs' +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' +import type { + ModuleEvaluator, + ModuleRunnerHMRConnection, + ModuleRunnerHmr, + ModuleRunnerOptions, +} from 'vite/module-runner' +import type { DevEnvironment } from '../../server/environment' +import type { ServerHotChannel } from '../../server/hmr' +import { ServerHMRConnector } from './serverHmrConnector' + +/** + * @experimental + */ +export interface ServerModuleRunnerOptions + extends Omit< + ModuleRunnerOptions, + 'root' | 'fetchModule' | 'hmr' | 'transport' + > { + /** + * Disable HMR or configure HMR logger. + */ + hmr?: + | false + | { + connection?: ModuleRunnerHMRConnection + logger?: ModuleRunnerHmr['logger'] + } + /** + * Provide a custom module evaluator. This controls how the code is executed. + */ + evaluator?: ModuleEvaluator +} + +function createHMROptions( + environment: DevEnvironment, + options: ServerModuleRunnerOptions, +) { + if (environment.config.server.hmr === false || options.hmr === false) { + return false + } + if (options.hmr?.connection) { + return { + connection: options.hmr.connection, + logger: options.hmr.logger, + } + } + if (!('api' in environment.hot)) return false + const connection = new ServerHMRConnector(environment.hot as ServerHotChannel) + return { + connection, + logger: options.hmr?.logger, + } +} + +const prepareStackTrace = { + retrieveFile(id: string) { + if (existsSync(id)) { + return readFileSync(id, 'utf-8') + } + }, +} + +function resolveSourceMapOptions(options: ServerModuleRunnerOptions) { + if (options.sourcemapInterceptor != null) { + if (options.sourcemapInterceptor === 'prepareStackTrace') { + return prepareStackTrace + } + if (typeof options.sourcemapInterceptor === 'object') { + return { ...prepareStackTrace, ...options.sourcemapInterceptor } + } + return options.sourcemapInterceptor + } + if (typeof process !== 'undefined' && 'setSourceMapsEnabled' in process) { + return 'node' + } + return prepareStackTrace +} + +/** + * Create an instance of the Vite SSR runtime that support HMR. + * @experimental + */ +export function createServerModuleRunner( + environment: DevEnvironment, + options: ServerModuleRunnerOptions = {}, +): ModuleRunner { + const hmr = createHMROptions(environment, options) + return new ModuleRunner( + { + ...options, + root: environment.config.root, + transport: { + fetchModule: (id, importer, options) => + environment.fetchModule(id, importer, options), + }, + hmr, + sourcemapInterceptor: resolveSourceMapOptions(options), + }, + options.evaluator || new ESModulesEvaluator(), + ) +} diff --git a/packages/vite/src/node/ssr/ssrFetchModule.ts b/packages/vite/src/node/ssr/ssrFetchModule.ts deleted file mode 100644 index d0e1c98cca2569..00000000000000 --- a/packages/vite/src/node/ssr/ssrFetchModule.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ViteDevServer } from '../server' -import type { FetchResult } from '../../runtime/types' -import { asyncFunctionDeclarationPaddingLineCount } from '../../shared/utils' -import { fetchModule } from './fetchModule' - -export function ssrFetchModule( - server: ViteDevServer, - id: string, - importer?: string, -): Promise { - return fetchModule(server, id, importer, { - processSourceMap(map) { - // this assumes that "new AsyncFunction" is used to create the module - return Object.assign({}, map, { - mappings: - ';'.repeat(asyncFunctionDeclarationPaddingLineCount) + map.mappings, - }) - }, - }) -} diff --git a/packages/vite/src/node/ssr/ssrManifestPlugin.ts b/packages/vite/src/node/ssr/ssrManifestPlugin.ts index 7818e4759d74b4..4ebf43d4fc2bd2 100644 --- a/packages/vite/src/node/ssr/ssrManifestPlugin.ts +++ b/packages/vite/src/node/ssr/ssrManifestPlugin.ts @@ -5,7 +5,6 @@ import type { ImportSpecifier, } from 'es-module-lexer' import type { OutputChunk } from 'rollup' -import type { ResolvedConfig } from '..' import type { Plugin } from '../plugin' import { preloadMethod } from '../plugins/importAnalysisBuild' import { @@ -15,15 +14,25 @@ import { numberToPos, sortObjectKeys, } from '../utils' +import { usePerEnvironmentState } from '../environment' -export function ssrManifestPlugin(config: ResolvedConfig): Plugin { +export function ssrManifestPlugin(): Plugin { // module id => preload assets mapping - const ssrManifest: Record = {} - const base = config.base + const getSsrManifest = usePerEnvironmentState(() => { + return {} as Record + }) return { name: 'vite:ssr-manifest', + + applyToEnvironment(environment) { + return !!environment.config.build.ssrManifest + }, + generateBundle(_options, bundle) { + const config = this.environment.config + const ssrManifest = getSsrManifest(this) + const { base } = config for (const file in bundle) { const chunk = bundle[file] if (chunk.type === 'chunk') { diff --git a/packages/vite/src/node/ssr/ssrModuleLoader.ts b/packages/vite/src/node/ssr/ssrModuleLoader.ts index 34856d7c90ac6c..b6a7bc1154bc41 100644 --- a/packages/vite/src/node/ssr/ssrModuleLoader.ts +++ b/packages/vite/src/node/ssr/ssrModuleLoader.ts @@ -1,244 +1,59 @@ -import path from 'node:path' -import { pathToFileURL } from 'node:url' import colors from 'picocolors' +import type { ModuleRunner } from 'vite/module-runner' import type { ViteDevServer } from '../server' -import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' -import { transformRequest } from '../server/transformRequest' -import type { InternalResolveOptionsWithOverrideConditions } from '../plugins/resolve' -import { tryNodeResolve } from '../plugins/resolve' -import { genSourceMapUrl } from '../server/sourcemap' -import { - AsyncFunction, - asyncFunctionDeclarationPaddingLineCount, - isWindows, - unwrapId, -} from '../../shared/utils' -import { - type SSRImportBaseMetadata, - analyzeImportedModDifference, -} from '../../shared/ssrTransform' -import { SOURCEMAPPING_URL } from '../../shared/constants' -import { - ssrDynamicImportKey, - ssrExportAllKey, - ssrImportKey, - ssrImportMetaKey, - ssrModuleExportsKey, -} from './ssrTransform' +import { unwrapId } from '../../shared/utils' import { ssrFixStacktrace } from './ssrStacktrace' +import { createServerModuleRunner } from './runtime/serverModuleRunner' type SSRModule = Record -interface NodeImportResolveOptions - extends InternalResolveOptionsWithOverrideConditions { - legacyProxySsrExternalModules?: boolean -} - -const pendingModules = new Map>() -const pendingModuleDependencyGraph = new Map>() -const importErrors = new WeakMap() - export async function ssrLoadModule( url: string, server: ViteDevServer, fixStacktrace?: boolean, ): Promise { + server._ssrCompatModuleRunner ||= createServerModuleRunner( + server.environments.ssr, + { + sourcemapInterceptor: false, + hmr: false, + }, + ) url = unwrapId(url) - // when we instantiate multiple dependency modules in parallel, they may - // point to shared modules. We need to avoid duplicate instantiation attempts - // by register every module as pending synchronously so that all subsequent - // request to that module are simply waiting on the same promise. - const pending = pendingModules.get(url) - if (pending) { - return pending - } - - const modulePromise = instantiateModule(url, server, fixStacktrace) - pendingModules.set(url, modulePromise) - modulePromise - .catch(() => { - /* prevent unhandled promise rejection error from bubbling up */ - }) - .finally(() => { - pendingModules.delete(url) - }) - return modulePromise + return instantiateModule( + url, + server._ssrCompatModuleRunner, + server, + fixStacktrace, + ) } async function instantiateModule( url: string, + runner: ModuleRunner, server: ViteDevServer, fixStacktrace?: boolean, ): Promise { - const { moduleGraph } = server - const mod = await moduleGraph.ensureEntryFromUrl(url, true) + const environment = server.environments.ssr + const mod = await environment.moduleGraph.ensureEntryFromUrl(url) if (mod.ssrError) { throw mod.ssrError } - if (mod.ssrModule) { - return mod.ssrModule - } - const result = - mod.ssrTransformResult || - (await transformRequest(url, server, { ssr: true })) - if (!result) { - // TODO more info? is this even necessary? - throw new Error(`failed to load module for ssr: ${url}`) - } - - const ssrModule = { - [Symbol.toStringTag]: 'Module', - } - Object.defineProperty(ssrModule, '__esModule', { value: true }) - - // Tolerate circular imports by ensuring the module can be - // referenced before it's been instantiated. - mod.ssrModule = ssrModule - - // replace '/' with '\\' on Windows to match Node.js - const osNormalizedFilename = isWindows ? path.resolve(mod.file!) : mod.file! - - const ssrImportMeta = { - dirname: path.dirname(osNormalizedFilename), - filename: osNormalizedFilename, - // The filesystem URL, matching native Node.js modules - url: pathToFileURL(mod.file!).toString(), - } - - const { - isProduction, - resolve: { dedupe, preserveSymlinks }, - root, - ssr, - } = server.config - - const overrideConditions = ssr.resolve?.externalConditions || [] - - const resolveOptions: NodeImportResolveOptions = { - mainFields: ['main'], - conditions: [], - overrideConditions: [...overrideConditions, 'production', 'development'], - extensions: ['.js', '.cjs', '.json'], - dedupe, - preserveSymlinks, - isBuild: false, - isProduction, - root, - ssrConfig: ssr, - legacyProxySsrExternalModules: - server.config.legacy?.proxySsrExternalModules, - packageCache: server.config.packageCache, - } - - const ssrImport = async (dep: string, metadata?: SSRImportBaseMetadata) => { - try { - if (dep[0] !== '.' && dep[0] !== '/') { - return await nodeImport(dep, mod.file!, resolveOptions, metadata) - } - // convert to rollup URL because `pendingImports`, `moduleGraph.urlToModuleMap` requires that - dep = unwrapId(dep) - - // Handle any potential circular dependencies for static imports, preventing - // deadlock scenarios when two modules are indirectly waiting on one another - // to finish initializing. Dynamic imports are resolved at runtime, hence do - // not contribute to the static module dependency graph in the same way - if (!metadata?.isDynamicImport) { - addPendingModuleDependency(url, dep) - - // If there's a circular dependency formed as a result of the dep import, - // return the current state of the dependent module being initialized, in - // order to avoid interlocking circular dependencies hanging indefinitely - if (checkModuleDependencyExists(dep, url)) { - const depSsrModule = moduleGraph.urlToModuleMap.get(dep)?.ssrModule - if (!depSsrModule) { - // Technically, this should never happen under normal circumstances - throw new Error( - '[vite] The dependency module is not yet fully initialized due to circular dependency. This is a bug in Vite SSR', - ) - } - return depSsrModule - } - } - - return ssrLoadModule(dep, server, fixStacktrace) - } catch (err) { - // tell external error handler which mod was imported with error - importErrors.set(err, { importee: dep }) - - throw err - } - } - - const ssrDynamicImport = (dep: string) => { - // #3087 dynamic import vars is ignored at rewrite import path, - // so here need process relative path - if (dep[0] === '.') { - dep = path.posix.resolve(path.dirname(url), dep) - } - return ssrImport(dep, { isDynamicImport: true }) - } - - function ssrExportAll(sourceModule: any) { - for (const key in sourceModule) { - if (key !== 'default' && key !== '__esModule') { - Object.defineProperty(ssrModule, key, { - enumerable: true, - configurable: true, - get() { - return sourceModule[key] - }, - }) - } - } - } - - let sourceMapSuffix = '' - if (result.map && 'version' in result.map) { - const moduleSourceMap = Object.assign({}, result.map, { - mappings: - ';'.repeat(asyncFunctionDeclarationPaddingLineCount) + - result.map.mappings, - }) - sourceMapSuffix = `\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(moduleSourceMap)}` - } - try { - const initModule = new AsyncFunction( - ssrModuleExportsKey, - ssrImportMetaKey, - ssrImportKey, - ssrDynamicImportKey, - ssrExportAllKey, - '"use strict";' + - result.code + - `\n//# sourceURL=${mod.id}${sourceMapSuffix}`, - ) - await initModule( - ssrModule, - ssrImportMeta, - ssrImport, - ssrDynamicImport, - ssrExportAll, - ) - } catch (e) { + const exports = await runner.import(url) + mod.ssrModule = exports + return exports + } catch (e: any) { mod.ssrError = e - const errorData = importErrors.get(e) - if (e.stack && fixStacktrace) { - ssrFixStacktrace(e, moduleGraph) + ssrFixStacktrace(e, environment.moduleGraph) } - server.config.logger.error( - colors.red( - `Error when evaluating SSR module ${url}:` + - (errorData?.importee - ? ` failed to import "${errorData.importee}"` - : '') + - `\n|- ${e.stack}\n`, - ), + environment.logger.error( + colors.red(`Error when evaluating SSR module ${url}:\n|- ${e.stack}\n`), { timestamp: true, clear: server.config.clearScreen, @@ -247,124 +62,5 @@ async function instantiateModule( ) throw e - } finally { - pendingModuleDependencyGraph.delete(url) - } - - return Object.freeze(ssrModule) -} - -function addPendingModuleDependency(originUrl: string, depUrl: string): void { - if (pendingModuleDependencyGraph.has(originUrl)) { - pendingModuleDependencyGraph.get(originUrl)!.add(depUrl) - } else { - pendingModuleDependencyGraph.set(originUrl, new Set([depUrl])) - } -} - -function checkModuleDependencyExists( - originUrl: string, - targetUrl: string, -): boolean { - const visited = new Set() - const stack = [originUrl] - - while (stack.length) { - const currentUrl = stack.pop()! - - if (currentUrl === targetUrl) { - return true - } - - if (!visited.has(currentUrl)) { - visited.add(currentUrl) - - const dependencies = pendingModuleDependencyGraph.get(currentUrl) - if (dependencies) { - for (const depUrl of dependencies) { - if (!visited.has(depUrl)) { - stack.push(depUrl) - } - } - } - } } - - return false -} - -// In node@12+ we can use dynamic import to load CJS and ESM -async function nodeImport( - id: string, - importer: string, - resolveOptions: NodeImportResolveOptions, - metadata?: SSRImportBaseMetadata, -) { - let url: string - let filePath: string | undefined - if (id.startsWith('data:') || isExternalUrl(id) || isBuiltin(id)) { - url = id - } else { - const resolved = tryNodeResolve( - id, - importer, - { ...resolveOptions, tryEsmOnly: true }, - false, - undefined, - true, - ) - if (!resolved) { - const err: any = new Error( - `Cannot find module '${id}' imported from '${importer}'`, - ) - err.code = 'ERR_MODULE_NOT_FOUND' - throw err - } - filePath = resolved.id - url = pathToFileURL(resolved.id).toString() - } - - const mod = await import(url) - - if (resolveOptions.legacyProxySsrExternalModules) { - return proxyESM(mod) - } else if (filePath) { - analyzeImportedModDifference( - mod, - id, - isFilePathESM(filePath, resolveOptions.packageCache) - ? 'module' - : undefined, - metadata, - ) - return mod - } else { - return mod - } -} - -// rollup-style default import interop for cjs -function proxyESM(mod: any) { - // This is the only sensible option when the exports object is a primitive - if (isPrimitive(mod)) return { default: mod } - - let defaultExport = 'default' in mod ? mod.default : mod - - if (!isPrimitive(defaultExport) && '__esModule' in defaultExport) { - mod = defaultExport - if ('default' in defaultExport) { - defaultExport = defaultExport.default - } - } - - return new Proxy(mod, { - get(mod, prop) { - if (prop === 'default') return defaultExport - return mod[prop] ?? defaultExport?.[prop] - }, - }) -} - -function isPrimitive(value: any) { - return !value || (typeof value !== 'object' && typeof value !== 'function') } diff --git a/packages/vite/src/node/ssr/ssrStacktrace.ts b/packages/vite/src/node/ssr/ssrStacktrace.ts index a98af4dd94bb74..18489224ea4af6 100644 --- a/packages/vite/src/node/ssr/ssrStacktrace.ts +++ b/packages/vite/src/node/ssr/ssrStacktrace.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping' -import type { ModuleGraph } from '../server/moduleGraph' +import type { EnvironmentModuleGraph } from '..' let offset: number @@ -22,7 +22,7 @@ function calculateOffsetOnce() { export function ssrRewriteStacktrace( stack: string, - moduleGraph: ModuleGraph, + moduleGraph: EnvironmentModuleGraph, ): string { calculateOffsetOnce() return stack @@ -33,8 +33,8 @@ export function ssrRewriteStacktrace( (input, varName, id, line, column) => { if (!id) return input - const mod = moduleGraph.idToModuleMap.get(id) - const rawSourceMap = mod?.ssrTransformResult?.map + const mod = moduleGraph.getModuleById(id) + const rawSourceMap = mod?.transformResult?.map if (!rawSourceMap) { return input @@ -86,7 +86,10 @@ export function rebindErrorStacktrace(e: Error, stacktrace: string): void { const rewroteStacktraces = new WeakSet() -export function ssrFixStacktrace(e: Error, moduleGraph: ModuleGraph): void { +export function ssrFixStacktrace( + e: Error, + moduleGraph: EnvironmentModuleGraph, +): void { if (!e.stack) return // stacktrace shouldn't be rewritten more than once if (rewroteStacktraces.has(e)) return diff --git a/packages/vite/src/node/ssr/ssrTransform.ts b/packages/vite/src/node/ssr/ssrTransform.ts index a03eb7266d9d18..322afa0a155101 100644 --- a/packages/vite/src/node/ssr/ssrTransform.ts +++ b/packages/vite/src/node/ssr/ssrTransform.ts @@ -27,7 +27,7 @@ type Node = _Node & { end: number } -interface TransformOptions { +export interface ModuleRunnerTransformOptions { json?: { stringify?: boolean } @@ -46,7 +46,7 @@ export async function ssrTransform( inMap: SourceMap | { mappings: '' } | null, url: string, originalCode: string, - options?: TransformOptions, + options?: ModuleRunnerTransformOptions, ): Promise { if (options?.json?.stringify && isJSONRequest(url)) { return ssrTransformJSON(code, inMap) @@ -63,6 +63,7 @@ async function ssrTransformJSON( map: inMap, deps: [], dynamicDeps: [], + ssr: true, } } @@ -370,6 +371,7 @@ async function ssrTransformScript( return { code: s.toString(), map, + ssr: true, deps: [...deps], dynamicDeps: [...dynamicDeps], } diff --git a/packages/vite/src/node/tsconfig.json b/packages/vite/src/node/tsconfig.json index fc1e1744a518d4..85c9d0b7cc3b87 100644 --- a/packages/vite/src/node/tsconfig.json +++ b/packages/vite/src/node/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["./", "../runtime", "../types"], + "include": ["./", "../module-runner", "../types"], "exclude": ["../**/__tests__"], "compilerOptions": { // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping#node-18 @@ -9,7 +9,7 @@ "skipLibCheck": true, // lib check is done on final types "stripInternal": true, "paths": { - "vite/runtime": ["../runtime"] + "vite/module-runner": ["../module-runner"] } } } diff --git a/packages/vite/src/node/typeUtils.ts b/packages/vite/src/node/typeUtils.ts new file mode 100644 index 00000000000000..83acb5f5089357 --- /dev/null +++ b/packages/vite/src/node/typeUtils.ts @@ -0,0 +1,22 @@ +import type { + ObjectHook, + Plugin as RollupPlugin, + PluginContext as RollupPluginContext, +} from 'rollup' + +export type NonNeverKeys = { + [K in keyof T]: T[K] extends never ? never : K +}[keyof T] + +export type GetHookContextMap = { + [K in keyof Plugin]-?: Plugin[K] extends ObjectHook + ? T extends (this: infer This, ...args: any[]) => any + ? This extends RollupPluginContext + ? This + : never + : never + : never +} + +type RollupPluginHooksContext = GetHookContextMap +export type RollupPluginHooks = NonNeverKeys diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 393bc391799aad..bcc5cd75acabff 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -30,7 +30,7 @@ import { loopbackHosts, wildcardHosts, } from './constants' -import type { DepOptimizationConfig } from './optimizer' +import type { DepOptimizationOptions } from './optimizer' import type { ResolvedConfig } from './config' import type { ResolvedServerUrls, ViteDevServer } from './server' import type { PreviewServer } from './preview' @@ -122,7 +122,7 @@ export function moduleListContains( export function isOptimizable( id: string, - optimizeDeps: DepOptimizationConfig, + optimizeDeps: DepOptimizationOptions, ): boolean { const { extensions } = optimizeDeps return ( @@ -1099,7 +1099,7 @@ function mergeConfigRecursively( continue } else if ( key === 'noExternal' && - rootPath === 'ssr' && + (rootPath === 'ssr' || rootPath === 'resolve') && (existing === true || value === true) ) { merged[key] = true diff --git a/packages/vite/src/shared/constants.ts b/packages/vite/src/shared/constants.ts index 7c0e685d5abf6b..a12c674cc98ed6 100644 --- a/packages/vite/src/shared/constants.ts +++ b/packages/vite/src/shared/constants.ts @@ -19,5 +19,5 @@ export const NULL_BYTE_PLACEHOLDER = `__x00__` export let SOURCEMAPPING_URL = 'sourceMa' SOURCEMAPPING_URL += 'ppingURL' -export const VITE_RUNTIME_SOURCEMAPPING_SOURCE = - '//# sourceMappingSource=vite-runtime' +export const MODULE_RUNNER_SOURCEMAPPING_SOURCE = + '//# sourceMappingSource=vite-generated' diff --git a/packages/vite/src/shared/hmr.ts b/packages/vite/src/shared/hmr.ts index 0f2cb23b4ad71f..ee4f727158f60e 100644 --- a/packages/vite/src/shared/hmr.ts +++ b/packages/vite/src/shared/hmr.ts @@ -111,9 +111,12 @@ export class HMRContext implements ViteHotContext { path: this.ownerPath, message, }) - this.send('vite:invalidate', { path: this.ownerPath, message }) + this.send('vite:invalidate', { + path: this.ownerPath, + message, + }) this.hmrClient.logger.debug( - `[vite] invalidate ${this.ownerPath}${message ? `: ${message}` : ''}`, + `invalidate ${this.ownerPath}${message ? `: ${message}` : ''}`, ) } @@ -252,7 +255,7 @@ export class HMRClient { this.logger.error(err) } this.logger.error( - `[hmr] Failed to reload ${path}. ` + + `Failed to reload ${path}. ` + `This could be due to syntax errors or importing non-existent ` + `modules. (see errors above)`, ) @@ -313,7 +316,7 @@ export class HMRClient { ) } const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}` - this.logger.debug(`[vite] hot updated: ${loggedPath}`) + this.logger.debug(`hot updated: ${loggedPath}`) } } } diff --git a/packages/vite/src/shared/ssrTransform.ts b/packages/vite/src/shared/ssrTransform.ts index 9bf1f667f60fd9..9ce6fe9b4ee9da 100644 --- a/packages/vite/src/shared/ssrTransform.ts +++ b/packages/vite/src/shared/ssrTransform.ts @@ -11,7 +11,7 @@ export interface DefineImportMetadata { importedNames?: string[] } -export interface SSRImportBaseMetadata extends DefineImportMetadata { +export interface SSRImportMetadata extends DefineImportMetadata { isDynamicImport?: boolean } @@ -24,7 +24,7 @@ export function analyzeImportedModDifference( mod: any, rawId: string, moduleType: string | undefined, - metadata?: SSRImportBaseMetadata, + metadata?: SSRImportMetadata, ): void { // No normalization needed if the user already dynamic imports this module if (metadata?.isDynamicImport) return diff --git a/packages/vite/src/shared/tsconfig.json b/packages/vite/src/shared/tsconfig.json index a7f7890f1d0e7b..96451a95759b42 100644 --- a/packages/vite/src/shared/tsconfig.json +++ b/packages/vite/src/shared/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "include": ["./", "../dep-types", "../types"], - "exclude": ["**/__tests__"], + "exclude": ["**/__tests__", "**/__tests_dts__"], "compilerOptions": { "lib": ["ESNext", "DOM"], "stripInternal": true diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index 79dc349d3c880c..7c7f81f6cc1daa 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -1,4 +1,6 @@ -export type HMRPayload = +/** @deprecated use HotPayload */ +export type HMRPayload = HotPayload +export type HotPayload = | ConnectedPayload | UpdatePayload | FullReloadPayload @@ -25,7 +27,7 @@ export interface Update { /** @internal */ isWithinCircularImport?: boolean /** @internal */ - ssrInvalidates?: string[] + invalidates?: string[] } export interface PrunePayload { diff --git a/playground/environment-react-ssr/__tests__/basic.spec.ts b/playground/environment-react-ssr/__tests__/basic.spec.ts new file mode 100644 index 00000000000000..4b98b37a2394f7 --- /dev/null +++ b/playground/environment-react-ssr/__tests__/basic.spec.ts @@ -0,0 +1,9 @@ +import { test } from 'vitest' +import { page } from '~utils' + +test('basic', async () => { + await page.getByText('hydrated: true').isVisible() + await page.getByText('Count: 0').isVisible() + await page.getByRole('button', { name: '+' }).click() + await page.getByText('Count: 1').isVisible() +}) diff --git a/playground/environment-react-ssr/index.html b/playground/environment-react-ssr/index.html new file mode 100644 index 00000000000000..9f4d44a675c1b1 --- /dev/null +++ b/playground/environment-react-ssr/index.html @@ -0,0 +1,14 @@ + + + + + environment-react-ssr + + + + + + diff --git a/playground/environment-react-ssr/package.json b/playground/environment-react-ssr/package.json new file mode 100644 index 00000000000000..9aa44aa8a14a0a --- /dev/null +++ b/playground/environment-react-ssr/package.json @@ -0,0 +1,17 @@ +{ + "name": "@vitejs/test-environment-react-ssr", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build --app", + "preview": "vite preview" + }, + "devDependencies": { + "@types/react": "^18.2.73", + "@types/react-dom": "^18.2.23", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/playground/environment-react-ssr/src/entry-client.tsx b/playground/environment-react-ssr/src/entry-client.tsx new file mode 100644 index 00000000000000..e33d677abfbab2 --- /dev/null +++ b/playground/environment-react-ssr/src/entry-client.tsx @@ -0,0 +1,12 @@ +import ReactDomClient from 'react-dom/client' +import React from 'react' +import Root from './root' + +async function main() { + const el = document.getElementById('root') + React.startTransition(() => { + ReactDomClient.hydrateRoot(el!, ) + }) +} + +main() diff --git a/playground/environment-react-ssr/src/entry-server.tsx b/playground/environment-react-ssr/src/entry-server.tsx new file mode 100644 index 00000000000000..588d365a0ca996 --- /dev/null +++ b/playground/environment-react-ssr/src/entry-server.tsx @@ -0,0 +1,24 @@ +import ReactDomServer from 'react-dom/server' +import type { Connect, ViteDevServer } from 'vite' +import Root from './root' + +const handler: Connect.NextHandleFunction = async (_req, res) => { + const ssrHtml = ReactDomServer.renderToString() + let html = await importHtml() + html = html.replace(//, `
${ssrHtml}
`) + res.setHeader('content-type', 'text/html').end(html) +} + +export default handler + +declare let __globalServer: ViteDevServer + +async function importHtml() { + if (import.meta.env.DEV) { + const mod = await import('/index.html?raw') + return __globalServer.transformIndexHtml('/', mod.default) + } else { + const mod = await import('/dist/client/index.html?raw') + return mod.default + } +} diff --git a/playground/environment-react-ssr/src/root.tsx b/playground/environment-react-ssr/src/root.tsx new file mode 100644 index 00000000000000..3d077cafb892ba --- /dev/null +++ b/playground/environment-react-ssr/src/root.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +export default function Root() { + const [count, setCount] = React.useState(0) + + const [hydrated, setHydrated] = React.useState(false) + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( +
+
hydrated: {String(hydrated)}
+
Count: {count}
+ + +
+ ) +} diff --git a/playground/environment-react-ssr/tsconfig.json b/playground/environment-react-ssr/tsconfig.json new file mode 100644 index 00000000000000..be3ffda527ca91 --- /dev/null +++ b/playground/environment-react-ssr/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/playground/environment-react-ssr/vite.config.ts b/playground/environment-react-ssr/vite.config.ts new file mode 100644 index 00000000000000..96c193677316a3 --- /dev/null +++ b/playground/environment-react-ssr/vite.config.ts @@ -0,0 +1,90 @@ +import { + type Connect, + type Plugin, + type PluginOption, + createServerModuleRunner, + defineConfig, +} from 'vite' + +export default defineConfig((env) => ({ + clearScreen: false, + appType: 'custom', + plugins: [ + vitePluginSsrMiddleware({ + entry: '/src/entry-server', + preview: new URL('./dist/server/index.js', import.meta.url).toString(), + }), + { + name: 'global-server', + configureServer(server) { + Object.assign(globalThis, { __globalServer: server }) + }, + }, + ], + environments: { + client: { + build: { + minify: false, + sourcemap: true, + outDir: 'dist/client', + }, + }, + ssr: { + build: { + outDir: 'dist/server', + // [feedback] + // is this still meant to be used? + // for example, `ssr: true` seems to make `minify: false` automatically + // and also externalization. + ssr: true, + rollupOptions: { + input: { + index: '/src/entry-server', + }, + }, + }, + }, + }, + + builder: { + async buildApp(builder) { + await builder.build(builder.environments.client) + await builder.build(builder.environments.ssr) + }, + }, +})) + +// vavite-style ssr middleware plugin +export function vitePluginSsrMiddleware({ + entry, + preview, +}: { + entry: string + preview?: string +}): PluginOption { + const plugin: Plugin = { + name: vitePluginSsrMiddleware.name, + + configureServer(server) { + const runner = createServerModuleRunner(server.environments.ssr) + const handler: Connect.NextHandleFunction = async (req, res, next) => { + try { + const mod = await runner.import(entry) + await mod['default'](req, res, next) + } catch (e) { + next(e) + } + } + return () => server.middlewares.use(handler) + }, + + async configurePreviewServer(server) { + if (preview) { + const mod = await import(preview) + return () => server.middlewares.use(mod.default) + } + return + }, + } + return [plugin] +} diff --git a/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts b/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts index 6a2b3763b3ffec..4e2f051dc1bb91 100644 --- a/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts +++ b/playground/hmr-ssr/__tests__/hmr-ssr.spec.ts @@ -2,10 +2,18 @@ import fs from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, posix, resolve } from 'node:path' import EventEmitter from 'node:events' -import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' +import { + afterAll, + beforeAll, + describe, + expect, + onTestFinished, + test, + vi, +} from 'vitest' import type { InlineConfig, Logger, ViteDevServer } from 'vite' -import { createServer, createViteRuntime } from 'vite' -import type { ViteRuntime } from 'vite/runtime' +import { createServer, createServerModuleRunner } from 'vite' +import type { ModuleRunner } from 'vite/module-runner' import type { RollupError } from 'rollup' import { addFile, @@ -19,7 +27,7 @@ import { let server: ViteDevServer const clientLogs: string[] = [] const serverLogs: string[] = [] -let runtime: ViteRuntime +let runner: ModuleRunner const logsEmitter = new EventEmitter() @@ -54,7 +62,7 @@ const updated = (file: string, via?: string) => { describe('hmr works correctly', () => { beforeAll(async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') }) test('should connect', async () => { @@ -297,27 +305,73 @@ describe('hmr works correctly', () => { // expect((await page.$$('link')).length).toBe(1) // }) - // #2255 - test('importing reloaded', async () => { - const outputEle = () => hmr('.importing-reloaded') + // #2255 - not applicable to SSR because invalidateModule expects the module + // to always be reloaded again + // test('importing reloaded', async () => { + // const outputEle = () => hmr('.importing-reloaded') - await untilUpdated(outputEle, ['a.js: a0', 'b.js: b0,a0'].join('
')) + // await untilUpdated(outputEle, ['a.js: a0', 'b.js: b0,a0'].join('
')) - editFile('importing-updated/a.js', (code) => code.replace("'a0'", "'a1'")) - await untilUpdated( - outputEle, - ['a.js: a0', 'b.js: b0,a0', 'a.js: a1'].join('
'), - ) + // editFile('importing-updated/a.js', (code) => code.replace("'a0'", "'a1'")) + // await untilUpdated( + // outputEle, + // ['a.js: a0', 'b.js: b0,a0', 'a.js: a1'].join('
'), + // ) - editFile('importing-updated/b.js', (code) => - code.replace('`b0,${a}`', '`b1,${a}`'), - ) - // note that "a.js: a1" should not happen twice after "b.js: b0,a0'" - await untilUpdated( - outputEle, - ['a.js: a0', 'b.js: b0,a0', 'a.js: a1', 'b.js: b1,a1'].join('
'), - ) - }) + // editFile('importing-updated/b.js', (code) => + // code.replace('`b0,${a}`', '`b1,${a}`'), + // ) + // // note that "a.js: a1" should not happen twice after "b.js: b0,a0'" + // await untilUpdated( + // outputEle, + // ['a.js: a0', 'b.js: b0,a0', 'a.js: a1', 'b.js: b1,a1'].join('
'), + // ) + // }) +}) + +describe('self accept with different entry point formats', () => { + test.each(['./unresolved.ts', './unresolved', '/unresolved'])( + 'accepts if entry point is relative to root', + async (entrypoint) => { + await setupModuleRunner(entrypoint, {}, '/unresolved.ts') + + onTestFinished(() => { + const filepath = resolvePath('..', 'unresolved.ts') + fs.writeFileSync(filepath, originalFiles.get(filepath)!, 'utf-8') + }) + + const el = () => hmr('.app') + await untilConsoleLogAfter( + () => + editFile('unresolved.ts', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + 'foo was: 1', + '(self-accepting 1) foo is now: 2', + '(self-accepting 2) foo is now: 2', + updated('/unresolved.ts'), + ], + true, + ) + await untilUpdated(() => el(), '2') + + await untilConsoleLogAfter( + () => + editFile('unresolved.ts', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + 'foo was: 2', + '(self-accepting 1) foo is now: 3', + '(self-accepting 2) foo is now: 3', + updated('/unresolved.ts'), + ], + true, + ) + await untilUpdated(() => el(), '3') + }, + ) }) describe('acceptExports', () => { @@ -338,7 +392,7 @@ describe('acceptExports', () => { beforeAll(async () => { await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) @@ -466,7 +520,7 @@ describe('acceptExports', () => { beforeAll(async () => { await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< named: ${a} ; ${dep}`) @@ -520,8 +574,9 @@ describe('acceptExports', () => { beforeAll(async () => { clientLogs.length = 0 // so it's in the module graph - await server.transformRequest(testFile, { ssr: true }) - await server.transformRequest('non-tested/dep.js', { ssr: true }) + const ssrEnvironment = server.environments.ssr + await ssrEnvironment.transformRequest(testFile) + await ssrEnvironment.transformRequest('non-tested/dep.js') }) test('does not full reload', async () => { @@ -569,7 +624,7 @@ describe('acceptExports', () => { const file = 'side-effects.ts' await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, />>>/], (logs) => { expect(logs).toContain('>>> side FX') @@ -598,7 +653,7 @@ describe('acceptExports', () => { const url = '/' + file await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, '-- unused --'], (logs) => { expect(logs).toContain('-- unused --') @@ -621,7 +676,7 @@ describe('acceptExports', () => { const file = `${testDir}/${fileName}` await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, '-- used --', 'used:foo0'], (logs) => { expect(logs).toContain('-- used --') @@ -654,7 +709,7 @@ describe('acceptExports', () => { const url = '/' + file await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, '>>> ready <<<'], (logs) => { expect(logs).toContain('loaded:all:a0b0c0default0') @@ -688,7 +743,7 @@ describe('acceptExports', () => { const file = `${testDir}/${fileName}` await untilConsoleLogAfter( - () => setupViteRuntime(`/${testDir}/index`), + () => setupModuleRunner(`/${testDir}/index`), [CONNECTED, '>>> ready <<<'], (logs) => { expect(logs).toContain('loaded:some:a0b0c0default0') @@ -716,7 +771,7 @@ describe('acceptExports', () => { }) test('handle virtual module updates', async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') const el = () => hmr('.virtual') expect(el()).toBe('[success]0') editFile('importedVirtual.js', (code) => code.replace('[success]', '[wow]')) @@ -724,7 +779,7 @@ test('handle virtual module updates', async () => { }) test('invalidate virtual module', async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') const el = () => hmr('.virtual') expect(el()).toBe('[wow]0') globalThis.__HMR__['virtual:increment']() @@ -732,7 +787,7 @@ test('invalidate virtual module', async () => { }) test.todo('should hmr when file is deleted and restored', async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') const parentFile = 'file-delete-restore/parent.js' const childFile = 'file-delete-restore/child.js' @@ -820,7 +875,7 @@ test.todo('delete file should not break hmr', async () => { test.todo( 'deleted file should trigger dispose and prune callbacks', async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') const parentFile = 'file-delete-restore/parent.js' const childFile = 'file-delete-restore/child.js' @@ -857,7 +912,7 @@ test.todo( ) test('import.meta.hot?.accept', async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') await untilConsoleLogAfter( () => editFile('optional-chaining/child.js', (code) => @@ -869,7 +924,7 @@ test('import.meta.hot?.accept', async () => { }) test('hmr works for self-accepted module within circular imported files', async () => { - await setupViteRuntime('/self-accept-within-circular/index') + await setupModuleRunner('/self-accept-within-circular/index') const el = () => hmr('.self-accept-within-circular') expect(el()).toBe('c') editFile('self-accept-within-circular/c.js', (code) => @@ -885,7 +940,7 @@ test('hmr works for self-accepted module within circular imported files', async }) test('hmr should not reload if no accepted within circular imported files', async () => { - await setupViteRuntime('/circular/index') + await setupModuleRunner('/circular/index') const el = () => hmr('.circular') expect(el()).toBe( // tests in the browser check that there is an error, but vite runtime just returns undefined in those cases @@ -901,7 +956,7 @@ test('hmr should not reload if no accepted within circular imported files', asyn }) test('assets HMR', async () => { - await setupViteRuntime('/hmr.ts') + await setupModuleRunner('/hmr.ts') const el = () => hmr('#logo') await untilConsoleLogAfter( () => @@ -1096,15 +1151,16 @@ function createInMemoryLogger(logs: string[]) { return logger } -async function setupViteRuntime( +async function setupModuleRunner( entrypoint: string, serverOptions: InlineConfig = {}, + waitForFile: string = entrypoint, ) { if (server) { await server.close() clientLogs.length = 0 serverLogs.length = 0 - runtime.clearCache() + runner.clearCache() } globalThis.__HMR__ = {} as any @@ -1137,32 +1193,39 @@ async function setupViteRuntime( const logger = new HMRMockLogger() // @ts-expect-error not typed for HMR - globalThis.log = (...msg) => logger.debug(...msg) + globalThis.log = (...msg) => logger.log(...msg) - runtime = await createViteRuntime(server, { + runner = createServerModuleRunner(server.environments.ssr, { hmr: { logger, }, }) - await waitForWatcher(server, entrypoint) + await waitForWatcher(server, waitForFile) - await runtime.executeEntrypoint(entrypoint) + await runner.import(entrypoint) return { - runtime, + runtime: runner, server, } } class HMRMockLogger { - debug(...msg: unknown[]) { + log(...msg: unknown[]) { const log = msg.join(' ') clientLogs.push(log) logsEmitter.emit('log', log) } + + debug(...msg: unknown[]) { + const log = ['[vite]', ...msg].join(' ') + clientLogs.push(log) + logsEmitter.emit('log', log) + } error(msg: string) { - clientLogs.push(msg) - logsEmitter.emit('log', msg) + const log = ['[vite]', msg].join(' ') + clientLogs.push(log) + logsEmitter.emit('log', log) } } diff --git a/playground/hmr-ssr/unresolved.ts b/playground/hmr-ssr/unresolved.ts new file mode 100644 index 00000000000000..f260385025ac97 --- /dev/null +++ b/playground/hmr-ssr/unresolved.ts @@ -0,0 +1,20 @@ +export const foo = 1 +hmr('.app', foo) + +if (import.meta.hot) { + import.meta.hot.accept(({ foo }) => { + log('(self-accepting 1) foo is now:', foo) + }) + + import.meta.hot.accept(({ foo }) => { + log('(self-accepting 2) foo is now:', foo) + }) + + import.meta.hot.dispose(() => { + log(`foo was:`, foo) + }) +} + +function hmr(key: string, value: unknown) { + ;(globalThis.__HMR__ as any)[key] = String(value) +} diff --git a/playground/hmr-ssr/vite.config.ts b/playground/hmr-ssr/vite.config.ts index 5b4a7c17fe27cb..bbecfcd4178b0e 100644 --- a/playground/hmr-ssr/vite.config.ts +++ b/playground/hmr-ssr/vite.config.ts @@ -8,18 +8,21 @@ export default defineConfig({ plugins: [ { name: 'mock-custom', - async handleHotUpdate({ file, read, server }) { + async hotUpdate({ file, read, server }) { if (file.endsWith('customFile.js')) { const content = await read() const msg = content.match(/export const msg = '(\w+)'/)[1] - server.hot.send('custom:foo', { msg }) - server.hot.send('custom:remove', { msg }) + this.environment.hot.send('custom:foo', { msg }) + this.environment.hot.send('custom:remove', { msg }) } }, configureServer(server) { - server.hot.on('custom:remote-add', ({ a, b }, client) => { - client.send('custom:remote-add-result', { result: a + b }) - }) + server.environments.ssr.hot.on( + 'custom:remote-add', + ({ a, b }, client) => { + client.send('custom:remote-add-result', { result: a + b }) + }, + ) }, }, virtualPlugin(), @@ -45,11 +48,14 @@ export const virtual = _virtual + '${num}';` } }, configureServer(server) { - server.hot.on('virtual:increment', async () => { - const mod = await server.moduleGraph.getModuleByUrl('\0virtual:file') + server.environments.ssr.hot.on('virtual:increment', async () => { + const mod = + await server.environments.ssr.moduleGraph.getModuleByUrl( + '\0virtual:file', + ) if (mod) { num++ - server.reloadModule(mod) + server.environments.ssr.reloadModule(mod) } }) }, diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index b290ff60a3140d..46641f3e20db46 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -10,18 +10,21 @@ export default defineConfig({ plugins: [ { name: 'mock-custom', - async handleHotUpdate({ file, read, server }) { + async hotUpdate({ file, read }) { if (file.endsWith('customFile.js')) { const content = await read() const msg = content.match(/export const msg = '(\w+)'/)[1] - server.hot.send('custom:foo', { msg }) - server.hot.send('custom:remove', { msg }) + this.environment.hot.send('custom:foo', { msg }) + this.environment.hot.send('custom:remove', { msg }) } }, configureServer(server) { - server.hot.on('custom:remote-add', ({ a, b }, client) => { - client.send('custom:remote-add-result', { result: a + b }) - }) + server.environments.client.hot.on( + 'custom:remote-add', + ({ a, b }, client) => { + client.send('custom:remote-add-result', { result: a + b }) + }, + ) }, }, virtualPlugin(), @@ -47,11 +50,14 @@ export const virtual = _virtual + '${num}';` } }, configureServer(server) { - server.hot.on('virtual:increment', async () => { - const mod = await server.moduleGraph.getModuleByUrl('\0virtual:file') + server.environments.client.hot.on('virtual:increment', async () => { + const mod = + await server.environments.client.moduleGraph.getModuleByUrl( + '\0virtual:file', + ) if (mod) { num++ - server.reloadModule(mod) + server.environments.client.reloadModule(mod) } }) }, diff --git a/playground/html/__tests__/html.spec.ts b/playground/html/__tests__/html.spec.ts index 9a5d31ce4edb1f..b3b8582d9a14e1 100644 --- a/playground/html/__tests__/html.spec.ts +++ b/playground/html/__tests__/html.spec.ts @@ -283,7 +283,7 @@ describe.runIf(isServe)('invalid', () => { await page.keyboard.press('Escape') await hiddenPromise - viteServer.hot.send({ + viteServer.environments.client.hot.send({ type: 'error', err: { message: 'someError', @@ -394,7 +394,10 @@ describe.runIf(isServe)('warmup', () => { // warmup transform files async during server startup, so the module check // here might take a while to load await withRetry(async () => { - const mod = await viteServer.moduleGraph.getModuleByUrl('/warmup/warm.js') + const mod = + await viteServer.environments.client.moduleGraph.getModuleByUrl( + '/warmup/warm.js', + ) expect(mod).toBeTruthy() }) }) diff --git a/playground/module-graph/__tests__/module-graph.spec.ts b/playground/module-graph/__tests__/module-graph.spec.ts index bfabd53f289724..20492e968c674f 100644 --- a/playground/module-graph/__tests__/module-graph.spec.ts +++ b/playground/module-graph/__tests__/module-graph.spec.ts @@ -4,7 +4,7 @@ import { isServe, page, viteServer } from '~utils' test.runIf(isServe)('importedUrls order is preserved', async () => { const el = page.locator('.imported-urls-order') expect(await el.textContent()).toBe('[success]') - const mod = await viteServer.moduleGraph.getModuleByUrl( + const mod = await viteServer.environments.client.moduleGraph.getModuleByUrl( '/imported-urls-order.js', ) const importedModuleIds = [...mod.importedModules].map((m) => m.url) diff --git a/playground/ssr-deps/__tests__/ssr-deps.spec.ts b/playground/ssr-deps/__tests__/ssr-deps.spec.ts index c8794ce915dc21..b61cb355f0a638 100644 --- a/playground/ssr-deps/__tests__/ssr-deps.spec.ts +++ b/playground/ssr-deps/__tests__/ssr-deps.spec.ts @@ -120,7 +120,11 @@ test('import css library', async () => { }) describe.runIf(isServe)('hmr', () => { - test('handle isomorphic module updates', async () => { + // TODO: the server file is not imported on the client at all + // so it's not present in the client moduleGraph anymore + // we need to decide if we want to support a usecase when ssr change + // affects the client in any way + test.skip('handle isomorphic module updates', async () => { await page.goto(url) expect(await page.textContent('.isomorphic-module-server')).toMatch( diff --git a/playground/ssr-html/__tests__/ssr-html.spec.ts b/playground/ssr-html/__tests__/ssr-html.spec.ts index 59d30456f7d8ae..4c9e4b0b6a1811 100644 --- a/playground/ssr-html/__tests__/ssr-html.spec.ts +++ b/playground/ssr-html/__tests__/ssr-html.spec.ts @@ -127,7 +127,7 @@ describe.runIf(isServe && !noNetworkImports)('network-imports', () => { [ '--experimental-network-imports', 'test-network-imports.js', - '--runtime', + '--module-runner', ], { cwd: fileURLToPath(new URL('..', import.meta.url)), diff --git a/playground/ssr-html/test-network-imports.js b/playground/ssr-html/test-network-imports.js index 91f84f6a3b3ea3..6e6a87d93d4219 100644 --- a/playground/ssr-html/test-network-imports.js +++ b/playground/ssr-html/test-network-imports.js @@ -1,8 +1,8 @@ import assert from 'node:assert' import { fileURLToPath } from 'node:url' -import { createServer, createViteRuntime } from 'vite' +import { createServer, createServerModuleRunner } from 'vite' -async function runTest(useRuntime) { +async function runTest(userRunner) { const server = await createServer({ configFile: false, root: fileURLToPath(new URL('.', import.meta.url)), @@ -11,9 +11,11 @@ async function runTest(useRuntime) { }, }) let mod - if (useRuntime) { - const runtime = await createViteRuntime(server, { hmr: false }) - mod = await runtime.executeUrl('/src/network-imports.js') + if (userRunner) { + const runner = await createServerModuleRunner(server.environments.ssr, { + hmr: false, + }) + mod = await runner.import('/src/network-imports.js') } else { mod = await server.ssrLoadModule('/src/network-imports.js') } @@ -21,4 +23,4 @@ async function runTest(useRuntime) { await server.close() } -runTest(process.argv.includes('--runtime')) +runTest(process.argv.includes('--module-runner')) diff --git a/playground/ssr-html/test-stacktrace-runtime.js b/playground/ssr-html/test-stacktrace-runtime.js index c2b8f670b5a089..0f4914dcbfe599 100644 --- a/playground/ssr-html/test-stacktrace-runtime.js +++ b/playground/ssr-html/test-stacktrace-runtime.js @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import assert from 'node:assert' -import { createServer, createViteRuntime } from 'vite' +import { createServer, createServerModuleRunner } from 'vite' // same test case as packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts // implemented for e2e to catch build specific behavior @@ -13,11 +13,11 @@ const server = await createServer({ }, }) -const runtime = await createViteRuntime(server, { +const runner = await createServerModuleRunner(server.environments.ssr, { sourcemapInterceptor: 'prepareStackTrace', }) -const mod = await runtime.executeEntrypoint('/src/has-error-deep.ts') +const mod = await runner.import('/src/has-error-deep.ts') let error try { mod.main() diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index eb28b5f544d453..fcf8c7b01903d0 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -13,6 +13,7 @@ import type { } from 'vite' import { build, + createBuilder, createServer, loadConfigFromFile, mergeConfig, @@ -266,15 +267,20 @@ export async function startDefaultServe(): Promise { plugins: [resolvedPlugin()], }, ) - const rollupOutput = await build(buildConfig) - const isWatch = !!resolvedConfig!.build.watch - // in build watch,call startStaticServer after the build is complete - if (isWatch) { - watcher = rollupOutput as RollupWatcher - await notifyRebuildComplete(watcher) - } - if (buildConfig.__test__) { - buildConfig.__test__() + if (buildConfig.builder) { + const builder = await createBuilder({ root: rootDir }) + await builder.buildApp() + } else { + const rollupOutput = await build(buildConfig) + const isWatch = !!resolvedConfig!.build.watch + // in build watch,call startStaticServer after the build is complete + if (isWatch) { + watcher = rollupOutput as RollupWatcher + await notifyRebuildComplete(watcher) + } + if (buildConfig.__test__) { + buildConfig.__test__() + } } const previewConfig = await loadConfig({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 135e5e3eeab26d..a9229565822bbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@eslint/js': specifier: ^9.9.1 version: 9.9.1 + '@type-challenges/utils': + specifier: ^0.1.1 + version: 0.1.1 '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -150,7 +153,7 @@ importers: version: 4.2.2 vitepress: specifier: 1.3.4 - version: 1.3.4(@algolia/client-search@4.20.0)(axios@1.7.7)(postcss@8.4.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3) + version: 1.3.4(@algolia/client-search@4.20.0)(@types/react@18.3.3)(axios@1.7.7)(postcss@8.4.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3) vue: specifier: ^3.4.38 version: 3.4.38(typescript@5.5.3) @@ -340,6 +343,9 @@ importers: mrmime: specifier: ^2.0.0 version: 2.0.0 + nanoid: + specifier: ^5.0.7 + version: 5.0.7 open: specifier: ^8.4.2 version: 8.4.2 @@ -413,6 +419,14 @@ importers: specifier: ^8.18.0 version: 8.18.0 + packages/vite/src/node/__tests__: + dependencies: + '@vitejs/cjs-ssr-dep': + specifier: link:./fixtures/cjs-ssr-dep + version: link:fixtures/cjs-ssr-dep + + packages/vite/src/node/__tests__/fixtures/cjs-ssr-dep: {} + packages/vite/src/node/__tests__/packages/module: {} packages/vite/src/node/__tests__/packages/name: {} @@ -433,14 +447,6 @@ importers: packages/vite/src/node/server/__tests__/fixtures/yarn/nested: {} - packages/vite/src/node/ssr/__tests__: - dependencies: - '@vitejs/cjs-ssr-dep': - specifier: link:./fixtures/cjs-ssr-dep - version: link:fixtures/cjs-ssr-dep - - packages/vite/src/node/ssr/__tests__/fixtures/cjs-ssr-dep: {} - packages/vite/src/node/ssr/runtime/__tests__: dependencies: '@vitejs/cjs-external': @@ -664,6 +670,21 @@ importers: playground/env-nested: {} + playground/environment-react-ssr: + devDependencies: + '@types/react': + specifier: ^18.2.73 + version: 18.3.3 + '@types/react-dom': + specifier: ^18.2.23 + version: 18.3.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + playground/extensions: dependencies: vue: @@ -3184,6 +3205,9 @@ packages: '@tsconfig/node16@1.0.2': resolution: {integrity: sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==} + '@type-challenges/utils@0.1.1': + resolution: {integrity: sha512-A7ljYfBM+FLw+NDyuYvGBJiCEV9c0lPWEAdzfOAkb3JFqfLl0Iv/WhWMMARHiRKlmmiD1g8gz/507yVvHdQUYA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3289,12 +3313,21 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/prop-types@15.7.12': + resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + '@types/qs@6.9.12': resolution: {integrity: sha512-bZcOkJ6uWrL0Qb2NAWKa7TBU+mJHPzhx9jjLL1KHF+XpzEcR7EXHvjbHlGtR/IsP1vyPrehuS6XqkmaePy//mg==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.0': + resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + + '@types/react@18.3.3': + resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -5537,6 +5570,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.0.7: + resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -7951,9 +7989,9 @@ snapshots: '@docsearch/css@3.6.1': {} - '@docsearch/js@3.6.1(@algolia/client-search@4.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docsearch/js@3.6.1(@algolia/client-search@4.20.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@docsearch/react': 3.6.1(@algolia/client-search@4.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/react': 3.6.1(@algolia/client-search@4.20.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) preact: 10.7.3 transitivePeerDependencies: - '@algolia/client-search' @@ -7962,13 +8000,14 @@ snapshots: - react-dom - search-insights - '@docsearch/react@3.6.1(@algolia/client-search@4.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docsearch/react@3.6.1(@algolia/client-search@4.20.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@algolia/autocomplete-core': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0) '@algolia/autocomplete-preset-algolia': 1.9.3(@algolia/client-search@4.20.0)(algoliasearch@4.20.0) '@docsearch/css': 3.6.1 algoliasearch: 4.20.0 optionalDependencies: + '@types/react': 18.3.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -8571,6 +8610,8 @@ snapshots: '@tsconfig/node16@1.0.2': {} + '@type-challenges/utils@0.1.1': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.25.6 @@ -8691,10 +8732,21 @@ snapshots: '@types/node': 20.14.14 kleur: 3.0.3 + '@types/prop-types@15.7.12': {} + '@types/qs@6.9.12': {} '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.0': + dependencies: + '@types/react': 18.3.3 + + '@types/react@18.3.3': + dependencies: + '@types/prop-types': 15.7.12 + csstype: 3.1.3 + '@types/resolve@1.20.2': {} '@types/semver@7.5.8': {} @@ -11233,6 +11285,8 @@ snapshots: nanoid@3.3.7: {} + nanoid@5.0.7: {} + natural-compare@1.4.0: {} needle@3.3.1: @@ -12539,10 +12593,10 @@ snapshots: transitivePeerDependencies: - supports-color - vitepress@1.3.4(@algolia/client-search@4.20.0)(axios@1.7.7)(postcss@8.4.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3): + vitepress@1.3.4(@algolia/client-search@4.20.0)(@types/react@18.3.3)(axios@1.7.7)(postcss@8.4.43)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3): dependencies: '@docsearch/css': 3.6.1 - '@docsearch/js': 3.6.1(@algolia/client-search@4.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/js': 3.6.1(@algolia/client-search@4.20.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@shikijs/core': 1.14.1 '@shikijs/transformers': 1.14.1 '@types/markdown-it': 14.1.2 diff --git a/vitest.config.ts b/vitest.config.ts index 4167b96a90a280..7ee61c4585006d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,9 +22,9 @@ export default defineConfig({ publicDir: false, resolve: { alias: { - 'vite/runtime': path.resolve( + 'vite/module-runner': path.resolve( _dirname, - './packages/vite/src/runtime/index.ts', + './packages/vite/src/module-runner/index.ts', ), }, },