[READ-ONLY] a fast, modern browser for the npm registry

fix: surfacing 429s as not found to users (#1200)

authored by

John Reilly and committed by
GitHub
13ef534b 5f9ef8f6

+78 -40
+64 -37
app/composables/npm/useNpmSearch.ts
··· 65 65 66 66 const isLoadingMore = shallowRef(false) 67 67 68 + // Track rate limit errors separately for better UX 69 + // Using ref instead of shallowRef to ensure reactivity triggers properly 70 + const isRateLimited = ref(false) 71 + 68 72 // Standard (non-incremental) search implementation 69 73 let lastSearch: NpmSearchResponse | undefined = undefined 70 74 ··· 74 78 const q = toValue(query) 75 79 76 80 if (!q.trim()) { 81 + isRateLimited.value = false 77 82 return emptySearchResponse 78 83 } 79 84 80 85 const opts = toValue(options) 81 86 82 87 // This only runs for initial load or query changes 83 - // Reset cache for new query 88 + // Reset cache for new query (but don't reset rate limit yet - only on success) 84 89 cache.value = null 85 90 86 91 const params = new URLSearchParams() ··· 88 93 // Use requested size for initial fetch 89 94 params.set('size', String(opts.size ?? 25)) 90 95 91 - if (q.length === 1) { 92 - const encodedName = encodePackageName(q) 93 - const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ 94 - $npmRegistry<Packument>(`/${encodedName}`, { signal }), 95 - $npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, { 96 - signal, 97 - }), 98 - ]) 96 + try { 97 + if (q.length === 1) { 98 + const encodedName = encodePackageName(q) 99 + const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ 100 + $npmRegistry<Packument>(`/${encodedName}`, { signal }), 101 + $npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, { 102 + signal, 103 + }), 104 + ]) 99 105 100 - if (!pkg) { 101 - return emptySearchResponse 106 + if (!pkg) { 107 + return emptySearchResponse 108 + } 109 + 110 + const result = packumentToSearchResult(pkg, downloads?.downloads) 111 + 112 + // If query changed/outdated, return empty search response 113 + if (q !== toValue(query)) { 114 + return emptySearchResponse 115 + } 116 + 117 + cache.value = { 118 + query: q, 119 + objects: [result], 120 + total: 1, 121 + } 122 + 123 + // Success - clear rate limit flag 124 + isRateLimited.value = false 125 + 126 + return { 127 + objects: [result], 128 + total: 1, 129 + isStale, 130 + time: new Date().toISOString(), 131 + } 102 132 } 103 133 104 - const result = packumentToSearchResult(pkg, downloads?.downloads) 134 + const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>( 135 + `/-/v1/search?${params.toString()}`, 136 + { signal }, 137 + 60, 138 + ) 105 139 106 140 // If query changed/outdated, return empty search response 107 141 if (q !== toValue(query)) { ··· 110 144 111 145 cache.value = { 112 146 query: q, 113 - objects: [result], 114 - total: 1, 147 + objects: response.objects, 148 + total: response.total, 115 149 } 116 150 117 - return { 118 - objects: [result], 119 - total: 1, 120 - isStale, 121 - time: new Date().toISOString(), 122 - } 123 - } 151 + // Success - clear rate limit flag 152 + isRateLimited.value = false 124 153 125 - const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>( 126 - `/-/v1/search?${params.toString()}`, 127 - { signal }, 128 - 60, 129 - ) 130 - 131 - // If query changed/outdated, return empty search response 132 - if (q !== toValue(query)) { 133 - return emptySearchResponse 134 - } 154 + return { ...response, isStale } 155 + } catch (error: unknown) { 156 + // Detect rate limit errors. npm's 429 response doesn't include CORS headers, 157 + // so the browser reports "Failed to fetch" instead of the actual status code. 158 + const errorMessage = (error as { message?: string })?.message || String(error) 159 + const isRateLimitError = 160 + errorMessage.includes('Failed to fetch') || errorMessage.includes('429') 135 161 136 - cache.value = { 137 - query: q, 138 - objects: response.objects, 139 - total: response.total, 162 + if (isRateLimitError) { 163 + isRateLimited.value = true 164 + return emptySearchResponse 165 + } 166 + throw error 140 167 } 141 - 142 - return { ...response, isStale } 143 168 }, 144 169 { default: () => lastSearch || emptySearchResponse }, 145 170 ) ··· 260 285 hasMore, 261 286 /** Manually fetch more results up to target size (incremental mode only) */ 262 287 fetchMore, 288 + /** Whether the search was rate limited by npm (429 error) */ 289 + isRateLimited: readonly(isRateLimited), 263 290 } 264 291 }
+11 -3
app/pages/search.vue
··· 72 72 isLoadingMore, 73 73 hasMore, 74 74 fetchMore, 75 + isRateLimited, 75 76 } = useNpmSearch(query, () => ({ 76 77 size: requestedSize.value, 77 78 incremental: true, ··· 706 707 </button> 707 708 </div> 708 709 710 + <!-- Rate limited by npm - check FIRST before showing any results --> 711 + <div v-if="isRateLimited" role="status" class="py-12"> 712 + <p class="text-fg-muted font-mono mb-6 text-center"> 713 + {{ $t('search.rate_limited') }} 714 + </p> 715 + </div> 716 + 709 717 <!-- Enhanced toolbar --> 710 - <div v-if="visibleResults.total > 0" class="mb-6"> 718 + <div v-else-if="visibleResults.total > 0" class="mb-6"> 711 719 <PackageListToolbar 712 720 :filters="filters" 713 721 v-model:sort-option="sortOption" ··· 805 813 </div> 806 814 807 815 <PackageList 808 - v-if="displayResults.length > 0" 816 + v-if="displayResults.length > 0 && !isRateLimited" 809 817 :results="displayResults" 810 818 :search-query="query" 811 819 :filters="filters" ··· 828 836 829 837 <!-- Pagination controls --> 830 838 <PaginationControls 831 - v-if="displayResults.length > 0" 839 + v-if="displayResults.length > 0 && !isRateLimited" 832 840 v-model:mode="paginationMode" 833 841 v-model:page-size="preferredPageSize" 834 842 v-model:current-page="currentPage"
+1
i18n/locales/en.json
··· 25 25 "found_packages": "No packages found | Found 1 package | Found {count} packages", 26 26 "updating": "(updating...)", 27 27 "no_results": "No packages found for \"{query}\"", 28 + "rate_limited": "Hit npm rate limit, try again in a moment", 28 29 "title": "search", 29 30 "title_search": "search: {search}", 30 31 "title_packages": "search packages",
+1
lunaria/files/en-GB.json
··· 25 25 "found_packages": "No packages found | Found 1 package | Found {count} packages", 26 26 "updating": "(updating...)", 27 27 "no_results": "No packages found for \"{query}\"", 28 + "rate_limited": "Hit npm rate limit, try again in a moment", 28 29 "title": "search", 29 30 "title_search": "search: {search}", 30 31 "title_packages": "search packages",
+1
lunaria/files/en-US.json
··· 25 25 "found_packages": "No packages found | Found 1 package | Found {count} packages", 26 26 "updating": "(updating...)", 27 27 "no_results": "No packages found for \"{query}\"", 28 + "rate_limited": "Hit npm rate limit, try again in a moment", 28 29 "title": "search", 29 30 "title_search": "search: {search}", 30 31 "title_packages": "search packages",