Files
archy/packages/app/vite-web-search.ts
T
DorianandClaude Opus 4.6 4dc9588c8a fix(app): replace CORS Access-Control-Allow-Origin * with explicit localhost origin
Add setCorsHeaders() and handleCorsOptions() helpers in server/dev-auth.ts.
Replace wildcard CORS origin with http://localhost:5173 in all Vite plugins
and claude-proxy.ts. Include Authorization in allowed CORS headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:25:10 +00:00

207 lines
7.3 KiB
TypeScript

import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { loadEnv } from 'vite'
import { search as searchDuckDuckGo } from 'duck-duck-scrape'
import { validateDevAuth, setCorsHeaders } from './server/dev-auth'
export interface WebSearchResult {
title: string
url: string
content?: string
imgSrc?: string
engine?: string
}
// SearXNG public instances — rotated on each request to spread load
const SEARXNG_INSTANCES = [
'https://searx.tiekoetter.com',
'https://search.bus-hit.me',
'https://paulgo.io',
'https://search.sapti.me',
'https://search.ononoki.org',
'https://priv.au',
'https://opnxng.com',
'https://etsi.me',
]
// Rotate starting instance per-request to avoid hammering a single one
let instanceRotation = 0
async function fetchFromSearXNG(
searchUrl: string,
): Promise<{ results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null> {
try {
const searchRes = await fetch(searchUrl, {
headers: { Accept: 'application/json', 'User-Agent': 'AIUI/1.0' },
signal: AbortSignal.timeout(6000),
})
if (!searchRes.ok) return null
const text = await searchRes.text()
// Guard against HTML responses from captcha/blocking pages
if (text.startsWith('<') || text.startsWith('<!')) return null
return JSON.parse(text)
} catch {
return null
}
}
/** Brave Search API — free tier (2000 queries/month). Set BRAVE_SEARCH_API_KEY in .env.local */
async function fetchFromBrave(
q: string,
apiKey: string,
): Promise<WebSearchResult[]> {
try {
const res = await fetch(
`https://api.search.brave.com/res/v1/web/search?${new URLSearchParams({ q, count: '6' })}`,
{
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey,
},
signal: AbortSignal.timeout(8000),
},
)
if (!res.ok) return []
const data = (await res.json()) as {
web?: { results?: { title?: string; url?: string; description?: string; thumbnail?: { src?: string } }[] }
}
return (data.web?.results ?? [])
.filter((r) => r.title && r.url)
.slice(0, 6)
.map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.description ?? undefined,
imgSrc: r.thumbnail?.src ?? undefined,
engine: 'brave',
}))
} catch {
return []
}
}
function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: string | undefined) {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
const q = url.searchParams.get('q')?.trim()
if (!q) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing q (query)' }))
return
}
const sendResults = (results: WebSearchResult[]) => {
res.setHeader('Content-Type', 'application/json')
setCorsHeaders(res)
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ results }))
}
// 1. Brave Search API (most reliable if configured)
if (braveApiKey) {
console.log('[web-search]', q.slice(0, 40), '→ Brave API')
const braveResults = await fetchFromBrave(q, braveApiKey)
if (braveResults.length > 0) {
console.log('[web-search]', braveResults.length, 'results from Brave')
sendResults(braveResults)
return
}
console.warn('[web-search] Brave API returned no results, falling through')
}
// 2. SearXNG instances (rotate to spread load)
const instances = searxUrl ? [searxUrl] : SEARXNG_INSTANCES
const startIdx = instanceRotation % instances.length
instanceRotation++
let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null
let lastError = ''
for (let i = 0; i < instances.length; i++) {
const baseUrl = instances[(startIdx + i) % instances.length].replace(/\/$/, '')
const searchUrl = `${baseUrl}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}`
console.log('[web-search]', q.slice(0, 40), '→', baseUrl)
data = await fetchFromSearXNG(searchUrl)
if (data?.results && data.results.length > 0) {
console.log('[web-search]', data.results.length, 'results from', baseUrl)
break
}
lastError = data ? 'no results' : 'request failed'
console.warn('[web-search]', baseUrl, lastError, '— trying next')
data = null
}
if (data?.results) {
const results: WebSearchResult[] = (data.results ?? [])
.filter((r) => r.title && r.url)
.slice(0, 6)
.map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.content ?? undefined,
imgSrc: r.img_src || r.thumbnail || undefined,
engine: r.engine ?? undefined,
}))
sendResults(results)
return
}
// 3. DuckDuckGo fallback
console.warn('[web-search] SearXNG failed, trying DuckDuckGo fallback')
try {
const ddg = await searchDuckDuckGo(q)
if (ddg?.results?.length) {
const results: WebSearchResult[] = ddg.results
.filter((r: { title?: string; url?: string }) => r.title && r.url)
.slice(0, 6)
.map((r: { title: string; url: string; description?: string; icon?: string }) => ({
title: r.title,
url: r.url,
content: r.description ?? undefined,
imgSrc: r.icon ?? undefined,
}))
console.log('[web-search] DuckDuckGo fallback:', results.length, 'results')
sendResults(results)
return
}
} catch (ddgErr) {
console.warn('[web-search] DuckDuckGo fallback failed:', ddgErr)
}
console.error('[web-search] All search backends failed:', lastError)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Web search unavailable. Set BRAVE_SEARCH_API_KEY or SEARXNG_URL in .env.local.' }))
}
}
export function webSearchPlugin(): Plugin {
let searxUrl: string | undefined
let braveApiKey: string | undefined
return {
name: 'aiui-web-search',
configResolved(config) {
const env = loadEnv(config.mode, process.cwd(), '')
searxUrl = env.SEARXNG_URL ?? env.VITE_SEARXNG_URL
braveApiKey = env.BRAVE_SEARCH_API_KEY ?? env.VITE_BRAVE_SEARCH_API_KEY
if (braveApiKey) {
console.log('[web-search] Brave Search API configured')
} else if (searxUrl) {
console.log('[web-search] SearXNG:', searxUrl)
} else {
console.log('[web-search] No search API key configured — using public SearXNG instances (unreliable)')
console.log('[web-search] For reliable search, set BRAVE_SEARCH_API_KEY in .env.local (free: https://brave.com/search/api/)')
}
},
configureServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl, braveApiKey))
},
configurePreviewServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl, braveApiKey))
},
}
}