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
27 changes: 16 additions & 11 deletions src/app/x/tripwire/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <Hero lifetime={aggregates.lifetime} />
// 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<Aggregates> }) {
const data = await aggregates
return <Hero lifetime={data.lifetime} />
}

async function StatsLive() {
const aggregates = await getAggregates()
return <StatsPanel aggregates={aggregates} />
async function StatsLive({ aggregates }: { aggregates: Promise<Aggregates> }) {
const data = await aggregates
return <StatsPanel aggregates={data} />
}


export default function TripwirePage() {
const aggregates = getAggregates()
return (
<main className="min-h-screen px-6 py-12 sm:px-16 sm:py-16">
<div className="mx-auto max-w-[720px]">
Expand All @@ -97,7 +102,7 @@ export default function TripwirePage() {
</header>

<Suspense fallback={<HeroSkeleton />}>
<HeroLive />
<HeroLive aggregates={aggregates} />
</Suspense>

<article className="prose-hyphens text-[16px] leading-[1.65]">
Expand Down Expand Up @@ -220,7 +225,7 @@ export default function TripwirePage() {
some of what I&rsquo;ve caught so far.
</p>
<Suspense fallback={<StatsPanelSkeleton />}>
<StatsLive />
<StatsLive aggregates={aggregates} />
</Suspense>
</section>

Expand Down
31 changes: 10 additions & 21 deletions src/lib/tripwire/aggregates.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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_<storeId>_<rest>. With this fixture the
// derived URL is https://teststore.private.blob.vercel-storage.com/<key>.
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 })
Expand All @@ -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"
})
Expand All @@ -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])
})
Expand Down
21 changes: 18 additions & 3 deletions src/lib/tripwire/aggregates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,15 +32,24 @@ const TTL_MS = 2 * 60 * 1000

let cached: { data: Aggregates; fetchedAt: number } | null = null

// Token format is `vercel_blob_rw_<storeId>_<rest>`. 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<Aggregates> {
if (cached && Date.now() - cached.fetchedAt < TTL_MS) {
return cached.data
}
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] },
})
Expand Down
26 changes: 15 additions & 11 deletions src/lib/tripwire/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,6 +32,16 @@ export { STATS_BLOB_KEY, DEFAULT_TOP_PATHS, type Aggregates }

let cachedAsnReader: ReaderModel | null = null

// Token format is `vercel_blob_rw_<storeId>_<rest>`. 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<ReaderModel> {
if (cachedAsnReader) {
slog.debug({ step: "asn.cache_hit" })
Expand All @@ -40,20 +50,14 @@ async function getAsnReader(): Promise<ReaderModel> {
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] },
})
Expand Down
Loading