diff --git a/docs/guide/api-plugin.md b/docs/guide/api-plugin.md index 272461da446b5e..16fd8c50210d69 100644 --- a/docs/guide/api-plugin.md +++ b/docs/guide/api-plugin.md @@ -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` +- **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` +- **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 }` diff --git a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts index 53438096c22011..31f5ddf12b231b 100644 --- a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts @@ -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) + }) +}) diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index a0be15a581123e..bed86871a58aa0 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -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' @@ -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' @@ -303,6 +303,20 @@ export interface Plugin extends RolldownPlugin { * applied. Hooks can be async functions and will be called in series. */ configurePreviewServer?: ObjectHook + /** + * 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 + /** + * Same as `closeServer` but for the preview server. The preview server never + * restarts, so no `reason` is provided. + */ + closePreviewServer?: ObjectHook /** * Transform index.html. * The hook receives the following arguments: diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index ac34549420e126..93e1331491b561 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -121,6 +121,10 @@ export type PreviewServerHook = ( server: PreviewServer, ) => (() => void) | void | Promise<(() => void) | void> +export type ClosePreviewServerHook = ( + this: MinimalPluginContextWithoutEnvironment, +) => void | Promise + /** * Starts the Vite server in preview mode, to simulate a production deployment */ @@ -169,8 +173,20 @@ export async function preview( let closeServerPromise: Promise | 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 = { diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 84a8d567a278eb..d3a51e9dfd5002 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -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 + export type HttpServer = http.Server | Http2SecureServer export async function resolveForwardConsoleOptions( @@ -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 /** * @internal */ @@ -606,7 +627,7 @@ export async function _createServer( // Promise used by `server.close()` to ensure `closeServer()` is only called once let closeServerPromise: Promise | undefined - const closeServer = async () => { + const closeServer = async (reason: 'restart' | 'close') => { if (!middlewareMode) { teardownSIGTERMListener(closeServerAndExit) } @@ -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 @@ -782,10 +814,7 @@ export async function _createServer( } }, async close() { - if (!closeServerPromise) { - closeServerPromise = closeServer() - } - return closeServerPromise + return server._closeServer('close') }, printUrls() { if (server.resolvedUrls) { @@ -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, @@ -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