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
176 changes: 176 additions & 0 deletions packages/vite/src/node/__tests__/http.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
if (blockingServer) {
blockingServer.close(() => resolve())
blockingServer = null
} else {
resolve()
}
})
})

async function createSimpleServer(port: number, host: string) {
const server = http.createServer()
await new Promise<void>((resolve) => {
server.listen(port, host, () => resolve())
})
return {
[Symbol.asyncDispose]() {
return new Promise<void>((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`,
)
})
})
87 changes: 67 additions & 20 deletions packages/vite/src/node/http.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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<boolean> {
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<boolean> {
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: {
Expand All @@ -171,31 +219,30 @@ export async function httpServerStart(
logger: Logger
},
): Promise<number> {
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(
Expand Down