feat: implement storage-backed OG previews with Upstash Redis

- Add ogStore service for Redis get/set operations
- Extract shared logic: ogHtml (generateHtml, escapeHtml) and articleMeta (relay/gateway fetching)
- Refactor article-og endpoint to read from Redis, try gateway on miss, trigger background refresh
- Add article-og-refresh endpoint for background relay fetching and caching
- Update vercel.json with refresh function config
- Remove WebSocket dependencies from main OG endpoint for faster crawler responses
This commit is contained in:
Gigi
2025-11-07 18:41:08 +01:00
parent 971b672591
commit c81b7b89d1
8 changed files with 330 additions and 238 deletions

35
api/article-og-refresh.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { setArticleMeta } from '../src/services/ogStore'
import { fetchArticleMetadataViaRelays } from '../src/services/articleMeta'
export default async function handler(req: VercelRequest, res: VercelResponse) {
// Validate refresh secret
const providedSecret = req.headers['x-refresh-key']
const expectedSecret = process.env.OG_REFRESH_SECRET || ''
if (providedSecret !== expectedSecret) {
return res.status(401).json({ error: 'Unauthorized' })
}
const naddr = (req.query.naddr as string | undefined)?.trim()
if (!naddr) {
return res.status(400).json({ error: 'Missing naddr parameter' })
}
try {
// Fetch metadata via relays (WebSockets)
const meta = await fetchArticleMetadataViaRelays(naddr)
if (meta) {
// Store in Redis
await setArticleMeta(naddr, meta)
return res.status(200).json({ ok: true, cached: true })
} else {
return res.status(200).json({ ok: true, cached: false })
}
} catch (err) {
console.error('Error refreshing article metadata:', err)
return res.status(500).json({ error: 'Internal server error' })
}
}

View File

@@ -1,213 +1,13 @@
import type { VercelRequest, VercelResponse } from '@vercel/node' import type { VercelRequest, VercelResponse } from '@vercel/node'
import WebSocket from 'ws' import { getArticleMeta, setArticleMeta } from '../src/services/ogStore'
;(globalThis as any).WebSocket ??= WebSocket as any import { fetchArticleMetadataViaGateway } from '../src/services/articleMeta'
import { RelayPool } from 'applesauce-relay' import { generateHtml } from '../src/services/ogHtml'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { extractProfileDisplayName } from '../lib/profile'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
// Relay configuration (from src/config/relays.ts)
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net'
]
type CacheEntry = {
html: string
expires: number
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void { function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`) res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Content-Type', 'text/html; charset=utf-8')
} }
interface ArticleMetadata {
title: string
summary: string
image: string
author: string
published?: number
}
async function fetchEventsFromRelays(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
// `request` emits NostrEvent objects directly
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
// Sort by created_at and return most recent first
return events.sort((a, b) => b.created_at - a.created_at)
}
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
// Decode naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Determine relay URLs
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
// Fetch article and profile in parallel
const [articleEvents, profileEvents] = await Promise.all([
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 7000),
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pointer.pubkey]
}, 5000)
])
if (articleEvents.length === 0) {
return null
}
const article = articleEvents[0]
// Extract article metadata
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile using centralized utility
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
const displayName = extractProfileDisplayName(profileEvents[0])
if (displayName && !displayName.startsWith('@')) {
authorName = displayName
} else if (displayName) {
authorName = displayName.substring(1) // Remove @ prefix
}
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at
}
} catch (err) {
console.error('Failed to fetch article metadata:', err)
return null
} finally {
// No explicit close needed; pool manages connections internally
}
}
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Read, Highlight, Explore'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/">Boris</a>...</p>
</noscript>
<script>
(function(){
try {
var p = '/a/${naddr}';
if (window.location.pathname !== p) {
history.replaceState(null, '', p);
}
window.location.replace('/');
} catch (e) {}
})();
</script>
</body>
</html>`
}
export default async function handler(req: VercelRequest, res: VercelResponse) { export default async function handler(req: VercelRequest, res: VercelResponse) {
const naddr = (req.query.naddr as string | undefined)?.trim() const naddr = (req.query.naddr as string | undefined)?.trim()
@@ -220,43 +20,47 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('X-Boris-Debug', '1') res.setHeader('X-Boris-Debug', '1')
} }
// Check cache for bots/crawlers // Try Redis cache first
const now = Date.now() let meta = await getArticleMeta(naddr)
const cached = memoryCache.get(naddr) let cacheMaxAge = 86400
if (cached && cached.expires > now) {
setCacheHeaders(res) if (!meta) {
if (debugEnabled) { // Cache miss: try gateway (fast HTTP, no WebSockets)
// Debug mode enabled meta = await fetchArticleMetadataViaGateway(naddr)
if (meta) {
// Gateway found metadata: store it and use it
await setArticleMeta(naddr, meta).catch((err) => {
console.error('Failed to cache gateway metadata:', err)
})
cacheMaxAge = 86400
} else {
// Gateway failed: use default fallback
cacheMaxAge = 300
} }
return res.status(200).send(cached.html)
// Trigger background refresh (fire-and-forget)
const secret = process.env.OG_REFRESH_SECRET || ''
const origin = req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']
? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}`
: `https://read.withboris.com`
fetch(`${origin}/api/article-og-refresh?naddr=${encodeURIComponent(naddr)}`, {
method: 'POST',
headers: { 'x-refresh-key': secret },
keepalive: true
}).catch(() => {
// Ignore errors in background refresh trigger
})
} }
try { // Generate and send HTML
// Fetch metadata const html = generateHtml(naddr, meta)
const meta = await fetchArticleMetadata(naddr) setCacheHeaders(res, cacheMaxAge)
// Generate HTML if (debugEnabled) {
const html = generateHtml(naddr, meta) // Debug mode enabled
// Cache the result
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
// Send response
setCacheHeaders(res)
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(html)
} catch (err) {
console.error('Error generating article OG HTML:', err)
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(html)
} }
return res.status(200).send(html)
} }

19
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2", "@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5", "@treeee/youtube-caption-extractor": "^1.5.5",
"@upstash/redis": "^1.35.6",
"@vercel/node": "^5.3.26", "@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0", "applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0", "applesauce-content": "^4.0.0",
@@ -57,6 +58,9 @@
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
},
"engines": {
"node": "22.x"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -3804,6 +3808,15 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@upstash/redis": {
"version": "1.35.6",
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.6.tgz",
"integrity": "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA==",
"license": "MIT",
"dependencies": {
"uncrypto": "^0.1.3"
}
},
"node_modules/@vercel/build-utils": { "node_modules/@vercel/build-utils": {
"version": "12.1.2", "version": "12.1.2",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz", "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-12.1.2.tgz",
@@ -11561,6 +11574,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici": { "node_modules/undici": {
"version": "5.28.4", "version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",

View File

@@ -20,6 +20,7 @@
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.0.2", "@fortawesome/react-fontawesome": "^3.0.2",
"@treeee/youtube-caption-extractor": "^1.5.5", "@treeee/youtube-caption-extractor": "^1.5.5",
"@upstash/redis": "^1.35.6",
"@vercel/node": "^5.3.26", "@vercel/node": "^5.3.26",
"applesauce-accounts": "^4.0.0", "applesauce-accounts": "^4.0.0",
"applesauce-content": "^4.0.0", "applesauce-content": "^4.0.0",

134
src/services/articleMeta.ts Normal file
View File

@@ -0,0 +1,134 @@
import WebSocket from 'ws'
;(globalThis as any).WebSocket ??= WebSocket as any
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { extractProfileDisplayName } from '../lib/profile'
import { RELAYS } from '../config/relays'
import type { ArticleMetadata } from './ogStore'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
async function fetchEventsFromRelays(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
return events.sort((a, b) => b.created_at - a.created_at)
}
export async function fetchArticleMetadataViaRelays(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
const [articleEvents, profileEvents] = await Promise.all([
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 7000),
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pointer.pubkey]
}, 5000)
])
if (articleEvents.length === 0) {
return null
}
const article = articleEvents[0]
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
const displayName = extractProfileDisplayName(profileEvents[0])
if (displayName && !displayName.startsWith('@')) {
authorName = displayName
} else if (displayName) {
authorName = displayName.substring(1)
}
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at
}
} catch (err) {
console.error('Failed to fetch article metadata via relays:', err)
return null
}
}
export async function fetchArticleMetadataViaGateway(naddr: string): Promise<ArticleMetadata | null> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2000)
const resp = await fetch(`https://njump.to/${naddr}`, {
redirect: 'follow',
signal: controller.signal
})
clearTimeout(timeout)
if (!resp.ok) {
return null
}
const html = await resp.text()
const pick = (re: RegExp) => (html.match(re)?.[1] ?? '').trim()
const title = pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
pick(/<title[^>]*>([^<]+)<\/title>/i)
const summary = pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
const image = pick(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
if (!title && !summary && !image) {
return null
}
return {
title: title || 'Read on Boris',
summary: summary || 'Read this article on Boris',
image: image || '/boris-social-1200.png',
author: 'Boris'
}
} catch (err) {
console.error('Failed to fetch article metadata via gateway:', err)
return null
}
}

71
src/services/ogHtml.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { ArticleMetadata } from './ogStore'
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Read, Highlight, Explore'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/">Boris</a>...</p>
</noscript>
<script>
(function(){
try {
var p = '/a/${naddr}';
if (window.location.pathname !== p) {
history.replaceState(null, '', p);
}
window.location.replace('/');
} catch (e) {}
})();
</script>
</body>
</html>`
}

25
src/services/ogStore.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Redis } from '@upstash/redis'
const redisWrite = Redis.fromEnv()
const redisRead = process.env.KV_REST_API_READ_ONLY_TOKEN && process.env.KV_REST_API_URL
? new Redis({ url: process.env.KV_REST_API_URL!, token: process.env.KV_REST_API_READ_ONLY_TOKEN! })
: redisWrite
const keyOf = (naddr: string) => `og:${naddr}`
export type ArticleMetadata = {
title: string
summary: string
image: string
author: string
published?: number
}
export async function getArticleMeta(naddr: string): Promise<ArticleMetadata | null> {
return (await redisRead.get<ArticleMetadata>(keyOf(naddr))) || null
}
export async function setArticleMeta(naddr: string, meta: ArticleMetadata, ttlSec = 604800): Promise<void> {
await redisWrite.set(keyOf(naddr), meta, { ex: ttlSec })
}

View File

@@ -3,6 +3,9 @@
"functions": { "functions": {
"api/article-og.ts": { "api/article-og.ts": {
"maxDuration": 10 "maxDuration": 10
},
"api/article-og-refresh.ts": {
"maxDuration": 10
} }
}, },
"rewrites": [ "rewrites": [