Skip to content

Commit e23b63f

Browse files
authored
feat(lambda-nodejs): ES modules (#18346)
Add a `format` option to choose the output format (CommonJS or ECMAScript module). Generate a `index.mjs` file when the ECMAScript module output format is chosen so that AWS Lambda treats it correctly. See https://aws.amazon.com/about-aws/whats-new/2022/01/aws-lambda-es-modules-top-level-await-node-js-14/ See https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ Closes #13274 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent ee11c96 commit e23b63f

File tree

10 files changed

+206
-9
lines changed

10 files changed

+206
-9
lines changed

Diff for: packages/@aws-cdk/aws-lambda-nodejs/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Alternatively, an entry file and handler can be specified:
4848

4949
```ts
5050
new lambda.NodejsFunction(this, 'MyFunction', {
51-
entry: '/path/to/my/file.ts', // accepts .js, .jsx, .ts and .tsx files
51+
entry: '/path/to/my/file.ts', // accepts .js, .jsx, .ts, .tsx and .mjs files
5252
handler: 'myExportedFunc', // defaults to 'handler'
5353
});
5454
```
@@ -191,6 +191,7 @@ new lambda.NodejsFunction(this, 'my-handler', {
191191
banner: '/* comments */', // requires esbuild >= 0.9.0, defaults to none
192192
footer: '/* comments */', // requires esbuild >= 0.9.0, defaults to none
193193
charset: lambda.Charset.UTF8, // do not escape non-ASCII characters, defaults to Charset.ASCII
194+
format: lambda.OutputFormat.ESM, // ECMAScript module output format, defaults to OutputFormat.CJS (OutputFormat.ESM requires Node.js 14.x)
194195
},
195196
});
196197
```

Diff for: packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Architecture, AssetCode, Code, Runtime } from '@aws-cdk/aws-lambda';
44
import * as cdk from '@aws-cdk/core';
55
import { PackageInstallation } from './package-installation';
66
import { PackageManager } from './package-manager';
7-
import { BundlingOptions, SourceMapMode } from './types';
7+
import { BundlingOptions, OutputFormat, SourceMapMode } from './types';
88
import { exec, extractDependencies, findUp } from './util';
99

1010
const ESBUILD_MAJOR_VERSION = '0';
@@ -112,6 +112,11 @@ export class Bundling implements cdk.BundlingOptions {
112112
throw new Error('preCompilation can only be used with typescript files');
113113
}
114114

115+
if (props.format === OutputFormat.ESM
116+
&& (props.runtime === Runtime.NODEJS_10_X || props.runtime === Runtime.NODEJS_12_X)) {
117+
throw new Error(`ECMAScript module output format is not supported by the ${props.runtime.name} runtime`);
118+
}
119+
115120
this.externals = [
116121
...props.externalModules ?? ['aws-sdk'], // Mark aws-sdk as external by default (available in the runtime)
117122
...props.nodeModules ?? [], // Mark the modules that we are going to install as externals also
@@ -185,12 +190,14 @@ export class Bundling implements cdk.BundlingOptions {
185190
const sourceMapValue = sourceMapMode === SourceMapMode.DEFAULT ? '' : `=${this.props.sourceMapMode}`;
186191
const sourcesContent = this.props.sourcesContent ?? true;
187192

193+
const outFile = this.props.format === OutputFormat.ESM ? 'index.mjs' : 'index.js';
188194
const esbuildCommand: string[] = [
189195
options.esbuildRunner,
190196
'--bundle', `"${pathJoin(options.inputDir, relativeEntryPath)}"`,
191197
`--target=${this.props.target ?? toTarget(this.props.runtime)}`,
192198
'--platform=node',
193-
`--outfile="${pathJoin(options.outputDir, 'index.js')}"`,
199+
...this.props.format ? [`--format=${this.props.format}`] : [],
200+
`--outfile="${pathJoin(options.outputDir, outFile)}"`,
194201
...this.props.minify ? ['--minify'] : [],
195202
...sourceMapEnabled ? [`--sourcemap${sourceMapValue}`] : [],
196203
...sourcesContent ? [] : [`--sources-content=${sourcesContent}`],

Diff for: packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,11 @@ function findLockFile(depsLockFilePath?: string): string {
158158
* 1. Given entry file
159159
* 2. A .ts file named as the defining file with id as suffix (defining-file.id.ts)
160160
* 3. A .js file name as the defining file with id as suffix (defining-file.id.js)
161+
* 4. A .mjs file name as the defining file with id as suffix (defining-file.id.mjs)
161162
*/
162163
function findEntry(id: string, entry?: string): string {
163164
if (entry) {
164-
if (!/\.(jsx?|tsx?)$/.test(entry)) {
165+
if (!/\.(jsx?|tsx?|mjs)$/.test(entry)) {
165166
throw new Error('Only JavaScript or TypeScript entry files are supported.');
166167
}
167168
if (!fs.existsSync(entry)) {
@@ -183,7 +184,12 @@ function findEntry(id: string, entry?: string): string {
183184
return jsHandlerFile;
184185
}
185186

186-
throw new Error(`Cannot find handler file ${tsHandlerFile} or ${jsHandlerFile}`);
187+
const mjsHandlerFile = definingFile.replace(new RegExp(`${extname}$`), `.${id}.mjs`);
188+
if (fs.existsSync(mjsHandlerFile)) {
189+
return mjsHandlerFile;
190+
}
191+
192+
throw new Error(`Cannot find handler file ${tsHandlerFile}, ${jsHandlerFile} or ${mjsHandlerFile}`);
187193
}
188194

189195
/**

Diff for: packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts

+24
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,30 @@ export interface BundlingOptions {
263263
* @default - asset hash is calculated based on the bundled output
264264
*/
265265
readonly assetHash?: string;
266+
267+
/**
268+
* Output format for the generated JavaScript files
269+
*
270+
* @default OutputFormat.CJS
271+
*/
272+
readonly format?: OutputFormat;
273+
}
274+
275+
/**
276+
* Output format for the generated JavaScript files
277+
*/
278+
export enum OutputFormat {
279+
/**
280+
* CommonJS
281+
*/
282+
CJS = 'cjs',
283+
284+
/**
285+
* ECMAScript module
286+
*
287+
* Requires a running environment that supports `import` and `export` syntax.
288+
*/
289+
ESM = 'esm'
266290
}
267291

268292
/**

Diff for: packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { AssetHashType, DockerImage } from '@aws-cdk/core';
66
import { version as delayVersion } from 'delay/package.json';
77
import { Bundling } from '../lib/bundling';
88
import { PackageInstallation } from '../lib/package-installation';
9-
import { Charset, LogLevel, SourceMapMode } from '../lib/types';
9+
import { Charset, LogLevel, OutputFormat, SourceMapMode } from '../lib/types';
1010
import * as util from '../lib/util';
1111

1212

@@ -184,7 +184,7 @@ test('esbuild bundling with esbuild options', () => {
184184
entry,
185185
projectRoot,
186186
depsLockFilePath,
187-
runtime: Runtime.NODEJS_12_X,
187+
runtime: Runtime.NODEJS_14_X,
188188
architecture: Architecture.X86_64,
189189
minify: true,
190190
sourceMap: true,
@@ -207,6 +207,7 @@ test('esbuild bundling with esbuild options', () => {
207207
'process.env.NUMBER': '7777',
208208
'process.env.STRING': JSON.stringify('this is a "test"'),
209209
},
210+
format: OutputFormat.ESM,
210211
});
211212

212213
// Correctly bundles with esbuild
@@ -218,7 +219,7 @@ test('esbuild bundling with esbuild options', () => {
218219
'bash', '-c',
219220
[
220221
'esbuild --bundle "/asset-input/lib/handler.ts"',
221-
'--target=es2020 --platform=node --outfile="/asset-output/index.js"',
222+
'--target=es2020 --platform=node --format=esm --outfile="/asset-output/index.mjs"',
222223
'--minify --sourcemap --sources-content=false --external:aws-sdk --loader:.png=dataurl',
223224
defineInstructions,
224225
'--log-level=silent --keep-names --tsconfig=/asset-input/lib/custom-tsconfig.ts',
@@ -234,6 +235,17 @@ test('esbuild bundling with esbuild options', () => {
234235
expect(bundleProcess.stdout.toString()).toMatchSnapshot();
235236
});
236237

238+
test('throws with ESM and NODEJS_12_X', () => {
239+
expect(() => Bundling.bundle({
240+
entry,
241+
projectRoot,
242+
depsLockFilePath,
243+
runtime: Runtime.NODEJS_12_X,
244+
architecture: Architecture.X86_64,
245+
format: OutputFormat.ESM,
246+
})).toThrow(/ECMAScript module output format is not supported by the nodejs12.x runtime/);
247+
});
248+
237249
test('esbuild bundling source map default', () => {
238250
Bundling.bundle({
239251
entry,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Dummy for test purposes

Diff for: packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ test('NodejsFunction with .js handler', () => {
5656
}));
5757
});
5858

59+
test('NodejsFunction with .mjs handler', () => {
60+
// WHEN
61+
new NodejsFunction(stack, 'handler3');
62+
63+
64+
// THEN
65+
expect(Bundling.bundle).toHaveBeenCalledWith(expect.objectContaining({
66+
entry: expect.stringContaining('function.test.handler3.mjs'), // Automatically finds .mjs handler file
67+
}));
68+
});
69+
5970
test('NodejsFunction with container env vars', () => {
6071
// WHEN
6172
new NodejsFunction(stack, 'handler1', {
@@ -98,7 +109,7 @@ test('throws when entry does not exist', () => {
98109
});
99110

100111
test('throws when entry cannot be automatically found', () => {
101-
expect(() => new NodejsFunction(stack, 'Fn')).toThrow(/Cannot find handler file .*function.test.Fn.ts or .*function.test.Fn.js/);
112+
expect(() => new NodejsFunction(stack, 'Fn')).toThrow(/Cannot find handler file .*function.test.Fn.ts, .*function.test.Fn.js or .*function.test.Fn.mjs/);
102113
});
103114

104115
test('throws with the wrong runtime family', () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/* eslint-disable no-console */
2+
import * as crypto from 'crypto';
3+
4+
export async function handler() {
5+
console.log(crypto.createHash('sha512').update('cdk').digest('hex'));
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
{
2+
"Resources": {
3+
"esmServiceRole84AC2522": {
4+
"Type": "AWS::IAM::Role",
5+
"Properties": {
6+
"AssumeRolePolicyDocument": {
7+
"Statement": [
8+
{
9+
"Action": "sts:AssumeRole",
10+
"Effect": "Allow",
11+
"Principal": {
12+
"Service": "lambda.amazonaws.com"
13+
}
14+
}
15+
],
16+
"Version": "2012-10-17"
17+
},
18+
"ManagedPolicyArns": [
19+
{
20+
"Fn::Join": [
21+
"",
22+
[
23+
"arn:",
24+
{
25+
"Ref": "AWS::Partition"
26+
},
27+
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
28+
]
29+
]
30+
}
31+
]
32+
}
33+
},
34+
"esm9B397D27": {
35+
"Type": "AWS::Lambda::Function",
36+
"Properties": {
37+
"Code": {
38+
"S3Bucket": {
39+
"Ref": "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3BucketD8FC0ACA"
40+
},
41+
"S3Key": {
42+
"Fn::Join": [
43+
"",
44+
[
45+
{
46+
"Fn::Select": [
47+
0,
48+
{
49+
"Fn::Split": [
50+
"||",
51+
{
52+
"Ref": "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3VersionKeyF7C65CF0"
53+
}
54+
]
55+
}
56+
]
57+
},
58+
{
59+
"Fn::Select": [
60+
1,
61+
{
62+
"Fn::Split": [
63+
"||",
64+
{
65+
"Ref": "AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3VersionKeyF7C65CF0"
66+
}
67+
]
68+
}
69+
]
70+
}
71+
]
72+
]
73+
}
74+
},
75+
"Role": {
76+
"Fn::GetAtt": [
77+
"esmServiceRole84AC2522",
78+
"Arn"
79+
]
80+
},
81+
"Environment": {
82+
"Variables": {
83+
"AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1"
84+
}
85+
},
86+
"Handler": "index.handler",
87+
"Runtime": "nodejs14.x"
88+
},
89+
"DependsOn": [
90+
"esmServiceRole84AC2522"
91+
]
92+
}
93+
},
94+
"Parameters": {
95+
"AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3BucketD8FC0ACA": {
96+
"Type": "String",
97+
"Description": "S3 bucket for asset \"a111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616\""
98+
},
99+
"AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616S3VersionKeyF7C65CF0": {
100+
"Type": "String",
101+
"Description": "S3 key for asset version \"a111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616\""
102+
},
103+
"AssetParametersa111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616ArtifactHashDDFE4A88": {
104+
"Type": "String",
105+
"Description": "Artifact hash for asset \"a111e7aee76f0a755b83f3d35098efc1659ba3915bd52dc401cb3a972573d616\""
106+
}
107+
}
108+
}
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as path from 'path';
2+
import { App, Stack, StackProps } from '@aws-cdk/core';
3+
import { Construct } from 'constructs';
4+
import * as lambda from '../lib';
5+
6+
class TestStack extends Stack {
7+
constructor(scope: Construct, id: string, props?: StackProps) {
8+
super(scope, id, props);
9+
10+
new lambda.NodejsFunction(this, 'esm', {
11+
entry: path.join(__dirname, 'integ-handlers/esm.ts'),
12+
bundling: {
13+
format: lambda.OutputFormat.ESM,
14+
},
15+
});
16+
}
17+
}
18+
19+
const app = new App();
20+
new TestStack(app, 'cdk-integ-lambda-nodejs-esm');
21+
app.synth();

0 commit comments

Comments
 (0)