forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}