feat: auth works!

This commit is contained in:
Derock 2023-11-05 12:56:57 -05:00
parent 45ecac8a48
commit 66796503a5
No known key found for this signature in database
28 changed files with 1151 additions and 949 deletions

View file

@ -4,62 +4,61 @@
"private": true,
"scripts": {
"build": "next build",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:push": "drizzle-kit push:sqlite",
"dev": "next dev",
"postinstall": "prisma generate",
"lint": "next lint",
"start": "next start"
},
"dependencies": {
"@mantine/form": "^7.1.7",
"@prisma/migrate": "^5.4.1",
"@prisma/migrate": "^5.5.2",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@t3-oss/env-nextjs": "^0.6.0",
"@tanstack/react-query": "^4.32.6",
"@trpc/client": "^10.37.1",
"@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.37.1",
"@trpc/server": "^10.37.1",
"argon2": "^0.31.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.43.1",
"@trpc/next": "^10.43.1",
"@trpc/react-query": "^10.43.1",
"@trpc/server": "^10.43.1",
"argon2": "^0.31.2",
"better-sqlite3": "^9.0.0",
"bunyan": "^1.8.15",
"bunyan-format": "^0.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"drizzle-orm": "^0.28.6",
"next": "^13.4.19",
"next-auth": "^4.23.2",
"next": "^14.0.1",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"superjson": "^1.13.1",
"react-icons": "^4.11.0",
"sonner": "^1.2.0",
"superjson": "^2.2.0",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"ts-permissions": "^1.0.0",
"zod": "^3.21.4"
"zod": "^3.22.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.6",
"@types/bunyan": "^1.8.9",
"@types/bunyan-format": "^0.2.6",
"@types/eslint": "^8.44.2",
"@types/node": "^18.16.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"autoprefixer": "^10.4.14",
"@types/bunyan": "^1.8.10",
"@types/bunyan-format": "^0.2.7",
"@types/eslint": "^8.44.6",
"@types/node": "^20.8.10",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"autoprefixer": "^10.4.16",
"drizzle-kit": "^0.19.13",
"eslint": "^8.47.0",
"eslint-config-next": "^13.4.19",
"postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.5.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
"eslint": "^8.53.0",
"eslint-config-next": "^14.0.1",
"postcss": "^8.4.31",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2"
},
"ct3aMetadata": {
"initVersion": "7.20.3-beta.2e8399f"

File diff suppressed because it is too large Load diff

View file

@ -12,11 +12,32 @@ import {
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { useForm } from "@mantine/form";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
const [toastLoading, setToastLoading] = useState<
string | number | undefined
>();
const login = api.auth.login.useMutation({
onSuccess: () => {
toast.success("Successfully logged in!", { id: toastLoading });
router.push("/dashboard");
},
onError: (error) => {
console.error(error);
toast.error(error.message, { id: toastLoading });
},
});
const form = useForm({
initialValues: {
email: "",
username: "",
password: "",
},
});
@ -24,35 +45,60 @@ export default function LoginForm() {
return (
<Card className="mx-auto w-80">
<CardHeader>
<CardTitle>Hostforge</CardTitle>
<CardTitle>🔥 Hostforge</CardTitle>
<CardDescription>Log into your account to start.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{/* basic login */}
<div className="flex flex-col gap-2">
<Label htmlFor="email">Username</Label>
<Input id="email" type="email" placeholder="jsmith" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" />
</div>
<CardContent>
<form
className="flex flex-col gap-4"
id="loginform"
onSubmit={(e) => {
e.preventDefault();
login.mutate(form.values);
setToastLoading(toast.loading("Logging you in..."));
}}
>
{/* basic login */}
<div className="flex flex-col gap-2">
<Label htmlFor="email">Username</Label>
<Input
id="username"
type="text"
placeholder="jsmith"
{...form.getInputProps("username")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...form.getInputProps("password")}
/>
</div>
</form>
{/* or */}
<div className="flex items-center gap-2">
{/* <div className="flex items-center gap-2">
<hr className="flex-grow border-gray-300" />
<span className="text-gray-500">or</span>
<hr className="flex-grow border-gray-300" />
</div>
</div> */}
{/* social login */}
<Button variant="outline">
{/* TODO: icons */}
GitHub
</Button>
{/* <Button variant="outline"> */}
{/* TODO: icons */}
{/* GitHub
</Button> */}
</CardContent>
<CardFooter>
<Button className="w-full">Log In</Button>
<Button
className="w-full"
type="submit"
form="loginform"
isLoading={login.isLoading}
>
Log In
</Button>
</CardFooter>
</Card>
);

View file

@ -1,43 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api } from "~/trpc/react";
export function CreatePost() {
const router = useRouter();
const [name, setName] = useState("");
const createPost = api.post.create.useMutation({
onSuccess: () => {
router.refresh();
setName("");
},
});
return (
<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 px-4 py-2 text-black"
/>
<button
type="submit"
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
disabled={createPost.isLoading}
>
{createPost.isLoading ? "Submitting..." : "Submit"}
</button>
</form>
);
}

View file

@ -10,12 +10,12 @@ const handler = (req: NextRequest) =>
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ req }),
createContext: ({ resHeaders }) => createTRPCContext({ req, resHeaders }),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,

11
src/app/dashboard/RSC.tsx Normal file
View file

@ -0,0 +1,11 @@
import { api } from "~/trpc/server";
export default async function Test() {
const user = await api.auth.me.query();
return (
<div>
Logged in as {user.username} ({user.id})
</div>
);
}

View file

@ -0,0 +1,11 @@
import { api } from "~/trpc/server";
import Test from "./RSC";
export default async function DashboardHome() {
return (
<div>
rip
<Test />
</div>
);
}

View file

@ -1,9 +1,10 @@
import "~/styles/globals.css";
import { Inter } from "next/font/google";
import { headers } from "next/headers";
import { cookies } from "next/headers";
import { TRPCReactProvider } from "~/trpc/react";
import { ThemeProvider } from "~/components/ThemeProvider";
import { ThemeProvider } from "~/components/contexts/ThemeProvider";
import { ToastProvider } from "~/components/contexts/ToastProvider";
const inter = Inter({
subsets: ["latin"],
@ -24,9 +25,9 @@ export default function RootLayout({
return (
<html lang="en">
<body className={`font-sans ${inter.variable} min-h-screen min-w-full`}>
<TRPCReactProvider headers={headers()}>
<TRPCReactProvider cookies={cookies().toString()}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
<ToastProvider>{children}</ToastProvider>
</ThemeProvider>
</TRPCReactProvider>
</body>

106
src/app/setup/SetupForm.tsx Normal file
View file

@ -0,0 +1,106 @@
"use client";
import { useForm } from "@mantine/form";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Required } from "~/components/ui/required";
import { api } from "~/trpc/react";
export function SetupForm() {
const router = useRouter();
// setup mutation
const [toastLoading, setToastLoading] = useState<
string | number | undefined
>();
const setupInstance = api.setup.useMutation({
onSuccess: () => {
router.push("/dashboard");
toast.success("Successfully setup instance!", { id: toastLoading });
},
onError: (error) => {
toast.error(error.message, { id: toastLoading });
},
});
const form = useForm({
initialValues: {
username: "",
password: "",
},
validate: {
password: (value) => value.length === 0 && "Password is required",
username: (value) => value.length === 0 && "Username is required",
},
});
return (
<Card className="m-auto h-fit max-w-xl">
<CardHeader>
<CardTitle>Setup Hostforge 🚀</CardTitle>
<CardDescription>
Welcome to Hostforge a modern self-hosted platform for deploying and
managing your own applications.
<br /> <br /> To start, please enter your desired username and
password for the administrator account. You can setup two factor
authentication later.
</CardDescription>
</CardHeader>
<CardContent>
<form
id="setupform"
className="flex flex-col gap-4"
onSubmit={form.onSubmit((data) => {
setToastLoading(toast.loading("Setting up instance..."));
setupInstance.mutate(data);
})}
>
<div className="flex flex-col gap-2">
<Label htmlFor="username">
Username
<Required />
</Label>
<Input {...form.getInputProps("username")} id="username" />
{form.errors.username && (
<div className="text-red-500">{form.errors.username}</div>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">
Password
<Required />
</Label>
<Input
{...form.getInputProps("password")}
id="password"
type="password"
/>
{form.errors.password && (
<div className="text-red-500">{form.errors.password}</div>
)}
</div>
</form>
</CardContent>
<CardFooter>
<Button className="w-full" type="submit" form="setupform">
Submit
</Button>
</CardFooter>
</Card>
);
}

View file

@ -1 +1,9 @@
export default function SetupInstance() {}
import { SetupForm } from "./SetupForm";
export default function SetupInstance() {
return (
<div className="min-w-screen flex min-h-screen justify-center p-4 align-middle">
<SetupForm />
</div>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import { useTheme } from "next-themes";
import { type ReactNode } from "react";
import { Toaster } from "sonner";
export function ToastProvider({ children }: { children: ReactNode }) {
const theme = useTheme();
return (
<>
<Toaster
position="bottom-center"
richColors
theme={theme.resolvedTheme === "dark" ? "dark" : "light"}
/>
{children}
</>
);
}

View file

@ -1,11 +1,11 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/utils/utils";
import { CgSpinner } from "react-icons/cg";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@ -38,17 +38,36 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isLoading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
(
{
className,
variant,
size,
asChild = false,
children,
isLoading,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(
buttonVariants({ variant, size, className }),
isLoading && "cursor-not-allowed brightness-75 filter",
)}
ref={ref}
{...props}
/>
disabled={isLoading || props.disabled}
>
{isLoading && <CgSpinner className="mr-2 animate-spin" />}
{children}
</Comp>
);
},
);

View file

@ -2,15 +2,18 @@ import * as React from "react";
import { cn } from "~/utils/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
error?: boolean | string;
};
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
({ className, type, error, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
error && "border-red-500 focus-visible:ring-red-500",
className,
)}
ref={ref}

View file

@ -0,0 +1 @@
export const Required = () => <span className="px-1 text-red-500">*</span>;

View file

@ -7,6 +7,17 @@ export async function register() {
const logger = (await import("./server/utils/logger")).default;
const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
const { db } = await import("./server/db");
const { mkdir, stat } = await import("fs/promises");
const { dirname } = await import("path");
// check if database folder exists
try {
const dir = dirname(env.DATABASE_PATH);
await stat(dir);
} catch (e) {
await mkdir(dirname(env.DATABASE_PATH), { recursive: true });
logger.debug(`Created database folder ${dirname(env.DATABASE_PATH)}`);
}
if (env.NODE_ENV === "production") {
logger.child({ module: "database" }).info("⚙️ Migrating database");

View file

@ -1,5 +1,6 @@
import { postRouter } from "~/server/api/routers/post";
import { createTRPCRouter } from "~/server/api/trpc";
import { setupProcedure } from "./routers/setup";
import { authRouter } from "./routers/auth";
/**
* This is the primary router for your server.
@ -7,7 +8,8 @@ import { createTRPCRouter } from "~/server/api/trpc";
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
post: postRouter,
setup: setupProcedure,
auth: authRouter,
});
// export type definition of API

View file

@ -64,7 +64,7 @@ export const authRouter = createTRPCRouter({
}
const session = await Session.createForUser(user.id, ctx.request);
ctx.request.cookies.set("session", session.data.token);
ctx.headers.set("Set-Cookie", session.getCookieString());
return {
success: true,

View file

@ -1,32 +0,0 @@
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 }) => {
// simulate a slow db call
await new Promise((resolve) => setTimeout(resolve, 1000));
return ctx.db.post.create({
data: {
name: input.name,
},
});
}),
getLatest: publicProcedure.query(({ ctx }) => {
return ctx.db.post.findFirst({
orderBy: { createdAt: "desc" },
});
}),
});

View file

@ -7,12 +7,13 @@
* need to use are documented accordingly near the end.
*/
import { TRPCError, initTRPC } from "@trpc/server";
import { type NextRequest } from "next/server";
import { NextResponse, type NextRequest } from "next/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { db } from "~/server/db";
import { Session } from "../auth/Session";
import { cookies } from "next/headers";
/**
* 1. CONTEXT
@ -52,9 +53,12 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
*
* @see https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: { req: NextRequest }) => {
export const createTRPCContext = async (opts: {
req: NextRequest;
resHeaders: Headers;
}) => {
// resolve session data
const sessionToken = opts.req.cookies.get("session")?.value;
const sessionToken = opts.req.cookies.get("sessionToken")?.value;
const session: Session | null = sessionToken
? await Session.fetchFromTokenAndUpdate(sessionToken, {
@ -64,7 +68,7 @@ export const createTRPCContext = async (opts: { req: NextRequest }) => {
: null;
return createInnerTRPCContext({
headers: opts.req.headers,
headers: opts.resHeaders,
session,
request: opts.req,
});

View file

@ -3,7 +3,7 @@ import { db } from "../db";
import { users, sessions } from "../db/schema";
import { randomBytes } from "crypto";
import assert from "assert";
import { NextRequest } from "next/server";
import { NextRequest, userAgent } from "next/server";
export type SessionUpdateData = Partial<{
ua: string;
@ -11,6 +11,12 @@ export type SessionUpdateData = Partial<{
}>;
export class Session {
/**
* The length of time a session should last.
* currently 30 days.
*/
static readonly EXPIRE_TIME = 1000 * 60 * 60 * 24 * 30;
/**
* Fetch a session from a session token.
* @param token The session cookie
@ -74,7 +80,7 @@ export class Session {
// parse context
const parsedContext =
context instanceof NextRequest
context instanceof Request
? Session.getContextFromRequest(context)
: context;
@ -98,7 +104,7 @@ export class Session {
*/
static getContextFromRequest(request: NextRequest) {
return {
ua: request.headers.get("user-agent") ?? undefined,
ua: userAgent(request).ua ?? undefined,
ip: request.ip,
} satisfies SessionUpdateData;
}
@ -134,4 +140,14 @@ export class Session {
async delete() {
await db.delete(sessions).where(eq(sessions.token, this.data.token));
}
/**
* Returns a cookie string for this session.
*/
getCookieString() {
const expire = new Date(this.data.createdAt + Session.EXPIRE_TIME);
return `sessionToken=${
this.data.token
}; Expires=${expire.toUTCString()}; Path=/; HttpOnly; SameSite=Strict`;
}
}

View file

@ -11,7 +11,12 @@ sqlite.pragma("journal_mode = WAL");
// load uuidv7 extension
// built from https://github.com/craigpastro/sqlite-uuidv7
sqlite.loadExtension(
env.SQLITE_UUIDV7_EXT_PATH ?? join(__dirname, "../../exts/sqlite-uuidv7"),
env.SQLITE_UUIDV7_EXT_PATH ??
join(
// cannot use __dirname since this file will change locations when compiled
process.cwd(),
"./exts/sqlite-uuidv7",
),
);
export const db = drizzle(sqlite);

View file

@ -9,7 +9,7 @@ import {
// util
const uuidv7 = sql`(uuid_generate_v7())`;
const now = sql`CURRENT_TIMESTAMP`;
const now = sql<number>`CURRENT_TIMESTAMP`;
/**
* User table.
@ -45,7 +45,7 @@ export const sessions = sqliteTable("session", {
lastIP: text("last_ip"),
// NOT IN MILLISECONDS!
lastAccessed: integer("last_accessed", { mode: "timestamp" }),
createdAt: integer("created_at").default(now),
createdAt: integer("created_at").default(now).notNull(),
userId: text("id").notNull(),
});

View file

@ -1,7 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
@ -9,63 +9,65 @@
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--background: 22.5 66.67% 2.35%;
--foreground: 20 100% 94.12%;
--primary: 19.5 100% 60.78%;
--primary-foreground: 0 0% 100%;
--card: 22.5 66.67% 2.35%;
--card-foreground: 20 100% 94.12%;
--popover: 22.5 66.67% 2.35%;
--popover-foreground: 20 100% 94.12%;
--secondary: 19.71 64.81% 21.18%;
--secondary-foreground: 0 0% 100%;
--muted: 25.71 11.48% 11.96%;
--muted-foreground: 15 1.61% 51.37%;
--accent: 19.71 64.81% 21.18%;
--accent-foreground: 20 100% 94.12%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 19.5 100% 60.78%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
@ -73,4 +75,4 @@
body {
@apply bg-background text-foreground;
}
}
}

25
src/trpc/auth.ts Normal file
View file

@ -0,0 +1,25 @@
import { TRPCLink } from "@trpc/client";
import { observable } from "@trpc/server/observable";
import { AppRouter } from "~/server/api/root";
import { httpLink } from "@trpc/client/links/httpLink";
type LinkOptions = Parameters<typeof httpLink>[0];
/**
* Simple re-implementation of httpLink, except it only acts as a terminating link for requests to *auth* routes.
* Cannot set cookies in a streaming response, but we don't want to require every request to be a non-streaming batch request, so this is the best option.
*/
export const authLink: (opts: LinkOptions) => TRPCLink<AppRouter> = (opts) => {
const originalLink = httpLink(opts);
return (runtime) =>
({ next, op }) => {
return observable((observer) => {
if (!op.path.startsWith("auth.")) {
return next(op).subscribe(observer);
}
originalLink(runtime)({ next, op }).subscribe(observer);
});
};
};

View file

@ -1,18 +1,41 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import {
HTTPBatchStreamLinkOptions,
loggerLink,
unstable_httpBatchStreamLink,
} from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react";
import { type AppRouter } from "~/server/api/root";
import { getUrl, transformer } from "./shared";
import { authLink } from "./auth";
export const api = createTRPCReact<AppRouter>();
const sharedLinkOptions = (cookies: string) =>
({
url: getUrl(),
headers() {
return {
cookie: cookies,
"x-trpc-source": "react",
};
},
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}) satisfies HTTPBatchStreamLinkOptions;
export function TRPCReactProvider(props: {
children: React.ReactNode;
headers: Headers;
cookies: string;
}) {
const [queryClient] = useState(() => new QueryClient());
@ -25,16 +48,10 @@ export function TRPCReactProvider(props: {
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
unstable_httpBatchStreamLink({
url: getUrl(),
headers() {
const heads = new Map(props.headers);
heads.set("x-trpc-source", "react");
return Object.fromEntries(heads);
},
}),
authLink(sharedLinkOptions(props.cookies)),
unstable_httpBatchStreamLink(sharedLinkOptions(props.cookies)),
],
})
}),
);
return (

View file

@ -3,10 +3,10 @@ import {
loggerLink,
unstable_httpBatchStreamLink,
} from "@trpc/client";
import { headers } from "next/headers";
import { type AppRouter } from "~/server/api/root";
import { getUrl, transformer } from "./shared";
import { cookies } from "next/headers";
export const api = createTRPCProxyClient<AppRouter>({
transformer,
@ -19,9 +19,10 @@ export const api = createTRPCProxyClient<AppRouter>({
unstable_httpBatchStreamLink({
url: getUrl(),
headers() {
const heads = new Map(headers());
heads.set("x-trpc-source", "rsc");
return Object.fromEntries(heads);
return {
cookie: cookies().toString(),
"x-trpc-source": "rsc",
};
},
}),
],