Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/guide/api-environment-frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,39 @@ await builder.buildApp()

`createBuilder` supersedes the standalone `build` function for environment-aware builds. `build` still works as the simple entry point for the legacy client-only and ssr-only builds described above, but it cannot build arbitrary environments. Running `builder.buildApp()` is the programmatic equivalent of `vite build --app`.

### Server request entrypoints

:::info Experimental
This API is experimental. We're gathering feedback in the [RFC discussion](https://github.com/vitejs/vite/discussions/22507).
:::

A server environment's bundle can contain several entries, only some of which are request handlers (for example an SSR entry alongside an RSC entry). A framework can annotate which inputs are request handler entrypoints with `requestEntrypoints`, so that framework-agnostic provider plugins (e.g. for deployment platforms) can locate the emitted files instead of guessing from `build.rollupOptions.input`.

Each key must match a named entry in `build.rollupOptions.input`. Entrypoints are assumed to be [Fetchable](https://fetchable.org/) modules unless marked `{ type: 'custom' }`. `requestEntrypoints` is only valid on `consumer: 'server'` environments.

```js [vite.config.js]
export default {
environments: {
server: {
consumer: 'server',
build: {
rollupOptions: {
input: {
ssr: 'virtual:my-framework/ssr-entry',
rsc: 'virtual:my-framework/rsc-entry',
},
},
},
// `ssr` is a request handler; `rsc` is not, so it is omitted.
// Shorthand for `{ ssr: {} }` when all entries are fetchable: `['ssr']`.
requestEntrypoints: { ssr: { type: 'fetchable' } },
},
},
}
```

Provider plugins read these annotations during the build to locate the emitted files. See [Resolving request entrypoints](./api-environment-plugins.md#resolving-request-entrypoints) for the consuming side.

## Environment Agnostic Code

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`. See [Environment API for Plugins](./api-environment-plugins.md) to learn about how to build environment aware plugins.
34 changes: 34 additions & 0 deletions docs/guide/api-environment-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,40 @@ The application side is same with the Client-server Communication feature. You c
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.

### Resolving request entrypoints

:::info Experimental
This API is experimental. We're gathering feedback in the [RFC discussion](https://github.com/vitejs/vite/discussions/22507).
:::

When a framework annotates a server environment's request handler entrypoints with [`requestEntrypoints`](./api-environment-frameworks.md#server-request-entrypoints), a provider plugin (for example, for a deployment platform) can resolve them to their emitted files from the `generateBundle` hook via `this.environment.getRequestEntrypointOutputs(bundle)`:

```js
export function myPlatform() {
return {
name: 'my-platform',
applyToEnvironment(environment) {
return environment.config.consumer === 'server'
},
generateBundle(_options, bundle) {
for (const entrypoint of this.environment.getRequestEntrypointOutputs(
bundle,
)) {
// entrypoint: { name, type, fileName, chunk }
if (entrypoint.type === 'fetchable') {
generateServerlessFunction({
name: entrypoint.name,
handler: entrypoint.fileName,
})
}
}
},
}
}
```

`this.environment.getRequestEntrypoints()` returns the configured entrypoints without needing a bundle. A configured name that doesn't resolve to exactly one emitted entry chunk throws during the build.

## Shared Plugins During Build

Before Vite 6, the plugins pipelines worked in a different way during dev and build:
Expand Down
127 changes: 127 additions & 0 deletions packages/vite/src/node/__tests__/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../build'
import type { Logger } from '../logger'
import { createLogger } from '../logger'
import type { RequestEntrypointOutput } from '..'
import { BuildEnvironment, resolveConfig } from '..'

const dirname = import.meta.dirname
Expand Down Expand Up @@ -805,6 +806,132 @@ describe('resolveBuildOutputs', () => {
const result = await builder.build(builder.environments.custom)
expect((result as RolldownOutput).output[0].code).not.toContain('preload')
})

test('getRequestEntrypointOutputs resolves entrypoints to emitted chunks', async () => {
const captured: RequestEntrypointOutput[] = []
const builder = await createBuilder({
root: resolve(dirname, 'fixtures/emit-assets'),
logLevel: 'warn',
environments: {
ssr: {
build: {
ssr: true,
rollupOptions: { input: { index: '/entry' } },
},
requestEntrypoints: { index: { type: 'fetchable' } },
},
},
plugins: [
{
name: 'provider-like',
generateBundle(_options, bundle) {
captured.push(
...this.environment.getRequestEntrypointOutputs(bundle),
)
},
},
],
})
// getRequestEntrypoints() returns the resolved annotations independent of any bundle
expect(builder.environments.ssr.getRequestEntrypoints()).toStrictEqual([
{ name: 'index', type: 'fetchable' },
])

await builder.build(builder.environments.ssr)
expect(captured).toMatchObject([
{ name: 'index', type: 'fetchable', fileName: 'index.mjs' },
])
expect(captured[0].chunk.isEntry).toBe(true)
})

test('throws when a requestEntrypoint does not match an input name', async () => {
const builder = await createBuilder({
root: resolve(dirname, 'fixtures/emit-assets'),
logLevel: 'silent',
environments: {
ssr: {
build: {
ssr: true,
rollupOptions: { input: { index: '/entry' } },
},
requestEntrypoints: { nope: {} },
},
},
})
await expect(builder.build(builder.environments.ssr)).rejects.toThrow(
/does not match any/,
)
})

test('resolves entrypoints against derived names for array form input', async () => {
const captured: RequestEntrypointOutput[] = []
const builder = await createBuilder({
root: resolve(dirname, 'fixtures/emit-assets'),
logLevel: 'warn',
environments: {
ssr: {
build: {
ssr: true,
rollupOptions: { input: ['/entry'] },
},
requestEntrypoints: ['entry'],
},
},
plugins: [
{
name: 'provider-like',
generateBundle(_options, bundle) {
captured.push(
...this.environment.getRequestEntrypointOutputs(bundle),
)
},
},
],
})
await builder.build(builder.environments.ssr)
expect(captured).toMatchObject([
{ name: 'entry', type: 'fetchable', fileName: 'entry.mjs' },
])
})

test('throws when a requestEntrypoint matches multiple entry chunks', async () => {
const builder = await createBuilder({
root: resolve(dirname, 'fixtures/request-entrypoints'),
logLevel: 'silent',
environments: {
ssr: {
build: {
ssr: true,
rollupOptions: { input: ['/a/handler.js', '/b/handler.js'] },
},
requestEntrypoints: ['handler'],
},
},
})
await expect(builder.build(builder.environments.ssr)).rejects.toThrow(
/ambiguous/,
)
})

test('does not validate requestEntrypoints on a non-server environment', async () => {
const builder = await createBuilder({
root: resolve(dirname, 'fixtures/emit-assets'),
logLevel: 'silent',
environments: {
client: {
build: {
rollupOptions: { input: { index: '/entry' } },
},
// unmatched name, but ignored because `client` is not a server consumer
requestEntrypoints: { nope: {} },
},
},
})
// the build succeeds rather than throwing on the unmatched name
await expect(
builder.build(builder.environments.client),
).resolves.toBeDefined()
})
})

test('default sharedConfigBuild true on build api', async () => {
Expand Down
75 changes: 75 additions & 0 deletions packages/vite/src/node/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,81 @@ describe('resolveConfig', () => {
})
})

describe('requestEntrypoints', () => {
test('normalizes the record form and defaults type to fetchable', async () => {
const config = await resolveConfig(
{
environments: {
ssr: { requestEntrypoints: { ssr: { type: 'fetchable' }, api: {} } },
},
},
'build',
)
expect(config.environments.ssr.requestEntrypoints).toStrictEqual([
{ name: 'ssr', type: 'fetchable' },
{ name: 'api', type: 'fetchable' },
])
})

test('preserves an explicit custom type', async () => {
const config = await resolveConfig(
{
environments: {
ssr: { requestEntrypoints: { api: { type: 'custom' }, ssr: {} } },
},
},
'build',
)
expect(config.environments.ssr.requestEntrypoints).toStrictEqual([
{ name: 'api', type: 'custom' },
{ name: 'ssr', type: 'fetchable' },
])
})

test('normalizes the array shorthand to fetchable entries', async () => {
const config = await resolveConfig(
{ environments: { ssr: { requestEntrypoints: ['ssr', 'api'] } } },
'build',
)
expect(config.environments.ssr.requestEntrypoints).toStrictEqual([
{ name: 'ssr', type: 'fetchable' },
{ name: 'api', type: 'fetchable' },
])
})

test('defaults to an empty array when unset', async () => {
const config = await resolveConfig({}, 'serve')
expect(config.environments.client.requestEntrypoints).toStrictEqual([])
expect(config.environments.ssr.requestEntrypoints).toStrictEqual([])
})

test('does not leak from a server environment into the client environment', async () => {
const config = await resolveConfig(
{ environments: { ssr: { requestEntrypoints: { ssr: {} } } } },
'build',
)
expect(config.environments.ssr.requestEntrypoints).toStrictEqual([
{ name: 'ssr', type: 'fetchable' },
])
expect(config.environments.client.requestEntrypoints).toStrictEqual([])
})

test('warns when set on a non-server environment', async () => {
expect.assertions(1)
const logger = createLogger('info')
logger.warnOnce = (str) => {
expect(str).to.include('only meaningful for server environments')
}
await resolveConfig(
{
customLogger: logger,
environments: { client: { requestEntrypoints: ['index'] } },
},
'serve',
)
})
})

test('config compat 1', async () => {
const config = await resolveConfig(
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function handler() {
return new Response('a')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function handler() {
return new Response('b')
}
41 changes: 41 additions & 0 deletions packages/vite/src/node/__tests_dts__/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { Equal, ExpectTrue } from '@type-challenges/utils'
import {
type EnvironmentOptions,
type UserConfig,
type UserConfigExport,
type UserConfigFn,
Expand Down Expand Up @@ -100,4 +101,44 @@ mergeConfig(
defineConfig({}),
)

defineConfig({
// @ts-expect-error --- `requestEntrypoints` is not a top-level option
requestEntrypoints: ['ssr'],
})

type IsAssignable<T, U> = T extends U ? true : false

export type requestEntrypointsCases = [
// allowed on a server environment (record form)
ExpectTrue<
Equal<
IsAssignable<
{
consumer: 'server'
requestEntrypoints: { ssr: { type: 'fetchable' } }
},
EnvironmentOptions
>,
true
>
>,
// allowed when `consumer` is left to default (server for non-client environments)
ExpectTrue<
Equal<
IsAssignable<{ requestEntrypoints: string[] }, EnvironmentOptions>,
true
>
>,
// rejected on a client environment
ExpectTrue<
Equal<
IsAssignable<
{ consumer: 'client'; requestEntrypoints: string[] },
EnvironmentOptions
>,
false
>
>,
]

export {}
Loading
Loading