Skip to content

Commit a7add36

Browse files
committed
fix(dev): harden listener edge cases
1 parent 5eee806 commit a7add36

4 files changed

Lines changed: 90 additions & 36 deletions

File tree

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { satisfies } from 'verkit'
1515
import{initialize}from'../dev'
1616

1717
import{closeInspector,openInspector,resolveInspectOptions}from'../dev/inspect'
18-
import{isReusePortSupported}from'../dev/listen'
18+
import{isReusePortSupported,parsePort}from'../dev/listen'
1919
import{ForkPool}from'../dev/pool'
2020
import{formatRestartReason}from'../dev/reason'
2121
import{setupShortcuts}from'../dev/shortcuts'
@@ -158,7 +158,7 @@ const command = defineCommand({
158158
constlistenOverrides=resolveListenOverrides(ctx.args)
159159

160160
consttakeover=awaittakeOverDevServer(resolveDevBuildDir(cwd),{
161-
requestedPort: parseRequestedPort(listenOverrides.port),
161+
requestedPort: parsePort(listenOverrides.port),
162162
takeover: ctx.args.takeover,
163163
})
164164
if(takeover.action==='refused'){
@@ -408,11 +408,6 @@ function resolveDevBuildDir(cwd: string): string {
408408
returnresolve(cwd,'.nuxt')
409409
}
410410

411-
functionparseRequestedPort(port: string|number|undefined): number|undefined{
412-
constparsed=Number(port)
413-
returnport===undefined||port===''||!Number.isInteger(parsed)||parsed<=0 ? undefined : parsed
414-
}
415-
416411
functionresolveForkPoolSize(): number|undefined{
417412
constraw=process.env.NUXT_DEV_FORK_POOL_SIZE
418413
if(!raw){

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

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export async function listen(handler: RequestListener, options: ListenOptions =
155155
constisolatedEnvironment=options.hostname===undefined&&!options.public&&detectIsolatedEnvironment()
156156
consthostname=validateHostname(options.hostname,options.public)??(options.public||isolatedEnvironment ? '' : 'localhost')
157157

158-
constrequestedPort=options.port===undefined||options.port==='' ? undefined : Number(options.port)
158+
constrequestedPort=parsePort(options.port)
159159
constport=options.handover&&requestedPort
160160
? requestedPort
161161
: awaitresolvePort(requestedPort,hostname,options.strictPort)
@@ -191,15 +191,16 @@ export async function listen(handler: RequestListener, options: ListenOptions =
191191
tunnel=awaitstartTunnel(`${protocol}://localhost:${address.port}`,!!certificate)
192192
}
193193

194+
consttunnelURL=tunnel?.url&&tunnel.url+baseURL
194195
constportless=resolvePortlessURLs()
195196
constportlessURL=portless.url&&portless.url+baseURL
196197
constportlessShareURL=portless.shareURL&&portless.shareURL+baseURL
197198
conststackblitzURL=resolveStackblitzURL()
198199

199200
functiongetURLs(): ListenURL[]{
200201
consturls: ListenURL[]=[]
201-
if(tunnel){
202-
urls.push({url: tunnel.url,type: 'tunnel'})
202+
if(tunnelURL){
203+
urls.push({url: tunnelURL,type: 'tunnel'})
203204
}
204205
for(constportlessURLofportless.all){
205206
urls.push({url: portlessURL+baseURL,type: 'public'})
@@ -221,7 +222,7 @@ export async function listen(handler: RequestListener, options: ListenOptions =
221222

222223
// The StackBlitz URL points at the editor rather than at a host another
223224
// device can open, so it is not a QR code candidate.
224-
constshareableURL=options.publicURL||tunnel?.url||portlessShareURL||portlessURL
225+
constshareableURL=options.publicURL||tunnelURL||portlessShareURL||portlessURL
225226
constpublicURL=shareableURL||stackblitzURL
226227

227228
constqrURL=options.qr===false
@@ -278,31 +279,35 @@ export async function listen(handler: RequestListener, options: ListenOptions =
278279
https: certificate,
279280
getURLs,
280281
showURLs,
281-
close: async()=>{
282-
awaittunnel?.close()
283-
returnnewPromise<void>((resolve,reject)=>{
284-
letforceClose: NodeJS.Timeout|undefined
285-
server.close((error)=>{
286-
if(forceClose){
287-
clearTimeout(forceClose)
288-
}
289-
if(error){
290-
reject(error)
291-
}
292-
else{
293-
resolve()
294-
}
295-
})
296-
// Sockets waiting on keep-alive are closed at once, so shutdown is only
297-
// delayed while a request is actually being served.
298-
server.closeIdleConnections?.()
299-
forceClose=setTimeout(()=>server.closeAllConnections?.(),CONNECTION_DRAIN_TIMEOUT_MS)
300-
forceClose.unref()
301-
})
302-
},
282+
close: ()=>Promise.all([
283+
tunnel?.close(),
284+
closeServer(server),
285+
]).then(()=>{}),
303286
}
304287
}
305288

289+
functioncloseServer(server: HttpServer): Promise<void>{
290+
returnnewPromise<void>((resolve,reject)=>{
291+
letforceClose: NodeJS.Timeout|undefined
292+
server.close((error)=>{
293+
if(forceClose){
294+
clearTimeout(forceClose)
295+
}
296+
if(error){
297+
reject(error)
298+
}
299+
else{
300+
resolve()
301+
}
302+
})
303+
// Sockets waiting on keep-alive are closed at once, so shutdown is only
304+
// delayed while a request is actually being served.
305+
server.closeIdleConnections?.()
306+
forceClose=setTimeout(()=>server.closeAllConnections?.(),CONNECTION_DRAIN_TIMEOUT_MS)
307+
forceClose.unref()
308+
})
309+
}
310+
306311
functionbindServer(server: HttpServer,port: number,hostname: string,reusePort: boolean): Promise<void>{
307312
returnnewPromise<void>((resolve,reject)=>{
308313
constonError=(error: NodeJS.ErrnoException)=>{
@@ -372,6 +377,17 @@ export function isReusePortSupported(): Promise<boolean> {
372377
returnreusePortSupport
373378
}
374379

380+
exportfunctionparsePort(value: string|number|undefined): number|undefined{
381+
if(value===undefined||value===''){
382+
returnundefined
383+
}
384+
constport=Number(value)
385+
if(!Number.isInteger(port)||port<0||port>65_535){
386+
thrownewError(`Invalid port \`${value}\`; expected an integer between 0 and 65535.`)
387+
}
388+
returnport
389+
}
390+
375391
asyncfunctionresolvePort(requestedPort: number|undefined,hostname: string,strictPort?: boolean): Promise<number>{
376392
if(requestedPort===0){
377393
returngetPort({random: true,host: hostname||undefined})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -899,8 +899,8 @@ function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) {
899899

900900
fileWatcher.prime(configDir)
901901
constconfigDirWatcher=watch(configDir)
902-
configDirWatcher.on('change',(_event,file: string)=>{
903-
if(!fileWatcher.shouldEmitChange(resolve(configDir,file))){
902+
configDirWatcher.on('change',(_event,file: string|null)=>{
903+
if(!file||!fileWatcher.shouldEmitChange(resolve(configDir,file))){
904904
return
905905
}
906906

‎packages/nuxt-cli/test/unit/listen.spec.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { networkInterfaces } from 'node:os'
55

66
import{afterEach,beforeEach,describe,expect,it,vi}from'vitest'
77

8-
import{copyURL,formatDisplayURL,getNetworkAddresses,isReusePortSupported,listen,openBrowser,resolveOpenCommand,validateHostname}from'../../src/dev/listen'
8+
import{copyURL,formatDisplayURL,getNetworkAddresses,isReusePortSupported,listen,openBrowser,parsePort,resolveOpenCommand,validateHostname}from'../../src/dev/listen'
99

1010
constwriteText=vi.hoisted(()=>vi.fn())
1111
constisolatedEnvironment=vi.hoisted(()=>({current: undefinedasstring|undefined}))
@@ -115,6 +115,22 @@ describe('validateHostname', () => {
115115
})
116116
})
117117

118+
describe('parsePort',()=>{
119+
it.each([
120+
['0',0],
121+
['3000',3000],
122+
[65_535,65_535],
123+
['',undefined],
124+
[undefined,undefined],
125+
])('should parse %j as %j',(value,expected)=>{
126+
expect(parsePort(value)).toBe(expected)
127+
})
128+
129+
it.each(['nope','-1','1.5','65536','Infinity'])('should reject %s',(value)=>{
130+
expect(()=>parsePort(value)).toThrow(`Invalid port \`${value}\``)
131+
})
132+
})
133+
118134
describe('resolveOpenCommand',()=>{
119135
consturl='http://localhost:3000/'
120136

@@ -180,6 +196,10 @@ describe('listen', () => {
180196
expect(listener.url).toBe(`http://localhost:${listener.address.port}/`)
181197
})
182198

199+
it('should reject an invalid port before binding',async()=>{
200+
awaitexpect(start({port: 'nope'})).rejects.toThrow('Invalid port `nope`; expected an integer between 0 and 65535.')
201+
})
202+
183203
it('should fall back to another port by default',async()=>{
184204
constfirst=awaitstart({port: 0})
185205
constsecond=awaitstart({port: first.address.port})
@@ -242,6 +262,29 @@ describe('listen', () => {
242262
})
243263

244264
describe('listener.close',()=>{
265+
it('should close the listener while a tunnel is still shutting down',async()=>{
266+
letcloseTunnel: (()=>void)|undefined
267+
vi.doMock('../../src/dev/tunnel',()=>({
268+
startTunnel: async()=>({
269+
url: 'https://example.test',
270+
close: ()=>newPromise<void>((resolve)=>{
271+
closeTunnel=resolve
272+
}),
273+
}),
274+
}))
275+
276+
constlistener=awaitlisten((_req,res)=>res.end('ok'),{port: 0,hostname: '127.0.0.1',baseURL: '/dashboard/',showURL: false,tunnel: true})
277+
expect(listener.publicURL).toBe('https://example.test/dashboard/')
278+
expect(listener.getURLs()).toContainEqual({url: 'https://example.test/dashboard/',type: 'tunnel'})
279+
280+
constclosed=listener.close()
281+
282+
awaitvi.waitFor(()=>expect(listener.server.listening).toBe(false))
283+
closeTunnel!()
284+
awaitclosed
285+
vi.doUnmock('../../src/dev/tunnel')
286+
})
287+
245288
it('should let an in-flight request finish',async()=>{
246289
letrespond: (()=>void)|undefined
247290
constlistener=awaitlisten((_req,res)=>{

0 commit comments

Comments
 (0)