diff --git a/.gitignore b/.gitignore index 45734458b..f674715dc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ test-results/ # generated files shared/types/lexicons + +**/__screenshots__/** diff --git a/app/composables/useCachedFetch.ts b/app/composables/useCachedFetch.ts index 23440fcbb..0dd9507a2 100644 --- a/app/composables/useCachedFetch.ts +++ b/app/composables/useCachedFetch.ts @@ -1,4 +1,5 @@ import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' +import defu from 'defu' /** * Get the cachedFetch function from the current request context. @@ -34,9 +35,12 @@ export function useCachedFetch(): CachedFetchFunction { return async ( url: string, options: Parameters[1] = {}, - _ttl?: number, + _ttl: number = FETCH_CACHE_DEFAULT_TTL, ): Promise> => { - const data = (await $fetch(url, options)) as T + const defaultFetchOptions: Parameters[1] = { + cache: 'force-cache', + } + const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T return { data, isStale: false, cachedAt: null } } } @@ -55,9 +59,12 @@ export function useCachedFetch(): CachedFetchFunction { return async ( url: string, options: Parameters[1] = {}, - _ttl?: number, + _ttl: number = FETCH_CACHE_DEFAULT_TTL, ): Promise> => { - const data = (await $fetch(url, options)) as T + const defaultFetchOptions: Parameters[1] = { + cache: 'force-cache', + } + const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T return { data, isStale: false, cachedAt: null } } } diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 62903d5c1..c20f8bf61 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -14,9 +14,6 @@ import { isExactVersion } from '~/utils/versions' import { extractInstallScriptsInfo } from '~/utils/install-scripts' import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' -const NPM_REGISTRY = 'https://registry.npmjs.org' -const NPM_API = 'https://api.npmjs.org' - // Cache for packument fetches to avoid duplicate requests across components const packumentCache = new Map>() @@ -33,6 +30,8 @@ async function fetchBulkDownloads( const downloads = new Map() if (packageNames.length === 0) return downloads + const { $npmApi } = useNuxtApp() + // Separate scoped and unscoped packages const scopedPackages = packageNames.filter(n => n.startsWith('@')) const unscopedPackages = packageNames.filter(n => !n.startsWith('@')) @@ -45,11 +44,11 @@ async function fetchBulkDownloads( bulkPromises.push( (async () => { try { - const response = await $fetch>( - `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`, + const response = await $npmApi>( + `/downloads/point/last-week/${chunk.join(',')}`, options, ) - for (const [name, data] of Object.entries(response)) { + for (const [name, data] of Object.entries(response.data)) { if (data?.downloads !== undefined) { downloads.set(name, data.downloads) } @@ -71,8 +70,8 @@ async function fetchBulkDownloads( const results = await Promise.allSettled( batch.map(async name => { const encoded = encodePackageName(name) - const data = await $fetch<{ downloads: number }>( - `${NPM_API}/downloads/point/last-week/${encoded}`, + const { data } = await $npmApi<{ downloads: number }>( + `/downloads/point/last-week/${encoded}`, ) return { name, downloads: data.downloads } }), @@ -184,13 +183,11 @@ export function usePackage( name: MaybeRefOrGetter, requestedVersion?: MaybeRefOrGetter, ) { - const cachedFetch = useCachedFetch() - const asyncData = useLazyAsyncData( () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, - async (_nuxtApp, { signal }) => { + async ({ $npmRegistry }, { signal }) => { const encodedName = encodePackageName(toValue(name)) - const { data: r, isStale } = await cachedFetch(`${NPM_REGISTRY}/${encodedName}`, { + const { data: r, isStale } = await $npmRegistry(`/${encodedName}`, { signal, }) const reqVer = toValue(requestedVersion) @@ -233,14 +230,14 @@ export function usePackageDownloads( name: MaybeRefOrGetter, period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', ) { - const cachedFetch = useCachedFetch() + const { $npmApi } = useNuxtApp() const asyncData = useLazyAsyncData( () => `downloads:${toValue(name)}:${toValue(period)}`, async (_nuxtApp, { signal }) => { const encodedName = encodePackageName(toValue(name)) - const { data, isStale } = await cachedFetch( - `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, + const { data, isStale } = await $npmApi( + `/downloads/point/${toValue(period)}/${encodedName}`, { signal }, ) return { ...data, isStale } @@ -273,9 +270,11 @@ export async function fetchNpmDownloadsRange( end: string, ): Promise { const encodedName = encodePackageName(packageName) - return await $fetch( - `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, - ) + const { $npmApi } = useNuxtApp() + + return ( + await $npmApi(`/downloads/range/${start}:${end}/${encodedName}`) + ).data } const emptySearchResponse = { @@ -294,7 +293,6 @@ export function useNpmSearch( query: MaybeRefOrGetter, options: MaybeRefOrGetter = {}, ) { - const cachedFetch = useCachedFetch() // Client-side cache const cache = shallowRef<{ query: string @@ -309,7 +307,7 @@ export function useNpmSearch( const asyncData = useLazyAsyncData( () => `search:incremental:${toValue(query)}`, - async (_nuxtApp, { signal }) => { + async ({ $npmRegistry }, { signal }) => { const q = toValue(query) if (!q.trim()) { return emptySearchResponse @@ -326,8 +324,8 @@ export function useNpmSearch( // Use requested size for initial fetch params.set('size', String(opts.size ?? 25)) - const { data: response, isStale } = await cachedFetch( - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, + const { data: response, isStale } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, { signal }, 60, ) @@ -368,6 +366,8 @@ export function useNpmSearch( isLoadingMore.value = true + const { $npmRegistry } = useNuxtApp() + try { // Fetch from where we left off - calculate size needed const from = currentCount @@ -378,7 +378,7 @@ export function useNpmSearch( params.set('size', String(size)) params.set('from', String(from)) - const { data: response } = await cachedFetch( + const { data: response } = await $npmRegistry( `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, {}, 60, @@ -509,11 +509,9 @@ function packumentToSearchResult(pkg: MinimalPackument, weeklyDownloads?: number * Returns search-result-like objects for compatibility with PackageList */ export function useOrgPackages(orgName: MaybeRefOrGetter) { - const cachedFetch = useCachedFetch() - const asyncData = useLazyAsyncData( () => `org-packages:${toValue(orgName)}`, - async (_nuxtApp, { signal }) => { + async ({ $npmRegistry }, { signal }) => { const org = toValue(orgName) if (!org) { return emptySearchResponse @@ -522,8 +520,8 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { // Get all package names in the org let packageNames: string[] try { - const { data } = await cachedFetch>( - `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, + const { data } = await $npmRegistry>( + `/-/org/${encodeURIComponent(org)}/package`, { signal }, ) packageNames = Object.keys(data) @@ -556,10 +554,9 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { batch.map(async name => { try { const encoded = encodePackageName(name) - const { data: pkg } = await cachedFetch( - `${NPM_REGISTRY}/${encoded}`, - { signal }, - ) + const { data: pkg } = await $npmRegistry(`/${encoded}`, { + signal, + }) return pkg } catch { return null @@ -702,6 +699,7 @@ async function checkDependencyOutdated( if (cached) { packument = await cached } else { + // todo: use $npmRegistry here const promise = cachedFetch(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) .then(({ data }) => data) .catch(() => null) diff --git a/app/pages/search.vue b/app/pages/search.vue index f6feebf6b..e8f084902 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -312,8 +312,6 @@ interface ValidatedSuggestion { /** Cache for existence checks to avoid repeated API calls */ const existenceCache = ref>({}) -const NPM_REGISTRY = 'https://registry.npmjs.org' - interface NpmSearchResponse { total: number objects: Array<{ package: { name: string } }> diff --git a/app/plugins/npm.ts b/app/plugins/npm.ts new file mode 100644 index 000000000..44bce487f --- /dev/null +++ b/app/plugins/npm.ts @@ -0,0 +1,22 @@ +export default defineNuxtPlugin(() => { + const cachedFetch = useCachedFetch() + + return { + provide: { + npmRegistry: ( + url: Parameters[0], + options?: Parameters[1], + ttl?: Parameters[2], + ) => { + return cachedFetch(url, { baseURL: NPM_REGISTRY, ...options }, ttl) + }, + npmApi: ( + url: Parameters[0], + options?: Parameters[1], + ttl?: Parameters[2], + ) => { + return cachedFetch(url, { baseURL: NPM_API, ...options }, ttl) + }, + }, + } +}) diff --git a/app/utils/package-name.ts b/app/utils/package-name.ts index 64aa7bbaa..c11ae8a79 100644 --- a/app/utils/package-name.ts +++ b/app/utils/package-name.ts @@ -1,4 +1,5 @@ import validatePackageName from 'validate-npm-package-name' +import { NPM_REGISTRY } from '#shared/utils/constants' /** * Normalize a package name for comparison by removing common variations. @@ -70,8 +71,6 @@ export interface CheckNameResult { similarPackages?: SimilarPackage[] } -const NPM_REGISTRY = 'https://registry.npmjs.org' - export async function checkPackageExists( name: string, options: Parameters[1] = {}, diff --git a/package.json b/package.json index b2f98c84f..ed9f65dba 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "@vitest/coverage-v8": "4.0.18", "@vue/test-utils": "2.4.6", "axe-core": "4.11.1", + "defu": "6.1.4", "knip": "5.82.1", "lint-staged": "16.2.7", "playwright-core": "1.58.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fa725f33..6a9ba3afd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: axe-core: specifier: 4.11.1 version: 4.11.1 + defu: + specifier: 6.1.4 + version: 6.1.4 knip: specifier: 5.82.1 version: 5.82.1(@types/node@24.10.9)(typescript@5.9.3) diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index d2292de6a..e0ccc8f8a 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -6,6 +6,7 @@ export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365 // API Strings export const NPM_REGISTRY = 'https://registry.npmjs.org' +export const NPM_API = 'https://api.npmjs.org' export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' export const ERROR_PACKAGE_REQUIREMENTS_FAILED = diff --git a/test/nuxt/composables/use-npm-registry.spec.ts b/test/nuxt/composables/use-npm-registry.spec.ts index c193f6025..4aa1adc56 100644 --- a/test/nuxt/composables/use-npm-registry.spec.ts +++ b/test/nuxt/composables/use-npm-registry.spec.ts @@ -27,7 +27,7 @@ describe('usePackageDownloads', () => { // Check that fetch was called with the correct URL (first argument) expect(fetchSpy).toHaveBeenCalled() - expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-week/vue') + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/downloads/point/last-week/vue') expect(data.value?.downloads).toBe(1234567) }) @@ -40,7 +40,7 @@ describe('usePackageDownloads', () => { // Check that fetch was called with the correct URL (first argument) expect(fetchSpy).toHaveBeenCalled() - expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-month/vue') + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/downloads/point/last-month/vue') }) it('should encode scoped package names', async () => { @@ -54,8 +54,6 @@ describe('usePackageDownloads', () => { // Check that fetch was called with the correct URL (first argument) expect(fetchSpy).toHaveBeenCalled() - expect(fetchSpy.mock.calls[0]?.[0]).toBe( - 'https://api.npmjs.org/downloads/point/last-week/@vue%2Fcore', - ) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/downloads/point/last-week/@vue%2Fcore') }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 8cd979425..e689ccad5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ }, browser: { enabled: true, + headless: true, provider: playwright(), instances: [{ browser: 'chromium' }], },