Using TRPCv11 with Astro
Using tRPC v11.1 with Astro, Prisma, and the Github API
With over 32k stars on Github, TRPC has become a popular framework for building type-safe RPC APIs. Its type safety builds upon Zod, a TypeScript-first schema declaration and validation library. Compared to a REST API, a type-safe RPC server may include editor support for client-side code completion, and saves the developer from having to deal with the implementation details of HTTP or REST.
However, several RPC use HTTP as their protocol, such as GRPC, which takes advantage of many great features of HTTP/2: bidirectional streaming (aka websockets), multiplexed requests (sending multiple requests over a single connection), and header compression. RPC differs from REST in that, the client only needs to call a server method in order to communicate with the server. Behind the scenes, the server method is stubbed, so that serialization, communication, and deserialization are hidden from the developer-facing API. This enables developers to focus on implementing API functionality, such as business logic, rather than the details of the protocol used. RPCs also benefit from type-safety - I had worked before in an environment that used GRPC to automatically create generate client-side API libraries for multiple languages, so that each microservice communicated with each other via the same type definitions.
tRPC integrates with newer best practices like using React Query to hydrate the
client with data from the server, instead of re-implementing the same logic in
its own library. For example, if we specified that on the RPC server, we require
a {name: string}
parameter for the createUser
procedure, the client would
not build without passing the correct typed parameter:
Server:
const router = router({create: publicProcedure.input(z.object({ name: z.string() })).mutation(async (opts) => {const { input } = opts;const user = await prisma.user.create({data: {name: input.name,},});return user;}),});
Client:
// Name must be a string, thanks to Zodconst { data: user } = await trpc.user.create.mutate({ name: "Jane" });
Today we’ll build a tRPC router that fetches a single file from Github API. To accomplish this task, we need to set up tRPC and write a procedure that fetches a file from a github repository.
Installing tRPC v11
First, let’s install v11 of tRPC.
tRPC v11 packages have the
@next
release tag, and the tRPC team states that v11 is
stable.
We can install them with the following command:
npm install @trpc/server@next @trpc/client@next @trpc/react-query@next @trpc/next@next @tanstack/react-query@latest @tanstack/react-query-devtools@latest
Building the tRPC server
-
Create a new folder called
rpc-server
in the root of the project. -
First, we’ll set up the “context” for our RPC requests. tRPC introduces the concept of inner and outer context. Inner context provides context to all server-calls that don’t depend on a request object being present, such as database queries. This enables us to write integration tests for our procedures without having to set up mocks for the HTTP request or response, as well as create server-side helpers where
req
andres
are not available (docs). Inner context is always available in procedures, while outer context depends on the request. Therefore authentication would need information from the outer context, as session information would live in the request. This kind of context is only available for procedures called via HTTP, which makes sense. -
We can implement both contexts by creating a file named
context.ts
in therpc-server
folder:
import prisma from "$services/prisma";// https://trpc.io/docs/server/adapters/fetch// If you're using Astro.js or other edge runtimes, use this adapter:import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";// If you're using Next.js, you can use the following import instead:// import type { CreateNextContextOptions } from '@trpc/server/adapters/next';interface CreateInnerContextOptionsextends Partial<FetchCreateContextFnOptions> {}/*** @link https://trpc.io/docs/v11/context#inner-and-outer-context*/export async function createContextInner(opts?: CreateInnerContextOptions) {return {prisma,};}// Outer context. This is a superset of the inner context, which will include// the Prisma context.export async function createContext({req,resHeaders,}: FetchCreateContextFnOptions) {const contextInner = await createContextInner({});return { ...contextInner, req: req, resHeaders: resHeaders };}export type Context = Awaited<ReturnType<typeof createContext>>;
- A server is needed to server the router. Create a file named ‘server.ts’ in
the
rpc-server
folder:
import { initTRPC } from "@trpc/server";import { createHTTPServer } from "@trpc/server/adapters/standalone";import { appRouter } from "./router.ts";import { createContext } from "./context.ts";import cors from "cors";createHTTPServer({middleware: cors(),router: appRouter,createContext,}).listen(3333);
Now that we have context and a server, the last piece on the server side is
writing a procedure. Let’s start by creating a new procedure call in a file
named rpc-server/services/github.ts
that fetches the contents of a file from a
GitHub repository. First, we’ll cURL the Github API to see what the response
looks like so we know what to expect. Here’s an example response, with some
fields omitted for brevity:
{body: {status: 200,url: 'https://api.github.com/repos/facebook/react/contents/packages%2Fshared%2FshallowEqual.js',headers: {// ...'x-ratelimit-limit': '60','x-ratelimit-remaining': '59','x-ratelimit-reset': '1711322225','x-ratelimit-resource': 'core','x-ratelimit-used': '1',},data: {name: 'shallowEqual.js',path: 'packages/shared/shallowEqual.js',sha: '7c73b88c05c32ce1e2ff16a51498631939469050',size: 1259,url: 'https://api.github.com/repos/facebook/react/contents/packages/shared/shallowEqual.js?ref=main',html_url: 'https://github.com/facebook/react/blob/main/packages/shared/shallowEqual.js',git_url: 'https://api.github.com/repos/facebook/react/git/blobs/7c73b88c05c32ce1e2ff16a51498631939469050',download_url: 'https://raw.githubusercontent.com/facebook/react/main/packages/shared/shallowEqual.js',type: 'file',content: '[long base64-encoded string]'encoding: 'base64',_links: [Object]}}}
As we can see, the endpoint returns base64-encoded content for the file we requested, along with rate-limit headers that will enable us to prevent hitting the rate limit. With the right combination of logic based on the headers, the rate limit should never be reached. Let’s add a metric so we can confirm our prediction after this gets deployed, and then a route in the tRPC router that will accept only certain metric types, so we can take advantage of type safety in both the client and server. In this example, we have a simple metrics table where a new row is created for each new event.
interface MetricInput {metricType: "views" | "gh_ratelimit_errors";}const router = router({// Creates a new row with value of 1incrementMetric: publicProcedure.input(MetricInput).mutation((resolver) => {const { prisma } = resolver.ctx;const { metricType, slug } = resolver.input;return await prisma.metric.create({metricType,value: 1,});}),});
Nice! Now our API only allows incrementing of the views
and
gh_ratelimit_errors
metrics, and type restriction is enforced at build time.
Writing the getRepoContents
procedure
Let’s create a services
folder in the rpc-server
folder, and create a
github.ts
file in that folder. While server-side code in the same repo could
simply make direct calls to the database with Prisma, re-using the RPC endpoint
on the server side has benefits of type-safety and less surface area to test.
export const getRepoContents = async (props: {owner: string;repo: string;path: string;}) => {const { owner, repo, path } = props;try {const body = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}",{owner,repo,path,headers: {"X-GitHub-Api-Version": "2022-11-28",},},);if (body.status !== 200) {throw new Error(`Invalid status code: ${body.status}`);}// sourcery skip: use-object-destructuring// TODO: handle rate limit headers and parse dataconst rateLimitRemaing = body.headers["x-ratelimit-remaining"];const rateLimitReset = body.headers["x-ratelimit-reset"];// Example code:// const status = handleRateLimit({ rateLimitRemaining, rateLimitReset });// if (status === "hit") throw new Error("Rate limit hit")const caller = createCaller({req: request,resHeaders: new Headers(),prisma: prisma,});const data = await caller.incrementMetric({metricType: "gh_ratelimit_errors",});const data = body.data;} catch (error) {console.error(error);return new Response(JSON.stringify({ error }), { status: 500 });}};
Regarding the rate limit headers, Github gives us the number of remaining requests we can send during a give period, as well as when the limit resets. It’s important to keep track of events like rate limit hits, so that we can better handle, and most importantly, avoid them.
One nuance of the Github API is that it returns an array of objects if the path is a directory, and a single object if the path is a file. Because of this, we must the type by giving the typescript compiler a hint: returning early if the response is an array.
async (props: { owner: string; repo: string; path: string }) => {const { owner, repo, path } = props;try {const body = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}",{owner,repo,path,headers: {"X-GitHub-Api-Version": "2022-11-28",},},);if (body.status !== 200) {throw new Error(`Invalid status code: ${body.status}`);}// TODO: handle rate limit headers and parse dataconst rateLimitRemaining = body.headers["x-ratelimit-remaining"];const rateLimitReset = body.headers["x-ratelimit-reset"];// Example code:// const status = handleRateLimit({ rateLimitRemaining, rateLimitReset });// if (status === "hit") throw new Error("Rate limit hit")const data = body.data;if (Array.isArray(data)) {This line narrows the type from array to object
for the rest of the method
throw new Error("No file found");}const downloadUrl = data.download_url;if (!downloadUrl) {throw new Error("No download url found");}const response = await fetch(downloadUrl);if (!response.ok) {throw new Error("Failed to download diff");}const decodedFileContents = await response.text();const filePath = nodePath.resolve(`data/${data.path}`);const folder = filePath.split("/").slice(0, -1).join("/");await fs.mkdir(folder, { recursive: true });await fs.writeFile(filePath, decodedFileContents);const fileWrittenResponse = {path: filePath,};return new Response(JSON.stringify(fileWrittenResponse));} catch (error) {console.error(error);return new Response(JSON.stringify({ error }), { status: 500 });}};
Testing the server side call
Now that we’ve written a function that fetches a file from a certain path in a github repository, let’s finish hooking up the client to our backend RPC server:
In the rpc-server
folder, create a file named router.ts
:
import { initTRPC } from "@trpc/server";import { z } from "zod";import type { Context } from "./context";import { getCommits, getRepoContents } from "./services/github";//export const t = initTRPC.context<Context>().create();const { createCallerFactory, router } = t;export const publicProcedure = t.procedure;export const apiProcedure = publicProcedure.use((opts) => {if (!opts.ctx.req || !opts.ctx.resHeaders) {throw new Error("You are missing `req` or `res` in your call.");}return opts.next({ctx: {// We overwrite the context with the truthy `req` & `res`, which will also overwrite the types used in your procedure.req: opts.ctx.req,res: opts.ctx.resHeaders,},});});export const appRouter = t.router({getFile: apiProcedure.input(z.object({owner: z.string(),repo: z.string(),path: z.string(),}),).query(async (opts) => {const response = await getRepoContents(opts.input);return await response.json();}),});// export type definition of APIexport type AppRouter = typeof appRouter;export const createCaller = createCallerFactory(appRouter);
Now our RPC server has a single procedure, getFile
. This is useful for
micro-frontend architectures, where the RPC server may live in a separate
package. If we wanted to call this RPC method from an Astro.js endpoint in a
micro-frontend architecture, we would create a caller
via the
createCallerFactory
function, as stated
here. We’ll put this endpoint
in the src/pages
directory called api/ops/repo-content.ts
.
import trpc from "$services/trpc";import { createCaller } from "$rpc/router";import prisma from "$services/prisma";import type { APIRoute } from "astro";export const GET: APIRoute = async ({ params, request, cookies }) => {const queryParams = new URLSearchParams(request.url.split("?")[1]);const parsedParams = Object.fromEntries(queryParams.entries());const { owner, repo, path } = parsedParams;if (!owner || !repo || !path) {return new Response(JSON.stringify({ error: "Missing params" }), {status: 400,});}const resHeaders = new Headers();const caller = createCaller({req: request,resHeaders: new Headers(),prisma: prisma,});const data = await caller.getFile({ owner, repo, path });return new Response(JSON.stringify(data));};
Now, let’s test it out. Make sure the astro server is running, and make a GET
request to
http://localhost:3000/api/ops/repo-content?owner=facebook&repo=react&path=data/packages/shallowEqual.js
.
In your repository, you should now see a new file called shallowEqual.js
under
the data/packages
directory. Next post will cover calling tRPC from the
frontend.
Testing with Vitest
Vitest allow us to mock entire imports, so we can mock our github service as follows:
vi.mock("../rpc-server/services/github.ts", () => {return {default: {getRepoContents: (input: string) =>new Promise((res) =>res({json: () =>new Promise((res) =>res({path: "data/packages/shared/shallowEqual.js",}),),}),),getCommits: vi.fn(),},};});
A test would look something like this:
describe("trpc router", () => {it("should call the Github API", async () => {const ctx = await createContext({prisma: prismaMock,githubService,});const caller = appRouter.createCaller(ctx);const input: inferProcedureInput<AppRouter["getFile"]> = {owner: "facebook",repo: "react",path: "data/packages/shared/shallowEqual.js",};const result = await caller.getFile(input);expect(result).toMatchObject({path: "data/packages/shared/shallowEqual.js",});});});
Note that tRPC provides for us the inferProcecudeInput
type, which will allow
us to infer the input type of a procedure. This is useful for mocking.