From 5437b91c108bb36ba8b735b9574acd57998596ba Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Sun, 9 Feb 2025 14:16:57 +0700 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Clean=20up=20trpc=20code?= =?UTF-8?q?=20and=20add=20global=20client=20error=20handling=20(#1549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 1 - apps/web/public/locales/en/app.json | 8 +- .../poll/[urlId]/duplicate-dialog.tsx | 2 +- apps/web/src/app/api/trpc/[trpc]/route.ts | 3 +- apps/web/src/app/providers.tsx | 45 +++-------- apps/web/src/components/poll/manage-poll.tsx | 2 +- .../poll/manage-poll/finalize-poll-dialog.tsx | 2 +- apps/web/src/trpc/client.ts | 10 +-- apps/web/src/trpc/client/config.ts | 51 ------------ apps/web/src/trpc/client/provider.tsx | 79 +++++++++++++++++++ apps/web/src/trpc/routers/auth.ts | 4 +- apps/web/src/trpc/routers/polls.ts | 6 +- apps/web/src/trpc/routers/polls/comments.ts | 4 +- .../src/trpc/routers/polls/participants.ts | 7 +- apps/web/src/trpc/routers/user.ts | 38 +++++---- apps/web/src/trpc/trpc.ts | 50 ++++++------ yarn.lock | 12 --- 17 files changed, 164 insertions(+), 160 deletions(-) delete mode 100644 apps/web/src/trpc/client/config.ts create mode 100644 apps/web/src/trpc/client/provider.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 9303eb13782..44afebc1924 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,7 +39,6 @@ "@tanstack/react-query": "^4.0.0", "@tanstack/react-table": "^8.9.1", "@trpc/client": "^10.13.0", - "@trpc/next": "^10.13.0", "@trpc/react-query": "^10.13.0", "@trpc/server": "^10.13.0", "@upstash/qstash": "^2.7.17", diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 39c7aa7e146..f5242bb78ae 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -32,7 +32,6 @@ "emailNotAllowed": "This email is not allowed.", "emailPlaceholder": "jessie.smith@example.com", "exportToCsv": "Export to CSV", - "forgetMe": "Forget me", "guest": "Guest", "ifNeedBe": "If need be", "location": "Location", @@ -199,9 +198,6 @@ "pollStatusFinalized": "Finalized", "share": "Share", "noParticipants": "No participants", - "userId": "User ID", - "aboutGuest": "Guest User", - "aboutGuestDescription": "Profile settings are not available for guest users. <0>Sign in to your existing account or <1>create a new account to customize your profile.", "logoutDescription": "Sign out of your existing session", "events": "Events", "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", "loginVerifyTitle": "Finish Logging In", "loginVerifyDescription": "Check your email for the verification code", - "createAccount": "Create Account" + "createAccount": "Create Account", + "tooManyRequests": "Too many requests", + "tooManyRequestsDescription": "Please try again later." } diff --git a/apps/web/src/app/[locale]/poll/[urlId]/duplicate-dialog.tsx b/apps/web/src/app/[locale]/poll/[urlId]/duplicate-dialog.tsx index 02bfc66fda7..75242809f83 100644 --- a/apps/web/src/app/[locale]/poll/[urlId]/duplicate-dialog.tsx +++ b/apps/web/src/app/[locale]/poll/[urlId]/duplicate-dialog.tsx @@ -14,8 +14,8 @@ import { import { useRouter } from "next/navigation"; import { DuplicateForm } from "@/app/[locale]/poll/[urlId]/duplicate-form"; -import { trpc } from "@/app/providers"; import { Trans } from "@/components/trans"; +import { trpc } from "@/trpc/client"; const formName = "duplicate-form"; export function DuplicateDialog({ diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index b9bc7bf6fdd..297cd437dec 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -31,7 +31,8 @@ const handler = (req: NextRequest) => { return { user, locale, - ip: ipAddress(req) ?? undefined, + ip: + process.env.NODE_ENV === "development" ? "127.0.0.1" : ipAddress(req), } satisfies TRPCContext; }, onError({ error }) { diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index d578c6c4fc5..08cae54a974 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -1,51 +1,32 @@ "use client"; import { PostHogProvider } from "@rallly/posthog/client"; 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 { useState } from "react"; import { UserProvider } from "@/components/user-provider"; import { I18nProvider } from "@/i18n/client"; -import { trpcConfig } from "@/trpc/client/config"; -import type { AppRouter } from "@/trpc/routers"; +import { TRPCProvider } from "@/trpc/client/provider"; import { ConnectedDayjsProvider } from "@/utils/dayjs"; import { PostHogPageView } from "./posthog-page-view"; -export const trpc = createTRPCReact({ - overrides: { - useMutation: { - async onSuccess(opts) { - await opts.originalFn(); - await opts.queryClient.invalidateQueries(); - }, - }, - }, -}); - export function Providers(props: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient()); - const [trpcClient] = useState(() => trpc.createClient(trpcConfig)); return ( - - - + + + + - - - - - {props.children} - - - + + + {props.children} + + - - - + + + ); } diff --git a/apps/web/src/components/poll/manage-poll.tsx b/apps/web/src/components/poll/manage-poll.tsx index 7d919f99c7c..8cfb6e1f925 100644 --- a/apps/web/src/components/poll/manage-poll.tsx +++ b/apps/web/src/components/poll/manage-poll.tsx @@ -28,13 +28,13 @@ import Link from "next/link"; import * as React from "react"; import { DuplicateDialog } from "@/app/[locale]/poll/[urlId]/duplicate-dialog"; -import { trpc } from "@/app/providers"; import { PayWallDialog } from "@/components/pay-wall-dialog"; import { FinalizePollDialog } from "@/components/poll/manage-poll/finalize-poll-dialog"; import { ProFeatureBadge } from "@/components/pro-feature-badge"; import { Trans } from "@/components/trans"; import { usePlan } from "@/contexts/plan"; import { usePoll } from "@/contexts/poll"; +import { trpc } from "@/trpc/client"; import { DeletePollDialog } from "./manage-poll/delete-poll-dialog"; import { useCsvExporter } from "./manage-poll/use-csv-exporter"; diff --git a/apps/web/src/components/poll/manage-poll/finalize-poll-dialog.tsx b/apps/web/src/components/poll/manage-poll/finalize-poll-dialog.tsx index 560809ff924..033ccd47558 100644 --- a/apps/web/src/components/poll/manage-poll/finalize-poll-dialog.tsx +++ b/apps/web/src/components/poll/manage-poll/finalize-poll-dialog.tsx @@ -24,12 +24,12 @@ import React from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { trpc } from "@/app/providers"; import { DateIconInner } from "@/components/date-icon"; import { useParticipants } from "@/components/participants-provider"; import { ConnectedScoreSummary } from "@/components/poll/score-summary"; import { VoteSummaryProgressBar } from "@/components/vote-summary-progress-bar"; import { usePoll } from "@/contexts/poll"; +import { trpc } from "@/trpc/client"; import { useDayjs } from "@/utils/dayjs"; const formSchema = z.object({ diff --git a/apps/web/src/trpc/client.ts b/apps/web/src/trpc/client.ts index dfaaa6c7098..9855fe05c40 100644 --- a/apps/web/src/trpc/client.ts +++ b/apps/web/src/trpc/client.ts @@ -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"; -export const trpc = createTRPCNext({ - config() { - return trpcConfig; - }, - unstable_overrides: { +export const trpc = createTRPCReact({ + overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); diff --git a/apps/web/src/trpc/client/config.ts b/apps/web/src/trpc/client/config.ts deleted file mode 100644 index 932082f8f31..00000000000 --- a/apps/web/src/trpc/client/config.ts +++ /dev/null @@ -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 = () => { - 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); - }, - }), - }, -}; diff --git a/apps/web/src/trpc/client/provider.tsx b/apps/web/src/trpc/client/provider.tsx new file mode 100644 index 00000000000..318e0143b8b --- /dev/null +++ b/apps/web/src/trpc/client/provider.tsx @@ -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 ( + + + {props.children} + + + ); +} diff --git a/apps/web/src/trpc/routers/auth.ts b/apps/web/src/trpc/routers/auth.ts index 0fc6bfbe7ef..549b925a98d 100644 --- a/apps/web/src/trpc/routers/auth.ts +++ b/apps/web/src/trpc/routers/auth.ts @@ -9,7 +9,7 @@ import { mergeGuestsIntoUser } from "@/auth/merge-user"; import { getEmailClient } from "@/utils/emails"; import { createToken, decryptToken } from "@/utils/session"; -import { publicProcedure, rateLimitMiddleware, router } from "../trpc"; +import { createRateLimitMiddleware, publicProcedure, router } from "../trpc"; import type { RegistrationTokenPayload } from "../types"; export const auth = router({ @@ -29,7 +29,7 @@ export const auth = router({ return { isRegistered: count > 0 }; }), requestRegistration: publicProcedure - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(5, "1 m")) .input( z.object({ name: z.string().min(1).max(100), diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts index b634d0c5d15..f28b46f8e94 100644 --- a/apps/web/src/trpc/routers/polls.ts +++ b/apps/web/src/trpc/routers/polls.ts @@ -12,11 +12,11 @@ import { getEmailClient } from "@/utils/emails"; import { getTimeZoneAbbreviation } from "../../utils/date"; import { + createRateLimitMiddleware, possiblyPublicProcedure, privateProcedure, proProcedure, publicProcedure, - rateLimitMiddleware, requireUserMiddleware, router, } from "../trpc"; @@ -130,7 +130,7 @@ export const polls = router({ // START LEGACY ROUTES create: possiblyPublicProcedure - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(20, "1 h")) .use(requireUserMiddleware) .input( z.object({ @@ -233,6 +233,7 @@ export const polls = router({ return { id: poll.id }; }), update: possiblyPublicProcedure + .use(createRateLimitMiddleware(60, "1 h")) .input( z.object({ urlId: z.string(), @@ -305,6 +306,7 @@ export const polls = router({ }); }), delete: possiblyPublicProcedure + .use(createRateLimitMiddleware(30, "1 h")) .input( z.object({ urlId: z.string(), diff --git a/apps/web/src/trpc/routers/polls/comments.ts b/apps/web/src/trpc/routers/polls/comments.ts index af4bc75ed40..b4de8091438 100644 --- a/apps/web/src/trpc/routers/polls/comments.ts +++ b/apps/web/src/trpc/routers/polls/comments.ts @@ -6,8 +6,8 @@ import { getEmailClient } from "@/utils/emails"; import { createToken } from "@/utils/session"; import { + createRateLimitMiddleware, publicProcedure, - rateLimitMiddleware, requireUserMiddleware, router, } from "../../trpc"; @@ -72,7 +72,7 @@ export const comments = router({ }); }), add: publicProcedure - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(5, "1 m")) .use(requireUserMiddleware) .input( z.object({ diff --git a/apps/web/src/trpc/routers/polls/participants.ts b/apps/web/src/trpc/routers/polls/participants.ts index 1ebcb3371e6..49d360c3b52 100644 --- a/apps/web/src/trpc/routers/polls/participants.ts +++ b/apps/web/src/trpc/routers/polls/participants.ts @@ -9,8 +9,8 @@ import { getEmailClient } from "@/utils/emails"; import { createToken } from "@/utils/session"; import { + createRateLimitMiddleware, publicProcedure, - rateLimitMiddleware, requireUserMiddleware, router, } from "../../trpc"; @@ -105,6 +105,7 @@ export const participants = router({ return participants; }), delete: publicProcedure + .use(createRateLimitMiddleware(20, "1 m")) .input( z.object({ participantId: z.string(), @@ -122,7 +123,7 @@ export const participants = router({ }); }), add: publicProcedure - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(20, "1 m")) .use(requireUserMiddleware) .input( z.object({ @@ -217,6 +218,7 @@ export const participants = router({ return participant; }), rename: publicProcedure + .use(createRateLimitMiddleware(20, "1 m")) .input(z.object({ participantId: z.string(), newName: z.string() })) .mutation(async ({ input: { participantId, newName } }) => { await prisma.participant.update({ @@ -230,6 +232,7 @@ export const participants = router({ }); }), update: publicProcedure + .use(createRateLimitMiddleware(20, "1 m")) .input( z.object({ pollId: z.string(), diff --git a/apps/web/src/trpc/routers/user.ts b/apps/web/src/trpc/routers/user.ts index e20974eb026..e2f9654ebea 100644 --- a/apps/web/src/trpc/routers/user.ts +++ b/apps/web/src/trpc/routers/user.ts @@ -12,9 +12,9 @@ import { createToken } from "@/utils/session"; import { getSubscriptionStatus } from "@/utils/subscription"; import { + createRateLimitMiddleware, privateProcedure, publicProcedure, - rateLimitMiddleware, router, } from "../trpc"; @@ -53,20 +53,22 @@ export const user = router({ }, }); }), - delete: privateProcedure.mutation(async ({ ctx }) => { - if (ctx.user.isGuest) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Guest users cannot be deleted", - }); - } + delete: privateProcedure + .use(createRateLimitMiddleware(5, "1 h")) + .mutation(async ({ ctx }) => { + if (ctx.user.isGuest) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Guest users cannot be deleted", + }); + } - await prisma.user.delete({ - where: { - id: ctx.user.id, - }, - }); - }), + await prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + }), subscription: publicProcedure.query( async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => { if (!ctx.user || ctx.user.isGuest) { @@ -80,6 +82,7 @@ export const user = router({ }, ), changeName: privateProcedure + .use(createRateLimitMiddleware(20, "1 h")) .input( z.object({ name: z.string().min(1).max(100), @@ -96,6 +99,7 @@ export const user = router({ }); }), updatePreferences: privateProcedure + .use(createRateLimitMiddleware(30, "1 h")) .input( z.object({ locale: z.string().optional(), @@ -122,7 +126,7 @@ export const user = router({ return { success: true }; }), requestEmailChange: privateProcedure - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(10, "1 h")) .input(z.object({ email: z.string().email() })) .mutation(async ({ input, ctx }) => { const currentUser = await prisma.user.findUnique({ @@ -174,7 +178,7 @@ export const user = router({ return { success: true as const }; }), getAvatarUploadUrl: privateProcedure - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(20, "1 h")) .input( z.object({ fileType: z.enum(["image/jpeg", "image/png"]), @@ -220,7 +224,7 @@ export const user = router({ }), updateAvatar: privateProcedure .input(z.object({ imageKey: z.string().max(255) })) - .use(rateLimitMiddleware) + .use(createRateLimitMiddleware(10, "1 h")) .mutation(async ({ ctx, input }) => { const userId = ctx.user.id; const oldImageKey = ctx.user.image; diff --git a/apps/web/src/trpc/trpc.ts b/apps/web/src/trpc/trpc.ts index 7ad2b4fe8ae..c09f66bc1d1 100644 --- a/apps/web/src/trpc/trpc.ts +++ b/apps/web/src/trpc/trpc.ts @@ -89,33 +89,37 @@ export const proProcedure = privateProcedure.use(async ({ ctx, next }) => { return next(); }); -export const rateLimitMiddleware = middleware(async ({ ctx, next }) => { - if (!process.env.KV_REST_API_URL) { - return next(); - } - - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(5, "1 m"), - }); +export const createRateLimitMiddleware = ( + requests: number, + duration: "1 m" | "1 h", +) => { + return middleware(async ({ ctx, next }) => { + if (!process.env.KV_REST_API_URL) { + return next(); + } - if (!ctx.ip) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to get client IP", + if (!ctx.ip) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + 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); - if (!res.success) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: "Too many requests", - }); - } + if (!res.success) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many requests", + }); + } - return next(); -}); + return next(); + }); +}; export const mergeRouters = t.mergeRouters; diff --git a/yarn.lock b/yarn.lock index a4fe8dd1a4d..df9899948f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6052,13 +6052,6 @@ resolved "https://registry.npmjs.org/@trpc/client/-/client-10.41.0.tgz" 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": version "10.41.0" 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-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: version "2.2.1" resolved "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz"