import { ZodError } from "zod"; import { authenticate } from "../shopify.server"; /** Simple per-instance token bucket (use Redis/edge rate limiting when running several instances). */ const buckets = new Map(); export function rateLimit(key: string, perMinute: number): boolean { const now = Date.now(); const b = buckets.get(key) ?? { tokens: perMinute, at: now }; b.tokens = Math.min(perMinute, b.tokens + ((now - b.at) / 60000) * perMinute); b.at = now; if (b.tokens < 1) { buckets.set(key, b); return false; } b.tokens -= 1; buckets.set(key, b); if (buckets.size > 50000) buckets.clear(); return true; } /** * Wrap an app-proxy handler: verifies Shopify's HMAC signature, rate limits, * parses JSON and turns validation errors into 400s. */ export async function proxyHandler( request: Request, limitPerMinute: number, fn: (ctx: { shop: string; admin?: Awaited>["admin"]; body: unknown; url: URL }) => Promise, ): Promise { const ctx = await authenticate.public.appProxy(request); const url = new URL(request.url); const shop = ctx.session?.shop ?? url.searchParams.get("shop") ?? ""; if (!ctx.session) return Response.json({ ok: false, message: "App not installed" }, { status: 404 }); const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon"; if (!rateLimit(`${shop}|${ip}|${url.pathname}`, limitPerMinute)) return Response.json({ ok: false, message: "Too many requests. Please wait a moment." }, { status: 429 }); try { const body = request.method === "GET" ? Object.fromEntries(url.searchParams) : await request.json().catch(() => ({})); const out = await fn({ shop, admin: ctx.admin, body, url }); return Response.json(out, { headers: { "Cache-Control": "no-store" } }); } catch (e) { if (e instanceof ZodError) return Response.json({ ok: false, message: "Invalid request", issues: e.issues.slice(0, 5) }, { status: 400 }); if (e instanceof Response) return e; console.error("proxy error", e); return Response.json({ ok: false, message: "Something went wrong. Please try again." }, { status: 500 }); } }