diff --git a/db/kv-cache.ts b/db/kv-cache.ts new file mode 100644 index 0000000..5e2055d --- /dev/null +++ b/db/kv-cache.ts @@ -0,0 +1,32 @@ +import { env } from "cloudflare:workers"; + +function getKV(): KVNamespace { + return env.KV; +} + +export async function kvCached( + key: string, + fn: () => Promise, +): Promise { + const kv = getKV(); + const cached = await kv.get(key, "json"); + if (cached !== null) return cached as T; + + const result = await fn(); + // Fire-and-forget write — don't block the response + kv.put(key, JSON.stringify(result)).catch(() => {}); + return result; +} + +export function cacheKey( + name: string, + params?: Record, +): string { + if (!params || Object.keys(params).length === 0) return name; + const sorted = Object.entries(params) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) + .join(":"); + return `${name}:${sorted}`; +} diff --git a/scripts/cache-purge.ts b/scripts/cache-purge.ts index 7d0172f..cf7f672 100644 --- a/scripts/cache-purge.ts +++ b/scripts/cache-purge.ts @@ -2,13 +2,15 @@ import "dotenv/config"; const zoneId = process.env.CF_ZONE_ID; const apiToken = process.env.CF_API_TOKEN; +const kvNamespaceId = process.env.CF_KV_NAMESPACE_ID; if (!zoneId || !apiToken) { console.error("Missing CF_ZONE_ID or CF_API_TOKEN environment variables"); process.exit(1); } -const response = await fetch( +// Purge Cloudflare edge cache +const cacheResponse = await fetch( `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, { method: "POST", @@ -20,11 +22,73 @@ const response = await fetch( }, ); -const result = await response.json(); +const cacheResult = await cacheResponse.json(); -if (result.success) { - console.log("Cache purged successfully"); +if (cacheResult.success) { + console.log("Edge cache purged"); } else { - console.error("Cache purge failed:", result.errors); + console.error("Edge cache purge failed:", cacheResult.errors); process.exit(1); } + +// Purge KV namespace +if (!kvNamespaceId) { + console.log("No CF_KV_NAMESPACE_ID set, skipping KV purge"); +} else { + const accountId = process.env.CF_ACCOUNT_ID; + if (!accountId) { + console.error("Missing CF_ACCOUNT_ID for KV purge"); + process.exit(1); + } + + // List all keys + let cursor: string | undefined; + const allKeys: string[] = []; + + do { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + + const listRes = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/keys?${params}`, + { headers: { Authorization: `Bearer ${apiToken}` } }, + ); + + const listData: any = await listRes.json(); + if (!listData.success) { + console.error("KV list failed:", listData.errors); + process.exit(1); + } + + allKeys.push(...listData.result.map((k: { name: string }) => k.name)); + cursor = listData.result_info?.cursor; + } while (cursor); + + if (allKeys.length === 0) { + console.log("KV namespace empty, nothing to purge"); + } else { + // Bulk delete (max 10,000 per request) + for (let i = 0; i < allKeys.length; i += 10000) { + const batch = allKeys.slice(i, i + 10000); + const delRes = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/bulk`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(batch), + }, + ); + + const delData: any = await delRes.json(); + if (!delData.success) { + console.error("KV bulk delete failed:", delData.errors); + process.exit(1); + } + } + + console.log(`KV purged: ${allKeys.length} keys deleted`); + } +} diff --git a/src/lib/server-fns.ts b/src/lib/server-fns.ts index baacd5f..30931e6 100644 --- a/src/lib/server-fns.ts +++ b/src/lib/server-fns.ts @@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start"; import { nanoid } from "nanoid"; import { getDb, getTursoClient } from "../../db/client"; import { embedText } from "../../db/embed"; +import { cacheKey, kvCached } from "../../db/kv-cache"; import { getAppAlternatives, getAppBySlug, @@ -34,37 +35,53 @@ export const fetchApps = createServerFn({ method: "GET" }) ) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record) doesn't satisfy TanStack's serialization check .handler(async ({ data }): Promise => { - const db = getDb(); - return listApps(db, data); + return kvCached(cacheKey("listApps", data), () => { + const db = getDb(); + return listApps(db, data); + }); }); export const fetchAppBySlug = createServerFn({ method: "GET" }) .inputValidator((input: { slug: string }) => input) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record) doesn't satisfy TanStack's serialization check .handler(async ({ data }): Promise => { - const db = getDb(); - return getAppBySlug(db, data.slug); + return kvCached(cacheKey("getAppBySlug", { slug: data.slug }), () => { + const db = getDb(); + return getAppBySlug(db, data.slug); + }); }); export const fetchAppAlternatives = createServerFn({ method: "GET" }) .inputValidator((input: { appId: string }) => input) .handler(async ({ data }) => { - const db = getDb(); - return getAppAlternatives(db, data.appId); + return kvCached( + cacheKey("getAppAlternatives", { appId: data.appId }), + () => { + const db = getDb(); + return getAppAlternatives(db, data.appId); + }, + ); }); export const fetchProprietaryApps = createServerFn({ method: "GET" }) .inputValidator((input: { page?: number; limit?: number }) => input) .handler(async ({ data }) => { - const db = getDb(); - return listProprietaryApps(db, data); + return kvCached(cacheKey("listProprietaryApps", data), () => { + const db = getDb(); + return listProprietaryApps(db, data); + }); }); export const fetchProprietaryAppBySlug = createServerFn({ method: "GET" }) .inputValidator((input: { slug: string }) => input) .handler(async ({ data }) => { - const db = getDb(); - return getProprietaryAppBySlug(db, data.slug); + return kvCached( + cacheKey("getProprietaryAppBySlug", { slug: data.slug }), + () => { + const db = getDb(); + return getProprietaryAppBySlug(db, data.slug); + }, + ); }); export const fetchProprietaryAppAlternatives = createServerFn({ @@ -73,27 +90,40 @@ export const fetchProprietaryAppAlternatives = createServerFn({ .inputValidator((input: { proprietaryAppId: string }) => input) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record) doesn't satisfy TanStack's serialization check .handler(async ({ data }): Promise => { - const db = getDb(); - return getProprietaryAppAlternatives(db, data.proprietaryAppId); + return kvCached( + cacheKey("getProprietaryAppAlternatives", { + proprietaryAppId: data.proprietaryAppId, + }), + () => { + const db = getDb(); + return getProprietaryAppAlternatives(db, data.proprietaryAppId); + }, + ); }); export const fetchTags = createServerFn({ method: "GET" }).handler(async () => { - const db = getDb(); - return listTags(db); + return kvCached(cacheKey("listTags"), () => { + const db = getDb(); + return listTags(db); + }); }); export const fetchTagsByType = createServerFn({ method: "GET" }) .inputValidator((input: { type: TagType }) => input) .handler(async ({ data }) => { - const db = getDb(); - return listTagsByType(db, data.type); + return kvCached(cacheKey("listTagsByType", { type: data.type }), () => { + const db = getDb(); + return listTagsByType(db, data.type); + }); }); export const fetchCategoriesWithApps = createServerFn({ method: "GET", }).handler(async () => { - const db = getDb(); - return listCategoriesWithApps(db); + return kvCached(cacheKey("listCategoriesWithApps"), () => { + const db = getDb(); + return listCategoriesWithApps(db); + }); }); export const fetchSearchResults = createServerFn({ method: "GET" }) @@ -135,8 +165,10 @@ export const fetchSearchResults = createServerFn({ method: "GET" }) export const fetchTagBySlug = createServerFn({ method: "GET" }) .inputValidator((input: { slug: string; type?: TagType }) => input) .handler(async ({ data }) => { - const db = getDb(); - return getTagBySlug(db, data.slug, data.type); + return kvCached(cacheKey("getTagBySlug", data), () => { + const db = getDb(); + return getTagBySlug(db, data.slug, data.type); + }); }); export const fetchAppsByTag = createServerFn({ method: "GET" }) @@ -145,21 +177,27 @@ export const fetchAppsByTag = createServerFn({ method: "GET" }) ) // eslint-disable-next-line @typescript-eslint/no-explicit-any .handler(async ({ data }): Promise => { - const db = getDb(); - return listAppsByTag(db, data.tagSlug, data); + return kvCached(cacheKey("listAppsByTag", data), () => { + const db = getDb(); + return listAppsByTag(db, data.tagSlug, data); + }); }); export const fetchTagsWithCounts = createServerFn({ method: "GET" }) .inputValidator((input: { type?: TagType }) => input) .handler(async ({ data }) => { - const db = getDb(); - return listTagsWithCounts(db, data.type); + return kvCached(cacheKey("listTagsWithCounts", data), () => { + const db = getDb(); + return listTagsWithCounts(db, data.type); + }); }); export const fetchLicenses = createServerFn({ method: "GET" }).handler( async () => { - const db = getDb(); - return listLicenses(db); + return kvCached(cacheKey("listLicenses"), () => { + const db = getDb(); + return listLicenses(db); + }); }, ); @@ -169,38 +207,51 @@ export const fetchAppsByLicense = createServerFn({ method: "GET" }) ) // eslint-disable-next-line @typescript-eslint/no-explicit-any .handler(async ({ data }): Promise => { - const db = getDb(); - return listAppsByLicense(db, data.license, data); + return kvCached(cacheKey("listAppsByLicense", data), () => { + const db = getDb(); + return listAppsByLicense(db, data.license, data); + }); }); export const fetchDesktopApps = createServerFn({ method: "GET" }) .inputValidator((input: { page?: number; limit?: number }) => input) // eslint-disable-next-line @typescript-eslint/no-explicit-any .handler(async ({ data }): Promise => { - const db = getDb(); - return listDesktopApps(db, data); + return kvCached(cacheKey("listDesktopApps", data), () => { + const db = getDb(); + return listDesktopApps(db, data); + }); }); export const fetchRecentApps = createServerFn({ method: "GET" }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Drizzle's AppSourceMetadata (Record) doesn't satisfy TanStack's serialization check .handler(async (): Promise => { - const db = getDb(); - return getRecentApps(db); + return kvCached(cacheKey("getRecentApps"), () => { + const db = getDb(); + return getRecentApps(db); + }); }); export const fetchComparisonBySlug = createServerFn({ method: "GET" }) .inputValidator((input: { slug: string }) => input) // eslint-disable-next-line @typescript-eslint/no-explicit-any .handler(async ({ data }): Promise => { - const db = getDb(); - return getComparisonBySlug(db, data.slug); + return kvCached( + cacheKey("getComparisonBySlug", { slug: data.slug }), + () => { + const db = getDb(); + return getComparisonBySlug(db, data.slug); + }, + ); }); export const fetchComparisonPairsForApp = createServerFn({ method: "GET" }) .inputValidator((input: { appId: string; limit?: number }) => input) .handler(async ({ data }) => { - const db = getDb(); - return listComparisonPairsForApp(db, data.appId, data.limit); + return kvCached(cacheKey("listComparisonPairsForApp", data), () => { + const db = getDb(); + return listComparisonPairsForApp(db, data.appId, data.limit); + }); }); export const trackDownload = createServerFn({ method: "POST" }) diff --git a/src/routes/-worker-entry.ts b/src/routes/-worker-entry.ts index 72aad88..a156104 100644 --- a/src/routes/-worker-entry.ts +++ b/src/routes/-worker-entry.ts @@ -3,6 +3,7 @@ import { defaultStreamHandler, } from "@tanstack/react-start/server"; import { getDb } from "../../db/client"; +import { kvCached } from "../../db/kv-cache"; import { listAllAppSlugs, listAllComparisonSlugs, @@ -132,7 +133,7 @@ function sitemapPages(): Response { async function sitemapApps(): Promise { const db = getDb(); - const slugs = await listAllAppSlugs(db); + const slugs = await kvCached("sitemapAppSlugs", () => listAllAppSlugs(db)); return xmlResponse( urlset( slugs.map((s) => ({ @@ -146,7 +147,9 @@ async function sitemapApps(): Promise { async function sitemapAlternatives(): Promise { const db = getDb(); - const slugs = await listAllProprietaryAppSlugs(db); + const slugs = await kvCached("sitemapProprietaryAppSlugs", () => + listAllProprietaryAppSlugs(db), + ); return xmlResponse( urlset( slugs.map((s) => ({ @@ -160,7 +163,9 @@ async function sitemapAlternatives(): Promise { async function sitemapCategories(): Promise { const db = getDb(); - const slugs = await listAllTagSlugs(db, "category"); + const slugs = await kvCached("sitemapCategorySlugs", () => + listAllTagSlugs(db, "category"), + ); return xmlResponse( urlset( slugs.map((s) => ({ @@ -174,7 +179,7 @@ async function sitemapCategories(): Promise { async function sitemapTags(): Promise { const db = getDb(); - const slugs = await listAllTagSlugs(db); + const slugs = await kvCached("sitemapTagSlugs", () => listAllTagSlugs(db)); const nonCategory = slugs.filter( (s: { slug: string; type: string }) => s.type !== "category", ); @@ -191,7 +196,9 @@ async function sitemapTags(): Promise { async function sitemapComparisons(): Promise { const db = getDb(); - const slugs = await listAllComparisonSlugs(db); + const slugs = await kvCached("sitemapComparisonSlugs", () => + listAllComparisonSlugs(db), + ); return xmlResponse( urlset( slugs.map((s) => ({ @@ -205,7 +212,7 @@ async function sitemapComparisons(): Promise { async function sitemapLicenses(): Promise { const db = getDb(); - const licenses = await listLicenses(db); + const licenses = await kvCached("sitemapLicenses", () => listLicenses(db)); return xmlResponse( urlset( licenses @@ -309,56 +316,35 @@ async function handleIconProxy(request: Request): Promise { // ─── Worker Entry ─────────────────────────────────────────────────── -const cache = (caches as unknown as { default: Cache }).default; - export default { async fetch(request: Request, _env: Env): Promise { const url = new URL(request.url); const { pathname } = url; - // robots.txt + sitemaps — serve from edge cache - if (pathname === "/robots.txt" || sitemapHandlers[pathname]) { - const cacheKey = new Request(url.toString(), { method: "GET" }); - const cached = await cache.match(cacheKey); - if (cached) return cached; - - const response = - pathname === "/robots.txt" - ? robotsTxt() - : await sitemapHandlers[pathname](); - cache.put(cacheKey, response.clone()).catch(() => {}); - return response; + // robots.txt + sitemaps + if (pathname === "/robots.txt") { + return robotsTxt(); } - // Icon proxy - if (pathname === "/icon") { - return handleIconProxy(request); + const sitemapHandler = sitemapHandlers[pathname]; + if (sitemapHandler) { + return sitemapHandler(); } - // Determine if this route is cacheable - const cacheHeader = getCacheHeader(pathname); - - // Check edge cache for cacheable GET requests - if (cacheHeader && request.method === "GET") { - const cacheKey = new Request(url.toString(), { method: "GET" }); - const cached = await cache.match(cacheKey); - if (cached) return cached; + // Icon proxy (keeps its own Cache API usage) + if (pathname === "/icon") { + return handleIconProxy(request); } // Pass through to TanStack Start const response = await tanstackFetch(request); - // Buffer and store cacheable responses in edge cache. - // TanStack streams HTML (no Content-Length), so we must read the - // full body before cache.put() will accept it. - if (cacheHeader && response.status === 200 && request.method === "GET") { - const cacheKey = new Request(url.toString(), { method: "GET" }); - const body = await response.arrayBuffer(); - const headers = new Headers(response.headers); - headers.set("Cache-Control", cacheHeader); - const buffered = new Response(body, { status: 200, headers }); - cache.put(cacheKey, buffered.clone()).catch(() => {}); - return buffered; + // Set cache headers for browser caching + const cacheHeader = getCacheHeader(pathname); + if (cacheHeader && response.status === 200) { + const newResponse = new Response(response.body, response); + newResponse.headers.set("Cache-Control", cacheHeader); + return newResponse; } return response; diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index b3497fe..dab666e 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,8 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 152a7b226ab22a724759409d9e1ce964) +// Generated by Wrangler by running `wrangler types` (hash: 894464fd42fc7eee4ab71289ef24d060) // Runtime types generated with workerd@1.20260310.1 2025-09-02 nodejs_compat declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/routes/-worker-entry"); + } interface Env { + KV: KVNamespace; AI: Ai; TURSO_DATABASE_URL: string; TURSO_AUTH_TOKEN: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index 529e3c3..335ad9c 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -6,6 +6,13 @@ "compatibility_flags": ["nodejs_compat"], "main": "src/routes/-worker-entry.ts", "ai": { "binding": "AI" }, + "kv_namespaces": [ + { + "binding": "KV", + "id": "47a12e13810f43f0b110ac082b60e133", + "preview_id": "8759a6a5e84c401d90984a20f7f060b5" + } + ], // Turso credentials: set via `wrangler secret put` for production, // .dev.vars for local development "vars": {}