tRPC, Astro and Cloudflare Workers


I haven’t seen if Astro, tRPC and Cloudflare Workers go well together. I found myself asking: must tRPC be in a separate package/deployment (e.g. packages/ in a monorepo, or separate Cloudflare Worker). I also managed to get Cloudflare bindings to work (e.g. D1, environment variables, R2). I’m pleased to say it’s relatively straight forward - there are a few options.

Update: I last updated this page on 6th April 2023, as I use tRPC in my job, and Astro in my free time.

Background:

If you haven’t heard of Cloudflare Workers, tRPC or Astro, read this. Otherwise, skip to options.

  • Cloudflare Workers: cheap but fast serverless platform that integrates Cloudflare’s other services: SQLite on the edge (D1), S3-alternative with zero egress fees (R2) and WebSockets (durable objects, also used by DriftDB).
  • tRPC lets you call your backend like you call functions. It’s similar to gRPC, but without the code generation headache that comes with it. When using gRPC/protobufs, I’ve found the codegen to be a very complex area:
    • There are multiple code generation tools for each language, with different features and bugs. They’re not consistent. Not all features are implemented for each language.
    • gRPC/protobuf code generation tools seem to be neglected.
    • Code generation can be configured in different ways. This means the generated code is different and incompatible. Switching codegen tools to get their features (e.g. google-protobuf/grpc-web to protobuf-ts, or in Python: from betterproto to grpcio_tools) are major breaking changes.
    • Warning: Should tRPC even be used in Cloudflare Workers? It might have cold-start issues.

Discord chat between 2 users in Cloudflare Discord about tRPC on Cloudflare Workers

  • I don’t have these issues, and another discord user mentioned:

Cold starts definitely play a part but it just sounds like the codes own initialisation

  • Astro is a framework for developing web applications with any Javascript/Typescript framework. You can make static or server-side rendered (SSG) websites. You can write code that executes in the browser and in your backend, in the same file (when using SSG). It’s like NextJS but it supports React, Svelte, Vue, Solid and more, in the same app (and UI component).
---
import Layout from "../layouts/Layout.astro";
import { trpcClient } from "../ts/trpcClient";

// Server-side: Call rest of backend APIs, e.g. createUser
const userId = await trpcClient.createUser.mutate({
	name: "benbutterworth",
	bio: "gardener",
});
const user = await trpcClient.getUserById.query(userId);
// Logged server-side (in cloudflare)
console.log({ user });
---

<Layout title="Welcome to Astro.">
	<main>
    <p>Generated server-side</p>
		<h1>Welcome, {user.name}</h1>
		<h2>User ID: {userId}</h2>
		<h2>User bio: {user.bio}</h2>
		<p>Generated client-side</p>
		<h1 class="user2"></h1>
		<h2 class="user2-id"></h2>
		<h2 class="user2-bio"></h2>
	</main>
	<script>
		// Client-side: Call backend APIs, e.g. createUser
		import { trpcClient } from "../ts/trpcClient";

		const userId = await trpcClient.createUser.mutate({
			name: "Anonymous",
			bio: "Interwebs",
		});
		const user = await trpcClient.getUserById.query(userId)
		// Logged client-side (browser console).
		console.log({ user });

		document.querySelector("h1.user2")!.textContent = `Hello, ${user.name}`;
		document.querySelector("h2.user2-id")!.textContent = userId;
		document.querySelector("h2.user2-bio")!.textContent = user.bio;
	</script>
</Layout>
  • Zapp.run: is a cool online Flutter IDE that makes use of Cloudflare Workers and Astro, and judging by Astro + tRPC v10, perhaps they use tRPC as well.

Options:

  • Simplest option: Avoid tRPC completely
  • Simpler option: Cloudflare Pages
  • Complex option: Cloudflare Pages + Cloudflare Worker

Simplest option: Avoid tRPC completely

In practice, this isn’t really an option.

Astro already supports server-side rendering. You can run code server-side above --- in your components. You can already use your environment variables securely, and connect directly to private APIs/databases. You can already call end-to-end typesafe APIs, because each Astro page can be rendered server side.

However, Astro doesn’t give you a way to call your APIs from the browser in a typesafe way, after the page loads.

Simpler option: Cloudflare Pages

  • Both the backend code (tRPC, database access, R2 access) lives in the same “package” as the frontend.
  • Example
  • Positives:
    • Less configuration and code
    • Simpler deployment. Your server-side code in .astro files run on the same service.
    • Single command to deploy
    • Hot-restart works well. Saving a file leads to the backend rebuilding, and the frontend refreshing. In the example, run pnpm dev to start the dev server.
  • Negatives:
    • Larger bundle size. Your frontend and backend application (or part of it) is in the same cloudflare pages project.
  • Deployed using Cloudflare Pages: wrangler pages publish (internally uses Cloudflare Workers)
  • Separation of concerns: You can call other Cloudflare Workers from your Cloudflare Pages Function using “Cloudflare Pages Functions Service Bindings”. This does not incur network latency because tRPC running on Cloudflare Workers uses the fetch API, which does not go over the network.

Complex option: Cloudflare Pages + Backend

  • This is approach is probably good if you’ve already got a backend. This backend can be Cloudflare Workers running tRPC, Hono, Express, any combination of these, or anything else (e.g. NodeJS). Consider tRPC’s Express.js adapter or Hono adapter (discussion). This allows you to avoid Cloudflare Worker platform for server-side rendering.
  • Positives:
    • Deploy frontend and backend separately. Choose different services for frontend and backend.
    • Separation: the code will not get entangled, because they’re in separate packages. Forces you to be more strict.
    • Cloudflare Workers separate from frontend: Cloudflare Pages (which uses Workers internally)
      • This separation means they are separate workers:
        • more control over worker / features
  • Negatives:
    • More complex maintenance. e.g. You need to setup monorepo tool to build backend code before frontend is built. When deploying a preview environment, you need to deploy the backend. The frontend needs to be pointed to the correct backend. Preview environments are not self-contained.
  • Deployed as 1 Cloudflare Pages deployment, and 1 Cloudflare worker: wrangler publish
  • Separation of concerns: You can call other Cloudflare Workers using Service Bindings. This also does not incur network latency because tRPC running on Cloudflare Workers uses the fetch API, which does not go over the network.

Summary 📒

I’m currently trying tRPC with Astro on Cloudflare Pages, with a single Cloudflare Pages Function. That is to say, I only have 1 worker: a fat worker. Let me know if you have any questions.

Other resources

  • tRPC Pages Plugin: for both options, you can use this plugin for some convenience (to get Cloudflare bindings to D1, R2, etc.). I don’t use it because it doesn’t seem to support Astro.
  • Warning: When calling another worker (service bindings), you need to use the Cloudflare API, which means you lose end-to-end typing / benefit of tRPC. tRPC has server-side calls, however that will not call another worker: it does not allow you to call workers with different implementations. This means you’d only have 1 worker, and it would get large. You lose “separation of concerns” between multiple workers.
    • I initially thought: “Surely there’s a way to get the benefits of both tRPC and Cloudflare Service bindings”.
    • Edit: oh, see tRPC with no latency or network errors. Thanks arjunyel.com - they did this to turn on Node compatibility just for the backend. However, IMHO if you don’t need node compatibility, it doesn’t make sense to do this unless you need to split your code across multiple workers - your worker bundle size might be too big (>1MB on free tier, >5MB on paid tier, or the size is affecting latency/start-up time). Or perhaps you have multiple teams and they want to deploy independently. It might get messy having multiple tRPC backends running and connecting to each other. I think tRPC being a backend framework where you can define all APIs has tension with splitting your backend into multiple workers/bundles. It’s possible, but I’d recommend not doing it until performance or functionality is limited because of it. Also, I got an answer from the blog author on Discord.
createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      // Any URL will work since we are communicating with the worker directly
      url: "https://www.example.com/trpc",
      // Pass in the Service binding after binding it (lol)
      fetch: env.BACKEND.fetch.bind(env.BACKEND),
    }),
  ],
});
  • Warning: Even though Jetbrain’s Webstorm IDE has an Astro plugin, it looks like it doesn’t support tRPC. For example, you cannot navigate to the implementation of a query/mutation (getUserById: t.procedure.input(z.string())...) from the astro file (const user = await client.getUserById.query(userId);).

Example tRPC Backend

pages/trpc/api/[trpc].ts

import { initTRPC } from '@trpc/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { Context, createContext } from '../context';
import { z } from 'zod';
import type { APIRoute } from 'astro';
import { trpcApiPath } from '../trpcPath';
type User = {
    id: string;
    name: string;
    bio?: string;
};

// This has to match quite strongly to the schema. Considering using an ORM?
type UserDb = {
    UserID: string;
    name: string;
    bio?: string;
}

// Inpsired by https://invertase.io/blog/astro-trpc-v10/ and https://trpc.io/docs/fetch
export const t = initTRPC.context<Context>().create();

const appRouter = t.router({
    getUsers: t.procedure.query(async ({ctx}) => {
        try {
            // When running locally, it seems to use the wrong database. My local database has a Users table
            // See `wrangler d1 execute trpc-astro-cloudflare-template --command="SELECT name FROM sqlite_master WHERE type='table'" --local`.
            // However, error is 
            // {
            //     message: 'D1_ERROR',
            //     cause: 'Error: SqliteError: no such table: Users'
            //   }
            const stmt = ctx.db.prepare('SELECT * FROM Users LIMIT 10');
            const { results } = await stmt.all<UserDb>();

            return results?.map((user) => ({id: user.UserID, name: user.name, bio: user.bio}));
        } catch (e: any) {
            console.log({
                message: e.message,
                cause: e.cause.message,
            });
        
        }
    }),
    getUserById: t.procedure.input(z.string()).query(async ({ input, ctx }) => {
        const result = await ctx.db.prepare('SELECT * FROM Users where UserID = ?1').bind(input).first();
        return {id: result.UserID, name: result.name, bio: result.bio};
    }),
    deleteUsers: t.procedure.mutation(async ({ctx}) => {
        await ctx.db.prepare('DELETE FROM Users').run();
    }),
    createUser: t.procedure
        // validate input with Zod
        .input(
            z.object({
                name: z.string().min(3),
                bio: z.string().max(142).optional(),
            }),
        )
        .mutation(async ({ input, ctx }) => {
            const id = Date.now().toString();
            const user: User = { id, ...input };
            await ctx.db.prepare('INSERT INTO Users (UserID, name, bio) VALUES (?1, ?2, ?3);').bind(id, user.name, user.bio).run();
            return id;
        })
});

// The Astro API route, handling all incoming HTTP requests.
export const all: APIRoute = ({ request }) => {    
    return fetchRequestHandler({
        req: request,
        endpoint: trpcApiPath,
        router: appRouter,
        createContext
    });
};

export type AppRouter = typeof appRouter;

src/ts/context.ts

import type { inferAsyncReturnType } from '@trpc/server';
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import type { Env } from './worker-configuration';
import { getRuntime } from "@astrojs/cloudflare/runtime";

export function createContext({
  req,
  resHeaders,
}: FetchCreateContextFnOptions) {
  // Get cloudflare bindings, as per https://docs.astro.build/en/guides/integrations-guide/cloudflare/#access-to-the-cloudflare-runtime
  const runtime = getRuntime<Env>(req);
  // console.log({runtime});
  // Now use a binding, for example: `runtime.env.SERVICE.fetch()`
  // Alternatively, get an environment variable with: `import.meta.env.SERVER_URL`
  // You can read custom or pre-defined environmment variables with e.g. import.meta.env.MODE, .BASE_URL, .CUSTOM_VAR, etc. 

  const user = { name: req.headers.get('username') ?? 'anonymous' };
  const db = runtime.env.DB;
  return { req, resHeaders, user, db };
}
export type Context = inferAsyncReturnType<typeof createContext>;