Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(lambda-nodejs): esbuild detection with Yarn 2 in PnP mode #13257

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 49 additions & 41 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import * as os from 'os';
import * as path from 'path';
import { AssetCode, Code, Runtime } from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';
import { EsbuildInstallation } from './esbuild-installation';
import { PackageManager } from './package-manager';
import { BundlingOptions } from './types';
import { exec, extractDependencies, findUp, getEsBuildVersion, LockFile } from './util';
import { exec, extractDependencies, findUp } from './util';

const ESBUILD_VERSION = '0';

Expand Down Expand Up @@ -41,11 +43,11 @@ export class Bundling implements cdk.BundlingOptions {
});
}

public static clearRunsLocallyCache(): void {
this.runsLocally = undefined;
public static clearEsbuildInstallationCache(): void {
this.esbuildInstallation = undefined;
}

private static runsLocally?: boolean;
private static esbuildInstallation?: EsbuildInstallation;

// Core bundling options
public readonly image: cdk.BundlingDockerImage;
Expand All @@ -57,11 +59,13 @@ export class Bundling implements cdk.BundlingOptions {
private readonly relativeEntryPath: string;
private readonly relativeTsconfigPath?: string;
private readonly externals: string[];
private readonly packageManager: PackageManager;

constructor(private readonly props: BundlingProps) {
Bundling.runsLocally = Bundling.runsLocally
?? getEsBuildVersion()?.startsWith(ESBUILD_VERSION)
?? false;
this.packageManager = PackageManager.fromLockFile(props.depsLockFilePath);

Bundling.esbuildInstallation = Bundling.esbuildInstallation ?? EsbuildInstallation.detect();
const runsLocally = !!Bundling.esbuildInstallation?.version.startsWith(ESBUILD_VERSION);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe esbuildInstallation?.isLocal

Copy link
Contributor Author

@jogold jogold Mar 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are too many locals... this runsLocally here means local bundling (vs Docker bundling). The isLocal of the esbuildInstallation means is locally installed (vs globally installed).


const projectRoot = path.dirname(props.depsLockFilePath);
this.relativeEntryPath = path.relative(projectRoot, path.resolve(props.entry));
Expand All @@ -76,7 +80,7 @@ export class Bundling implements cdk.BundlingOptions {
];

// Docker bundling
const shouldBuildImage = props.forceDockerBundling || !Bundling.runsLocally;
const shouldBuildImage = props.forceDockerBundling || !runsLocally;
this.image = shouldBuildImage
? props.dockerImage ?? cdk.BundlingDockerImage.fromAsset(path.join(__dirname, '../lib'), {
buildArgs: {
Expand All @@ -87,7 +91,12 @@ export class Bundling implements cdk.BundlingOptions {
})
: cdk.BundlingDockerImage.fromRegistry('dummy'); // Do not build if we don't need to

const bundlingCommand = this.createBundlingCommand(cdk.AssetStaging.BUNDLING_INPUT_DIR, cdk.AssetStaging.BUNDLING_OUTPUT_DIR);
const bundlingCommand = this.createBundlingCommand({
inputDir: cdk.AssetStaging.BUNDLING_INPUT_DIR,
outputDir: cdk.AssetStaging.BUNDLING_OUTPUT_DIR,
esbuildRunner: 'esbuild', // esbuild is installed globally in the docker image
osPlatform: 'linux', // linux docker image
});
this.command = ['bash', '-c', bundlingCommand];
this.environment = props.environment;
// Bundling sets the working directory to cdk.AssetStaging.BUNDLING_INPUT_DIR
Expand All @@ -97,16 +106,21 @@ export class Bundling implements cdk.BundlingOptions {
// Local bundling
if (!props.forceDockerBundling) { // only if Docker is not forced
const osPlatform = os.platform();
const createLocalCommand = (outputDir: string) => this.createBundlingCommand(projectRoot, outputDir, osPlatform);
const createLocalCommand = (outputDir: string, esbuild: EsbuildInstallation) => this.createBundlingCommand({
inputDir: projectRoot,
outputDir,
esbuildRunner: esbuild.isLocal ? this.packageManager.runBinCommand('esbuild') : 'esbuild',
osPlatform,
});

this.local = {
tryBundle(outputDir: string) {
if (Bundling.runsLocally === false) {
if (!Bundling.esbuildInstallation || !runsLocally) {
process.stderr.write('esbuild cannot run locally. Switching to Docker bundling.\n');
return false;
}

const localCommand = createLocalCommand(outputDir);
const localCommand = createLocalCommand(outputDir, Bundling.esbuildInstallation);

exec(
osPlatform === 'win32' ? 'cmd' : 'bash',
Expand All @@ -131,31 +145,30 @@ export class Bundling implements cdk.BundlingOptions {
}
}

public createBundlingCommand(inputDir: string, outputDir: string, osPlatform: NodeJS.Platform = 'linux'): string {
const pathJoin = osPathJoin(osPlatform);
public createBundlingCommand(options: BundlingCommandOptions): string {
const pathJoin = osPathJoin(options.osPlatform);

const npx = osPlatform === 'win32' ? 'npx.cmd' : 'npx';
const loaders = Object.entries(this.props.loader ?? {});
const defines = Object.entries(this.props.define ?? {});

const esbuildCommand: string = [
npx, 'esbuild',
'--bundle', pathJoin(inputDir, this.relativeEntryPath).replace(/(\s+)/g, '\\$1'),
const esbuildCommand: string[] = [
options.esbuildRunner,
'--bundle', pathJoin(options.inputDir, this.relativeEntryPath).replace(/(\s+)/g, '\\$1'),
`--target=${this.props.target ?? toTarget(this.props.runtime)}`,
'--platform=node',
`--outfile=${pathJoin(outputDir, 'index.js')}`,
`--outfile=${pathJoin(options.outputDir, 'index.js')}`,
...this.props.minify ? ['--minify'] : [],
...this.props.sourceMap ? ['--sourcemap'] : [],
...this.externals.map(external => `--external:${external}`),
...loaders.map(([ext, name]) => `--loader:${ext}=${name}`),
...defines.map(([key, value]) => `--define:${key}=${value}`),
...this.props.logLevel ? [`--log-level=${this.props.logLevel}`] : [],
...this.props.keepNames ? ['--keep-names'] : [],
...this.relativeTsconfigPath ? [`--tsconfig=${pathJoin(inputDir, this.relativeTsconfigPath)}`] : [],
...this.props.metafile ? [`--metafile=${pathJoin(outputDir, 'index.meta.json')}`] : [],
...this.relativeTsconfigPath ? [`--tsconfig=${pathJoin(options.inputDir, this.relativeTsconfigPath)}`] : [],
...this.props.metafile ? [`--metafile=${pathJoin(options.outputDir, 'index.meta.json')}`] : [],
...this.props.banner ? [`--banner='${this.props.banner}'`] : [],
...this.props.footer ? [`--footer='${this.props.footer}'`] : [],
].join(' ');
];

let depsCommand = '';
if (this.props.nodeModules) {
Expand All @@ -168,37 +181,32 @@ export class Bundling implements cdk.BundlingOptions {

// Determine dependencies versions, lock file and installer
const dependencies = extractDependencies(pkgPath, this.props.nodeModules);
let installer = Installer.NPM;
let lockFile = LockFile.NPM;
if (this.props.depsLockFilePath.endsWith(LockFile.YARN)) {
lockFile = LockFile.YARN;
installer = Installer.YARN;
}

const osCommand = new OsCommand(osPlatform);
const osCommand = new OsCommand(options.osPlatform);

// Create dummy package.json, copy lock file if any and then install
depsCommand = chain([
osCommand.writeJson(pathJoin(outputDir, 'package.json'), { dependencies }),
osCommand.copy(pathJoin(inputDir, lockFile), pathJoin(outputDir, lockFile)),
osCommand.changeDirectory(outputDir),
`${installer} install`,
osCommand.writeJson(pathJoin(options.outputDir, 'package.json'), { dependencies }),
osCommand.copy(pathJoin(options.inputDir, this.packageManager.lockFile), pathJoin(options.outputDir, this.packageManager.lockFile)),
osCommand.changeDirectory(options.outputDir),
this.packageManager.installCommand.join(' '),
]);
}

return chain([
...this.props.commandHooks?.beforeBundling(inputDir, outputDir) ?? [],
esbuildCommand,
...(this.props.nodeModules && this.props.commandHooks?.beforeInstall(inputDir, outputDir)) ?? [],
...this.props.commandHooks?.beforeBundling(options.inputDir, options.outputDir) ?? [],
esbuildCommand.join(' '),
...(this.props.nodeModules && this.props.commandHooks?.beforeInstall(options.inputDir, options.outputDir)) ?? [],
depsCommand,
...this.props.commandHooks?.afterBundling(inputDir, outputDir) ?? [],
...this.props.commandHooks?.afterBundling(options.inputDir, options.outputDir) ?? [],
]);
}
}

enum Installer {
NPM = 'npm',
YARN = 'yarn',
interface BundlingCommandOptions {
readonly inputDir: string;
readonly outputDir: string;
readonly esbuildRunner: string;
readonly osPlatform: NodeJS.Platform;
}

/**
Expand Down
35 changes: 35 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/esbuild-installation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { spawnSync } from 'child_process';
import { getModuleVersion } from './util';

/**
* An esbuild installation
*/
export abstract class EsbuildInstallation {
public static detect(): EsbuildInstallation | undefined {
try {
try {
// Check local version first
const version = getModuleVersion('esbuild');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this have to throw if the module doesn't exist? Can this be tryGetModuleVersion?

return {
isLocal: true,
version,
};
} catch (err) {
// Fallback to a global version
const esbuild = spawnSync('esbuild', ['--version']);
if (esbuild.status === 0 && !esbuild.error) {
return {
isLocal: false,
version: esbuild.stdout.toString().trim(),
};
}
return undefined;
}
} catch (err) {
return undefined;
}
}

public abstract readonly isLocal: boolean;
public abstract readonly version: string;
}
5 changes: 3 additions & 2 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import * as fs from 'fs';
import * as path from 'path';
import * as lambda from '@aws-cdk/aws-lambda';
import { Bundling } from './bundling';
import { PackageManager } from './package-manager';
import { BundlingOptions } from './types';
import { callsites, findUp, LockFile, nodeMajorVersion } from './util';
import { callsites, findUp, nodeMajorVersion } from './util';

// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
// eslint-disable-next-line no-duplicate-imports, import/order
Expand Down Expand Up @@ -95,7 +96,7 @@ export class NodejsFunction extends lambda.Function {
}
depsLockFilePath = path.resolve(props.depsLockFilePath);
} else {
const lockFile = findUp(LockFile.YARN) ?? findUp(LockFile.NPM);
const lockFile = findUp(PackageManager.YARN.lockFile) ?? findUp(PackageManager.NPM.lockFile);
if (!lockFile) {
throw new Error('Cannot find a package lock file (`yarn.lock` or `package-lock.json`). Please specify it with `depsFileLockPath`.');
}
Expand Down
57 changes: 57 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/package-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as os from 'os';
import * as path from 'path';

interface PackageManagerProps {
readonly lockFile: string;
readonly installCommand: string[];
readonly runCommand: string[];
}

/**
* A node package manager
*/
export class PackageManager {
public static NPM = new PackageManager({
lockFile: 'package-lock.json',
installCommand: ['npm', 'install'],
runCommand: ['npx', '--no-install'],
});

public static YARN = new PackageManager({
lockFile: 'yarn.lock',
installCommand: ['yarn', 'install'],
runCommand: ['yarn', 'run'],
});

public static fromLockFile(lockFilePath: string): PackageManager {
const lockFile = path.basename(lockFilePath);

switch (lockFile) {
case PackageManager.NPM.lockFile:
return PackageManager.NPM;
case PackageManager.YARN.lockFile:
return PackageManager.YARN;
default:
return PackageManager.NPM;
}
}

public readonly lockFile: string;
public readonly installCommand: string[];
public readonly runCommand: string[];

constructor(props: PackageManagerProps) {
this.lockFile = props.lockFile;
this.installCommand = props.installCommand;
this.runCommand = props.runCommand;
}

public runBinCommand(bin: string): string {
const [runCommand, ...runArgs] = this.runCommand;
return [
os.platform() === 'win32' ? `${runCommand}.cmd` : runCommand,
...runArgs,
bin,
].join(' ');
}
}
47 changes: 14 additions & 33 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { spawnSync, SpawnSyncOptions } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

export interface CallSite {
Expand Down Expand Up @@ -78,6 +77,18 @@ export function exec(cmd: string, args: string[], options?: SpawnSyncOptions) {
return proc;
}

/**
* Returns a module version by requiring its package.json file
*/
export function getModuleVersion(mod: string): string {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(`${mod}/package.json`).version;
} catch (err) {
throw new Error(`Cannot extract version for module '${mod}'`);
}
}

/**
* Extract versions for a list of modules.
*
Expand All @@ -97,39 +108,9 @@ export function extractDependencies(pkgPath: string, modules: string[]): { [key:
};

for (const mod of modules) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const version = pkgDependencies[mod] ?? require(`${mod}/package.json`).version;
dependencies[mod] = version;
} catch (err) {
throw new Error(`Cannot extract version for module '${mod}'. Check that it's referenced in your package.json or installed.`);
}
const version = pkgDependencies[mod] ?? getModuleVersion(mod);
dependencies[mod] = version;
}

return dependencies;
}

/**
* Returns the installed esbuild version
*/
export function getEsBuildVersion(): string | undefined {
try {
// --no-install ensures that we are checking for an installed version
// (either locally or globally)
const npx = os.platform() === 'win32' ? 'npx.cmd' : 'npx';
const esbuild = spawnSync(npx, ['--no-install', 'esbuild', '--version']);

if (esbuild.status !== 0 || esbuild.error) {
return undefined;
}

return esbuild.stdout.toString().trim();
} catch (err) {
return undefined;
}
}

export enum LockFile {
NPM = 'package-lock.json',
YARN = 'yarn.lock'
}
Loading