~ views

Using TRPCv11 with Astro


An example of a tRPC router, taken from the source code of this site.

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 Zod
const { 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

  1. Create a new folder called rpc-server in the root of the project.

  2. 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 and res 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.

  3. We can implement both contexts by creating a file named context.ts in the rpc-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 CreateInnerContextOptions
extends 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>>;
  1. 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 1
incrementMetric: 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 data
const 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 data
const 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 API
export 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.

Back to top