Skip to content
Merged
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
52 changes: 52 additions & 0 deletions docs/guide/api-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,58 @@ Vite plugins can also provide hooks that serve Vite-specific purposes. These hoo
})
```

### `closeServer`

- **Type:** `(context: { reason: 'restart' | 'close' }) => void | Promise<void>`
- **Kind:** `async`, `parallel`
- **Scope:** [Global](/guide/api-environment-plugins#per-environment-hooks-and-global-hooks)

Called when the dev server is restarted or closed, after the server has been torn down. Typically used to dispose resources created in [`configureServer`](/guide/api-plugin.html#configureserver).

The `context.reason` distinguishes the two cases:
- `'restart'`: the server is restarting (e.g. a config file change or a call to `server.restart()`).
- `'close'`: the server is shutting down (e.g. the `q` shortcut, or a call to `server.close()`).

```js
const myPlugin = () => {
let resource
return {
name: 'close-server',
configureServer(server) {
resource = createResource()
},
async closeServer({ reason }) {
if (reason === 'close') {
await resource.dispose()
}
},
}
}
```

### `closePreviewServer`

- **Type:** `() => void | Promise<void>`
- **Kind:** `async`, `parallel`
- **Scope:** [Global](/guide/api-environment-plugins#per-environment-hooks-and-global-hooks)

Same as [`closeServer`](/guide/api-plugin.html#closeserver) but for the preview server. The preview server never restarts, so there is no `reason`.

```js
const myPlugin = () => {
let resource
return {
name: 'close-preview-server',
configurePreviewServer(server) {
resource = createResource()
},
async closePreviewServer() {
await resource.dispose()
},
}
}
```

### `transformIndexHtml`

- **Type:** `IndexHtmlTransformHook | { order?: 'pre' | 'post', handler: IndexHtmlTransformHook }`
Expand Down
166 changes: 166 additions & 0 deletions packages/vite/src/node/__tests__/plugins/hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,169 @@ describe('watcher add/unlink error handling', () => {
expect(logError).toHaveBeenCalledWith(error)
})
})

describe('closeServer hook', () => {
test('is called with reason "close" on server.close()', async () => {
const closeServer = vi.fn()
const server = await createServerWithPlugin({
name: 'test',
closeServer,
})

await server.close()

expect(closeServer).toHaveBeenCalledTimes(1)
expect(closeServer).toHaveBeenCalledWith({ reason: 'close' })
})

test('receives a minimal plugin context as `this`', async () => {
expect.assertions(2)

const server = await createServerWithPlugin({
name: 'test',
closeServer() {
expect(this).toMatchObject({
debug: expect.any(Function),
info: expect.any(Function),
warn: expect.any(Function),
error: expect.any(Function),
meta: expect.any(Object),
})
// Global hooks don't have an environment.
expect(this).not.toHaveProperty('environment')
},
})

await server.close()
})

test('is awaited before server.close() resolves', async () => {
let hookDone = false
const server = await createServerWithPlugin({
name: 'test',
async closeServer() {
await new Promise((r) => setTimeout(r, 10))
hookDone = true
},
})

await server.close()

// `server.close()` does not resolve until the async hook has completed.
expect(hookDone).toBe(true)
})

test('runs after the server is torn down (after closeBundle)', async () => {
const order: string[] = []
const server = await createServerWithPlugin({
name: 'test',
closeBundle() {
order.push('closeBundle')
},
closeServer() {
order.push('closeServer')
},
})

await server.close()

// `closeBundle` runs as part of teardown (once per environment); the
// `closeServer` hook runs afterwards, so it is the last event.
expect(order.at(-1)).toBe('closeServer')
expect(order.indexOf('closeBundle')).toBeLessThan(
order.indexOf('closeServer'),
)
})

test('runs hooks in parallel', async () => {
const events: string[] = []
const server = await createServer({
configFile: false,
root: import.meta.dirname,
plugins: [
{
name: 'a',
async closeServer() {
events.push('a:start')
await new Promise((r) => setTimeout(r, 20))
events.push('a:end')
},
},
{
name: 'b',
async closeServer() {
events.push('b:start')
await new Promise((r) => setTimeout(r, 20))
events.push('b:end')
},
},
resolveEntryPlugin,
],
logLevel: 'error',
server: { middlewareMode: true, ws: false },
})

await server.close()

// Both hooks start before either finishes.
expect(events.slice(0, 2)).toStrictEqual(['a:start', 'b:start'])
})

test('is called only once even if close() is called multiple times', async () => {
const closeServer = vi.fn()
const server = await createServerWithPlugin({
name: 'test',
closeServer,
})

await Promise.all([server.close(), server.close()])
await server.close()

expect(closeServer).toHaveBeenCalledTimes(1)
})

test('is called with reason "restart" on server.restart()', async () => {
const closeServer = vi.fn()
const server = await createServerWithPlugin({
name: 'test',
closeServer,
})

await server.restart()

expect(closeServer).toHaveBeenCalledTimes(1)
expect(closeServer).toHaveBeenCalledWith({ reason: 'restart' })

await server.close()
})
})

describe('closePreviewServer hook', () => {
test('is called on preview server.close()', async () => {
const closePreviewServer = vi.fn()
const server = await createPreviewServerWithPlugin({
name: 'test',
closePreviewServer,
})

await server.close()

expect(closePreviewServer).toHaveBeenCalledTimes(1)
})

test('is awaited before server.close() resolves', async () => {
let hookDone = false
const server = await createPreviewServerWithPlugin({
name: 'test',
async closePreviewServer() {
await new Promise((r) => setTimeout(r, 10))
hookDone = true
},
})

await server.close()

// `server.close()` does not resolve until the async hook has completed.
expect(hookDone).toBe(true)
})
})
18 changes: 16 additions & 2 deletions packages/vite/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type {
ResolvedConfig,
UserConfig,
} from './config'
import type { ServerHook } from './server'
import type { CloseServerHook, ServerHook } from './server'
import type { BuildAppHook } from './build'
import type { IndexHtmlTransform } from './plugins/html'
import type { EnvironmentModuleNode } from './server/moduleGraph'
Expand All @@ -28,7 +28,7 @@ import type { HmrContext, HotUpdateOptions } from './server/hmr'
import type { DevEnvironment } from './server/environment'
import type { Environment } from './environment'
import type { PartialEnvironment } from './baseEnvironment'
import type { PreviewServerHook } from './preview'
import type { ClosePreviewServerHook, PreviewServerHook } from './preview'
import { arraify, asyncFlatten } from './utils'
import type { StringFilter } from './plugins/pluginFilter'

Expand Down Expand Up @@ -303,6 +303,20 @@ export interface Plugin<A = any> extends RolldownPlugin<A> {
* applied. Hooks can be async functions and will be called in series.
*/
configurePreviewServer?: ObjectHook<PreviewServerHook>
/**
* Run logic when the server is restarted or closed. The hook receives a
* `reason` that is `'restart'` when the server is restarting and `'close'`
* when it is closing.
*
* The hooks are called after the server is torn down. Hooks can be async
* functions and will be called in parallel.
*/
closeServer?: ObjectHook<CloseServerHook>
/**
* Same as `closeServer` but for the preview server. The preview server never
* restarts, so no `reason` is provided.
*/
closePreviewServer?: ObjectHook<ClosePreviewServerHook>
/**
* Transform index.html.
* The hook receives the following arguments:
Expand Down
16 changes: 16 additions & 0 deletions packages/vite/src/node/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ export type PreviewServerHook = (
server: PreviewServer,
) => (() => void) | void | Promise<(() => void) | void>

export type ClosePreviewServerHook = (
this: MinimalPluginContextWithoutEnvironment,
) => void | Promise<void>

/**
* Starts the Vite server in preview mode, to simulate a production deployment
*/
Expand Down Expand Up @@ -169,8 +173,20 @@ export async function preview(
let closeServerPromise: Promise<void> | undefined
const closeServer = async () => {
teardownSIGTERMListener(closeServerAndExit)

await closeHttpServer()
server.resolvedUrls = null

// Run `closePreviewServer` plugin hooks after the server has been torn down.
const closePreviewServerContext = new BasicMinimalPluginContext(
{ ...basePluginContextMeta, watchMode: false },
config.logger,
)
await Promise.all(
config
.getSortedPluginHooks('closePreviewServer')
.map((hook) => hook.call(closePreviewServerContext)),
)
}

const server: PreviewServer = {
Expand Down
49 changes: 43 additions & 6 deletions packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,20 @@ export type ServerHook = (
server: ViteDevServer,
) => (() => void) | void | Promise<(() => void) | void>

export interface CloseServerHookContext {
/**
* Whether the server is being restarted (e.g. a config change or
* `server.restart()`) or closed (e.g. the `q` shortcut, SIGTERM, stdin
* ending, or `server.close()`).
*/
reason: 'restart' | 'close'
}

export type CloseServerHook = (
this: MinimalPluginContextWithoutEnvironment,
context: CloseServerHookContext,
) => void | Promise<void>

export type HttpServer = http.Server | Http2SecureServer

export async function resolveForwardConsoleOptions(
Expand Down Expand Up @@ -439,6 +453,13 @@ export interface ViteDevServer {
* @internal
*/
_setInternalServer(server: ViteDevServer): void
/**
* Internal close implementation shared by `close()` and `restart()`. The
* `reason` is forwarded to `closeServer` plugin hooks so they can distinguish
* a restart from a real close.
* @internal
*/
_closeServer(reason: 'restart' | 'close'): Promise<void>
/**
* @internal
*/
Expand Down Expand Up @@ -606,7 +627,7 @@ export async function _createServer(

// Promise used by `server.close()` to ensure `closeServer()` is only called once
let closeServerPromise: Promise<void> | undefined
const closeServer = async () => {
const closeServer = async (reason: 'restart' | 'close') => {
if (!middlewareMode) {
teardownSIGTERMListener(closeServerAndExit)
}
Expand All @@ -624,6 +645,17 @@ export async function _createServer(
])
server.resolvedUrls = null
server._ssrCompatModuleRunner = undefined

// Run `closeServer` plugin hooks after the server has been torn down.
const closeServerContext = new BasicMinimalPluginContext(
{ ...basePluginContextMeta, watchMode: true },
config.logger,
)
await Promise.all(
config
.getSortedPluginHooks('closeServer')
.map((hook) => hook.call(closeServerContext, { reason })),
)
}

let hot = ws
Expand Down Expand Up @@ -782,10 +814,7 @@ export async function _createServer(
}
},
async close() {
if (!closeServerPromise) {
closeServerPromise = closeServer()
}
return closeServerPromise
return server._closeServer('close')
},
printUrls() {
if (server.resolvedUrls) {
Expand Down Expand Up @@ -825,6 +854,12 @@ export async function _createServer(
// server instance after a restart
server = _server
},
_closeServer(reason: 'restart' | 'close') {
if (!closeServerPromise) {
closeServerPromise = closeServer(reason)
}
return closeServerPromise
},
_restartPromise: options.previousRestartPromise ?? null,
_forceOptimizeOnRestart: options.previousForceOptimizeOnRestart ?? false,
_shortcutsState: options.previousShortcutsState,
Expand Down Expand Up @@ -1380,7 +1415,9 @@ async function restartServer(server: ViteDevServer) {
// Detach readline so close handler skips it. Reused to avoid stdin issues
server._shortcutsState = undefined

await server.close()
// Close with reason 'restart' so `closeServer` hooks can distinguish a
// restart from a real close.
await server._closeServer('restart')

// Assign new server props to existing server instance
const middlewares = server.middlewares
Expand Down
Loading