diff --git a/CHANGELOG.md b/CHANGELOG.md index b5245f42fc1..699bc6516f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +* Install script falls back to `npm install` ([#319](https://github.com/evanw/esbuild/issues/319)) + + The `esbuild` package has a post-install script that downloads the esbuild binary. However, this will fail if `registry.npmjs.org` (or the configured custom npm registry) is inaccessible. + + This release adds an additional fallback for when the download fails. It tries to use the `npm install` command to download the esbuild binary instead. This handles situations where users have either configured npm with a proxy or have a custom command in their path called `npm`. + ## 0.6.17 * Add a download cache to the install script diff --git a/lib/install.ts b/lib/install.ts index 6edc974b87b..c5b6ae45b89 100644 --- a/lib/install.ts +++ b/lib/install.ts @@ -4,6 +4,7 @@ import url = require('url'); import path = require('path'); import zlib = require('zlib'); import https = require('https'); +import child_process = require('child_process'); const version = require('./package.json').version; const binPath = path.join(__dirname, 'bin', 'esbuild'); @@ -40,6 +41,24 @@ async function installBinaryFromPackage(name: string, fromPath: string, toPath: let urls = [`https://${officialRegistry}/${name}/-/${name}-${version}.tgz`]; let debug = false; + let finishInstall = (buffer: Buffer): void => { + // Write out the binary executable that was extracted from the package + fs.writeFileSync(toPath, buffer, { mode: 0o755 }); + + // Mark the operation as successful so this script is idempotent + fs.writeFileSync(stampPath, ''); + + // Also try to cache the file to speed up future installs + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.copyFileSync(toPath, cachePath); + cleanCacheLRU(cachePath); + } catch { + } + + if (debug) console.error(`Install successful`); + }; + // Try downloading from a custom registry first if one is configured try { let env = url.parse(process.env.npm_config_registry || ''); @@ -57,38 +76,31 @@ async function installBinaryFromPackage(name: string, fromPath: string, toPath: } catch { } + // Try each registry URL in succession for (let url of urls) { let tryText = `Trying to download ${JSON.stringify(url)}`; - if (debug) console.error(tryText); - try { - let buffer = extractFileFromTarGzip(await fetch(url), fromPath); - if (debug) console.error(`Install successful`); - - // Write out the binary executable that was extracted from the package - fs.writeFileSync(toPath, buffer, { mode: 0o755 }); - - // Mark the operation as successful so this script is idempotent - fs.writeFileSync(stampPath, ''); - - // Also try to cache the file to speed up future installs - try { - fs.mkdirSync(path.dirname(cachePath), { recursive: true }); - fs.copyFileSync(toPath, cachePath); - await cleanCacheLRU(cachePath); - } catch { - } - + if (debug) console.error(tryText); + finishInstall(extractFileFromTarGzip(await fetch(url), fromPath)); return; - } - - catch (err) { + } catch (err) { if (!debug) console.error(tryText); console.error(`Failed to download ${JSON.stringify(url)}: ${err && err.message || err}`); debug = true; } } + // If all of this fails, try using npm install instead. This is an attempt to + // work around users that cannot send normal HTTP requests. For example, they + // may have blocked registry.npmjs.org and configured a HTTPS proxy instead. + try { + console.log(`Trying to install "${name}" using npm`) + finishInstall(installUsingNPM(name, fromPath)); + return; + } catch (err) { + console.error(`Failed to install "${name}" using npm: ${err && err.message || err}`); + } + console.error(`Install unsuccessful`); process.exit(1); } @@ -101,23 +113,27 @@ function getCachePath(name: string): string { return path.join(home, '.cache', ...common); } -async function cleanCacheLRU(fileToKeep: string): Promise { +function cleanCacheLRU(fileToKeep: string): void { // Gather all entries in the cache const dir = path.dirname(fileToKeep); const entries: { path: string, mtime: Date }[] = []; - await Promise.all(fs.readdirSync(dir).map(entry => new Promise(resolve => { + for (const entry of fs.readdirSync(dir)) { const entryPath = path.join(dir, entry); - fs.stat(entryPath, (err, stats) => { - if (!err) entries.push({ path: entryPath, mtime: stats.mtime }); - resolve(); - }); - }))); + try { + const stats = fs.statSync(entryPath); + entries.push({ path: entryPath, mtime: stats.mtime }); + } catch { + } + } // Only keep the most recent entries entries.sort((a, b) => +b.mtime - +a.mtime); - await Promise.all(entries.slice(5).map(entry => new Promise(resolve => { - fs.unlink(entry.path, resolve); - }))); + for (const entry of entries.slice(5)) { + try { + fs.unlinkSync(entry.path); + } catch { + } + } } function fetch(url: string): Promise { @@ -142,6 +158,7 @@ function extractFileFromTarGzip(buffer: Buffer, file: string): Buffer { } let str = (i: number, n: number) => String.fromCharCode(...buffer.subarray(i, i + n)).replace(/\0.*$/, ''); let offset = 0; + file = `package/${file}`; while (offset < buffer.length) { let name = str(offset, 100); let size = parseInt(str(offset + 124, 12), 8); @@ -154,12 +171,44 @@ function extractFileFromTarGzip(buffer: Buffer, file: string): Buffer { throw new Error(`Could not find ${JSON.stringify(file)} in archive`); } +function installUsingNPM(name: string, file: string): Buffer { + const installDir = path.join(__dirname, '.install'); + fs.mkdirSync(installDir); + fs.writeFileSync(path.join(installDir, 'package.json'), '{}'); + + // Erase "npm_config_global" so that "npm install --global esbuild" works. + // Otherwise this nested "npm install" will also be global, and the install + // will deadlock waiting for the global installation lock. + const env = { ...process.env, npm_config_global: undefined }; + + child_process.execSync(`npm install --loglevel=error --prefer-offline --no-audit --progress=false ${name}@${version}`, + { cwd: installDir, stdio: 'inherit', env }); + const buffer = fs.readFileSync(path.join(installDir, 'node_modules', name, file)); + removeRecursive(installDir); + return buffer; +} + +function removeRecursive(dir: string): void { + for (const entry of fs.readdirSync(dir)) { + const entryPath = path.join(dir, entry); + let stats; + try { + stats = fs.lstatSync(entryPath); + } catch (e) { + continue; // Guard against https://github.com/nodejs/node/issues/4760 + } + if (stats.isDirectory()) removeRecursive(entryPath); + else fs.unlinkSync(entryPath); + } + fs.rmdirSync(dir); +} + function installOnUnix(name: string): void { if (process.env.ESBUILD_BIN_PATH_FOR_TESTS) { fs.unlinkSync(binPath); fs.symlinkSync(process.env.ESBUILD_BIN_PATH_FOR_TESTS, binPath); } else { - installBinaryFromPackage(name, 'package/bin/esbuild', binPath) + installBinaryFromPackage(name, 'bin/esbuild', binPath) .catch(e => setImmediate(() => { throw e; })); } } @@ -177,7 +226,7 @@ child_process.spawnSync(esbuild_exe, process.argv.slice(2), { stdio: 'inherit' } if (process.env.ESBUILD_BIN_PATH_FOR_TESTS) { fs.copyFileSync(process.env.ESBUILD_BIN_PATH_FOR_TESTS, exePath); } else { - installBinaryFromPackage(name, 'package/esbuild.exe', exePath) + installBinaryFromPackage(name, 'esbuild.exe', exePath) .catch(e => setImmediate(() => { throw e; })); } }