Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions src/app/api/v1/users/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,33 @@ function withCors(res: NextResponse): NextResponse {
const RATE_LIMIT_MAX = 60; // requests per window, per IP, per isolate
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_KEYS = 10_000; // hard cap on tracked IPs to bound memory
const rateHits = new Map<string, { count: number; resetAt: number }>();
let rateHits: Map<string, { count: number; resetAt: number }> | null = null;

function getRateHits(): Map<string, { count: number; resetAt: number }> {
if (!rateHits) {
rateHits = new Map();
}
return rateHits;
}

// Run periodic cleanup every 60 seconds in the background to evict stale
// entries and keep heap growth bounded even under sustained traffic.
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
function ensureCleanup(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const hits = getRateHits();
if (hits.size === 0) return;
const now = Date.now();
for (const [key, value] of hits) {
if (now >= value.resetAt) hits.delete(key);
}
}, 60_000);
// Allow the process to exit even if the interval is still active.
if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) {
cleanupInterval.unref();
}
}

function getClientIp(request: NextRequest): string {
// Prefer the platform-set header (unspoofable on Cloudflare) so a client can't
Expand All @@ -48,23 +74,26 @@ function getClientIp(request: NextRequest): string {
}

function checkRateLimit(ip: string): { limited: boolean; retryAfter: number } {
ensureCleanup();

const hits = getRateHits();
const now = Date.now();

// Opportunistically drop expired entries, then enforce a hard cap so a flood
// of many unique IPs can't grow the map without bound and OOM the isolate.
if (rateHits.size > RATE_LIMIT_MAX_KEYS) {
for (const [key, value] of rateHits) {
if (now >= value.resetAt) rateHits.delete(key);
// Prune expired entries, then enforce a hard cap so a flood of many unique
// IPs can't grow the map without bound and OOM the isolate.
if (hits.size > RATE_LIMIT_MAX_KEYS) {
for (const [key, value] of hits) {
if (now >= value.resetAt) hits.delete(key);
}
// Still at capacity after pruning: stop tracking new keys rather than grow.
if (rateHits.size > RATE_LIMIT_MAX_KEYS && !rateHits.has(ip)) {
if (hits.size > RATE_LIMIT_MAX_KEYS && !hits.has(ip)) {
return { limited: true, retryAfter: Math.ceil(RATE_LIMIT_WINDOW_MS / 1000) };
}
}

const entry = rateHits.get(ip);
const entry = hits.get(ip);
if (!entry || now >= entry.resetAt) {
rateHits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
hits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { limited: false, retryAfter: 0 };
}

Expand Down
42 changes: 1 addition & 41 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,3 @@
const REQUIRED_PUBLIC_VARS = [
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
] as const;

const REQUIRED_SERVER_VARS = [
"SUPABASE_SERVICE_ROLE_KEY",
] as const;

interface EnvResult {
missing: string[];
valid: boolean;
}

function checkVars(vars: readonly string[]): EnvResult {
const missing: string[] = [];
for (const key of vars) {
if (!process.env[key] || process.env[key] === "placeholder-anon-key" || process.env[key] === "https://placeholder.supabase.co") {
missing.push(key);
}
}
return { missing, valid: missing.length === 0 };
}

export function validatePublicEnv(): EnvResult {
return checkVars(REQUIRED_PUBLIC_VARS);
}

export function validateServerEnv(): EnvResult {
return checkVars(REQUIRED_SERVER_VARS);
}

export function getEnvWarning(): string | null {
const publicEnv = validatePublicEnv();
if (!publicEnv.valid) {
return `Missing required public environment variables: ${publicEnv.missing.join(", ")}. Copy .env.example to .env.local and fill in the values.`;
}
return null;
}

export function isSupabaseConfigured(): boolean {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
Expand All @@ -49,4 +9,4 @@ export function isSupabaseConfigured(): boolean {
);
}

export const isDev = process.env.NODE_ENV === "development";

5 changes: 0 additions & 5 deletions src/lib/fetch-with-timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ function isAbortLikeError(err: unknown): boolean {
return false;
}

export function isTimeoutError(err: unknown): boolean {
if (err instanceof FetchTimeoutError) return true;
return isAbortLikeError(err);
}

export async function fetchWithTimeout(
input: RequestInfo | URL,
init: RequestInit = {},
Expand Down
93 changes: 75 additions & 18 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,91 @@
import type { ContributorStats, Repo } from "@/types";
import { redis } from "./redis";
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import { fetchWithTimeout, FetchTimeoutError } from "@/lib/fetch-with-timeout";


const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";

const RETRY_CONFIG = {
maxRetries: 3,
baseDelayMs: 1_000,
maxDelayMs: 10_000,
};

async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function githubGraphQL<T>(
query: string,
variables: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetchWithTimeout(
GITHUB_GRAPHQL_URL,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
next: { revalidate: 3600 }, // fallback memory cache for 1 hour
},
10_000
);
let lastError: Error | null = null;

for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
try {
const res = await fetchWithTimeout(
GITHUB_GRAPHQL_URL,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
next: { revalidate: 3600 },
},
10_000
);

if (!res.ok) {
// 429 or 5xx — retryable
if (res.status === 429 || res.status >= 500) {
lastError = new Error(`GitHub API error: ${res.status}`);
if (attempt < RETRY_CONFIG.maxRetries) {
const delay = Math.min(
RETRY_CONFIG.baseDelayMs * 2 ** attempt,
RETRY_CONFIG.maxDelayMs
);
await sleep(delay);
continue;
}
}
throw new Error(`GitHub API error: ${res.status}`);
}

const json = await res.json();
if (json.errors) {
lastError = new Error(json.errors[0].message);
// Some GitHub errors are retryable (e.g. secondary rate limit)
if (attempt < RETRY_CONFIG.maxRetries) {
const delay = Math.min(
RETRY_CONFIG.baseDelayMs * 2 ** attempt,
RETRY_CONFIG.maxDelayMs
);
await sleep(delay);
continue;
}
throw lastError;
}
return json.data as T;
} catch (err) {
if (err instanceof FetchTimeoutError) {
lastError = err;
if (attempt < RETRY_CONFIG.maxRetries) {
const delay = Math.min(
RETRY_CONFIG.baseDelayMs * 2 ** attempt,
RETRY_CONFIG.maxDelayMs
);
await sleep(delay);
continue;
}
}
throw err;
}
}

if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
const json = await res.json();
if (json.errors) throw new Error(json.errors[0].message);
return json.data as T;
throw lastError ?? new Error("GitHub API request failed after retries");
}

export const CONTRIBUTOR_QUERY = `
Expand Down
10 changes: 1 addition & 9 deletions src/lib/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,4 @@ export function calculateScore(stats: ContributorStats, repos: Repo[]): number {
return breakdown.total;
}

/** Calculates the percentage difference relative to a baseline score. */
export function getScoreDeltaPercentage(scoreA: number, scoreB: number): string {
if (scoreA === scoreB) return "0%";
const max = Math.max(scoreA, scoreB);
const min = Math.min(scoreA, scoreB);
const diff = max - min;
const percentage = (diff / (min || 1)) * 100;
return `+${percentage.toFixed(0)}%`;
}

23 changes: 18 additions & 5 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,28 @@ function warningMissingEnv() {
}
}

// Connection pool sizing — the Supabase JS client uses HTTP keep-alive under
// the hood, so these options control connection-level behaviour at the
// transport layer. The pool settings here are tuned for a serverless edge
// environment where short-lived, bursty connections are the norm.
const POOL_CONFIG = {
db: {
pool: {
min: 0,
max: 5,
acquireTimeoutMillis: 10_000,
createTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
},
},
};

let client: SupabaseClient | null = null;

export function getSupabase(): SupabaseClient {
if (!client) {
warningMissingEnv();
client = createClient(supabaseUrl, supabaseAnonKey);
client = createClient(supabaseUrl, supabaseAnonKey, POOL_CONFIG);
}
return client;
}
Expand All @@ -29,8 +45,5 @@ export const supabase = getSupabase();
export function supabaseAdmin(): SupabaseClient {
warningMissingEnv();
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || "placeholder-service-key";
return createClient(supabaseUrl, serviceKey);
return createClient(supabaseUrl, serviceKey, POOL_CONFIG);
}

// NOTE: Ensure database migrations (compound index idx_profiles_score_username) are applied in Supabase
// to keep paginated getSupabase queries performant under heavy user loads.
3 changes: 1 addition & 2 deletions src/lib/validators/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { NextResponse } from "next/server";
import type { AppError } from "@/lib/errors";
import { toApiErrorBody } from "@/lib/errors";

export function sanitizeUsername(value: unknown): string | null {
if (typeof value !== "string") return null;
Expand Down Expand Up @@ -91,6 +89,7 @@ export function createErrorResponse(
});
}


function errorCodeForStatus(status: number): string {
switch (status) {
case 400: return "VALIDATION_ERROR";
Expand Down
Loading