diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88103d69d..e7a59fa7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,8 @@ jobs: cd ${{ matrix.package }} if [ "${{ matrix.package }}" = ".agents" ]; then find __tests__ -name '*.test.ts' ! -name '*.integration.test.ts' 2>/dev/null | sort | xargs -I {} bun test {} || echo "No regular tests found in .agents" + elif [ "${{ matrix.package }}" = "web" ]; then + bun run test --runInBand else find src -name '*.test.ts' ! -name '*.integration.test.ts' | sort | xargs -I {} bun test {} fi @@ -244,7 +246,4 @@ jobs: find src -name '*.integration.test.ts' | sort | xargs -I {} bun test {} fi - # - name: Open interactive debug shell - # if: ${{ failure() }} - # uses: mxschmitt/action-tmate@v3 - # timeout-minutes: 15 # optional guard + # E2E tests for web intentionally omitted for now. diff --git a/web/README.md b/web/README.md index 2cbc0109f..9d2922e71 100644 --- a/web/README.md +++ b/web/README.md @@ -76,5 +76,38 @@ The following scripts are available in the `package.json`: - `test:watch`: Run unit tests in watch mode - `e2e`: Run end-to-end tests - `e2e:ui`: Run end-to-end tests with UI -- `postbuild`: Generate sitemap - `prepare`: Install Husky for managing Git hooks + +## SEO & SSR + +- Store SSR: `src/app/store/page.tsx` renders agents server-side using cached data (ISR `revalidate=600`). +- Client fallback: `src/app/store/store-client.tsx` only fetches `/api/agents` if SSR data is empty. +- Dynamic metadata: + - Store: `src/app/store/page.tsx` + - Publisher: `src/app/publishers/[id]/page.tsx` + - Agent detail: `src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx` + +### Warm the Store cache + +The agents cache is automatically warmed to ensure SEO data is available immediately: + +1. **Build-time validation**: `scripts/prebuild-agents-cache.ts` runs after `next build` to validate the database connection and data pipeline +2. **Health check warming** (Primary): `/api/healthz` endpoint warms the cache when Render performs health checks before routing traffic + +On Render, set the Health Check Path to `/api/healthz` in your service settings to ensure the cache is warm before traffic is routed to the app. + +### E2E tests for SSR and hydration + +- Hydration fallback: `src/__tests__/e2e/store-hydration.spec.ts` - Tests client-side data fetching when SSR data is empty +- SSR HTML: `src/__tests__/e2e/store-ssr.spec.ts` - Tests server-side rendering with JavaScript disabled + +Both tests use Playwright's `page.route()` to mock API responses without polluting production code. + +Run locally: + +``` +cd web +bun run e2e +``` + + diff --git a/web/jest.config.cjs b/web/jest.config.cjs index e1fe9c5be..8273c5ce7 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -12,7 +12,14 @@ const config = { '^@/(.*)$': '/src/$1', '^common/(.*)$': '/../common/src/$1', '^@codebuff/internal/xml-parser$': '/src/test-stubs/xml-parser.ts', + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', }, + testPathIgnorePatterns: [ + '/src/__tests__/e2e', + '/src/app/api/v1/.*/__tests__', + '/src/app/api/agents/publish/__tests__', + ], } module.exports = createJestConfig(config) diff --git a/web/package.json b/web/package.json index cf56ee6c3..1f2b0244f 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ }, "scripts": { "dev": "next dev -p ${NEXT_PUBLIC_WEB_PORT:-3000}", - "build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d'", + "build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d' && bun run scripts/prebuild-agents-cache.ts", "start": "next start", "preview": "bun run build && bun run start", "contentlayer": "contentlayer build", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 307a18d8d..6a1c81ea4 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from '@playwright/test' +// Use the same port as the dev server, defaulting to 3000 +const PORT = process.env.NEXT_PUBLIC_WEB_PORT || '3000' +const BASE_URL = `http://127.0.0.1:${PORT}` + export default defineConfig({ testDir: './src/__tests__/e2e', fullyParallel: true, @@ -8,7 +12,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'http://127.0.0.1:3001', + baseURL: BASE_URL, trace: 'on-first-retry', }, @@ -28,8 +32,8 @@ export default defineConfig({ ], webServer: { - command: 'bun run dev', - url: 'http://127.0.0.1:3001', + command: `NEXT_PUBLIC_WEB_PORT=${PORT} bun run dev`, + url: BASE_URL, reuseExistingServer: !process.env.CI, }, }) diff --git a/web/scripts/prebuild-agents-cache.ts b/web/scripts/prebuild-agents-cache.ts new file mode 100644 index 000000000..8f1528fdd --- /dev/null +++ b/web/scripts/prebuild-agents-cache.ts @@ -0,0 +1,32 @@ +/** + * Pre-build cache warming for agents data + * This runs during the build process to validate the database connection + * and ensure agents data can be fetched successfully. + * + * Note: This doesn't actually populate Next.js cache (which requires runtime context), + * but it validates the data fetching pipeline works before deployment. + */ + +import { fetchAgentsWithMetrics } from '../src/server/agents-data' + +async function main() { + console.log('[Prebuild] Validating agents data pipeline...') + + try { + const startTime = Date.now() + const agents = await fetchAgentsWithMetrics() + const duration = Date.now() - startTime + + console.log(`[Prebuild] Successfully fetched ${agents.length} agents in ${duration}ms`) + console.log('[Prebuild] Data pipeline validated - ready for deployment') + + process.exit(0) + } catch (error) { + console.error('[Prebuild] Failed to fetch agents data:', error) + // Don't fail the build - health check will warm cache at runtime + console.error('[Prebuild] WARNING: Data fetch failed, relying on runtime health check') + process.exit(0) + } +} + +main() diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.spec.ts new file mode 100644 index 000000000..9eb02f213 --- /dev/null +++ b/web/src/__tests__/e2e/store-hydration.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test' + +test('store hydrates agents via client fetch when SSR is empty', async ({ page }) => { + const agents = [ + { + id: 'base', + name: 'Base', + description: 'desc', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + version: '1.2.3', + created_at: new Date().toISOString(), + weekly_spent: 10, + weekly_runs: 5, + usage_count: 50, + total_spent: 100, + avg_cost_per_invocation: 0.2, + unique_users: 3, + last_used: new Date().toISOString(), + version_stats: {}, + tags: ['test'], + }, + ] + + // Intercept client-side fetch to /api/agents to return our fixture + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(agents), + }) + }) + + await page.goto('/store') + + // Expect the agent card to render after hydration by checking the copy button title + await expect( + page.getByTitle('Copy: codebuff --agent codebuff/base@1.2.3').first(), + ).toBeVisible() +}) diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.spec.ts new file mode 100644 index 000000000..ef5632cfa --- /dev/null +++ b/web/src/__tests__/e2e/store-ssr.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test' + +// Disable JS to validate pure SSR HTML +test.use({ javaScriptEnabled: false }) + +test('SSR HTML contains at least one agent card', async ({ page }) => { + const agents = [ + { + id: 'base', + name: 'Base', + description: 'desc', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + version: '1.2.3', + created_at: new Date().toISOString(), + weekly_spent: 10, + weekly_runs: 5, + usage_count: 50, + total_spent: 100, + avg_cost_per_invocation: 0.2, + unique_users: 3, + last_used: new Date().toISOString(), + version_stats: {}, + tags: ['test'], + }, + ] + + // Mock the server-side API call that happens during SSR + // This intercepts the request before SSR completes + await page.route('**/api/agents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(agents), + }) + }) + + const response = await page.goto('/store', { + waitUntil: 'domcontentloaded', + }) + expect(response).not.toBeNull() + const html = await response!.text() + + // Validate SSR output contains agent content (publisher + id) + expect(html).toContain('@codebuff') + expect(html).toContain('>base<') +}) diff --git a/web/src/app/api/agents/route.ts b/web/src/app/api/agents/route.ts index 916ecceac..d94d61978 100644 --- a/web/src/app/api/agents/route.ts +++ b/web/src/app/api/agents/route.ts @@ -1,300 +1,19 @@ -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { sql, eq, and, gte } from 'drizzle-orm' -import { unstable_cache } from 'next/cache' import { NextResponse } from 'next/server' import { logger } from '@/util/logger' +import { applyCacheHeaders } from '@/server/apply-cache-headers' +import { getCachedAgents } from '@/server/agents-data' // ISR Configuration for API route export const revalidate = 600 // Cache for 10 minutes export const dynamic = 'force-static' -// Cached function for expensive agent aggregations -const getCachedAgents = unstable_cache( - async () => { - const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - - // Get all published agents with their publisher info - const agents = await db - .select({ - id: schema.agentConfig.id, - version: schema.agentConfig.version, - data: schema.agentConfig.data, - created_at: schema.agentConfig.created_at, - publisher: { - id: schema.publisher.id, - name: schema.publisher.name, - verified: schema.publisher.verified, - avatar_url: schema.publisher.avatar_url, - }, - }) - .from(schema.agentConfig) - .innerJoin( - schema.publisher, - sql`${schema.agentConfig.publisher_id} = ${schema.publisher.id}`, - ) - .orderBy(sql`${schema.agentConfig.created_at} DESC`) - - // Get aggregated all-time usage metrics across all versions - const usageMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - total_invocations: sql`COUNT(*)`, - total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, - unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, - last_used: sql`MAX(${schema.agentRun.created_at})`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - ), - ) - .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) - - // Get aggregated weekly usage metrics across all versions - const weeklyMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - weekly_runs: sql`COUNT(*)`, - weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - gte(schema.agentRun.created_at, oneWeekAgo), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - ), - ) - .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) - - // Get per-version usage metrics for all-time - const perVersionMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - agent_version: schema.agentRun.agent_version, - total_invocations: sql`COUNT(*)`, - total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, - unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, - last_used: sql`MAX(${schema.agentRun.created_at})`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - sql`${schema.agentRun.agent_version} IS NOT NULL`, - ), - ) - .groupBy( - schema.agentRun.publisher_id, - schema.agentRun.agent_name, - schema.agentRun.agent_version, - ) - - // Get per-version weekly usage metrics - const perVersionWeeklyMetrics = await db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - agent_version: schema.agentRun.agent_version, - weekly_runs: sql`COUNT(*)`, - weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - gte(schema.agentRun.created_at, oneWeekAgo), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - sql`${schema.agentRun.agent_version} IS NOT NULL`, - ), - ) - .groupBy( - schema.agentRun.publisher_id, - schema.agentRun.agent_name, - schema.agentRun.agent_version, - ) - - // Create weekly metrics map by publisher/agent_name - const weeklyMap = new Map() - weeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - weeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } - }) - - // Create a map of aggregated usage metrics by publisher/agent_name - const metricsMap = new Map() - usageMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - const weeklyData = weeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - metricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used, - }) - } - }) - - // Create per-version weekly metrics map - const perVersionWeeklyMap = new Map() - perVersionWeeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name && metric.agent_version) { - const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` - perVersionWeeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } - }) - - // Create per-version metrics map - const perVersionMetricsMap = new Map() - perVersionMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name && metric.agent_version) { - const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` - const weeklyData = perVersionWeeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - perVersionMetricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used, - }) - } - }) - - // Group per-version metrics by agent - const versionMetricsByAgent = new Map() - perVersionMetricsMap.forEach((metrics, key) => { - const [publisherAgentKey, version] = key.split('@') - if (!versionMetricsByAgent.has(publisherAgentKey)) { - versionMetricsByAgent.set(publisherAgentKey, {}) - } - versionMetricsByAgent.get(publisherAgentKey)[version] = metrics - }) - - // First, group agents by publisher/name to get the latest version of each - const latestAgents = new Map() - agents.forEach((agent) => { - const agentData = - typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data - const agentName = agentData.name || agent.id - const key = `${agent.publisher.id}/${agentName}` - - if (!latestAgents.has(key)) { - latestAgents.set(key, { - agent, - agentData, - agentName, - }) - } - }) - - // Transform the latest agents with their aggregated metrics - const result = Array.from(latestAgents.values()).map( - ({ agent, agentData, agentName }) => { - const agentKey = `${agent.publisher.id}/${agentName}` - const metrics = metricsMap.get(agentKey) || { - weekly_runs: 0, - weekly_dollars: 0, - total_dollars: 0, - total_invocations: 0, - avg_cost_per_run: 0, - unique_users: 0, - last_used: null, - } - - // Use agent.id (config ID) to get version stats since that's what the runs table uses as agent_name - const versionStatsKey = `${agent.publisher.id}/${agent.id}` - const version_stats = versionMetricsByAgent.get(versionStatsKey) || {} - - return { - id: agent.id, - name: agentName, - description: agentData.description, - publisher: agent.publisher, - version: agent.version, - created_at: agent.created_at, - // Aggregated stats across all versions (for agent store) - usage_count: metrics.total_invocations, - weekly_runs: metrics.weekly_runs, - weekly_spent: metrics.weekly_dollars, - total_spent: metrics.total_dollars, - avg_cost_per_invocation: metrics.avg_cost_per_run, - unique_users: metrics.unique_users, - last_used: metrics.last_used, - // Per-version stats for agent detail pages - version_stats, - tags: agentData.tags || [], - } - }, - ) - - // Sort by weekly usage (most prominent metric) - result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) - - return result - }, - ['agents-data'], - { - revalidate: 600, // 10 minutes - tags: ['agents', 'api'], - }, -) - export async function GET() { try { const result = await getCachedAgents() const response = NextResponse.json(result) - - // Add optimized cache headers for better performance - response.headers.set( - 'Cache-Control', - 'public, max-age=300, s-maxage=600, stale-while-revalidate=3600', - ) - - // Add compression and optimization headers - response.headers.set('Vary', 'Accept-Encoding') - response.headers.set('X-Content-Type-Options', 'nosniff') - response.headers.set('Content-Type', 'application/json; charset=utf-8') - - return response + return applyCacheHeaders(response) } catch (error) { logger.error({ error }, 'Error fetching agents') return NextResponse.json( diff --git a/web/src/app/api/healthz/route.ts b/web/src/app/api/healthz/route.ts index 19acdccc0..d0a96194e 100644 --- a/web/src/app/api/healthz/route.ts +++ b/web/src/app/api/healthz/route.ts @@ -1,5 +1,25 @@ import { NextResponse } from 'next/server' +import { getCachedAgents } from '@/server/agents-data' export const GET = async () => { - return NextResponse.json({ status: 'ok' }) + try { + // Warm the cache by fetching agents data + // This ensures SEO-critical data is available immediately + const agents = await getCachedAgents() + + return NextResponse.json({ + status: 'ok', + cached_agents: agents.length, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('[Healthz] Failed to warm cache:', error) + + // Still return 200 so health check passes, but indicate cache warming failed + return NextResponse.json({ + status: 'ok', + cache_warm: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + } } diff --git a/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx b/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx index c626eacdd..6811efb6f 100644 --- a/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx +++ b/web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx @@ -58,12 +58,27 @@ export async function generateMetadata({ params }: AgentDetailPageProps) { ? JSON.parse(agent[0].data) : agent[0].data const agentName = agentData.name || params.agentId + // Fetch publisher for OG image + const pub = await db + .select() + .from(schema.publisher) + .where(eq(schema.publisher.id, params.id)) + .limit(1) + + const title = `${agentName} v${agent[0].version} - Agent Details` + const description = + agentData.description || `View details for ${agentName} version ${agent[0].version}` + const ogImages = (pub?.[0]?.avatar_url ? [pub[0].avatar_url] : []) as string[] return { - title: `${agentName} v${agent[0].version} - Agent Details`, - description: - agentData.description || - `View details for ${agentName} version ${agent[0].version}`, + title, + description, + openGraph: { + title, + description, + type: 'article', + images: ogImages, + }, } } diff --git a/web/src/app/publishers/[id]/page.tsx b/web/src/app/publishers/[id]/page.tsx index e5ebb5663..d9171b378 100644 --- a/web/src/app/publishers/[id]/page.tsx +++ b/web/src/app/publishers/[id]/page.tsx @@ -29,11 +29,20 @@ export async function generateMetadata({ params }: PublisherPageProps) { } } + const title = `${publisher[0].name} - Codebuff Publisher` + const description = + publisher[0].bio || `View ${publisher[0].name}'s published agents on Codebuff` + const ogImages = (publisher[0].avatar_url ? [publisher[0].avatar_url] : []) as string[] + return { - title: `${publisher[0].name} - Codebuff Publisher`, - description: - publisher[0].bio || - `View ${publisher[0].name}'s published agents on Codebuff`, + title, + description, + openGraph: { + title, + description, + type: 'profile', + images: ogImages, + }, } } diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 8bfc34d38..779c9fe43 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -1,19 +1,66 @@ import { env } from '@codebuff/common/env' +import { getCachedAgents } from '@/server/agents-data' import type { MetadataRoute } from 'next' -export default function sitemap(): MetadataRoute.Sitemap { - return [ +export default async function sitemap(): Promise { + const toUrl = (path: string) => `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}${path}` + + const items: MetadataRoute.Sitemap = [ { - url: env.NEXT_PUBLIC_CODEBUFF_APP_URL || '/', + url: toUrl('/'), lastModified: new Date(), changeFrequency: 'yearly', priority: 1, alternates: { languages: { - pl: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pl`, + pl: toUrl('/pl'), }, }, }, + { + url: toUrl('/store'), + lastModified: new Date(), + changeFrequency: 'hourly', + priority: 0.9, + }, ] + + // Include agent detail pages and publisher pages derived from cached store data + try { + const agents = await getCachedAgents() + + const seenPublishers = new Set() + for (const agent of agents) { + const pubId = agent.publisher?.id + if (pubId && !seenPublishers.has(pubId)) { + items.push({ + url: toUrl(`/publishers/${pubId}`), + lastModified: new Date(agent.last_used || agent.created_at), + changeFrequency: 'daily', + priority: 0.7, + }) + seenPublishers.add(pubId) + } + + if (pubId && agent.id && agent.version) { + items.push({ + url: toUrl( + `/publishers/${pubId}/agents/${agent.id}/${agent.version}`, + ), + lastModified: new Date(agent.last_used || agent.created_at), + changeFrequency: 'daily', + priority: 0.8, + }) + } + } + } catch (error) { + console.error( + '[Sitemap] Failed to fetch agents for sitemap generation:', + error, + ) + // If fetching fails, fall back to base entries only + } + + return items } diff --git a/web/src/app/store/agents-data.ts b/web/src/app/store/agents-data.ts deleted file mode 100644 index 7acbecf47..000000000 --- a/web/src/app/store/agents-data.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { env } from '@codebuff/common/env' -import { unstable_cache } from 'next/cache' - -// Types -interface AgentData { - id: string - name: string - description?: string - publisher: { - id: string - name: string - verified: boolean - avatar_url?: string | null - } - version: string - created_at: string - usage_count?: number - weekly_runs?: number - weekly_spent?: number - total_spent?: number - avg_cost_per_invocation?: number - unique_users?: number - last_used?: string - version_stats?: Record - tags?: string[] -} - -// Server-side data fetching function with ISR -export const getAgentsData = unstable_cache( - async (): Promise => { - const baseUrl = env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000' - - try { - const response = await fetch(`${baseUrl}/api/agents`, { - headers: { - 'User-Agent': 'Codebuff-Store-Static', - }, - // Configure fetch-level caching - next: { - revalidate: 600, // 10 minutes - tags: ['agents', 'store'], - }, - }) - - if (!response.ok) { - console.error( - 'Failed to fetch agents:', - response.status, - response.statusText, - ) - return [] - } - - return await response.json() - } catch (error) { - console.error('Error fetching agents data:', error) - return [] - } - }, - ['store-agents-data'], - { - revalidate: 600, // Cache for 10 minutes - tags: ['agents', 'store'], - }, -) - -// Helper function for on-demand revalidation (can be used in webhooks/admin actions) -export async function revalidateAgentsData() { - const { revalidateTag } = await import('next/cache') - revalidateTag('agents') - revalidateTag('store') -} diff --git a/web/src/app/store/page.tsx b/web/src/app/store/page.tsx index 014a70573..1722ce1a7 100644 --- a/web/src/app/store/page.tsx +++ b/web/src/app/store/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from 'next' +import { getCachedAgents } from '@/server/agents-data' import AgentStoreClient from './store-client' -import { getAgentsData } from './agents-data' interface PublisherProfileResponse { id: string @@ -9,14 +9,43 @@ interface PublisherProfileResponse { avatar_url?: string | null } -export const metadata: Metadata = { - title: 'Agent Store | Codebuff', - description: 'Browse all published AI agents. Run, compose, or fork them.', - openGraph: { - title: 'Agent Store | Codebuff', - description: 'Browse all published AI agents. Run, compose, or fork them.', - type: 'website', - }, +export async function generateMetadata(): Promise { + let agents: Array<{ + name?: string + publisher?: { avatar_url?: string | null } + }> = [] + try { + agents = await getCachedAgents() + } catch (error) { + console.error('[Store] Failed to fetch agents for metadata:', error) + agents = [] + } + const count = agents.length + const firstAgent = agents[0]?.name + const title = + count > 0 + ? `Agent Store – ${count} Agents Available | Codebuff` + : 'Agent Store | Codebuff' + const description = + count > 0 + ? `Browse ${count} Codebuff agents including ${firstAgent} and more.` + : 'Browse all published AI agents. Run, compose, or fork them.' + + const ogImages = agents + .map((a) => a.publisher?.avatar_url) + .filter((u): u is string => !!u) + .slice(0, 3) + + return { + title, + description, + openGraph: { + title, + description, + type: 'website', + images: ogImages, + }, + } } // ISR Configuration - revalidate every 10 minutes @@ -28,8 +57,14 @@ interface StorePageProps { } export default async function StorePage({ searchParams }: StorePageProps) { - // Fetch agents data with ISR - const agentsData = await getAgentsData() + // Fetch agents data on the server with ISR cache + let agentsData: any[] = [] + try { + agentsData = await getCachedAgents() + } catch (error) { + console.error('[Store] Failed to fetch agents data:', error) + agentsData = [] + } // For static generation, we don't pass session data // The client will handle authentication state diff --git a/web/src/app/store/store-client.tsx b/web/src/app/store/store-client.tsx index 176a50430..ad6e00b28 100644 --- a/web/src/app/store/store-client.tsx +++ b/web/src/app/store/store-client.tsx @@ -185,10 +185,24 @@ export default function AgentStoreClient({ loadingStateRef.current = { isLoadingMore, hasMore } }, [isLoadingMore, hasMore]) - // Use the initial agents directly + // Hydrate agents client-side if SSR provided none (build-time fallback) + const { data: hydratedAgents } = useQuery({ + queryKey: ['agents'], + queryFn: async () => { + const response = await fetch('/api/agents') + if (!response.ok) { + throw new Error(`Failed to fetch agents: ${response.statusText}`) + } + return response.json() + }, + enabled: (initialAgents?.length ?? 0) === 0, + staleTime: 600000, // 10 minutes + }) + + // Prefer hydrated data if present; else use SSR data const agents = useMemo(() => { - return initialAgents - }, [initialAgents]) + return hydratedAgents ?? initialAgents + }, [hydratedAgents, initialAgents]) const editorsChoice = useMemo(() => { return agents.filter((agent) => EDITORS_CHOICE_AGENTS.includes(agent.id)) diff --git a/web/src/server/__tests__/agents-transform.test.ts b/web/src/server/__tests__/agents-transform.test.ts new file mode 100644 index 000000000..6a50bc7af --- /dev/null +++ b/web/src/server/__tests__/agents-transform.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from '@jest/globals' +import { buildAgentsData, type AgentRow } from '../agents-transform' + +describe('buildAgentsData', () => { + it('dedupes by latest and merges metrics + sorts by weekly_spent', () => { + const agents: AgentRow[] = [ + { + id: 'base', + version: '1.0.0', + data: { name: 'Base', description: 'desc', tags: ['x'] }, + created_at: '2025-01-01T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + // older duplicate by name should be ignored due to first-seen is latest ordering + { + id: 'base-old', + version: '0.9.0', + data: { name: 'Base', description: 'old' }, + created_at: '2024-12-01T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + { + id: 'reviewer', + version: '2.1.0', + data: { name: 'Reviewer' }, + created_at: '2025-01-03T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + ] + + const usageMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'Base', + total_invocations: 50, + total_dollars: 100, + avg_cost_per_run: 2, + unique_users: 4, + last_used: new Date('2025-01-05T00:00:00.000Z'), + }, + { + publisher_id: 'codebuff', + agent_name: 'reviewer', + total_invocations: 5, + total_dollars: 5, + avg_cost_per_run: 1, + unique_users: 1, + last_used: new Date('2025-01-04T00:00:00.000Z'), + }, + ] + + const weeklyMetrics = [ + { publisher_id: 'codebuff', agent_name: 'Base', weekly_runs: 10, weekly_dollars: 20 }, + { publisher_id: 'codebuff', agent_name: 'reviewer', weekly_runs: 2, weekly_dollars: 1 }, + ] + + const perVersionMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'base', + agent_version: '1.0.0', + total_invocations: 10, + total_dollars: 20, + avg_cost_per_run: 2, + unique_users: 3, + last_used: new Date('2025-01-05T00:00:00.000Z'), + }, + ] + + const perVersionWeeklyMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'base', + agent_version: '1.0.0', + weekly_runs: 3, + weekly_dollars: 6, + }, + ] + + const out = buildAgentsData({ + agents, + usageMetrics: usageMetrics as any, + weeklyMetrics: weeklyMetrics as any, + perVersionMetrics: perVersionMetrics as any, + perVersionWeeklyMetrics: perVersionWeeklyMetrics as any, + }) + + // should have deduped to two agents + expect(out.length).toBe(2) + + const base = out.find((a) => a.id === 'base')! + expect(base.name).toBe('Base') + expect(base.weekly_spent).toBe(20) + expect(base.weekly_runs).toBe(10) + expect(base.total_spent).toBe(100) + expect(base.usage_count).toBe(50) + expect(base.avg_cost_per_invocation).toBe(2) + expect(base.unique_users).toBe(4) + expect(base.version_stats?.['1.0.0']).toMatchObject({ weekly_runs: 3, weekly_dollars: 6 }) + + // sorted by weekly_spent desc + expect(out[0].weekly_spent! >= out[1].weekly_spent!).toBe(true) + }) + + it('handles missing metrics gracefully and normalizes defaults', () => { + const agents = [ + { + id: 'solo', + version: '0.1.0', + data: { description: 'no name provided' }, + created_at: new Date('2025-02-01T00:00:00.000Z'), + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + ] as any + + const out = buildAgentsData({ + agents, + usageMetrics: [], + weeklyMetrics: [], + perVersionMetrics: [], + perVersionWeeklyMetrics: [], + }) + + expect(out).toHaveLength(1) + const a = out[0] + // falls back to id when name missing + expect(a.name).toBe('solo') + // defaults present + expect(a.weekly_spent).toBe(0) + expect(a.weekly_runs).toBe(0) + expect(a.total_spent).toBe(0) + expect(a.usage_count).toBe(0) + expect(a.avg_cost_per_invocation).toBe(0) + expect(a.unique_users).toBe(0) + expect(a.last_used).toBeUndefined() + expect(a.version_stats).toEqual({}) + expect(a.tags).toEqual([]) + // created_at normalized to string + expect(typeof a.created_at).toBe('string') + }) + + it('uses data.name for aggregate metrics and agent.id for version stats', () => { + const agents = [ + { + id: 'file-picker', + version: '1.2.0', + data: { name: 'File Picker' }, + created_at: '2025-03-01T00:00:00.000Z', + publisher: { id: 'codebuff', name: 'Codebuff', verified: true, avatar_url: null }, + }, + ] as any + + // Aggregate metrics keyed by data.name + const usageMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'File Picker', + total_invocations: 7, + total_dollars: 3.5, + avg_cost_per_run: 0.5, + unique_users: 2, + last_used: new Date('2025-03-02T00:00:00.000Z'), + }, + ] + const weeklyMetrics = [ + { publisher_id: 'codebuff', agent_name: 'File Picker', weekly_runs: 4, weekly_dollars: 1.5 }, + ] + + // Version stats keyed by agent.id in runs + const perVersionMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'file-picker', + agent_version: '1.2.0', + total_invocations: 4, + total_dollars: 2, + avg_cost_per_run: 0.5, + unique_users: 2, + last_used: new Date('2025-03-02T00:00:00.000Z'), + }, + ] + const perVersionWeeklyMetrics = [ + { + publisher_id: 'codebuff', + agent_name: 'file-picker', + agent_version: '1.2.0', + weekly_runs: 2, + weekly_dollars: 1, + }, + ] + + const out = buildAgentsData({ + agents: agents as any, + usageMetrics: usageMetrics as any, + weeklyMetrics: weeklyMetrics as any, + perVersionMetrics: perVersionMetrics as any, + perVersionWeeklyMetrics: perVersionWeeklyMetrics as any, + }) + + expect(out).toHaveLength(1) + const fp = out[0] + // Aggregate metrics align with data.name + expect(fp.name).toBe('File Picker') + expect(fp.weekly_runs).toBe(4) + expect(fp.weekly_spent).toBe(1.5) + expect(fp.usage_count).toBe(7) + expect(fp.total_spent).toBe(3.5) + // Version stats keyed by id@version (not display name) + expect(fp.version_stats?.['1.2.0']).toMatchObject({ weekly_runs: 2, weekly_dollars: 1 }) + }) +}) diff --git a/web/src/server/__tests__/apply-cache-headers.test.ts b/web/src/server/__tests__/apply-cache-headers.test.ts new file mode 100644 index 000000000..592eb5a98 --- /dev/null +++ b/web/src/server/__tests__/apply-cache-headers.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from '@jest/globals' +import { applyCacheHeaders } from '../apply-cache-headers' + +describe('applyCacheHeaders', () => { + it('sets expected cache and content headers', () => { + const map = new Map() + const res = { headers: { set: (k: string, v: string) => map.set(k, v) } } + + const out = applyCacheHeaders(res) + expect(out).toBe(res) + expect(map.get('Cache-Control')).toContain('public') + expect(map.get('Vary')).toBe('Accept-Encoding') + expect(map.get('X-Content-Type-Options')).toBe('nosniff') + expect(map.get('Content-Type')).toContain('application/json') + }) +}) + diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts new file mode 100644 index 000000000..c960a1b70 --- /dev/null +++ b/web/src/server/agents-data.ts @@ -0,0 +1,166 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { unstable_cache } from 'next/cache' +import { sql, eq, and, gte } from 'drizzle-orm' +import { buildAgentsData } from './agents-transform' + +export interface AgentData { + id: string + name: string + description?: string + publisher: { + id: string + name: string + verified: boolean + avatar_url?: string | null + } + version: string + created_at: string + usage_count?: number + weekly_runs?: number + weekly_spent?: number + total_spent?: number + avg_cost_per_invocation?: number + unique_users?: number + last_used?: string + version_stats?: Record + tags?: string[] +} + +export const fetchAgentsWithMetrics = async (): Promise => { + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + + // Get all published agents with their publisher info + const agents = await db + .select({ + id: schema.agentConfig.id, + version: schema.agentConfig.version, + data: schema.agentConfig.data, + created_at: schema.agentConfig.created_at, + publisher: { + id: schema.publisher.id, + name: schema.publisher.name, + verified: schema.publisher.verified, + avatar_url: schema.publisher.avatar_url, + }, + }) + .from(schema.agentConfig) + .innerJoin( + schema.publisher, + eq(schema.agentConfig.publisher_id, schema.publisher.id), + ) + .orderBy(sql`${schema.agentConfig.created_at} DESC`) + + // Get aggregated all-time usage metrics across all versions + const usageMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + total_invocations: sql`COUNT(*)`, + total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, + unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, + last_used: sql`MAX(${schema.agentRun.created_at})`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + ), + ) + .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) + + // Get aggregated weekly usage metrics across all versions + const weeklyMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + weekly_runs: sql`COUNT(*)`, + weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + gte(schema.agentRun.created_at, oneWeekAgo), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + ), + ) + .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) + + // Get per-version usage metrics for all-time + const perVersionMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + agent_version: schema.agentRun.agent_version, + total_invocations: sql`COUNT(*)`, + total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, + unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, + last_used: sql`MAX(${schema.agentRun.created_at})`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + sql`${schema.agentRun.agent_version} IS NOT NULL`, + ), + ) + .groupBy( + schema.agentRun.publisher_id, + schema.agentRun.agent_name, + schema.agentRun.agent_version, + ) + + // Get per-version weekly usage metrics + const perVersionWeeklyMetrics = await db + .select({ + publisher_id: schema.agentRun.publisher_id, + agent_name: schema.agentRun.agent_name, + agent_version: schema.agentRun.agent_version, + weekly_runs: sql`COUNT(*)`, + weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, + }) + .from(schema.agentRun) + .where( + and( + eq(schema.agentRun.status, 'completed'), + gte(schema.agentRun.created_at, oneWeekAgo), + sql`${schema.agentRun.agent_id} != 'test-agent'`, + sql`${schema.agentRun.publisher_id} IS NOT NULL`, + sql`${schema.agentRun.agent_name} IS NOT NULL`, + sql`${schema.agentRun.agent_version} IS NOT NULL`, + ), + ) + .groupBy( + schema.agentRun.publisher_id, + schema.agentRun.agent_name, + schema.agentRun.agent_version, + ) + + return buildAgentsData({ + agents, + usageMetrics, + weeklyMetrics, + perVersionMetrics, + perVersionWeeklyMetrics, + }) +} + +export const getCachedAgents = unstable_cache( + fetchAgentsWithMetrics, + ['agents-data'], + { + revalidate: 600, // 10 minutes + tags: ['agents', 'api', 'store'], + }, +) diff --git a/web/src/server/agents-transform.ts b/web/src/server/agents-transform.ts new file mode 100644 index 000000000..aa2b124f8 --- /dev/null +++ b/web/src/server/agents-transform.ts @@ -0,0 +1,211 @@ +export interface AgentRow { + id: string + version: string + data: any + created_at: string | Date + publisher: { id: string; name: string; verified: boolean; avatar_url?: string | null } +} + +export interface UsageMetricRow { + publisher_id: string | null + agent_name: string | null + total_invocations: number | string + total_dollars: number | string + avg_cost_per_run: number | string + unique_users: number | string + last_used: Date | string | null +} + +export interface WeeklyMetricRow { + publisher_id: string | null + agent_name: string | null + weekly_runs: number | string + weekly_dollars: number | string +} + +export interface PerVersionMetricRow { + publisher_id: string | null + agent_name: string | null + agent_version: string | null + total_invocations: number | string + total_dollars: number | string + avg_cost_per_run: number | string + unique_users: number | string + last_used: Date | string | null +} + +export interface PerVersionWeeklyMetricRow { + publisher_id: string | null + agent_name: string | null + agent_version: string | null + weekly_runs: number | string + weekly_dollars: number | string +} + +export interface AgentDataOut { + id: string + name: string + description?: string + publisher: { id: string; name: string; verified: boolean; avatar_url?: string | null } + version: string + created_at: string + usage_count?: number + weekly_runs?: number + weekly_spent?: number + total_spent?: number + avg_cost_per_invocation?: number + unique_users?: number + last_used?: string + version_stats?: Record + tags?: string[] +} + +export function buildAgentsData(params: { + agents: AgentRow[] + usageMetrics: UsageMetricRow[] + weeklyMetrics: WeeklyMetricRow[] + perVersionMetrics: PerVersionMetricRow[] + perVersionWeeklyMetrics: PerVersionWeeklyMetricRow[] +}): AgentDataOut[] { + const { agents, usageMetrics, weeklyMetrics, perVersionMetrics, perVersionWeeklyMetrics } = params + + const weeklyMap = new Map() + weeklyMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name) { + const key = `${metric.publisher_id}/${metric.agent_name}` + weeklyMap.set(key, { + weekly_runs: Number(metric.weekly_runs), + weekly_dollars: Number(metric.weekly_dollars), + }) + } + }) + + const metricsMap = new Map< + string, + { + weekly_runs: number + weekly_dollars: number + total_dollars: number + total_invocations: number + avg_cost_per_run: number + unique_users: number + last_used: Date | string | null + } + >() + usageMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name) { + const key = `${metric.publisher_id}/${metric.agent_name}` + const weeklyData = weeklyMap.get(key) || { weekly_runs: 0, weekly_dollars: 0 } + metricsMap.set(key, { + weekly_runs: weeklyData.weekly_runs, + weekly_dollars: weeklyData.weekly_dollars, + total_dollars: Number(metric.total_dollars), + total_invocations: Number(metric.total_invocations), + avg_cost_per_run: Number(metric.avg_cost_per_run), + unique_users: Number(metric.unique_users), + last_used: metric.last_used ?? null, + }) + } + }) + + const perVersionWeeklyMap = new Map() + perVersionWeeklyMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name && metric.agent_version) { + const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` + perVersionWeeklyMap.set(key, { + weekly_runs: Number(metric.weekly_runs), + weekly_dollars: Number(metric.weekly_dollars), + }) + } + }) + + const perVersionMetricsMap = new Map>() + perVersionMetrics.forEach((metric) => { + if (metric.publisher_id && metric.agent_name && metric.agent_version) { + const key = `${metric.publisher_id}/${metric.agent_name}@${metric.agent_version}` + const weeklyData = perVersionWeeklyMap.get(key) || { weekly_runs: 0, weekly_dollars: 0 } + perVersionMetricsMap.set(key, { + weekly_runs: weeklyData.weekly_runs, + weekly_dollars: weeklyData.weekly_dollars, + total_dollars: Number(metric.total_dollars), + total_invocations: Number(metric.total_invocations), + avg_cost_per_run: Number(metric.avg_cost_per_run), + unique_users: Number(metric.unique_users), + last_used: metric.last_used + ? typeof metric.last_used === 'string' + ? metric.last_used + : metric.last_used.toISOString() + : null, + }) + } + }) + + const versionMetricsByAgent = new Map>() + perVersionMetricsMap.forEach((metrics, key) => { + const [publisherAgentKey, version] = key.split('@') + if (!versionMetricsByAgent.has(publisherAgentKey)) { + versionMetricsByAgent.set(publisherAgentKey, {}) + } + versionMetricsByAgent.get(publisherAgentKey)![version] = metrics + }) + + const latestAgents = new Map< + string, + { agent: AgentRow; agentData: any; agentName: string } + >() + agents.forEach((agent) => { + const agentData = typeof agent.data === 'string' ? JSON.parse(agent.data) : agent.data + const agentName = agentData?.name || agent.id + const key = `${agent.publisher.id}/${agentName}` + if (!latestAgents.has(key)) { + latestAgents.set(key, { agent, agentData, agentName }) + } + }) + + const result = Array.from(latestAgents.values()).map(({ agent, agentData, agentName }) => { + const agentKey = `${agent.publisher.id}/${agentName}` + const metrics = metricsMap.get(agentKey) || { + weekly_runs: 0, + weekly_dollars: 0, + total_dollars: 0, + total_invocations: 0, + avg_cost_per_run: 0, + unique_users: 0, + last_used: null, + } + const versionStatsKey = `${agent.publisher.id}/${agent.id}` + const rawVersionStats = versionMetricsByAgent.get(versionStatsKey) || {} + const version_stats = Object.fromEntries( + Object.entries(rawVersionStats).map(([version, stats]) => [ + version, + { ...stats, last_used: (stats as any)?.last_used ?? undefined }, + ]), + ) + + return { + id: agent.id, + name: agentName, + description: agentData?.description, + publisher: agent.publisher, + version: agent.version, + created_at: agent.created_at instanceof Date ? agent.created_at.toISOString() : (agent.created_at as string), + usage_count: metrics.total_invocations, + weekly_runs: metrics.weekly_runs, + weekly_spent: metrics.weekly_dollars, + total_spent: metrics.total_dollars, + avg_cost_per_invocation: metrics.avg_cost_per_run, + unique_users: metrics.unique_users, + last_used: metrics.last_used + ? typeof metrics.last_used === 'string' + ? metrics.last_used + : metrics.last_used.toISOString() + : undefined, + version_stats, + tags: agentData?.tags || [], + } + }) + + result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) + return result +} + diff --git a/web/src/server/apply-cache-headers.ts b/web/src/server/apply-cache-headers.ts new file mode 100644 index 000000000..cea5e03ed --- /dev/null +++ b/web/src/server/apply-cache-headers.ts @@ -0,0 +1,15 @@ +export interface HeaderWritable { + headers: { set: (k: string, v: string) => void } +} + +export function applyCacheHeaders(res: T): T { + res.headers.set( + 'Cache-Control', + 'public, max-age=300, s-maxage=600, stale-while-revalidate=3600', + ) + res.headers.set('Vary', 'Accept-Encoding') + res.headers.set('X-Content-Type-Options', 'nosniff') + res.headers.set('Content-Type', 'application/json; charset=utf-8') + return res +} + diff --git a/web/tsconfig.json b/web/tsconfig.json index ae6432bbe..353ba8fcf 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -4,7 +4,7 @@ "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "baseUrl": ".", - "types": ["bun", "node"], + "types": ["bun", "node", "jest", "@testing-library/jest-dom"], "allowJs": true, "skipLibCheck": true, "strict": true,