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>
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:
create-repo:
aws ecr create-repository \
--profile $(AWS_PROFILE) \
--repository-name $(FUNCTION_NAME)
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)
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
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
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
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
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"
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"
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"
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";
}
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"]
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)