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

feat: unifying npm registry requests with caching (#641)

Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
Co-authored-by: Philippe Serhal <philippe.serhal@gmail.com>
Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Robin
James Garbutt
Philippe Serhal
Daniel Roe
and committed by
GitHub
a81dbe01 7af8c65a

+78 -59
+2
.gitignore
··· 40 40 # generated files 41 41 shared/types/lexicons 42 42 43 + **/__screenshots__/** 44 + 43 45 # output 44 46 .vercel
+9 -9
app/composables/npm/useNpmSearch.ts
··· 5 5 NpmDownloadCount, 6 6 MinimalPackument, 7 7 } from '#shared/types' 8 - import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common' 9 8 10 9 /** 11 10 * Convert packument to search result format for display ··· 55 54 query: MaybeRefOrGetter<string>, 56 55 options: MaybeRefOrGetter<NpmSearchOptions> = {}, 57 56 ) { 58 - const cachedFetch = useCachedFetch() 57 + const { $npmRegistry } = useNuxtApp() 58 + 59 59 // Client-side cache 60 60 const cache = shallowRef<{ 61 61 query: string ··· 70 70 71 71 const asyncData = useLazyAsyncData( 72 72 () => `search:incremental:${toValue(query)}`, 73 - async (_nuxtApp, { signal }) => { 73 + async ({ $npmRegistry, $npmApi }, { signal }) => { 74 74 const q = toValue(query) 75 75 76 76 if (!q.trim()) { ··· 91 91 if (q.length === 1) { 92 92 const encodedName = encodePackageName(q) 93 93 const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ 94 - cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { signal }), 95 - cachedFetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`, { 94 + $npmRegistry<Packument>(`/${encodedName}`, { signal }), 95 + $npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, { 96 96 signal, 97 97 }), 98 98 ]) ··· 122 122 } 123 123 } 124 124 125 - const { data: response, isStale } = await cachedFetch<NpmSearchResponse>( 126 - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 125 + const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>( 126 + `/-/v1/search?${params.toString()}`, 127 127 { signal }, 128 128 60, 129 129 ) ··· 179 179 params.set('size', String(size)) 180 180 params.set('from', String(from)) 181 181 182 - const { data: response } = await cachedFetch<NpmSearchResponse>( 183 - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, 182 + const { data: response } = await $npmRegistry<NpmSearchResponse>( 183 + `/-/v1/search?${params.toString()}`, 184 184 {}, 185 185 60, 186 186 )
+14 -16
app/composables/npm/useOrgPackages.ts
··· 1 + import type { NuxtApp } from '#app' 1 2 import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' 2 3 import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' 3 - import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common' 4 4 import { mapWithConcurrency } from '#shared/utils/async' 5 5 6 6 /** ··· 10 10 * Note: npm bulk downloads API does not support scoped packages. 11 11 */ 12 12 async function fetchBulkDownloads( 13 + $npmApi: NuxtApp['$npmApi'], 13 14 packageNames: string[], 14 15 options: Parameters<typeof $fetch>[1] = {}, 15 16 ): Promise<Map<string, number>> { ··· 28 29 bulkPromises.push( 29 30 (async () => { 30 31 try { 31 - const response = await $fetch<Record<string, { downloads: number } | null>>( 32 - `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`, 32 + const response = await $npmApi<Record<string, { downloads: number } | null>>( 33 + `/downloads/point/last-week/${chunk.join(',')}`, 33 34 options, 34 35 ) 35 - for (const [name, data] of Object.entries(response)) { 36 + for (const [name, data] of Object.entries(response.data)) { 36 37 if (data?.downloads !== undefined) { 37 38 downloads.set(name, data.downloads) 38 39 } ··· 54 55 const results = await Promise.allSettled( 55 56 batch.map(async name => { 56 57 const encoded = encodePackageName(name) 57 - const data = await $fetch<{ downloads: number }>( 58 - `${NPM_API}/downloads/point/last-week/${encoded}`, 58 + const { data } = await $npmApi<{ downloads: number }>( 59 + `/downloads/point/last-week/${encoded}`, 59 60 ) 60 61 return { name, downloads: data.downloads } 61 62 }), ··· 80 81 * Returns search-result-like objects for compatibility with PackageList 81 82 */ 82 83 export function useOrgPackages(orgName: MaybeRefOrGetter<string>) { 83 - const cachedFetch = useCachedFetch() 84 - 85 84 const asyncData = useLazyAsyncData( 86 85 () => `org-packages:${toValue(orgName)}`, 87 - async (_nuxtApp, { signal }) => { 86 + async ({ $npmRegistry, $npmApi }, { signal }) => { 88 87 const org = toValue(orgName) 89 88 if (!org) { 90 89 return emptySearchResponse ··· 93 92 // Get all package names in the org 94 93 let packageNames: string[] 95 94 try { 96 - const { data } = await cachedFetch<Record<string, string>>( 97 - `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, 95 + const { data } = await $npmRegistry<Record<string, string>>( 96 + `/-/org/${encodeURIComponent(org)}/package`, 98 97 { signal }, 99 98 ) 100 99 packageNames = Object.keys(data) ··· 124 123 async name => { 125 124 try { 126 125 const encoded = encodePackageName(name) 127 - const { data: pkg } = await cachedFetch<MinimalPackument>( 128 - `${NPM_REGISTRY}/${encoded}`, 129 - { signal }, 130 - ) 126 + const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, { 127 + signal, 128 + }) 131 129 return pkg 132 130 } catch { 133 131 return null ··· 141 139 ) 142 140 })(), 143 141 // Fetch downloads in bulk 144 - fetchBulkDownloads(packageNames, { signal }), 142 + fetchBulkDownloads($npmApi, packageNames, { signal }), 145 143 ]) 146 144 147 145 // Convert to search results with download data
+5 -3
app/composables/npm/useOutdatedDependencies.ts
··· 1 + import type { NuxtApp } from '#app' 1 2 import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver' 2 3 import type { Packument } from '#shared/types' 3 4 import { mapWithConcurrency } from '#shared/utils/async' ··· 7 8 isNonSemverConstraint, 8 9 constraintIncludesPrerelease, 9 10 } from '~/utils/npm/outdated-dependencies' 10 - import { NPM_REGISTRY } from '~/utils/npm/common' 11 11 12 12 // Cache for packument fetches to avoid duplicate requests across components 13 13 const packumentCache = new Map<string, Promise<Packument | null>>() ··· 18 18 */ 19 19 async function checkDependencyOutdated( 20 20 cachedFetch: CachedFetchFunction, 21 + $npmRegistry: NuxtApp['$npmRegistry'], 21 22 packageName: string, 22 23 constraint: string, 23 24 ): Promise<OutdatedDependencyInfo | null> { ··· 31 32 if (cached) { 32 33 packument = await cached 33 34 } else { 34 - const promise = cachedFetch<Packument>(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) 35 + const promise = $npmRegistry<Packument>(`/${encodePackageName(packageName)}`) 35 36 .then(({ data }) => data) 36 37 .catch(() => null) 37 38 packumentCache.set(packageName, promise) ··· 92 93 export function useOutdatedDependencies( 93 94 dependencies: MaybeRefOrGetter<Record<string, string> | undefined>, 94 95 ) { 96 + const { $npmRegistry } = useNuxtApp() 95 97 const cachedFetch = useCachedFetch() 96 98 const outdated = shallowRef<Record<string, OutdatedDependencyInfo>>({}) 97 99 ··· 105 107 const batchResults = await mapWithConcurrency( 106 108 entries, 107 109 async ([name, constraint]) => { 108 - const info = await checkDependencyOutdated(cachedFetch, name, constraint) 110 + const info = await checkDependencyOutdated(cachedFetch, $npmRegistry, name, constraint) 109 111 return [name, info] as const 110 112 }, 111 113 5,
+2 -5
app/composables/npm/usePackage.ts
··· 1 1 import type { Packument, SlimPackument, SlimVersion, SlimPackumentVersion } from '#shared/types' 2 - import { NPM_REGISTRY } from '~/utils/npm/common' 3 2 import { extractInstallScriptsInfo } from '~/utils/install-scripts' 4 3 5 4 /** Number of recent versions to include in initial payload */ ··· 98 97 name: MaybeRefOrGetter<string>, 99 98 requestedVersion?: MaybeRefOrGetter<string | null>, 100 99 ) { 101 - const cachedFetch = useCachedFetch() 102 - 103 100 const asyncData = useLazyAsyncData( 104 101 () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, 105 - async (_nuxtApp, { signal }) => { 102 + async ({ $npmRegistry }, { signal }) => { 106 103 const encodedName = encodePackageName(toValue(name)) 107 - const { data: r, isStale } = await cachedFetch<Packument>(`${NPM_REGISTRY}/${encodedName}`, { 104 + const { data: r, isStale } = await $npmRegistry<Packument>(`/${encodedName}`, { 108 105 signal, 109 106 }) 110 107 const reqVer = toValue(requestedVersion)
+3 -6
app/composables/npm/usePackageDownloads.ts
··· 1 1 import type { NpmDownloadCount } from '#shared/types' 2 - import { NPM_API } from '~/utils/npm/common' 3 2 4 3 export function usePackageDownloads( 5 4 name: MaybeRefOrGetter<string>, 6 5 period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', 7 6 ) { 8 - const cachedFetch = useCachedFetch() 9 - 10 7 const asyncData = useLazyAsyncData( 11 8 () => `downloads:${toValue(name)}:${toValue(period)}`, 12 - async (_nuxtApp, { signal }) => { 9 + async ({ $npmApi }, { signal }) => { 13 10 const encodedName = encodePackageName(toValue(name)) 14 - const { data, isStale } = await cachedFetch<NpmDownloadCount>( 15 - `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, 11 + const { data, isStale } = await $npmApi<NpmDownloadCount>( 12 + `/downloads/point/${toValue(period)}/${encodedName}`, 16 13 { signal }, 17 14 ) 18 15 return { ...data, isStale }
+11 -4
app/composables/useCachedFetch.ts
··· 1 1 import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' 2 + import { defu } from 'defu' 2 3 3 4 /** 4 5 * Get the cachedFetch function from the current request context. ··· 34 35 return async <T = unknown>( 35 36 url: string, 36 37 options: Parameters<typeof $fetch>[1] = {}, 37 - _ttl?: number, 38 + _ttl: number = FETCH_CACHE_DEFAULT_TTL, 38 39 ): Promise<CachedFetchResult<T>> => { 39 - const data = (await $fetch<T>(url, options)) as T 40 + const defaultFetchOptions: Parameters<typeof $fetch>[1] = { 41 + cache: 'force-cache', 42 + } 43 + const data = (await $fetch<T>(url, defu(options, defaultFetchOptions))) as T 40 44 return { data, isStale: false, cachedAt: null } 41 45 } 42 46 } ··· 55 59 return async <T = unknown>( 56 60 url: string, 57 61 options: Parameters<typeof $fetch>[1] = {}, 58 - _ttl?: number, 62 + _ttl: number = FETCH_CACHE_DEFAULT_TTL, 59 63 ): Promise<CachedFetchResult<T>> => { 60 - const data = (await $fetch<T>(url, options)) as T 64 + const defaultFetchOptions: Parameters<typeof $fetch>[1] = { 65 + cache: 'force-cache', 66 + } 67 + const data = (await $fetch<T>(url, defu(options, defaultFetchOptions))) as T 61 68 return { data, isStale: false, cachedAt: null } 62 69 } 63 70 }
-2
app/pages/search.vue
··· 316 316 /** Cache for existence checks to avoid repeated API calls */ 317 317 const existenceCache = ref<Record<string, boolean | 'pending'>>({}) 318 318 319 - const NPM_REGISTRY = 'https://registry.npmjs.org' 320 - 321 319 interface NpmSearchResponse { 322 320 total: number 323 321 objects: Array<{ package: { name: string } }>
+22
app/plugins/npm.ts
··· 1 + export default defineNuxtPlugin(() => { 2 + const cachedFetch = useCachedFetch() 3 + 4 + return { 5 + provide: { 6 + npmRegistry: <T>( 7 + url: Parameters<CachedFetchFunction>[0], 8 + options?: Parameters<CachedFetchFunction>[1], 9 + ttl?: Parameters<CachedFetchFunction>[2], 10 + ) => { 11 + return cachedFetch<T>(url, { baseURL: NPM_REGISTRY, ...options }, ttl) 12 + }, 13 + npmApi: <T>( 14 + url: Parameters<CachedFetchFunction>[0], 15 + options?: Parameters<CachedFetchFunction>[1], 16 + ttl?: Parameters<CachedFetchFunction>[2], 17 + ) => { 18 + return cachedFetch<T>(url, { baseURL: NPM_API, ...options }, ttl) 19 + }, 20 + }, 21 + } 22 + })
+4 -4
app/utils/npm/api.ts
··· 1 1 import type { PackageVersionInfo } from '#shared/types' 2 2 import { getVersions } from 'fast-npm-meta' 3 3 import { compare } from 'semver' 4 - import { NPM_API } from './common' 5 4 6 5 type NpmDownloadsRangeResponse = { 7 6 start: string ··· 19 18 start: string, 20 19 end: string, 21 20 ): Promise<NpmDownloadsRangeResponse> { 21 + const { $npmApi } = useNuxtApp() 22 22 const encodedName = encodePackageName(packageName) 23 - return await $fetch<NpmDownloadsRangeResponse>( 24 - `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, 25 - ) 23 + return ( 24 + await $npmApi<NpmDownloadsRangeResponse>(`/downloads/range/${start}:${end}/${encodedName}`) 25 + ).data 26 26 } 27 27 28 28 // ============================================================================
-3
app/utils/npm/common.ts
··· 1 - export const NPM_REGISTRY = 'https://registry.npmjs.org' 2 - export const NPM_API = 'https://api.npmjs.org' 3 - 4 1 /** 5 2 * Constructs a scope:team string in the format expected by npm. 6 3 * npm operations require the format @scope:team (with @ prefix).
+1 -2
app/utils/package-name.ts
··· 1 1 import validatePackageName from 'validate-npm-package-name' 2 + import { NPM_REGISTRY } from '#shared/utils/constants' 2 3 import { encodePackageName } from '#shared/utils/npm' 3 4 4 5 /** ··· 70 71 validationWarnings?: string[] 71 72 similarPackages?: SimilarPackage[] 72 73 } 73 - 74 - const NPM_REGISTRY = 'https://registry.npmjs.org' 75 74 76 75 export async function checkPackageExists( 77 76 name: string,
+1
package.json
··· 119 119 "@vitest/coverage-v8": "4.0.18", 120 120 "@vue/test-utils": "2.4.6", 121 121 "axe-core": "4.11.1", 122 + "defu": "6.1.4", 122 123 "eslint-plugin-regexp": "3.0.0", 123 124 "fast-check": "4.5.3", 124 125 "h3": "1.15.5",
+1
shared/utils/constants.ts
··· 12 12 export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/' 13 13 export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments' 14 14 export const NPM_REGISTRY = 'https://registry.npmjs.org' 15 + export const NPM_API = 'https://api.npmjs.org' 15 16 export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' 16 17 export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' 17 18 export const ERROR_PACKAGE_REQUIREMENTS_FAILED =
+3 -5
test/nuxt/composables/use-npm-registry.spec.ts
··· 27 27 28 28 // Check that fetch was called with the correct URL (first argument) 29 29 expect(fetchSpy).toHaveBeenCalled() 30 - expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-week/vue') 30 + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/downloads/point/last-week/vue') 31 31 expect(data.value?.downloads).toBe(1234567) 32 32 }) 33 33 ··· 40 40 41 41 // Check that fetch was called with the correct URL (first argument) 42 42 expect(fetchSpy).toHaveBeenCalled() 43 - expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-month/vue') 43 + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/downloads/point/last-month/vue') 44 44 }) 45 45 46 46 it('should encode scoped package names', async () => { ··· 54 54 55 55 // Check that fetch was called with the correct URL (first argument) 56 56 expect(fetchSpy).toHaveBeenCalled() 57 - expect(fetchSpy.mock.calls[0]?.[0]).toBe( 58 - 'https://api.npmjs.org/downloads/point/last-week/@vue%2Fcore', 59 - ) 57 + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/downloads/point/last-week/@vue%2Fcore') 60 58 }) 61 59 })