[READ-ONLY] a fast, modern browser for the npm registry
at main 194 lines 5.2 kB view raw
1import validatePackageName from 'validate-npm-package-name' 2import { NPM_REGISTRY } from '#shared/utils/constants' 3import { encodePackageName } from '#shared/utils/npm' 4 5/** 6 * Normalize a package name for comparison by removing common variations. 7 * This aims to mirror npm's typosquatting detection algorithm. 8 */ 9export function normalizePackageName(name: string): string { 10 // Remove scope if present 11 const unscoped = name.startsWith('@') ? name.split('/')[1] || name : name 12 13 // Normalize: lowercase, remove punctuation (.-_), remove 'js' and 'node' suffixes/prefixes 14 return ( 15 unscoped 16 .toLowerCase() 17 // Remove all punctuation 18 .replace(/[.\-_]/g, '') 19 // Remove common suffixes/prefixes 20 .replace(/^(node|js)|(-?js|-?node)$/g, '') 21 ) 22} 23 24/** 25 * Calculate similarity between two strings using Levenshtein distance. 26 */ 27export function levenshteinDistance(a: string, b: string): number { 28 const matrix: number[][] = [] 29 30 for (let i = 0; i <= b.length; i++) { 31 matrix[i] = [i] 32 } 33 for (let j = 0; j <= a.length; j++) { 34 matrix[0]![j] = j 35 } 36 37 for (let i = 1; i <= b.length; i++) { 38 for (let j = 1; j <= a.length; j++) { 39 if (b.charAt(i - 1) === a.charAt(j - 1)) { 40 matrix[i]![j] = matrix[i - 1]![j - 1]! 41 } else { 42 matrix[i]![j] = Math.min( 43 matrix[i - 1]![j - 1]! + 1, // substitution 44 matrix[i]![j - 1]! + 1, // insertion 45 matrix[i - 1]![j]! + 1, // deletion 46 ) 47 } 48 } 49 } 50 51 return matrix[b.length]![a.length]! 52} 53 54export function isValidNewPackageName(name: string): boolean { 55 if (!name) return false 56 const result = validatePackageName(name) 57 return result.validForNewPackages === true 58} 59 60export interface SimilarPackage { 61 name: string 62 description?: string 63 similarity: 'exact-match' | 'very-similar' | 'similar' 64} 65 66export interface CheckNameResult { 67 name: string 68 available: boolean 69 valid: boolean 70 validationErrors?: string[] 71 validationWarnings?: string[] 72 similarPackages?: SimilarPackage[] 73} 74 75export async function checkPackageExists( 76 name: string, 77 options: Parameters<typeof $fetch>[1] = {}, 78): Promise<boolean> { 79 try { 80 await $fetch(`${NPM_REGISTRY}/${encodePackageName(name)}`, { 81 ...options, 82 method: 'HEAD', 83 }) 84 return true 85 } catch { 86 return false 87 } 88} 89 90export async function findSimilarPackages( 91 name: string, 92 options: Parameters<typeof $fetch>[1] = {}, 93): Promise<SimilarPackage[]> { 94 const normalized = normalizePackageName(name) 95 const similar: SimilarPackage[] = [] 96 97 try { 98 const searchResponse = await $fetch<{ 99 objects: Array<{ 100 package: { 101 name: string 102 description?: string 103 } 104 }> 105 }>(`${NPM_REGISTRY}/-/v1/search?text=${encodeURIComponent(name)}&size=20`, options) 106 107 for (const obj of searchResponse.objects) { 108 const pkgName = obj.package.name 109 const pkgNormalized = normalizePackageName(pkgName) 110 111 // Skip if it's the exact same name 112 if (pkgName === name) { 113 similar.push({ 114 name: pkgName, 115 description: obj.package.description, 116 similarity: 'exact-match', 117 }) 118 continue 119 } 120 121 // Check if normalized names match (high similarity) 122 if (normalized === pkgNormalized) { 123 similar.push({ 124 name: pkgName, 125 description: obj.package.description, 126 similarity: 'very-similar', 127 }) 128 continue 129 } 130 131 // Check Levenshtein distance for similar names 132 const distance = levenshteinDistance(normalized, pkgNormalized) 133 const maxLen = Math.max(normalized.length, pkgNormalized.length) 134 135 // Guard against division by zero 136 if (maxLen === 0) continue 137 138 const similarityScore = 1 - distance / maxLen 139 140 if (similarityScore >= 0.8 || distance <= 2) { 141 similar.push({ 142 name: pkgName, 143 description: obj.package.description, 144 similarity: 'similar', 145 }) 146 } 147 } 148 149 // Sort by similarity (exact > very-similar > similar) 150 const order = { 'exact-match': 0, 'very-similar': 1, 'similar': 2 } 151 similar.sort((a, b) => order[a.similarity] - order[b.similarity]) 152 153 return similar.slice(0, 10) // Limit to 10 results 154 } catch { 155 return [] 156 } 157} 158 159export async function checkPackageName( 160 name: string, 161 options: Parameters<typeof $fetch>[1] = {}, 162): Promise<CheckNameResult> { 163 const validation = validatePackageName(name) 164 const valid = validation.validForNewPackages === true 165 166 const result: CheckNameResult = { 167 name, 168 available: false, 169 valid, 170 } 171 172 if (validation.errors?.length) { 173 result.validationErrors = validation.errors 174 } 175 if (validation.warnings?.length) { 176 result.validationWarnings = validation.warnings 177 } 178 179 // If name is not valid for new packages, return early 180 if (!valid) { 181 return result 182 } 183 184 // Check if package exists and find similar packages in parallel 185 const [exists, similarPackages] = await Promise.all([ 186 checkPackageExists(name, options), 187 findSimilarPackages(name, options), 188 ]) 189 190 result.available = !exists 191 result.similarPackages = similarPackages 192 193 return result 194}