Skip to content

Commit b85728c

Browse files
committed
feat(web): SSR Store SEO Phase 1+2, dynamic metadata, sitemap, warm-cache script; add SSR e2e test + LHCI + docs
1 parent e1fa274 commit b85728c

File tree

14 files changed

+367
-102
lines changed

14 files changed

+367
-102
lines changed

.github/workflows/lighthouse.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Lighthouse CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [ main ]
7+
8+
jobs:
9+
lhci:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v4
14+
15+
- name: Setup Bun
16+
uses: oven-sh/setup-bun@v2
17+
with:
18+
bun-version: '1.1.34'
19+
20+
- name: Install dependencies (workspace)
21+
run: bun install --frozen-lockfile
22+
23+
- name: Build web
24+
working-directory: web
25+
run: bun run build
26+
env:
27+
NEXT_PUBLIC_CODEBUFF_APP_URL: http://127.0.0.1:3000
28+
29+
- name: Start Next server
30+
working-directory: web
31+
run: |
32+
export NEXT_PUBLIC_WEB_PORT=3000
33+
export E2E_ENABLE_QUERY_FIXTURE=1
34+
nohup bun run start >/tmp/web.log 2>&1 &
35+
echo $! > /tmp/web.pid
36+
for i in {1..60}; do
37+
if curl -sSf http://127.0.0.1:3000/store?e2eFixture=1 >/dev/null; then
38+
echo "Server is up"; break; fi; sleep 1; done
39+
40+
- name: Run Lighthouse CI
41+
working-directory: web
42+
run: bunx lhci autorun --config=lighthouserc.json
43+
44+
- name: Upload server logs on failure
45+
if: failure()
46+
run: |
47+
echo "\n--- web server log ---\n"
48+
cat /tmp/web.log || true
49+
50+
- name: Stop Next server
51+
if: always()
52+
run: |
53+
if [ -f /tmp/web.pid ]; then kill $(cat /tmp/web.pid) || true; fi
54+

web/README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,45 @@ The following scripts are available in the `package.json`:
7676
- `test:watch`: Run unit tests in watch mode
7777
- `e2e`: Run end-to-end tests
7878
- `e2e:ui`: Run end-to-end tests with UI
79-
- `postbuild`: Generate sitemap
79+
- `postbuild`: Warm Store cache (non-blocking)
80+
- `warm:store`: Warm Store cache via `/api/agents`
8081
- `prepare`: Install Husky for managing Git hooks
82+
83+
## SEO & SSR
84+
85+
- Store SSR: `src/app/store/page.tsx` renders agents server-side using cached data (ISR `revalidate=600`).
86+
- Client fallback: `src/app/store/store-client.tsx` only fetches `/api/agents` if SSR data is empty.
87+
- Dynamic metadata:
88+
- Store: `src/app/store/page.tsx`
89+
- Publisher: `src/app/publishers/[id]/page.tsx`
90+
- Agent detail: `src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx`
91+
92+
### Warm the Store cache
93+
94+
- Script: `scripts/warm-store-cache.ts`
95+
- Local: `bun run -C web warm:store`
96+
- CI/CD: run after deploy; set `NEXT_PUBLIC_CODEBUFF_APP_URL` to your deployed base URL.
97+
98+
### E2E tests for SSR and hydration
99+
100+
- Hydration fallback: `src/__tests__/e2e/store-hydration.spec.ts`
101+
- SSR HTML: `src/__tests__/e2e/store-ssr.spec.ts` (JavaScript disabled) using server-side fixture `src/app/store/e2e-fixture.ts` when `E2E_ENABLE_QUERY_FIXTURE=1`.
102+
103+
Run locally:
104+
105+
```
106+
cd web
107+
bun run e2e
108+
```
109+
110+
## Lighthouse CI
111+
112+
- Config: `lighthouserc.json`
113+
- Workflow: `.github/workflows/lighthouse.yml`
114+
115+
Run locally:
116+
117+
```
118+
cd web
119+
bunx lhci autorun --config=lighthouserc.json
120+
```

web/lighthouserc.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"ci": {
3+
"collect": {
4+
"url": [
5+
"http://127.0.0.1:3000/store?e2eFixture=1"
6+
],
7+
"numberOfRuns": 1,
8+
"settings": {
9+
"preset": "desktop"
10+
}
11+
},
12+
"assert": {
13+
"assertions": {
14+
"categories:performance": ["warn", { "minScore": 0.8 }],
15+
"categories:seo": ["warn", { "minScore": 0.9 }],
16+
"categories:accessibility": ["warn", { "minScore": 0.9 }],
17+
"categories:best-practices": ["warn", { "minScore": 0.9 }]
18+
}
19+
},
20+
"upload": {
21+
"target": "temporary-public-storage"
22+
}
23+
}
24+
}
25+

web/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"build": "next build 2>&1 | sed '/Contentlayer esbuild warnings:/,/^]/d'",
1515
"start": "next start",
1616
"preview": "bun run build && bun run start",
17+
"postbuild": "bun run warm:store || true",
1718
"contentlayer": "contentlayer build",
1819
"lint": "next lint",
1920
"lint:fix": "next lint --fix",
@@ -24,6 +25,7 @@
2425
"test:watch": "jest --watchAll",
2526
"e2e": "playwright test",
2627
"e2e:ui": "playwright test --ui",
28+
"warm:store": "bun run scripts/warm-store-cache.ts",
2729
"discord:start": "bun run scripts/discord/index.ts",
2830
"discord:register": "bun run scripts/discord/register-commands.ts",
2931
"clean": "rm -rf .next"
@@ -132,6 +134,7 @@
132134
"typescript": "^5",
133135
"unified": "^11.0.5",
134136
"unist-util-visit": "^5.0.0",
135-
"vfile-matter": "^5.0.1"
137+
"vfile-matter": "^5.0.1",
138+
"@lhci/cli": "^0.13.0"
136139
}
137140
}

web/playwright.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,8 @@ export default defineConfig({
3131
command: 'NEXT_PUBLIC_WEB_PORT=3001 bun run dev',
3232
url: 'http://127.0.0.1:3001',
3333
reuseExistingServer: !process.env.CI,
34+
env: {
35+
E2E_ENABLE_QUERY_FIXTURE: '1',
36+
},
3437
},
3538
})

web/scripts/warm-store-cache.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bun
2+
import 'dotenv/config'
3+
4+
const base = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000'
5+
const url = `${base}/api/agents`
6+
7+
async function main() {
8+
try {
9+
const res = await fetch(url, {
10+
headers: {
11+
'User-Agent': 'Codebuff-Warm-Store-Cache',
12+
Accept: 'application/json',
13+
},
14+
})
15+
if (!res.ok) {
16+
console.error(`Warm cache failed: ${res.status} ${res.statusText}`)
17+
process.exitCode = 0 // do not fail pipeline
18+
return
19+
}
20+
21+
const data = (await res.json()) as unknown[]
22+
console.log(
23+
`Warm cache succeeded: fetched ${Array.isArray(data) ? data.length : 0} agents from ${url}`,
24+
)
25+
} catch (err) {
26+
console.error(`Warm cache error: ${(err as Error).message}`)
27+
// Do not fail build/postbuild step
28+
process.exitCode = 0
29+
}
30+
}
31+
32+
await main()
33+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
// Disable JS to validate pure SSR HTML
4+
test.use({ javaScriptEnabled: false })
5+
6+
test('SSR HTML contains at least one agent card', async ({ page }) => {
7+
const response = await page.goto('/store?e2eFixture=1', {
8+
waitUntil: 'domcontentloaded',
9+
})
10+
expect(response).not.toBeNull()
11+
const html = await response!.text()
12+
13+
// Validate SSR output contains agent content (publisher + id)
14+
expect(html).toContain('@codebuff')
15+
expect(html).toContain('>base<')
16+
})

web/src/app/publishers/[id]/agents/[agentId]/[version]/page.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,27 @@ export async function generateMetadata({ params }: AgentDetailPageProps) {
5858
? JSON.parse(agent[0].data)
5959
: agent[0].data
6060
const agentName = agentData.name || params.agentId
61+
// Fetch publisher for OG image
62+
const pub = await db
63+
.select()
64+
.from(schema.publisher)
65+
.where(eq(schema.publisher.id, params.id))
66+
.limit(1)
67+
68+
const title = `${agentName} v${agent[0].version} - Agent Details`
69+
const description =
70+
agentData.description || `View details for ${agentName} version ${agent[0].version}`
71+
const ogImages = (pub?.[0]?.avatar_url ? [pub[0].avatar_url] : []) as string[]
6172

6273
return {
63-
title: `${agentName} v${agent[0].version} - Agent Details`,
64-
description:
65-
agentData.description ||
66-
`View details for ${agentName} version ${agent[0].version}`,
74+
title,
75+
description,
76+
openGraph: {
77+
title,
78+
description,
79+
type: 'article',
80+
images: ogImages,
81+
},
6782
}
6883
}
6984

web/src/app/publishers/[id]/page.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,20 @@ export async function generateMetadata({ params }: PublisherPageProps) {
2929
}
3030
}
3131

32+
const title = `${publisher[0].name} - Codebuff Publisher`
33+
const description =
34+
publisher[0].bio || `View ${publisher[0].name}'s published agents on Codebuff`
35+
const ogImages = (publisher[0].avatar_url ? [publisher[0].avatar_url] : []) as string[]
36+
3237
return {
33-
title: `${publisher[0].name} - Codebuff Publisher`,
34-
description:
35-
publisher[0].bio ||
36-
`View ${publisher[0].name}'s published agents on Codebuff`,
38+
title,
39+
description,
40+
openGraph: {
41+
title,
42+
description,
43+
type: 'profile',
44+
images: ogImages,
45+
},
3746
}
3847
}
3948

web/src/app/sitemap.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,61 @@
11
import { env } from '@codebuff/common/env'
2+
import { getCachedAgents } from '@/server/agents-data'
23

34
import type { MetadataRoute } from 'next'
45

5-
export default function sitemap(): MetadataRoute.Sitemap {
6-
return [
6+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
7+
const base = env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'http://localhost:3000'
8+
const toUrl = (path: string) => `${base}${path}`
9+
10+
const items: MetadataRoute.Sitemap = [
711
{
8-
url: env.NEXT_PUBLIC_CODEBUFF_APP_URL || '/',
12+
url: toUrl('/'),
913
lastModified: new Date(),
1014
changeFrequency: 'yearly',
1115
priority: 1,
1216
alternates: {
1317
languages: {
14-
pl: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pl`,
18+
pl: toUrl('/pl'),
1519
},
1620
},
1721
},
22+
{
23+
url: toUrl('/store'),
24+
lastModified: new Date(),
25+
changeFrequency: 'hourly',
26+
priority: 0.9,
27+
},
1828
]
29+
30+
// Include agent detail pages and publisher pages derived from cached store data
31+
try {
32+
const agents = await getCachedAgents()
33+
34+
const seenPublishers = new Set<string>()
35+
for (const agent of agents) {
36+
const pubId = agent.publisher?.id
37+
if (pubId && !seenPublishers.has(pubId)) {
38+
items.push({
39+
url: toUrl(`/publishers/${pubId}`),
40+
lastModified: new Date(agent.last_used || agent.created_at),
41+
changeFrequency: 'daily',
42+
priority: 0.7,
43+
})
44+
seenPublishers.add(pubId)
45+
}
46+
47+
if (pubId && agent.id && agent.version) {
48+
items.push({
49+
url: toUrl(`/publishers/${pubId}/agents/${agent.id}/${agent.version}`),
50+
lastModified: new Date(agent.last_used || agent.created_at),
51+
changeFrequency: 'daily',
52+
priority: 0.8,
53+
})
54+
}
55+
}
56+
} catch {
57+
// If fetching fails, fall back to base entries only
58+
}
59+
60+
return items
1961
}

0 commit comments

Comments
 (0)