diff --git a/.env.example b/.env.example index ddd5685..3ebcf08 100644 --- a/.env.example +++ b/.env.example @@ -4,13 +4,9 @@ NEXT_PUBLIC_POSTHOG_KEY= POSTHOG_API_KEY= POSTHOG_ENV_ID= -# Postgres credentials (set by PlanetScale integration) +# Postgres connection string (with credentials) DATABASE_URL= -# Redis credentials (set by Upstash integration) -KV_REST_API_TOKEN= -KV_REST_API_URL= - # Inngest credentials (set by Inngest integration) INNGEST_EVENT_KEY= INNGEST_SIGNING_KEY= @@ -28,8 +24,5 @@ BLOB_READ_WRITE_TOKEN= # Blob key hashing secret (required outside development) BLOB_SIGNING_SECRET= -# Vercel Cron secret for authenticating scheduled tasks -CRON_SECRET= - # Optional: override user agent sent with upstream requests EXTERNAL_USER_AGENT= diff --git a/AGENTS.md b/AGENTS.md index 616c4be..a4fd833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ - `trpc/` tRPC client setup, query client, and error handling. ## Build, Test, and Development Commands -- `pnpm dev` — start all local services (Postgres, Redis, SRH, Inngest) and Next.js dev server at http://localhost:3000 using `concurrently`. +- `pnpm dev` — start all local services (Postgres, Inngest, etc.) and Next.js dev server at http://localhost:3000 using `concurrently`. - `pnpm build` — compile production bundle. - `pnpm start` — serve compiled output for smoke tests. - `pnpm lint` — run Biome lint + type-aware checks (`--write` to fix). @@ -71,17 +71,15 @@ - Keep secrets in `.env.local`. See `.env.example` for required variables. - Vercel Edge Config provides dynamic, low-latency configuration without redeployment: - `domain_suggestions` (array): Homepage domain suggestions; fails gracefully to empty array - - `rate_limits` (object): Service rate limits; **fails open** (no limits) if unavailable for maximum availability - Edge Config is completely optional and gracefully degrades when not configured - Uses Next.js 16 `"use cache"` directive with `@vercel/edge-config` SDK for SSR compatibility - Vercel Blob backs favicon/screenshot storage with automatic public URLs; metadata cached in Postgres. - Screenshots (Puppeteer): prefer `puppeteer-core` + `@sparticuz/chromium` on Vercel. - Persist domain data in Postgres via Drizzle with per-table TTL columns (`expiresAt`). -- **Redis usage**: ONLY for IP-based rate limiting via `@upstash/ratelimit`. All other caching uses Next.js Data Cache (`unstable_cache`) or Postgres. +- All caching uses Next.js Data Cache (`unstable_cache`) or Postgres. - Database connections: Use Vercel's Postgres connection pooling (`@vercel/postgres`) for optimal performance. - Background revalidation: Event-driven via Inngest functions in `lib/inngest/functions/` with built-in concurrency control. - Use Next.js 16 `after()` for fire-and-forget background operations (analytics, domain access tracking) with graceful degradation. -- Cron jobs trigger Inngest events via `app/api/cron/` endpoints secured with `CRON_SECRET`. - Review `trpc/init.ts` when extending procedures to ensure auth/context remain intact. ## Analytics & Observability diff --git a/README.md b/README.md index 851e3b7..9113dcb 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ - **SEO insights**: Extract titles, meta tags, social previews, canonical data, and `robots.txt` signals. - **Screenshots & favicons**: Server-side screenshots and favicon extraction, cached in Postgres with Vercel Blob storage. - **Fast, private, no sign-up**: Live fetches with intelligent multi-layer caching. -- **Reliable data pipeline**: Postgres persistence with per-table TTLs (Drizzle), event-driven background revalidation (Inngest), and Redis for short-lived rate limiting. -- **Dynamic configuration**: Vercel Edge Config for runtime-adjustable rate limits and domain suggestions without redeployment. +- **Reliable data pipeline**: Postgres persistence with per-table TTLs (Drizzle) and event-driven background revalidation (Inngest). +- **Dynamic configuration**: Vercel Edge Config for runtime-adjustable domain suggestions without redeployment. ## 🛠️ Tech Stack @@ -20,8 +20,7 @@ - **tRPC** API - **PlanetScale Postgres** + **Drizzle ORM** with connection pooling - **Inngest** for event-driven background revalidation with built-in concurrency control -- **Upstash Redis** for IP-based rate limiting -- **Vercel Edge Config** for runtime configuration (domain suggestions, service rate limits) +- **Vercel Edge Config** for runtime configuration (domain suggestions) - **Vercel Blob** for favicon/screenshot storage with Postgres metadata caching - [**rdapper**](https://github.com/jakejarvis/rdapper) for RDAP lookups with WHOIS fallback - **Puppeteer** (with `@sparticuz/chromium` on Vercel) for server-side screenshots @@ -66,8 +65,6 @@ pnpm dev This single command boots: - **Postgres** on `localhost:5432` -- **Redis** on `localhost:6379` -- **Serverless Redis HTTP (SRH)** on `http://localhost:8079` (Upstash-compatible REST proxy) - **Inngest dev server** on `http://localhost:8288` - **Next.js dev server** on `http://localhost:3000` diff --git a/app/api/trpc/[trpc]/route.ts b/app/api/trpc/[trpc]/route.ts index c56808a..82c101b 100644 --- a/app/api/trpc/[trpc]/route.ts +++ b/app/api/trpc/[trpc]/route.ts @@ -8,28 +8,6 @@ const handler = (req: Request) => req, router: appRouter, createContext: () => createContext({ req }), - responseMeta: ({ errors }) => { - const err = errors.find((e) => e.code === "TOO_MANY_REQUESTS"); - if (!err) return {}; - - // Prefer formatted data from errorFormatter for consistent headers - const data = ( - err as { - data?: { retryAfter?: number; limit?: number; remaining?: number }; - } - ).data; - const retryAfter = Math.max(1, Math.round(Number(data?.retryAfter ?? 1))); - const headers: Record = { - "Retry-After": String(retryAfter), - "Cache-Control": "no-cache, no-store", - }; - if (typeof data?.limit === "number") - headers["X-RateLimit-Limit"] = String(data.limit); - if (typeof data?.remaining === "number") - headers["X-RateLimit-Remaining"] = String(data.remaining); - - return { headers, status: 429 }; - }, onError: ({ path, error }) => { console.error(`[trpc] unhandled error ${path}`, error); }, diff --git a/docker-compose.yml b/docker-compose.yml index d8a45a9..bb20575 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,25 +16,6 @@ services: timeout: 5s retries: 5 - # Local Redis (binary protocol on 6379) - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - # Serverless Redis HTTP (SRH) — Upstash-compatible REST proxy on 8079 - # Set KV_REST_API_URL to http://localhost:8079 - srh: - image: hiett/serverless-redis-http:latest - environment: - SRH_MODE: "env" - SRH_TOKEN: "dev-token" - SRH_CONNECTION_STRING: "redis://redis:6379" - ports: - - "8079:80" - depends_on: - - redis - # Inngest Dev Server on 8288 # Next.js runs on the HOST at :3000; the dev server reaches it via host.docker.internal inngest: diff --git a/lib/edge-config.ts b/lib/edge-config.ts index af00171..e428c3a 100644 --- a/lib/edge-config.ts +++ b/lib/edge-config.ts @@ -1,55 +1,6 @@ "use cache"; import { get } from "@vercel/edge-config"; -import type { ServiceLimits } from "@/lib/ratelimit"; - -/** - * Fetches rate limits from Vercel Edge Config. - * - * FAILS OPEN: Returns null if Edge Config is not configured or fails, - * completely disabling rate limiting for maximum availability. - * - * Edge Config key: `rate_limits` - * - * Expected schema: - * ```json - * { - * "rate_limits": { - * "dns": { "points": 60, "window": "1 m" }, - * "headers": { "points": 60, "window": "1 m" }, - * "certs": { "points": 30, "window": "1 m" }, - * "registration": { "points": 10, "window": "1 m" }, - * "screenshot": { "points": 30, "window": "1 h" }, - * "favicon": { "points": 100, "window": "1 m" }, - * "seo": { "points": 30, "window": "1 m" }, - * "hosting": { "points": 30, "window": "1 m" }, - * "pricing": { "points": 30, "window": "1 m" } - * } - * } - * ``` - * - * @returns Service rate limits or null if unavailable (fail open) - */ -export async function getRateLimits(): Promise { - // If EDGE_CONFIG is not set, fail open - if (!process.env.EDGE_CONFIG) { - return null; - } - - try { - const limits = await get("rate_limits"); - - // Return limits if they exist, otherwise null (fail open) - return limits ?? null; - } catch (error) { - // Log the error but fail open (no limits enforced) - console.warn( - "[edge-config] failed to fetch rate limits, failing open (no limits)", - error instanceof Error ? error.message : String(error), - ); - return null; - } -} /** * Fetches the default domain suggestions from Vercel Edge Config. diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts deleted file mode 100644 index 49256ca..0000000 --- a/lib/ratelimit.ts +++ /dev/null @@ -1,110 +0,0 @@ -import "server-only"; - -import { TRPCError } from "@trpc/server"; -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { after } from "next/server"; -import { getRateLimits } from "@/lib/edge-config"; - -/** - * Rate limit configuration for each service. - */ -export type RateLimitConfig = { - points: number; - window: `${number} ${"s" | "m" | "h"}`; -}; - -/** - * Service rate limits configuration. - * - * Each service has a points budget and time window for rate limiting. - * Example: { points: 60, window: "1 m" } = 60 requests per minute - */ -export type ServiceLimits = { - dns: RateLimitConfig; - headers: RateLimitConfig; - certs: RateLimitConfig; - registration: RateLimitConfig; - screenshot: RateLimitConfig; - favicon: RateLimitConfig; - seo: RateLimitConfig; - hosting: RateLimitConfig; - pricing: RateLimitConfig; -}; -export type ServiceName = keyof ServiceLimits; - -/** - * Redis client for rate limiting only. - * - * Uses KV_REST_API_URL and KV_REST_API_TOKEN from Vercel KV integration. - * All other caching uses Next.js Data Cache or Postgres. - */ -export const redis = Redis.fromEnv(); - -/** - * Assert that a rate limit is not exceeded for a given service and IP address. - * @param service - The service name - * @param ip - The IP address - * @returns The rate limit result - * @throws TRPCError if the rate limit is exceeded - */ -export async function assertRateLimit( - service: keyof ServiceLimits, - ip: string, -): Promise<{ limit: number; remaining: number; reset: number }> { - const limits = await getRateLimits(); - - // Fail open: if no limits configured or Edge Config fails, skip rate limiting - if (limits === null) { - console.info(`[ratelimit] rate limiting disabled (fail open)`); - return { limit: 0, remaining: 0, reset: 0 }; - } - - const cfg = limits[service]; - if (!cfg) { - console.warn(`[ratelimit] no config for service ${service}, skipping`); - return { limit: 0, remaining: 0, reset: 0 }; - } - - const limiter = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(cfg.points, cfg.window), - analytics: true, - }); - - const res = await limiter.limit(`${service}:${ip}`); - - if (!res.success) { - const retryAfterSec = Math.max( - 1, - Math.ceil((res.reset - Date.now()) / 1000), - ); - - console.warn( - `[ratelimit] blocked ${service} for ${ip} (limit=${res.limit}, retry in ${retryAfterSec}s)`, - ); - - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: `Rate limit exceeded for ${service}. Try again in ${retryAfterSec}s.`, - cause: { - retryAfter: retryAfterSec, - service, - limit: res.limit, - remaining: res.remaining, - reset: res.reset, - }, - }); - } - - // allow ratelimit analytics to be sent in background - try { - after(async () => { - await res.pending; - }); - } catch { - // no-op - } - - return { limit: res.limit, remaining: res.remaining, reset: res.reset }; -} diff --git a/package.json b/package.json index 764af6a..d454f07 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,6 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", - "@upstash/ratelimit": "^2.0.7", - "@upstash/redis": "^1.35.6", "@vercel/analytics": "^1.5.0", "@vercel/blob": "^2.0.0", "@vercel/edge-config": "^1.4.3", @@ -64,7 +62,7 @@ "ipaddr.js": "^2.2.0", "lucide-react": "^0.554.0", "mapbox-gl": "^3.16.0", - "media-chrome": "^4.15.1", + "media-chrome": "^4.16.0", "motion": "^12.23.24", "ms": "3.0.0-canary.202508261828", "next": "16.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b021a9..1801437 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,12 +41,6 @@ importers: '@trpc/tanstack-react-query': specifier: ^11.7.1 version: 11.7.1(@tanstack/react-query@5.90.10(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - '@upstash/ratelimit': - specifier: ^2.0.7 - version: 2.0.7(@upstash/redis@1.35.6) - '@upstash/redis': - specifier: ^1.35.6 - version: 1.35.6 '@vercel/analytics': specifier: ^1.5.0 version: 1.5.0(next@16.0.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) @@ -114,8 +108,8 @@ importers: specifier: ^3.16.0 version: 3.16.0 media-chrome: - specifier: ^4.15.1 - version: 4.15.1(react@19.2.0) + specifier: ^4.16.0 + version: 4.16.0(react@19.2.0) motion: specifier: ^12.23.24 version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -2773,15 +2767,6 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@upstash/core-analytics@0.0.10': - resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} - engines: {node: '>=16.0.0'} - - '@upstash/ratelimit@2.0.7': - resolution: {integrity: sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw==} - peerDependencies: - '@upstash/redis': ^1.34.3 - '@upstash/redis@1.35.6': resolution: {integrity: sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==} @@ -3127,8 +3112,8 @@ packages: canonicalize@1.0.8: resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==} - ce-la-react@0.3.1: - resolution: {integrity: sha512-g0YwpZDPIwTwFumGTzNHcgJA6VhFfFCJkSNdUdC04br2UfU+56JDrJrJva3FZ7MToB4NDHAFBiPE/PZdNl1mQA==} + ce-la-react@0.3.2: + resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==} peerDependencies: react: '>=17.0.0' @@ -4085,8 +4070,8 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - media-chrome@4.15.1: - resolution: {integrity: sha512-Hxqr0qQ67ewmRaLJBqe5ayu53txFX+DODb9xBSHgTbw7j+gITGZ4llbPPEmqMlDnatw7IsF+AUh9rJYbpnn4ZQ==} + media-chrome@4.16.0: + resolution: {integrity: sha512-c5xpTYcYo9nYsC/G/C1PyOcPXEL6iIaSR9MH3GncVuj4S90aHqvGbsyUWFDPPBKx5sCwWLxDnbszE/24eMT54g==} mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} @@ -7720,18 +7705,10 @@ snapshots: '@types/node': 24.10.1 optional: true - '@upstash/core-analytics@0.0.10': - dependencies: - '@upstash/redis': 1.35.6 - - '@upstash/ratelimit@2.0.7(@upstash/redis@1.35.6)': - dependencies: - '@upstash/core-analytics': 0.0.10 - '@upstash/redis': 1.35.6 - '@upstash/redis@1.35.6': dependencies: uncrypto: 0.1.3 + optional: true '@vercel/analytics@1.5.0(next@16.0.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': optionalDependencies: @@ -8042,7 +8019,7 @@ snapshots: canonicalize@1.0.8: {} - ce-la-react@0.3.1(react@19.2.0): + ce-la-react@0.3.2(react@19.2.0): dependencies: react: 19.2.0 @@ -8986,9 +8963,9 @@ snapshots: mdn-data@2.12.2: {} - media-chrome@4.15.1(react@19.2.0): + media-chrome@4.16.0(react@19.2.0): dependencies: - ce-la-react: 0.3.1(react@19.2.0) + ce-la-react: 0.3.2(react@19.2.0) transitivePeerDependencies: - react @@ -9871,7 +9848,8 @@ snapshots: ulid@2.4.0: {} - uncrypto@0.1.3: {} + uncrypto@0.1.3: + optional: true undici-types@6.21.0: {} diff --git a/scripts/start-dev-infra.sh b/scripts/start-dev-infra.sh index 0ea8fd8..636db33 100755 --- a/scripts/start-dev-infra.sh +++ b/scripts/start-dev-infra.sh @@ -61,10 +61,6 @@ wait_for_port() { # --- Wait for exposed services ---------------------------------------------- # Postgres TCP wait_for_port "127.0.0.1" 5432 "Postgres" -# Redis TCP -wait_for_port "127.0.0.1" 6379 "Redis" -# Serverless Redis HTTP (SRH) -wait_for_port "127.0.0.1" 8079 "SRH (Upstash-compatible HTTP)" # Inngest Dev Server wait_for_port "127.0.0.1" 8288 "Inngest Dev Server" @@ -72,8 +68,6 @@ wait_for_port "127.0.0.1" 8288 "Inngest Dev Server" echo echo "🎉 Local infra is ready!" echo " * Postgres: postgres://postgres:postgres@localhost:5432/main" -echo " * Redis: redis://localhost:6379" -echo " * SRH: http://localhost:8079" echo " * Inngest: http://localhost:8288" echo diff --git a/server/routers/domain.ts b/server/routers/domain.ts index 268df21..2a19027 100644 --- a/server/routers/domain.ts +++ b/server/routers/domain.ts @@ -20,7 +20,11 @@ import { getPricingForTld } from "@/server/services/pricing"; import { getRegistration } from "@/server/services/registration"; import { getOrCreateScreenshotBlobUrl } from "@/server/services/screenshot"; import { getSeo } from "@/server/services/seo"; -import { createTRPCRouter, domainProcedure } from "@/trpc/init"; +import { + createTRPCRouter, + domainProcedure, + publicProcedure, +} from "@/trpc/init"; const DomainInputSchema = z .object({ domain: z.string().min(1) }) @@ -32,47 +36,38 @@ const DomainInputSchema = z export const domainRouter = createTRPCRouter({ registration: domainProcedure - .meta({ service: "registration" }) .input(DomainInputSchema) .output(RegistrationSchema) .query(({ input }) => getRegistration(input.domain)), dns: domainProcedure - .meta({ service: "dns" }) .input(DomainInputSchema) .output(DnsResolveResultSchema) .query(({ input }) => resolveAll(input.domain)), hosting: domainProcedure - .meta({ service: "hosting" }) .input(DomainInputSchema) .output(HostingSchema) .query(({ input }) => detectHosting(input.domain)), certificates: domainProcedure - .meta({ service: "certs" }) .input(DomainInputSchema) .output(CertificatesSchema) .query(({ input }) => getCertificates(input.domain)), headers: domainProcedure - .meta({ service: "headers" }) .input(DomainInputSchema) .output(HttpHeadersResponseSchema) .query(({ input }) => probeHeaders(input.domain)), seo: domainProcedure - .meta({ service: "seo" }) .input(DomainInputSchema) .output(SeoResponseSchema) .query(({ input }) => getSeo(input.domain)), - favicon: domainProcedure - .meta({ service: "favicon", recordAccess: false }) + favicon: publicProcedure .input(DomainInputSchema) .output(StorageUrlSchema) .query(({ input }) => getOrCreateFaviconBlobUrl(input.domain)), - screenshot: domainProcedure - .meta({ service: "screenshot", recordAccess: false }) + screenshot: publicProcedure .input(DomainInputSchema) .output(StorageUrlSchema) .query(({ input }) => getOrCreateScreenshotBlobUrl(input.domain)), - pricing: domainProcedure - .meta({ service: "pricing", recordAccess: false }) + pricing: publicProcedure .input(DomainInputSchema) .output(PricingSchema) .query(({ input }) => getPricingForTld(input.domain)), diff --git a/trpc/client.tsx b/trpc/client.tsx index ab86087..d4069d2 100644 --- a/trpc/client.tsx +++ b/trpc/client.tsx @@ -11,7 +11,6 @@ import { useState } from "react"; import superjson from "superjson"; import { TRPCProvider as Provider } from "@/lib/trpc/client"; import type { AppRouter } from "@/server/routers/_app"; -import { errorToastLink } from "@/trpc/error-toast-link"; import { makeQueryClient } from "@/trpc/query-client"; let browserQueryClient: ReturnType | undefined; @@ -43,7 +42,6 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), - errorToastLink(), httpBatchStreamLink({ url: `${getBaseUrl()}/api/trpc`, transformer: superjson, diff --git a/trpc/error-toast-link.ts b/trpc/error-toast-link.ts deleted file mode 100644 index 0e2ca47..0000000 --- a/trpc/error-toast-link.ts +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { TRPCClientError, type TRPCLink } from "@trpc/client"; -import type { AnyRouter } from "@trpc/server"; -import { observable } from "@trpc/server/observable"; -import { formatDistanceToNowStrict } from "date-fns"; -import { Gauge } from "lucide-react"; -import { createElement } from "react"; -import { toast } from "sonner"; - -export function errorToastLink< - TRouter extends AnyRouter = AnyRouter, ->(): TRPCLink { - return () => - ({ next, op }) => - observable((observer) => { - const sub = next(op).subscribe({ - next(value) { - observer.next(value); - }, - error(err) { - if (err instanceof TRPCClientError) { - const code = err.data?.code; - if (code === "TOO_MANY_REQUESTS") { - const retryAfterSec = Math.max( - 1, - Math.round(Number(err.data?.retryAfter ?? 1)), - ); - const service = err.data?.service as string | undefined; - const retryUntil = new Date(Date.now() + retryAfterSec * 1000); - const friendly = formatDistanceToNowStrict(retryUntil, { - addSuffix: true, - }); - const title = service - ? `Too many ${service} requests` - : "You're doing that too much"; - toast.error(title, { - id: "rate-limit", - description: `Try again ${friendly}.`, - icon: createElement(Gauge, { className: "h-4 w-4" }), - position: "top-center", - }); - } - } - observer.error(err); - }, - complete() { - observer.complete(); - }, - }); - return () => sub.unsubscribe(); - }); -} diff --git a/trpc/init.ts b/trpc/init.ts index 3f0052d..709e0fe 100644 --- a/trpc/init.ts +++ b/trpc/init.ts @@ -4,7 +4,6 @@ import { after } from "next/server"; import superjson from "superjson"; import { updateLastAccessed } from "@/lib/db/repos/domains"; import { toRegistrableDomain } from "@/lib/domain-server"; -import { assertRateLimit, type ServiceName } from "@/lib/ratelimit"; const IP_HEADERS = ["x-real-ip", "x-forwarded-for", "cf-connecting-ip"]; @@ -42,33 +41,6 @@ export const t = initTRPC .meta>() .create({ transformer: superjson, - errorFormatter({ shape, error }) { - const cause = ( - error as unknown as { - cause?: { - retryAfter?: number; - service?: string; - limit?: number; - remaining?: number; - }; - } - ).cause; - return { - ...shape, - data: { - ...shape.data, - retryAfter: - typeof cause?.retryAfter === "number" - ? cause.retryAfter - : undefined, - service: - typeof cause?.service === "string" ? cause.service : undefined, - limit: typeof cause?.limit === "number" ? cause.limit : undefined, - remaining: - typeof cause?.remaining === "number" ? cause.remaining : undefined, - }, - }; - }, }); export const createTRPCRouter = t.router; @@ -110,30 +82,12 @@ const withLogging = t.middleware(async ({ path, type, next }) => { } }); -/** - * Middleware to rate limit requests. - * - Expects meta to have a `service` field containing the service name. - * - Expects ctx to have an `ip` field containing the IP address. - * - Throws a TRPCError if the rate limit is exceeded. - */ -const withRatelimit = t.middleware(async ({ ctx, next, meta }) => { - if (meta?.service && ctx.ip) { - await assertRateLimit(meta.service as ServiceName, ctx.ip); - } - return next(); -}); - /** * Middleware to record that a domain was accessed by a user (for decay calculation). - * - Expects input to have a `domain` field. - * - Can be disabled by setting `meta.recordAccess = false`. + * Expects input to have a `domain` field. * Schedules the write to happen after the response is sent using Next.js after(). */ -const withDomainAccessUpdate = t.middleware(async ({ input, meta, next }) => { - // Allow procedures to opt-out of access tracking - if (meta?.recordAccess === false) { - return next(); - } +const withDomainAccessUpdate = t.middleware(async ({ input, next }) => { // Check if input is a valid object with a domain property if ( input && @@ -157,9 +111,7 @@ const withDomainAccessUpdate = t.middleware(async ({ input, meta, next }) => { export const publicProcedure = t.procedure.use(withLogging); /** - * Domain-specific procedure with rate limiting and access tracking. + * Domain-specific procedure with "last accessed at" tracking. * Use this for all domain data endpoints (dns, hosting, seo, etc). */ -export const domainProcedure = publicProcedure - .use(withRatelimit) - .use(withDomainAccessUpdate); +export const domainProcedure = publicProcedure.use(withDomainAccessUpdate);