Skip to content

Commit 88de5da

Browse files
committed
perf(dev): bind the dev server port before the config is loaded
1 parent 36119fe commit 88de5da

8 files changed

Lines changed: 432 additions & 33 deletions

File tree

‎packages/nuxt-cli/src/dev/listen.ts‎

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,43 @@ const HOSTNAME_RE = /^(?!-)[\d.:a-z-]{1,253}(?<!-)$/i
101101
* Check `hostname` is a plausible host or IP address, falling back to a
102102
* bindable default (with a warning) when it is not.
103103
*/
104-
exportfunctionvalidateHostname(hostname: string|undefined,isPublic?: boolean): string|undefined{
104+
exportfunctionvalidateHostname(hostname: string|undefined,isPublic?: boolean,options: {silent?: boolean}={}): string|undefined{
105105
constisValid=!!hostname
106106
&&HOSTNAME_RE.test(hostname)
107107
&&hostname.split('.').every(label=>label.length<=63)
108108
if(!hostname||isValid){
109109
returnhostname
110110
}
111111
constfallback=isPublic ? '' : 'localhost'
112-
logger.warn(`Invalid host \`${hostname}\`, using \`${fallback||'0.0.0.0'}\` instead.`)
112+
if(!options.silent){
113+
logger.warn(`Invalid host \`${hostname}\`, using \`${fallback||'0.0.0.0'}\` instead.`)
114+
}
113115
returnfallback
114116
}
115117

118+
/** Hostname `bindListener` will bind for `options`. */
119+
functionresolveBindHostname(options: ListenOptions,silent=false): string{
120+
constisolated=options.hostname===undefined&&!options.public&&detectIsolatedEnvironment()
121+
returnvalidateHostname(options.hostname,options.public,{ silent })??(options.public||isolated ? '' : 'localhost')
122+
}
123+
124+
/**
125+
* Whether an already bound server can serve `options`, so a listener bound
126+
* before the Nuxt config was known can be kept rather than rebound.
127+
*/
128+
exportfunctionmatchesBoundTarget(bound: BoundServer,options: ListenOptions): boolean{
129+
if(resolveBindHostname(options,true)!==bound.hostname){
130+
returnfalse
131+
}
132+
if(!!options.https!==!!bound.https){
133+
returnfalse
134+
}
135+
// Parsed leniently: an unusable port cannot match, and reporting that is
136+
// `bindListener`'s job rather than this comparison's.
137+
constport=options.port===undefined||options.port==='' ? undefined : Number(options.port)
138+
returnport===undefined||port===bound.address.port||port===bound.requestedPort
139+
}
140+
116141
/**
117142
* External IPv4 addresses other devices can reach. IPv4 link-local addresses
118143
* (169.254.0.0/16, from a macOS Thunderbolt Bridge or an unconfigured adapter)
@@ -152,9 +177,22 @@ function createSecureServer(certificate: ResolvedCertificate, handler: RequestLi
152177
}
153178
}
154179

155-
exportasyncfunctionlisten(handler: RequestListener,options: ListenOptions={}): Promise<Listener>{
156-
constisolatedEnvironment=options.hostname===undefined&&!options.public&&detectIsolatedEnvironment()
157-
consthostname=validateHostname(options.hostname,options.public)??(options.public||isolatedEnvironment ? '' : 'localhost')
180+
/** A bound socket, before any URL resolution, tunnel or console output. */
181+
exportinterfaceBoundServer{
182+
server: HttpServer
183+
address: AddressInfo
184+
https: false|ResolvedCertificate
185+
hostname: string
186+
/** Port that was asked for, before any in-use fallback. */
187+
requestedPort?: number
188+
}
189+
190+
/**
191+
* Bind a socket and nothing else, so the port can be taken before the Nuxt
192+
* config is known and requests can be answered while it loads.
193+
*/
194+
exportasyncfunctionbindListener(handler: RequestListener,options: ListenOptions={}): Promise<BoundServer>{
195+
consthostname=resolveBindHostname(options)
158196

159197
constrequestedPort=parsePort(options.port)
160198
constport=options.handover&&requestedPort
@@ -178,31 +216,37 @@ export async function listen(handler: RequestListener, options: ListenOptions =
178216
// takes the whole dev process down.
179217
server.on('error',error=>logger.error(`Dev server error: ${error.message}`))
180218

181-
// Set inside `createListener`, so a failure after the tunnel is up can still
182-
// tear down the cloudflared process rather than leaking it until exit.
219+
return{ server,address: server.address()asAddressInfo,https: certificate, hostname, requestedPort }
220+
}
221+
222+
/**
223+
* Resolve the URLs of an already bound server and, unless `announce` is false,
224+
* start any tunnel and print, copy and open the URLs.
225+
*/
226+
exportasyncfunctioncreateListener(bound: BoundServer,options: ListenOptions={},{ announce =true}: {announce?: boolean}={}): Promise<Listener>{
227+
const{ server, address, hostname,https: certificate}=bound
228+
229+
// Set before anything that can throw, so a failure after the tunnel is up can
230+
// still tear down the cloudflared process rather than leaking it until exit.
183231
lettunnel: Tunnel|undefined
184232

185233
try{
186-
returnawaitcreateListener()
234+
returnawaitresolveListener()
187235
}
188236
catch(error){
189-
awaitPromise.all([
190-
tunnel?.close().catch(()=>{}),
191-
closeServer(server).catch(()=>{}),
192-
])
237+
awaittunnel?.close().catch(()=>{})
193238
throwerror
194239
}
195240

196-
asyncfunctioncreateListener(): Promise<Listener>{
197-
constaddress=server.address()asAddressInfo
241+
asyncfunctionresolveListener(): Promise<Listener>{
198242
constprotocol=certificate ? 'https' : 'http'
199243
constbaseURL=options.baseURL||'/'
200244
constformatURL=(host: string)=>formatDisplayURL(protocol,host,address.port,baseURL)
201245

202246
constanyHost=ANY_HOSTS.has(hostname)
203247
consturl=formatURL(anyHost ? 'localhost' : hostname)
204248

205-
if(options.tunnel){
249+
if(announce&&options.tunnel){
206250
const{ startTunnel }=awaitimport('./tunnel')
207251
tunnel=awaitstartTunnel(`${protocol}://localhost:${address.port}`,!!certificate)
208252
}
@@ -271,19 +315,21 @@ export async function listen(handler: RequestListener, options: ListenOptions =
271315
console.log(`${qr ? '' : '\n'}${lines.join('\n')}\n`)
272316
}
273317

274-
if(options.showURL!==false){
275-
if(qrURL){
276-
awaitprintQRCode(qrURL)
318+
if(announce){
319+
if(options.showURL!==false){
320+
if(qrURL){
321+
awaitprintQRCode(qrURL)
322+
}
323+
showURLs({qr: !!qrURL})
277324
}
278-
showURLs({qr: !!qrURL})
279-
}
280325

281-
if(options.clipboard){
282-
awaitcopyURL(publicURL||url)
283-
}
326+
if(options.clipboard){
327+
awaitcopyURL(publicURL||url)
328+
}
284329

285-
if(options.open){
286-
openBrowser(options.openURL ? resolveOpenURL(options.openURL,url) : url)
330+
if(options.open){
331+
openBrowser(options.openURL ? resolveOpenURL(options.openURL,url) : url)
332+
}
287333
}
288334

289335
return{
@@ -303,6 +349,18 @@ export async function listen(handler: RequestListener, options: ListenOptions =
303349
}
304350
}
305351

352+
/** Bind and announce in one step, for callers that already know their config. */
353+
exportasyncfunctionlisten(handler: RequestListener,options: ListenOptions={}): Promise<Listener>{
354+
constbound=awaitbindListener(handler,options)
355+
try{
356+
returnawaitcreateListener(bound,options)
357+
}
358+
catch(error){
359+
awaitcloseServer(bound.server).catch(()=>{})
360+
throwerror
361+
}
362+
}
363+
306364
functioncloseServer(server: HttpServer): Promise<void>{
307365
returnnewPromise<void>((resolve,reject)=>{
308366
letforceClose: NodeJS.Timeout|undefined
@@ -494,7 +552,7 @@ function centerBlock(block: string, blockWidth?: number): string {
494552
returnlines.map(line=>indent+line).join('\n')
495553
}
496554

497-
functionresolveOpenURL(target: string,baseURL: string): string{
555+
exportfunctionresolveOpenURL(target: string,baseURL: string): string{
498556
try{
499557
returnnewURL(target,baseURL).href
500558
}

‎packages/nuxt-cli/src/dev/utils.ts‎

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Server as HttpServer, IncomingMessage, RequestListener, ServerResp
66

77
importtype{ResolvedCertificate}from'./cert'
88
importtype{InspectOptions}from'./inspect'
9-
importtype{DevListenOverrides,Listener,ListenOptions}from'./listen'
9+
importtype{BoundServer,DevListenOverrides,Listener,ListenOptions}from'./listen'
1010
importtype{DevRestartReason}from'./reason'
1111
import{Buffer}from'node:buffer'
1212
import{hash}from'node:crypto'
@@ -24,14 +24,15 @@ import { toNodeHandler } from 'srvx/node'
2424
import{provider}from'std-env'
2525

2626
import{showBanner}from'../utils/banner'
27+
import{loadDevServerHint,saveDevServerHint}from'../utils/dev-hint'
2728
import{ActionableError}from'../utils/errors'
2829
import{clearBuildDir}from'../utils/fs'
2930
import{loadKit}from'../utils/kit'
3031
import{acquireLock,formatLockError,getTakeoverPid,updateLock}from'../utils/lockfile'
3132
import{debug,logger,writeNotice}from'../utils/logger'
3233
import{loadNuxtManifest,resolveNuxtManifest,writeNuxtManifest}from'../utils/nuxt'
3334
import{renderError,renderErrorAnsi}from'./error-lazy'
34-
import{listen}from'./listen'
35+
import{bindListener,createListener,matchesBoundTarget,openBrowser,resolveOpenURL}from'./listen'
3536
import{resolveDefaultLoadingTemplate}from'./loading-template'
3637
import{resolvePortlessURLs}from'./portless'
3738
import{formatChangedKeys,formatRestartReason,formatSkippedReload,mergeRestartReasons,withConfigKeys}from'./reason'
@@ -279,6 +280,8 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
279280
#pendingReason?: DevRestartReason
280281
#rawConfig?: Record<string,unknown>
281282
#changedConfigKeys?: string[]
283+
#bound?: BoundServer
284+
#openedEagerly =false
282285

283286
loadDebounced: ()=>void
284287
handler: RequestListener
@@ -374,9 +377,11 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
374377
this.#handler =undefined
375378
this.emit('loading',this.#loadingMessage)
376379

377-
awaitthis.#loadNuxtInstance()
380+
awaitthis.#bindEagerListener()
378381

379-
// Acquire lock before binding a listener so parallel agent invocations
382+
awaitthis.#loadNuxtInstance(this.#bound &&this.listener.getURLs().map(({ url })=>url))
383+
384+
// Acquire lock before serving so parallel agent invocations
380385
// fail fast without starting a second server (agent-only).
381386
this.#acquireDevLock(this.#currentNuxt!.options.buildDir)
382387

@@ -540,13 +545,85 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
540545
this.#currentNuxt =awaitkit.loadNuxt(loadOptions)
541546
}
542547

548+
/**
549+
* Bind a socket before the Nuxt config is known, so the port answers within
550+
* milliseconds of spawn and pre-ready requests get the loading screen instead
551+
* of hanging. The address is taken from the CLI/env, else from the address the
552+
* previous run resolved, else from the schema default.
553+
*
554+
* A guessed address is only a guess: `#createListener` rebinds if the resolved
555+
* config disagrees. Set `NUXT_DEV_EAGER_LISTEN=0` to bind after the config instead.
556+
*/
557+
async #bindEagerListener(): Promise<void>{
558+
if(process.env.NUXT_DEV_EAGER_LISTEN==='0'||process.env.NUXT_DEV_EAGER_LISTEN==='false'){
559+
return
560+
}
561+
562+
constoverrides=this.options.listenOverrides||{}
563+
consthint=loadDevServerHint(this.options.cwd)
564+
565+
// Without `--https` only `nuxt.config` knows whether https is wanted, and the
566+
// certificate options that come with it are not in the hint.
567+
if(overrides.httpsEnabled===undefined&&hint?.https){
568+
return
569+
}
570+
consthttpsEnabled=!!overrides.httpsEnabled
571+
572+
consthasExplicitPort=overrides.port!==undefined&&overrides.port!==''
573+
constlistenOptions: ListenOptions={
574+
...overrides,
575+
port: hasExplicitPort ? overrides.port : hint?.port,
576+
hostname: overrides.hostname??hint?.hostname,
577+
baseURL: hint?.baseURL,
578+
https: httpsEnabled ? overrides.https : undefined,
579+
showURL: false,
580+
open: false,
581+
clipboard: false,
582+
tunnel: false,
583+
}
584+
585+
try{
586+
this.#bound =awaitbindListener(this.handler,listenOptions)
587+
}
588+
catch(error){
589+
// An explicit port is the user's instruction, so its failure is a real
590+
// error; a guessed one may simply disagree with the config.
591+
if(hasExplicitPort){
592+
throwerror
593+
}
594+
debug('Could not bind the dev server before loading Nuxt:',error)
595+
return
596+
}
597+
598+
this.listener=awaitcreateListener(this.#bound,listenOptions,{announce: false})
599+
600+
constknowsScheme=overrides.httpsEnabled!==undefined||hint?.https===false
601+
if(overrides.open&&knowsScheme&&(hasExplicitPort||hint?.port===this.#bound.address.port)){
602+
this.#openedEagerly =true
603+
openBrowser(overrides.openURL ? resolveOpenURL(overrides.openURL,this.listener.url) : this.listener.url)
604+
}
605+
}
606+
543607
async #createListener(): Promise<void>{
544608
if(!this.#currentNuxt){
545609
thrownewError('Nuxt must be loaded before creating listener')
546610
}
547611

548612
constlistenOptions=this.#resolveListenOptions()
549-
this.listener=awaitlisten(this.handler,listenOptions)
613+
this.#persistDevServerHint(listenOptions.baseURL)
614+
615+
if(this.#bound &&!matchesBoundTarget(this.#bound,listenOptions)){
616+
// Only loading screens have been served so far, so rebinding is safe.
617+
awaitthis.listener.close()
618+
this.#bound =undefined
619+
this.#openedEagerly =false
620+
}
621+
622+
this.#bound ??=awaitbindListener(this.handler,listenOptions)
623+
this.listener=awaitcreateListener(this.#bound,{
624+
...listenOptions,
625+
open: listenOptions.open&&!this.#openedEagerly,
626+
})
550627

551628
if(listenOptions.public){
552629
this.#currentNuxt.options.devServer.cors={origin: '*'}
@@ -563,7 +640,29 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
563640
allowedHosts: urls.map(u=>newURL(u).hostname),
564641
},
565642
})
643+
constallowedHosts=this.#currentNuxt.options.vite.server?.allowedHosts
644+
if(Array.isArray(allowedHosts)){
645+
this.#currentNuxt.options.vite.server!.allowedHosts=dedupe(allowedHosts)
646+
}
647+
}
648+
}
649+
650+
/**
651+
* Record the address `nuxt.config` resolves to, so the next run can bind it
652+
* before loading the config. CLI and env overrides are deliberately excluded:
653+
* a one-off `--port` should not change where the next plain run binds.
654+
*/
655+
#persistDevServerHint(baseURL?: string): void{
656+
constdevServer=this.#currentNuxt?.options.devServer
657+
if(!devServer){
658+
return
566659
}
660+
saveDevServerHint(this.options.cwd,{
661+
port: Number(devServer.port)||undefined,
662+
hostname: devServer.host||undefined,
663+
https: !!devServer.https,
664+
baseURL,
665+
})
567666
}
568667

569668
#resolveListenOptions(): ListenOptions{
@@ -871,6 +970,17 @@ function resolveDevServerHTTPS(certificate: false | ResolvedCertificate): NuxtOp
871970
return{key: certificate.key!,cert: certificate.cert!}
872971
}
873972

973+
functiondedupe<T>(values: T[]): T[]{
974+
return[...newSet(values)]
975+
}
976+
977+
/**
978+
* Config the CLI injects on behalf of the live listener.
979+
*
980+
* Everything returned here is confined to `vite.server` and `devServer`, which
981+
* Vite leaves out of its dependency optimiser hash. A port-derived or per-run
982+
* value anywhere else would re-optimise dependencies on every start.
983+
*/
874984
functionresolveDevServerDefaults(listenOptions: {hostname?: string,https: boolean},urls: string[]=[]): Partial<NuxtConfig>{
875985
constdefaultConfig: Partial<NuxtConfig>={}
876986

@@ -896,6 +1006,16 @@ function resolveDevServerDefaults(listenOptions: { hostname?: string, https: boo
8961006
defaultConfig.devServer=defu(defaultConfig.devServer,{cors: {origin: portlessOrigins}})
8971007
}
8981008

1009+
// `defu` concatenates arrays, so the hostname and the listener urls overlap.
1010+
constallowedHosts=defaultConfig.vite?.server?.allowedHosts
1011+
if(Array.isArray(allowedHosts)){
1012+
defaultConfig.vite!.server!.allowedHosts=dedupe(allowedHosts)
1013+
}
1014+
constcorsOrigin=defaultConfig.devServer?.cors?.origin
1015+
if(Array.isArray(corsOrigin)){
1016+
defaultConfig.devServer!.cors!.origin=dedupe(corsOrigin)
1017+
}
1018+
8991019
returndefaultConfig
9001020
}
9011021

0 commit comments

Comments
 (0)