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
16 changes: 7 additions & 9 deletions app/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ const nextConfig: NextConfig = {
},
{
// Sharded athlete profiles, fetched at render time by the edge-runtime
// athlete page. The files are pre-gzipped on disk; declaring
// Content-Encoding makes fetch decompress them at the HTTP layer, so
// the edge code needs no zlib/DecompressionStream (the latter doesn't
// exist in Next's edge sandbox).
// athlete page. Served as raw gzip bytes (Content-Type: application/gzip,
// NO Content-Encoding) and decompressed explicitly by the edge code —
// same as the search shards above. A hand-set Content-Encoding: gzip is
// NOT honored by Vercel's CDN on public/ assets, so relying on HTTP-layer
// auto-decompression delivered raw gzip to res.json() and 404'd every
// athlete (see app/src/lib/athlete-shards.ts).
source: "/athlete-shards/:path*",
headers: [
{
Expand All @@ -53,11 +55,7 @@ const nextConfig: NextConfig = {
},
{
key: "Content-Type",
value: "application/json",
},
{
key: "Content-Encoding",
value: "gzip",
value: "application/gzip",
},
],
},
Expand Down
7 changes: 7 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"fflate": "^0.8.3",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
Expand Down
68 changes: 58 additions & 10 deletions app/src/lib/__tests__/athlete-shards.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { gzipSync } from "node:zlib";
import { shardId, SHARD_COUNT } from "@/lib/athlete-shards";
import type { AthleteProfile } from "@/lib/types";
import { createRequire } from "module";
Expand Down Expand Up @@ -84,13 +85,15 @@ describe("getAthleteProfile (edge-compatible fetch)", () => {
return { slug, fullName: "Ann Lee", country: "United States", countryISO: "US", races: [] };
}

// Shards are stored gzipped but served with Content-Encoding: gzip (see
// next.config.ts), so fetch hands the module already-decompressed JSON —
// which is what this mock returns.
// Shards are served as RAW gzip bytes (Content-Type: application/gzip, no
// Content-Encoding — see next.config.ts) and the module decompresses them
// explicitly. This mirrors how Vercel's CDN actually delivers the files:
// a manually-set Content-Encoding: gzip is not honored there, so relying on
// the HTTP layer to auto-decompress produced garbage and a false 404.
function shardResponse(shard: Record<string, AthleteProfile>): Response {
return new Response(JSON.stringify(shard), {
return new Response(new Uint8Array(gzipSync(JSON.stringify(shard))), {
status: 200,
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/gzip" },
});
}

Expand All @@ -111,7 +114,7 @@ describe("getAthleteProfile (edge-compatible fetch)", () => {
vi.unstubAllGlobals();
});

it("fetches the slug's shard and returns the profile", async () => {
it("fetches the slug's shard (raw gzip) and returns the profile", async () => {
const shard = { [SLUG]: makeProfile(SLUG) };
const fetchMock = vi.fn(async () => shardResponse(shard));
vi.stubGlobal("fetch", fetchMock);
Expand All @@ -133,6 +136,24 @@ describe("getAthleteProfile (edge-compatible fetch)", () => {
expect(await mod.getAthleteProfile(SLUG)).toBeNull();
});

// Some servers (e.g. `next start`) transfer-decompress the response, so the
// body arrives as plain JSON rather than gzip. The module must handle both;
// it only inflates when the gzip magic bytes are present.
it("handles a shard body already decompressed to plain JSON", async () => {
const shard = { [SLUG]: makeProfile(SLUG) };
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify(shard), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);

const mod = await freshModule();
expect(await mod.getAthleteProfile(SLUG)).toEqual(makeProfile(SLUG));
});

it("memoizes the shard: a second lookup does not refetch", async () => {
const shard = { [SLUG]: makeProfile(SLUG) };
const fetchMock = vi.fn(async () => shardResponse(shard));
Expand All @@ -145,28 +166,55 @@ describe("getAthleteProfile (edge-compatible fetch)", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

// Regression test for the production 404 bug: Vercel's CDN does not honor a
// hand-set `Content-Encoding: gzip` on public/ assets, so the edge fetch
// received raw gzip bytes. The old code called res.json() on those bytes,
// which threw, was swallowed, and reported the (real) athlete as missing →
// 404. The module must decompress the gzip itself.
it("throws on a non-ok response instead of reporting the athlete missing", async () => {
const fetchMock = vi.fn(async () => new Response("unauthorized", { status: 401 }));
vi.stubGlobal("fetch", fetchMock);

const mod = await freshModule();
await expect(mod.getAthleteProfile(SLUG)).rejects.toThrow();
});

it("throws when the body is not valid gzip (never a silent null → false 404)", async () => {
const fetchMock = vi.fn(
async () =>
new Response(new Uint8Array([1, 2, 3, 4]), {
status: 200,
headers: { "Content-Type": "application/gzip" },
}),
);
vi.stubGlobal("fetch", fetchMock);

const mod = await freshModule();
await expect(mod.getAthleteProfile(SLUG)).rejects.toThrow();
});

it("does not cache a failed load — the next lookup retries", async () => {
const shard = { [SLUG]: makeProfile(SLUG) };
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("nope", { status: 401 }))
.mockRejectedValueOnce(new Error("network down"))
.mockResolvedValueOnce(shardResponse(shard));
vi.stubGlobal("fetch", fetchMock);

const mod = await freshModule();
expect(await mod.getAthleteProfile(SLUG)).toBeNull();
await expect(mod.getAthleteProfile(SLUG)).rejects.toThrow();
expect(await mod.getAthleteProfile(SLUG)).toEqual(makeProfile(SLUG));
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("returns null (and does not throw) on network failure", async () => {
it("throws (not returns null) on network failure so it is never a false 404", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("network down");
});
vi.stubGlobal("fetch", fetchMock);

const mod = await freshModule();
expect(await mod.getAthleteProfile(SLUG)).toBeNull();
await expect(mod.getAthleteProfile(SLUG)).rejects.toThrow();
});

it("fetches from VERCEL_URL and sends the protection-bypass header when set", async () => {
Expand Down
94 changes: 65 additions & 29 deletions app/src/lib/athlete-shards.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { gunzipSync, strFromU8 } from "fflate";
import { AthleteProfile } from "./types";

// Edge-compatible athlete-shard access for /athlete/[slug] (runtime = "edge").
// Uses only fetch — no fs/path/zlib — so it runs on the Edge runtime as well
// as Node (`next dev` / vitest). Keep Node-only modules (like data.ts) out of
// this file's import graph. The shards are stored gzipped but served with
// Content-Encoding: gzip (next.config.ts), so fetch decompresses them at the
// HTTP layer — DecompressionStream is NOT available in Next's edge sandbox.
// Uses only fetch + fflate (pure-JS gunzip) — no fs/path/zlib and no
// DecompressionStream — so it runs on the Edge runtime as well as Node
// (`next dev` / vitest). Keep Node-only modules (like data.ts) out of this
// file's import graph.
//
// The shards ship as raw gzip bytes (Content-Type: application/gzip, NO
// Content-Encoding — see next.config.ts) and are decompressed here explicitly,
// mirroring the search shards. Two earlier approaches both 404'd every real
// athlete in production:
// 1. Serving them with a hand-set `Content-Encoding: gzip` and relying on
// fetch to auto-decompress at the HTTP layer. Vercel's CDN does not honor
// that header on public/ assets, so the edge fetch received raw gzip and
// res.json() threw.
// 2. Decompressing with DecompressionStream — Next's edge runtime rejects it
// ("A Node.js API is used (DecompressionStream) which is not supported in
// the Edge Runtime"), so it threw at render time.
// fflate is pure JS (typed arrays only), so it works in the edge runtime.

export const SHARD_COUNT = 1024;

Expand Down Expand Up @@ -36,37 +49,60 @@ function shardOrigin(): string {
return `http://localhost:${process.env.PORT ?? 3000}`;
}

// The shard bytes may arrive still-gzipped or already decompressed, depending
// on whether the HTTP layer honored the on-disk gzip:
// - Vercel's CDN serves the raw .gz bytes (Content-Encoding is not applied to
// public/ assets), so we receive gzip and must inflate it ourselves.
// - Some servers (e.g. `next start`) transfer-decompress the response, so we
// receive plain JSON already.
// Detect the gzip magic (0x1f 0x8b) and only inflate when it's actually there,
// so the same code path works in both environments.
function shardBytesToText(buf: Uint8Array): string {
const isGzip = buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
return strFromU8(isGzip ? gunzipSync(buf) : buf);
}

async function decompressShard(res: Response): Promise<Record<string, AthleteProfile>> {
const buf = new Uint8Array(await res.arrayBuffer());
return JSON.parse(shardBytesToText(buf)) as Record<string, AthleteProfile>;
}

async function loadAthleteShard(id: number): Promise<Record<string, AthleteProfile>> {
const cached = shardCache.get(id);
if (cached) return cached;
try {
// On protected (preview) deployments the self-fetch is unauthenticated and
// would 401; send the automation-bypass secret when Vercel provides it.
// No-op in production, where deployments aren't protected.
const headers: Record<string, string> = {};
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypass) headers["x-vercel-protection-bypass"] = bypass;
// force-cache keeps this fetch in Next's data cache across requests.
const res = await fetch(`${shardOrigin()}/athlete-shards/${id}.json.gz`, {
cache: "force-cache",
headers,
});
if (res.ok) {
const shard = (await res.json()) as Record<string, AthleteProfile>;
// Cache only on success — every shard exists, so a failure is transient
// (network blip, cold CDN, 401 before bypass). Caching {} here would turn
// a transient error into persistent "not found" for the whole shard until
// the isolate recycles.
shardCache.set(id, shard);
return shard;
}
} catch {
// Network/parse failure → fall through; don't cache, so the next request retries.

// On protected (preview) deployments the self-fetch is unauthenticated and
// would 401; send the automation-bypass secret when Vercel provides it.
// No-op in production, where deployments aren't protected.
const headers: Record<string, string> = {};
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypass) headers["x-vercel-protection-bypass"] = bypass;
// force-cache keeps this fetch in Next's data cache across requests.
const res = await fetch(`${shardOrigin()}/athlete-shards/${id}.json.gz`, {
cache: "force-cache",
headers,
});

if (!res.ok) {
// Every shard 0..1023 is built and deployed, so a non-ok status is a load
// failure (network blip, cold CDN, 401 before bypass), NOT "athlete does
// not exist". Throw so the page renders a retryable error instead of a
// false 404 that tells a real athlete they aren't in the data. We don't
// cache it, so the next request retries.
throw new Error(`athlete shard ${id} fetch failed: ${res.status}`);
}
return {};

// Decompress + parse can also throw (truncated download, corrupt bytes).
// Let it propagate for the same reason — a load failure must never be
// silently converted into "not found".
const shard = await decompressShard(res);
shardCache.set(id, shard);
return shard;
}

export async function getAthleteProfile(slug: string): Promise<AthleteProfile | null> {
// A missing slug within a successfully-loaded shard is the ONLY genuine
// "not found" (→ null → notFound() → 404). Load failures throw above.
const shard = await loadAthleteShard(shardId(slug));
return shard[slug] ?? null;
}