From c06106b09ba599f1298ca7f88c3585796a64b8ac Mon Sep 17 00:00:00 2001 From: Federico Coppede Date: Fri, 2 Oct 2026 15:15:27 -0300 Subject: [PATCH] docs: replace built in fumadocs search with custom semantic vector search (#12862) **Description:** Replaces Fumadocs' default client-side Orama search with our server-side Postgres vector search backend. **Key Changes:** - API Proxy (app/api/search/route.ts): Added a secure proxy to the search backend with IP rate-limiting and Edge caching. - Custom Search UI (custom-search-dialog.tsx): Built a headless Fumadocs search dialog featuring 300ms debouncing, request cancellation (AbortController), and URL deduplication. - Smart Highlighting: Added the component to render exact matches with Tailwind styling inside the search results. - Providers: Hooked the new custom dialog into the RootProvider. (cherry picked from commit ab3b44cd743709649e46f7708da2f6a6a70bc227) --- apps/docs/app/api/search/route.ts | 100 ++++---- apps/docs/app/providers.tsx | 20 +- apps/docs/components/custom-search-dialog.tsx | 216 ++++++++++++++++++ 3 files changed, 276 insertions(+), 60 deletions(-) create mode 100644 apps/docs/components/custom-search-dialog.tsx diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts index 1db78882de..c1784dba16 100644 --- a/apps/docs/app/api/search/route.ts +++ b/apps/docs/app/api/search/route.ts @@ -1,50 +1,58 @@ -import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'; -import { createSearchAPI } from 'fumadocs-core/search/server'; -import { source, versionSource } from '@/lib/source'; -import { getVersionFromUrl, LATEST_VERSION } from '@/lib/versions'; +// app/api/search/route.ts +import { NextResponse } from 'next/server'; -// Both loaders share one index. Every entry is tagged with its docs version so the -// client can scope results to the version being viewed (see app/providers.tsx). -function indexPages(loader: typeof source | typeof versionSource, getTag: (url: string) => string) { - const tree = loader.getPageTree(); - - return loader.getPages().map((page) => ({ - id: page.url, - url: page.url, - title: page.data.title, - description: page.data.description, - structuredData: page.data.structuredData, - // Sidebar path (e.g. "Deploy & Operate › Self-Hosted"), shown with each result. - breadcrumbs: getBreadcrumbItems(page.url, tree) - .map((item) => item.name) - .filter((name): name is string => typeof name === 'string'), - tag: getTag(page.url), - })); -} - -const searchAPI = createSearchAPI('advanced', { - // https://docs.orama.com/docs/orama-js/supported-languages - language: 'english', - indexes: [ - ...indexPages(source, () => LATEST_VERSION), - ...indexPages(versionSource, getVersionFromUrl), - ], -}); - -// fumadocs' built-in default. Its endpoint passes `limit: undefined` when the URL -// has no `?limit=`, which overrides that default and returns every match (450 -// entries for "oidc"). -const DEFAULT_LIMIT = '60'; - -// Results only change with a deploy, so the CDN answers repeated queries (cached per -// URL, i.e. per query and tag) instead of a function that may first have to rebuild -// the index on a cold start. A static index is not an option: one version alone -// serializes to over 20 MB of JSON. export async function GET(request: Request) { - const url = new URL(request.url); - if (!url.searchParams.has('limit')) url.searchParams.set('limit', DEFAULT_LIMIT); + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + const searchType = searchParams.get('type') || 'docs'; - const response = await searchAPI.GET(new Request(url)); - response.headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=604800'); - return response; + if (!query || query.trim() === '') { + return NextResponse.json([]); + } + const safeQuery = query.substring(0, 150).trim(); + + // Extract original client IP to pass down to the Express rate limiter + const forwardedFor = request.headers.get('x-forwarded-for'); + const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : '127.0.0.1'; + + const baseUrl = process.env.DOCS_SEARCH_URL || 'http://localhost:8080'; + const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); + + const backendUrl = new URL(`${cleanBaseUrl}/api/search/docs`); + backendUrl.searchParams.set('q', safeQuery); + backendUrl.searchParams.set('limit', '15'); + backendUrl.searchParams.set('type', searchType); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + + const response = await fetch(backendUrl.toString(), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${process.env.DOCS_SEARCH_SECRET}`, + 'Content-Type': 'application/json', + 'X-Forwarded-For': clientIp // Forwarding IP to Express + }, + signal: controller.signal, + next: { revalidate: 300 } + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 429) { + return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }); + } + return NextResponse.json({ error: 'Search service unavailable' }, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data); + + } catch (error: any) { + console.error('Proxy Search Error:', error.name === 'AbortError' ? 'Timeout' : error.message); + const status = error.name === 'AbortError' ? 504 : 500; + return NextResponse.json({ error: 'Search service connection failed' }, { status }); + } } diff --git a/apps/docs/app/providers.tsx b/apps/docs/app/providers.tsx index 60bce1a272..02794d4a7f 100644 --- a/apps/docs/app/providers.tsx +++ b/apps/docs/app/providers.tsx @@ -2,26 +2,18 @@ import { RootProvider } from 'fumadocs-ui/provider/next'; import { ReactNode } from 'react'; -import { useParams } from 'next/navigation'; - import MixpanelProvider from '@/components/mixpanel-provider'; import PlausibleProvider from '@/components/plausible-provider'; -import { getVersionFromSlug } from '@/lib/versions'; + +// Import our new custom dialog +import CustomSearchDialog from '@/components/custom-search-dialog'; export function Providers({ children }: { children: ReactNode }) { - const params = useParams(); - const slug = params?.slug as string[] | undefined; - return ( @@ -30,4 +22,4 @@ export function Providers({ children }: { children: ReactNode }) { ); -} +} \ No newline at end of file diff --git a/apps/docs/components/custom-search-dialog.tsx b/apps/docs/components/custom-search-dialog.tsx new file mode 100644 index 0000000000..50b56e3ea7 --- /dev/null +++ b/apps/docs/components/custom-search-dialog.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useState, ReactNode } from 'react'; +import { useParams } from 'next/navigation'; +import { + SearchDialog, + SearchDialogClose, + SearchDialogContent, + SearchDialogHeader, + SearchDialogIcon, + SearchDialogInput, + SearchDialogList, + SearchDialogOverlay, + type SharedProps, +} from 'fumadocs-ui/components/dialog/search'; +import { getVersionFromSlug } from '@/lib/versions'; + +type SearchResultItem = { + id: string; + content: ReactNode; + url: string; + type: 'page'; +}; + +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const HighlightMatch = ({ text, query }: { text: string; query: string }) => { + if (!query || !text) { + return {text}; + } + + const safeRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + const parts = text.split(safeRegex); + + return ( + + {parts.map((part, i) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + + ); +}; + +export default function CustomSearchDialog(props: SharedProps) { + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [searchType, setSearchType] = useState<'docs' | 'api'>('docs'); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + // New error state to track backend failures + const [errorMsg, setErrorMsg] = useState(null); + + const params = useParams(); + const slug = params?.slug as string[] | undefined; + const currentVersion = getVersionFromSlug(slug); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedQuery(query); + }, 300); + return () => clearTimeout(handler); + }, [query]); + + useEffect(() => { + if (!debouncedQuery.trim()) { + setResults([]); + setIsLoading(false); + setErrorMsg(null); + return; + } + + setIsLoading(true); + setErrorMsg(null); + const abortController = new AbortController(); + + async function fetchResults() { + try { + const searchUrl = `/docs/api/search?q=${encodeURIComponent(debouncedQuery)}&type=${searchType}&tag=${encodeURIComponent(currentVersion)}`; + const res = await fetch(searchUrl, { signal: abortController.signal }); + + if (!res.ok) { + if (res.status === 429) throw new Error('Too many requests. Please slow down.'); + throw new Error('Search service is currently unavailable.'); + } + + const data = await res.json(); + const seenUrls = new Set(); + const formattedResults: SearchResultItem[] = []; + + for (const item of data) { + const targetUrl = item.url; + + if (!seenUrls.has(targetUrl)) { + seenUrls.add(targetUrl); + + formattedResults.push({ + id: targetUrl, + content: ( +
+ + {item.title || 'Documentation Page'} + + +
+ ), + url: targetUrl, + type: 'page' + }); + + if (formattedResults.length >= 15) break; + } + } + + setResults(formattedResults); + + } catch (err: any) { + if (err.name !== 'AbortError') { + console.error('Search error:', err); + setErrorMsg(err.message); + setResults([]); + } + } finally { + // Fix: Only clear loading if this specific request wasn't superseded/aborted + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + } + + fetchResults(); + + return () => abortController.abort(); + }, [debouncedQuery, searchType, currentVersion]); + + return ( + + + + +
+ + + + {isLoading && ( +
+
+
+ )} + + +
+ +
+ + +
+ + + {results.length > 0 && } + + {/* Explicit Error State */} + {errorMsg && !isLoading && ( +
+ {errorMsg} +
+ )} + + {!debouncedQuery && !errorMsg && ( +
+ Type a query to search {searchType === 'docs' ? 'Guides & Docs' : 'API Endpoints'}. +
+ )} + + {!isLoading && debouncedQuery && results.length === 0 && !errorMsg && ( +
+ No results found in {searchType === 'docs' ? 'Docs' : 'API Endpoints'} for "{debouncedQuery}". +
+ )} + + + ); +}