DEV Community

Cover image for AWS Lambda in Deno or Bun
Alexander Demin
Alexander Demin

Posted on

AWS Lambda in Deno or Bun

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.

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

create-repo:
  aws ecr create-repository \
  --profile $(AWS_PROFILE) \
  --repository-name $(FUNCTION_NAME)
Enter fullscreen mode Exit fullscreen mode

make create-repo

Login docker to the repository:

ecr-login:
  aws ecr get-login-password --region $(AWS_REGION) \
  --profile $(AWS_PROFILE) \
  | docker login --username AWS --password-stdin $(REPO)
Enter fullscreen mode Exit fullscreen mode

make ecr-login

Build, tag and push the image:

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
Enter fullscreen mode Exit fullscreen mode

make build-tag-push

Before creating the AWS Lambda, we need to create a role:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

make create-lambda

We must create the AWS lambda URL and allow unauthenticated access to complete the lambda creation.

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 
Enter fullscreen mode Exit fullscreen mode

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:

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"
Enter fullscreen mode Exit fullscreen mode

make deploy

This command builds, tags and deploys a new image.

Let's invoke the function:

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"
Enter fullscreen mode Exit fullscreen mode

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.

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"
Enter fullscreen mode Exit fullscreen mode

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!

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";
}
Enter fullscreen mode Exit fullscreen mode

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.

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"]
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)