Skip to content

Commit 0027fb2

Browse files
committed
fix(dev): pin cloudflared version and shut tunnels down gracefully
1 parent a2e7338 commit 0027fb2

3 files changed

Lines changed: 80 additions & 17 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,17 @@ interface ConsentOptions {
2626
/**
2727
* Resolve a third-party tool binary: from `PATH`, then the download cache,
2828
* then (with the user's consent) by downloading it.
29+
*
30+
* `cacheName` overrides the cached filename, so version-pinned tools can key
31+
* their cache entry by version without affecting the `PATH` lookup.
2932
*/
30-
exportasyncfunctionresolveTool(name: string,options: {url?: string,archive?: boolean,consent: ConsentOptions}): Promise<string|undefined>{
33+
exportasyncfunctionresolveTool(name: string,options: {url?: string,archive?: boolean,cacheName?: string,consent: ConsentOptions}): Promise<string|undefined>{
3134
constexisting=findInPath(name)
3235
if(existing){
3336
returnexisting
3437
}
35-
constdestination=join(getCacheDir('bin'),process.platform==='win32' ? `${name}.exe` : name)
38+
constcacheName=options.cacheName||name
39+
constdestination=join(getCacheDir('bin'),process.platform==='win32' ? `${cacheName}.exe` : cacheName)
3640
if(existsSync(destination)){
3741
returndestination
3842
}
@@ -129,6 +133,11 @@ async function downloadBinary(url: string, destination: string, options: { archi
129133
writeFileSync(archivePath,data)
130134
execFileSync('tar',['-xzf',archivePath,'-C',stagingDir],{stdio: 'ignore'})
131135
rmSync(archivePath)
136+
// Archives are named after the tool, not the cache entry.
137+
constextracted=join(stagingDir,options.name||basename(destination))
138+
if(extracted!==staged&&existsSync(extracted)){
139+
renameSync(extracted,staged)
140+
}
132141
}
133142
else{
134143
writeFileSync(staged,data)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,8 @@ export async function listen(handler: RequestListener, options: ListenOptions =
197197
server,
198198
https: certificate,
199199
getURLs,
200-
close: ()=>{
201-
tunnel?.close()
200+
close: async()=>{
201+
awaittunnel?.close()
202202
returnnewPromise<void>((resolve,reject)=>{
203203
server.close(error=>(error ? reject(error) : resolve()))
204204
server.closeAllConnections?.()

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

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
importtype{Buffer}from'node:buffer'
22

33
import{spawn}from'node:child_process'
4+
import{once}from'node:events'
45
importprocessfrom'node:process'
56

67
import{debug,logger}from'../utils/logger'
78
import{resolveTool}from'./binaries'
89

910
constTUNNEL_URL_RE=/https:\/\/(?!api\.)[\w-]+\.trycloudflare\.com/
1011

12+
/**
13+
* Pinned so a bad Cloudflare release cannot break `--tunnel` for everyone.
14+
* Set `CLOUDFLARED_VERSION` (or `latest`) to override.
15+
*/
16+
constCLOUDFLARED_VERSION=process.env.CLOUDFLARED_VERSION||'2026.7.3'
17+
18+
/** How long to wait for `cloudflared` to exit on `SIGINT` before `SIGKILL`. */
19+
constSHUTDOWN_TIMEOUT_MS=5000
20+
1121
exportinterfaceTunnel{
1222
url: string
13-
close: ()=>void
23+
close: ()=>Promise<void>
1424
}
1525

1626
exportasyncfunctionstartTunnel(localURL: string,insecure?: boolean): Promise<Tunnel|undefined>{
@@ -27,10 +37,20 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
2737
constchild=spawn(binary,args,{stdio: ['ignore','pipe','pipe']})
2838

2939
letoutput=''
40+
constrecentOutput: string[]=[]
3041
consturl=awaitnewPromise<string|undefined>((resolve)=>{
3142
consttimeout=setTimeout(resolve,20_000,undefined)
3243
constonData=(chunk: Buffer)=>{
33-
output+=chunk.toString()
44+
consttext=chunk.toString()
45+
output+=text
46+
for(constlineoftext.split('\n')){
47+
if(line.trim()){
48+
recentOutput.push(line.trim())
49+
}
50+
}
51+
if(recentOutput.length>10){
52+
recentOutput.splice(0,recentOutput.length-10)
53+
}
3454
constmatch=output.match(TUNNEL_URL_RE)
3555
if(match){
3656
clearTimeout(timeout)
@@ -55,7 +75,10 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
5575
if(!url){
5676
child.kill()
5777
debug('cloudflared output:',output)
58-
logger.warn('Could not establish a Cloudflare quick tunnel. Run with `DEBUG=nuxi` for details.')
78+
logger.warn([
79+
'Could not establish a Cloudflare quick tunnel.',
80+
...recentOutput.map(line=>` ${line}`),
81+
].join('\n'))
5982
returnundefined
6083
}
6184

@@ -72,7 +95,7 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
7295

7396
constkill=()=>{
7497
try{
75-
child.kill()
98+
child.kill('SIGKILL')
7699
}
77100
catch{
78101
// process may have already exited
@@ -82,24 +105,55 @@ export async function startTunnel(localURL: string, insecure?: boolean): Promise
82105

83106
return{
84107
url,
85-
close: ()=>{
108+
close: async()=>{
86109
process.removeListener('exit',kill)
87-
kill()
110+
if(child.exitCode!==null||child.signalCode!==null){
111+
return
112+
}
113+
// `SIGINT` lets cloudflared drain its connections and deregister the
114+
// quick tunnel; escalate if it does not exit promptly.
115+
constexited=once(child,'exit')
116+
child.kill('SIGINT')
117+
constescalation=setTimeout(kill,SHUTDOWN_TIMEOUT_MS)
118+
try{
119+
awaitexited
120+
}
121+
catch(error){
122+
debug('Failed to stop `cloudflared`:',error)
123+
}
124+
finally{
125+
clearTimeout(escalation)
126+
}
88127
},
89128
}
90129
}
91130

131+
constCLOUDFLARED_ASSETS: Record<string,Partial<Record<typeofprocess.arch,string>>>={
132+
darwin: {
133+
arm64: 'cloudflared-darwin-arm64.tgz',
134+
x64: 'cloudflared-darwin-amd64.tgz',
135+
},
136+
linux: {
137+
arm: 'cloudflared-linux-arm',
138+
arm64: 'cloudflared-linux-arm64',
139+
ia32: 'cloudflared-linux-386',
140+
x64: 'cloudflared-linux-amd64',
141+
},
142+
win32: {
143+
ia32: 'cloudflared-windows-386.exe',
144+
x64: 'cloudflared-windows-amd64.exe',
145+
},
146+
}
147+
92148
asyncfunctionresolveCloudflared(): Promise<string|undefined>{
93-
constarch=process.arch==='arm64' ? 'arm64' : 'amd64'
94-
constbase='https://github.com/cloudflare/cloudflared/releases/latest/download'
95-
constasset=({
96-
darwin: `cloudflared-darwin-${arch}.tgz`,
97-
linux: `cloudflared-linux-${arch}`,
98-
win32: `cloudflared-windows-amd64.exe`,
99-
}asRecord<string,string>)[process.platform]
149+
constbase=CLOUDFLARED_VERSION==='latest'
150+
? 'https://github.com/cloudflare/cloudflared/releases/latest/download'
151+
: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`
152+
constasset=CLOUDFLARED_ASSETS[process.platform]?.[process.arch]
100153
returnresolveTool('cloudflared',{
101154
url: asset&&`${base}/${asset}`,
102155
archive: asset?.endsWith('.tgz'),
156+
cacheName: `cloudflared-${CLOUDFLARED_VERSION}`,
103157
consent: {
104158
key: 'cloudflared',
105159
notice: [

0 commit comments

Comments
 (0)