Skip to content

Commit 967f709

Browse files
committed
fix(dev): close the listener when setup fails after binding
1 parent 82fe2d6 commit 967f709

2 files changed

Lines changed: 145 additions & 92 deletions

File tree

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

Lines changed: 108 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -178,112 +178,128 @@ export async function listen(handler: RequestListener, options: ListenOptions =
178178
// takes the whole dev process down.
179179
server.on('error',error=>logger.error(`Dev server error: ${error.message}`))
180180

181-
constaddress=server.address()asAddressInfo
182-
constprotocol=certificate ? 'https' : 'http'
183-
constbaseURL=options.baseURL||'/'
184-
constformatURL=(host: string)=>formatDisplayURL(protocol,host,address.port,baseURL)
185-
186-
constanyHost=ANY_HOSTS.has(hostname)
187-
consturl=formatURL(anyHost ? 'localhost' : hostname)
188-
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.
189183
lettunnel: Tunnel|undefined
190-
if(options.tunnel){
191-
const{ startTunnel }=awaitimport('./tunnel')
192-
tunnel=awaitstartTunnel(`${protocol}://localhost:${address.port}`,!!certificate)
184+
185+
try{
186+
returnawaitcreateListener()
187+
}
188+
catch(error){
189+
awaitPromise.all([
190+
tunnel?.close().catch(()=>{}),
191+
closeServer(server).catch(()=>{}),
192+
])
193+
throwerror
193194
}
194195

195-
consttunnelURL=tunnel?.url&&tunnel.url+baseURL
196-
constportless=resolvePortlessURLs()
197-
constportlessURL=portless.url&&portless.url+baseURL
198-
constportlessShareURL=portless.shareURL&&portless.shareURL+baseURL
199-
conststackblitzURL=resolveStackblitzURL()
196+
asyncfunctioncreateListener(): Promise<Listener>{
197+
constaddress=server.address()asAddressInfo
198+
constprotocol=certificate ? 'https' : 'http'
199+
constbaseURL=options.baseURL||'/'
200+
constformatURL=(host: string)=>formatDisplayURL(protocol,host,address.port,baseURL)
200201

201-
functiongetURLs(): ListenURL[]{
202-
consturls: ListenURL[]=[]
203-
if(tunnelURL){
204-
urls.push({url: tunnelURL,type: 'tunnel'})
205-
}
206-
for(constportlessURLofportless.all){
207-
urls.push({url: portlessURL+baseURL,type: 'public'})
208-
}
209-
if(stackblitzURL){
210-
urls.push({url: stackblitzURL,type: 'public'})
202+
constanyHost=ANY_HOSTS.has(hostname)
203+
consturl=formatURL(anyHost ? 'localhost' : hostname)
204+
205+
if(options.tunnel){
206+
const{ startTunnel }=awaitimport('./tunnel')
207+
tunnel=awaitstartTunnel(`${protocol}://localhost:${address.port}`,!!certificate)
211208
}
212-
if(anyHost){
213-
urls.push({url: formatURL('localhost'),type: 'local'})
214-
for(constaddressofgetNetworkAddresses()){
215-
urls.push({url: formatURL(address),type: 'network'})
209+
210+
consttunnelURL=tunnel?.url&&tunnel.url+baseURL
211+
constportless=resolvePortlessURLs()
212+
constportlessURL=portless.url&&portless.url+baseURL
213+
constportlessShareURL=portless.shareURL&&portless.shareURL+baseURL
214+
conststackblitzURL=resolveStackblitzURL()
215+
216+
functiongetURLs(): ListenURL[]{
217+
consturls: ListenURL[]=[]
218+
if(tunnelURL){
219+
urls.push({url: tunnelURL,type: 'tunnel'})
216220
}
221+
for(constportlessURLofportless.all){
222+
urls.push({url: portlessURL+baseURL,type: 'public'})
223+
}
224+
if(stackblitzURL){
225+
urls.push({url: stackblitzURL,type: 'public'})
226+
}
227+
if(anyHost){
228+
urls.push({url: formatURL('localhost'),type: 'local'})
229+
for(constaddressofgetNetworkAddresses()){
230+
urls.push({url: formatURL(address),type: 'network'})
231+
}
232+
}
233+
else{
234+
urls.push({ url,type: 'local'})
235+
}
236+
returnurls
217237
}
218-
else{
219-
urls.push({ url,type: 'local'})
220-
}
221-
returnurls
222-
}
223238

224-
// The StackBlitz URL points at the editor rather than at a host another
225-
// device can open, so it is not a QR code candidate.
226-
constshareableURL=options.publicURL||tunnelURL||portlessShareURL||portlessURL
227-
constpublicURL=shareableURL||stackblitzURL
228-
229-
constqrURL=options.qr===false
230-
? undefined
231-
: shareableURL
232-
||getURLs().find(({ type })=>type==='network')?.url
233-
||(options.qr ? url : undefined)
234-
235-
functionshowURLs({ qr =false}: {qr?: boolean}={}): void{
236-
consturls=getURLs()
237-
constlabels={local: 'Local:',network: 'Network:',tunnel: 'Tunnel:',public: 'Public:'}asconst
238-
constlabelColors={local: 'green',network: 'magenta',tunnel: 'cyan',public: 'magenta'}asconst
239-
constlines: string[]=[]
240-
constline=(color: (text: string)=>string,label: string,value: string,isQR: boolean)=>
241-
` ${color('➜')}${styleText('bold',color(label.padEnd(10)))}${value}${isQR ? styleText('gray',' [QR code]') : ''}`
242-
for(const{url: displayURL, type }ofurls){
243-
lines.push(line(text=>styleText(labelColors[type],text),labels[type],styleText('cyan',displayURL),qr&&displayURL===qrURL))
244-
}
245-
if(!anyHost&&!tunnel&&!portless.url&&!stackblitzURL){
246-
constisolated=LOOPBACK_HOSTS.has(hostname) ? detectIsolatedEnvironment() : undefined
247-
consthint=isolated
248-
? `use ${styleText('white','--host')} to reach this server from outside ${isolated}`
249-
: `use ${styleText('white','--host')} to expose`
250-
lines.push(line(text=>styleText('magenta',text),'Network:',styleText('gray',hint),false))
251-
}
252-
if(publicURL&&publicURL!==url&&!urls.some(entry=>entry.url===publicURL)){
253-
lines.push(line(text=>styleText('magenta',text),'Public:',styleText('cyan',publicURL),qr&&publicURL===qrURL))
239+
// The StackBlitz URL points at the editor rather than at a host another
240+
// device can open, so it is not a QR code candidate.
241+
constshareableURL=options.publicURL||tunnelURL||portlessShareURL||portlessURL
242+
constpublicURL=shareableURL||stackblitzURL
243+
244+
constqrURL=options.qr===false
245+
? undefined
246+
: shareableURL
247+
||getURLs().find(({ type })=>type==='network')?.url
248+
||(options.qr ? url : undefined)
249+
250+
functionshowURLs({ qr =false}: {qr?: boolean}={}): void{
251+
consturls=getURLs()
252+
constlabels={local: 'Local:',network: 'Network:',tunnel: 'Tunnel:',public: 'Public:'}asconst
253+
constlabelColors={local: 'green',network: 'magenta',tunnel: 'cyan',public: 'magenta'}asconst
254+
constlines: string[]=[]
255+
constline=(color: (text: string)=>string,label: string,value: string,isQR: boolean)=>
256+
` ${color('➜')}${styleText('bold',color(label.padEnd(10)))}${value}${isQR ? styleText('gray',' [QR code]') : ''}`
257+
for(const{url: displayURL, type }ofurls){
258+
lines.push(line(text=>styleText(labelColors[type],text),labels[type],styleText('cyan',displayURL),qr&&displayURL===qrURL))
259+
}
260+
if(!anyHost&&!tunnel&&!portless.url&&!stackblitzURL){
261+
constisolated=LOOPBACK_HOSTS.has(hostname) ? detectIsolatedEnvironment() : undefined
262+
consthint=isolated
263+
? `use ${styleText('white','--host')} to reach this server from outside ${isolated}`
264+
: `use ${styleText('white','--host')} to expose`
265+
lines.push(line(text=>styleText('magenta',text),'Network:',styleText('gray',hint),false))
266+
}
267+
if(publicURL&&publicURL!==url&&!urls.some(entry=>entry.url===publicURL)){
268+
lines.push(line(text=>styleText('magenta',text),'Public:',styleText('cyan',publicURL),qr&&publicURL===qrURL))
269+
}
270+
// eslint-disable-next-line no-console
271+
console.log(`${qr ? '' : '\n'}${lines.join('\n')}\n`)
254272
}
255-
// eslint-disable-next-line no-console
256-
console.log(`${qr ? '' : '\n'}${lines.join('\n')}\n`)
257-
}
258273

259-
if(options.showURL!==false){
260-
if(qrURL){
261-
awaitprintQRCode(qrURL)
274+
if(options.showURL!==false){
275+
if(qrURL){
276+
awaitprintQRCode(qrURL)
277+
}
278+
showURLs({qr: !!qrURL})
262279
}
263-
showURLs({qr: !!qrURL})
264-
}
265280

266-
if(options.clipboard){
267-
awaitcopyURL(publicURL||url)
268-
}
281+
if(options.clipboard){
282+
awaitcopyURL(publicURL||url)
283+
}
269284

270-
if(options.open){
271-
openBrowser(options.openURL ? resolveOpenURL(options.openURL,url) : url)
272-
}
285+
if(options.open){
286+
openBrowser(options.openURL ? resolveOpenURL(options.openURL,url) : url)
287+
}
273288

274-
return{
275-
url,
276-
publicURL,
277-
qrURL,
278-
address,
279-
server,
280-
https: certificate,
281-
getURLs,
282-
showURLs,
283-
close: ()=>Promise.all([
284-
tunnel?.close(),
285-
closeServer(server),
286-
]).then(()=>{}),
289+
return{
290+
url,
291+
publicURL,
292+
qrURL,
293+
address,
294+
server,
295+
https: certificate,
296+
getURLs,
297+
showURLs,
298+
close: ()=>Promise.all([
299+
tunnel?.close(),
300+
closeServer(server),
301+
]).then(()=>{}),
302+
}
287303
}
288304
}
289305

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,43 @@ describe('listener.close', () => {
290290
vi.doUnmock('../../src/dev/tunnel')
291291
})
292292

293+
it('should close the tunnel when setup fails after it started',async()=>{
294+
constcloseTunnel=vi.fn(()=>Promise.resolve())
295+
vi.doMock('../../src/dev/tunnel',()=>({
296+
startTunnel: async()=>({url: 'https://example.test',close: closeTunnel}),
297+
}))
298+
vi.doMock('uqr',()=>({
299+
renderUnicodeCompact: ()=>{
300+
thrownewError('qr unavailable')
301+
},
302+
}))
303+
304+
awaitexpect(listen((_req,res)=>res.end('ok'),{port: 0,hostname: '127.0.0.1',tunnel: true,qr: true})).rejects.toThrow('qr unavailable')
305+
306+
expect(closeTunnel).toHaveBeenCalledTimes(1)
307+
vi.doUnmock('../../src/dev/tunnel')
308+
vi.doUnmock('uqr')
309+
})
310+
311+
it('should release the port when setup fails after binding',async()=>{
312+
vi.doMock('../../src/dev/tunnel',()=>({
313+
startTunnel: async()=>{
314+
thrownewError('tunnel unavailable')
315+
},
316+
}))
317+
318+
constprobe=awaitlisten((_req,res)=>res.end('ok'),{port: 0,hostname: '127.0.0.1',showURL: false})
319+
constport=probe.address.port
320+
awaitprobe.close()
321+
322+
awaitexpect(listen((_req,res)=>res.end('ok'),{ port,hostname: '127.0.0.1',showURL: false,strictPort: true,tunnel: true})).rejects.toThrow('tunnel unavailable')
323+
vi.doUnmock('../../src/dev/tunnel')
324+
325+
constlistener=awaitlisten((_req,res)=>res.end('ok'),{ port,hostname: '127.0.0.1',showURL: false,strictPort: true})
326+
expect(listener.address.port).toBe(port)
327+
awaitlistener.close()
328+
})
329+
293330
it('should let an in-flight request finish',async()=>{
294331
letrespond: (()=>void)|undefined
295332
constlistener=awaitlisten((_req,res)=>{

0 commit comments

Comments
 (0)