Skip to content

Commit 13bf5e2

Browse files
authored
Merge pull request #12 from context-dot-dev/fix/brand-logo-credit-burn
fix: stop burning context.dev credits on brand-logo lookups (#11)
2 parents 28f2195 + 75b1aff commit 13bf5e2

7 files changed

Lines changed: 265 additions & 70 deletions

File tree

‎scripts/test-deduped-cache.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Standalone test for the deduped cache that backs brand-logo lookups. This is the
3+
* credit-burn safeguard from issue #11, so we prove — without touching the network —
4+
* that repeated/concurrent requests never fan out into repeated loads.
5+
*
6+
* Run: npx tsx scripts/test-deduped-cache.ts
7+
*/
8+
import assert from "node:assert/strict";
9+
import { createDedupedCache } from "../src/lib/deduped-cache";
10+
11+
let passed = 0;
12+
async function test(name: string, fn: () => Promise<void> | void) {
13+
await fn();
14+
passed++;
15+
console.log(` ok ${name}`);
16+
}
17+
18+
/** A loader that counts how many times each key was actually loaded. */
19+
function countingLoader<V>(map: Record<string, V>, delayMs = 0) {
20+
const calls: Record<string, number> = {};
21+
const load = async (key: string): Promise<V> => {
22+
calls[key] = (calls[key] ?? 0) + 1;
23+
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
24+
return map[key];
25+
};
26+
return { load, calls };
27+
}
28+
29+
async function main() {
30+
console.log("deduped-cache");
31+
32+
await test("concurrent requests for one key load it exactly once", async () => {
33+
const cache = createDedupedCache<string>({ ttlMs: 10_000 });
34+
const { load, calls } = countingLoader({ "openai.com": "logo-a" }, 20);
35+
const results = await Promise.all(
36+
Array.from({ length: 100 }, () => cache.get("openai.com", load)),
37+
);
38+
assert.equal(calls["openai.com"], 1, "expected a single load for 100 concurrent callers");
39+
assert.ok(results.every((r) => r === "logo-a"), "all callers get the same value");
40+
});
41+
42+
await test("repeated sequential requests within TTL never reload (refresh/HMR case)", async () => {
43+
let clock = 0;
44+
const cache = createDedupedCache<string>({ ttlMs: 10_000, now: () => clock });
45+
const { load, calls } = countingLoader({ "stripe.com": "logo-b" });
46+
// Simulate 50 page renders / hot-reloads over 5 seconds.
47+
for (let i = 0; i < 50; i++) {
48+
clock += 100;
49+
const v = await cache.get("stripe.com", load);
50+
assert.equal(v, "logo-b");
51+
}
52+
assert.equal(calls["stripe.com"], 1, "50 renders must cost exactly one load");
53+
});
54+
55+
await test("value is reloaded only after the TTL expires", async () => {
56+
let clock = 0;
57+
const cache = createDedupedCache<string>({ ttlMs: 1_000, now: () => clock });
58+
const { load, calls } = countingLoader({ "vercel.com": "logo-c" });
59+
await cache.get("vercel.com", load);
60+
clock += 500; // still fresh
61+
await cache.get("vercel.com", load);
62+
assert.equal(calls["vercel.com"], 1, "no reload before TTL");
63+
clock += 1_000; // now expired
64+
await cache.get("vercel.com", load);
65+
assert.equal(calls["vercel.com"], 2, "one reload after TTL");
66+
});
67+
68+
await test("distinct keys each load once; a mixed burst is deduped per key", async () => {
69+
const cache = createDedupedCache<string>({ ttlMs: 10_000 });
70+
const domains = ["a.com", "b.com", "c.com"];
71+
const { load, calls } = countingLoader({ "a.com": "1", "b.com": "2", "c.com": "3" }, 10);
72+
// 30 callers spread across 3 keys, all at once.
73+
await Promise.all(
74+
Array.from({ length: 30 }, (_, i) => cache.get(domains[i % 3], load)),
75+
);
76+
assert.deepEqual(calls, { "a.com": 1, "b.com": 1, "c.com": 1 });
77+
assert.equal(cache.size, 3);
78+
});
79+
80+
await test("failed loads are not cached and are retried on the next request", async () => {
81+
const cache = createDedupedCache<string>({ ttlMs: 10_000 });
82+
let attempts = 0;
83+
const flaky = async () => {
84+
attempts++;
85+
if (attempts === 1) throw new Error("transient");
86+
return "recovered";
87+
};
88+
await assert.rejects(() => cache.get("hn.com", flaky), /transient/);
89+
const v = await cache.get("hn.com", flaky); // should retry, not serve a poisoned entry
90+
assert.equal(v, "recovered");
91+
assert.equal(attempts, 2, "error must not be cached");
92+
assert.equal(cache.size, 1);
93+
});
94+
95+
await test("null results are cached (a domain with no logo isn't refetched)", async () => {
96+
const cache = createDedupedCache<string | null>({ ttlMs: 10_000 });
97+
let calls = 0;
98+
const load = async () => {
99+
calls++;
100+
return null;
101+
};
102+
assert.equal(await cache.get("nologo.com", load), null);
103+
assert.equal(await cache.get("nologo.com", load), null);
104+
assert.equal(calls, 1, "a cached null still counts as a hit");
105+
});
106+
107+
console.log(`\n${passed} tests passed`);
108+
}
109+
110+
main().catch((err) => {
111+
console.error("\nTEST FAILED:", err);
112+
process.exit(1);
113+
});

‎src/app/dashboard/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { websiteOwnerAccessible } from "@/lib/account-access";
55
import { requireUser } from "@/lib/session";
66
import { WebsiteListView } from "@/components/website-list-view";
77
import { effectiveContextDevApiKey } from "@/lib/server-managed-config";
8-
import { getStarterTemplatesWithLogos } from "@/lib/starter-template-logos.server";
8+
import { getStarterTemplatesWithLogos } from "@/lib/brand-logos.server";
99
import { STARTER_TEMPLATES } from "@/lib/starter-templates";
1010

1111
export default async function DashboardPage() {

‎src/app/page.tsx‎

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import { redirect } from "next/navigation";
33
import { APP_NAME } from "@/lib/product-info";
44
import { getCurrentSession } from "@/lib/session";
55
import { STARTER_TEMPLATES } from "@/lib/starter-templates";
6-
import { getStarterTemplatesWithLogos } from "@/lib/starter-template-logos.server";
6+
import { getBrandLogoMap } from "@/lib/brand-logos.server";
77
import { effectiveContextDevApiKey } from "@/lib/server-managed-config";
8-
import { retrieveBrand, pickBrandAssets } from "@/lib/context-client";
98
import { WatchInput } from "@/components/watch-input";
109
import { AnimatedNotifList } from "@/components/animated-notif-list";
1110
import type { NotifItem } from "@/components/animated-notif-list";
@@ -42,38 +41,24 @@ export default async function LandingPage() {
4241
if (session?.user) redirect("/dashboard");
4342

4443
const apiKey = effectiveContextDevApiKey(null);
45-
let templates: StarterTemplateWithLogo[] = STARTER_TEMPLATES.map((t) => ({ ...t, logoUrl: null }));
46-
try {
47-
templates = await getStarterTemplatesWithLogos(apiKey);
48-
} catch {
49-
/* fall back to initials */
50-
}
51-
52-
const logoMap = Object.fromEntries(templates.map((t) => [t.domain, t.logoUrl]));
53-
54-
// Fetch logos for any notif logoDomain overrides not already in the template map.
55-
const extraDomains = [...new Set(
56-
RAW_NOTIFS.filter((n) => n.logoDomain && !(n.logoDomain in logoMap)).map((n) => n.logoDomain!),
57-
)];
58-
const extraLogoMap: Record<string, string | null> = {};
59-
if (apiKey && extraDomains.length > 0) {
60-
await Promise.all(
61-
extraDomains.map(async (d) => {
62-
try {
63-
const brand = await retrieveBrand(d, { apiKey });
64-
const { logoUrl } = pickBrandAssets(brand);
65-
extraLogoMap[d] = logoUrl;
66-
} catch {
67-
extraLogoMap[d] = null;
68-
}
69-
}),
70-
);
71-
}
44+
45+
// One cached, deduped lookup for every domain the page shows (templates + notif brands).
46+
const logoMap = await getBrandLogoMap(
47+
[
48+
...STARTER_TEMPLATES.map((t) => t.domain),
49+
...RAW_NOTIFS.map((n) => n.logoDomain ?? n.domain),
50+
],
51+
apiKey,
52+
);
53+
54+
const templates: StarterTemplateWithLogo[] = STARTER_TEMPLATES.map((t) => ({
55+
...t,
56+
logoUrl: logoMap[t.domain] ?? null,
57+
}));
7258

7359
const notifItems: NotifItem[] = RAW_NOTIFS.map((n) => {
7460
const lookupDomain = n.logoDomain ?? n.domain;
75-
const logoUrl = logoMap[lookupDomain] ?? extraLogoMap[lookupDomain] ?? null;
76-
return { ...n, logoUrl };
61+
return { ...n, logoUrl: logoMap[lookupDomain] ?? null };
7762
});
7863

7964
return (

‎src/lib/brand-logos.server.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import "server-only";
2+
import { pickBrandAssets, retrieveBrand } from "./context-client";
3+
import { createDedupedCache, type DedupedCache } from "./deduped-cache";
4+
import {
5+
STARTER_TEMPLATES,
6+
type StarterTemplateWithLogo,
7+
} from "./starter-templates";
8+
9+
/**
10+
* Brand-logo lookups are expensive (each one costs context.dev credits), yet the
11+
* landing page and empty dashboard need logos for the same fixed set of well-known
12+
* domains on every render. We memoize by domain so a domain is fetched at most once
13+
* per TTL per process, and dedupe concurrent lookups so a burst of renders (or dev
14+
* hot-reloads) can't fan out into repeated retrievals.
15+
*
16+
* The cache lives on `globalThis` so it survives Next.js dev HMR module re-evaluation
17+
* (module-level state is otherwise reset on every code change).
18+
*/
19+
const TTL_MS = 1000 * 60 * 60 * 24; // 24h — logos rarely change.
20+
21+
const store = globalThis as typeof globalThis & {
22+
__brandLogoCache?: DedupedCache<string | null>;
23+
};
24+
25+
const logoCache = (store.__brandLogoCache ??= createDedupedCache<string | null>({
26+
ttlMs: TTL_MS,
27+
}));
28+
29+
async function fetchLogo(domain: string, apiKey: string): Promise<string | null> {
30+
try {
31+
const brand = await retrieveBrand(domain, { apiKey, maxAgeMs: TTL_MS });
32+
return pickBrandAssets(brand).logoUrl;
33+
} catch {
34+
return null;
35+
}
36+
}
37+
38+
/** Resolve one domain's logo, served from cache when fresh and deduped while in flight. */
39+
export async function getBrandLogoUrl(
40+
domain: string,
41+
apiKey: string | null,
42+
): Promise<string | null> {
43+
if (!apiKey) return null;
44+
return logoCache.get(domain, (d) => fetchLogo(d, apiKey));
45+
}
46+
47+
/** Resolve many domains at once, deduped, returning a `{ domain: logoUrl }` map. */
48+
export async function getBrandLogoMap(
49+
domains: string[],
50+
apiKey: string | null,
51+
): Promise<Record<string, string | null>> {
52+
const unique = [...new Set(domains)];
53+
const entries = await Promise.all(
54+
unique.map(async (domain) => [domain, await getBrandLogoUrl(domain, apiKey)] as const),
55+
);
56+
return Object.fromEntries(entries);
57+
}
58+
59+
/** Starter templates with their brand logos resolved (empty state on landing/dashboard). */
60+
export async function getStarterTemplatesWithLogos(
61+
apiKey: string | null,
62+
): Promise<StarterTemplateWithLogo[]> {
63+
const logoMap = await getBrandLogoMap(
64+
STARTER_TEMPLATES.map((t) => t.domain),
65+
apiKey,
66+
);
67+
return STARTER_TEMPLATES.map((t) => ({ ...t, logoUrl: logoMap[t.domain] ?? null }));
68+
}

‎src/lib/context-client.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,10 +358,16 @@ export interface BrandData {
358358

359359
export async function retrieveBrand(
360360
domain: string,
361-
options?: { apiKey?: string | null },
361+
options?: { apiKey?: string | null; maxAgeMs?: number },
362362
): Promise<BrandData> {
363+
// `maxAgeMs: 0` forces a fresh (billable) retrieval. Callers that only need a
364+
// logo/asset (e.g. starter templates) should pass a large window so context.dev
365+
// can serve its cached brand record instead of re-crawling.
363366
const res = await withContextDevErrors(() =>
364-
contextDevClient(options?.apiKey).brand.retrieve({ domain, maxAgeMs: 0 }),
367+
contextDevClient(options?.apiKey).brand.retrieve({
368+
domain,
369+
maxAgeMs: options?.maxAgeMs ?? 0,
370+
}),
365371
);
366372
return toBrandData(res.brand, domain);
367373
}

‎src/lib/deduped-cache.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* A tiny async cache that guarantees each key is loaded at most once per TTL and
3+
* deduplicates concurrent loads of the same key (a burst of callers awaiting the
4+
* same key share a single in-flight promise). Failed loads are never cached, so a
5+
* transient error doesn't poison the key.
6+
*
7+
* This is deliberately dependency-free so it can be unit-tested in isolation — it
8+
* backs the brand-logo lookups that would otherwise burn context.dev credits on
9+
* every render / dev hot-reload.
10+
*/
11+
export type DedupedCache<V> = {
12+
/** Return the cached value if fresh, join an in-flight load, or start a new one. */
13+
get(key: string, load: (key: string) => Promise<V>): Promise<V>;
14+
/** Drop all cached + in-flight entries. */
15+
clear(): void;
16+
/** Number of resolved entries currently cached. */
17+
readonly size: number;
18+
};
19+
20+
export function createDedupedCache<V>(options: {
21+
ttlMs: number;
22+
/** Injectable clock for testing; defaults to `Date.now`. */
23+
now?: () => number;
24+
}): DedupedCache<V> {
25+
const { ttlMs } = options;
26+
const now = options.now ?? Date.now;
27+
const cache = new Map<string, { value: V; at: number }>();
28+
const inflight = new Map<string, Promise<V>>();
29+
30+
async function get(key: string, load: (key: string) => Promise<V>): Promise<V> {
31+
const hit = cache.get(key);
32+
if (hit && now() - hit.at < ttlMs) return hit.value;
33+
34+
const pending = inflight.get(key);
35+
if (pending) return pending;
36+
37+
const promise = (async () => {
38+
const value = await load(key);
39+
cache.set(key, { value, at: now() });
40+
return value;
41+
})().finally(() => {
42+
inflight.delete(key);
43+
});
44+
45+
inflight.set(key, promise);
46+
return promise;
47+
}
48+
49+
return {
50+
get,
51+
clear() {
52+
cache.clear();
53+
inflight.clear();
54+
},
55+
get size() {
56+
return cache.size;
57+
},
58+
};
59+
}

‎src/lib/starter-template-logos.server.ts‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)