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/npm/useNpmSearch.ts b/app/composables/npm/useNpmSearch.ts index c5ef3c687..b553275c4 100644 --- a/app/composables/npm/useNpmSearch.ts +++ b/app/composables/npm/useNpmSearch.ts @@ -5,7 +5,6 @@ import type { NpmDownloadCount, MinimalPackument, } from '#shared/types' -import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common' /** * Convert packument to search result format for display @@ -55,7 +54,6 @@ export function useNpmSearch( query: MaybeRefOrGetter, options: MaybeRefOrGetter = {}, ) { - const cachedFetch = useCachedFetch() // Client-side cache const cache = shallowRef<{ query: string @@ -70,7 +68,7 @@ export function useNpmSearch( const asyncData = useLazyAsyncData( () => `search:incremental:${toValue(query)}`, - async (_nuxtApp, { signal }) => { + async ({ $npmRegistry, $npmApi }, { signal }) => { const q = toValue(query) if (!q.trim()) { @@ -91,8 +89,8 @@ export function useNpmSearch( if (q.length === 1) { const encodedName = encodePackageName(q) const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ - cachedFetch(`${NPM_REGISTRY}/${encodedName}`, { signal }), - cachedFetch(`${NPM_API}/downloads/point/last-week/${encodedName}`, { + $npmRegistry(`/${encodedName}`, { signal }), + $npmApi(`/downloads/point/last-week/${encodedName}`, { signal, }), ]) @@ -122,8 +120,8 @@ export function useNpmSearch( } } - const { data: response, isStale } = await cachedFetch( - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, + const { data: response, isStale } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, { signal }, 60, ) @@ -146,6 +144,7 @@ export function useNpmSearch( // Fetch more results incrementally (only used in incremental mode) async function fetchMore(targetSize: number): Promise { + const { $npmRegistry } = useNuxtApp() const q = toValue(query).trim() if (!q) { cache.value = null @@ -179,8 +178,8 @@ export function useNpmSearch( params.set('size', String(size)) params.set('from', String(from)) - const { data: response } = await cachedFetch( - `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, + const { data: response } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, {}, 60, ) diff --git a/app/composables/npm/useOrgPackages.ts b/app/composables/npm/useOrgPackages.ts index 0dcb3f463..7b059a32f 100644 --- a/app/composables/npm/useOrgPackages.ts +++ b/app/composables/npm/useOrgPackages.ts @@ -1,6 +1,5 @@ import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types' import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch' -import { NPM_REGISTRY, NPM_API } from '~/utils/npm/common' import { mapWithConcurrency } from '#shared/utils/async' /** @@ -13,6 +12,7 @@ async function fetchBulkDownloads( packageNames: string[], options: Parameters[1] = {}, ): Promise> { + const { $npmApi } = useNuxtApp() const downloads = new Map() if (packageNames.length === 0) return downloads @@ -28,11 +28,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) } @@ -54,8 +54,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 } }), @@ -80,11 +80,9 @@ async function fetchBulkDownloads( * 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 @@ -93,8 +91,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) @@ -124,10 +122,9 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { 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 diff --git a/app/composables/npm/useOutdatedDependencies.ts b/app/composables/npm/useOutdatedDependencies.ts index dc372ecf5..956856a97 100644 --- a/app/composables/npm/useOutdatedDependencies.ts +++ b/app/composables/npm/useOutdatedDependencies.ts @@ -7,7 +7,6 @@ import { isNonSemverConstraint, constraintIncludesPrerelease, } from '~/utils/npm/outdated-dependencies' -import { NPM_REGISTRY } from '~/utils/npm/common' // Cache for packument fetches to avoid duplicate requests across components const packumentCache = new Map>() @@ -21,6 +20,8 @@ async function checkDependencyOutdated( packageName: string, constraint: string, ): Promise { + const { $npmRegistry } = useNuxtApp() + if (isNonSemverConstraint(constraint)) { return null } @@ -31,7 +32,7 @@ async function checkDependencyOutdated( if (cached) { packument = await cached } else { - const promise = cachedFetch(`${NPM_REGISTRY}/${encodePackageName(packageName)}`) + const promise = $npmRegistry(`/${encodePackageName(packageName)}`) .then(({ data }) => data) .catch(() => null) packumentCache.set(packageName, promise) diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index e0d900f50..ae99b0afc 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -1,5 +1,4 @@ import type { Packument, SlimPackument, SlimVersion, SlimPackumentVersion } from '#shared/types' -import { NPM_REGISTRY } from '~/utils/npm/common' import { extractInstallScriptsInfo } from '~/utils/install-scripts' /** Number of recent versions to include in initial payload */ @@ -98,13 +97,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) diff --git a/app/composables/npm/usePackageDownloads.ts b/app/composables/npm/usePackageDownloads.ts index 591a7d2d7..bfe3b98f6 100644 --- a/app/composables/npm/usePackageDownloads.ts +++ b/app/composables/npm/usePackageDownloads.ts @@ -1,18 +1,15 @@ import type { NpmDownloadCount } from '#shared/types' -import { NPM_API } from '~/utils/npm/common' export function usePackageDownloads( name: MaybeRefOrGetter, period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', ) { - const cachedFetch = useCachedFetch() - const asyncData = useLazyAsyncData( () => `downloads:${toValue(name)}:${toValue(period)}`, - async (_nuxtApp, { signal }) => { + async ({ $npmApi }, { 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 } 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/pages/search.vue b/app/pages/search.vue index 2faf3f3be..2e4aa9a25 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -316,8 +316,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/npm/api.ts b/app/utils/npm/api.ts index 063e4396c..cfefbcb8e 100644 --- a/app/utils/npm/api.ts +++ b/app/utils/npm/api.ts @@ -1,7 +1,6 @@ import type { PackageVersionInfo } from '#shared/types' import { getVersions } from 'fast-npm-meta' import { compare } from 'semver' -import { NPM_API } from './common' type NpmDownloadsRangeResponse = { start: string @@ -19,10 +18,11 @@ export async function fetchNpmDownloadsRange( start: string, end: string, ): Promise { + const { $npmApi } = useNuxtApp() const encodedName = encodePackageName(packageName) - return await $fetch( - `${NPM_API}/downloads/range/${start}:${end}/${encodedName}`, - ) + return ( + await $npmApi(`/downloads/range/${start}:${end}/${encodedName}`) + ).data } // ============================================================================ diff --git a/app/utils/npm/common.ts b/app/utils/npm/common.ts index 23b96fe1a..b75432ad2 100644 --- a/app/utils/npm/common.ts +++ b/app/utils/npm/common.ts @@ -1,6 +1,3 @@ -export const NPM_REGISTRY = 'https://registry.npmjs.org' -export const NPM_API = 'https://api.npmjs.org' - /** * Constructs a scope:team string in the format expected by npm. * npm operations require the format @scope:team (with @ prefix). 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 30caadcf5..373cac25d 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@vitest/coverage-v8": "4.0.18", "@vue/test-utils": "2.4.6", "axe-core": "4.11.1", + "defu": "6.1.4", "fast-check": "4.5.3", "h3": "1.15.5", "knip": "5.83.0", diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 1229a2d50..ceac47f59 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -12,6 +12,7 @@ export const NPMX_SITE = 'https://npmx.dev' export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/' export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments' 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 26bb2cf05..f82efc5af 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ }, browser: { enabled: true, + headless: true, provider: playwright(), instances: [{ browser: 'chromium', headless: true }], },