From e46376c42c4d4bb6957b28e1b4fc5be06776deb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 19:01:53 +0000 Subject: [PATCH] fix(tripwire): share aggregates promise across Suspense leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /x/tripwire page had two async server components (HeroLive and StatsLive) that each independently called getAggregates(). React renders them in parallel, so on a cold module cache both fired @vercel/blob's head() simultaneously. Under Bun on Vercel, the SDK's internal `await apiResponse.json()` after its Response goes out of scope can leave one of those parallel calls stuck waiting for EOF — its Suspense boundary then sat on its skeleton forever. A reload warmed the cache and both rendered. Two changes that converge on the fix: 1. The page kicks off getAggregates() once (without awaiting) and passes the promise to both leaves. One network call, both boundaries reveal together when the data lands. Page shell still streams immediately because the page itself isn't async. 2. aggregates.ts and stats.ts now construct the private blob URL from BLOB_READ_WRITE_TOKEN's storeId instead of calling head() to resolve it. The bearer-token fetch is the path we already know works on Bun-on-Vercel. No SDK call to hang on. --- src/app/x/tripwire/page.tsx | 27 +++++++++++++++---------- src/lib/tripwire/aggregates.test.ts | 31 ++++++++++------------------- src/lib/tripwire/aggregates.ts | 21 ++++++++++++++++--- src/lib/tripwire/stats.ts | 26 ++++++++++++++---------- 4 files changed, 59 insertions(+), 46 deletions(-) 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] }, })