From c329081968edfbad147d2d5e5a2ae3f0acbc8612 Mon Sep 17 00:00:00 2001 From: limengjun Date: Fri, 11 Mar 2022 20:56:10 +0800 Subject: [PATCH] feat: optimize the resolve logic of devServer's port and the server running at... log(#7271) --- packages/vite/src/node/constants.ts | 3 + packages/vite/src/node/http.ts | 137 ++++++++++++++++++++++++---- packages/vite/src/node/logger.ts | 42 ++++++++- packages/vite/src/node/utils.ts | 6 +- 4 files changed, 164 insertions(+), 24 deletions(-) diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 9612cd8c96460d..8f0efe6dfca962 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -92,3 +92,6 @@ export const DEFAULT_ASSETS_RE = new RegExp( ) export const DEP_VERSION_RE = /[\?&](v=[\w\.-]+)\b/ + +export const DEFAULT_IPV4_ADDR = '0.0.0.0' +export const DEFAULT_IPV6_ADDR = '::' diff --git a/packages/vite/src/node/http.ts b/packages/vite/src/node/http.ts index bfc2ddbe32a302..e51f16ac21b931 100644 --- a/packages/vite/src/node/http.ts +++ b/packages/vite/src/node/http.ts @@ -1,4 +1,7 @@ import fs, { promises as fsp } from 'fs' +import * as http from 'http' +import os from 'os' +import type { NetworkInterfaceInfoIPv6 } from 'os' import path from 'path' import type { OutgoingHttpHeaders as HttpServerHeaders, @@ -168,6 +171,115 @@ async function getCertificate(cacheDir: string) { } } +function getExternalHost() { + const hosts: string[] = [] + Object.values(os.networkInterfaces()) + .flatMap((nInterface) => nInterface ?? []) + .filter((detail) => detail && detail.address && detail.internal === false) + .map((detail) => { + let address = detail.address + if (address.indexOf('fe80:') === 0) { + // support ipv6 scope + address += '%' + (detail as NetworkInterfaceInfoIPv6).scopeid + } + hosts.push(address) + }) + return hosts +} + +function createTestServer(port: number, host: string): Promise { + return new Promise(function (resolve, reject) { + const server = http.createServer() + server.listen(port, host, function () { + server.once('close', function () { + resolve(host) + }) + server.close() + }) + server.on('error', (e: Error & { code?: string }) => { + if (e.code === 'EADDRINUSE') { + reject(host) + } else { + reject(e) + } + }) + }) +} + +function getConflictHosts(host: string | undefined) { + const externalHost = getExternalHost() + const internalHost = ['127.0.0.1', '::1'] + const defaultHost = ['0.0.0.0', '::'] + let conflictIpList: string[] = [] + if (host === undefined || host === '::' || host === '0.0.0.0') { + // User may want to listen on every IPs, we should check every IPs + conflictIpList = [...externalHost, ...internalHost, ...defaultHost] + } else if (host === '127.0.0.1' || host === 'localhost') { + // User may want to listen on 127.0.0.1 and use localhost as the hostname. + // check ::1 cause localhost may parse to ::1 first,::1 is reachable when ::1 or :: is in listening + conflictIpList = ['127.0.0.1', '::1', '::'] + } else { + // Only listen on specific address + conflictIpList = [host] + } + return conflictIpList +} + +// inspired by https://gist.github.com/eplawless/51afd77bc6e8631f6b5cb117208d5fe0#file-node-is-a-liar-snippet-4-js +async function checkHostsAndPortAvailable(hosts: string[], port: number) { + const servers = hosts.reduce(function ( + lastServer: Promise | null, + host + ) { + return lastServer + ? lastServer.then(function () { + return createTestServer(port, host) + }) + : createTestServer(port, host) + }, + null) + + return servers +} + +async function resolvePort( + host: string | undefined, + startPort: number, + logger: Logger, + strictPort?: boolean +) { + const conflictIpList = getConflictHosts(host) + return new Promise((resolve: (port: number) => void, reject) => { + checkHostsAndPortAvailable(conflictIpList, startPort).then( + () => { + resolve(startPort) + }, + (conflictHost: string | Error) => { + if (typeof conflictHost !== 'string') { + reject(conflictHost) + return + } + if (strictPort) { + logger.info(`Port ${startPort} is in use by ${conflictHost}`) + reject(conflictHost) + } else { + logger.info( + `Port ${startPort} is in use by ${conflictHost}, trying another one...` + ) + resolvePort(host, startPort + 1, logger, strictPort).then( + (port) => { + resolve(port) + }, + (e: Error) => { + reject(e) + } + ) + } + } + ) + }) +} + export async function httpServerStart( httpServer: HttpServer, serverOptions: { @@ -180,26 +292,17 @@ export async function httpServerStart( return new Promise((resolve, reject) => { let { port, strictPort, host, logger } = serverOptions - 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) + resolvePort(host, port, logger, strictPort).then((availablePort) => { + const onError = (e: Error & { code?: string }) => { reject(e) } - } - httpServer.on('error', onError) + httpServer.on('error', onError) - httpServer.listen(port, host, () => { - httpServer.removeListener('error', onError) - resolve(port) - }) + httpServer.listen(availablePort, host, () => { + httpServer.removeListener('error', onError) + resolve(port) + }) + }, reject) }) } diff --git a/packages/vite/src/node/logger.ts b/packages/vite/src/node/logger.ts index b6cf76f2aaa432..c50b2a2929d1fa 100644 --- a/packages/vite/src/node/logger.ts +++ b/packages/vite/src/node/logger.ts @@ -9,6 +9,7 @@ import type { ResolvedConfig } from '.' import type { CommonServerOptions } from './http' import type { Hostname } from './utils' import { resolveHostname } from './utils' +import { DEFAULT_IPV4_ADDR, DEFAULT_IPV6_ADDR } from './constants' export type LogType = 'error' | 'warn' | 'info' export type LogLevel = LogType | 'silent' @@ -190,12 +191,43 @@ function printServerUrls( } else { Object.values(os.networkInterfaces()) .flatMap((nInterface) => nInterface ?? []) - .filter((detail) => detail && detail.address && detail.family === 'IPv4') + .filter((detail) => { + if (!detail || !detail.address) { + return false + } + + // Only show ipv6 url when host is ipv6 and host isn't :: + if (detail.family === 'IPv6') { + return ( + hostname.host && + hostname.host.includes(detail.address) && + hostname.host !== '::' + ) + } else { + const isIpv4DefaultAddress = detail.address.includes('127.0.0.1') + if ( + hostname.host === undefined || + hostname.host === DEFAULT_IPV4_ADDR || + hostname.host === DEFAULT_IPV6_ADDR || + hostname.host.includes(detail.address) || + // Use '127.0.0.1' for any other host except '::1' as local url + // here '127.0.0.1' will be replace to hostname.name later + (isIpv4DefaultAddress && hostname.host !== '::1') + ) { + return true + } + return false + } + }) .map((detail) => { - const type = detail.address.includes('127.0.0.1') - ? 'Local: ' - : 'Network: ' - const host = detail.address.replace('127.0.0.1', hostname.name) + const type = + detail.address.includes('127.0.0.1') || detail.address.includes('::1') + ? 'Local: ' + : 'Network: ' + let host = detail.address.replace('127.0.0.1', hostname.name) + if (host.includes(':')) { + host = `[${host}]` + } const url = `${protocol}://${host}:${colors.bold(port)}${base}` return ` > ${type} ${colors.cyan(url)}` }) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index cca8df5750ec49..22bebc2bd9d0b0 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -635,8 +635,10 @@ export function resolveHostname( optionsHost: string | boolean | undefined ): Hostname { let host: string | undefined - if (optionsHost === undefined || optionsHost === false) { - // Use a secure default + if (!optionsHost) { + // Use a secure default when optionsHost can transfer to false + // to avoid node listen on default_addr. ie: optionsHost is '' or 0 or other false values + // see https://github.com/nodejs/node/blob/v17.7.1/lib/net.js#L906 host = '127.0.0.1' } else if (optionsHost === true) { // If passed --host in the CLI without arguments