diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 219c9dd2a9138f..24422159bbbb84 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -54,6 +54,7 @@ import { shouldExternalizeForSSR, } from '../ssr/ssrExternal' import { getDepsOptimizer, optimizedDepNeedsInterop } from '../optimizer' +import { ERR_CLOSED_SERVER } from '../server/pluginContainer' import { checkPublicFile } from './asset' import { ERR_OUTDATED_OPTIMIZED_DEP, @@ -255,10 +256,10 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // have been loaded so its entry is guaranteed in the module graph. const importerModule = moduleGraph.getModuleById(importer)! if (!importerModule) { - // When the server is restarted, the module graph is cleared, so we - // return without transforming. This request is no longer valid, a full reload - // is going to request this id again. Throwing an outdated error so we - // properly finish the request with a 504 sent to the browser. + // This request is no longer valid. It could happen for optimized deps + // requests. A full reload is going to request this id again. + // Throwing an outdated error so we properly finish the request with a + // 504 sent to the browser. throwOutdatedRequest(importer) } @@ -650,8 +651,11 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // by the deps optimizer const url = removeImportQuery(hmrUrl) server.transformRequest(url, { ssr }).catch((e) => { - if (e?.code === ERR_OUTDATED_OPTIMIZED_DEP) { - // This are expected errors + 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 diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 17f10e5e8676ab..4c2c1f8d5688b8 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -467,6 +467,17 @@ export async function _createServer( getDepsOptimizer(server.config, true)?.close(), closeHttpServer(), ]) + // Await pending requests. We throw early in transformRequest + // and in hooks if the server is closing, so the import analysis + // plugin stops pre-transforming static imports and this block + // is resolved sooner. + while (server._pendingRequests.size > 0) { + await Promise.allSettled( + [...server._pendingRequests.values()].map( + (pending) => pending.request, + ), + ) + } server.resolvedUrls = null }, printUrls() { diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index a063bc91bdbaf9..9de3d1f7c7b548 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -36,6 +36,8 @@ import { unwrapId, wrapId, } from '../../utils' +import { ERR_CLOSED_SERVER } from '../pluginContainer' +import { ERR_OUTDATED_OPTIMIZED_DEP } from '../../plugins/optimizedDeps' import { isCSSRequest } from '../../plugins/css' import { checkPublicFile } from '../../plugins/asset' import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap' @@ -349,6 +351,13 @@ function preTransformRequest(server: ViteDevServer, url: string, base: string) { // transform all url as non-ssr as html includes client-side assets only server.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 server.config.logger.error(e.message) }) diff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts index 0e83f997c9c892..d71e2390866d22 100644 --- a/packages/vite/src/node/server/middlewares/transform.ts +++ b/packages/vite/src/node/server/middlewares/transform.ts @@ -35,6 +35,7 @@ import { ERR_OPTIMIZE_DEPS_PROCESSING_ERROR, ERR_OUTDATED_OPTIMIZED_DEP, } from '../../plugins/optimizedDeps' +import { ERR_CLOSED_SERVER } from '../pluginContainer' import { getDepsOptimizer } from '../../optimizer' const debugCache = createDebugger('vite:cache') @@ -234,6 +235,21 @@ export function transformMiddleware( // error but a normal part of the missing deps discovery flow return } + if (e?.code === ERR_CLOSED_SERVER) { + // Skip if response has already been sent + if (!res.writableEnded) { + res.statusCode = 504 // status code request timeout + res.statusMessage = 'Outdated Request' + res.end() + } + // We don't need to log an error in this case, the request + // is outdated because new dependencies were discovered and + // the new pre-bundle dependencies have changed. + // A full-page reload has been issued, and these old requests + // can't be properly fulfilled. This isn't an unexpected + // error but a normal part of the missing deps discovery flow + return + } if (e?.code === ERR_LOAD_URL) { // Let other middleware handle if we can't load the url via transformRequest return next() diff --git a/packages/vite/src/node/server/pluginContainer.ts b/packages/vite/src/node/server/pluginContainer.ts index a0e46cf4a2fb06..29d5efe5e0da78 100644 --- a/packages/vite/src/node/server/pluginContainer.ts +++ b/packages/vite/src/node/server/pluginContainer.ts @@ -84,6 +84,18 @@ import { createPluginHookUtils } from '../plugins' import { buildErrorMessage } from './middlewares/error' import type { ModuleGraph } from './moduleGraph' +export const ERR_CLOSED_SERVER = 'ERR_CLOSED_SERVER' + +export function throwClosedServerError(): never { + const err: any = new Error( + 'The server is being restarted or closed. Request is outdated', + ) + err.code = ERR_CLOSED_SERVER + // This error will be caught by the transform middleware that will + // send a 504 status code request timeout + throw err +} + export interface PluginContainerOptions { cwd?: string output?: OutputOptions @@ -195,6 +207,7 @@ export async function createPluginContainer( ): Promise { const parallelPromises: Promise[] = [] for (const plugin of getSortedPlugins(hookName)) { + // Don't throw here if closed, so buildEnd and closeBundle hooks can finish running const hook = plugin[hookName] if (!hook) continue // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -571,12 +584,26 @@ export async function createPluginContainer( } let closed = false + const processesing = new Set>() + // keeps track of hook promises so that we can wait for them all to finish upon closing the server + function handleHookPromise(maybePromise: undefined | T | Promise) { + if (!(maybePromise as any)?.then) { + return maybePromise + } + const promise = maybePromise as Promise + processesing.add(promise) + return promise.finally(() => processesing.delete(promise)) + } const container: PluginContainer = { options: await (async () => { let options = rollupOptions for (const optionsHook of getSortedPluginHooks('options')) { - options = (await optionsHook.call(minimalContext, options)) || options + if (closed) throwClosedServerError() + options = + (await handleHookPromise( + optionsHook.call(minimalContext, options), + )) || options } if (options.acornInjectPlugins) { parser = acorn.Parser.extend( @@ -593,10 +620,12 @@ export async function createPluginContainer( getModuleInfo, async buildStart() { - await hookParallel( - 'buildStart', - (plugin) => new Context(plugin), - () => [container.options as NormalizedInputOptions], + await handleHookPromise( + hookParallel( + 'buildStart', + (plugin) => new Context(plugin), + () => [container.options as NormalizedInputOptions], + ), ) }, @@ -609,10 +638,10 @@ export async function createPluginContainer( ctx._scan = scan ctx._resolveSkips = skip const resolveStart = debugResolve ? performance.now() : 0 - let id: string | null = null const partial: Partial = {} for (const plugin of getSortedPlugins('resolveId')) { + if (closed) throwClosedServerError() if (!plugin.resolveId) continue if (skip?.has(plugin)) continue @@ -623,13 +652,15 @@ export async function createPluginContainer( 'handler' in plugin.resolveId ? plugin.resolveId.handler : plugin.resolveId - const result = await handler.call(ctx as any, rawId, importer, { - assertions: options?.assertions ?? {}, - custom: options?.custom, - isEntry: !!options?.isEntry, - ssr, - scan, - }) + const result = await handleHookPromise( + handler.call(ctx as any, rawId, importer, { + assertions: options?.assertions ?? {}, + custom: options?.custom, + isEntry: !!options?.isEntry, + ssr, + scan, + }), + ) if (!result) continue if (typeof result === 'string') { @@ -675,11 +706,14 @@ export async function createPluginContainer( const ctx = new Context() ctx.ssr = !!ssr for (const plugin of getSortedPlugins('load')) { + if (closed) throwClosedServerError() if (!plugin.load) continue ctx._activePlugin = plugin const handler = 'handler' in plugin.load ? plugin.load.handler : plugin.load - const result = await handler.call(ctx as any, id, { ssr }) + const result = await handleHookPromise( + handler.call(ctx as any, id, { ssr }), + ) if (result != null) { if (isObject(result)) { updateModuleInfo(id, result) @@ -696,6 +730,7 @@ export async function createPluginContainer( const ctx = new TransformContext(id, code, inMap as SourceMap) ctx.ssr = !!ssr for (const plugin of getSortedPlugins('transform')) { + if (closed) throwClosedServerError() if (!plugin.transform) continue ctx._activePlugin = plugin ctx._activeId = id @@ -707,7 +742,9 @@ export async function createPluginContainer( ? plugin.transform.handler : plugin.transform try { - result = await handler.call(ctx as any, code, id, { ssr }) + result = await handleHookPromise( + handler.call(ctx as any, code, id, { ssr }), + ) } catch (e) { ctx.error(e) } @@ -741,6 +778,8 @@ export async function createPluginContainer( async close() { if (closed) return + closed = true + await Promise.allSettled(Array.from(processesing)) const ctx = new Context() await hookParallel( 'buildEnd', @@ -752,7 +791,6 @@ export async function createPluginContainer( () => ctx, () => [], ) - closed = true }, } diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index be6a1107d3f405..92f9abbc04a4fd 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -20,6 +20,7 @@ import { checkPublicFile } from '../plugins/asset' import { getDepsOptimizer } from '../optimizer' import { applySourcemapIgnoreList, injectSourcesContent } from './sourcemap' import { isFileServingAllowed } from './middlewares/static' +import { throwClosedServerError } from './pluginContainer' export const ERR_LOAD_URL = 'ERR_LOAD_URL' export const ERR_LOAD_PUBLIC_URL = 'ERR_LOAD_PUBLIC_URL' @@ -46,6 +47,8 @@ export function transformRequest( server: ViteDevServer, options: TransformOptions = {}, ): Promise { + if (server._restartPromise) throwClosedServerError() + const cacheKey = (options.ssr ? 'ssr:' : options.html ? 'html:' : '') + url // This module may get invalidated while we are processing it. For example @@ -108,9 +111,8 @@ export function transformRequest( timestamp, abort: clearCache, }) - request.then(clearCache, clearCache) - return request + return request.finally(clearCache) } async function doTransform( @@ -253,6 +255,9 @@ async function loadAndTransform( err.code = isPublicFile ? ERR_LOAD_PUBLIC_URL : ERR_LOAD_URL throw err } + + if (server._restartPromise) throwClosedServerError() + // ensure module in graph after successful load mod ??= await moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved) ensureWatchedFile(watcher, mod.file, root) @@ -314,6 +319,8 @@ async function loadAndTransform( } } + if (server._restartPromise) throwClosedServerError() + const result = ssr && !server.config.experimental.skipSsrTransform ? await server.ssrTransform(code, map as SourceMap, url, originalCode)