diff --git a/packages/@aws-cdk/aws-lambda-nodejs/README.md b/packages/@aws-cdk/aws-lambda-nodejs/README.md index 81fb45b3b1f4a..21f0c02004903 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/README.md +++ b/packages/@aws-cdk/aws-lambda-nodejs/README.md @@ -48,7 +48,7 @@ Alternatively, an entry file and handler can be specified: ```ts new lambda.NodejsFunction(this, 'MyFunction', { - entry: '/path/to/my/file.ts', // accepts .js, .jsx, .ts and .tsx files + entry: '/path/to/my/file.ts', // accepts .js, .jsx, .ts, .tsx and .mjs files handler: 'myExportedFunc', // defaults to 'handler' }); ``` @@ -191,6 +191,7 @@ new lambda.NodejsFunction(this, 'my-handler', { banner: '/* comments */', // requires esbuild >= 0.9.0, defaults to none footer: '/* comments */', // requires esbuild >= 0.9.0, defaults to none charset: lambda.Charset.UTF8, // do not escape non-ASCII characters, defaults to Charset.ASCII + format: lambda.OutputFormat.ESM, // ECMAScript module output format, defaults to OutputFormat.CJS (OutputFormat.ESM requires Node.js 14.x) }, }); ``` diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts index 7994859706379..62b5d56a0bc1d 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts @@ -4,7 +4,7 @@ import { Architecture, AssetCode, Code, Runtime } from '@aws-cdk/aws-lambda'; import * as cdk from '@aws-cdk/core'; import { PackageInstallation } from './package-installation'; import { PackageManager } from './package-manager'; -import { BundlingOptions, SourceMapMode } from './types'; +import { BundlingOptions, OutputFormat, SourceMapMode } from './types'; import { exec, extractDependencies, findUp } from './util'; const ESBUILD_MAJOR_VERSION = '0'; @@ -112,6 +112,11 @@ export class Bundling implements cdk.BundlingOptions { throw new Error('preCompilation can only be used with typescript files'); } + if (props.format === OutputFormat.ESM + && (props.runtime === Runtime.NODEJS_10_X || props.runtime === Runtime.NODEJS_12_X)) { + throw new Error(`ECMAScript module output format is not supported by the ${props.runtime.name} runtime`); + } + this.externals = [ ...props.externalModules ?? ['aws-sdk'], // Mark aws-sdk as external by default (available in the runtime) ...props.nodeModules ?? [], // Mark the modules that we are going to install as externals also @@ -185,12 +190,14 @@ export class Bundling implements cdk.BundlingOptions { const sourceMapValue = sourceMapMode === SourceMapMode.DEFAULT ? '' : `=${this.props.sourceMapMode}`; const sourcesContent = this.props.sourcesContent ?? true; + const outFile = this.props.format === OutputFormat.ESM ? 'index.mjs' : 'index.js'; const esbuildCommand: string[] = [ options.esbuildRunner, '--bundle', `"${pathJoin(options.inputDir, relativeEntryPath)}"`, `--target=${this.props.target ?? toTarget(this.props.runtime)}`, '--platform=node', - `--outfile="${pathJoin(options.outputDir, 'index.js')}"`, + ...this.props.format ? [`--format=${this.props.format}`] : [], + `--outfile="${pathJoin(options.outputDir, outFile)}"`, ...this.props.minify ? ['--minify'] : [], ...sourceMapEnabled ? [`--sourcemap${sourceMapValue}`] : [], ...sourcesContent ? [] : [`--sources-content=${sourcesContent}`], diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts index 171df8ccbf385..83f135a12a97b 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts @@ -158,10 +158,11 @@ function findLockFile(depsLockFilePath?: string): string { * 1. Given entry file * 2. A .ts file named as the defining file with id as suffix (defining-file.id.ts) * 3. A .js file name as the defining file with id as suffix (defining-file.id.js) + * 4. A .mjs file name as the defining file with id as suffix (defining-file.id.mjs) */ function findEntry(id: string, entry?: string): string { if (entry) { - if (!/\.(jsx?|tsx?)$/.test(entry)) { + if (!/\.(jsx?|tsx?|mjs)$/.test(entry)) { throw new Error('Only JavaScript or TypeScript entry files are supported.'); } if (!fs.existsSync(entry)) { @@ -183,7 +184,12 @@ function findEntry(id: string, entry?: string): string { return jsHandlerFile; } - throw new Error(`Cannot find handler file ${tsHandlerFile} or ${jsHandlerFile}`); + const mjsHandlerFile = definingFile.replace(new RegExp(`${extname}$`), `.${id}.mjs`); + if (fs.existsSync(mjsHandlerFile)) { + return mjsHandlerFile; + } + + throw new Error(`Cannot find handler file ${tsHandlerFile}, ${jsHandlerFile} or ${mjsHandlerFile}`); } /** diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts index e16e9db8120b6..b74ac1df3b74a 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts @@ -263,6 +263,30 @@ export interface BundlingOptions { * @default - asset hash is calculated based on the bundled output */ readonly assetHash?: string; + + /** + * Output format for the generated JavaScript files + * + * @default OutputFormat.CJS + */ + readonly format?: OutputFormat; +} + +/** + * Output format for the generated JavaScript files + */ +export enum OutputFormat { + /** + * CommonJS + */ + CJS = 'cjs', + + /** + * ECMAScript module + * + * Requires a running environment that supports `import` and `export` syntax. + */ + ESM = 'esm' } /** 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 2f8b823dcce45..5941ce880a987 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts @@ -6,7 +6,7 @@ import { AssetHashType, DockerImage } from '@aws-cdk/core'; import { version as delayVersion } from 'delay/package.json'; import { Bundling } from '../lib/bundling'; import { PackageInstallation } from '../lib/package-installation'; -import { Charset, LogLevel, SourceMapMode } from '../lib/types'; +import { Charset, LogLevel, OutputFormat, SourceMapMode } from '../lib/types'; import * as util from '../lib/util'; @@ -184,7 +184,7 @@ test('esbuild bundling with esbuild options', () => { entry, projectRoot, depsLockFilePath, - runtime: Runtime.NODEJS_12_X, + runtime: Runtime.NODEJS_14_X, architecture: Architecture.X86_64, minify: true, sourceMap: true, @@ -207,6 +207,7 @@ test('esbuild bundling with esbuild options', () => { 'process.env.NUMBER': '7777', 'process.env.STRING': JSON.stringify('this is a "test"'), }, + format: OutputFormat.ESM, }); // Correctly bundles with esbuild @@ -218,7 +219,7 @@ test('esbuild bundling with esbuild options', () => { 'bash', '-c', [ 'esbuild --bundle "/asset-input/lib/handler.ts"', - '--target=es2020 --platform=node --outfile="/asset-output/index.js"', + '--target=es2020 --platform=node --format=esm --outfile="/asset-output/index.mjs"', '--minify --sourcemap --sources-content=false --external:aws-sdk --loader:.png=dataurl', defineInstructions, '--log-level=silent --keep-names --tsconfig=/asset-input/lib/custom-tsconfig.ts', @@ -234,6 +235,17 @@ test('esbuild bundling with esbuild options', () => { expect(bundleProcess.stdout.toString()).toMatchSnapshot(); }); +test('throws with ESM and NODEJS_12_X', () => { + expect(() => Bundling.bundle({ + entry, + projectRoot, + depsLockFilePath, + runtime: Runtime.NODEJS_12_X, + architecture: Architecture.X86_64, + format: OutputFormat.ESM, + })).toThrow(/ECMAScript module output format is not supported by the nodejs12.x runtime/); +}); + test('esbuild bundling source map default', () => { Bundling.bundle({ entry, diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.handler3.mjs b/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.handler3.mjs new file mode 100644 index 0000000000000..33af638be9b99 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.handler3.mjs @@ -0,0 +1 @@ +// Dummy for test purposes 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 83ea2131c043a..01b90d89dd6dd 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts @@ -56,6 +56,17 @@ test('NodejsFunction with .js handler', () => { })); }); +test('NodejsFunction with .mjs handler', () => { + // WHEN + new NodejsFunction(stack, 'handler3'); + + + // THEN + expect(Bundling.bundle).toHaveBeenCalledWith(expect.objectContaining({ + entry: expect.stringContaining('function.test.handler3.mjs'), // Automatically finds .mjs handler file + })); +}); + test('NodejsFunction with container env vars', () => { // WHEN new NodejsFunction(stack, 'handler1', { @@ -98,7 +109,7 @@ test('throws when entry does not exist', () => { }); test('throws when entry cannot be automatically found', () => { - expect(() => new NodejsFunction(stack, 'Fn')).toThrow(/Cannot find handler file .*function.test.Fn.ts or .*function.test.Fn.js/); + expect(() => new NodejsFunction(stack, 'Fn')).toThrow(/Cannot find handler file .*function.test.Fn.ts, .*function.test.Fn.js or .*function.test.Fn.mjs/); }); test('throws with the wrong runtime family', () => { diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ-handlers/esm.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/integ-handlers/esm.ts new file mode 100644 index 0000000000000..29f247f3942ab --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ-handlers/esm.ts @@ -0,0 +1,6 @@ +/* eslint-disable no-console */ +import * as crypto from 'crypto'; + +export async function handler() { + console.log(crypto.createHash('sha512').update('cdk').digest('hex')); +} diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.expected.json b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.expected.json new file mode 100644 index 0000000000000..8e6b8cabf01c6 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.expected.json @@ -0,0 +1,108 @@ +{ + "Resources": { + "esmServiceRole84AC2522": { + "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" + ] + ] + } + ] + } + }, + "esm9B397D27": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3BucketD8FC0ACA" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3VersionKeyF7C65CF0" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3VersionKeyF7C65CF0" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "esmServiceRole84AC2522", + "Arn" + ] + }, + "Environment": { + "Variables": { + "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1" + } + }, + "Handler": "index.handler", + "Runtime": "nodejs14.x" + }, + "DependsOn": [ + "esmServiceRole84AC2522" + ] + } + }, + "Parameters": { + "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3BucketD8FC0ACA": { + "Type": "String", + "Description": "S3 bucket for asset \"a111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616\"" + }, + "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3VersionKeyF7C65CF0": { + "Type": "String", + "Description": "S3 key for asset version \"a111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616\"" + }, + "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616ArtifactHashDDFE4A88": { + "Type": "String", + "Description": "Artifact hash for asset \"a111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.ts new file mode 100644 index 0000000000000..acf0ac363489b --- /dev/null +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.ts @@ -0,0 +1,21 @@ +import * as path from 'path'; +import { App, Stack, StackProps } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import * as lambda from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + new lambda.NodejsFunction(this, 'esm', { + entry: path.join(__dirname, 'integ-handlers/esm.ts'), + bundling: { + format: lambda.OutputFormat.ESM, + }, + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-lambda-nodejs-esm'); +app.synth();