packages/jsii-pacmak/lib/util.ts (249 lines of code) (raw):
import { spawn, SpawnOptions } from 'child_process';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import * as logging from './logging';
/**
* Find the directory that contains a given dependency, identified by its 'package.json', from a starting search directory
*
* (This code is duplicated among jsii/jsii-pacmak/jsii-reflect. Changes should be done in all
* 3 locations, and we should unify these at some point: https://github.com/aws/jsii/issues/3236)
*/
export async function findDependencyDirectory(
dependencyName: string,
searchStart: string,
) {
// Explicitly do not use 'require("dep/package.json")' because that will fail if the
// package does not export that particular file.
const entryPoint = require.resolve(dependencyName, {
paths: [searchStart],
});
// Search up from the given directory, looking for a package.json that matches
// the dependency name (so we don't accidentally find stray 'package.jsons').
const depPkgJsonPath = await findPackageJsonUp(
dependencyName,
path.dirname(entryPoint),
);
if (!depPkgJsonPath) {
throw new Error(
`Could not find dependency '${dependencyName}' from '${searchStart}'`,
);
}
return depPkgJsonPath;
}
/**
* Whether the given dependency is a built-in
*
* Some dependencies that occur in `package.json` are also built-ins in modern Node
* versions (most egregious example: 'punycode'). Detect those and filter them out.
*/
export function isBuiltinModule(depName: string) {
// eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires
const { builtinModules } = require('module');
return (builtinModules ?? []).includes(depName);
}
/**
* Find the package.json for a given package upwards from the given directory
*
* (This code is duplicated among jsii/jsii-pacmak/jsii-reflect. Changes should be done in all
* 3 locations, and we should unify these at some point: https://github.com/aws/jsii/issues/3236)
*/
export async function findPackageJsonUp(
packageName: string,
directory: string,
) {
return findUp(directory, async (dir) => {
const pjFile = path.join(dir, 'package.json');
return (
(await fs.pathExists(pjFile)) &&
(await fs.readJson(pjFile)).name === packageName
);
});
}
/**
* Find a directory up the tree from a starting directory matching a condition
*
* Will return `undefined` if no directory matches
*
* (This code is duplicated among jsii/jsii-pacmak/jsii-reflect. Changes should be done in all
* 3 locations, and we should unify these at some point: https://github.com/aws/jsii/issues/3236)
*/
export async function findUp(
directory: string,
pred: (dir: string) => Promise<boolean>,
): Promise<string | undefined> {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
if (await pred(directory)) {
return directory;
}
const parent = path.dirname(directory);
if (parent === directory) {
return undefined;
}
directory = parent;
}
}
export interface RetryOptions {
/**
* The maximum amount of attempts to make.
*
* @default 5
*/
maxAttempts?: number;
/**
* The amount of time (in milliseconds) to wait after the first failed attempt.
*
* @default 150
*/
backoffBaseMilliseconds?: number;
/**
* The multiplier to apply after each failed attempts. If the backoff before
* the previous attempt was `B`, the next backoff is computed as
* `B * backoffMultiplier`, creating an exponential series.
*
* @default 2
*/
backoffMultiplier?: number;
/**
* An optionnal callback that gets invoked when an attempt failed. This can be
* used to give the user indications of what is happening.
*
* This callback must not throw.
*
* @param error the error that just occurred
* @param attemptsLeft the number of attempts left
* @param backoffMilliseconds the amount of milliseconds of back-off that will
* be awaited before making the next attempt (if
* there are attempts left)
*/
onFailedAttempt?: (
error: unknown,
attemptsLeft: number,
backoffMilliseconds: number,
) => void;
}
export class AllAttemptsFailed<R> extends Error {
public constructor(
public readonly callback: () => Promise<R>,
public readonly errors: readonly Error[],
) {
super(
`All attempts failed. Last error: ${errors[errors.length - 1].message}`,
);
}
}
/**
* Adds back-off and retry logic around the provided callback.
*
* @param cb the callback which is to be retried.
* @param opts the backoff-and-retry configuration
*
* @returns the result of `cb`
*/
export async function retry<R>(
cb: () => Promise<R>,
opts: RetryOptions = {},
waiter: (ms: number) => Promise<void> = wait,
): Promise<R> {
let attemptsLeft = opts.maxAttempts ?? 5;
let backoffMs = opts.backoffBaseMilliseconds ?? 150;
const backoffMult = opts.backoffMultiplier ?? 2;
// Check for incorrect usage
if (attemptsLeft <= 0) {
throw new Error('maxTries must be > 0');
}
if (backoffMs <= 0) {
throw new Error('backoffBaseMilliseconds must be > 0');
}
if (backoffMult <= 1) {
throw new Error('backoffMultiplier must be > 1');
}
const errors = new Array<Error>();
while (attemptsLeft > 0) {
attemptsLeft--;
try {
// eslint-disable-next-line no-await-in-loop
return await cb();
} catch (error: any) {
errors.push(error);
if (opts.onFailedAttempt != null) {
opts.onFailedAttempt(error, attemptsLeft, backoffMs);
}
}
if (attemptsLeft > 0) {
// eslint-disable-next-line no-await-in-loop
await waiter(backoffMs).then(() => (backoffMs *= backoffMult));
}
}
return Promise.reject(new AllAttemptsFailed(cb, errors));
}
export interface ShellOptions extends Omit<SpawnOptions, 'shell' | 'stdio'> {
/**
* Configure in-line retries if the execution fails.
*
* @default - no retries
*/
readonly retry?: RetryOptions;
}
/**
* Spawns a child process with the provided command and arguments. The child
* process is always spawned using `shell: true`, and the contents of
* `process.env` is used as the initial value of the `env` spawn option (values
* provided in `options.env` can override those).
*
* @param cmd the command to shell out to.
* @param args the arguments to provide to `cmd`
* @param options any options to pass to `spawn`
*/
export async function shell(
cmd: string,
args: string[],
{ retry: retryOptions, ...options }: ShellOptions = {},
): Promise<string> {
async function spawn1() {
logging.debug(cmd, args.join(' '), JSON.stringify(options));
return new Promise<string>((ok, ko) => {
const child = spawn(cmd, args, {
...options,
shell: true,
env: { ...process.env, ...(options.env ?? {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = new Array<Buffer>();
const stderr = new Array<Buffer>();
child.stdout.on('data', (chunk) => {
if (logging.level.valueOf() >= logging.LEVEL_SILLY) {
process.stderr.write(chunk); // notice - we emit all build output to stderr
}
stdout.push(Buffer.from(chunk));
});
child.stderr.on('data', (chunk) => {
if (logging.level.valueOf() >= logging.LEVEL_SILLY) {
process.stderr.write(chunk);
}
stderr.push(Buffer.from(chunk));
});
child.once('error', ko);
// Must use CLOSE instead of EXIT; EXIT may fire while there is still data in the
// I/O pipes, which we will miss if we return at that point.
child.once('close', (code, signal) => {
const out = Buffer.concat(stdout).toString('utf-8');
if (code === 0) {
return ok(out);
}
const err = Buffer.concat(stderr).toString('utf-8');
const reason = signal != null ? `signal ${signal}` : `status ${code}`;
const command = `${cmd} ${args.join(' ')}`;
return ko(
new Error(
[
`Command (${command}) failed with ${reason}:`,
// STDERR first, the erro message could be truncated in logs.
prefix(err, '#STDERR> '),
prefix(out, '#STDOUT> '),
].join('\n'),
),
);
function prefix(text: string, add: string): string {
return text
.split('\n')
.map((line) => `${add}${line}`)
.join('\n');
}
});
});
}
if (retryOptions != null) {
return retry(spawn1, {
...retryOptions,
onFailedAttempt:
retryOptions.onFailedAttempt ??
((error, attemptsLeft, backoffMs) => {
const message = (error as Error).message ?? error;
const retryInfo =
attemptsLeft > 0
? `Waiting ${backoffMs} ms before retrying (${attemptsLeft} attempts left)`
: 'No attempts left';
logging.info(
`Command "${cmd} ${args.join(
' ',
)}" failed with ${message}. ${retryInfo}.`,
);
}),
});
}
return spawn1();
}
/**
* Strip filesystem unsafe characters from a string
*/
export function slugify(x: string) {
return x.replace(/[^a-zA-Z0-9_-]/g, '_');
}
/**
* Class that makes a temporary directory and holds on to an operation object
*/
export class Scratch<A> {
public static async make<A>(
factory: (dir: string) => Promise<A>,
): Promise<Scratch<A>>;
public static async make<A>(factory: (dir: string) => A): Promise<Scratch<A>>;
public static async make<A>(factory: (dir: string) => A | Promise<A>) {
const tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'npm-pack'));
return new Scratch(tmpdir, await factory(tmpdir), false);
}
public static fake<A>(directory: string, object: A) {
return new Scratch(directory, object, true);
}
public static async cleanupAll<A>(tempDirs: Array<Scratch<A>>) {
await Promise.all(tempDirs.map((t) => t.cleanup()));
}
private constructor(
public readonly directory: string,
public readonly object: A,
private readonly fake: boolean,
) {}
public async cleanup() {
if (!this.fake) {
try {
await fs.remove(this.directory);
} catch (e: any) {
if (e.code === 'EBUSY') {
// This occasionally happens on Windows if we try to clean up too
// quickly after we're done... Could be because some AV software is
// still running in the background.
// Wait 1s and retry once!
await new Promise((ok) => setTimeout(ok, 1_000));
try {
await fs.remove(this.directory);
} catch (e2: any) {
logging.warn(`Unable to clean up ${this.directory}: ${e2}`);
}
return;
}
logging.warn(`Unable to clean up ${this.directory}: ${e}`);
}
}
}
}
export function setExtend<A>(xs: Set<A>, els: Iterable<A>) {
for (const el of els) {
xs.add(el);
}
}
export async function filterAsync<A>(
xs: A[],
pred: (x: A) => Promise<boolean>,
): Promise<A[]> {
const mapped = await Promise.all(
xs.map(async (x) => ({ x, pred: await pred(x) })),
);
return mapped.filter(({ pred }) => pred).map(({ x }) => x);
}
export async function wait(ms: number): Promise<void> {
return new Promise((ok) => setTimeout(ok, ms));
}
export function flatten<A>(xs: readonly A[][]): A[] {
return Array.prototype.concat.call([], ...xs);
}