diff --git a/app/next.config.ts b/app/next.config.ts index 2736ddf..d10ea3b 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -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: [ { @@ -53,11 +55,7 @@ const nextConfig: NextConfig = { }, { key: "Content-Type", - value: "application/json", - }, - { - key: "Content-Encoding", - value: "gzip", + value: "application/gzip", }, ], }, diff --git a/app/package-lock.json b/app/package-lock.json index ab64e8e..48ab599 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,6 +10,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" @@ -5908,6 +5909,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", diff --git a/app/package.json b/app/package.json index 8811358..60a32a6 100644 --- a/app/package.json +++ b/app/package.json @@ -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" diff --git a/app/src/lib/__tests__/athlete-shards.test.ts b/app/src/lib/__tests__/athlete-shards.test.ts index 140c014..2cbaded 100644 --- a/app/src/lib/__tests__/athlete-shards.test.ts +++ b/app/src/lib/__tests__/athlete-shards.test.ts @@ -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"; @@ -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): 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" }, }); } @@ -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); @@ -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)); @@ -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 () => { diff --git a/app/src/lib/athlete-shards.ts b/app/src/lib/athlete-shards.ts index f0d7165..811cf8e 100644 --- a/app/src/lib/athlete-shards.ts +++ b/app/src/lib/athlete-shards.ts @@ -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; @@ -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> { + const buf = new Uint8Array(await res.arrayBuffer()); + return JSON.parse(shardBytesToText(buf)) as Record; +} + async function loadAthleteShard(id: number): Promise> { 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 = {}; - 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; - // 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 = {}; + 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 { + // 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; }