AWS Lambda in Deno or Bun
Tags: deno, bunjs, aws, awslambda
Published: 2024-07-04 16:22:41

The article describes creating the AWS Lambda using purely Deno or Bun Javascript runtimes with zero external dependencies. We will use Deno by default, but the switch to Bun can be made via the `RUNTIME` variable (see `Makefile`).

Usually, to create an AWS lambda in Typescript, the code must be compiled in Javascript because AWS Lambda does not natively support Deno and Bun, only Node.

Some projects offer the flexibility of using Typescript directly in AWS Lambda, such as [Deno Lambda](https://github.com/denoland/deno-lambda).

However, we will implement our own custom AWS Lambda runtime to run Typescript by Deno or Bun and use AWS Lambda API directly.

The project comprises a Makefile for automation and clarity, Dockerfiles for containerization, and the `lambda.ts` file for the AWS Lambda function. That's all you need.

We will be building Docker images-based AWS Lambda deployment.

We start by explaining how to prepare AWS resources (image repository, role and policies, and lambda deployment). 

We will use AWS CLI. 

## Part 1

You can skip this part of the article entirely if you are comfortable creating an AWS Elastic Container Repository named `$(FUNCTION_NAME)` (refer to Makefile) to prepare the lambda function named `$(FUNCTION_NAME)` to be created from the image, which we will build later.

You need to have `.env` file:
```
AWS_PROFILE=<YOU AWS PROFILE NAME>
AWS_ACCOUNT=<YOU AWS ACCOUNT>
AWS_REGION=<YOU AWS REGION>
```
The profile name allows AWS CLI to find your AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY.

The `.env` file is included at the beginning of `Makefile`:
```
include .env
export

FUNCTION_NAME=lambda-ts-container
REPO = $(AWS_ACCOUNT).dkr.ecr.$(AWS_REGION).amazonaws.com
```

Once again, instead of using the Makefile file below, you can create the repository and manually prepare the AWS Lambda creation via AWS Console.

Create the depository:
```Makeflle
create-repo:
  aws ecr create-repository \
  --profile $(AWS_PROFILE) \
  --repository-name $(FUNCTION_NAME)
```

`make create-repo`

Login docker to the repository:

```Makefile
ecr-login:
  aws ecr get-login-password --region $(AWS_REGION) \
  --profile $(AWS_PROFILE) \
  | docker login --username AWS --password-stdin $(REPO)
```

`make ecr-login`

Build, tag and push the image:
```Makefile
build-tag-push: build tag-push

build:
  docker build -t $(FUNCTION_NAME) \
  --platform linux/amd64 \
  -f Dockerfile-$(RUNTIME) .

tag-push:
  docker tag $(FUNCTION_NAME):latest \
  $(REPO)/$(FUNCTION_NAME):latest \
  docker push $(REPO)/$(FUNCTION_NAME):latest
```

`make build-tag-push`

Before creating the AWS Lambda, we need to create a role:
```Makefile
create-lambda-role:
  aws iam create-role \
  --profile $(AWS_PROFILE) \
  --role-name $(FUNCTION_NAME)-role \
  --assume-role-policy-document \
  '{"Version": "2012-10-17","Statement": [{ "Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
  aws iam attach-role-policy \
  --profile $(AWS_PROFILE)
  --role-name $(FUNCTION_NAME)-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
```

`make create-lambda-role`

The role uses the `AWSLambdaBasicExecutionRole` policy, which allows the lambda to write logs for CloudWatch.

Finally, we create the lambda function:
```Makefile
create-lambda:
  aws lambda create-function \
  --function-name $(FUNCTION_NAME) \
  --role arn:aws:iam::$(AWS_ACCOUNT):role/$(FUNCTION_NAME)-role \
  --package-type Image \
  --code ImageUri=$(REPO)/$(FUNCTION_NAME):latest \
  --architectures x86_64 \
  --profile $(AWS_PROFILE) | cat
```

`make create-lambda`

We must create the AWS lambda URL and allow unauthenticated access to complete the lambda creation.
```Makefile
create-lambda-url:
  aws lambda create-function-url-config \
  --profile $(AWS_PROFILE) \
  --function-name $(FUNCTION_NAME) \
  --auth-type NONE 

create-lambda-invoke-permission:
  aws lambda add-permission \
  --profile $(AWS_PROFILE) \
  --function-name $(FUNCTION_NAME) \
  --action lambda:InvokeFunctionUrl \
  --statement-id FunctionURLAllowPublicAccess \
  --principal "*" \
  --function-url-auth-type NONE 
```

`make create-lambda-url create-lambda-invoke-permission`

At this point, the AWS should be successfully created and deployed.

If you change the lambda source and want to deploy the update, you call:
```Makefile
deploy: build-tag-push update-image wait

update-image:
  SHA=$(shell make last-tag) && \
  echo "SHA=$(WHITE)$$SHA$(NC)" && \
  aws lambda update-function-code \
  --profile $(AWS_PROFILE) \
  --function-name $(FUNCTION_NAME) \
  --image $(REPO)/$(FUNCTION_NAME)@$$SHA \
  | jq -r '.CodeSha256'

status:
  @aws lambda get-function \
  --function-name $(FUNCTION_NAME) \
  --profile $(AWS_PROFILE) \
  | jq -r .Configuration.LastUpdateStatus

wait:
  @while [ "$$(make status)" != "Successful" ]; do \
    echo "wait a moment for AWS to update the function..."; \
    sleep 10; \
  done
  @echo "lambda function update complete"
```

`make deploy`

This command builds, tags and deploys a new image.

Let's invoke the function:
```Makefile
lambda-url:
  @aws lambda get-function-url-config \
  --function-name $(FUNCTION_NAME) \
  | jq -r '.FunctionUrl | rtrimstr("/")'

get:
  @HOST=$(shell make lambda-url) && \
  http GET "$$HOST/call?a=1"
```

`make get`

This command calls the lambda function via its public URL. The URL path is `/call` but can be anything with some query parameters. The path and query parameters will be provided to the function code and other standard HTTP-related information.

Other examples in `Makefile` invoke the function in different ways. For example, `put-` calls the data function in the request body.

```Makefile
put-json:
  @HOST=$(shell make lambda-url) && \
  http -b PUT "$$HOST/call?q=1" a=1 b="message"

put-text:
  @HOST=$(shell make lambda-url) && \
  http -b PUT "$$HOST/call?q=1" --raw='plain data'

get-418:
  @HOST=$(shell make lambda-url) && \
  http GET "$$HOST/call?a=1&status=418"
```

The code uses the `http` command from `httpie`.

## Part 2

Let's look at the most exciting part -- the function's source code. 

As I promised, we do not use any libraries. Instead, we use AWS Lambda API directly.

The AWS lambda lifecycle is a simple loop. The code below fetches the next function invocation event from the AWS API, passes it to the handler, and then sends the response to the AWS Lambda API response endpoint. That is it!

```Typescript
import process from "node:process";

const env = process.env;

const AWS_LAMBDA_RUNTIME_API = env.AWS_LAMBDA_RUNTIME_API || "?";
console.log("AWS_LAMBDA_RUNTIME_API", AWS_LAMBDA_RUNTIME_API);

const API = `http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation`;

while (true) {
    const event = await fetch(API + "/next");

    const REQUEST_ID = event.headers.get("Lambda-Runtime-Aws-Request-Id");
    console.log("REQUEST_ID", REQUEST_ID);

    const response = await handler(await event.json());

    await fetch(API + `/${REQUEST_ID}/response`, {
        method: "POST",
        body: JSON.stringify(response),
    });
}

// This is a simplified version of the AWS Lambda runtime API.
// The full specification can be found at:
// https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html

type APIGatewayProxyEvent = {
    queryStringParameters?: Record<string, string>;
    requestContext: { http: { method: string; path: string } };
    body?: string;
};

async function handler(event: APIGatewayProxyEvent) {
    const { method, path } = event.requestContext.http;

    const echo = {
        method,
        path,
        status: "200",
        queryStringParameters: {},
        runtime: runtime(),
        env: {
            ...env,
            AWS_SESSION_TOKEN: "REDACTED",
            AWS_SECRET_ACCESS_KEY: "REDACTED",
        },
        format: "",
        body: "",
    };

    if (event.queryStringParameters) {
        echo.queryStringParameters = event.queryStringParameters;
        echo.status = event.queryStringParameters.status || "200";
    }

    if (event.body) {
        try {
            echo.body = JSON.parse(event.body);
            echo.format = "json";
        } catch {
            echo.body = event.body;
            echo.format = "text";
        }
    }

    return {
        statusCode: echo.status,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(echo),
    };
}

function runtime() {
    return typeof Deno !== "undefined"
        ? "deno " + Deno.version.deno
        : typeof Bun !== "undefined"
        ? "bun " + Bun.version
        : "maybe node";
}
```

For demonstration purposes, the handler returns the input data as the response.

NOTE: There are essential moments in the Dockerfile where we must configure the location for temporary files. The AWS Lambda container execution environment file system is read-only, and only the `/tmp` directory can be used for writing.

Let's discuss Dockerfiles to build the image.

```Dockerfile
FROM denoland/deno as deno

FROM public.ecr.aws/lambda/provided:al2

COPY --from=deno /usr/bin/deno /usr/bin/deno

# We need to set the DENO_DIR to /tmp because the AWS lambda filesystem 
# is read-only except for /tmp. Deno may need to write to its cache.
ENV DENO_DIR=/tmp

COPY lambda.ts /var/task/

ENTRYPOINT [ "/usr/bin/deno" ]
CMD [ "run", "-A", "--no-lock", "/var/task/lambda.ts"]
```

Dockerfile uses the official AWS base image `public.ecr.aws/lambda/provided:al2`.

This image comes with the AWS Lambda Runtime Client preinstalled. This client runs in the background and proxies the requests from the lambda function loop to AWS endpoints. The `AWS_LAMBDA_RUNTIME_API` variable points to `localhost` with a port on which the Lambda Runtime Client listens.

This concludes the article.

By default, `Makefile` uses Deno (RUNTIME=deno). The RUNTIME variable can be set to `bun` as a drop-in change, so no other changes are required.

For convenience, the handler reports what runtime it is running on in the `runtime` field.

## Resources

The links to the sources of the files from this article:

- [Makefile](https://gist.github.com/begoon/993e29f5cf9a384b9e0e96e70a71b491#file-makefile)
- [lambda.ts](https://gist.github.com/begoon/993e29f5cf9a384b9e0e96e70a71b491#lambda-ts)
- [Dockefile-deno](https://gist.github.com/begoon/993e29f5cf9a384b9e0e96e70a71b491#Dockefile-deno)
- [Dockerfile-bun](https://gist.github.com/begoon/993e29f5cf9a384b9e0e96e70a71b491#Dockerfile-bun)