diff --git a/package.json b/package.json
index 0451699a..01186555 100755
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"migrate": "prisma migrate dev --schema ./packages/canyon-backend/prisma/schema.prisma",
"preinstall": "node ./scripts/check.js",
"prepare": "husky",
- "test": "pnpm -r do-test"
+ "test": "pnpm -r do-test",
+ "start-canyon-ut": "pnpm -r do-start-canyon-ut"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
diff --git a/packages/canyon-ut/.eslintrc.cjs b/packages/canyon-ut/.eslintrc.cjs
new file mode 100644
index 00000000..a92fb0bc
--- /dev/null
+++ b/packages/canyon-ut/.eslintrc.cjs
@@ -0,0 +1,42 @@
+/** @type {import("eslint").Linter.Config} */
+const config = {
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "project": true
+ },
+ "plugins": [
+ "@typescript-eslint"
+ ],
+ "extends": [
+ "next/core-web-vitals",
+ "plugin:@typescript-eslint/recommended-type-checked",
+ "plugin:@typescript-eslint/stylistic-type-checked"
+ ],
+ "rules": {
+ "@typescript-eslint/array-type": "off",
+ "@typescript-eslint/consistent-type-definitions": "off",
+ "@typescript-eslint/consistent-type-imports": [
+ "warn",
+ {
+ "prefer": "type-imports",
+ "fixStyle": "inline-type-imports"
+ }
+ ],
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ "argsIgnorePattern": "^_"
+ }
+ ],
+ "@typescript-eslint/require-await": "off",
+ "@typescript-eslint/no-misused-promises": [
+ "error",
+ {
+ "checksVoidReturn": {
+ "attributes": false
+ }
+ }
+ ]
+ }
+}
+module.exports = config;
\ No newline at end of file
diff --git a/packages/canyon-ut/.gitignore b/packages/canyon-ut/.gitignore
new file mode 100644
index 00000000..c24a8359
--- /dev/null
+++ b/packages/canyon-ut/.gitignore
@@ -0,0 +1,46 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# database
+/prisma/db.sqlite
+/prisma/db.sqlite-journal
+db.sqlite
+
+# next.js
+/.next/
+/out/
+next-env.d.ts
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
+.env
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+
+# idea files
+.idea
\ No newline at end of file
diff --git a/packages/canyon-ut/README.md b/packages/canyon-ut/README.md
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/canyon-ut/next.config.js b/packages/canyon-ut/next.config.js
new file mode 100644
index 00000000..853e4a06
--- /dev/null
+++ b/packages/canyon-ut/next.config.js
@@ -0,0 +1,21 @@
+/**
+ * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
+ * for Docker builds.
+ */
+
+const dotenv = await import('dotenv');
+dotenv.config({ path: '../../.env' });
+
+await import("./src/env.js");
+
+/** @type {import("next").NextConfig} */
+const config = {
+ typescript: {
+ ignoreBuildErrors: true,
+ },
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+};
+
+export default config;
diff --git a/packages/canyon-ut/package.json b/packages/canyon-ut/package.json
new file mode 100644
index 00000000..6f24d8cc
--- /dev/null
+++ b/packages/canyon-ut/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "canyon-ut",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "do-build": "next build",
+ "dev": "next dev",
+ "postinstall": "prisma generate",
+ "lint": "next lint",
+ "do-start-canyon-ut": "next start --port 8080"
+ },
+ "dependencies": {
+ "@prisma/client": "^5.14.0",
+ "@t3-oss/env-nextjs": "^0.10.1",
+ "@tanstack/react-query": "^5.50.0",
+ "@trpc/client": "^11.0.0-rc.446",
+ "@trpc/react-query": "^11.0.0-rc.446",
+ "@trpc/server": "^11.0.0-rc.446",
+ "dotenv": "^16.4.5",
+ "geist": "^1.3.0",
+ "next": "^14.2.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "server-only": "^0.0.1",
+ "superjson": "^2.2.1",
+ "zod": "^3.23.3"
+ },
+ "devDependencies": {
+ "@types/eslint": "^8.56.10",
+ "@types/node": "^20.14.10",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^8.1.0",
+ "@typescript-eslint/parser": "^8.1.0",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "^14.2.4",
+ "postcss": "^8.4.39",
+ "prettier": "^3.3.2",
+ "prettier-plugin-tailwindcss": "^0.6.5",
+ "prisma": "^5.14.0",
+ "tailwindcss": "^3.4.3",
+ "typescript": "^5.5.3"
+ },
+ "ct3aMetadata": {
+ "initVersion": "7.37.0"
+ },
+ "packageManager": "pnpm@9.9.0"
+}
diff --git a/packages/canyon-ut/postcss.config.cjs b/packages/canyon-ut/postcss.config.cjs
new file mode 100644
index 00000000..4cdb2f43
--- /dev/null
+++ b/packages/canyon-ut/postcss.config.cjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ tailwindcss: {},
+ },
+};
+
+module.exports = config;
diff --git a/packages/canyon-ut/prettier.config.js b/packages/canyon-ut/prettier.config.js
new file mode 100644
index 00000000..b2d59b46
--- /dev/null
+++ b/packages/canyon-ut/prettier.config.js
@@ -0,0 +1,6 @@
+/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
+const config = {
+ plugins: ["prettier-plugin-tailwindcss"],
+};
+
+export default config;
diff --git a/packages/canyon-ut/prisma/schema.prisma b/packages/canyon-ut/prisma/schema.prisma
new file mode 100644
index 00000000..448c81ab
--- /dev/null
+++ b/packages/canyon-ut/prisma/schema.prisma
@@ -0,0 +1,37 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model Coverage {
+ id String @id @default(cuid())
+ sha String @map("sha")
+ branch String
+ projectID String @map("project_id")
+ // 代码覆盖率
+ branchesTotal Int @map("branches_total")
+ branchesCovered Int @map("branches_covered")
+ functionsTotal Int @map("functions_total")
+ functionsCovered Int @map("functions_covered")
+ linesTotal Int @map("lines_total")
+ linesCovered Int @map("lines_covered")
+ statementsTotal Int @map("statements_total")
+ statementsCovered Int @map("statements_covered")
+ newlinesTotal Int @map("newlines_total")
+ newlinesCovered Int @map("newlines_covered")
+ summary String //zstd+pb 必要性,提高概览页面查询速度
+ // 代码覆盖率详情
+ // 覆盖率实体
+ hit String //zstd+pb
+ // 通用
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
+
+ @@map("ut_coverage")
+}
diff --git a/packages/canyon-ut/public/favicon.ico b/packages/canyon-ut/public/favicon.ico
new file mode 100644
index 00000000..60c702aa
Binary files /dev/null and b/packages/canyon-ut/public/favicon.ico differ
diff --git a/packages/canyon-ut/src/app/_components/post.tsx b/packages/canyon-ut/src/app/_components/post.tsx
new file mode 100644
index 00000000..e33fac30
--- /dev/null
+++ b/packages/canyon-ut/src/app/_components/post.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useState } from "react";
+
+import { api } from "~/trpc/react";
+
+export function LatestCoverage() {
+ const [latestCoverage] = api.coverage.getLatest.useSuspenseQuery();
+
+ const utils = api.useUtils();
+ const [name, setName] = useState("");
+ const createCoverage = api.coverage.create.useMutation({
+ onSuccess: async () => {
+ await utils.coverage.invalidate();
+ setName("");
+ },
+ });
+
+ return (
+
+ {latestCoverage ? (
+
Your most recent coverage: {latestCoverage.name}
+ ) : (
+
You have no coverages yet.
+ )}
+
+
+ );
+}
diff --git a/packages/canyon-ut/src/app/api/health/route.ts b/packages/canyon-ut/src/app/api/health/route.ts
new file mode 100644
index 00000000..b59e1ea9
--- /dev/null
+++ b/packages/canyon-ut/src/app/api/health/route.ts
@@ -0,0 +1,8 @@
+import {NextRequest, NextResponse} from "next/server";
+
+const healthCheckHandler = (_req: NextRequest) => {
+
+ return NextResponse.json('success');
+}
+
+export { healthCheckHandler as GET };
diff --git a/packages/canyon-ut/src/app/api/trpc/[trpc]/route.ts b/packages/canyon-ut/src/app/api/trpc/[trpc]/route.ts
new file mode 100644
index 00000000..f194ae57
--- /dev/null
+++ b/packages/canyon-ut/src/app/api/trpc/[trpc]/route.ts
@@ -0,0 +1,41 @@
+import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
+import {type NextRequest, NextResponse} 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 ?? ""}: ${error.message}`
+ );
+ }
+ : undefined,
+ });
+
+const healthCheckHandler = (_req: NextRequest) => {
+
+ return NextResponse.json({
+ ok: true,
+ })
+}
+
+export { healthCheckHandler as GET, handler as POST };
diff --git a/packages/canyon-ut/src/app/layout.tsx b/packages/canyon-ut/src/app/layout.tsx
new file mode 100644
index 00000000..c2215fff
--- /dev/null
+++ b/packages/canyon-ut/src/app/layout.tsx
@@ -0,0 +1,24 @@
+import "~/styles/globals.css";
+
+import { GeistSans } from "geist/font/sans";
+import { type Metadata } from "next";
+
+import { TRPCReactProvider } from "~/trpc/react";
+
+export const metadata: Metadata = {
+ title: "Create T3 App",
+ description: "Generated by create-t3-app",
+ icons: [{ rel: "icon", url: "/favicon.ico" }],
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/canyon-ut/src/app/page.tsx b/packages/canyon-ut/src/app/page.tsx
new file mode 100644
index 00000000..978aca10
--- /dev/null
+++ b/packages/canyon-ut/src/app/page.tsx
@@ -0,0 +1,53 @@
+import Link from "next/link";
+
+import { LatestCoverage } from "~/app/_components/post";
+import { api, HydrateClient } from "~/trpc/server";
+
+export default async function Home() {
+ const hello = await api.coverage.hello({ text: "from tRPC" });
+
+ void api.coverage.getLatest.prefetch();
+
+ return (
+
+
+
+
+ Create T3 App
+
+
+
+
First Steps →
+
+ Just the basics - Everything you need to know to set up your
+ database and authentication.
+
+
+
+
Documentation →
+
+ Learn more about Create T3 App, the libraries it uses, and how
+ to deploy it.
+
+
+
+
+
+ {hello ? hello.greeting : "Loading tRPC query..."}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/canyon-ut/src/env.js b/packages/canyon-ut/src/env.js
new file mode 100644
index 00000000..6ca7f3e9
--- /dev/null
+++ b/packages/canyon-ut/src/env.js
@@ -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,
+});
diff --git a/packages/canyon-ut/src/server/api/root.ts b/packages/canyon-ut/src/server/api/root.ts
new file mode 100644
index 00000000..41b9dc67
--- /dev/null
+++ b/packages/canyon-ut/src/server/api/root.ts
@@ -0,0 +1,23 @@
+import { coverageRouter } from "~/server/api/routers/coverage";
+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({
+ coverage: coverageRouter,
+});
+
+// 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.coverage.all();
+ * ^? coverage[]
+ */
+export const createCaller = createCallerFactory(appRouter);
diff --git a/packages/canyon-ut/src/server/api/routers/coverage.ts b/packages/canyon-ut/src/server/api/routers/coverage.ts
new file mode 100644
index 00000000..0293d7fb
--- /dev/null
+++ b/packages/canyon-ut/src/server/api/routers/coverage.ts
@@ -0,0 +1,71 @@
+import { z } from "zod";
+
+import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
+
+export const coverageRouter = 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.coverage.create({
+ data: {
+ // model Coverage {
+ // id String @id @default(cuid())
+ // sha String @map("sha")
+ // branch String
+ // projectID String @map("project_id")
+ // // 代码覆盖率
+ // branchesTotal Int @map("branches_total")
+ // branchesCovered Int @map("branches_covered")
+ // functionsTotal Int @map("functions_total")
+ // functionsCovered Int @map("functions_covered")
+ // linesTotal Int @map("lines_total")
+ // linesCovered Int @map("lines_covered")
+ // statementsTotal Int @map("statements_total")
+ // statementsCovered Int @map("statements_covered")
+ // newlinesTotal Int @map("newlines_total")
+ // newlinesCovered Int @map("newlines_covered")
+ // summary String //zstd+pb 必要性,提高概览页面查询速度
+ // // 代码覆盖率详情
+ // // 覆盖率实体
+ // hit String //zstd+pb
+ // // 通用
+ // createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
+ //
+ // @@map("ut_coverage")
+ // }
+ // id: "1",
+ sha: "1",
+ branch: "1",
+ projectID: "1",
+ branchesTotal: 1,
+ branchesCovered: 1,
+ functionsTotal: 1,
+ functionsCovered: 1,
+ linesTotal: 1,
+ linesCovered: 1,
+ statementsTotal: 1,
+ statementsCovered: 1,
+ newlinesTotal: 1,
+ newlinesCovered: 1,
+ summary: "1",
+ hit: "1",
+ },
+ });
+ }),
+
+ getLatest: publicProcedure.query(async ({ ctx }) => {
+ const coverage = await ctx.db.coverage.findFirst({
+ orderBy: { createdAt: "desc" },
+ });
+
+ return coverage ?? null;
+ }),
+});
diff --git a/packages/canyon-ut/src/server/api/trpc.ts b/packages/canyon-ut/src/server/api/trpc.ts
new file mode 100644
index 00000000..4e24ba43
--- /dev/null
+++ b/packages/canyon-ut/src/server/api/trpc.ts
@@ -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().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);
diff --git a/packages/canyon-ut/src/server/db.ts b/packages/canyon-ut/src/server/db.ts
new file mode 100644
index 00000000..07dc0271
--- /dev/null
+++ b/packages/canyon-ut/src/server/db.ts
@@ -0,0 +1,17 @@
+import { PrismaClient } from "@prisma/client";
+
+import { env } from "~/env";
+
+const createPrismaClient = () =>
+ new PrismaClient({
+ log:
+ env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
+ });
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: ReturnType | undefined;
+};
+
+export const db = globalForPrisma.prisma ?? createPrismaClient();
+
+if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
diff --git a/packages/canyon-ut/src/styles/globals.css b/packages/canyon-ut/src/styles/globals.css
new file mode 100644
index 00000000..b5c61c95
--- /dev/null
+++ b/packages/canyon-ut/src/styles/globals.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/packages/canyon-ut/src/trpc/query-client.ts b/packages/canyon-ut/src/trpc/query-client.ts
new file mode 100644
index 00000000..bda64397
--- /dev/null
+++ b/packages/canyon-ut/src/trpc/query-client.ts
@@ -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,
+ },
+ },
+ });
diff --git a/packages/canyon-ut/src/trpc/react.tsx b/packages/canyon-ut/src/trpc/react.tsx
new file mode 100644
index 00000000..8c0521a7
--- /dev/null
+++ b/packages/canyon-ut/src/trpc/react.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
+import { loggerLink, unstable_httpBatchStreamLink } 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
+ return (clientQueryClientSingleton ??= createQueryClient());
+};
+
+export const api = createTRPCReact();
+
+/**
+ * Inference helper for inputs.
+ *
+ * @example type HelloInput = RouterInputs['example']['hello']
+ */
+export type RouterInputs = inferRouterInputs;
+
+/**
+ * Inference helper for outputs.
+ *
+ * @example type HelloOutput = RouterOutputs['example']['hello']
+ */
+export type RouterOutputs = inferRouterOutputs;
+
+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),
+ }),
+ unstable_httpBatchStreamLink({
+ transformer: SuperJSON,
+ url: getBaseUrl() + "/api/trpc",
+ headers: () => {
+ const headers = new Headers();
+ headers.set("x-trpc-source", "nextjs-react");
+ return headers;
+ },
+ }),
+ ],
+ })
+ );
+
+ return (
+
+
+ {props.children}
+
+
+ );
+}
+
+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}`;
+}
diff --git a/packages/canyon-ut/src/trpc/server.ts b/packages/canyon-ut/src/trpc/server.ts
new file mode 100644
index 00000000..59300a63
--- /dev/null
+++ b/packages/canyon-ut/src/trpc/server.ts
@@ -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(() => {
+ const heads = new Headers(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(
+ caller,
+ getQueryClient
+);
diff --git a/packages/canyon-ut/tailwind.config.ts b/packages/canyon-ut/tailwind.config.ts
new file mode 100644
index 00000000..5fd44e8f
--- /dev/null
+++ b/packages/canyon-ut/tailwind.config.ts
@@ -0,0 +1,14 @@
+import { type Config } from "tailwindcss";
+import { fontFamily } from "tailwindcss/defaultTheme";
+
+export default {
+ content: ["./src/**/*.tsx"],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ["var(--font-geist-sans)", ...fontFamily.sans],
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/packages/canyon-ut/tsconfig.json b/packages/canyon-ut/tsconfig.json
new file mode 100644
index 00000000..905062de
--- /dev/null
+++ b/packages/canyon-ut/tsconfig.json
@@ -0,0 +1,42 @@
+{
+ "compilerOptions": {
+ /* Base Options: */
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "target": "es2022",
+ "allowJs": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "isolatedModules": true,
+
+ /* Strictness */
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "checkJs": true,
+
+ /* Bundled projects */
+ "lib": ["dom", "dom.iterable", "ES2022"],
+ "noEmit": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "preserve",
+ "plugins": [{ "name": "next" }],
+ "incremental": true,
+
+ /* Path Aliases */
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./src/*"]
+ }
+ },
+ "include": [
+ ".eslintrc.cjs",
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.cjs",
+ "**/*.js",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}