@@ -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] },
})