Skip to content

Commit 1d9fb64

Browse files
committed
fix: extract minimal meta into an endpoint (wip 1)
1 parent 534f068 commit 1d9fb64

File tree

5 files changed

+190
-62
lines changed

5 files changed

+190
-62
lines changed

app/composables/npm/search-utils.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,25 @@
1-
import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types'
1+
import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types'
22

33
/**
4-
* Convert packument to search result format for display
4+
* Convert a lightweight package-meta API response to a search result for display.
55
*/
6-
export function packumentToSearchResult(
7-
pkg: MinimalPackument,
8-
weeklyDownloads?: number,
9-
): NpmSearchResult {
10-
let latestVersion = ''
11-
if (pkg['dist-tags']) {
12-
latestVersion = pkg['dist-tags'].latest || Object.values(pkg['dist-tags'])[0] || ''
13-
}
14-
const modified = pkg.time.modified || pkg.time[latestVersion] || ''
15-
6+
export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult {
167
return {
178
package: {
18-
name: pkg.name,
19-
version: latestVersion,
20-
description: pkg.description,
21-
keywords: pkg.keywords,
22-
date: pkg.time[latestVersion] || modified,
23-
links: {
24-
npm: `https://www.npmjs.com/package/${pkg.name}`,
25-
},
26-
maintainers: pkg.maintainers,
9+
name: meta.name,
10+
version: meta.version,
11+
description: meta.description,
12+
keywords: meta.keywords,
13+
license: meta.license,
14+
date: meta.date,
15+
links: meta.links,
16+
author: meta.author,
17+
maintainers: meta.maintainers,
2718
},
2819
score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } },
2920
searchScore: 0,
30-
downloads: weeklyDownloads !== undefined ? { weekly: weeklyDownloads } : undefined,
31-
updated: pkg.time[latestVersion] || modified,
21+
downloads: meta.weeklyDownloads !== undefined ? { weekly: meta.weeklyDownloads } : undefined,
22+
updated: meta.date,
3223
}
3324
}
3425

app/composables/npm/useNpmSearch.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Packument, NpmSearchResponse, NpmDownloadCount } from '#shared/types'
2-
import { emptySearchResponse, packumentToSearchResult } from './search-utils'
1+
import type { NpmSearchResponse, PackageMetaResponse } from '#shared/types'
2+
import { emptySearchResponse, metaToSearchResult } from './search-utils'
33

44
export interface NpmSearchOptions {
55
/** Number of results */
@@ -19,42 +19,41 @@ export interface NpmSearchOptions {
1919
* to call at any time (event handlers, async callbacks, etc.).
2020
*/
2121
export function useNpmSearch() {
22-
const { $npmRegistry, $npmApi } = useNuxtApp()
22+
const { $npmRegistry } = useNuxtApp()
2323

2424
/**
2525
* Search npm packages via the npm registry API.
2626
* Returns results in the same `NpmSearchResponse` format as `useAlgoliaSearch`.
2727
*
28-
* Single-character queries are handled specially: they do a direct packument
29-
* + download count lookup instead of a search, because the search API returns
30-
* poor results for single-char terms.
28+
* Single-character queries are handled specially: they fetch lightweight
29+
* metadata from a server-side proxy instead of a search, because the
30+
* search API returns poor results for single-char terms. The proxy
31+
* fetches the full packument + download counts server-side and returns
32+
* only the fields needed for package cards.
3133
*/
3234
async function search(
3335
query: string,
3436
options: NpmSearchOptions = {},
3537
signal?: AbortSignal,
3638
): Promise<NpmSearchResponse> {
37-
// Single-character: direct packument lookup
39+
// Single-character: fetch lightweight metadata via server proxy
3840
if (query.length === 1) {
39-
const encodedName = encodePackageName(query)
40-
const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([
41-
$npmRegistry<Packument>(`/${encodedName}`, { signal }),
42-
$npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, {
43-
signal,
44-
}),
45-
])
41+
try {
42+
const meta = await $fetch<PackageMetaResponse>(
43+
`/api/registry/package-meta/${encodePackageName(query)}`,
44+
{ signal },
45+
)
4646

47-
if (!pkg) {
48-
return emptySearchResponse()
49-
}
47+
const result = metaToSearchResult(meta)
5048

51-
const result = packumentToSearchResult(pkg, downloads?.downloads)
52-
53-
return {
54-
objects: [result],
55-
total: 1,
56-
isStale,
57-
time: new Date().toISOString(),
49+
return {
50+
objects: [result],
51+
total: 1,
52+
isStale: false,
53+
time: new Date().toISOString(),
54+
}
55+
} catch {
56+
return emptySearchResponse()
5857
}
5958
}
6059

app/composables/npm/useOrgPackages.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types'
2-
import { emptySearchResponse, packumentToSearchResult } from './search-utils'
1+
import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types'
2+
import { emptySearchResponse, metaToSearchResult } from './search-utils'
33
import { mapWithConcurrency } from '#shared/utils/async'
44

55
/**
@@ -15,7 +15,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
1515

1616
const asyncData = useLazyAsyncData(
1717
() => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
18-
async ({ $npmRegistry, ssrContext }, { signal }) => {
18+
async ({ ssrContext }, { signal }) => {
1919
const org = toValue(orgName)
2020
if (!org) {
2121
return emptySearchResponse()
@@ -62,28 +62,25 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
6262
}
6363
}
6464

65-
// npm fallback: fetch packuments individually
66-
const packuments = await mapWithConcurrency(
65+
// npm fallback: fetch lightweight metadata via server proxy
66+
const metaResults = await mapWithConcurrency(
6767
packageNames,
6868
async name => {
6969
try {
70-
const encoded = encodePackageName(name)
71-
const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
72-
signal,
73-
})
74-
return pkg
70+
return await $fetch<PackageMetaResponse>(
71+
`/api/registry/package-meta/${encodePackageName(name)}`,
72+
{ signal },
73+
)
7574
} catch {
7675
return null
7776
}
7877
},
7978
10,
8079
)
8180

82-
const validPackuments = packuments.filter(
83-
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
84-
)
85-
86-
const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg))
81+
const results: NpmSearchResult[] = metaResults
82+
.filter((meta): meta is PackageMetaResponse => meta !== null)
83+
.map(metaToSearchResult)
8784

8885
return {
8986
isStale: false,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { NpmDownloadCount } from '#shared/types'
2+
import {
3+
CACHE_MAX_AGE_FIVE_MINUTES,
4+
ERROR_NPM_FETCH_FAILED,
5+
NPM_API,
6+
} from '#shared/utils/constants'
7+
import { encodePackageName } from '#shared/utils/npm'
8+
9+
/**
10+
* Returns lightweight package metadata for search results.
11+
*
12+
* Fetches the full packument + weekly downloads server-side, extracts only
13+
* the fields needed for package cards, and returns a small JSON payload.
14+
* This avoids sending the full packument (which can be MBs) to the client.
15+
*
16+
* URL patterns:
17+
* - /api/registry/package-meta/packageName
18+
* - /api/registry/package-meta/@scope/packageName
19+
*/
20+
export default defineCachedEventHandler(
21+
async event => {
22+
const pkgParam = getRouterParam(event, 'pkg')
23+
if (!pkgParam) {
24+
throw createError({ statusCode: 404, message: 'Package name is required' })
25+
}
26+
27+
const packageName = decodeURIComponent(pkgParam)
28+
const encodedName = encodePackageName(packageName)
29+
30+
try {
31+
const [packument, downloads] = await Promise.all([
32+
fetchNpmPackage(packageName),
33+
$fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`).catch(
34+
() => null,
35+
),
36+
])
37+
38+
const latestVersion =
39+
packument['dist-tags']?.latest || Object.values(packument['dist-tags'] ?? {})[0] || ''
40+
const modified = packument.time?.modified || packument.time?.[latestVersion] || ''
41+
const date = packument.time?.[latestVersion] || modified
42+
43+
// Extract repository URL from the packument's repository field
44+
// TODO: @npm/types says repository is always an object, but some old
45+
// packages have a bare string in the registry JSON
46+
let repositoryUrl: string | undefined
47+
if (packument.repository) {
48+
const repo = packument.repository as { url?: string } | string
49+
const rawUrl = typeof repo === 'string' ? repo : repo.url
50+
if (rawUrl) {
51+
// Normalize git+https:// and git:// URLs to https://
52+
repositoryUrl = rawUrl
53+
.replace(/^git\+/, '')
54+
.replace(/^git:\/\//, 'https://')
55+
.replace(/\.git$/, '')
56+
}
57+
}
58+
59+
// Extract bugs URL
60+
// TODO: @npm/types types bugs as { email?: string; url?: string } on
61+
// packuments, but some old packages store it as a plain URL string
62+
let bugsUrl: string | undefined
63+
if (packument.bugs) {
64+
const bugs = packument.bugs as { url?: string } | string
65+
bugsUrl = typeof bugs === 'string' ? bugs : bugs.url
66+
}
67+
68+
// Normalize author field to NpmPerson shape
69+
// TODO: @npm/types types author as Contact (object), but some old
70+
// packages store it as a plain string (e.g. "Name <email>")
71+
let author: { name?: string; email?: string; url?: string } | undefined
72+
if (packument.author) {
73+
const a = packument.author as { name?: string; email?: string; url?: string } | string
74+
author = typeof a === 'string' ? { name: a } : { name: a.name, email: a.email, url: a.url }
75+
}
76+
77+
// Normalize license to a string
78+
// TODO: @npm/types types license as string, but some old packages use
79+
// the deprecated { type, url } object format
80+
const license = packument.license
81+
? typeof packument.license === 'string'
82+
? packument.license
83+
: (packument.license as { type: string }).type
84+
: undefined
85+
86+
return {
87+
name: packument.name,
88+
version: latestVersion,
89+
description: packument.description,
90+
keywords: packument.keywords,
91+
license,
92+
date,
93+
links: {
94+
npm: `https://www.npmjs.com/package/${packument.name}`,
95+
homepage: packument.homepage,
96+
repository: repositoryUrl,
97+
bugs: bugsUrl,
98+
},
99+
author,
100+
maintainers: packument.maintainers,
101+
weeklyDownloads: downloads?.downloads,
102+
}
103+
} catch (error: unknown) {
104+
handleApiError(error, {
105+
statusCode: 502,
106+
message: ERROR_NPM_FETCH_FAILED,
107+
})
108+
}
109+
},
110+
{
111+
maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
112+
swr: true,
113+
getKey: event => {
114+
const pkg = getRouterParam(event, 'pkg') ?? ''
115+
return `package-meta:v1:${pkg}`
116+
},
117+
},
118+
)

shared/types/npm-registry.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,26 @@ export interface MinimalPackument {
379379
'time': Record<string, string>
380380
'maintainers'?: NpmPerson[]
381381
}
382+
383+
/**
384+
* Lightweight package metadata returned by /api/registry/package-meta/.
385+
* Contains only the fields needed for search result cards, extracted
386+
* server-side from the full packument + downloads API.
387+
*/
388+
export interface PackageMetaResponse {
389+
name: string
390+
version: string
391+
description?: string
392+
keywords?: string[]
393+
license?: string
394+
date: string
395+
links: {
396+
npm: string
397+
homepage?: string
398+
repository?: string
399+
bugs?: string
400+
}
401+
author?: NpmPerson
402+
maintainers?: NpmPerson[]
403+
weeklyDownloads?: number
404+
}

0 commit comments

Comments
 (0)