♻️ Clean up trpc code and add global client error handling (#1549)

This commit is contained in:
Luke Vella 2025-02-09 14:16:57 +07:00 committed by GitHub
parent 4e603d737f
commit 5437b91c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 167 additions and 163 deletions

View file

@ -39,7 +39,6 @@
"@tanstack/react-query": "^4.0.0", "@tanstack/react-query": "^4.0.0",
"@tanstack/react-table": "^8.9.1", "@tanstack/react-table": "^8.9.1",
"@trpc/client": "^10.13.0", "@trpc/client": "^10.13.0",
"@trpc/next": "^10.13.0",
"@trpc/react-query": "^10.13.0", "@trpc/react-query": "^10.13.0",
"@trpc/server": "^10.13.0", "@trpc/server": "^10.13.0",
"@upstash/qstash": "^2.7.17", "@upstash/qstash": "^2.7.17",

View file

@ -32,7 +32,6 @@
"emailNotAllowed": "This email is not allowed.", "emailNotAllowed": "This email is not allowed.",
"emailPlaceholder": "jessie.smith@example.com", "emailPlaceholder": "jessie.smith@example.com",
"exportToCsv": "Export to CSV", "exportToCsv": "Export to CSV",
"forgetMe": "Forget me",
"guest": "Guest", "guest": "Guest",
"ifNeedBe": "If need be", "ifNeedBe": "If need be",
"location": "Location", "location": "Location",
@ -199,9 +198,6 @@
"pollStatusFinalized": "Finalized", "pollStatusFinalized": "Finalized",
"share": "Share", "share": "Share",
"noParticipants": "No participants", "noParticipants": "No participants",
"userId": "User ID",
"aboutGuest": "Guest User",
"aboutGuestDescription": "Profile settings are not available for guest users. <0>Sign in</0> to your existing account or <1>create a new account</1> to customize your profile.",
"logoutDescription": "Sign out of your existing session", "logoutDescription": "Sign out of your existing session",
"events": "Events", "events": "Events",
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.", "inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
@ -305,5 +301,7 @@
"registerVerifyDescription": "Check your email for the verification code", "registerVerifyDescription": "Check your email for the verification code",
"loginVerifyTitle": "Finish Logging In", "loginVerifyTitle": "Finish Logging In",
"loginVerifyDescription": "Check your email for the verification code", "loginVerifyDescription": "Check your email for the verification code",
"createAccount": "Create Account" "createAccount": "Create Account",
"tooManyRequests": "Too many requests",
"tooManyRequestsDescription": "Please try again later."
} }

View file

@ -14,8 +14,8 @@ import {
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DuplicateForm } from "@/app/[locale]/poll/[urlId]/duplicate-form"; import { DuplicateForm } from "@/app/[locale]/poll/[urlId]/duplicate-form";
import { trpc } from "@/app/providers";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { trpc } from "@/trpc/client";
const formName = "duplicate-form"; const formName = "duplicate-form";
export function DuplicateDialog({ export function DuplicateDialog({

View file

@ -31,7 +31,8 @@ const handler = (req: NextRequest) => {
return { return {
user, user,
locale, locale,
ip: ipAddress(req) ?? undefined, ip:
process.env.NODE_ENV === "development" ? "127.0.0.1" : ipAddress(req),
} satisfies TRPCContext; } satisfies TRPCContext;
}, },
onError({ error }) { onError({ error }) {

View file

@ -1,51 +1,32 @@
"use client"; "use client";
import { PostHogProvider } from "@rallly/posthog/client"; import { PostHogProvider } from "@rallly/posthog/client";
import { TooltipProvider } from "@rallly/ui/tooltip"; import { TooltipProvider } from "@rallly/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCReact } from "@trpc/react-query";
import { domMax, LazyMotion } from "framer-motion"; import { domMax, LazyMotion } from "framer-motion";
import { useState } from "react";
import { UserProvider } from "@/components/user-provider"; import { UserProvider } from "@/components/user-provider";
import { I18nProvider } from "@/i18n/client"; import { I18nProvider } from "@/i18n/client";
import { trpcConfig } from "@/trpc/client/config"; import { TRPCProvider } from "@/trpc/client/provider";
import type { AppRouter } from "@/trpc/routers";
import { ConnectedDayjsProvider } from "@/utils/dayjs"; import { ConnectedDayjsProvider } from "@/utils/dayjs";
import { PostHogPageView } from "./posthog-page-view"; import { PostHogPageView } from "./posthog-page-view";
export const trpc = createTRPCReact<AppRouter>({
overrides: {
useMutation: {
async onSuccess(opts) {
await opts.originalFn();
await opts.queryClient.invalidateQueries();
},
},
},
});
export function Providers(props: { children: React.ReactNode }) { export function Providers(props: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() => trpc.createClient(trpcConfig));
return ( return (
<LazyMotion features={domMax}> <LazyMotion features={domMax}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<I18nProvider> <I18nProvider>
<TooltipProvider>
<PostHogProvider> <PostHogProvider>
<PostHogPageView /> <PostHogPageView />
<TRPCProvider>
<TooltipProvider>
<UserProvider> <UserProvider>
<ConnectedDayjsProvider> <ConnectedDayjsProvider>
{props.children} {props.children}
</ConnectedDayjsProvider> </ConnectedDayjsProvider>
</UserProvider> </UserProvider>
</PostHogProvider>
</TooltipProvider> </TooltipProvider>
</TRPCProvider>
</PostHogProvider>
</I18nProvider> </I18nProvider>
</QueryClientProvider>
</trpc.Provider>
</LazyMotion> </LazyMotion>
); );
} }

View file

@ -28,13 +28,13 @@ import Link from "next/link";
import * as React from "react"; import * as React from "react";
import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog"; import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog";
import { trpc } from "@/app/providers";
import { PayWallDialog } from "@/components/pay-wall-dialog"; import { PayWallDialog } from "@/components/pay-wall-dialog";
import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog"; import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog";
import { ProFeatureBadge } from "@/components/pro-feature-badge"; import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePlan } from "@/contexts/plan"; import { usePlan } from "@/contexts/plan";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { trpc } from "@/trpc/client";
import { DeletePollDialog } from "./manage-poll/delete-poll-dialog"; import { DeletePollDialog } from "./manage-poll/delete-poll-dialog";
import { useCsvExporter } from "./manage-poll/use-csv-exporter"; import { useCsvExporter } from "./manage-poll/use-csv-exporter";

View file

@ -24,12 +24,12 @@ import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { trpc } from "@/app/providers";
import { DateIconInner } from "@/components/date-icon"; import { DateIconInner } from "@/components/date-icon";
import { useParticipants } from "@/components/participants-provider"; import { useParticipants } from "@/components/participants-provider";
import { ConnectedScoreSummary } from "@/components/poll/score-summary"; import { ConnectedScoreSummary } from "@/components/poll/score-summary";
import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar"; import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { trpc } from "@/trpc/client";
import { useDayjs } from "@/utils/dayjs"; import { useDayjs } from "@/utils/dayjs";
const formSchema = z.object({ const formSchema = z.object({

View file

@ -1,13 +1,9 @@
import { createTRPCNext } from "@trpc/next"; import { createTRPCReact } from "@trpc/react-query";
import { trpcConfig } from "@/trpc/client/config";
import type { AppRouter } from "@/trpc/routers"; import type { AppRouter } from "@/trpc/routers";
export const trpc = createTRPCNext<AppRouter>({ export const trpc = createTRPCReact<AppRouter>({
config() { overrides: {
return trpcConfig;
},
unstable_overrides: {
useMutation: { useMutation: {
async onSuccess(opts) { async onSuccess(opts) {
await opts.originalFn(); await opts.originalFn();

View file

@ -1,51 +0,0 @@
import * as Sentry from "@sentry/browser";
import { MutationCache } from "@tanstack/react-query";
import { type TRPCLink, httpBatchLink, TRPCClientError } from "@trpc/client";
import { observable } from "@trpc/server/observable";
import superjson from "superjson";
import type { AppRouter } from "../routers";
const errorHandlingLink: TRPCLink<AppRouter> = () => {
return ({ next, op }) => {
return observable((observer) => {
const unsubscribe = next(op).subscribe({
next: (result) => observer.next(result),
error: (error) => {
if (
error instanceof TRPCClientError &&
error.data?.code === "UNAUTHORIZED"
) {
window.location.href = "/login";
}
observer.error(error);
},
});
return unsubscribe;
});
};
};
export const trpcConfig = {
links: [
errorHandlingLink,
httpBatchLink({
url: "/api/trpc",
}),
],
transformer: superjson,
queryClientConfig: {
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
staleTime: 1000 * 60,
},
},
mutationCache: new MutationCache({
onError: (error) => {
Sentry.captureException(error);
},
}),
},
};

View file

@ -0,0 +1,79 @@
"use client";
import { usePostHog } from "@rallly/posthog/client";
import { useToast } from "@rallly/ui/hooks/use-toast";
import {
MutationCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { httpBatchLink, TRPCClientError } from "@trpc/client";
import { useState } from "react";
import superjson from "superjson";
import { useTranslation } from "@/i18n/client";
import { trpc } from "../client";
export function TRPCProvider(props: { children: React.ReactNode }) {
const posthog = usePostHog();
const { toast } = useToast();
const { t } = useTranslation();
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
staleTime: 1000 * 60,
},
},
mutationCache: new MutationCache({
onError(error) {
if (error instanceof TRPCClientError) {
posthog.capture("failed api request", {
path: error.data.path,
code: error.data.code,
message: error.message,
});
switch (error.data.code) {
case "UNAUTHORIZED":
window.location.href = "/login";
break;
case "TOO_MANY_REQUESTS":
toast({
title: t("tooManyRequests", {
defaultValue: "Too many requests",
}),
description: t("tooManyRequestsDescription", {
defaultValue: "Please try again later.",
}),
});
break;
default:
console.error(error);
break;
}
}
},
}),
}),
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
}),
],
transformer: superjson,
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
);
}

View file

@ -9,7 +9,7 @@ import { mergeGuestsIntoUser } from "@/auth/merge-user";
import { getEmailClient } from "@/utils/emails"; import { getEmailClient } from "@/utils/emails";
import { createToken, decryptToken } from "@/utils/session"; import { createToken, decryptToken } from "@/utils/session";
import { publicProcedure, rateLimitMiddleware, router } from "../trpc"; import { createRateLimitMiddleware, publicProcedure, router } from "../trpc";
import type { RegistrationTokenPayload } from "../types"; import type { RegistrationTokenPayload } from "../types";
export const auth = router({ export const auth = router({
@ -29,7 +29,7 @@ export const auth = router({
return { isRegistered: count > 0 }; return { isRegistered: count > 0 };
}), }),
requestRegistration: publicProcedure requestRegistration: publicProcedure
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(5, "1 m"))
.input( .input(
z.object({ z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),

View file

@ -12,11 +12,11 @@ import { getEmailClient } from "@/utils/emails";
import { getTimeZoneAbbreviation } from "../../utils/date"; import { getTimeZoneAbbreviation } from "../../utils/date";
import { import {
createRateLimitMiddleware,
possiblyPublicProcedure, possiblyPublicProcedure,
privateProcedure, privateProcedure,
proProcedure, proProcedure,
publicProcedure, publicProcedure,
rateLimitMiddleware,
requireUserMiddleware, requireUserMiddleware,
router, router,
} from "../trpc"; } from "../trpc";
@ -130,7 +130,7 @@ export const polls = router({
// START LEGACY ROUTES // START LEGACY ROUTES
create: possiblyPublicProcedure create: possiblyPublicProcedure
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(20, "1 h"))
.use(requireUserMiddleware) .use(requireUserMiddleware)
.input( .input(
z.object({ z.object({
@ -233,6 +233,7 @@ export const polls = router({
return { id: poll.id }; return { id: poll.id };
}), }),
update: possiblyPublicProcedure update: possiblyPublicProcedure
.use(createRateLimitMiddleware(60, "1 h"))
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),
@ -305,6 +306,7 @@ export const polls = router({
}); });
}), }),
delete: possiblyPublicProcedure delete: possiblyPublicProcedure
.use(createRateLimitMiddleware(30, "1 h"))
.input( .input(
z.object({ z.object({
urlId: z.string(), urlId: z.string(),

View file

@ -6,8 +6,8 @@ import { getEmailClient } from "@/utils/emails";
import { createToken } from "@/utils/session"; import { createToken } from "@/utils/session";
import { import {
createRateLimitMiddleware,
publicProcedure, publicProcedure,
rateLimitMiddleware,
requireUserMiddleware, requireUserMiddleware,
router, router,
} from "../../trpc"; } from "../../trpc";
@ -72,7 +72,7 @@ export const comments = router({
}); });
}), }),
add: publicProcedure add: publicProcedure
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(5, "1 m"))
.use(requireUserMiddleware) .use(requireUserMiddleware)
.input( .input(
z.object({ z.object({

View file

@ -9,8 +9,8 @@ import { getEmailClient } from "@/utils/emails";
import { createToken } from "@/utils/session"; import { createToken } from "@/utils/session";
import { import {
createRateLimitMiddleware,
publicProcedure, publicProcedure,
rateLimitMiddleware,
requireUserMiddleware, requireUserMiddleware,
router, router,
} from "../../trpc"; } from "../../trpc";
@ -105,6 +105,7 @@ export const participants = router({
return participants; return participants;
}), }),
delete: publicProcedure delete: publicProcedure
.use(createRateLimitMiddleware(20, "1 m"))
.input( .input(
z.object({ z.object({
participantId: z.string(), participantId: z.string(),
@ -122,7 +123,7 @@ export const participants = router({
}); });
}), }),
add: publicProcedure add: publicProcedure
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(20, "1 m"))
.use(requireUserMiddleware) .use(requireUserMiddleware)
.input( .input(
z.object({ z.object({
@ -217,6 +218,7 @@ export const participants = router({
return participant; return participant;
}), }),
rename: publicProcedure rename: publicProcedure
.use(createRateLimitMiddleware(20, "1 m"))
.input(z.object({ participantId: z.string(), newName: z.string() })) .input(z.object({ participantId: z.string(), newName: z.string() }))
.mutation(async ({ input: { participantId, newName } }) => { .mutation(async ({ input: { participantId, newName } }) => {
await prisma.participant.update({ await prisma.participant.update({
@ -230,6 +232,7 @@ export const participants = router({
}); });
}), }),
update: publicProcedure update: publicProcedure
.use(createRateLimitMiddleware(20, "1 m"))
.input( .input(
z.object({ z.object({
pollId: z.string(), pollId: z.string(),

View file

@ -12,9 +12,9 @@ import { createToken } from "@/utils/session";
import { getSubscriptionStatus } from "@/utils/subscription"; import { getSubscriptionStatus } from "@/utils/subscription";
import { import {
createRateLimitMiddleware,
privateProcedure, privateProcedure,
publicProcedure, publicProcedure,
rateLimitMiddleware,
router, router,
} from "../trpc"; } from "../trpc";
@ -53,7 +53,9 @@ export const user = router({
}, },
}); });
}), }),
delete: privateProcedure.mutation(async ({ ctx }) => { delete: privateProcedure
.use(createRateLimitMiddleware(5, "1 h"))
.mutation(async ({ ctx }) => {
if (ctx.user.isGuest) { if (ctx.user.isGuest) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@ -80,6 +82,7 @@ export const user = router({
}, },
), ),
changeName: privateProcedure changeName: privateProcedure
.use(createRateLimitMiddleware(20, "1 h"))
.input( .input(
z.object({ z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
@ -96,6 +99,7 @@ export const user = router({
}); });
}), }),
updatePreferences: privateProcedure updatePreferences: privateProcedure
.use(createRateLimitMiddleware(30, "1 h"))
.input( .input(
z.object({ z.object({
locale: z.string().optional(), locale: z.string().optional(),
@ -122,7 +126,7 @@ export const user = router({
return { success: true }; return { success: true };
}), }),
requestEmailChange: privateProcedure requestEmailChange: privateProcedure
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(10, "1 h"))
.input(z.object({ email: z.string().email() })) .input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const currentUser = await prisma.user.findUnique({ const currentUser = await prisma.user.findUnique({
@ -174,7 +178,7 @@ export const user = router({
return { success: true as const }; return { success: true as const };
}), }),
getAvatarUploadUrl: privateProcedure getAvatarUploadUrl: privateProcedure
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(20, "1 h"))
.input( .input(
z.object({ z.object({
fileType: z.enum(["image/jpeg", "image/png"]), fileType: z.enum(["image/jpeg", "image/png"]),
@ -220,7 +224,7 @@ export const user = router({
}), }),
updateAvatar: privateProcedure updateAvatar: privateProcedure
.input(z.object({ imageKey: z.string().max(255) })) .input(z.object({ imageKey: z.string().max(255) }))
.use(rateLimitMiddleware) .use(createRateLimitMiddleware(10, "1 h"))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.user.id; const userId = ctx.user.id;
const oldImageKey = ctx.user.image; const oldImageKey = ctx.user.image;

View file

@ -89,22 +89,25 @@ export const proProcedure = privateProcedure.use(async ({ ctx, next }) => {
return next(); return next();
}); });
export const rateLimitMiddleware = middleware(async ({ ctx, next }) => { export const createRateLimitMiddleware = (
requests: number,
duration: "1 m" | "1 h",
) => {
return middleware(async ({ ctx, next }) => {
if (!process.env.KV_REST_API_URL) { if (!process.env.KV_REST_API_URL) {
return next(); return next();
} }
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(5, "1 m"),
});
if (!ctx.ip) { if (!ctx.ip) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to get client IP", message: "Failed to get client IP",
}); });
} }
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(requests, duration),
});
const res = await ratelimit.limit(ctx.ip); const res = await ratelimit.limit(ctx.ip);
@ -117,5 +120,6 @@ export const rateLimitMiddleware = middleware(async ({ ctx, next }) => {
return next(); return next();
}); });
};
export const mergeRouters = t.mergeRouters; export const mergeRouters = t.mergeRouters;

View file

@ -6052,13 +6052,6 @@
resolved "https://registry.npmjs.org/@trpc/client/-/client-10.41.0.tgz" resolved "https://registry.npmjs.org/@trpc/client/-/client-10.41.0.tgz"
integrity sha512-W4lYULb7//2yXkULCKim49slXsBwiBq48rfge1yOWXdq0Ed8VxzXvZt8+uWOkxmHbQAw4lq8G5fCNYFB+Za6vQ== integrity sha512-W4lYULb7//2yXkULCKim49slXsBwiBq48rfge1yOWXdq0Ed8VxzXvZt8+uWOkxmHbQAw4lq8G5fCNYFB+Za6vQ==
"@trpc/next@^10.13.0":
version "10.41.0"
resolved "https://registry.npmjs.org/@trpc/next/-/next-10.41.0.tgz"
integrity sha512-QwvZrvDjRFEzErmLZ4hMdYfX13nsH0SpijjuTNPIlSIyFISCIfDCqmBvWC07O6fCG/swh+XM19FhJN6RMqTlKQ==
dependencies:
react-ssr-prepass "^1.5.0"
"@trpc/react-query@^10.13.0": "@trpc/react-query@^10.13.0":
version "10.41.0" version "10.41.0"
resolved "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.41.0.tgz" resolved "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.41.0.tgz"
@ -12511,11 +12504,6 @@ react-remove-scroll@^2.5.6:
use-callback-ref "^1.3.0" use-callback-ref "^1.3.0"
use-sidecar "^1.1.2" use-sidecar "^1.1.2"
react-ssr-prepass@^1.5.0:
version "1.5.0"
resolved "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz"
integrity sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==
react-style-singleton@^2.2.1: react-style-singleton@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz" resolved "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz"