diff --git a/packages/vite/src/node/__tests__/http.spec.ts b/packages/vite/src/node/__tests__/http.spec.ts new file mode 100644 index 00000000000000..08cba2eded190f --- /dev/null +++ b/packages/vite/src/node/__tests__/http.spec.ts @@ -0,0 +1,176 @@ +import http from 'node:http' +import { afterEach, describe, expect, test } from 'vitest' +import { createServer } from '..' +import type { ViteDevServer } from '..' + +const BASE_PORT = 15181 + +describe('port detection', () => { + let blockingServer: http.Server | null = null + let viteServer: ViteDevServer | null = null + + afterEach(async () => { + if (viteServer) { + await viteServer.close() + viteServer = null + } + + await new Promise((resolve) => { + if (blockingServer) { + blockingServer.close(() => resolve()) + blockingServer = null + } else { + resolve() + } + }) + }) + + async function createSimpleServer(port: number, host: string) { + const server = http.createServer() + await new Promise((resolve) => { + server.listen(port, host, () => resolve()) + }) + return { + [Symbol.asyncDispose]() { + return new Promise((resolve) => { + server.close(() => resolve()) + }) + }, + } + } + + describe('port fallback', () => { + test('detects port conflict', async () => { + await using _blockingServer = await createSimpleServer( + BASE_PORT, + 'localhost', + ) + + viteServer = await createServer({ + root: __dirname, + logLevel: 'silent', + server: { port: BASE_PORT, strictPort: false, ws: false }, + }) + await viteServer.listen() + + const address = viteServer.httpServer!.address() + expect(address).toStrictEqual( + expect.objectContaining({ port: BASE_PORT + 1 }), + ) + }) + + test('detects multiple port conflict', async () => { + await using _blockingServer1 = await createSimpleServer( + BASE_PORT, + 'localhost', + ) + await using _blockingServer2 = await createSimpleServer( + BASE_PORT + 1, + 'localhost', + ) + + viteServer = await createServer({ + root: __dirname, + logLevel: 'silent', + server: { port: BASE_PORT, strictPort: false, ws: false }, + }) + await viteServer.listen() + + const address = viteServer.httpServer!.address() + expect(address).toStrictEqual( + expect.objectContaining({ port: BASE_PORT + 2 }), + ) + }) + + test('detects port conflict when server listens on 0.0.0.0', async () => { + await using _blockingServer = await createSimpleServer( + BASE_PORT, + '0.0.0.0', + ) + + viteServer = await createServer({ + root: __dirname, + logLevel: 'silent', + server: { port: BASE_PORT, strictPort: false, ws: false }, + }) + await viteServer.listen() + + const address = viteServer.httpServer!.address() + expect(address).toStrictEqual( + expect.objectContaining({ port: BASE_PORT + 1 }), + ) + }) + + test('detects port conflict when server listens on :: (IPv6)', async (ctx) => { + let blockingServer + try { + blockingServer = await createSimpleServer(BASE_PORT, '::') + } catch { + // Skip test if IPv6 is not available on this system + ctx.skip() + return + } + await using _blockingServer = blockingServer + + viteServer = await createServer({ + root: __dirname, + logLevel: 'silent', + server: { port: BASE_PORT, strictPort: false, ws: false }, + }) + await viteServer.listen() + + const address = viteServer.httpServer!.address() + expect(address).toStrictEqual( + expect.objectContaining({ port: BASE_PORT + 1 }), + ) + }) + + test('wildcard check also runs after EADDRINUSE fallback', async () => { + // localhost:n occupied + // 0.0.0.0:n+1 occupied + // => Vite should pick n+2 + + await using _localhostServer = await createSimpleServer( + BASE_PORT, + 'localhost', + ) + await using _wildcardServer = await createSimpleServer( + BASE_PORT + 1, + '0.0.0.0', + ) + + viteServer = await createServer({ + root: __dirname, + logLevel: 'silent', + server: { + port: BASE_PORT, + strictPort: false, + ws: false, + }, + }) + await viteServer.listen() + + const address = viteServer.httpServer!.address() + expect(address).toStrictEqual( + expect.objectContaining({ port: BASE_PORT + 2 }), + ) + }) + }) + + test('throws error when port is blocked and strictPort is true', async () => { + await using _blockingServer = await createSimpleServer( + BASE_PORT, + 'localhost', + ) + + viteServer = await createServer({ + root: __dirname, + logLevel: 'silent', + server: { port: BASE_PORT, strictPort: true, ws: false }, + }) + + await expect(viteServer.listen()).rejects.toThrow( + `Port ${BASE_PORT} is already in use`, + ) + }) +}) diff --git a/packages/vite/src/node/http.ts b/packages/vite/src/node/http.ts index d5d45447fa54ea..9f6418b12a8403 100644 --- a/packages/vite/src/node/http.ts +++ b/packages/vite/src/node/http.ts @@ -1,4 +1,5 @@ import fsp from 'node:fs/promises' +import net from 'node:net' import path from 'node:path' import type { OutgoingHttpHeaders as HttpServerHeaders } from 'node:http' import type { ServerOptions as HttpsServerOptions } from 'node:https' @@ -7,6 +8,7 @@ import type { Connect } from '#dep-types/connect' import type { ProxyOptions } from './server/middlewares/proxy' import type { Logger } from './logger' import type { HttpServer } from './server' +import { wildcardHosts } from './constants' export interface CommonServerOptions { /** @@ -162,6 +164,52 @@ async function readFileIfExists(value?: string | Buffer | any[]) { return value } +// Check if a port is available on wildcard addresses (0.0.0.0, ::) +async function isPortAvailable(port: number): Promise { + for (const host of wildcardHosts) { + // Gracefully handle errors (e.g., IPv6 disabled on the system) + const available = await tryListen(port, host).catch(() => true) + if (!available) return false + } + return true +} + +function tryListen(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => { + // Ensure server is closed even on error to prevent resource leaks + server.close(() => resolve(false)) + }) + server.once('listening', () => { + server.close(() => resolve(true)) + }) + server.listen(port, host) + }) +} + +async function tryBindServer( + httpServer: HttpServer, + port: number, + host: string | undefined, +): Promise< + { success: true } | { success: false; error: Error & { code?: string } } +> { + return new Promise((resolve) => { + const onError = (e: Error & { code?: string }) => { + httpServer.removeListener('error', onError) + resolve({ success: false, error: e }) + } + httpServer.on('error', onError) + httpServer.listen(port, host, () => { + httpServer.removeListener('error', onError) + resolve({ success: true }) + }) + }) +} + +const MAX_PORT = 65535 + export async function httpServerStart( httpServer: HttpServer, serverOptions: { @@ -171,31 +219,30 @@ export async function httpServerStart( logger: Logger }, ): Promise { - let { port, strictPort, host, logger } = serverOptions + const { port: startPort, strictPort, host, logger } = serverOptions - return new Promise((resolve, reject) => { - const onError = (e: Error & { code?: string }) => { - if (e.code === 'EADDRINUSE') { - if (strictPort) { - httpServer.removeListener('error', onError) - reject(new Error(`Port ${port} is already in use`)) - } else { - logger.info(`Port ${port} is in use, trying another one...`) - httpServer.listen(++port, host) - } - } else { - httpServer.removeListener('error', onError) - reject(e) + for (let port = startPort; port <= MAX_PORT; port++) { + // Pre-check port availability on wildcard addresses (0.0.0.0, ::) + // so that we avoid conflicts with other servers listening on all interfaces + if (await isPortAvailable(port)) { + const result = await tryBindServer(httpServer, port, host) + if (result.success) { + return port + } + if (result.error.code !== 'EADDRINUSE') { + throw result.error } } - httpServer.on('error', onError) + if (strictPort) { + throw new Error(`Port ${port} is already in use`) + } - httpServer.listen(port, host, () => { - httpServer.removeListener('error', onError) - resolve(port) - }) - }) + logger.info(`Port ${port} is in use, trying another one...`) + } + throw new Error( + `No available ports found between ${startPort} and ${MAX_PORT}`, + ) } export function setClientErrorHandler(