From 331c66ee3f1860f8ddf84102ab410b2cbac1fdad Mon Sep 17 00:00:00 2001 From: Mattia Cerutti Date: Wed, 19 Nov 2025 19:33:28 +0100 Subject: [PATCH 1/6] feat: add base trpc --- package-lock.json | 79 ++++++++++++++++++- package.json | 5 ++ src/app/api/snippets/languages/route.ts | 11 --- src/app/api/snippets/random/route.ts | 32 -------- src/app/api/trpc/[trpc]/route.ts | 14 ++++ src/app/game/page.tsx | 53 ++++++++----- src/app/layout.tsx | 5 +- src/core/middleware/rate-limiter.ts | 8 +- .../adapters/snippet-fetch.client.ts | 34 -------- .../services/get-random-snippets.client.ts | 4 +- src/server/trpc/context.ts | 13 +++ src/server/trpc/routers/_app.ts | 8 ++ src/server/trpc/routers/snippet.ts | 27 +++++++ src/server/trpc/trpc.ts | 21 +++++ src/trpc/react.tsx | 35 ++++++++ 15 files changed, 243 insertions(+), 106 deletions(-) delete mode 100644 src/app/api/snippets/languages/route.ts delete mode 100644 src/app/api/snippets/random/route.ts create mode 100644 src/app/api/trpc/[trpc]/route.ts delete mode 100644 src/features/snippets/infrastructure/adapters/snippet-fetch.client.ts create mode 100644 src/server/trpc/context.ts create mode 100644 src/server/trpc/routers/_app.ts create mode 100644 src/server/trpc/routers/snippet.ts create mode 100644 src/server/trpc/trpc.ts create mode 100644 src/trpc/react.tsx diff --git a/package-lock.json b/package-lock.json index 743d4c5..8ba67af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,10 @@ "dependencies": { "@davisvaughan/tree-sitter-r": "^1.2.0", "@prisma/client": "^6.5.0", + "@tanstack/react-query": "^5.90.10", + "@trpc/client": "^11.7.1", + "@trpc/react-query": "^11.7.1", + "@trpc/server": "^11.7.1", "@types/umami-browser": "^2.18.0", "axios": "^1.8.4", "axios-cache-interceptor": "^1.7.0", @@ -38,6 +42,7 @@ "tree-sitter-lua": "2.1.3", "tree-sitter-python": "0.20.0", "tree-sitter-typescript": "0.23.2", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { @@ -2065,6 +2070,77 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", + "integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", + "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.90.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@trpc/client": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.7.1.tgz", + "integrity": "sha512-uOnAjElKI892/U6aQMcBHYs3x7mme3Cvv1F87ytBL56rBvs7+DyK7r43zgaXKf13+GtPEI6ex5xjVUfyDW8XcQ==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "@trpc/server": "11.7.1", + "typescript": ">=5.7.2" + } + }, + "node_modules/@trpc/react-query": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.7.1.tgz", + "integrity": "sha512-dEHDjIqSTzO8nLlCbtiFBMBwhbSkK1QP7aYVo3nP3sYBna0b+iCtrPXdxVPCSopr9/aIqDTEh+dMRZa7yBgjfQ==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@tanstack/react-query": "^5.80.3", + "@trpc/client": "11.7.1", + "@trpc/server": "11.7.1", + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "typescript": ">=5.7.2" + } + }, + "node_modules/@trpc/server": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.7.1.tgz", + "integrity": "sha512-N3U8LNLIP4g9C7LJ/sLkjuPHwqlvE3bnspzC4DEFVdvx2+usbn70P80E3wj5cjOTLhmhRiwJCSXhlB+MHfGeCw==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": ">=5.7.2" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -4258,7 +4334,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8398,7 +8473,6 @@ "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -8804,7 +8878,6 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", - "dev": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index e0bd155..425a5b0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "dependencies": { "@davisvaughan/tree-sitter-r": "^1.2.0", "@prisma/client": "^6.5.0", + "@tanstack/react-query": "^5.90.10", + "@trpc/client": "^11.7.1", + "@trpc/react-query": "^11.7.1", + "@trpc/server": "^11.7.1", "@types/umami-browser": "^2.18.0", "axios": "^1.8.4", "axios-cache-interceptor": "^1.7.0", @@ -46,6 +50,7 @@ "tree-sitter-lua": "2.1.3", "tree-sitter-python": "0.20.0", "tree-sitter-typescript": "0.23.2", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/src/app/api/snippets/languages/route.ts b/src/app/api/snippets/languages/route.ts deleted file mode 100644 index 9fb67c3..0000000 --- a/src/app/api/snippets/languages/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {rateLimit} from "@/core/middleware/rate-limiter"; -import {getLanguages} from "@/features/snippets/infrastructure/repositories/language.repository.server"; -import {NextRequest, NextResponse} from "next/server"; - -export async function GET(request: NextRequest) { - const resRateLimit = rateLimit(request); - if (resRateLimit) return resRateLimit; - - const languages = await getLanguages(); - return NextResponse.json(languages); -} diff --git a/src/app/api/snippets/random/route.ts b/src/app/api/snippets/random/route.ts deleted file mode 100644 index 42ff19b..0000000 --- a/src/app/api/snippets/random/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {rateLimit} from "@/core/middleware/rate-limiter"; -import {doesLanguageExist} from "@/features/snippets/infrastructure/repositories/language.repository.server"; -import {getRandomSnippets} from "@/features/snippets/services/get-random-snippets.server"; -import {NextRequest, NextResponse} from "next/server"; - -export async function GET(request: NextRequest) { - const resRateLimit = rateLimit(request); - if (resRateLimit) return resRateLimit; - - const {searchParams} = new URL(request.url); - let languageId = searchParams.get("language"); - - if (!languageId) { - return NextResponse.json({error: "Language not provided"}, {status: 400}); - } - - languageId = languageId.toLowerCase(); - - // Check if language provided exists - const languageExists = await doesLanguageExist(languageId); - if (!languageExists) { - return NextResponse.json({error: "Language not found"}, {status: 404}); - } - - try { - const snippets = await getRandomSnippets(languageId); - return NextResponse.json(snippets); - } catch (error) { - console.error("Error fetching random snippets:", error); - return NextResponse.json({error: "Failed to fetch random snippets"}, {status: 500}); - } -} diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..ef909ec --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,14 @@ +import {fetchRequestHandler} from "@trpc/server/adapters/fetch"; +import {NextRequest} from "next/server"; +import {appRouter} from "@/server/trpc/routers/_app"; +import {createTRPCContext} from "@/server/trpc/context"; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => createTRPCContext({req}), + }); + +export {handler as GET, handler as POST}; diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index e054188..f1fa75b 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -1,8 +1,7 @@ "use client"; import {useEffect, useRef, useState} from "react"; -import {buildClientSnippet, getRandomCodeSnippets} from "@/features/snippets/services/get-random-snippets.client"; -import {fetchLanguages} from "@/features/snippets/infrastructure/adapters/snippet-fetch.client"; +import {buildClientSnippet, buildClientSnippets} from "@/features/snippets/services/get-random-snippets.client"; import useStopwatch from "@/features/game/hooks/useStopwatch"; import {GameStatus} from "@/features/game/types/game-status"; import {REFRESH_BUTTON_MIN_DELAY} from "@/features/game/config/game"; @@ -14,6 +13,7 @@ import type {ILanguage} from "@/shared/types/language"; import type {ISnippet} from "@/shared/types/snippet"; import {AutoClosingMode} from "@/features/settings/types/autoclosing-mode"; import {track} from "@/features/game/logic/track"; +import {api} from "@/trpc/react"; function Home() { const status = useGameStore((state) => state.status); @@ -49,21 +49,33 @@ function Home() { const [isNextButtonLocked, setIsNextButtonLocked] = useState(false); const backgroundFetchPromise = useRef | null>(null); - const [availableLanguages, setAvailableLanguages] = useState<{[key: string]: ILanguage} | null>(null); + const { + data: availableLanguages, + isLoading: isLoadingLanguages, + isError: languagesError, + } = api.snippet.languages.useQuery(undefined, { + staleTime: Infinity, + gcTime: Infinity, + }); + + const {mutateAsync: requestRandomSnippets} = api.snippet.random.useMutation(); + + const fetchClientSnippets = async (languageId: string) => { + const autoClosingEnabled = useSettingsStore.getState().autoClosingMode !== AutoClosingMode.DISABLED; + const rawSnippets = await requestRandomSnippets({languageId}); + return buildClientSnippets(rawSnippets, autoClosingEnabled); + }; const setSnippets = async (selectedLanguage: ILanguage) => { setSelectedLanguage(selectedLanguage); resetStopwatch(); - const autoClosingMode = useSettingsStore.getState().autoClosingMode; - const snippets = await getRandomCodeSnippets(selectedLanguage.id, autoClosingMode !== AutoClosingMode.DISABLED); + const snippets = await fetchClientSnippets(selectedLanguage.id); initialize(selectedLanguage, snippets); }; const backgroundGetSnippets = async () => { if (!language) return; - const autoClosingMode = useSettingsStore.getState().autoClosingMode; - const snippets = await getRandomCodeSnippets(language!.id, autoClosingMode !== AutoClosingMode.DISABLED); - + const snippets = await fetchClientSnippets(language.id); addSnippetsToQueue(snippets); }; @@ -132,18 +144,17 @@ function Home() { }; useEffect(() => { - const initializeGame = async () => { - const languages = await fetchLanguages(); - setAvailableLanguages(languages); + if (!availableLanguages) return; + + const selectedLanguage = useSettingsStore.getState().selectedLanguage; + const langId = selectedLanguage?.id ?? Object.keys(availableLanguages)[0]; - const selectedLanguage = useSettingsStore.getState().selectedLanguage; + if (!langId) return; - const langId = selectedLanguage?.id ?? Object.keys(languages)[0]; + const languageToUse = availableLanguages[langId]; - await setSnippets(languages[langId]); - }; - initializeGame(); - }, [setSnippets]); + setSnippets(languageToUse); + }, [availableLanguages, setSnippets]); const reparseExistingSnippets = async () => { const gameState = useGameStore.getState(); @@ -170,13 +181,17 @@ function Home() { useEffect(() => { reparseExistingSnippets(); - }, [autoClosingMode, reparseExistingSnippets]); + }, [reparseExistingSnippets]); if (status === GameStatus.FINISHED && currentSnippet) { return ; } - if (status === GameStatus.LOADING || !availableLanguages || !currentSnippet || !language) { + if (languagesError) { + return
Failed to load languages.
; + } + + if (status === GameStatus.LOADING || isLoadingLanguages || !availableLanguages || !currentSnippet || !language) { return
Loading...
; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b69d265..8900f48 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import Script from "next/script"; import "./globals.css"; import ThemeProvider from "@/shared/components/theme-provider"; +import {TRPCReactProvider} from "@/trpc/react"; export default function RootLayout({ children, @@ -11,7 +12,9 @@ export default function RootLayout({ {process.env.NODE_ENV === "production" &&