Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const emit = defineEmits(['blur', 'focus'])

const router = useRouter()
const route = useRoute()
const { isAlgolia } = useSearchProvider()

const isSearchFocused = shallowRef(false)

Expand All @@ -28,8 +29,7 @@ const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

// Debounced URL update for search query
const updateUrlQuery = debounce((value: string) => {
function updateUrlQueryImpl(value: string) {
// Don't navigate away from pages that use ?q for local filtering
if (pagesWithLocalFilter.has(route.name as string)) {
return
Expand All @@ -48,9 +48,18 @@ const updateUrlQuery = debounce((value: string) => {
q: value,
},
})
}, 250)
}

const updateUrlQueryNpm = debounce(updateUrlQueryImpl, 250)
const updateUrlQueryAlgolia = debounce(updateUrlQueryImpl, 80)

const updateUrlQuery = Object.assign(
(value: string) => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm)(value),
{
flush: () => (isAlgolia.value ? updateUrlQueryAlgolia : updateUrlQueryNpm).flush(),
},
)

// Watch input and debounce URL updates
watch(searchQuery, value => {
updateUrlQuery(value)
})
Expand Down
36 changes: 33 additions & 3 deletions app/composables/npm/search-utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types'

/**
* Convert a lightweight package-meta API response to a search result for display.
*/
export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult {
return {
package: {
Expand Down Expand Up @@ -31,3 +28,36 @@ export function emptySearchResponse(): NpmSearchResponse {
time: new Date().toISOString(),
}
}

export interface SearchSuggestion {
type: 'user' | 'org'
name: string
exists: boolean
}

export type SuggestionIntent = 'user' | 'org' | 'both' | null

export function isValidNpmName(name: string): boolean {
if (!name || name.length === 0 || name.length > 214) return false
if (!/^[a-z0-9]/i.test(name)) return false
return /^[\w-]+$/.test(name)
}

/** Parse a search query into a suggestion intent (`~user`, `@org`, or plain `both`). */
export function parseSuggestionIntent(query: string): { intent: SuggestionIntent; name: string } {
const q = query.trim()
if (!q) return { intent: null, name: '' }

if (q.startsWith('~')) {
const name = q.slice(1)
return isValidNpmName(name) ? { intent: 'user', name } : { intent: null, name: '' }
}

if (q.startsWith('@')) {
if (q.includes('/')) return { intent: null, name: '' }
const name = q.slice(1)
return isValidNpmName(name) ? { intent: 'org', name } : { intent: null, name: '' }
}

return isValidNpmName(q) ? { intent: 'both', name: q } : { intent: null, name: '' }
}
186 changes: 135 additions & 51 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import type { NpmSearchResponse, NpmSearchResult } from '#shared/types'
import {
liteClient as algoliasearch,
type LiteClient,
type SearchQuery,
type SearchResponse,
} from 'algoliasearch/lite'

/**
* Singleton Algolia client, keyed by appId to handle config changes.
*/
let _searchClient: LiteClient | null = null
let _configuredAppId: string | null = null

Expand Down Expand Up @@ -36,10 +34,7 @@ interface AlgoliaRepo {
branch?: string
}

/**
* Shape of a hit from the Algolia `npm-search` index.
* Only includes fields we retrieve via `attributesToRetrieve`.
*/
/** Shape of a hit from the Algolia `npm-search` index. */
interface AlgoliaHit {
objectID: string
name: string
Expand All @@ -58,7 +53,6 @@ interface AlgoliaHit {
license: string | null
}

/** Fields we always request from Algolia to keep payload small */
const ATTRIBUTES_TO_RETRIEVE = [
'name',
'version',
Expand All @@ -76,6 +70,8 @@ const ATTRIBUTES_TO_RETRIEVE = [
'license',
]

const EXISTENCE_CHECK_ATTRS = ['name']

function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult {
return {
package: {
Expand Down Expand Up @@ -113,48 +109,53 @@ function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult {
}

export interface AlgoliaSearchOptions {
/** Number of results */
size?: number
/** Offset for pagination */
from?: number
/** Algolia filters expression (e.g. 'owner.name:username') */
filters?: string
}

/** Extra checks bundled into a single multi-search request. */
export interface AlgoliaMultiSearchChecks {
name?: string
checkOrg?: boolean
checkUser?: boolean
checkPackage?: string
}

export interface AlgoliaSearchWithSuggestionsResult {
search: NpmSearchResponse
orgExists: boolean
userExists: boolean
packageExists: boolean | null
}

/**
* Composable that provides Algolia search functions for npm packages.
*
* Must be called during component setup (or inside another composable)
* because it reads from `useRuntimeConfig()`. The returned functions
* are safe to call at any time (event handlers, async callbacks, etc.).
* Composable providing Algolia search for npm packages.
* Must be called during component setup.
*/
export function useAlgoliaSearch() {
const { algolia } = useRuntimeConfig().public
const client = getOrCreateClient(algolia.appId, algolia.apiKey)
const indexName = algolia.indexName

/**
* Search npm packages via Algolia.
* Returns results in the same NpmSearchResponse format as the npm registry API.
*/
async function search(
query: string,
options: AlgoliaSearchOptions = {},
): Promise<NpmSearchResponse> {
const { results } = await client.search([
{
indexName,
params: {
const { results } = await client.search({
requests: [
{
indexName,
query,
offset: options.from,
length: options.size,
filters: options.filters || '',
analyticsTags: ['npmx.dev'],
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
attributesToHighlight: [],
},
},
])
} satisfies SearchQuery,
],
})

const response = results[0] as SearchResponse<AlgoliaHit> | undefined
if (!response) {
Expand All @@ -169,10 +170,7 @@ export function useAlgoliaSearch() {
}
}

/**
* Fetch all packages for an Algolia owner (org or user).
* Uses `owner.name` filter for efficient server-side filtering.
*/
/** Fetch all packages for an owner using `owner.name` filter with pagination. */
async function searchByOwner(
ownerName: string,
options: { maxResults?: number } = {},
Expand All @@ -184,35 +182,32 @@ export function useAlgoliaSearch() {
let serverTotal = 0
const batchSize = 200

// Algolia supports up to 1000 results per query with offset/length pagination
while (offset < max) {
// Cap at both the configured max and the server's actual total (once known)
const remaining = serverTotal > 0 ? Math.min(max, serverTotal) - offset : max - offset
if (remaining <= 0) break
const length = Math.min(batchSize, remaining)

const { results } = await client.search([
{
indexName,
params: {
const { results } = await client.search({
requests: [
{
indexName,
query: '',
offset,
length,
filters: `owner.name:${ownerName}`,
analyticsTags: ['npmx.dev'],
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
attributesToHighlight: [],
},
},
])
} satisfies SearchQuery,
],
})

const response = results[0] as SearchResponse<AlgoliaHit> | undefined
if (!response) break

serverTotal = response.nbHits ?? 0
allHits.push(...response.hits)

// If we got fewer than requested, we've exhausted all results
if (response.hits.length < length || allHits.length >= serverTotal) {
break
}
Expand All @@ -223,23 +218,17 @@ export function useAlgoliaSearch() {
return {
isStale: false,
objects: allHits.map(hitToSearchResult),
// Use server total so callers can detect truncation (allHits.length < total)
total: serverTotal,
time: new Date().toISOString(),
}
}

/**
* Fetch metadata for specific packages by exact name.
* Uses Algolia's getObjects REST API to look up packages by objectID
* (which equals the package name in the npm-search index).
*/
/** Fetch metadata for specific packages by exact name using Algolia's getObjects API. */
async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> {
if (packageNames.length === 0) {
return { isStale: false, objects: [], total: 0, time: new Date().toISOString() }
}

// Algolia getObjects REST API: fetch up to 1000 objects by ID in a single request
const response = await $fetch<{ results: (AlgoliaHit | null)[] }>(
`https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`,
{
Expand Down Expand Up @@ -267,12 +256,107 @@ export function useAlgoliaSearch() {
}
}

/**
* Combined search + org/user/package existence checks in a single
* Algolia multi-search request.
*/
async function searchWithSuggestions(
query: string,
options: AlgoliaSearchOptions = {},
checks?: AlgoliaMultiSearchChecks,
): Promise<AlgoliaSearchWithSuggestionsResult> {
const requests: SearchQuery[] = [
{
indexName,
query,
offset: options.from,
length: options.size,
filters: options.filters || '',
analyticsTags: ['npmx.dev'],
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
attributesToHighlight: [],
},
]

const orgQueryIndex = checks?.checkOrg && checks.name ? requests.length : -1
if (checks?.checkOrg && checks.name) {
requests.push({
indexName,
query: `"@${checks.name}"`,
length: 1,
analyticsTags: ['npmx.dev'],
attributesToRetrieve: EXISTENCE_CHECK_ATTRS,
attributesToHighlight: [],
})
}

const userQueryIndex = checks?.checkUser && checks.name ? requests.length : -1
if (checks?.checkUser && checks.name) {
requests.push({
indexName,
query: '',
filters: `owner.name:${checks.name}`,
length: 1,
analyticsTags: ['npmx.dev'],
attributesToRetrieve: EXISTENCE_CHECK_ATTRS,
attributesToHighlight: [],
})
}

const packageQueryIndex = checks?.checkPackage ? requests.length : -1
if (checks?.checkPackage) {
requests.push({
indexName,
query: '',
filters: `objectID:${checks.checkPackage}`,
length: 1,
analyticsTags: ['npmx.dev'],
attributesToRetrieve: EXISTENCE_CHECK_ATTRS,
attributesToHighlight: [],
})
}

const { results } = await client.search({ requests })

const mainResponse = results[0] as SearchResponse<AlgoliaHit> | undefined
if (!mainResponse) {
throw new Error('Algolia returned an empty response')
}

const searchResult: NpmSearchResponse = {
isStale: false,
objects: mainResponse.hits.map(hitToSearchResult),
total: mainResponse.nbHits ?? 0,
time: new Date().toISOString(),
}

let orgExists = false
if (orgQueryIndex >= 0 && checks?.name) {
const orgResponse = results[orgQueryIndex] as SearchResponse<AlgoliaHit> | undefined
const scopePrefix = `@${checks.name.toLowerCase()}/`
orgExists =
orgResponse?.hits?.some(h => h.name?.toLowerCase().startsWith(scopePrefix)) ?? false
}

let userExists = false
if (userQueryIndex >= 0) {
const userResponse = results[userQueryIndex] as SearchResponse<AlgoliaHit> | undefined
userExists = (userResponse?.nbHits ?? 0) > 0
}

let packageExists: boolean | null = null
if (packageQueryIndex >= 0) {
const pkgResponse = results[packageQueryIndex] as SearchResponse<AlgoliaHit> | undefined
packageExists = (pkgResponse?.nbHits ?? 0) > 0
}

return { search: searchResult, orgExists, userExists, packageExists }
}

return {
/** Search packages by text query */
search,
/** Fetch all packages for an owner (org or user) */
searchWithSuggestions,
searchByOwner,
/** Fetch metadata for specific packages by exact name */
getPackagesByName,
}
}
Loading
Loading