feat: Move search functionality to Vercel function (#48443)

This commit is contained in:
Jeremias Menichelli authored and GitHub committed 2026-07-31 11:25:21 +02:00
1 parent 6a99952af5
commit a76f1f0c9b
8 files changed
+248 -18

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
}
@@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POST } from './route'
const isFeatureEnabledMock = vi.fn().mockReturnValue(true)
vi.mock('common/enabled-features', () => ({
isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args),
}))
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
function makeRequest(body: unknown) {
return new NextRequest('https://example.com/api/search/embeddings', {
method: 'POST',
body: JSON.stringify(body),
})
}
describe('/api/search/embeddings', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('returns 400 when query is missing', async () => {
const response = await POST(makeRequest({}))
expect(response.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})
it('forwards the query and feature flag to the search-embeddings function', async () => {
isFeatureEnabledMock.mockReturnValue(false)
fetchMock.mockResolvedValue(
new Response(JSON.stringify([{ id: 1, path: '/guides/test' }]), { status: 200 })
)
const response = await POST(makeRequest({ query: 'realtime' }))
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toContain('/functions/v1/search-embeddings')
expect(JSON.parse(init.body)).toEqual({ query: 'realtime', useAlternateSearchIndex: true })
expect(response.status).toBe(200)
expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }])
})
it('propagates the upstream status on error', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'boom' }), { status: 500 }))
const response = await POST(makeRequest({ query: 'realtime' }))
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'boom' })
})
})
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs'
import { type NextRequest } from 'next/server'
import { corsHeaders } from '../cors'
import { _handleEmbeddingsSearchRequest } from './route.utils'
export const runtime = 'edge'
export async function OPTIONS() {
return new Response(null, { headers: corsHeaders })
}
export async function POST(request: NextRequest) {
try {
const response = await _handleEmbeddingsSearchRequest(request)
Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value))
return response
} catch (error) {
console.error('Error handling docs embeddings search request:', error)
Sentry.captureException(error, { tags: { route: 'search-embeddings' } })
return Response.json(
{ error: 'There was an error processing your request' },
{ status: 500, headers: corsHeaders }
)
}
}
@@ -0,0 +1,33 @@
import * as Sentry from '@sentry/nextjs'
import { isFeatureEnabled } from 'common/enabled-features'
import { type NextRequest } from 'next/server'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
export async function _handleEmbeddingsSearchRequest(request: NextRequest) {
const { query } = await request.json()
if (!query || typeof query !== 'string') {
return Response.json({ error: 'Missing query in request data' }, { status: 400 })
}
const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex')
const response = await fetch(`${SUPABASE_URL}/functions/v1/search-embeddings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, useAlternateSearchIndex }),
})
const data = await response.json()
if (!response.ok) {
console.error('Error running docs embeddings search:', data)
Sentry.captureException(new Error(data?.error ?? 'search-embeddings request failed'), {
tags: { route: 'search-embeddings' },
extra: { query, status: response.status, data },
})
}
return Response.json(data, { status: response.status })
}
@@ -0,0 +1,62 @@
import { NextRequest } from 'next/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POST } from './route'
const rpcSpy = vi.fn()
vi.mock('~/lib/supabase', () => ({
supabase: () => ({ rpc: rpcSpy }),
}))
const isFeatureEnabledMock = vi.fn().mockReturnValue(true)
vi.mock('common/enabled-features', () => ({
isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args),
}))
function makeRequest(body: unknown) {
return new NextRequest('https://example.com/api/search/fts', {
method: 'POST',
body: JSON.stringify(body),
})
}
describe('/api/search/fts', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('returns 400 when query is missing', async () => {
const response = await POST(makeRequest({}))
expect(response.status).toBe(400)
expect(rpcSpy).not.toHaveBeenCalled()
})
it('calls docs_search_fts when the full index feature is enabled', async () => {
isFeatureEnabledMock.mockReturnValue(true)
rpcSpy.mockResolvedValue({ data: [{ id: 1, path: '/guides/test' }], error: null })
const response = await POST(makeRequest({ query: ' realtime ' }))
expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts', { query: 'realtime' })
expect(response.status).toBe(200)
expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }])
})
it('calls docs_search_fts_nimbus when the full index feature is disabled', async () => {
isFeatureEnabledMock.mockReturnValue(false)
rpcSpy.mockResolvedValue({ data: [], error: null })
await POST(makeRequest({ query: 'realtime' }))
expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts_nimbus', { query: 'realtime' })
})
it('returns 500 when the RPC errors', async () => {
rpcSpy.mockResolvedValue({ data: null, error: { message: 'boom' } })
const response = await POST(makeRequest({ query: 'realtime' }))
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'boom' })
})
})
+26
View File
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs'
import { type NextRequest } from 'next/server'
import { corsHeaders } from '../cors'
import { _handleFtsSearchRequest } from './route.utils'
export const runtime = 'edge'
export async function OPTIONS() {
return new Response(null, { headers: corsHeaders })
}
export async function POST(request: NextRequest) {
try {
const response = await _handleFtsSearchRequest(request)
Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value))
return response
} catch (error) {
console.error('Error handling docs full-text search request:', error)
Sentry.captureException(error, { tags: { route: 'search-fts' } })
return Response.json(
{ error: 'There was an error processing your request' },
{ status: 500, headers: corsHeaders }
)
}
}
@@ -0,0 +1,28 @@
import * as Sentry from '@sentry/nextjs'
import { supabase } from '~/lib/supabase'
import { isFeatureEnabled } from 'common/enabled-features'
import { type NextRequest } from 'next/server'
export async function _handleFtsSearchRequest(request: NextRequest) {
const { query } = await request.json()
if (!query || typeof query !== 'string') {
return Response.json({ error: 'Missing query in request data' }, { status: 400 })
}
const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex')
const searchFunction = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts'
const { data, error } = await supabase().rpc(searchFunction, { query: query.trim() })
if (error) {
console.error('Error running docs full-text search:', error)
Sentry.captureException(new Error(error.message), {
tags: { route: 'search-fts' },
extra: { query, searchFunction, error },
})
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json(data)
}
+12 -18
View File
@@ -3,13 +3,15 @@
import { compact, debounce, uniqBy } from 'lodash'
import { useCallback, useMemo, useReducer, useRef } from 'react'
import { isFeatureEnabled } from '../enabled-features'
const NUMBER_SOURCES = 2
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
const FUNCTIONS_URL = '/functions/v1/'
// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs').
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
// Public URL of the docs deployment, which hosts the search API routes.
// Same constant apps/studio already uses for cross-linking to docs (see lib/constants/index.ts).
const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs'
// From inside the docs app itself, call our own routes relatively; from studio/www, call docs directly.
const SEARCH_API_BASE = BASE_PATH === '/docs' ? BASE_PATH : DOCS_URL
enum PageType {
Markdown = 'markdown',
@@ -203,18 +205,9 @@ const useDocsSearch = () => {
let sourcesLoaded = 0
const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex')
const searchEndpoint = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts'
fetch(`${SUPABASE_URL}/rest/v1/rpc/${searchEndpoint}`, {
fetch(`${SEARCH_API_BASE}/api/search/fts`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(SUPABASE_ANON_KEY && {
apikey: SUPABASE_ANON_KEY,
authorization: `Bearer ${SUPABASE_ANON_KEY}`,
}),
},
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: query.trim() }),
})
.then((res) => res.json())
@@ -248,9 +241,10 @@ const useDocsSearch = () => {
})
})
fetch(`${SUPABASE_URL}${FUNCTIONS_URL}search-embeddings`, {
fetch(`${SEARCH_API_BASE}/api/search/embeddings`, {
method: 'POST',
body: JSON.stringify({ query, useAlternateSearchIndex }),
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query }),
})
.then((response) => response.json())
.then((results) => {