diff --git a/src/app/x/tripwire/page.tsx b/src/app/x/tripwire/page.tsx index 303d343..143ab2d 100644 --- a/src/app/x/tripwire/page.tsx +++ b/src/app/x/tripwire/page.tsx @@ -5,6 +5,7 @@ import { Suspense } from "react" import ThemeToggle from "@/components/ThemeToggle" import { getAggregates } from "@/lib/tripwire/aggregates" +import type { Aggregates } from "@/lib/tripwire/aggregate-shape" import { BombDemo } from "./_components/BombDemo" import { Hero, @@ -59,21 +60,25 @@ function Ext({ href, children }: { href: string; children: React.ReactNode }) { ) } -// Async leaves: the blob fetch happens here, suspended out of the page -// shell so the response streams immediately and the numbers stream in -// when the fetch resolves. -async function HeroLive() { - const aggregates = await getAggregates() - return +// Async leaves: each one awaits a shared promise that the page kicks +// off (without awaiting) so the page shell streams immediately, the +// fetch happens exactly once, and both Suspense boundaries reveal +// when the data lands. Calling getAggregates() inside each leaf +// independently used to race on a cold module cache and one of the +// SDK calls would hang, leaving its boundary stuck on the skeleton. +async function HeroLive({ aggregates }: { aggregates: Promise }) { + const data = await aggregates + return } -async function StatsLive() { - const aggregates = await getAggregates() - return +async function StatsLive({ aggregates }: { aggregates: Promise }) { + const data = await aggregates + return } export default function TripwirePage() { + const aggregates = getAggregates() return (
@@ -97,7 +102,7 @@ export default function TripwirePage() { }> - +
@@ -220,7 +225,7 @@ export default function TripwirePage() { some of what I’ve caught so far.

}> - + diff --git a/src/lib/tripwire/aggregates.test.ts b/src/lib/tripwire/aggregates.test.ts index f88b3ae..853efbd 100644 --- a/src/lib/tripwire/aggregates.test.ts +++ b/src/lib/tripwire/aggregates.test.ts @@ -1,6 +1,5 @@ // src/lib/tripwire/aggregates.test.ts -import { describe, test, expect, beforeEach, mock } from "bun:test" -import * as blob from "@vercel/blob" +import { describe, test, expect, beforeEach } from "bun:test" import { STATS_BLOB_TAG, type Aggregates } from "@/lib/tripwire/aggregate-shape" const SAMPLE: Aggregates = { @@ -21,24 +20,17 @@ const SAMPLE: Aggregates = { byAsn: [], } -const FAKE_URL = "https://store.private.blob.vercel-storage.com/stats/tripwire-aggregates.json" +// Token format: vercel_blob_rw__. With this fixture the +// derived URL is https://teststore.private.blob.vercel-storage.com/. +process.env.BLOB_READ_WRITE_TOKEN = "vercel_blob_rw_teststore_secret" +const EXPECTED_URL = + "https://teststore.private.blob.vercel-storage.com/stats/tripwire-aggregates.json" -interface HeadCall { pathname: string } interface FetchCall { url: string; init: RequestInit | undefined } - -const headCalls: HeadCall[] = [] const fetchCalls: FetchCall[] = [] type FetchMode = "ok" | "bad-status" let fetchMode: FetchMode = "ok" -mock.module("@vercel/blob", () => ({ - ...blob, - head: async (pathname: string) => { - headCalls.push({ pathname }) - return { url: FAKE_URL, pathname } - }, -})) - const realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { fetchCalls.push({ url: String(input), init }) @@ -51,13 +43,10 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { }) }) as typeof fetch -process.env.BLOB_READ_WRITE_TOKEN = "vercel_blob_rw_test_token" - const { getAggregates, _resetAggregatesCacheForTests } = await import("./aggregates") beforeEach(() => { _resetAggregatesCacheForTests() - headCalls.length = 0 fetchCalls.length = 0 fetchMode = "ok" }) @@ -66,12 +55,12 @@ describe("getAggregates", () => { test("cache miss fetches and parses the blob", async () => { const result = await getAggregates() expect(result).toEqual(SAMPLE) - expect(headCalls).toHaveLength(1) - expect(headCalls[0].pathname).toBe("stats/tripwire-aggregates.json") expect(fetchCalls).toHaveLength(1) - expect(fetchCalls[0].url).toBe(FAKE_URL) + expect(fetchCalls[0].url).toBe(EXPECTED_URL) const headers = new Headers(fetchCalls[0].init?.headers) - expect(headers.get("authorization")).toBe("Bearer vercel_blob_rw_test_token") + expect(headers.get("authorization")).toBe( + "Bearer vercel_blob_rw_teststore_secret", + ) const next = (fetchCalls[0].init as { next?: { tags?: string[] } } | undefined)?.next expect(next?.tags).toEqual([STATS_BLOB_TAG]) }) diff --git a/src/lib/tripwire/aggregates.ts b/src/lib/tripwire/aggregates.ts index e727f5d..39f2688 100644 --- a/src/lib/tripwire/aggregates.ts +++ b/src/lib/tripwire/aggregates.ts @@ -11,11 +11,17 @@ // instance without crossing the network at all. Stale data is fine for // up to 2 minutes — the cron only runs every 15. // +// We bypass @vercel/blob's head()/get() entirely. The SDK ends every +// API call with `await apiResponse.json()` after its internal Response +// goes out of scope, which under Bun on Vercel can leave the body +// stream stuck waiting for EOF. We construct the blob URL ourselves +// from BLOB_READ_WRITE_TOKEN's storeId and call fetch directly so the +// Response stays in scope across the .json() drain. +// // On any fetch error we throw — `src/app/x/tripwire/error.tsx` surfaces // a retry button. We deliberately don't fall back to stale data; a hard // failure is better than silently lying about freshness. -import { head } from "@vercel/blob" import { STATS_BLOB_KEY, STATS_BLOB_TAG, @@ -26,6 +32,16 @@ const TTL_MS = 2 * 60 * 1000 let cached: { data: Aggregates; fetchedAt: number } | null = null +// Token format is `vercel_blob_rw__`. The SDK does the +// same split internally to construct private blob URLs. +function privateBlobUrl(pathname: string, token: string): string { + const storeId = token.split("_")[3] + if (!storeId) { + throw new Error("could not extract store id from BLOB_READ_WRITE_TOKEN") + } + return `https://${storeId}.private.blob.vercel-storage.com/${pathname}` +} + export async function getAggregates(): Promise { if (cached && Date.now() - cached.fetchedAt < TTL_MS) { return cached.data @@ -33,8 +49,7 @@ export async function getAggregates(): Promise { const token = process.env.BLOB_READ_WRITE_TOKEN if (!token) throw new Error("BLOB_READ_WRITE_TOKEN is not set") - const meta = await head(STATS_BLOB_KEY) - const res = await fetch(meta.url, { + const res = await fetch(privateBlobUrl(STATS_BLOB_KEY, token), { headers: { authorization: `Bearer ${token}` }, next: { tags: [STATS_BLOB_TAG] }, }) diff --git a/src/lib/tripwire/stats.ts b/src/lib/tripwire/stats.ts index 6daf0af..424b4ff 100644 --- a/src/lib/tripwire/stats.ts +++ b/src/lib/tripwire/stats.ts @@ -11,7 +11,7 @@ // it across cron invocations and only the first cold instance pays the // ~10MB blob fetch. -import { head, put } from "@vercel/blob" +import { put } from "@vercel/blob" import { Reader, type Asn, type ReaderModel } from "@maxmind/geoip2-node" import { sql } from "drizzle-orm" import { getDb } from "@/db" @@ -32,6 +32,16 @@ export { STATS_BLOB_KEY, DEFAULT_TOP_PATHS, type Aggregates } let cachedAsnReader: ReaderModel | null = null +// Token format is `vercel_blob_rw__`. The SDK does the +// same split internally to construct private blob URLs. +function privateBlobUrl(pathname: string, token: string): string { + const storeId = token.split("_")[3] + if (!storeId) { + throw new Error("could not extract store id from BLOB_READ_WRITE_TOKEN") + } + return `https://${storeId}.private.blob.vercel-storage.com/${pathname}` +} + async function getAsnReader(): Promise { if (cachedAsnReader) { slog.debug({ step: "asn.cache_hit" }) @@ -40,20 +50,14 @@ async function getAsnReader(): Promise { const token = process.env.BLOB_READ_WRITE_TOKEN if (!token) throw new Error("BLOB_READ_WRITE_TOKEN is not set") - // head() resolves the (stable) blob URL for the pathname. The body is - // small JSON metadata, so it doesn't trip the large-body stream hang we - // hit when calling get() on the 12MB mmdb directly. - const tHead = Date.now() - slog.debug({ step: "asn.head_start", key: ASN_BLOB_KEY }) - const meta = await head(ASN_BLOB_KEY) - slog.debug({ step: "asn.head_done", elapsed_ms: Date.now() - tHead, url: meta.url }) - // Direct fetch with the bearer token, tagged for the Next.js data cache. // tripwire-asn-update calls revalidateTag(ASN_BLOB_TAG) after a fresh put, // so we only pay for the 12MB drain when the mmdb actually changed. + // We bypass @vercel/blob's head() because it goes through the SDK's + // body-drain pattern that hangs on Bun-on-Vercel. const tFetch = Date.now() - slog.debug({ step: "asn.fetch_start" }) - const res = await fetch(meta.url, { + slog.debug({ step: "asn.fetch_start", key: ASN_BLOB_KEY }) + const res = await fetch(privateBlobUrl(ASN_BLOB_KEY, token), { headers: { authorization: `Bearer ${token}` }, next: { tags: [ASN_BLOB_TAG] }, })