forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { Packument, PackumentVersion, DependencyDepth } from '#shared/types'
2import { mapWithConcurrency } from '#shared/utils/async'
3import { encodePackageName } from '#shared/utils/npm'
4import { maxSatisfying } from 'semver'
5
6/** Concurrency limit for fetching packuments during dependency resolution */
7const PACKUMENT_FETCH_CONCURRENCY = 20
8
9/**
10 * Target platform for dependency resolution.
11 * We resolve for linux-x64 with glibc as a representative platform.
12 */
13export const TARGET_PLATFORM = {
14 os: 'linux',
15 cpu: 'x64',
16 libc: 'glibc',
17}
18
19/**
20 * Fetch packument with caching (returns null on error for tree traversal)
21 */
22export const fetchPackument = defineCachedFunction(
23 async (name: string): Promise<Packument | null> => {
24 try {
25 return await $fetch<Packument>(`https://registry.npmjs.org/${encodePackageName(name)}`)
26 } catch (error) {
27 if (import.meta.dev) {
28 // oxlint-disable-next-line no-console -- log npm registry failures for debugging
29 console.warn(`[dep-resolver] Failed to fetch packument for ${name}:`, error)
30 }
31 return null
32 }
33 },
34 {
35 maxAge: 60 * 60,
36 swr: true,
37 name: 'packument',
38 getKey: (name: string) => name,
39 },
40)
41
42/**
43 * Check if a package version matches the target platform.
44 * Returns false if the package explicitly excludes our target platform.
45 */
46export function matchesPlatform(version: PackumentVersion): boolean {
47 if (version.os && Array.isArray(version.os) && version.os.length > 0) {
48 const osMatch = version.os.some(os => {
49 if (os.startsWith('!')) return os.slice(1) !== TARGET_PLATFORM.os
50 return os === TARGET_PLATFORM.os
51 })
52 if (!osMatch) return false
53 }
54
55 if (version.cpu && Array.isArray(version.cpu) && version.cpu.length > 0) {
56 const cpuMatch = version.cpu.some(cpu => {
57 if (cpu.startsWith('!')) return cpu.slice(1) !== TARGET_PLATFORM.cpu
58 return cpu === TARGET_PLATFORM.cpu
59 })
60 if (!cpuMatch) return false
61 }
62
63 const libc = (version as { libc?: string[] }).libc
64 if (libc && Array.isArray(libc) && libc.length > 0) {
65 const libcMatch = libc.some(l => {
66 if (l.startsWith('!')) return l.slice(1) !== TARGET_PLATFORM.libc
67 return l === TARGET_PLATFORM.libc
68 })
69 if (!libcMatch) return false
70 }
71
72 return true
73}
74
75/**
76 * Resolve a semver range to a specific version from available versions.
77 */
78export function resolveVersion(range: string, versions: string[]): string | null {
79 if (versions.includes(range)) return range
80
81 // Handle npm: protocol (aliases)
82 if (range.startsWith('npm:')) {
83 const atIndex = range.lastIndexOf('@')
84 if (atIndex > 4) {
85 return resolveVersion(range.slice(atIndex + 1), versions)
86 }
87 return null
88 }
89
90 // Handle URLs, git refs, etc. - we can't resolve these
91 if (
92 range.startsWith('http://') ||
93 range.startsWith('https://') ||
94 range.startsWith('git://') ||
95 range.startsWith('git+') ||
96 range.startsWith('file:') ||
97 range.includes('/')
98 ) {
99 return null
100 }
101
102 return maxSatisfying(versions, range)
103}
104
105/** Resolved package info */
106export interface ResolvedPackage {
107 name: string
108 version: string
109 size: number
110 optional: boolean
111 /** Depth level (only when trackDepth is enabled) */
112 depth?: DependencyDepth
113 /** Dependency path from root (only when trackDepth is enabled) */
114 path?: string[]
115 /** Deprecation message if the version is deprecated */
116 deprecated?: string
117}
118
119/**
120 * Resolve the entire dependency tree for a package.
121 * Uses level-by-level BFS to ensure correct depth assignment when trackDepth is enabled.
122 */
123export async function resolveDependencyTree(
124 rootName: string,
125 rootVersion: string,
126 options: { trackDepth?: boolean } = {},
127): Promise<Map<string, ResolvedPackage>> {
128 const resolved = new Map<string, ResolvedPackage>()
129 const seen = new Set<string>()
130
131 // Process level by level for correct depth tracking
132 // Each entry includes the path of package names leading to this dependency
133 let currentLevel = new Map<string, { range: string; optional: boolean; path: string[] }>([
134 [rootName, { range: rootVersion, optional: false, path: [] }],
135 ])
136 let level = 0
137
138 while (currentLevel.size > 0) {
139 const nextLevel = new Map<string, { range: string; optional: boolean; path: string[] }>()
140
141 // Mark all packages in current level as seen before processing
142 for (const name of currentLevel.keys()) {
143 seen.add(name)
144 }
145
146 // Process current level with concurrency limit
147 const entries = [...currentLevel.entries()]
148 await mapWithConcurrency(
149 entries,
150 async ([name, { range, optional, path }]) => {
151 const packument = await fetchPackument(name)
152 if (!packument) return
153
154 const versions = Object.keys(packument.versions)
155 const version = resolveVersion(range, versions)
156 if (!version) return
157
158 const versionData = packument.versions[version]
159 if (!versionData) return
160
161 if (!matchesPlatform(versionData)) return
162
163 const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0
164 const key = `${name}@${version}`
165
166 // Build path for this package (path to parent + this package with version)
167 const currentPath = [...path, `${name}@${version}`]
168
169 if (!resolved.has(key)) {
170 const pkg: ResolvedPackage = { name, version, size, optional }
171 if (options.trackDepth) {
172 pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive'
173 pkg.path = currentPath
174 }
175 if (versionData.deprecated) {
176 pkg.deprecated = versionData.deprecated
177 }
178 resolved.set(key, pkg)
179 }
180
181 // Collect dependencies for next level
182 if (versionData.dependencies) {
183 for (const [depName, depRange] of Object.entries(versionData.dependencies)) {
184 if (!seen.has(depName) && !nextLevel.has(depName)) {
185 nextLevel.set(depName, { range: depRange, optional: false, path: currentPath })
186 }
187 }
188 }
189
190 // Collect optional dependencies
191 if (versionData.optionalDependencies) {
192 for (const [depName, depRange] of Object.entries(versionData.optionalDependencies)) {
193 if (!seen.has(depName) && !nextLevel.has(depName)) {
194 nextLevel.set(depName, { range: depRange, optional: true, path: currentPath })
195 }
196 }
197 }
198 },
199 PACKUMENT_FETCH_CONCURRENCY,
200 )
201
202 currentLevel = nextLevel
203 level++
204 }
205
206 return resolved
207}