forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { Packument, NpmSearchResponse } from '#shared/types'
2import { encodePackageName, fetchLatestVersion } from '#shared/utils/npm'
3import { maxSatisfying, prerelease } from 'semver'
4import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants'
5
6const NPM_REGISTRY = 'https://registry.npmjs.org'
7
8export const fetchNpmPackage = defineCachedFunction(
9 async (name: string): Promise<Packument> => {
10 const encodedName = encodePackageName(name)
11 return await $fetch<Packument>(`${NPM_REGISTRY}/${encodedName}`)
12 },
13 {
14 maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
15 swr: true,
16 name: 'npm-package',
17 getKey: (name: string) => name,
18 },
19)
20
21/**
22 * Get the latest version of a package using fast-npm-meta API.
23 * Falls back to full packument if fast-npm-meta fails.
24 *
25 * @param name Package name
26 * @returns Latest version string or null if not found
27 */
28export async function fetchLatestVersionWithFallback(name: string): Promise<string | null> {
29 const version = await fetchLatestVersion(name)
30 if (version) return version
31
32 // Fallback to full packument (also cached)
33 try {
34 const packument = await fetchNpmPackage(name)
35 return packument['dist-tags']?.latest ?? null
36 } catch {
37 return null
38 }
39}
40
41/**
42 * Check if a version constraint explicitly includes a prerelease tag.
43 * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases
44 */
45function constraintIncludesPrerelease(constraint: string): boolean {
46 // Look for prerelease identifiers in the constraint
47 return (
48 /-(?:alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) ||
49 /-\d/.test(constraint)
50 ) // e.g., -0, -1
51}
52
53/**
54 * Resolve a semver version constraint to the best matching version.
55 * Returns the highest version that satisfies the constraint, or null if none match.
56 *
57 * By default, excludes prerelease versions unless the constraint explicitly
58 * includes a prerelease tag (e.g., "^1.0.0-beta").
59 */
60export async function resolveVersionConstraint(
61 packageName: string,
62 constraint: string,
63): Promise<string | null> {
64 try {
65 const packument = await fetchNpmPackage(packageName)
66 let versions = Object.keys(packument.versions)
67
68 // Filter out prerelease versions unless constraint explicitly includes one
69 if (!constraintIncludesPrerelease(constraint)) {
70 versions = versions.filter(v => !prerelease(v))
71 }
72
73 return maxSatisfying(versions, constraint)
74 } catch {
75 return null
76 }
77}
78
79/**
80 * Resolve multiple dependency constraints to their best matching versions.
81 * Returns a map of package name to resolved version.
82 */
83export async function resolveDependencyVersions(
84 dependencies: Record<string, string>,
85): Promise<Record<string, string>> {
86 const entries = Object.entries(dependencies)
87 const results = await Promise.all(
88 entries.map(async ([name, constraint]) => {
89 const resolved = await resolveVersionConstraint(name, constraint)
90 return [name, resolved] as const
91 }),
92 )
93
94 const resolved: Record<string, string> = {}
95 for (const [name, version] of results) {
96 if (version) {
97 resolved[name] = version
98 }
99 }
100 return resolved
101}
102
103/**
104 * Find a user's email address from its username
105 * by exploring metadata in its public packages
106 */
107export const fetchUserEmail = defineCachedFunction(
108 async (username: string): Promise<string | null> => {
109 const handle = username.trim()
110 if (!handle) return null
111
112 // Fetch packages with the user's handle as a maintainer
113 const params = new URLSearchParams({
114 text: `maintainer:${handle}`,
115 size: '20',
116 })
117 const response = await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search?${params}`)
118 const lowerHandle = handle.toLowerCase()
119
120 // Search for the user's email in packages metadata
121 for (const result of response.objects) {
122 const maintainers = result.package.maintainers ?? []
123 const match = maintainers.find(
124 person =>
125 person.username?.toLowerCase() === lowerHandle ||
126 person.name?.toLowerCase() === lowerHandle,
127 )
128 if (match?.email) {
129 return match.email
130 }
131 }
132
133 return null
134 },
135 {
136 maxAge: CACHE_MAX_AGE_ONE_DAY,
137 swr: true,
138 name: 'npm-user-email',
139 getKey: (username: string) => `npm-user-email:${username.trim().toLowerCase()}`,
140 },
141)