This commit is contained in:
Jet Pham 2025-11-18 00:27:01 -08:00
commit 99ca448d0d
No known key found for this signature in database
52 changed files with 3241 additions and 0 deletions

View file

@ -0,0 +1,108 @@
import Anser, { type AnserJsonEntry } from "anser";
import { escapeCarriageReturn } from "escape-carriage";
import React from "react";
const colorMap: Record<string, string> = {
"ansi-black": "text-[var(--black)]",
"ansi-red": "text-[var(--red)]",
"ansi-green": "text-[var(--green)]",
"ansi-yellow": "text-[var(--brown)]",
"ansi-blue": "text-[var(--blue)]",
"ansi-magenta": "text-[var(--magenta)]",
"ansi-cyan": "text-[var(--cyan)]",
"ansi-white": "text-[var(--light-gray)]",
"ansi-bright-black": "text-[var(--dark-gray)]",
"ansi-bright-red": "text-[var(--light-red)]",
"ansi-bright-green": "text-[var(--light-green)]",
"ansi-bright-yellow": "text-[var(--yellow)]",
"ansi-bright-blue": "text-[var(--light-blue)]",
"ansi-bright-magenta": "text-[var(--light-magenta)]",
"ansi-bright-cyan": "text-[var(--light-cyan)]",
"ansi-bright-white": "text-[var(--white)]",
};
const bgColorMap: Record<string, string> = {
"ansi-black": "bg-transparent",
"ansi-red": "bg-[var(--red)]",
"ansi-green": "bg-[var(--green)]",
"ansi-yellow": "bg-[var(--brown)]",
"ansi-blue": "bg-[var(--blue)]",
"ansi-magenta": "bg-[var(--magenta)]",
"ansi-cyan": "bg-[var(--cyan)]",
"ansi-white": "bg-[var(--light-gray)]",
"ansi-bright-black": "bg-[var(--dark-gray)]",
"ansi-bright-red": "bg-[var(--light-red)]",
"ansi-bright-green": "bg-[var(--light-green)]",
"ansi-bright-yellow": "bg-[var(--yellow)]",
"ansi-bright-blue": "bg-[var(--light-blue)]",
"ansi-bright-magenta": "bg-[var(--light-magenta)]",
"ansi-bright-cyan": "bg-[var(--light-cyan)]",
"ansi-bright-white": "bg-[var(--white)]",
};
const decorationMap: Record<string, string> = {
bold: "font-bold",
dim: "opacity-50",
italic: "italic",
hidden: "invisible",
strikethrough: "line-through",
underline: "underline",
blink: "animate-pulse",
};
function fixBackspace(txt: string): string {
let tmp = txt;
do {
txt = tmp;
tmp = txt.replace(/[^\n]\x08/gm, "");
} while (tmp.length < txt.length);
return txt;
}
function createClass(bundle: AnserJsonEntry): string | null {
const classes = [];
if (bundle.bg && bgColorMap[bundle.bg]) {
classes.push(bgColorMap[bundle.bg]);
}
if (bundle.fg && colorMap[bundle.fg]) {
classes.push(colorMap[bundle.fg]);
}
if (bundle.decoration && decorationMap[bundle.decoration]) {
classes.push(decorationMap[bundle.decoration]);
}
return classes.length ? classes.join(" ") : null;
}
interface Props {
children?: string;
className?: string;
}
export default function Ansi({ className, children = "" }: Props) {
const input = escapeCarriageReturn(fixBackspace(children));
const bundles = Anser.ansiToJson(input, {
json: true,
remove_empty: true,
use_classes: true,
});
return (
<div className="flex justify-center">
<pre className={className ?? ""} style={{ textAlign: "left" }}>
<code>
{bundles.map((bundle, key) => {
const bundleClassName = createClass(bundle);
return (
<span key={key} className={bundleClassName ?? undefined}>
{bundle.content}
</span>
);
})}
</code>
</pre>
</div>
);
}

View file

@ -0,0 +1,67 @@
import { type ReactNode } from "react";
interface BorderedBoxProps {
label?: string;
children: ReactNode;
className?: string;
}
export function BorderedBox({
label,
children,
className = "",
}: BorderedBoxProps) {
// Generate unique ID for this instance to avoid conflicts
const maskId = `borderMask-${Math.random().toString(36).substring(2, 11)}`;
// Calculate SVG mask values - approximate 1ch = 16px for IBM VGA font
const chToPx = 16;
const maskX = 4 + chToPx; // 4px + 1ch
const maskWidth = label ? label.length * chToPx : 0;
return (
<div
className={`relative my-[calc(2ch-2px)] px-[calc(1.5ch-0.5px)] py-[1ch] ${className}`}
>
<div
className="absolute inset-0 border-2 border-white"
style={{
maskImage: label ? `url(#${maskId})` : "none",
WebkitMaskImage: label ? `url(#${maskId})` : "none",
}}
/>
{label && (
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ zIndex: 1 }}
>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<rect
x={maskX}
y="-8"
width={maskWidth}
height="16"
fill="black"
/>
</mask>
</defs>
</svg>
)}
{label && (
<span
className="absolute -top-[1ch] bg-transparent text-white"
style={{ zIndex: 2 }}
>
{label}
</span>
)}
<div className="relative z-10">{children}</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
export function CgolCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const initializedRef = useRef(false);
useEffect(() => {
if (typeof window === "undefined") return;
const initializeWasm = async () => {
try {
const canvas = canvasRef.current;
if (!canvas || initializedRef.current) return;
const cgolModule = await import("cgol");
// Initialize WASM module
const initFunction = cgolModule.default;
if (initFunction && typeof initFunction === "function") {
await initFunction();
}
// Start CGOL
if (typeof cgolModule.start === "function") {
cgolModule.start();
initializedRef.current = true;
}
} catch (error: unknown) {
console.error("Failed to initialize CGOL WebAssembly module:", error);
}
};
const timeoutId = setTimeout(() => {
void initializeWasm();
}, 100);
return () => clearTimeout(timeoutId);
}, []);
return (
<canvas
ref={canvasRef}
id="canvas"
className="fixed top-0 left-0 w-screen h-screen -z-10"
/>
);
}

View file

@ -0,0 +1,44 @@
import { type ReactNode } from "react";
interface FrostedBoxProps {
label?: string;
children: ReactNode;
className?: string;
}
export function FrostedBox({
label,
children,
className = "",
}: FrostedBoxProps) {
return (
<div
className={`relative my-[calc(2ch-2px)] px-[calc(0.5ch-0.5px)] py-[1ch] ${className}`}
>
{/* Extended frosted glass backdrop with mask */}
<div
className="pointer-events-none absolute inset-0 h-[200%] bg-black/60 backdrop-blur-lg"
style={{
maskImage:
"linear-gradient(to bottom, black 0% 50%, transparent 50% 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0% 50%, transparent 50% 100%)",
}}
/>
{/* Border */}
<div className="absolute inset-0 border-2 border-white" />
{/* Content */}
<div className="relative z-10">
{label && (
<span className="absolute -top-[1ch] left-2 bg-transparent text-white">
{label}
</span>
)}
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import React from "react";
import Ansi from "./ansi";
interface HeaderProps {
content: string;
className?: string;
}
export default function Header({ content, className }: HeaderProps) {
return (
<div className={className}>
<Ansi>{content}</Ansi>
</div>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
export function LatestPost() {
const [latestPost] = api.post.getLatest.useSuspenseQuery();
const utils = api.useUtils();
const [name, setName] = useState("");
const createPost = api.post.create.useMutation({
onSuccess: async () => {
await utils.post.invalidate();
setName("");
},
});
return (
<div className="w-full max-w-xs">
{latestPost ? (
<p className="truncate">Your most recent post: {latestPost.name}</p>
) : (
<p>You have no posts yet.</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({ name });
}}
className="flex flex-col gap-2"
>
<input
type="text"
placeholder="Title"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-full bg-white/10 px-4 py-2 text-white"
/>
<button
type="submit"
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
disabled={createPost.isPending}
>
{createPost.isPending ? "Submitting..." : "Submit"}
</button>
</form>
</div>
);
}

View file

@ -0,0 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };

BIN
src/app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
src/app/icon0.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 979 KiB

BIN
src/app/icon1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

26
src/app/layout.tsx Normal file
View file

@ -0,0 +1,26 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { TRPCReactProvider } from "~/trpc/react";
export const metadata: Metadata = {
title: "Jet Pham",
description: "Jet Pham's personal website",
icons: [{ rel: "icon", url: "/favicon.ico" }],
appleWebApp: {
title: "Jet Pham",
},
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
}

22
src/app/manifest.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "Jet Pham",
"short_name": "Jet Pham",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#a80055",
"background_color": "#a80055",
"display": "standalone"
}

89
src/app/page.tsx Normal file
View file

@ -0,0 +1,89 @@
import Link from "next/link";
import Image from "next/image";
import { HydrateClient } from "~/trpc/server";
import { BorderedBox } from "./_components/bordered-box";
import { FrostedBox } from "./_components/frosted-box";
import Header from "./_components/header";
import { CgolCanvas } from "./_components/cgol-canvas";
import FirstName from "~/assets/Jet.txt";
export default async function Home() {
return (
<HydrateClient>
<CgolCanvas />
<main>
<div className="flex flex-col items-center justify-start px-4">
<FrostedBox className="mt-4 w-full max-w-[66.666667%] min-w-fit px-[calc(1.5ch-0.5px)] md:mt-16">
<div className="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
<div className="order-1 flex flex-col items-center md:order-2">
<Header content={FirstName} />
<div className="mt-[3ch]">Software Extremist</div>
</div>
<div className="order-2 flex-shrink-0 px-[1ch] md:order-1">
<div className="md:hidden w-full flex justify-center">
<div className="w-full max-w-[250px] aspect-square overflow-hidden">
<Image
src="/jet.svg"
alt="Jet"
width={250}
height={250}
className="w-full h-full object-cover"
priority
/>
</div>
</div>
<Image
src="/jet.svg"
alt="Jet"
width={175}
height={263}
className="hidden md:block w-[175px] h-[263px]"
priority
/>
</div>
</div>
<BorderedBox label="Skills">
<div>Making crazy stuff</div>
</BorderedBox>
<BorderedBox label="Links">
<ol>
<li>
<Link
href="https://github.com/jetpham"
className="inline-flex items-center"
>
GitHub
</Link>
</li>
<li>
<Link
href="https://linkedin.com/in/jetpham"
className="inline-flex items-center"
>
LinkedIn
</Link>
</li>
<li>
<Link
href="https://bsky.app/profile/jetpham.com"
className="inline-flex items-center"
>
Bluesky
</Link>
</li>
<li>
<Link
href="https://x.com/jetpham5"
className="inline-flex items-center"
>
X
</Link>
</li>
</ol>
</BorderedBox>
</FrostedBox>
</div>
</main>
</HydrateClient>
);
}

8
src/assets/Jet.txt Normal file
View file

@ -0,0 +1,8 @@
 ▄▄ ▄▄
▐▓▀░  ▓▀ ▀
▐▒█▓  ▄▄▓▀▓▄▄ ▄▄▒ ▓▄▄
 ▐░█▓  ▒▀  ░   ░ 
▒▄▓ ▐░ ▓▀▀▀▀▀▀  ░▒▄░
▓█▓  ▓░▄ ░▓█  ▓█▒
▐▄██▀  ▀▀▓▄▓▀▀   ▀▓▓▄


44
src/env.js Normal file
View file

@ -0,0 +1,44 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

10
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
declare module "*.txt" {
const content: string;
export default content;
}
declare module "*.utf8ans" {
const content: string;
export default content;
}

23
src/server/api/root.ts Normal file
View file

@ -0,0 +1,23 @@
import { postRouter } from "~/server/api/routers/post";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
post: postRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
/**
* Create a server-side caller for the tRPC API.
* @example
* const trpc = createCaller(createContext);
* const res = await trpc.post.all();
* ^? Post[]
*/
export const createCaller = createCallerFactory(appRouter);

View file

@ -0,0 +1,31 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
export const postRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
create: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
name: input.name,
},
});
}),
getLatest: publicProcedure.query(async ({ ctx }) => {
const post = await ctx.db.post.findFirst({
orderBy: { createdAt: "desc" },
});
return post ?? null;
}),
});

106
src/server/api/trpc.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1).
* 2. You want to create a new middleware or type of procedure (see Part 3).
*
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { db } from "~/server/db";
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
* wrap this and provides the required context.
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
return {
db,
...opts,
};
};
/**
* 2. INITIALIZATION
*
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* Create a server-side caller.
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const createCallerFactory = t.createCallerFactory;
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these a lot in the
* "/src/server/api/routers" directory.
*/
/**
* This is how you create new routers and sub-routers in your tRPC API.
*
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);

16
src/server/db.ts Normal file
View file

@ -0,0 +1,16 @@
import { env } from "~/env";
import { PrismaClient } from "../../generated/prisma";
const createPrismaClient = () =>
new PrismaClient({
log:
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof createPrismaClient> | undefined;
};
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;

81
src/styles/globals.css Normal file
View file

@ -0,0 +1,81 @@
@import "tailwindcss";
@font-face {
font-family: "IBM VGA";
src: url("/Web437_IBM_VGA_8x16.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
--font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
/* 16-color palette */
--black: #000000;
--blue: #0000AA;
--green: #00AA00;
--cyan: #00AAAA;
--red: #AA0000;
--magenta: #AA00AA;
--brown: #AA5500;
--light-gray: #AAAAAA;
--dark-gray: #555555;
--light-blue: #5555FF;
--light-green: #55FF55;
--light-cyan: #55FFFF;
--light-red: #FF5555;
--light-magenta: #FF55FF;
--yellow: #FFFF55;
--white: #FFFFFF;
}
/* Global BBS-style 80-character width constraint - responsive */
html {
height: 100%;
width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */
padding: 1rem;
margin: 0 auto;
font-family: "IBM VGA", monospace;
font-size: 1.5rem; /* Increased font size to make characters bigger */
white-space: pre;
line-height: 1;
box-sizing: border-box;
overflow: hidden; /* Disable scrolling */
}
/* Apply CGA theme to body */
body {
height: 100%;
background-color: var(--black);
color: var(--white);
margin: 0;
padding: 0;
overflow: hidden; /* Disable scrolling */
}
/* Global focus ring styles for all tabbable elements */
button:focus,
a:focus,
input:focus,
textarea:focus,
select:focus,
[tabindex]:focus,
[contenteditable]:focus {
outline: 2px solid white;
outline-offset: -2px;
border-radius: 0;
}
/* Link styles - blue without underline */
a {
color: var(--light-blue);
text-decoration: none;
}
a:hover {
color: var(--blue);
}

25
src/trpc/query-client.ts Normal file
View file

@ -0,0 +1,25 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});

78
src/trpc/react.tsx Normal file
View file

@ -0,0 +1,78 @@
"use client";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
clientQueryClientSingleton ??= createQueryClient();
return clientQueryClientSingleton;
};
export const api = createTRPCReact<AppRouter>();
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

30
src/trpc/server.ts Normal file
View file

@ -0,0 +1,30 @@
import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";
import { createCaller, type AppRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { createQueryClient } from "./query-client";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient
);