Skip to content

Commit

Permalink
fix #319: use "npm install" if download fails
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Aug 7, 2020
1 parent feaca75 commit 894d835
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 34 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
117 changes: 83 additions & 34 deletions lib/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 || '');
Expand All @@ -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);
}
Expand All @@ -101,23 +113,27 @@ function getCachePath(name: string): string {
return path.join(home, '.cache', ...common);
}

async function cleanCacheLRU(fileToKeep: string): Promise<void> {
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<Buffer> {
Expand All @@ -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);
Expand All @@ -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; }));
}
}
Expand All @@ -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; }));
}
}
Expand Down

0 comments on commit 894d835

Please sign in to comment.