From 40f2fd56b82195c2e906e7182d94713402832679 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:43:23 +0100 Subject: [PATCH] chore: global speed checker (#1817) * refactor: play checker structure * chore: add structured data to tools content * chore: ratelimit global speed checker * chore: improve error message * fix: tsc * fix: review * fix: self-requests * fix: tsc --- apps/web/src/app/(landing)/page.tsx | 2 + .../(landing)/play/checker/[slug]/client.tsx | 2 +- .../(landing)/play/checker/[slug]/page.tsx | 8 +- .../app/(landing)/play/checker/api/route.ts | 163 +++++++++++++++++- .../src/app/(landing)/play/checker/client.tsx | 20 ++- .../src/app/(landing)/play/checker/page.tsx | 4 +- .../src/app/(landing)/play/checker/utils.ts | 5 +- .../src/app/api/checker/test/http/route.ts | 4 +- apps/web/src/app/api/og/checker/route.tsx | 2 +- .../src/content/pages/tools/checker-slug.mdx | 3 + apps/web/src/content/pages/tools/checker.mdx | 7 + apps/web/src/content/pages/tools/curl.mdx | 3 + .../src/content/pages/tools/uptime-sla.mdx | 5 + .../play/checker/api => lib/checker}/mock.ts | 5 +- .../checker}/utils.ts | 111 ++++++------ apps/web/src/lib/ratelimit.ts | 70 ++++++++ pnpm-lock.yaml | 2 - 17 files changed, 339 insertions(+), 77 deletions(-) rename apps/web/src/{app/(landing)/play/checker/api => lib/checker}/mock.ts (99%) rename apps/web/src/{components/ping-response-analysis => lib/checker}/utils.ts (74%) create mode 100644 apps/web/src/lib/ratelimit.ts diff --git a/apps/web/src/app/(landing)/page.tsx b/apps/web/src/app/(landing)/page.tsx index 5344d678..32ca6ae4 100644 --- a/apps/web/src/app/(landing)/page.tsx +++ b/apps/web/src/app/(landing)/page.tsx @@ -4,6 +4,7 @@ import { defaultMetadata } from "@/lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDFAQPage, + getJsonLDHowTo, getJsonLDOrganization, getJsonLDProduct, getJsonLDSoftwareApplication, @@ -21,6 +22,7 @@ export default function Page() { getJsonLDProduct(), getJsonLDSoftwareApplication(), getJsonLDWebPage(homePage), + getJsonLDHowTo(homePage), getJsonLDFAQPage(homePage), ]); diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx index a8a5d617..42697067 100644 --- a/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/[slug]/client.tsx @@ -6,7 +6,7 @@ import { getTimingPhases, regionFormatter, timestampFormatter, -} from "@/components/ping-response-analysis/utils"; +} from "@/lib/checker/utils"; import { cn } from "@/lib/utils"; import { type Region, regionDict } from "@openstatus/regions"; import { Button } from "@openstatus/ui"; diff --git a/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx b/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx index 5212e4ba..d37b8483 100644 --- a/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx +++ b/apps/web/src/app/(landing)/play/checker/[slug]/page.tsx @@ -1,10 +1,11 @@ +import { CustomMDX } from "@/content/mdx"; +import { getToolsPage } from "@/content/utils"; +import { mockCheckAllRegions } from "@/lib/checker/mock"; import { getCheckerDataById, latencyFormatter, regionFormatter, -} from "@/components/ping-response-analysis/utils"; -import { CustomMDX } from "@/content/mdx"; -import { getToolsPage } from "@/content/utils"; +} from "@/lib/checker/utils"; import { BASE_URL, getPageMetadata } from "@/lib/metadata/shared-metadata"; import { createJsonLDGraph, @@ -13,7 +14,6 @@ import { } from "@/lib/metadata/structured-data"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { mockCheckAllRegions } from "../api/mock"; import { Table } from "./client"; function formatDate(date: Date) { diff --git a/apps/web/src/app/(landing)/play/checker/api/route.ts b/apps/web/src/app/(landing)/play/checker/api/route.ts index 8ce02b8b..40cf1f1c 100644 --- a/apps/web/src/app/(landing)/play/checker/api/route.ts +++ b/apps/web/src/app/(landing)/play/checker/api/route.ts @@ -1,16 +1,75 @@ +import { mockCheckRegion } from "@/lib/checker/mock"; import { type Method, checkRegion, storeBaseCheckerData, storeCheckerData, -} from "@/components/ping-response-analysis/utils"; +} from "@/lib/checker/utils"; +import { getClientIP, ratelimit } from "@/lib/ratelimit"; import { iteratorToStream, yieldMany } from "@/lib/stream"; import { wait } from "@/lib/utils"; import { AVAILABLE_REGIONS } from "@openstatus/regions"; -import { mockCheckRegion } from "./mock"; +import { z } from "zod"; export const runtime = "edge"; +// Request schema validation +const playCheckerRequestSchema = z.object({ + url: z.url("Invalid URL format"), + method: z + .enum(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) + .default("GET"), + headers: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .optional(), + body: z.string().optional(), +}); + +type PlayCheckerRequest = z.infer; + +// Error response types +type ErrorCode = + | "RATE_LIMIT_EXCEEDED" + | "INVALID_REQUEST" + | "NO_CLIENT_IP" + | "INTERNAL_ERROR"; + +interface ErrorResponse { + error: string; + code: ErrorCode; + details?: Record; + limit?: number; + remaining?: number; + reset?: number; +} + +function createErrorResponse( + code: ErrorCode, + error: string, + status: number, + details?: Record, + headers?: Record, +): Response { + const response: ErrorResponse = { + error, + code, + ...details, + }; + + return new Response(JSON.stringify(response), { + status, + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); +} + const encoder = new TextEncoder(); async function* makeIterator({ @@ -29,7 +88,7 @@ async function* makeIterator({ // Perform the fetch operation const check = process.env.NODE_ENV === "production" - ? await checkRegion(url, region, { method }) + ? await checkRegion({ url, region, method }) : await mockCheckRegion(region); if ("body" in check) { @@ -65,13 +124,103 @@ async function* generator(id: string) { } export async function POST(request: Request) { - const json = await request.json(); - const { url, method } = json; + // Parse and validate request body + let requestData: PlayCheckerRequest; + try { + const json = await request.json(); + const parsed = playCheckerRequestSchema.safeParse(json); + + if (!parsed.success) { + return createErrorResponse( + "INVALID_REQUEST", + "Invalid request format", + 400, + { + details: { + issues: parsed.error.issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })), + }, + }, + ); + } + + requestData = parsed.data; + } catch (_error) { + return createErrorResponse( + "INVALID_REQUEST", + "Invalid JSON in request body", + 400, + ); + } + + const { url, method } = requestData; + + const urlObject = new URL(url); + + if ( + urlObject.hostname.includes("openstatus.dev") && + urlObject.pathname.startsWith("/play/checker/api") + ) { + return createErrorResponse( + "INVALID_REQUEST", + "Self-requests are not allowed", + 400, + ); + } + + // Rate limiting check + const clientIP = getClientIP(request.headers); + + if (!clientIP) { + return createErrorResponse( + "NO_CLIENT_IP", + "Unable to determine client IP address", + 400, + ); + } + + const rateLimitResult = await ratelimit(`play-checker:${clientIP}`, { + window: 60, // 60 seconds + limit: 10, // 10 requests + }); + + if (!rateLimitResult.success) { + return createErrorResponse( + "RATE_LIMIT_EXCEEDED", + "You have exceeded the rate limit of 10 requests per 60 seconds", + 429, + { + limit: rateLimitResult.limit, + remaining: rateLimitResult.remaining, + reset: rateLimitResult.reset, + }, + { + "X-RateLimit-Limit": rateLimitResult.limit.toString(), + "X-RateLimit-Remaining": rateLimitResult.remaining.toString(), + "X-RateLimit-Reset": rateLimitResult.reset.toString(), + "Retry-After": Math.ceil( + (rateLimitResult.reset - Date.now()) / 1000, + ).toString(), + }, + ); + } const uuid = crypto.randomUUID().replace(/-/g, ""); await storeBaseCheckerData({ url, method, id: uuid }); - const iterator = makeIterator({ url, method, id: uuid }); + const iterator = makeIterator({ + url, + method, + id: uuid, + }); const stream = iteratorToStream(iterator); - return new Response(stream); + return new Response(stream, { + headers: { + "X-RateLimit-Limit": rateLimitResult.limit.toString(), + "X-RateLimit-Remaining": rateLimitResult.remaining.toString(), + "X-RateLimit-Reset": rateLimitResult.reset.toString(), + }, + }); } diff --git a/apps/web/src/app/(landing)/play/checker/client.tsx b/apps/web/src/app/(landing)/play/checker/client.tsx index 77236216..a822d00f 100644 --- a/apps/web/src/app/(landing)/play/checker/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/client.tsx @@ -7,7 +7,7 @@ import { latencyFormatter, regionCheckerSchema, regionFormatter, -} from "@/components/ping-response-analysis/utils"; +} from "@/lib/checker/utils"; import { toast } from "@/lib/toast"; import { cn, notEmpty } from "@/lib/utils"; import { @@ -149,6 +149,24 @@ export function Form({ signal: abortController.signal, }); + if (!response.ok) { + try { + const json = await response.json(); + toast.error(json.error, { + id: toastId, + className: "text-destructive!", + }); + return; + } catch { + toast.error("Failed to fetch data", { + id: toastId, + description: "Please try again.", + className: "text-destructive!", + }); + return; + } + } + clearTimeout(timeoutId); const reader = response?.body?.getReader(); diff --git a/apps/web/src/app/(landing)/play/checker/page.tsx b/apps/web/src/app/(landing)/play/checker/page.tsx index ef303b8a..b4d22ce8 100644 --- a/apps/web/src/app/(landing)/play/checker/page.tsx +++ b/apps/web/src/app/(landing)/play/checker/page.tsx @@ -1,6 +1,7 @@ -import { getCheckerDataById } from "@/components/ping-response-analysis/utils"; import { CustomMDX } from "@/content/mdx"; import { getToolsPage } from "@/content/utils"; +import { mockCheckAllRegions } from "@/lib/checker/mock"; +import { getCheckerDataById } from "@/lib/checker/utils"; import { BASE_URL, getPageMetadata } from "@/lib/metadata/shared-metadata"; import { createJsonLDGraph, @@ -8,7 +9,6 @@ import { getJsonLDWebPage, } from "@/lib/metadata/structured-data"; import type { Metadata } from "next"; -import { mockCheckAllRegions } from "./api/mock"; import { CheckerProvider, DetailsButtonLink, diff --git a/apps/web/src/app/(landing)/play/checker/utils.ts b/apps/web/src/app/(landing)/play/checker/utils.ts index 43032e22..d46cc2f8 100644 --- a/apps/web/src/app/(landing)/play/checker/utils.ts +++ b/apps/web/src/app/(landing)/play/checker/utils.ts @@ -1,7 +1,4 @@ -import { - type Timing, - getTimingPhases, -} from "@/components/ping-response-analysis/utils"; +import { type Timing, getTimingPhases } from "@/lib/checker/utils"; import { toast } from "@/lib/toast"; import { type Region, regionDict } from "@openstatus/regions"; diff --git a/apps/web/src/app/api/checker/test/http/route.ts b/apps/web/src/app/api/checker/test/http/route.ts index 15f20ed6..fb55fbf0 100644 --- a/apps/web/src/app/api/checker/test/http/route.ts +++ b/apps/web/src/app/api/checker/test/http/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; -import { checkRegion } from "@/components/ping-response-analysis/utils"; +import { checkRegion } from "@/lib/checker/utils"; import { httpPayloadSchema } from "@openstatus/utils"; import { isAnInvalidTestUrl } from "../../utils"; @@ -34,7 +34,7 @@ export async function POST(request: Request) { return NextResponse.json({ success: true }, { status: 200 }); } - const res = await checkRegion(url, region, { method, headers, body }); + const res = await checkRegion({ url, region, method, headers, body }); return NextResponse.json(res); } catch (e) { diff --git a/apps/web/src/app/api/og/checker/route.tsx b/apps/web/src/app/api/og/checker/route.tsx index 4bb4adca..2d8737db 100644 --- a/apps/web/src/app/api/og/checker/route.tsx +++ b/apps/web/src/app/api/og/checker/route.tsx @@ -4,7 +4,7 @@ import { getCheckerDataById, regionFormatter, timestampFormatter, -} from "@/components/ping-response-analysis/utils"; +} from "@/lib/checker/utils"; import { cn } from "@/lib/utils"; import { BasicLayout } from "../_components/basic-layout"; import { diff --git a/apps/web/src/content/pages/tools/checker-slug.mdx b/apps/web/src/content/pages/tools/checker-slug.mdx index d7be01f6..b0f8fccd 100644 --- a/apps/web/src/content/pages/tools/checker-slug.mdx +++ b/apps/web/src/content/pages/tools/checker-slug.mdx @@ -4,6 +4,9 @@ publishedAt: "2025-11-10" author: "Thibault Le Ouay Ducasse" description: "Check the speed of your website from around the world." category: "Product" +faq: + - question: "How long is the data stored?" + answer: "The data is stored for 7 days. If you want to keep it longer, consider creating an account at https://app.openstatus.dev and use the cloud solution." --- The data is getting stored for **7 days**. If you want to keep it longer, consider [creating an account](https://app.openstatus.dev) and use our cloud solution. diff --git a/apps/web/src/content/pages/tools/checker.mdx b/apps/web/src/content/pages/tools/checker.mdx index a5eac313..09a895f7 100644 --- a/apps/web/src/content/pages/tools/checker.mdx +++ b/apps/web/src/content/pages/tools/checker.mdx @@ -4,6 +4,13 @@ publishedAt: "2025-11-10" author: "Thibault Le Ouay Ducasse" description: "Check the speed of your website from around the world." category: "Product" +faq: + - question: "What Is a Website Speed Checker?" + answer: "A Website Speed Checker is an online tool that measures how fast your website or API responds when someone visits it. It analyzes various website performance metrics including client-side performance (FCP, LCP, CLS) and server-side performance (DNS lookup, TCP connection, TLS handshake, server response time)." + - question: "What Is a Global Speed Checker?" + answer: "A Global Speed Checker measures your website or API's latency and response time from multiple locations around the world. OpenStatus runs checks from 28 global regions across 3 cloud providers, giving you a complete picture of your site's real-world performance." + - question: "What can I do with openstatus Global Speed Checker?" + answer: "You can test how fast your API or website responds worldwide, compare latency across different regions, identify network bottlenecks, and monitor uptime and availability in real time from distributed locations across Europe, Asia, North America, and beyond." --- ## Start monitoring your services diff --git a/apps/web/src/content/pages/tools/curl.mdx b/apps/web/src/content/pages/tools/curl.mdx index 23f0bd14..a6f38528 100644 --- a/apps/web/src/content/pages/tools/curl.mdx +++ b/apps/web/src/content/pages/tools/curl.mdx @@ -4,6 +4,9 @@ publishedAt: "2025-11-10" author: "Maximilian Kaske" description: "An online curl command line builder. Generate curl commands to test your API endpoints." category: "Product" +faq: + - question: "What is cURL?" + answer: "cURL (Client URL) is a command-line tool and library for transferring data with URLs. It supports various protocols like HTTP, HTTPS, FTP, and more, making it a versatile choice for testing APIs, downloading files, or performing network tasks. It's available on most operating systems, including Linux, macOS, and Windows." --- ## What is cURL? diff --git a/apps/web/src/content/pages/tools/uptime-sla.mdx b/apps/web/src/content/pages/tools/uptime-sla.mdx index 25830efd..e6ff987f 100644 --- a/apps/web/src/content/pages/tools/uptime-sla.mdx +++ b/apps/web/src/content/pages/tools/uptime-sla.mdx @@ -4,6 +4,11 @@ publishedAt: "2025-11-10" author: "Thibault Le Ouay Ducasse" description: "Calculate allowed downtime from uptime percentages or determine uptime percentages from actual downtime. Supports multiple reporting periods and SLA tiers." category: "Product" +faq: + - question: "What is Uptime SLA?" + answer: "Service Level Agreements (SLAs) define the expected performance and availability of your services. Understanding uptime percentages and their corresponding downtime allowances is crucial for maintaining customer trust and meeting compliance requirements." + - question: "What are common SLA tiers?" + answer: "Common SLA tiers include 99.9% (three nines), 99.99% (four nines), and 99.999% (five nines). For example, 99.9% uptime allows for 8.77 hours of downtime per year, while 99.99% allows only 52.6 minutes annually. All calculations assume continuous 24/7 availability requirements." --- _All calculations assume continuous 24/7 availability requirements._ diff --git a/apps/web/src/app/(landing)/play/checker/api/mock.ts b/apps/web/src/lib/checker/mock.ts similarity index 99% rename from apps/web/src/app/(landing)/play/checker/api/mock.ts rename to apps/web/src/lib/checker/mock.ts index fd7ffdf0..0a3ebc2a 100644 --- a/apps/web/src/app/(landing)/play/checker/api/mock.ts +++ b/apps/web/src/lib/checker/mock.ts @@ -1,7 +1,4 @@ -import { - type RegionChecker, - cachedCheckerSchema, -} from "@/components/ping-response-analysis/utils"; +import { type RegionChecker, cachedCheckerSchema } from "@/lib/checker/utils"; import { wait } from "@/lib/utils"; import type { Region } from "@openstatus/db/src/schema/constants"; diff --git a/apps/web/src/components/ping-response-analysis/utils.ts b/apps/web/src/lib/checker/utils.ts similarity index 74% rename from apps/web/src/components/ping-response-analysis/utils.ts rename to apps/web/src/lib/checker/utils.ts index 63e37240..c4a48178 100644 --- a/apps/web/src/components/ping-response-analysis/utils.ts +++ b/apps/web/src/lib/checker/utils.ts @@ -1,12 +1,22 @@ -import { Redis } from "@upstash/redis"; import { z } from "zod"; -import { - flyRegions, - monitorRegionSchema, -} from "@openstatus/db/src/schema/constants"; +import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; import type { Region } from "@openstatus/db/src/schema/constants"; import { continentDict, getRegionInfo, regionDict } from "@openstatus/regions"; +import { Redis } from "@upstash/redis"; + +// ============================================================================ +// Constants +// ============================================================================ + +const FLY_CHECKER_URL = "https://checker.openstatus.dev/ping"; +const KOYEB_CHECKER_URL = "https://openstatus-checker.koyeb.app/ping"; +const RAILWAY_CHECKER_URL = + "https://railway-proxy-production-9cb1.up.railway.app/ping"; + +// ============================================================================ +// Formatters +// ============================================================================ export function latencyFormatter(value: number) { return `${new Intl.NumberFormat("us").format(value).toString()}ms`; @@ -30,6 +40,10 @@ export function regionFormatter( return `${location} ${flag}`; } +// ============================================================================ +// Timing Utilities +// ============================================================================ + export function getTotalLatency(timing: Timing) { const { dns, connection, tls, ttfb, transfer } = getTimingPhases(timing); return dns + connection + tls + ttfb + transfer; @@ -86,6 +100,10 @@ export function getTimingPhasesWidth(timing: Timing) { }; } +// ============================================================================ +// Schemas & Types +// ============================================================================ + export const timingSchema = z.object({ dnsStart: z.number(), dnsDone: z.number(), @@ -113,7 +131,9 @@ export const checkerSchema = z.object({ export const cachedCheckerSchema = z.object({ url: z.string(), timestamp: z.number(), - method: z.enum(["GET", "POST", "PUT", "DELETE"]).prefault("GET"), + method: z.string(), // Simplified - validation happens at runtime + headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), + body: z.string().optional(), checks: checkerSchema.extend({ region: monitorRegionSchema }).array(), }); @@ -132,9 +152,9 @@ export const regionCheckerSchemaResponse = regionCheckerSchema.or( region: monitorRegionSchema, }), ); + export type Timing = z.infer; export type Checker = z.infer; -// FIXME: does not include TCP! export type RegionChecker = z.infer; export type RegionCheckerResponse = z.infer; export type Method = @@ -148,36 +168,41 @@ export type Method = | "CONNECT" | "TRACE"; export type CachedRegionChecker = z.infer; - export type ErrorRequest = z.infer; + +type CheckRegionRequest = { + url: string; + region: Region; + method?: Method; + headers?: { value: string; key: string }[]; + body?: string; +}; + +// ============================================================================ +// API Functions +// ============================================================================ + export async function checkRegion( - url: string, - region: Region, - opts?: { - method?: Method; - headers?: { value: string; key: string }[]; - body?: string; - }, + props: CheckRegionRequest, ): Promise { - // - // + const { url, region, method, headers, body } = props; const regionInfo = regionDict[region]; let endpoint = ""; let regionHeader = {}; switch (regionInfo.provider) { case "fly": - endpoint = `https://checker.openstatus.dev/ping/${region}`; + endpoint = `${FLY_CHECKER_URL}/${region}`; regionHeader = { "fly-prefer-region": region }; break; case "koyeb": - endpoint = `https://openstatus-checker.koyeb.app/ping/${region}`; + endpoint = `${KOYEB_CHECKER_URL}/${region}`; regionHeader = { "X-KOYEB-REGION-OVERRIDE": region.replace("koyeb_", ""), }; break; case "railway": - endpoint = `https://railway-proxy-production-9cb1.up.railway.app/ping/${region}`; + endpoint = `${RAILWAY_CHECKER_URL}/${region}`; regionHeader = { "railway-region": region.replace("railway_", "") }; break; default: @@ -193,17 +218,15 @@ export async function checkRegion( method: "POST", body: JSON.stringify({ url, - method: opts?.method || "GET", - headers: opts?.headers?.reduce((acc, { key, value }) => { - if (!key) return acc; // key === "" is an invalid header - - return { - // biome-ignore lint/performance/noAccumulatingSpread: - ...acc, - [key]: value, - }; - }, {}), - body: opts?.body ? opts.body : undefined, + method: method || "GET", + headers: headers?.reduce( + (acc, { key, value }) => { + if (!key) return acc; // key === "" is an invalid header + return { ...acc, [key]: value }; + }, + {} as Record, + ), + body: body ? body : undefined, }), next: { revalidate: 0 }, }); @@ -227,22 +250,9 @@ export async function checkRegion( }; } -/** - * Used for the /play/checker page only - */ -export async function checkAllRegions(url: string, opts?: { method: Method }) { - // TODO: settleAll - return await Promise.all( - flyRegions.map(async (region) => { - const check = await checkRegion(url, region, opts); - if (check.state === "success") { - // REMINDER: dropping the body to avoid storing it within Redis Cache (Err max request size exceeded) - check.body = undefined; - } - return check; - }), - ); -} +// ============================================================================ +// Redis Caching +// ============================================================================ export async function storeBaseCheckerData({ url, @@ -277,8 +287,6 @@ export async function storeCheckerData({ check: RegionChecker; id: string; }) { - const redis = Redis.fromEnv(); - const parsed = cachedCheckerSchema .pick({ checks: true }) .safeParse({ checks: [check] }); @@ -289,6 +297,7 @@ export async function storeCheckerData({ const first = parsed.data.checks?.[0]; + const redis = Redis.fromEnv(); if (first) await redis.sadd(`check:data:${id}`, first); return id; @@ -319,6 +328,10 @@ export async function getCheckerDataById(id: string) { return parsed.data; } +// ============================================================================ +// Validation Utilities +// ============================================================================ + /** * Simple function to validate crypto.randomUUID() format like "aec4e0ec3c4f4557b8ce46e55078fc95" * @param uuid diff --git a/apps/web/src/lib/ratelimit.ts b/apps/web/src/lib/ratelimit.ts new file mode 100644 index 00000000..35e9ac8d --- /dev/null +++ b/apps/web/src/lib/ratelimit.ts @@ -0,0 +1,70 @@ +import { redis } from "@openstatus/upstash"; + +interface RateLimitConfig { + window: number; // in seconds + limit: number; // max requests per window +} + +interface RateLimitResult { + success: boolean; + limit: number; + remaining: number; + reset: number; // timestamp when the window resets +} + +/** + * Simple fixed window rate limiter using Redis + * @param identifier - Unique identifier for the rate limit (e.g., IP address) + * @param config - Rate limit configuration + * @returns Rate limit result + */ +export async function ratelimit( + identifier: string, + config: RateLimitConfig, +): Promise { + const key = `ratelimit:${identifier}`; + const now = Date.now(); + + // Increment the counter + const count = await redis.incr(key); + + // If this is the first request, set the expiry + if (count === 1) { + await redis.expire(key, config.window); + } + + // Get the TTL to calculate reset time + const ttl = await redis.ttl(key); + const reset = now + (ttl > 0 ? ttl * 1000 : config.window * 1000); + + const success = count <= config.limit; + const remaining = Math.max(0, config.limit - count); + + return { + success, + limit: config.limit, + remaining, + reset, + }; +} + +/** + * Extract IP address from request headers + * @param headers - Request headers + * @returns IP address or null + */ +export function getClientIP(headers: Headers): string | null { + // Check x-real-ip first (commonly set by Vercel, Cloudflare, etc.) + const realIP = headers.get("x-real-ip"); + if (realIP) { + return realIP; + } + + // Check x-forwarded-for (can contain multiple IPs, take the first one) + const forwardedFor = headers.get("x-forwarded-for"); + if (forwardedFor) { + return forwardedFor.split(",")[0].trim(); + } + + return null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20a0a84f..f8b4a6de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1940,8 +1940,6 @@ importers: specifier: 5.9.3 version: 5.9.3 - packages/react/dist: {} - packages/regions: dependencies: zod: -- 2.51.2