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
90 changes: 90 additions & 0 deletions src/app/api/cron/warm-search-featured/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import {
cohortSnapshotConfigured,
writeCohortSnapshot,
} from "@/lib/cohort-snapshot";
import { buildFeaturedLeaders } from "@/lib/search-featured";

export const runtime = "nodejs";
// No ISR — the cron's whole job is to refresh the blob. A cached 200 from
// a previous run would silently skip the rebuild.
export const dynamic = "force-dynamic";

/**
* Vercel cron: refresh the search dialog's "Live leaders" + "Trending"
* blob in Upstash. Runs every minute (vercel.json crons block).
*
* Why a dedicated blob: the search dialog used to fetch /api/citable on
* every open (the full ~30-bench citable index, ~50 KB, plus all the
* assembly cost server-side). Now the cron pre-computes the 12-card
* subset the dialog actually needs (~2 KB), the public endpoint becomes
* one KV GET, and the dialog opens with data already prefetched at page
* load.
*
* Same token gate + soft-no-op pattern as snapshot-perp-cohort.
*/

function isAuthorized(req: NextRequest): boolean {
const secret = (process.env.CRON_SECRET ?? "").trim();
const header = (req.headers.get("authorization") ?? "").trim();
if (!secret) {
return process.env.NODE_ENV !== "production";
}
const expected = Buffer.from(`Bearer ${secret}`);
const provided = Buffer.from(header);
if (provided.length !== expected.length) return false;
return timingSafeEqual(provided, expected);
}

export async function GET(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
if (!cohortSnapshotConfigured()) {
return NextResponse.json(
{
ok: true,
configured: false,
message:
"cohort snapshot store not configured (KV_REST_API_URL / UPSTASH_REDIS_REST_URL absent)",
},
{ status: 200 },
);
}

const startedAt = Date.now();
let blob;
try {
blob = await buildFeaturedLeaders();
} catch (err) {
return NextResponse.json(
{
ok: false,
stage: "build",
error: err instanceof Error ? err.message : String(err),
},
{ status: 502 },
);
}

try {
await writeCohortSnapshot("search-featured", blob);
} catch (err) {
return NextResponse.json(
{
ok: false,
stage: "write",
error: err instanceof Error ? err.message : String(err),
},
{ status: 502 },
);
}

return NextResponse.json({
ok: true,
featuredCount: blob.featured.length,
trendingCount: blob.trending.length,
durationMs: Date.now() - startedAt,
});
}
65 changes: 65 additions & 0 deletions src/app/api/search/featured/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { readCohortSnapshot } from "@/lib/cohort-snapshot";
import {
buildFeaturedLeaders,
type FeaturedLeadersBlob,
} from "@/lib/search-featured";

export const runtime = "nodejs";
// 60 s revalidate so even a cold edge cache only needs one round-trip
// per minute per region. The cron rewrites the underlying KV blob on the
// same cadence; stale-while-revalidate keeps every other request hot.
export const revalidate = 60;

/**
* Slim payload feeding the header search dialog's "Live leaders" +
* "Trending" sections. Reads through Upstash KV first (cron-warmed,
* ~5 ms response) and falls through to a live build only on cold KV
* (deploy minute zero, missing creds, or worker outage).
*
* The previous implementation called /api/citable which assembled the
* full ~30-bench citable index (~50 KB) on every dialog open. This
* route returns the 12-card subset (~2 KB) the dialog actually needs.
*/
export async function GET() {
const snap = await readCohortSnapshot<FeaturedLeadersBlob>("search-featured");
if (snap?.data) {
return NextResponse.json(
{ ok: true, source: "kv", ageMs: snap.ageMs, ...snap.data },
{
headers: {
"cache-control":
"public, s-maxage=60, stale-while-revalidate=300",
"access-control-allow-origin": "*",
},
},
);
}

// Cold-fall-through: build live. Slower (one full benchmark assembly)
// but never blocks the dialog if the cron hasn't run yet or Upstash is
// unreachable. The cron will heal this on its next tick.
try {
const blob = await buildFeaturedLeaders();
return NextResponse.json(
{ ok: true, source: "live", ageMs: 0, ...blob },
{
headers: {
"cache-control":
"public, s-maxage=60, stale-while-revalidate=300",
"access-control-allow-origin": "*",
},
},
);
} catch (err) {
return NextResponse.json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
featured: [],
trending: [],
},
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
}
124 changes: 51 additions & 73 deletions src/components/search/search-dialog.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,38 +62,13 @@ const KIND_ICON: Record<SearchKind, typeof Search> = {
};

/**
* Hand-picked editorial leaders. Shown as horizontal "Live leaders" cards
* when the query is empty. Live values (#1 provider + p50) come from
* `/api/citable`, which is already edge-cached for 300s. All slugs must
* appear in the citable response (editorialStatus === "live" + leader()
* non-null) — verified against prod before shipping.
* The featured + trending lists themselves now live in
* `src/lib/search-featured.ts` (single source of truth used by the cron
* that warms the KV blob and by the public endpoint that serves it).
* The dialog consumes the pre-resolved blob from SearchProvider, which
* fetches `/api/search/featured` at mount + on trigger hover.
*/
const FEATURED_BENCH_SLUGS = [
"pm-data-freshness",
"aggregator-head-lag",
"l1-finality",
"rpc-capabilities",
"perp-fees",
"bridge-quote-latency",
];

const TRENDING_BENCH_SLUGS = [
"stablecoin-peg-usdt-anchored",
"metadata-coverage",
"validator-yield",
"network-fees",
"perp-funding",
"solana-tx-landing",
];

type CitableLeader = {
slug: string;
title: string;
category: string;
value: number | null;
unit: string;
leader: { name: string; slug: string; value: number } | null;
};
import type { FeaturedCardData } from "@/lib/search-featured";

function Kbd({ children }: { children: React.ReactNode }) {
return (
Expand DownExpand Up@@ -139,33 +114,14 @@ function fmtUnit(value: number | null | undefined, unit: string): string {
}

export default function SearchDialog() {
const { items, close: onClose } = useSearch();
const { items, close: onClose, featured: featuredBlob } = useSearch();
const router = useRouter();
const [query, setQuery] = useState("");
const [isClosing, setIsClosing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { recent, push: pushRecent, remove: removeRecent, clear: clearRecent } =
useRecentSearches();

// Featured / trending live data. One fetch on mount, cached by browser
// since /api/citable ships s-maxage=300.
const [citable, setCitable] = useState<Map<string, CitableLeader> | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/citable")
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (cancelled || !j?.benchmarks) return;
const map = new Map<string, CitableLeader>();
for (const b of j.benchmarks as CitableLeader[]) map.set(b.slug, b);
setCitable(map);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

// One Fuse instance per dialog mount. The full corpus is ~400 docs
// so build cost is sub-millisecond, no need to memoise across mounts.
const fuse = useMemo(
Expand DownExpand Up@@ -222,27 +178,44 @@ export default function SearchDialog() {
return map;
}, [items]);

const featured = useMemo(() => {
return FEATURED_BENCH_SLUGS
.map((slug) => {
const item = benchItemBySlug.get(slug);
// Featured + trending lists arrive pre-resolved from SearchProvider
// (warmed by a cron + Upstash KV blob, served via /api/search/featured).
// We zip each card against the search index entry to inherit the URL
// and any extra item-level metadata. Missing search-index hits drop
// out silently so a stale slug never breaks the dialog.
const zip = (cards: FeaturedCardData[] | undefined) =>
(cards ?? [])
.map((card) => {
const item = benchItemBySlug.get(card.slug);
if (!item) return null;
const live = citable?.get(slug);
return { item, live: live ?? null };
return { item, live: card };
})
.filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x));
}, [benchItemBySlug, citable]);
.filter((x): x is { item: SearchItem; live: FeaturedCardData } => Boolean(x));

const featured = useMemo(
() => zip(featuredBlob?.featured),
// benchItemBySlug is stable for a given items array; zipping inside
// the memo keeps the array reference stable across renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
[benchItemBySlug, featuredBlob?.featured],
);

const trending = useMemo(() => {
return TRENDING_BENCH_SLUGS
.map((slug) => {
const item = benchItemBySlug.get(slug);
if (!item) return null;
const live = citable?.get(slug);
return { item, live: live ?? null };
})
.filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x));
}, [benchItemBySlug, citable]);
const trending = useMemo(
() => zip(featuredBlob?.trending),
// eslint-disable-next-line react-hooks/exhaustive-deps
[benchItemBySlug, featuredBlob?.trending],
);

// Flat slug → card lookup for the search-results render. Any bench
// appearing in featured OR trending gets its leader logo on the row;
// others fall back to the trophy / kind icon. Avoids an extra fetch
// and keeps the dialog snappy.
const featuredBySlug = useMemo(() => {
const map = new Map<string, FeaturedCardData>();
for (const c of featuredBlob?.featured ?? []) map.set(c.slug, c);
for (const c of featuredBlob?.trending ?? []) map.set(c.slug, c);
return map;
}, [featuredBlob]);

const results = useMemo<SearchItem[]>(() => {
const q = query.trim();
Expand DownExpand Up@@ -400,7 +373,7 @@ export default function SearchDialog() {
<SectionHeader
label="Live leaders"
action={
citable && (
featuredBlob && (
<span className="inline-flex items-center gap-1.5 text-[10px] text-ink-faint">
<span className="size-1.5 rounded-full bg-good animate-pulse" />
live
Expand All@@ -412,7 +385,7 @@ export default function SearchDialog() {
<div className="flex gap-2.5 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden snap-x snap-mandatory -mx-1 px-1">
{featured.map(({ item, live }) => {
const category = item.tags?.[0] ?? "Benchmark";
const isLoading = citable === null;
const isLoading = featuredBlob === null;
return (
<button
key={item.id}
Expand DownExpand Up@@ -471,7 +444,7 @@ export default function SearchDialog() {
<Command.Group className="px-2">
{trending.map(({ item, live }) => {
const category = item.tags?.[0] ?? "Benchmark";
const isLoading = citable === null;
const isLoading = featuredBlob === null;
return (
<Command.Item
key={item.id}
Expand DownExpand Up@@ -545,9 +518,14 @@ export default function SearchDialog() {
>
{list.map((it) => {
const entry = entryFromItem(it);
// For benchmark results, try to surface the current
// leader's logo if the bench is in our warmed featured/
// trending set. Otherwise fall through to the kind-icon
// fallback — we deliberately don't fetch /api/citable
// here to keep the dialog responsive.
const benchLive =
it.kind === "Benchmark" && entry.slug
? citable?.get(entry.slug)
? featuredBySlug.get(entry.slug)
: null;
const logoSlug =
it.kind === "Benchmark"
Expand Down
Loading
Loading