Skip to content

Commit

Permalink
feat(lambda-nodejs): ES modules (aws#18346)
Browse files Browse the repository at this point in the history
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 aws#13274

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
jogold authored and TikiTDO committed Feb 21, 2022
1 parent 55e6f52 commit 17a26ca
Show file tree
Hide file tree
Showing 10 changed files with 206 additions and 9 deletions.
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-lambda-nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
```
Expand Down Expand Up @@ -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)
},
});
```
Expand Down
11 changes: 9 additions & 2 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`],
Expand Down
10 changes: 8 additions & 2 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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}`);
}

/**
Expand Down
24 changes: 24 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

/**
Expand Down
18 changes: 15 additions & 3 deletions packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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',
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Dummy for test purposes
13 changes: 12 additions & 1 deletion packages/@aws-cdk/aws-lambda-nodejs/test/function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'));
}
108 changes: 108 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.expected.json
Original file line number Diff line number Diff line change
@@ -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\""
}
}
}
21 changes: 21 additions & 0 deletions packages/@aws-cdk/aws-lambda-nodejs/test/integ.esm.ts
Original file line number Diff line number Diff line change
@@ -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();

0 comments on commit 17a26ca

Please sign in to comment.