|
| 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 | +) |
0 commit comments