A few weeks I wrote a post about using tRPC to get type safe requests between a web client and the server. It turns out that there is an even simpler way of calling your AWS backend (still using tRPC) π€―!
In this post I will show you how to use tRPC and a Lambda function with Lambda Function URLs to create a ToDo-app for my terminal. The backend is deployed to AWS using the Serverless Stack.
This post is divided into two sections.
Section 1 - First we set up our Lambda function with a public HTTPS endpoint and set up a type safe remote procedure call using tRPC.
Section 2 - Next, we turn the initial setup into a ToDo-app, by adding a database for persisting state, and procedures to create, list and complete ToDos.
Overview
Let us look at the system we will have built (after section 2).
The goal is to build a ToDo-app that we can run in our terminal. It will use a tRPC client to make requests to a Lambda function that stores our ToDos in a DynamoDB table. The Lambda function will be exposed as an HTTPS endpoint using a Lambda Function URL. As mentioned in the intro, this is deployed to AWS using the Serverless Stack.
Our architecture diagram π
Before we get coding, let us go through what Lambda Function URLs and tRPC.
First, Lambda Function URLs is a new feature (from April, 2022) for AWS Lambda that let us expose our Lambda function as HTTPS endpoints directly, without using an API gateway. This has been available for GCP Functions for a while, and it is great to see that Lambda get this feature as well. Here is a comparison of using an API Gateway in front of Lambda and using Function URLs directly:
API Gateway | Function URLs | |
---|---|---|
Authentication | IAM, Cognito, Lambda, API keys | IAM |
Timeout | 30 seconds | 15 Minutes |
Websocket | βοΈ | β |
Custom Domains | βοΈ | β |
CORS | βοΈ | βοΈ |
Price | Medium | Low |
URLs | One URL multiple endpoints | Multiple URLs |
β¦ | β¦ | β¦ |
Cool π | β | βοΈ |
More details over at Serverless Guru
For most production scenarios, you probably want an API Gateway. For my scenario, I use Function URLs here for a few reasons.
- Curiosity: Function URLs is a new feature for AWS, and I want to try them out!
- Using tRPC, we only actually want and need a single endpoint. Perfect match!
In short, I think Function URLs are a great match for my scenario!
The second concept is tRPC. It is a Typescript package that lets build fully type safe APIs for both with both server and client. As you will see, it REALLY simplifies making requests from a client to the server, while still being type safe. All this without requiring code generation. Read more about it in my post β[Typesafe serverless API with tRPC](./ 2022-06-19-typesafe-http-client-serverless)β (or in the official documentation).
Now we are ready to build a minimal client-server setup.
Section 1 - The basics
First out, we create the boilerplate for our stack:
# First we initialize the Serverless Stack using the Typescript starter
npx create-sst@latest \
--template=starters/typescript-starter trpc-with-lambda-function-urls
# Install the dependencies
cd trpc-with-lambda-function-urls
npm install
# Install the tRPC client and server packages
npm install @trpc/server @trpc/client
After the SST setup, our directory looks as follows:
.
βββ package.json
βββ services
βΒ Β βββ functions
βΒ Β βΒ Β βββ lambda.ts
βΒ Β βββ package.json
βΒ Β βββ test
βΒ Β βΒ Β βββ sample.test.ts
βΒ Β βββ tsconfig.json
βββ sst.json
βββ stacks
βΒ Β βββ MyStack.ts
βΒ Β βββ index.ts
βββ tsconfig.json
βββ vitest.config.ts
Updating the stack
As I mentioned in the introduction, we do not need the API gateway in our stack. Letβs update the stack that SST gave us, and replace the API Gateway with it with a Lambda function. It is located in ./stacks/MyStack.ts
.
-import { StackContext, Api } from "@serverless-stack/resources";
+import { StackContext, Function } from "@serverless-stack/resources";
export function MyStack({ stack }: StackContext) {
- const api = new Api(stack, "api", {
- routes: {
- "GET /": "functions/lambda.handler",
- },
- });
+
+ const fn = new Function(stack, "trpc", {
+ handler: "functions/lambda.handler",
+ url: true // <--- enable function URL
+ })
+
stack.addOutputs({
- ApiEndpoint: api.url
+ FunctionURL: fn.url ?? ""
});
}
Then we can deploy our stack for the first time. It takes around a minute.
npm start
Output:
> trpc-with-lambda-function-urls@0.0.0 start
> sst start
Look like youβre running sst for the first time in this directory. Please enter a stage name youβd like to use locally. Or hit enter to use the one based on your AWS credentials (magnus):
Using stage: magnus
...
...
β
magnus-trpc-with-lambda-function-urls-MyStack
Stack magnus-trpc-with-lambda-function-urls-MyStack
Status: deployed
Outputs:
FunctionURL: https://kr2madrbusxrglf3aamfj7o4sy0hlebb.lambda-url.us-east-1.on.aws/
Open the FunctionURL
from the ouput, and you should get a response from your function:
How cool is that? Next we will replace it our tRPC handler Lambda.
The HTTP handler
Replace the code in ./services/function/lambda.ts
with the following.
import * as trpc from "@trpc/server";
import * as z from "zod";
import { awsLambdaRequestHandler } from "@trpc/server/adapters/aws-lambda";
const appRouter = trpc.router().query("sayHello", {
// 1. Procedure name
input: z.string(), // 2. Input
async resolve(req) {
return `Hello, ${req.input}!`; // 3. Response
},
});
export type AppRouter = typeof appRouter;
export const handler = awsLambdaRequestHandler({
router: appRouter,
});
SST will automatically reload the handler. If you go to the same URL as before, you will get an error similar to the one below.
This is fine! The API is now a tRPC endpoint, and not ment to be called directly by the browser.
Instead, letβs create our terminal client.
The client
Create ./client/index.ts
and add:
import type { AppRouter } from "../services/functions/lambda";
import { createTRPCClient } from "@trpc/client";
// 1. Replace with your Function URL, e.g.
// https://kr2madrbusxrglf3aamfj7o4sy0hlebb.lambda-url.us-east-1.on.aws
const function_url = "<YOUR FUNCTION URL HERE>";
// 2. Create tPRC client
const client = createTRPCClient<AppRouter>({
url: function_url,
});
// 3. Make request to server
(async function () {
const greeting = await client.query("sayHello", "Bilbo");
console.log(greeting);
})();
We invoke our client with npx ts-node ./client
. Output:
Hello, Bilbo!
Success!
Summary - section 1
What happened here?
- We created a client based on the
AppRouter
from the server - We made a request to remote procedure
sayHello
with the argument βBilbo
β. - Our handler (Lambda function) received the request, and understood how to route it to the right procedure
sayHello
and responded with βHello, Bilbo!
β - Our client printed the greeting to the terminal
Note: Type safety!
What could be easy to miss here is that tRPC makes all this type safe. If I try to call a non-existent procedure, my editor gives me a helpful warning:
![tRPC type error 1](/img/trpc-with-lambda-function-urls-on-aws/error_1.png)
Same if the parameter is the wrong type:
![tRPC type error 2](/img/trpc-with-lambda-function-urls-on-aws/error_2.png)
Just as Typescripts makes our Javascript code more reliable using types and warnings in the editor, tRPC does the same for making requests to our remote backend π.
This is all we need to set up a tRPC Lambda function on AWS and call it from a client! In the next section we will make our app a lot cooler. See you there!
Part 2 - Improving the app
In this section we will say goodbye to our basic app Greeting Generating app into a cool ToDo app! For this we need the following changes
- Add a DynamoDB table to store our ToDos
- Add one query and two mutations to our tRPC router
- Change the client from a one of command to a terminal prompt
More setup
For the next steps, we need ksuid
that will be used as ID for our ToDos, and prompt
that is used by our client.
npm install ksuid
npm install prompts @types/prompts
Updating the stack
Next, we add a DynamoDB table, using the SST construct Table. Make sure to give the Lambda function permission to access the table, and set the name of the table as an environment.
-import {Function, StackContext} from "@serverless-stack/resources";
+import {Function, StackContext, Table} from "@serverless-stack/resources";
export function MyStack({stack}: StackContext) {
+ const todoTable = new Table(stack, "table", {
+ fields: {
+ id: "string",
+ },
+ primaryIndex: {partitionKey: "id"},
+ })
const fn = new Function(stack, "trpc", {
handler: "functions/lambda.handler",
+ permissions: [todoTable],
+ environment: {
+ TABLE_NAME: todoTable.tableName,
+ },
url: true
})
Updating the router
To make the code a bit easier to follow, I will break out the database calls to a separate file. We will create two mutations (writers of data) and one query (reader of data).
createToDo(id: string, description: string)
(a mutation)completeToDo(id: string)
(a mutation)listToDos()
(a query) Create a new file at./services/functions/db.ts
import { DynamoDB } from "aws-sdk";
const tableName = process.env.TABLE_NAME ?? ""; // <--- 1.
const dynamoDb = new DynamoDB.DocumentClient(); // <--- 2.
type ToDo = {
id: string;
description: string;
completed: boolean;
};
export const createToDo = async (id: string, description: string) => {
const todo = {
id: id,
description: description,
completed: false,
};
const putParams = {
TableName: tableName,
Item: todo,
};
await dynamoDb.put(putParams).promise();
};
export const completeToDo = async (id: string) => {
const updateParams = {
TableName: tableName,
Key: {
id: id,
},
UpdateExpression: `set completed = :completed`,
ExpressionAttributeValues: {
":completed": true,
":id": id,
},
ConditionExpression: "id = :id",
};
await dynamoDb.update(updateParams).promise();
};
export const listToDos = async () => {
const resp = await dynamoDb
.scan({
TableName: tableName,
})
.promise();
return resp.Items?.map(item => item as ToDo);
};
I will not go into the details of how the request to DynamoDB works. A couple of notes:
- We get to use the environment variable
TABLE_NAME
we propagated in the stack. - The DynamoDB DocumentClient is A LOT easier to use the regular SDK client. I highly recommend it!
For a better understanding of how to call DynamoDB with Javascript, I often refer to this Cheat Sheet from Dynobase (they also have it for Go, Python, probably everything else).
Now we can update our existing Lambda at ./services/functions/lambda.ts
:
import * as trpc from "@trpc/server";
import * as z from "zod";
import KSUID from "ksuid";
import { awsLambdaRequestHandler } from "@trpc/server/adapters/aws-lambda";
import { completeToDo, createToDo, listToDos } from "./db";
const appRouter = trpc
.router()
.mutation("createToDo", {
input: z.string(),
async resolve(req) {
const id = KSUID.randomSync().string;
await createToDo(id, req.input);
return id;
},
})
.mutation("completeToDo", {
input: z.string(),
async resolve(req) {
return completeToDo(req.input);
},
})
.query("listToDos", {
async resolve(_) {
return listToDos();
},
});
export type AppRouter = typeof appRouter;
export const handler = awsLambdaRequestHandler({
router: appRouter,
});
Here we have created 3 remote procedures:
createToDo
- takes a description string as input, generates a KSUID, and creates a new TodocompleteToDo
- takes a KSUID string as input, marks that ToDo as completedlistToDos
- takes no input, returns all ToDos
Once again, in order to test that our tRPC handler works, we need to use it in a client. Update the client as follows:
import type { AppRouter } from "../services/functions/lambda";
import { createTRPCClient } from "@trpc/client";
const function_url =
"https://kr2madrbusxrglf3aamfj7o4sy0hlebb.lambda-url.us-east-1.on.aws";
const client = createTRPCClient<AppRouter>({
url: function_url,
});
(async function () {
// Create two ToDos
const first_id = await client.mutation("createToDo", "Buy milk");
const secnd_id = await client.mutation("createToDo", "Buy eggs");
// Mark one as completed
await client.mutation("completeToDo", first_id);
// List Todos
const todos = await client.query("listToDos");
if (!todos) {
return;
}
todos.forEach(function (todo, i) {
const state = todo.completed ? "completed" : "in progress";
console.log(`${i + 1} - ${todo.description} (${state})`);
});
})();
If we run the client, we get the following output.
1 - Buy milk (completed)
2 - Buy eggs (in progress)
Seems to work!
Client
Finally, letβs turn our client into a prompt, as promised.
I have never developed command line clients in Typescript before. There are most likely better ways to write the following application π€
Replace the client code in ./client/index.ts
as follows:
import type {AppRouter} from '../services/functions/lambda';
import {createTRPCClient} from '@trpc/client';
import prompts from "prompts"
const function_url = "https://kr2madrbusxrglf3aamfj7o4sy0hlebb.lambda-url.us-east-1.on.aws"
const client = createTRPCClient<AppRouter>({
url: function_url,
});
const promptNewTodo = async () => {
const entered = await prompts({
type: 'text',
name: 'description',
message: `Please describe your ToDo`,
});
await client.mutation('createToDo', entered.description)
}
async function promptForTodoComplete() {
const todos = await client.query('listToDos') ?? [];
const todoChoices = todos.map((todo) => {
return {
title: todo.description + (todo.completed ? " (completed)" : ""),
description: `Mark as completed`,
value: todo.id,
disabled: todo.completed,
}
})
const choices = [
...todoChoices,
{title: "ACTION: CREATE TODO", value: "new"},
{title: "ACTION: EXIT", value: "exit"}
]
const selected = await prompts({
type: 'select',
name: "id",
message: 'Select an action',
warn: " ",
hint: " ",
choices: choices,
});
if (!selected.id || selected.id == "exit") {
return {id: "", exit: true}
}
return {id: selected.id, exit: false}
}
(async () => {
console.log("Loading ToDos...")
let exit = false;
while (!exit) {
const {id, exit} = await promptForTodoComplete();
if (exit)
break
if (id == "new") {
await promptNewTodo()
} else {
await client.mutation('completeToDo', id)
}
}
})();
And for the last time, run the client. The app is now complete π!
Conclusion
We have created a semi-useful ToDo-app using tRPC and Lambda functions and Function URLs. I hope by now I have made it apparent how easy tRPC (and SST) made developing this app. I spent more time writing the silly prompt than hooking up the client to the server, and deploying it all. Great developer experience!
Hope you enjoyed this post.
Please send any feedback or comments to @wahlstra.
Resources
- I wrote more about tRPC here: Typesafe serverless API with tRPC
- The tRPC website
- Comparison between API Gateway and Function URLs by Serverless Guru
- DynamoDB & DocumentClient Cheat Sheet from Dynobase