DigitaleKumpel

Serverless TypeScript Functions with AWS CDK

When I need to build and deploy serverless functions based on TypeScript on AWS, the quickest way to do that is by using the AWS Cloud Development Kit, which gives me an infrastructure as code solution while also living side by side with my business logic.

But what exactly is the AWS CDK?

The AWS-CDK is an open-source library maintained by AWS, which helps building your AWS Resources by using something called Constructs. AWS-CDK translates your infrastructure code into a CloudFormation Template that can be deployed. But it is not to be understood as a one-to-one interpretation of the CloudFormation Resources, but rather builds on top of them. We will utilise one of these higher level abstractions in our following example on deploying TypeScript code to AWS Lambda.

Infrastructure as Code with AWS-CDK

In the following example we will build a Lambda Function and expose it via a function url. This will make our Lambda Function callable over the public internet. Our business-logic will be written in TypeScript.

At the end of this article, you will have a grasp on how you can leverage the AWS CDK to deploy your TypeScript business logic on AWS with minimal overhead and all the benefits that come with infrastructure as code, such as co-locating your infrastructure code in the same NodeJS Project.

If you never heard about infrastructure as code, it is a practice where you are mutating your infrastructure through code and have it checked into your source control provider, just like your apps and monoliths and frontend-code. You are doing reviews and pull requests for your infrastructure code just like you do for the rest of your code and it can also live side by side with your business logic, therefore reducing the mental overhead of understanding the scope and features of your application. It's also favorable to ClickOps which is essentially creating your resources manually, making it harder to replicate across environments.

Before we begin with this example on AWS-CDK, please make sure that you have AWS access configured via the aws-cli and have nodejs installed on your machine. We will need to have both installed for our small example.

In order to use AWS-CDK, you will have to bootstrap it for your account. With your AWS access configured, you can run the npx cdk bootstrap command. In essence, this will create the roles for the CDK deployment, along with a bucket that will hold your bundled assets, such as your transpiled JavaScript code. This happens behind the scenes and once you have bootstrapped, we can start on getting started with CDK.

Initialising a new Project

Let us begin creating our CDK Project, run the following commands which will create a new folder called cdk-example and then scaffold a new cdk project into that folder. You will be prompted to install a dependency for this project.

$ mkdir cdk-example
$ cd cdk-example
$ npx cdk init app --language=typescript

This command will install all dependencies and a basic folder structure for our CDK example. Let's quickly survey what we got, when we ran that command.

As you can see, we have a bin folder and a lib folder. There is also a test folder, but let's leave that for another post. In the bin folder, we have instructions on what cdk will deploy. It will deploy the stack we have defined in the lib folder. As you can see, we could pass quite a bit of properties to this stack itself, this is useful when we want to differentiate multiple environments, or have different accounts for different environments. (You should have that.)

Checking the bin folder

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CdkExampleStack } from '../lib/cdk-example-stack';
 
const app = new cdk.App();
new CdkExampleStack(app, 'CdkExampleStack', {
  /* If you don't specify 'env', this stack will be environment-agnostic.
   * Account/Region-dependent features and context lookups will not work,
   * but a single synthesized template can be deployed anywhere. */
 
  /* Uncomment the next line to specialize this stack for the AWS Account
   * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
 
  /* Uncomment the next line if you know exactly what Account and Region you
   * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },
 
  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});

Every AWS CDK project outputs a CloudFormation stack. (Atleast when we are talking about apps here, you can also create a CDK library and use it in other projects, but we are not doing this here in this example.) This is the output you will get from your CDK code. And this stack will then be deployed to your AWS account as a CloudFormation stack. If you want to be very mean, you could call AWS-CDK a CloudFormation Template generator. But that's a big understatement.

Let's leave the bin folder as it is for now. If you're looking to deploy multiple stacks for multiple environments, you are most likely changing the stack or duplicating the stacks that I created in the bin folder. But for now, let's assume we just have one environment where we want to deploy our lambda function.

I would suggest that you also take a look at the AWS CDK API reference, which is exhaustive and really, really good. Because what we will be using now is a level three construct. In the CDK world, there are three levels of constructs. A level one construct is only a one-to-one implementation of a cloud formation resource. A level two construct is with added syntax and typing on top. It is not just a translation of a cloudformation resource, but it also adds methods to the respective resource.. And a level three construct adds functionality on top of the resource. An example for a level three construct would be the constructs from the ecs_patterns module. The Construct we are about to use is also a level three construct: NodejsFunction. You can read about it here.

We will leverage the NodejsFunction construct to handle the transpiling of our TypeScript business logic into JavaScript. It will happen when we deploy, we don't need to worry about it, we don't need to add it to our ci/cd scripts. There are even more things, that AWS CDK takes care of, we don't need to upload a zip or any code assets, that will happen behind the scenes when we deploy.

Let's review the cdk-example-stack.ts file we have in the lib folder, it will look like this, if you followed the naming of the folder, else it might look a bit different.

Checking the lib folder

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
// import * as sqs from 'aws-cdk-lib/aws-sqs';
 
export class CdkExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
 
    // The code that defines your stack goes here
 
    // example resource
    // const queue = new sqs.Queue(this, 'CdkExampleQueue', {
    //   visibilityTimeout: cdk.Duration.seconds(300)
    // });
  }
}
 

Utilising the NodejsFunction construct

You see that there is a commented-out section where we can see how a SQS Queue could be deployed. Let's delete that, and instead write the boilerplate-code for our example.

import * as cdk from 'aws-cdk-lib';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
 
export class CdkExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
		const exampleFn = new NodejsFunction(this, 'example-function', {
      functionName: 'example-function',
      entry: './src/index.ts'
    })
  }
}
 

As you can see, we are utilising the NodejsFunction construct. The first parameter is always a pointer to the scope, which is in this case this. And then, we give it a functionName, so we can find it later on and also add an entry, where it would expect our TypeScript code. Please be aware that through the defaults, it will look for an export named "handler". We will do this together in the next step.

Create a src folder that will hold the code for our TypeScript Lambda function. We can already initialise a index.ts in that directory, we will move to it at a later point.

$ mkdir src
$ touch src/index.ts

While we are at it, we can also install the aws-lambda types and esbuild. One quick note about bundling with the NodejsFunction: If your project doesn't have esbuild as the dev dependency, it will try to leverage your local docker client to build your function inside an image. It's quicker to just install esbuild and let it use that instead. As for the aws-lambda types, we are using TypeScript and we are not looking to litter up our code with any.

Writing business logic

For our short example we will just return "Hello World". Add this code to your src/index.ts.

import { LambdaFunctionURLEvent } from 'aws-lambda';
 
export const handler = async (request: LambdaFunctionURLEvent) => {
  // do something with request
  console.log("Routekey", request.routeKey)
  return { message: "Hello World"};
}

Okay, this will work. As we are typing the request, we get autocomplete on it. That's neat. Let's move to the next step, where we add a function url to our lambda function.

If you have never worked a function URL, a function URL is a simple mechanism built into Lambda where it can expose the Lambda function to the open world using a random URL given by AWS. You cannot control the URL you are given. But you can of course assign a CNAME record to it with a more memorable URL.

You can also have a function URL that requires authentication. We are not doing this here, but it's something you should be aware of. For now, let's just call the add function URL function on the Lambda function. Update your code to this

import * as cdk from 'aws-cdk-lib';
import { FunctionUrlAuthType } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
 
export class CdkExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const exampleFn = new NodejsFunction(this, 'example-function', {
      functionName: 'example-function',
      entry: './src/index.ts'
    })
    exampleFn.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE
    })
  }
}
 

Excellent! Now we have a function url specified.

Now that we are done with the coding, we can do a npx cdk diff in the directory and then see the output that it creates. You will most likely see that it will create a lambda function and also behind the scenes, since we have not specified it, it will also create a IAM role that will be assumed by this lambda function.

In CDK it's pretty easy to also enhance your lambda resource. Like let's say you need extra permissions for your lambda functions. Then you would create a new CDK resource, namely a IAM role for the lambda function. And you would also create a policy which would have permissions and attach that to that role. That's all possible to be done in CDK. And this also helps having everything you have, everything you need for a lambda function in one place. And also make it easily understandable by reviewers or other people looking at your code to know what exactly is happening here and what permissions are being used.

Again, as a reminder, you can only create new resources. You cannot mutate existing resources with CDK unless you create a lambda function in your CDK stack, which will then run API calls and mutate something else. But from the stack alone, you cannot import another resource and mutate it. And all resources that you reference are read-only. So if you need the name of a VPC, like the VPC ID, you could reference, like you could run commands to get the resource and read the properties of that resource, but you cannot mutate it.

Adding a CloudFormation Output

Before we actually deploy evertything, let's adjust a tiny little thing, so that we can get the function URL as a CloudFormation Output, add this snippet to your Stack definition. When we define a CloudFormation Output, it will be printed on our CLI on successful deployment. We have to capture the functionUrl into a variable and pass it to the CfnOutput.

import * as cdk from 'aws-cdk-lib';
import { FunctionUrlAuthType } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
 
export class CdkExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const exampleFn = new NodejsFunction(this, 'example-function', {
      functionName: 'example-function',
      entry: './src/index.ts'
    })
    const functionUrl = exampleFn.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE
    })
    new cdk.CfnOutput(this, 'function-url', {
      value: functionUrl.url
    })
  }
}
 

To finally deploy our stack , run the npx cdk deploy command and you will see that it will create all the resources necessary for us.Make note of the URL that is displayed in the CLI after our cdk deploy command ran successfully.

You can open the URL to see the "Hello World" message from our business logic.And this concludes our introductory example for deploying TypeScript workloads on AWS using AWS CDK.

Before you go, please remember to delete the stack once you are done with experimenting. You can use npx cdk destroy for that.

Have a good one.

About the Author

Sebastian

Sebastian

Available for work

sebastian@digitale-kumpel.ruhr
©️ 2024 Digitale Kumpel GmbH. All rights reserved.