Skip to content
Closed
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
29 changes: 29 additions & 0 deletions src/app/api/og/[username]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,35 @@ describe("OG Image Route", () => {
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("0");
});

it("should dedupe concurrent fetches for the same username", async () => {
// Create a delayed fetch so multiple concurrent GETs will hit the inflight map
let callCount = 0;
const mockFetch = vi.spyOn(global, "fetch").mockImplementation(() => {
callCount++;
return new Promise((resolve) => {
setTimeout(() => {
resolve(new Response(JSON.stringify({ name: "Concurrent User" }), { status: 200 }));
}, 10);
});
});

const req1 = new NextRequest("http://localhost/api/og/concurrentuser");
const req2 = new NextRequest("http://localhost/api/og/concurrentuser");

// Fire concurrently
const [res1, res2] = await Promise.all([
GET(req1, { params: Promise.resolve({ username: "concurrentuser" }) }),
GET(req2, { params: Promise.resolve({ username: "concurrentuser" }) })
]);

expect(res1.status).toBe(200);
expect(res2.status).toBe(200);
expect(callCount).toBe(1); // fetch should only be called once
expect(mockFetch).toHaveBeenCalledTimes(1);

// We should restore fetch, though afterEach does that
});
});

function collectText(node: unknown): string[] {
Expand Down
27 changes: 20 additions & 7 deletions src/app/api/og/[username]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { getClientIp } from "@/lib/rateLimit";

export const runtime = "edge";
const inflightFetch = new Map<string, Promise<Response>>();
const rateLimiter = new RateLimiter(50, 60 * 1000);
const ONE_HOUR_IN_SECONDS = 60 * 60;
const ONE_DAY_IN_SECONDS = 24 * ONE_HOUR_IN_SECONDS;
Expand Down Expand Up @@ -42,13 +43,25 @@
let publicRepos = 0;

try {
const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "github-user-summary",
},
next: { revalidate: ONE_DAY_IN_SECONDS },
});
const url = `https://api.github.com/users/${encodeURIComponent(username)}`;
let fetchPromise = inflightFetch.get(url);
if (!fetchPromise) {
fetchPromise = fetch(url, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "github-user-summary",
},
next: { revalidate: ONE_DAY_IN_SECONDS },
}).finally(() => {
inflightFetch.delete(url);
});
inflightFetch.set(url, fetchPromise);
}

// We clone the response because multiple concurrent requests
// will need to read the body stream independently
const res = (await fetchPromise).clone();

if (res.ok) {
const data = await res.json();
name = data.name ?? username;
Expand Down Expand Up @@ -84,7 +97,7 @@
}}
>
{avatarUrl && (
<img

Check warning on line 100 in src/app/api/og/[username]/route.tsx

View workflow job for this annotation

GitHub Actions / Lint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={sanitizeUrl(avatarUrl)}
alt=""
width={120}
Expand Down
Loading