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

feat(lambda-nodejs): external and install modules #8681

Merged
merged 10 commits into from
Jun 29, 2020
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ cdk.out/

# Yarn error log
yarn-error.log

# Parcel default cache directory
.parcel-cache
4 changes: 0 additions & 4 deletions packages/@aws-cdk/aws-lambda-nodejs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ nyc.config.js
.LAST_PACKAGE
*.snk

# Parcel
.build
.cache

!test/integ-handlers/js-handler.js
!test/function.test.handler2.js
!.eslintrc.js
Expand Down
35 changes: 33 additions & 2 deletions packages/@aws-cdk/aws-lambda-nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,41 @@ new lambda.NodejsFunction(this, 'my-handler', {
```

### Configuring Parcel
The `NodejsFunction` construct exposes some [Parcel](https://parceljs.org/) options via properties: `minify`, `sourceMaps`,
`buildDir` and `cacheDir`.
The `NodejsFunction` construct exposes some [Parcel](https://parceljs.org/) options via properties: `minify`, `sourceMaps` and `cacheDir`.

Parcel transpiles your code (every internal module) with [@babel/preset-env](https://babeljs.io/docs/en/babel-preset-env) and uses the
runtime version of your Lambda function as target.

Configuring Babel with Parcel is possible via a `.babelrc` or a `babel` config in `package.json`.

### Working with modules

#### Externals
By default, all node modules are bundled except for `aws-sdk`. This can be configured by specifying
the `externalModules` prop.

```ts
new lambda.NodejsFunction(this, 'my-handler', {
externalModules: [
'aws-sdk', // Use the 'aws-sdk' available in the Lambda runtime
'cool-module', // 'cool-module' is already available in a Layer
],
});
```

#### Install modules
By default, all node modules referenced in your Lambda code will be bundled by Parcel.
Use the `nodeModules` prop to specify a list of modules that should not be bundled
but instead included in the `node_modules` folder of the Lambda package. This is useful
when working with native dependencies or when Parcel fails to bundle a module.

```ts
new lambda.NodejsFunction(this, 'my-handler', {
installModules: ['native-module', 'other-module']
eladb marked this conversation as resolved.
Show resolved Hide resolved
});
```

The modules listed in `nodeModules` must be present in the `package.json`'s dependencies. The
same version will be used for installation. If a lock file is detected (`package-lock.json` or
`yarn.lock`) it will be used along with the right installer (`npm` or `yarn`). The modules are
installed in a [Lambda compatible Docker container](https://github.com/lambci/docker-lambda).
247 changes: 162 additions & 85 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,136 +2,213 @@ import * as lambda from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';
import * as fs from 'fs';
import * as path from 'path';
import { findPkgPath } from './util';
import { PackageJsonManager } from './package-json-manager';
import { findClosestPathContaining } from './util';

/**
* Options for Parcel bundling
* Base options for Parcel bundling
*/
export interface ParcelOptions {
export interface ParcelBaseOptions {
/**
* Entry file
* Whether to minify files when bundling.
*
* @default false
*/
readonly entry: string;
readonly minify?: boolean;

/**
* Expose modules as UMD under this name
* Whether to include source maps when bundling.
*
* @default false
*/
readonly global: string;
readonly sourceMaps?: boolean;

/**
* Minify
* The cache directory
*
* Parcel uses a filesystem cache for fast rebuilds.
*
* @default - `.cache` in the root directory
*/
readonly minify?: boolean;
readonly cacheDir?: string;

/**
* Include source maps
* The root of the project. This will be used as the source for the volume
* mounted in the Docker container. If you specify this prop, ensure that
* this path includes `entry` and any module/dependencies used by your
* function otherwise bundling will not be possible.
*
* @default - the closest path containing a .git folder
*/
readonly sourceMaps?: boolean;
readonly projectRoot?: string;

/**
* The cache directory
* Environment variables defined when Parcel runs.
*
* @default - no environment variables are defined.
*/
readonly cacheDir?: string;
readonly parcelEnvironment?: { [key: string]: string; };

/**
* The node version to use as target for Babel
* A list of modules that should be considered as externals (already available
* in the runtime).
*
* @default ['aws-sdk']
*/
readonly nodeVersion: string;
readonly externalModules?: string[];

/**
* The docker tag of the node base image to use in the parcel-bundler docker image
* A list of modules that should be installed instead of bundled. Modules are
* installed in a Lambda compatible environnment.
*
* @see https://hub.docker.com/_/node/?tab=tags
* @default - all modules are bundled
*/
readonly nodeDockerTag: string;
readonly nodeModules?: string[];

/**
* The root of the project. This will be used as the source for the volume
* mounted in the Docker container.
* The version of Parcel to use.
*
* @default - 2.0.0-beta.1
*/
readonly parcelVersion?: string;
}

/**
* Options for Parcel bundling
*/
export interface ParcelOptions extends ParcelBaseOptions {
/**
* Entry file
*/
readonly projectRoot: string;
readonly entry: string;

/**
* The environment variables to pass to the container running Parcel.
*
* @default - no environment variables are passed to the container
* The runtime of the lambda function
*/
readonly environment?: { [key: string]: string; };
readonly runtime: lambda.Runtime;
}

/**
* Parcel code
* Bundling
*/
export class Bundling {
/**
* Parcel bundled Lambda asset code
*/
public static parcel(options: ParcelOptions): lambda.AssetCode {
// Original package.json path and content
let pkgPath = findPkgPath();
if (!pkgPath) {
throw new Error('Cannot find a `package.json` in this project.');
// Find project root
const projectRoot = options.projectRoot ?? findClosestPathContaining(`.git${path.sep}`);
if (!projectRoot) {
throw new Error('Cannot find project root. Please specify it with `projectRoot`.');
}
pkgPath = path.join(pkgPath, 'package.json');
const originalPkg = fs.readFileSync(pkgPath);
const originalPkgJson = JSON.parse(originalPkg.toString());

// Update engines.node in package.json to set the right Babel target
setEngines(options.nodeVersion, pkgPath, originalPkgJson);
// Bundling image derived from runtime bundling image (lambci)
const image = cdk.BundlingDockerImage.fromAsset(path.join(__dirname, '../parcel'), {
buildArgs: {
IMAGE: options.runtime.bundlingDockerImage.image,
PARCEL_VERSION: options.parcelVersion ?? '2.0.0-beta.1',
},
});

const packageJsonManager = new PackageJsonManager();

// Collect external and install modules
let includeNodeModules: { [key: string]: boolean } | undefined;
let dependencies: { [key: string]: string } | undefined;
const externalModules = options.externalModules ?? ['aws-sdk'];
if (externalModules || options.nodeModules) {
const modules = [...externalModules, ...options.nodeModules ?? []];
includeNodeModules = {};
for (const mod of modules) {
includeNodeModules[mod] = false;
}
if (options.nodeModules) {
dependencies = packageJsonManager.getVersions(options.nodeModules);
}
}

// Entry file path relative to container path
const containerEntryPath = path.join(cdk.AssetStaging.BUNDLING_INPUT_DIR, path.relative(options.projectRoot, path.resolve(options.entry)));

try {
const command = [
'parcel', 'build', containerEntryPath.replace(/\\/g, '/'), // Always use POSIX paths in the container
'--out-dir', cdk.AssetStaging.BUNDLING_OUTPUT_DIR,
'--out-file', 'index.js',
'--global', options.global,
'--target', 'node',
'--bundle-node-modules',
'--log-level', '2',
!options.minify && '--no-minify',
!options.sourceMaps && '--no-source-maps',
...(options.cacheDir ? ['--cache-dir', '/parcel-cache'] : []),
].filter(Boolean) as string[];

return lambda.Code.fromAsset(options.projectRoot, {
assetHashType: cdk.AssetHashType.BUNDLE,
bundling: {
image: cdk.BundlingDockerImage.fromAsset(path.join(__dirname, '../parcel-bundler'), {
buildArgs: {
NODE_TAG: options.nodeDockerTag ?? `${process.versions.node}-alpine`,
},
}),
environment: options.environment,
volumes: options.cacheDir
? [{ containerPath: '/parcel-cache', hostPath: options.cacheDir }]
: [],
workingDirectory: path.dirname(containerEntryPath).replace(/\\/g, '/'), // Always use POSIX paths in the container
command,
// Configure target in package.json for Parcel
packageJsonManager.update({
'cdk-lambda': `${cdk.AssetStaging.BUNDLING_OUTPUT_DIR}/index.js`,
'targets': {
'cdk-lambda': {
context: 'node',
includeNodeModules: includeNodeModules ?? true,
sourceMap: options.sourceMaps ?? false,
minify: options.minify ?? false,
engines: {
node: `>= ${runtimeVersion(options.runtime)}`,
},
},
});
} finally {
restorePkg(pkgPath, originalPkg);
},
});

// Entry file path relative to container path
const containerEntryPath = path.join(cdk.AssetStaging.BUNDLING_INPUT_DIR, path.relative(projectRoot, path.resolve(options.entry)));
const parcelCommand = `parcel build ${containerEntryPath.replace(/\\/g, '/')} --target cdk-lambda${options.cacheDir ? ' --cache-dir /parcel-cache' : ''}`;

let installer = Installer.NPM;
let lockfile: string | undefined;
let depsCommand = '';

if (dependencies) {
// Create a dummy package.json for dependencies that we need to install
fs.writeFileSync(
path.join(projectRoot, '.package.json'),
JSON.stringify({ dependencies }),
);

// Use npm unless we have a yarn.lock.
if (fs.existsSync(path.join(projectRoot, LockFile.YARN))) {
installer = Installer.YARN;
lockfile = LockFile.YARN;
} else if (fs.existsSync(path.join(projectRoot, LockFile.NPM))) {
lockfile = LockFile.NPM;
}

// Move dummy package.json and lock file then install
depsCommand = chain([
`mv ${cdk.AssetStaging.BUNDLING_INPUT_DIR}/.package.json ${cdk.AssetStaging.BUNDLING_OUTPUT_DIR}/package.json`,
lockfile ? `cp ${cdk.AssetStaging.BUNDLING_INPUT_DIR}/${lockfile} ${cdk.AssetStaging.BUNDLING_OUTPUT_DIR}/${lockfile}` : '',
`cd ${cdk.AssetStaging.BUNDLING_OUTPUT_DIR} && ${installer} install`,
]);
}

return lambda.Code.fromAsset(projectRoot, {
assetHashType: cdk.AssetHashType.BUNDLE,
bundling: {
image,
command: ['bash', '-c', chain([parcelCommand, depsCommand])],
environment: options.parcelEnvironment,
volumes: options.cacheDir
? [{ containerPath: '/parcel-cache', hostPath: options.cacheDir }]
: [],
workingDirectory: path.dirname(containerEntryPath).replace(/\\/g, '/'), // Always use POSIX paths in the container
},
});
}
}

function setEngines(nodeVersion: string, pkgPath: string, originalPkgJson: any): void {
// Update engines.node (Babel target)
const updateData = {
engines: {
node: `>= ${nodeVersion}`,
},
};

// Write new package.json
if (Object.keys(updateData).length !== 0) {
fs.writeFileSync(pkgPath, JSON.stringify({
...originalPkgJson,
...updateData,
}, null, 2));
enum Installer {
NPM = 'npm',
YARN = 'yarn',
}

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

function runtimeVersion(runtime: lambda.Runtime): string {
const match = runtime.name.match(/nodejs(\d+)/);

if (!match) {
throw new Error('Cannot extract version from runtime.');
}

return match[1];
}

function restorePkg(pkgPath: string, originalPkg: Buffer): void {
fs.writeFileSync(pkgPath, originalPkg);
function chain(commands: string[]): string {
return commands.filter(c => !!c).join(' && ');
}