diff --git a/.gitignore b/.gitignore index 2cb016405e016..36d6e15a83ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ cdk.out/ # Yarn error log yarn-error.log + +# Parcel default cache directory +.parcel-cache diff --git a/packages/@aws-cdk/aws-lambda-nodejs/.gitignore b/packages/@aws-cdk/aws-lambda-nodejs/.gitignore index 7be76e8fa94c1..ce811676a5cb2 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/.gitignore +++ b/packages/@aws-cdk/aws-lambda-nodejs/.gitignore @@ -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 diff --git a/packages/@aws-cdk/aws-lambda-nodejs/README.md b/packages/@aws-cdk/aws-lambda-nodejs/README.md index 84b938347b6f1..b4e4579991f9e 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/README.md +++ b/packages/@aws-cdk/aws-lambda-nodejs/README.md @@ -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', { + nodeModules: ['native-module', 'other-module'] +}); +``` + +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). diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts index 15457bcf45869..274e043bf11a3 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts @@ -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(' && '); } diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts index c231eb06d7f84..70cfb8f19b130 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts @@ -2,13 +2,14 @@ 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 { Bundling } from './bundling'; -import { findGitPath, nodeMajorVersion, parseStackTrace } from './util'; +import { Bundling, ParcelBaseOptions } from './bundling'; +import { PackageJsonManager } from './package-json-manager'; +import { nodeMajorVersion, parseStackTrace } from './util'; /** * Properties for a NodejsFunction */ -export interface NodejsFunctionProps extends lambda.FunctionOptions { +export interface NodejsFunctionProps extends lambda.FunctionOptions, ParcelBaseOptions { /** * Path to the entry file (JavaScript or TypeScript). * @@ -34,62 +35,6 @@ export interface NodejsFunctionProps extends lambda.FunctionOptions { * `NODEJS_10_X` otherwise. */ readonly runtime?: lambda.Runtime; - - /** - * Whether to minify files when bundling. - * - * @default false - */ - readonly minify?: boolean; - - /** - * Whether to include source maps when bundling. - * - * @default false - */ - readonly sourceMaps?: boolean; - - /** - * The build directory - * - * @default - `.build` in the entry file directory - */ - readonly buildDir?: string; - - /** - * The cache directory - * - * Parcel uses a filesystem cache for fast rebuilds. - * - * @default - `.cache` in the root directory - */ - readonly cacheDir?: string; - - /** - * The docker tag of the node base image to use in the parcel-bundler docker image - * - * @see https://hub.docker.com/_/node/?tab=tags - * - * @default - the `process.versions.node` alpine image - */ - readonly nodeDockerTag?: string; - - /** - * 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 projectRoot?: string; - - /** - * The environment variables to pass to the container running Parcel. - * - * @default - no environment variables are passed to the container - */ - readonly containerEnvironment?: { [key: string]: string; }; } /** @@ -101,34 +46,32 @@ export class NodejsFunction extends lambda.Function { throw new Error('Only `NODEJS` runtimes are supported.'); } + // Entry and defaults const entry = findEntry(id, props.entry); const handler = props.handler ?? 'handler'; const defaultRunTime = nodeMajorVersion() >= 12 ? lambda.Runtime.NODEJS_12_X : lambda.Runtime.NODEJS_10_X; const runtime = props.runtime ?? defaultRunTime; - const projectRoot = props.projectRoot ?? findGitPath(); - if (!projectRoot) { - throw new Error('Cannot find project root. Please specify it with `projectRoot`.'); - } - const nodeDockerTag = props.nodeDockerTag ?? `${process.versions.node}-alpine`; - super(scope, id, { - ...props, - runtime, - code: Bundling.parcel({ - entry, - global: handler, - minify: props.minify, - sourceMaps: props.sourceMaps, - cacheDir: props.cacheDir, - nodeVersion: extractVersion(runtime), - nodeDockerTag, - projectRoot: path.resolve(projectRoot), - environment: props.containerEnvironment, - }), - handler: `index.${handler}`, - }); + // We need to restore the package.json after bundling + const packageJsonManager = new PackageJsonManager(); + + try { + super(scope, id, { + ...props, + runtime, + code: Bundling.parcel({ + entry, + runtime, + ...props, + }), + handler: `index.${handler}`, + }); + } finally { + // We can only restore after the code has been bound to the function + packageJsonManager.restore(); + } } } @@ -178,16 +121,3 @@ function findDefiningFile(): string { return stackTrace[functionIndex + 1].file; } - -/** - * Extracts the version from the runtime - */ -function extractVersion(runtime: lambda.Runtime): string { - const match = runtime.name.match(/nodejs(\d+)/); - - if (!match) { - throw new Error('Cannot extract version from runtime.'); - } - - return match[1]; -} diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/index.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/index.ts index 2653adb2a89e8..bcda298c2f5fe 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/index.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/index.ts @@ -1 +1,2 @@ export * from './function'; +export * from './bundling'; diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/package-json-manager.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/package-json-manager.ts new file mode 100644 index 0000000000000..c43c4b0963351 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/package-json-manager.ts @@ -0,0 +1,71 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { findClosestPathContaining } from './util'; + +/** + * A package.json manager to act on the closest package.json file. + * + * Configuring the bundler requires to manipulate the package.json and then + * restore it. + */ +export class PackageJsonManager { + private readonly pkgPath: string; + private readonly pkg: Buffer; + private readonly pkgJson: PackageJson; + + constructor() { + const pkgPath = findClosestPathContaining('package.json'); + if (!pkgPath) { + throw new Error('Cannot find a `package.json` in this project.'); + } + this.pkgPath = path.join(pkgPath, 'package.json'); + this.pkg = fs.readFileSync(this.pkgPath); + this.pkgJson = JSON.parse(this.pkg.toString()); + } + + /** + * Update the package.json + */ + public update(data: any) { + fs.writeFileSync(this.pkgPath, JSON.stringify({ + ...this.pkgJson, + ...data, + }, null, 2)); + } + + /** + * Restore the package.json to the original + */ + public restore() { + fs.writeFileSync(this.pkgPath, this.pkg); + } + + /** + * Extract versions for a list of modules + */ + public getVersions(modules: string[]): { [key: string]: string } { + const dependencies: { [key: string]: string } = {}; + + const allDependencies = { + ...this.pkgJson.dependencies ?? {}, + ...this.pkgJson.devDependencies ?? {}, + ...this.pkgJson.peerDependencies ?? {}, + }; + + for (const mod of modules) { + if (!allDependencies[mod]) { + throw new Error(`Cannot extract version for ${mod} in package.json`); + } + dependencies[mod] = allDependencies[mod]; + } + + return dependencies; + + } +} + +interface PackageJson { + dependencies?: { [key: string]: string }; + devDependencies?: { [key: string]: string }; + peerDependencies?: { [key: string]: string }; +} diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/util.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/util.ts index c54b0e3ed56c4..92d61e7f76e1a 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/util.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/util.ts @@ -53,7 +53,7 @@ export function nodeMajorVersion(): number { /** * Finds the closest path containg a path */ -function findClosestPathContaining(p: string): string | undefined { +export function findClosestPathContaining(p: string): string | undefined { for (const nodeModulesPath of module.paths) { if (fs.existsSync(path.join(path.dirname(nodeModulesPath), p))) { return path.dirname(nodeModulesPath); @@ -62,17 +62,3 @@ function findClosestPathContaining(p: string): string | undefined { return undefined; } - -/** - * Finds closest package.json path - */ -export function findPkgPath(): string | undefined { - return findClosestPathContaining('package.json'); -} - -/** - * Finds closest .git/ - */ -export function findGitPath(): string | undefined { - return findClosestPathContaining(`.git${path.sep}`); -} diff --git a/packages/@aws-cdk/aws-lambda-nodejs/package.json b/packages/@aws-cdk/aws-lambda-nodejs/package.json index 31995f757c849..48c612067a32b 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/package.json +++ b/packages/@aws-cdk/aws-lambda-nodejs/package.json @@ -61,7 +61,7 @@ "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", - "fs-extra": "^9.0.1", + "delay": "4.3.0", "pkglint": "0.0.0" }, "dependencies": { diff --git a/packages/@aws-cdk/aws-lambda-nodejs/parcel-bundler/Dockerfile b/packages/@aws-cdk/aws-lambda-nodejs/parcel-bundler/Dockerfile deleted file mode 100644 index 0a92746c6464c..0000000000000 --- a/packages/@aws-cdk/aws-lambda-nodejs/parcel-bundler/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -# runs the parcel-bundler npm package to package and install dependencies of nodejs lambda functions -ARG NODE_TAG -FROM node:${NODE_TAG} - -RUN yarn global add parcel-bundler@^1 - -# add the global node_modules folder to NODE_PATH so that plugins can find parcel-bundler -ENV NODE_PATH /usr/local/share/.config/yarn/global/node_modules - -CMD [ "parcel" ] diff --git a/packages/@aws-cdk/aws-lambda-nodejs/parcel/Dockerfile b/packages/@aws-cdk/aws-lambda-nodejs/parcel/Dockerfile new file mode 100644 index 0000000000000..0b91c0df6f600 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/parcel/Dockerfile @@ -0,0 +1,19 @@ +# The correct lambci build image based on the runtime of the function will be +# passed as build arg. The default allows to do `docker build .` when testing. +ARG IMAGE=lambci/lambda:build-nodejs12.x +FROM $IMAGE + +# Ensure npm cache is not owned by root because the image will not run +# as root and can potentially run `npm install`. +RUN mkdir /npm-cache && \ + chown -R 1000:1000 /npm-cache && \ + npm config --global set cache /npm-cache + +# Install yarn +RUN npm install --global yarn + +# Install parcel 2 (fix the version since it's still in beta) +ARG PARCEL_VERSION=2.0.0-beta.1 +RUN yarn global add parcel@$PARCEL_VERSION + +CMD [ "parcel" ] diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts index 9a58350934516..e7129cf5da0c8 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts @@ -1,11 +1,14 @@ -import { Code } from '@aws-cdk/aws-lambda'; +import { Code, Runtime } from '@aws-cdk/aws-lambda'; import { AssetHashType } from '@aws-cdk/core'; +import { version as delayVersion } from 'delay/package.json'; import * as fs from 'fs'; import { Bundling } from '../lib/bundling'; jest.mock('@aws-cdk/aws-lambda'); -const writeFileSyncMock = jest.spyOn(fs, 'writeFileSync'); +const writeFileSyncMock = jest.spyOn(fs, 'writeFileSync').mockReturnValue(); +const existsSyncOriginal = fs.existsSync; +const existsSyncMock = jest.spyOn(fs, 'existsSync'); beforeEach(() => { jest.clearAllMocks(); @@ -14,12 +17,10 @@ beforeEach(() => { test('Parcel bundling', () => { Bundling.parcel({ entry: '/project/folder/entry.ts', - global: 'handler', + runtime: Runtime.NODEJS_12_X, cacheDir: '/cache-dir', - nodeDockerTag: 'lts-alpine', - nodeVersion: '12', projectRoot: '/project', - environment: { + parcelEnvironment: { KEY: 'value', }, }); @@ -34,42 +35,113 @@ test('Parcel bundling', () => { volumes: [{ containerPath: '/parcel-cache', hostPath: '/cache-dir' }], workingDirectory: '/asset-input/folder', command: [ - 'parcel', 'build', '/asset-input/folder/entry.ts', - '--out-dir', '/asset-output', - '--out-file', 'index.js', - '--global', 'handler', - '--target', 'node', - '--bundle-node-modules', - '--log-level', '2', - '--no-minify', - '--no-source-maps', - '--cache-dir', '/parcel-cache', + 'bash', '-c', 'parcel build /asset-input/folder/entry.ts --target cdk-lambda --cache-dir /parcel-cache', ], }), }); // Correctly updates package.json - expect(writeFileSyncMock).toHaveBeenCalledWith( - expect.stringContaining('package.json'), - expect.stringContaining('"node": ">= 12"'), - ); + const call = writeFileSyncMock.mock.calls[0]; + expect(call[0]).toMatch('package.json'); + expect(JSON.parse(call[1])).toEqual(expect.objectContaining({ + 'cdk-lambda': '/asset-output/index.js', + 'targets': { + 'cdk-lambda': { + context: 'node', + includeNodeModules: { + 'aws-sdk': false, + }, + sourceMap: false, + minify: false, + engines: { + node: '>= 12', + }, + }, + }, + })); }); test('Parcel with Windows paths', () => { Bundling.parcel({ entry: 'C:\\my-project\\lib\\entry.ts', - global: 'handler', + runtime: Runtime.NODEJS_12_X, cacheDir: '/cache-dir', - nodeDockerTag: 'lts-alpine', - nodeVersion: '12', projectRoot: 'C:\\my-project', }); expect(Code.fromAsset).toHaveBeenCalledWith('C:\\my-project', expect.objectContaining({ bundling: expect.objectContaining({ command: expect.arrayContaining([ - 'parcel', 'build', expect.stringContaining('/lib/entry.ts'), + expect.stringContaining('/lib/entry.ts'), ]), }), })); }); + +test('Parcel bundling with externals and dependencies', () => { + Bundling.parcel({ + entry: '/project/folder/entry.ts', + runtime: Runtime.NODEJS_12_X, + projectRoot: '/project', + externalModules: ['abc'], + nodeModules: ['delay'], + }); + + // Correctly bundles with parcel + expect(Code.fromAsset).toHaveBeenCalledWith('/project', { + assetHashType: AssetHashType.BUNDLE, + bundling: expect.objectContaining({ + command: [ + 'bash', '-c', + 'parcel build /asset-input/folder/entry.ts --target cdk-lambda && mv /asset-input/.package.json /asset-output/package.json && cd /asset-output && npm install', + ], + }), + }); + + // Correctly updates package.json + const call = writeFileSyncMock.mock.calls[0]; + expect(call[0]).toMatch('package.json'); + expect(JSON.parse(call[1])).toEqual(expect.objectContaining({ + targets: expect.objectContaining({ + 'cdk-lambda': expect.objectContaining({ + includeNodeModules: { + delay: false, + abc: false, + }, + }), + }), + })); + + // Correctly writes dummy package.json + expect(writeFileSyncMock).toHaveBeenCalledWith('/project/.package.json', JSON.stringify({ + dependencies: { + delay: delayVersion, + }, + })); +}); + +test('Detects yarn.lock', () => { + existsSyncMock.mockImplementation((p: fs.PathLike) => { + if (/yarn.lock/.test(p.toString())) { + return true; + } + return existsSyncOriginal(p); + }); + + Bundling.parcel({ + entry: '/project/folder/entry.ts', + runtime: Runtime.NODEJS_12_X, + projectRoot: '/project', + nodeModules: ['delay'], + }); + + // Correctly bundles with parcel + expect(Code.fromAsset).toHaveBeenCalledWith('/project', { + assetHashType: AssetHashType.BUNDLE, + bundling: expect.objectContaining({ + command: expect.arrayContaining([ + expect.stringMatching(/yarn\.lock.+yarn install/), + ]), + }), + }); +}); diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts index 25f5589e9e388..200200fdeb1ce 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts @@ -29,7 +29,6 @@ test('NodejsFunction with .ts handler', () => { expect(Bundling.parcel).toHaveBeenCalledWith(expect.objectContaining({ entry: expect.stringContaining('function.test.handler1.ts'), // Automatically finds .ts handler file - global: 'handler', })); expect(stack).toHaveResource('AWS::Lambda::Function', { @@ -50,13 +49,13 @@ test('NodejsFunction with .js handler', () => { test('NodejsFunction with container env vars', () => { // WHEN new NodejsFunction(stack, 'handler1', { - containerEnvironment: { + parcelEnvironment: { KEY: 'VALUE', }, }); expect(Bundling.parcel).toHaveBeenCalledWith(expect.objectContaining({ - environment: { + parcelEnvironment: { KEY: 'VALUE', }, })); diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ-handlers/dependencies.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/integ-handlers/dependencies.ts new file mode 100644 index 0000000000000..31abbb04b4cfe --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ-handlers/dependencies.ts @@ -0,0 +1,10 @@ +// tslint:disable:no-console +import { S3 } from 'aws-sdk'; // eslint-disable-line import/no-extraneous-dependencies +import * as delay from 'delay'; + +const s3 = new S3(); + +export async function handler() { + console.log(s3); + await delay(5); +} diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ.dependencies.expected.json b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.dependencies.expected.json new file mode 100644 index 0000000000000..d67505788c16c --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.dependencies.expected.json @@ -0,0 +1,103 @@ +{ + "Resources": { + "externalServiceRole85A00A90": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "external068F12D1": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersf3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4S3BucketC81DD688" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersf3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4S3VersionKeyDA9CBF67" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersf3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4S3VersionKeyDA9CBF67" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "externalServiceRole85A00A90", + "Arn" + ] + }, + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "externalServiceRole85A00A90" + ] + } + }, + "Parameters": { + "AssetParametersf3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4S3BucketC81DD688": { + "Type": "String", + "Description": "S3 bucket for asset \"f3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4\"" + }, + "AssetParametersf3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4S3VersionKeyDA9CBF67": { + "Type": "String", + "Description": "S3 key for asset version \"f3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4\"" + }, + "AssetParametersf3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4ArtifactHash0E6684C0": { + "Type": "String", + "Description": "Artifact hash for asset \"f3a8dacfae15c18a4397faeaae668d7170beb89acf2fd97e47f260f73587bde4\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ.dependencies.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.dependencies.ts new file mode 100644 index 0000000000000..acd55e7f6e8c7 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.dependencies.ts @@ -0,0 +1,25 @@ +import { Runtime } from '@aws-cdk/aws-lambda'; +import { App, Construct, Stack, StackProps } from '@aws-cdk/core'; +import * as path from 'path'; +import * as lambda from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + // This function uses aws-sdk but it will not be included + new lambda.NodejsFunction(this, 'external', { + entry: path.join(__dirname, 'integ-handlers/dependencies.ts'), + runtime: Runtime.NODEJS_12_X, + minify: true, + // Will be installed, not bundled + // (delay is a zero dependency package and its version is fixed + // in the package.json to ensure a stable hash for this integ test) + nodeModules: ['delay'], + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-lambda-nodejs-dependencies'); +app.synth(); diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ.function.expected.json b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.function.expected.json index 6633fb1402bf7..b88a7e8503886 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/test/integ.function.expected.json +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.function.expected.json @@ -36,7 +36,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9S3Bucket7F316AC2" + "Ref": "AssetParameters9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88S3BucketD344F833" }, "S3Key": { "Fn::Join": [ @@ -49,7 +49,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9S3VersionKeyEEDC3772" + "Ref": "AssetParameters9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88S3VersionKeyEB3332E0" } ] } @@ -62,7 +62,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9S3VersionKeyEEDC3772" + "Ref": "AssetParameters9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88S3VersionKeyEB3332E0" } ] } @@ -121,7 +121,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990S3Bucket88C76A86" + "Ref": "AssetParameters0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0S3Bucket3B0DF548" }, "S3Key": { "Fn::Join": [ @@ -134,7 +134,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990S3VersionKey1E5E4562" + "Ref": "AssetParameters0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0S3VersionKey1D84CC0E" } ] } @@ -147,7 +147,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990S3VersionKey1E5E4562" + "Ref": "AssetParameters0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0S3VersionKey1D84CC0E" } ] } @@ -172,29 +172,29 @@ } }, "Parameters": { - "AssetParameters20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9S3Bucket7F316AC2": { + "AssetParameters9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88S3BucketD344F833": { "Type": "String", - "Description": "S3 bucket for asset \"20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9\"" + "Description": "S3 bucket for asset \"9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88\"" }, - "AssetParameters20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9S3VersionKeyEEDC3772": { + "AssetParameters9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88S3VersionKeyEB3332E0": { "Type": "String", - "Description": "S3 key for asset version \"20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9\"" + "Description": "S3 key for asset version \"9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88\"" }, - "AssetParameters20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9ArtifactHash8387A82E": { + "AssetParameters9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88ArtifactHash079EA103": { "Type": "String", - "Description": "Artifact hash for asset \"20afe351e391b62b260d621490f511b5a25bc8bceb71127d3784c5d8e62aa5e9\"" + "Description": "Artifact hash for asset \"9003cb217f859844be0ac9b0b22c7eb387ac397607197d29b624cbf8dc872a88\"" }, - "AssetParameters39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990S3Bucket88C76A86": { + "AssetParameters0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0S3Bucket3B0DF548": { "Type": "String", - "Description": "S3 bucket for asset \"39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990\"" + "Description": "S3 bucket for asset \"0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0\"" }, - "AssetParameters39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990S3VersionKey1E5E4562": { + "AssetParameters0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0S3VersionKey1D84CC0E": { "Type": "String", - "Description": "S3 key for asset version \"39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990\"" + "Description": "S3 key for asset version \"0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0\"" }, - "AssetParameters39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990ArtifactHash37B9CB08": { + "AssetParameters0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0ArtifactHash7545DAEB": { "Type": "String", - "Description": "Artifact hash for asset \"39c8a0dc659dd89e6876d7d8447b17176396320962e88fb69ea7d7feb9a23990\"" + "Description": "Artifact hash for asset \"0a35a944532d281b38e1ee670488bc40e0c813140eb0a41371db4c5a32202be0\"" } } -} +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/init-templates/app/javascript/.template.gitignore b/packages/aws-cdk/lib/init-templates/app/javascript/.template.gitignore index 599776985b7ff..a2da1bef05b07 100644 --- a/packages/aws-cdk/lib/init-templates/app/javascript/.template.gitignore +++ b/packages/aws-cdk/lib/init-templates/app/javascript/.template.gitignore @@ -4,6 +4,5 @@ node_modules .cdk.staging cdk.out -# Parcel build directories -.cache -.build +# Parcel default cache directory +.parcel-cache diff --git a/packages/aws-cdk/lib/init-templates/app/typescript/.template.gitignore b/packages/aws-cdk/lib/init-templates/app/typescript/.template.gitignore index 96eba04a818de..305c7fbcc4d89 100644 --- a/packages/aws-cdk/lib/init-templates/app/typescript/.template.gitignore +++ b/packages/aws-cdk/lib/init-templates/app/typescript/.template.gitignore @@ -7,6 +7,5 @@ node_modules .cdk.staging cdk.out -# Parcel build directories -.cache -.build +# Parcel default cache directory +.parcel-cache diff --git a/packages/aws-cdk/lib/init-templates/lib/typescript/.template.gitignore b/packages/aws-cdk/lib/init-templates/lib/typescript/.template.gitignore index 96eba04a818de..305c7fbcc4d89 100644 --- a/packages/aws-cdk/lib/init-templates/lib/typescript/.template.gitignore +++ b/packages/aws-cdk/lib/init-templates/lib/typescript/.template.gitignore @@ -7,6 +7,5 @@ node_modules .cdk.staging cdk.out -# Parcel build directories -.cache -.build +# Parcel default cache directory +.parcel-cache diff --git a/packages/aws-cdk/lib/init-templates/sample-app/javascript/.template.gitignore b/packages/aws-cdk/lib/init-templates/sample-app/javascript/.template.gitignore index 599776985b7ff..a2da1bef05b07 100644 --- a/packages/aws-cdk/lib/init-templates/sample-app/javascript/.template.gitignore +++ b/packages/aws-cdk/lib/init-templates/sample-app/javascript/.template.gitignore @@ -4,6 +4,5 @@ node_modules .cdk.staging cdk.out -# Parcel build directories -.cache -.build +# Parcel default cache directory +.parcel-cache diff --git a/packages/aws-cdk/lib/init-templates/sample-app/typescript/.template.gitignore b/packages/aws-cdk/lib/init-templates/sample-app/typescript/.template.gitignore index 96eba04a818de..305c7fbcc4d89 100644 --- a/packages/aws-cdk/lib/init-templates/sample-app/typescript/.template.gitignore +++ b/packages/aws-cdk/lib/init-templates/sample-app/typescript/.template.gitignore @@ -7,6 +7,5 @@ node_modules .cdk.staging cdk.out -# Parcel build directories -.cache -.build +# Parcel default cache directory +.parcel-cache diff --git a/yarn.lock b/yarn.lock index 6abcc81885b97..3ad380188332a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3528,6 +3528,11 @@ degenerator@^1.0.4: escodegen "1.x.x" esprima "3.x.x" +delay@4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-4.3.0.tgz#efeebfb8f545579cb396b3a722443ec96d14c50e" + integrity sha512-Lwaf3zVFDMBop1yDuFZ19F9WyGcZcGacsbdlZtWjQmM50tOcMntm1njF/Nb/Vjij3KaSvCF+sEYGKrrjObu2NA== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"