Skip to content

Commit

Permalink
feat(web): rsc support
Browse files Browse the repository at this point in the history
  • Loading branch information
mrevanzak committed Dec 22, 2023
1 parent 11b9d52 commit 84f5605
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 41 deletions.
8 changes: 4 additions & 4 deletions apps/nextjs/src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { NextRequest } from "next/server";
import { getAuth } from "@clerk/nextjs/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";

import { appRouter, createInnerTRPCContext } from "@vivat/api";
import type { NextRequest } from "next/server";
import { appRouter, createTRPCContext } from "@vivat/api";

export const runtime = "edge";

Expand All @@ -26,12 +26,12 @@ export function OPTIONS() {
}

const handler = async (req: NextRequest) => {
const auth = getAuth(req);
const response = await fetchRequestHandler({
endpoint: "/api/trpc",
router: appRouter,
req,
createContext: () => createInnerTRPCContext({ auth }),
createContext: () =>
createTRPCContext({ auth: getAuth(req), headers: req.headers }),
onError({ error, path }) {
console.error(`>>> tRPC Error on '${path}'`, error);
},
Expand Down
6 changes: 5 additions & 1 deletion apps/nextjs/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Inter } from "next/font/google";

import "@/styles/globals.css";

import { cache } from "react";
import { headers } from "next/headers";

import { TRPCReactProvider } from "./providers";
Expand Down Expand Up @@ -30,11 +31,14 @@ export const metadata: Metadata = {
},
};

// Lazy load headers
const getHeaders = cache(() => Promise.resolve(headers()));

export default function RootLayout(props: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={["font-sans", fontSans.variable].join(" ")}>
<TRPCReactProvider headers={headers()}>
<TRPCReactProvider headersPromise={getHeaders()}>
{props.children}
</TRPCReactProvider>
</body>
Expand Down
8 changes: 4 additions & 4 deletions apps/nextjs/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState } from "react";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Toaster } from "@/components/ui/toaster";
import { env } from "@/env.mjs";
import { api } from "@/utils/api";
import { api } from "@/lib/api";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
Expand All @@ -24,7 +24,7 @@ const getBaseUrl = () => {

export function TRPCReactProvider(props: {
children: React.ReactNode;
headers?: Headers;
headersPromise: Promise<Headers>;
}) {
const [queryClient] = useState(
() =>
Expand All @@ -48,8 +48,8 @@ export function TRPCReactProvider(props: {
}),
unstable_httpBatchStreamLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Map(props.headers);
async headers() {
const headers = new Map(await props.headersPromise);
headers.set("x-trpc-source", "nextjs-react");
return Object.fromEntries(headers);
},
Expand Down
2 changes: 1 addition & 1 deletion apps/nextjs/src/components/categories/CategoryForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { api } from "@/utils/api";
import { api } from "@/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import type { z } from "zod";
Expand Down
2 changes: 1 addition & 1 deletion apps/nextjs/src/components/categories/CategoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { api } from "@/utils/api";
import { api } from "@/lib/api";

import type { services } from "@vivat/api";

Expand Down
File renamed without changes.
71 changes: 71 additions & 0 deletions apps/nextjs/src/lib/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import "server-only";

import { cache } from "react";
import { cookies, headers } from "next/headers";
import { NextRequest } from "next/server";
import { getAuth } from "@clerk/nextjs/server";
import {
createTRPCProxyClient,
loggerLink,
TRPCClientError,
} from "@trpc/client";
import { callProcedure } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import type { TRPCErrorResponse } from "@trpc/server/rpc";
import SuperJSON from "superjson";

import type { AppRouter } from "@vivat/api";
import { appRouter, createTRPCContext } from "@vivat/api";

/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
// eslint-disable-next-line @typescript-eslint/require-await
const createContext = cache(async () => {
return createTRPCContext({
headers: new Headers({
cookie: cookies().toString(),
"x-trpc-source": "rsc",
}),
auth: getAuth(
new NextRequest("https://notused.com", { headers: headers() }),
),
});
});

export const api = createTRPCProxyClient<AppRouter>({
transformer: SuperJSON,
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
/**
* Custom RSC link that lets us invoke procedures without using http requests. Since Server
* Components always run on the server, we can just call the procedure as a function.
*/
() =>
({ op }) =>
observable((observer) => {
createContext()
.then((ctx) => {
return callProcedure({
procedures: appRouter._def.procedures,
path: op.path,
getRawInput: () => Promise.resolve(op.input),
ctx,
type: op.type,
});
})
.then((data) => {
observer.next({ result: { data } });
observer.complete();
})
.catch((cause: TRPCErrorResponse) => {
observer.error(TRPCClientError.from(cause));
});
}),
],
});
49 changes: 19 additions & 30 deletions packages/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import type {
SignedInAuthObject,
SignedOutAuthObject,
} from "@clerk/nextjs/server";
import { getAuth } from "@clerk/nextjs/server";
import type { inferAsyncReturnType } from "@trpc/server";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import superjson from "superjson";
import { ZodError } from "zod";

Expand All @@ -30,41 +28,18 @@ import { db } from "@vivat/db";
*/
interface CreateContextOptions {
auth: SignedInAuthObject | SignedOutAuthObject;
headers: Headers;
}

/**
* This helper generates the "internals" for a tRPC context. If you need to use
* it, you can export it from here
*
* Examples of things you may need it for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
*/
// eslint-disable-next-line @typescript-eslint/require-await
export const createInnerTRPCContext = async ({ auth }: CreateContextOptions) => {
export const createTRPCContext = ({ auth, headers }: CreateContextOptions) => {
const source = headers.get("x-trpc-source") ?? "unknown";

console.log(">>> tRPC Request from", source, "by", auth?.userId);
return {
auth,
db,
};
};

/**
* This is the actual context you'll use in your router. It will be used to
* process every request that goes through your tRPC endpoint
* @link https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const auth = getAuth(opts.req);
const source = opts.req?.headers["x-trpc-source"] ?? "unknown";

console.log(">>> tRPC Request from", source, "by", auth?.user);

return await createInnerTRPCContext({
auth,
});
};

/**
* 2. INITIALIZATION
*
Expand Down Expand Up @@ -123,6 +98,19 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
},
});
});
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.auth?.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.auth.sessionClaims?.role !== "admin") {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not an admin" });
}
return next({
ctx: {
auth: ctx.auth,
},
});
});

/**
* Protected (authed) procedure
Expand All @@ -134,3 +122,4 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);

0 comments on commit 84f5605

Please sign in to comment.