An API you can curl, or open in a browser, to receive Bluesky data as markdown!
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add rate limiting and smarter cache headers

- Edge middleware: 20 req/min per IP on /profile/*, /search, /trending
Returns 429 with Retry-After: 60 when exceeded
- Posts/threads: s-maxage=86400, stale-while-revalidate=604800 (immutable)
- Profiles/lists/search: s-maxage=120, stale-while-revalidate=3600
Keeps Vercel serverless invocations and Bluesky API calls sustainable
on the free tier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

jack 9a6a4264 6f647095

+68 -14
+2 -2
app/profile/[handle]/post/[rkey]/route.ts
··· 1 1 import { type NextRequest } from 'next/server' 2 2 import { getPost } from '@/lib/bsky' 3 3 import { renderPost } from '@/lib/markdown' 4 - import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 4 + import { immutableMarkdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 5 6 6 export async function GET( 7 7 req: NextRequest, ··· 11 11 const { handle, rkey } = await params 12 12 const post = await getPost(handle, rkey) 13 13 const md = renderPost(post, baseUrl(req)) 14 - return markdownResponse(md) 14 + return immutableMarkdownResponse(md) 15 15 }) 16 16 } 17 17
+2 -2
app/profile/[handle]/post/[rkey]/thread/route.ts
··· 1 1 import { type NextRequest } from 'next/server' 2 2 import { getThread } from '@/lib/bsky' 3 3 import { renderThread } from '@/lib/markdown' 4 - import { markdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 4 + import { immutableMarkdownResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 5 6 6 export async function GET( 7 7 req: NextRequest, ··· 11 11 const { handle, rkey } = await params 12 12 const thread = await getThread(handle, rkey) 13 13 const md = renderThread(thread, baseUrl(req)) 14 - return markdownResponse(md) 14 + return immutableMarkdownResponse(md) 15 15 }) 16 16 } 17 17
+19 -1
lib/respond.ts
··· 5 5 'Access-Control-Max-Age': '86400', 6 6 } 7 7 8 + // Default: profiles, lists, search — fresh for 2 min, stale-serve for 1 hr 8 9 const CACHE_HEADERS = { 9 - 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', 10 + 'Cache-Control': 'public, s-maxage=120, stale-while-revalidate=3600', 11 + } 12 + 13 + // Posts and threads are immutable — cache aggressively 14 + const IMMUTABLE_CACHE_HEADERS = { 15 + 'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800', 10 16 } 11 17 12 18 export function markdownResponse(body: string, status = 200): Response { ··· 17 23 Vary: 'Accept', 18 24 ...CORS_HEADERS, 19 25 ...CACHE_HEADERS, 26 + }, 27 + }) 28 + } 29 + 30 + export function immutableMarkdownResponse(body: string): Response { 31 + return new Response(body, { 32 + status: 200, 33 + headers: { 34 + 'Content-Type': 'text/markdown; charset=utf-8', 35 + Vary: 'Accept', 36 + ...CORS_HEADERS, 37 + ...IMMUTABLE_CACHE_HEADERS, 20 38 }, 21 39 }) 22 40 }
+45 -9
middleware.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server' 2 2 3 + // --------------------------------------------------------------------------- 4 + // Rate limiting — edge-side, in-memory sliding window 5 + // Each edge instance tracks its own state. Not globally coordinated, but 6 + // sufficient to cap runaway single-IP abuse and protect Bluesky's API. 7 + // --------------------------------------------------------------------------- 8 + const WINDOW_MS = 60_000 // 1 minute 9 + const MAX_REQUESTS = 20 // per IP per window 10 + 11 + const ipWindows = new Map<string, number[]>() 12 + 13 + function isRateLimited(ip: string): boolean { 14 + const now = Date.now() 15 + const hits = (ipWindows.get(ip) ?? []).filter(t => now - t < WINDOW_MS) 16 + if (hits.length >= MAX_REQUESTS) return true 17 + hits.push(now) 18 + ipWindows.set(ip, hits) 19 + return false 20 + } 21 + 22 + // --------------------------------------------------------------------------- 23 + // Terminal-client detection (curl, wget, etc.) 24 + // --------------------------------------------------------------------------- 3 25 function isTerminalClient(req: NextRequest): boolean { 4 26 const ua = req.headers.get('user-agent') ?? '' 5 27 const accept = req.headers.get('accept') ?? '' 6 28 7 - // Known terminal / CLI clients 8 29 if (/^(curl|Wget|HTTPie|httpie|xh\/|python-httpx|python-requests|Go-http-client|nushell|Nu\/|httpx\/)/i.test(ua)) { 9 30 return true 10 31 } 11 - 12 - // Anything that explicitly accepts text/markdown or text/plain first 13 32 if (/^text\/(markdown|plain)/.test(accept)) return true 14 - 15 - // No Mozilla = definitely not a browser 16 - // (every real browser sends "Mozilla/5.0 ...") 17 33 if (ua && !ua.includes('Mozilla') && !accept.includes('text/html')) { 18 34 return true 19 35 } 20 - 21 36 return false 22 37 } 23 38 39 + // --------------------------------------------------------------------------- 40 + // Middleware 41 + // --------------------------------------------------------------------------- 24 42 export function middleware(req: NextRequest) { 25 - if (req.nextUrl.pathname === '/' && isTerminalClient(req)) { 43 + const { pathname } = req.nextUrl 44 + 45 + // Terminal rewrite on homepage 46 + if (pathname === '/' && isTerminalClient(req)) { 26 47 const url = req.nextUrl.clone() 27 48 url.pathname = '/cli' 28 49 return NextResponse.rewrite(url) 29 50 } 51 + 52 + // Rate-limit API routes 53 + if (pathname.startsWith('/profile') || pathname === '/search' || pathname === '/trending') { 54 + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' 55 + if (isRateLimited(ip)) { 56 + return new NextResponse('Rate limit exceeded. Please slow down.\n', { 57 + status: 429, 58 + headers: { 59 + 'Content-Type': 'text/plain; charset=utf-8', 60 + 'Retry-After': '60', 61 + }, 62 + }) 63 + } 64 + } 65 + 30 66 return NextResponse.next() 31 67 } 32 68 33 69 export const config = { 34 - matcher: '/', 70 + matcher: ['/', '/profile/:path*', '/search', '/trending'], 35 71 }