71 lines · 2.6 KB
import { env } from "cloudflare:workers";
import { getRequestHeader } from "@tanstack/react-start/server";
import { HttpError } from "./errors";
import type { Viewer } from "~/lib/types";

const HTTP_TOO_MANY = 429;

type LimiterName = "AUTH_LIMITER" | "WRITE_LIMITER" | "HEAVY_LIMITER" | "GIT_LIMITER";

export function requestIp(request?: Request): string {
  const header = request ? request.headers.get("cf-connecting-ip") : getRequestHeader("cf-connecting-ip");
  return header ?? "unknown";
}

/** Uses the Workers Rate Limiting binding; throws 429 once the key exceeds its budget. */
export async function enforceRateLimit(limiter: LimiterName, key: string): Promise<void> {
  const { success } = await env[limiter].limit({ key });
  if (!success) throw new HttpError(HTTP_TOO_MANY, "Rate limit exceeded; slow down and try again shortly");
}

/** Rate-limit key for the current caller: the signed-in user, otherwise the client IP. */
export function callerKey(viewer: Viewer | null, request?: Request): string {
  return viewer ? `user:${viewer.id}` : `ip:${requestIp(request)}`;
}

export async function limitWrite(viewer: Viewer): Promise<void> {
  await enforceRateLimit("WRITE_LIMITER", `user:${viewer.id}`);
}

export async function limitHeavy(viewer: Viewer | null, request?: Request): Promise<void> {
  await enforceRateLimit("HEAVY_LIMITER", callerKey(viewer, request));
}

/** Rejects creation once a per-scope count cap is reached, bounding fan-out work and storage. */
export function assertBelowCap(count: number, cap: number, what: string): void {
  if (count >= cap) throw new HttpError(HTTP_TOO_MANY, `Limit reached: at most ${cap} ${what}`);
}

export function numberVar(value: string | undefined, fallback: number): number {
  const parsed = Number(value);