Skip to content

Even simpler way of calling your AWS backend

July 9, 2022

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.

Overview

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 GatewayFunction URLs
AuthenticationIAM, Cognito, Lambda, API keysIAM
Timeout30 seconds15 Minutes
Websocketβœ”οΈβŒ
Custom Domainsβœ”οΈβŒ
CORSβœ”οΈβœ”οΈ
PriceMediumLow
URLsOne URL multiple endpointsMultiple 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.

  1. Curiosity: Function URLs is a new feature for AWS, and I want to try them out!
  2. 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:

First request

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.

tRPC error

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?

  1. We created a client based on the AppRouter from the server
  2. We made a request to remote procedure sayHello with the argument β€œBilbo”.
  3. Our handler (Lambda function) received the request, and understood how to route it to the right procedure sayHello and responded with β€œHello, Bilbo!”
  4. 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

Same if the parameter is the wrong type:

tRPC type error 2

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

  1. Add a DynamoDB table to store our ToDos
  2. Add one query and two mutations to our tRPC router
  3. 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:

  1. We get to use the environment variable TABLE_NAME we propagated in the stack.
  2. 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 Todo
  • completeToDo - takes a KSUID string as input, marks that ToDo as completed
  • listToDos - 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