Published on

Bundling typescript for lambda with CDK

AWS CDK is a great tool to manage infrastructure as code.

If you're building lambda functions with AWS CDK, you may have come across the problem of bundling or building your code into artifacts so that the lambda runtime can run them.

There's several ways to do this but the most straightforward way is to build it outside of CDK and use the build outputs directly as an asset.

new Function(this, 'my-function', {
  runtime: Runtime.NODEJS_18_X,
  handler: 'index.handler',
  code: Code.fromAsset(`${path.resolve(__dirname)}/handler`),
})

This means that there's a build step involved somewhere before npx cdk deploy as the artifacts need to be built before. However, this could actually be handled by CDK. You could bring in any build tool to perform all the necessary steps.

Here's an example typescript function.

const timezone = process.env.TZ;

export const handler = async (event: CloudFrontRequestEvent): Promise<CloudFrontRequest> => {
  const { request } = event.Records[0].cf;

  console.log(timezone);

  return request;
}

It's very handy to write the function in typescript because we get type checking, however to run this on a lambda runtime we will first have to compile this to javascript. You may also want to minify and bundle files if your function has a dependency.

So instead of just directly passing the files as assets, we'll use a build tool like esbuild to compile and bundle typescript to javascript.

AWS provides the ILocalBundling interface which you can leverage to build using your tool of choice. I'll provide an example with esbuild but you can use bring a different tool like webpack.

Esbuild is available as an npm package, which means you can use it in scripts. This is very handy because this lets us build our own bundler class in typescript!

To get started, you'll first need to add esbuild as a dependency to your cdk project with npm install --save-dev esbuild

You can then create a custom class that implements the ILocalBundling interface, importing esbuild as a package.

// esbuild.ts
import { BundlingOptions, ILocalBundling } from 'aws-cdk-lib'
import { buildSync, BuildOptions } from 'esbuild'

export class Esbuild implements ILocalBundling {
  private readonly options: BuildOptions

  constructor(options: BuildOptions) {
    this.options = options

    // Override with default options
    Object.assign(this.options, {
      logLevel: 'info',
      sourcemap: false,
      bundle: true,
      minify: true,
      platform: 'node',
      // Do not minify identifiers, otherwise the exported function name gets minified failing to start the function
      minifyIdentifiers: false,
      minifyWhitespace: true,
      minifySyntax: true,
    })
  }

  tryBundle(outputDir: string, options: BundlingOptions): boolean {
    try {
      this.options.outdir = outputDir
      buildSync(this.options)
    } catch (error) {
      console.log(error)
      return true
    }

    return true
  }
}

Once you have your bundler class, you can import and create a new instance passing in all options for the bundler. Here is the modified snippet from the original that only defined an asset.

new Function(this, `${id}-index-fn`, {
  code: Code.fromAsset(join(__dirname, "handlers"), {
    assetHashType: AssetHashType.OUTPUT,
    bundling: {
      command,
      image: DockerImage.fromRegistry("busybox"),
      local: new Esbuild({
        entryPoints: [join(__dirname, "handlers/index.ts")],
        define: {
          "process.env.TZ": 'Australia/Adelaide',
        },
      }),
    },
  }),
  runtime: Runtime.NODEJS_18_X,
  handler: "index.handler",
});

The DockerImage.fromRegistry("busybox") is necessary as it is a required property, however you can pass in any random value as it will never be used. This is because we always return true from the tryBundle function.

I've also used esbuild's define property to replace global identifier with a constant expression. This is a good way to bring environment variables into your lambda function. This is especially useful for Lambda@Edge where there's a limitation that you can't define lambda environment variables. 1

The nice thing about this is, it doesn't have to be just typescript and esbuild. You can write any build logic within your class that implements the ILocalBundling interface.

Here's another example to build a Java function with dependencies using Maven.

export class Maven implements ILocalBundling {
  tryBundle(outputDir: string, options: BundlingOptions): boolean {
    const commands = [
      `cd lambda`,
      `mvn clean install`,
      `cp target/javalambda-jar-with-dependencies.jar ${outputDir}`
    ];

    execSync(commands.join(' && '));
    return true
  }
}

This neat little approach lets us eliminate any manual build step. This means when it is time to deploy, we can just rely on npx cdk deploy!

Footnotes

  1. Restrictions on edge functions https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-functions-restrictions.html#lambda-at-edge-function-restrictions