[READ-ONLY] a fast, modern browser for the npm registry
at main 207 lines 6.6 kB view raw
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}