1import fs = require('fs'); 2import os = require('os'); 3import path = require('path'); 4import zlib = require('zlib'); 5import https = require('https'); 6import child_process = require('child_process'); 7 8declare const ESBUILD_VERSION: string; 9 10const version = ESBUILD_VERSION; 11const binPath = path.join(__dirname, 'bin', 'esbuild'); 12 13async function installBinaryFromPackage(name: string, fromPath: string, toPath: string): Promise<void> { 14 // Try to install from the cache if possible 15 const cachePath = getCachePath(name); 16 try { 17 // Copy from the cache 18 fs.copyFileSync(cachePath, toPath); 19 fs.chmodSync(toPath, 0o755); 20 21 // Verify that the binary is the correct version 22 validateBinaryVersion(toPath); 23 24 // Mark the cache entry as used for LRU 25 const now = new Date; 26 fs.utimesSync(cachePath, now, now); 27 return; 28 } catch { 29 } 30 31 // Next, try to install using npm. This should handle various tricky cases 32 // such as environments where requests to npmjs.org will hang (in which case 33 // there is probably a proxy and/or a custom registry configured instead). 34 let buffer: Buffer | undefined; 35 let didFail = false; 36 try { 37 buffer = installUsingNPM(name, fromPath); 38 } catch (err) { 39 didFail = true; 40 console.error(`Trying to install "${name}" using npm`); 41 console.error(`Failed to install "${name}" using npm: ${err && err.message || err}`); 42 } 43 44 // If that fails, the user could have npm configured incorrectly or could not 45 // have npm installed. Try downloading directly from npm as a last resort. 46 if (!buffer) { 47 const url = `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`; 48 console.error(`Trying to download ${JSON.stringify(url)}`); 49 try { 50 buffer = extractFileFromTarGzip(await fetch(url), fromPath); 51 } catch (err) { 52 console.error(`Failed to download ${JSON.stringify(url)}: ${err && err.message || err}`); 53 } 54 } 55 56 // Give up if none of that worked 57 if (!buffer) { 58 console.error(`Install unsuccessful`); 59 process.exit(1); 60 } 61 62 // Write out the binary executable that was extracted from the package 63 fs.writeFileSync(toPath, buffer, { mode: 0o755 }); 64 65 // Verify that the binary is the correct version 66 try { 67 validateBinaryVersion(toPath); 68 } catch (err) { 69 console.error(`The version of the downloaded binary is incorrect: ${err && err.message || err}`); 70 console.error(`Install unsuccessful`); 71 process.exit(1); 72 } 73 74 // Also try to cache the file to speed up future installs 75 try { 76 fs.mkdirSync(path.dirname(cachePath), { 77 recursive: true, 78 mode: 0o700, // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 79 }); 80 fs.copyFileSync(toPath, cachePath); 81 cleanCacheLRU(cachePath); 82 } catch { 83 } 84 85 if (didFail) console.error(`Install successful`); 86} 87 88function validateBinaryVersion(binaryPath: string): void { 89 const stdout = child_process.execFileSync(binaryPath, ['--version']).toString().trim(); 90 if (stdout !== version) { 91 throw new Error(`Expected ${JSON.stringify(version)} but got ${JSON.stringify(stdout)}`); 92 } 93} 94 95function getCachePath(name: string): string { 96 const home = os.homedir(); 97 const common = ['esbuild', 'bin', `${name}@${version}`]; 98 if (process.platform === 'darwin') return path.join(home, 'Library', 'Caches', ...common); 99 if (process.platform === 'win32') return path.join(home, 'AppData', 'Local', 'Cache', ...common); 100 101 // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 102 const XDG_CACHE_HOME = process.env.XDG_CACHE_HOME; 103 if (process.platform === 'linux' && XDG_CACHE_HOME && path.isAbsolute(XDG_CACHE_HOME)) 104 return path.join(XDG_CACHE_HOME, ...common); 105 106 return path.join(home, '.cache', ...common); 107} 108 109function cleanCacheLRU(fileToKeep: string): void { 110 // Gather all entries in the cache 111 const dir = path.dirname(fileToKeep); 112 const entries: { path: string, mtime: Date }[] = []; 113 for (const entry of fs.readdirSync(dir)) { 114 const entryPath = path.join(dir, entry); 115 try { 116 const stats = fs.statSync(entryPath); 117 entries.push({ path: entryPath, mtime: stats.mtime }); 118 } catch { 119 } 120 } 121 122 // Only keep the most recent entries 123 entries.sort((a, b) => +b.mtime - +a.mtime); 124 for (const entry of entries.slice(5)) { 125 try { 126 fs.unlinkSync(entry.path); 127 } catch { 128 } 129 } 130} 131 132function fetch(url: string): Promise<Buffer> { 133 return new Promise((resolve, reject) => { 134 https.get(url, res => { 135 if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) 136 return fetch(res.headers.location).then(resolve, reject); 137 if (res.statusCode !== 200) 138 return reject(new Error(`Server responded with ${res.statusCode}`)); 139 let chunks: Buffer[] = []; 140 res.on('data', chunk => chunks.push(chunk)); 141 res.on('end', () => resolve(Buffer.concat(chunks))); 142 }).on('error', reject); 143 }); 144} 145 146function extractFileFromTarGzip(buffer: Buffer, file: string): Buffer { 147 try { 148 buffer = zlib.unzipSync(buffer); 149 } catch (err) { 150 throw new Error(`Invalid gzip data in archive: ${err && err.message || err}`); 151 } 152 let str = (i: number, n: number) => String.fromCharCode(...buffer.subarray(i, i + n)).replace(/\0.*$/, ''); 153 let offset = 0; 154 file = `package/${file}`; 155 while (offset < buffer.length) { 156 let name = str(offset, 100); 157 let size = parseInt(str(offset + 124, 12), 8); 158 offset += 512; 159 if (!isNaN(size)) { 160 if (name === file) return buffer.subarray(offset, offset + size); 161 offset += (size + 511) & ~511; 162 } 163 } 164 throw new Error(`Could not find ${JSON.stringify(file)} in archive`); 165} 166 167function installUsingNPM(name: string, file: string): Buffer { 168 const installDir = path.join(os.tmpdir(), 'esbuild-' + Math.random().toString(36).slice(2)); 169 fs.mkdirSync(installDir, { recursive: true }); 170 fs.writeFileSync(path.join(installDir, 'package.json'), '{}'); 171 172 // Erase "npm_config_global" so that "npm install --global esbuild" works. 173 // Otherwise this nested "npm install" will also be global, and the install 174 // will deadlock waiting for the global installation lock. 175 const env = { ...process.env, npm_config_global: undefined }; 176 177 child_process.execSync(`npm install --loglevel=error --prefer-offline --no-audit --progress=false ${name}@${version}`, 178 { cwd: installDir, stdio: 'pipe', env }); 179 const buffer = fs.readFileSync(path.join(installDir, 'node_modules', name, file)); 180 try { 181 removeRecursive(installDir); 182 } catch (e) { 183 // Removing a file or directory can randomly break on Windows, returning 184 // EBUSY for an arbitrary length of time. I think this happens when some 185 // other program has that file or directory open (e.g. an anti-virus 186 // program). This is fine on Unix because the OS just unlinks the entry 187 // but keeps the reference around until it's unused. In this case we just 188 // ignore errors because this directory is in a temporary directory, so in 189 // theory it should get cleaned up eventually anyway. 190 } 191 return buffer; 192} 193 194function removeRecursive(dir: string): void { 195 for (const entry of fs.readdirSync(dir)) { 196 const entryPath = path.join(dir, entry); 197 let stats; 198 try { 199 stats = fs.lstatSync(entryPath); 200 } catch (e) { 201 continue; // Guard against https://github.com/nodejs/node/issues/4760 202 } 203 if (stats.isDirectory()) removeRecursive(entryPath); 204 else fs.unlinkSync(entryPath); 205 } 206 fs.rmdirSync(dir); 207} 208 209function isYarnBerryOrNewer(): boolean { 210 const { npm_config_user_agent } = process.env; 211 if (npm_config_user_agent) { 212 const match = npm_config_user_agent.match(/yarn\/(\d+)/); 213 if (match && match[1]) { 214 return parseInt(match[1], 10) >= 2; 215 } 216 } 217 return false; 218} 219 220function installDirectly(name: string) { 221 if (process.env.ESBUILD_BINARY_PATH) { 222 fs.copyFileSync(process.env.ESBUILD_BINARY_PATH, binPath); 223 validateBinaryVersion(binPath); 224 } else { 225 // Write to a temporary file, then move the file into place. This is an 226 // attempt to avoid problems with package managers like pnpm which will 227 // usually turn each file into a hard link. We don't want to mutate the 228 // hard-linked file which may be shared with other files. 229 const tempBinPath = binPath + '__'; 230 installBinaryFromPackage(name, 'bin/esbuild', tempBinPath) 231 .then(() => fs.renameSync(tempBinPath, binPath)) 232 .catch(e => setImmediate(() => { throw e; })); 233 } 234} 235 236function installWithWrapper(name: string, fromPath: string, toPath: string): void { 237 fs.writeFileSync( 238 binPath, 239 `#!/usr/bin/env node 240const path = require('path'); 241const esbuild_exe = path.join(__dirname, '..', ${JSON.stringify(toPath)}); 242const child_process = require('child_process'); 243const { status } = child_process.spawnSync(esbuild_exe, process.argv.slice(2), { stdio: 'inherit' }); 244process.exitCode = status === null ? 1 : status; 245`); 246 const absToPath = path.join(__dirname, toPath); 247 if (process.env.ESBUILD_BINARY_PATH) { 248 fs.copyFileSync(process.env.ESBUILD_BINARY_PATH, absToPath); 249 validateBinaryVersion(absToPath); 250 } else { 251 installBinaryFromPackage(name, fromPath, absToPath) 252 .catch(e => setImmediate(() => { throw e; })); 253 } 254} 255 256function installOnUnix(name: string): void { 257 // Yarn 2 is deliberately incompatible with binary modules because the 258 // developers of Yarn 2 don't think they should be used. See this thread for 259 // details: https://github.com/yarnpkg/berry/issues/882. 260 // 261 // We want to avoid slowing down esbuild for everyone just because of this 262 // decision by the Yarn 2 developers, so we explicitly detect if esbuild is 263 // being installed using Yarn 2 and install a compatability shim only for 264 // Yarn 2. Normal package managers can just run the binary directly for 265 // maximum speed. 266 if (isYarnBerryOrNewer()) { 267 installWithWrapper(name, "bin/esbuild", "esbuild"); 268 } else { 269 installDirectly(name); 270 } 271} 272 273function installOnWindows(name: string): void { 274 installWithWrapper(name, "esbuild.exe", "esbuild.exe"); 275} 276 277const platformKey = `${process.platform} ${os.arch()} ${os.endianness()}`; 278const knownWindowsPackages: Record<string, string> = { 279 'win32 arm64 LE': 'esbuild-windows-arm64', 280 'win32 ia32 LE': 'esbuild-windows-32', 281 'win32 x64 LE': 'esbuild-windows-64', 282}; 283const knownUnixlikePackages: Record<string, string> = { 284 'android arm64 LE': 'esbuild-android-arm64', 285 'darwin arm64 LE': 'esbuild-darwin-arm64', 286 'darwin x64 LE': 'esbuild-darwin-64', 287 'freebsd arm64 LE': 'esbuild-freebsd-arm64', 288 'freebsd x64 LE': 'esbuild-freebsd-64', 289 'openbsd x64 LE': 'esbuild-openbsd-64', 290 'linux arm LE': 'esbuild-linux-arm', 291 'linux arm64 LE': 'esbuild-linux-arm64', 292 'linux ia32 LE': 'esbuild-linux-32', 293 'linux mips64el LE': 'esbuild-linux-mips64le', 294 'linux ppc64 LE': 'esbuild-linux-ppc64le', 295 'linux x64 LE': 'esbuild-linux-64', 296}; 297 298// Pick a package to install 299if (platformKey in knownWindowsPackages) { 300 installOnWindows(knownWindowsPackages[platformKey]); 301} else if (platformKey in knownUnixlikePackages) { 302 installOnUnix(knownUnixlikePackages[platformKey]); 303} else { 304 console.error(`Unsupported platform: ${platformKey}`); 305 process.exit(1); 306} 307