From 6f4c2e781af4683821f798b32fdfc8ba52d90b8c Mon Sep 17 00:00:00 2001 From: OrbisK Date: Sun, 1 Feb 2026 13:49:10 +0100 Subject: [PATCH 1/6] wip: proof of concept unifying all registry requests --- app/composables/useNpmRegistry.ts | 62 +++++++++++++++---------------- app/pages/search.vue | 2 - app/plugins/npm.ts | 22 +++++++++++ app/utils/package-name.ts | 2 - shared/utils/constants.ts | 1 + 5 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 app/plugins/npm.ts diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index b1ae7390f..80d3234af 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>() @@ -30,6 +27,8 @@ async function fetchBulkDownloads(packageNames: string[]): Promise() 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('@')) @@ -42,10 +41,10 @@ async function fetchBulkDownloads(packageNames: string[]): Promise { try { - const response = await $fetch>( - `${NPM_API}/downloads/point/last-week/${chunk.join(',')}`, + const response = await $npmApi>( + `/downloads/point/last-week/${chunk.join(',')}`, ) - 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) } @@ -67,8 +66,8 @@ async function fetchBulkDownloads(packageNames: string[]): Promise { 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 } }), @@ -180,13 +179,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) @@ -229,14 +226,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 } @@ -269,9 +266,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 = { @@ -290,7 +289,6 @@ export function useNpmSearch( query: MaybeRefOrGetter, options: MaybeRefOrGetter = {}, ) { - const cachedFetch = useCachedFetch() // Client-side cache const cache = shallowRef<{ query: string @@ -305,7 +303,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 @@ -322,8 +320,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, ) @@ -364,6 +362,8 @@ export function useNpmSearch( isLoadingMore.value = true + const { $npmRegistry } = useNuxtApp() + try { // Fetch from where we left off - calculate size needed const from = currentCount @@ -374,7 +374,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, @@ -505,11 +505,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 @@ -518,8 +516,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) @@ -552,10 +550,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 @@ -698,6 +695,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 c249381fc..6ccca7daa 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 30162099b..5f4166aef 100644 --- a/app/utils/package-name.ts +++ b/app/utils/package-name.ts @@ -70,8 +70,6 @@ export interface CheckNameResult { similarPackages?: SimilarPackage[] } -const NPM_REGISTRY = 'https://registry.npmjs.org' - export async function checkPackageExists(name: string): Promise { try { const encodedName = name.startsWith('@') 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 = From fe1fd2d1bf868fe491f885694e0f237ae51b8b92 Mon Sep 17 00:00:00 2001 From: OrbisK Date: Sun, 1 Feb 2026 14:14:27 +0100 Subject: [PATCH 2/6] fix: add cache header for cachedFetch --- app/composables/useCachedFetch.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/composables/useCachedFetch.ts b/app/composables/useCachedFetch.ts index 23440fcbb..cf6d8147c 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 { type CachedFetchResult, FETCH_CACHE_DEFAULT_TTL } from '#shared/utils/fetch-cache-config' +import defu from 'defu' /** * Get the cachedFetch function from the current request context. @@ -30,13 +31,20 @@ import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' */ export function useCachedFetch(): CachedFetchFunction { // On client, return a function that just uses $fetch (no caching, not stale) + if (import.meta.client) { 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] = { + headers: { + 'Cache-Control': `max-age=${ttl}, must-revalidate`, + }, + } + + const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T return { data, isStale: false, cachedAt: null } } } @@ -55,9 +63,15 @@ 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] = { + headers: { + 'Cache-Control': `max-age=${ttl}, must-revalidate`, + }, + } + + const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T return { data, isStale: false, cachedAt: null } } } From 4ede1f5cfe88b39be0b8b991dbdd49615c921c96 Mon Sep 17 00:00:00 2001 From: OrbisK Date: Sun, 1 Feb 2026 14:36:10 +0100 Subject: [PATCH 3/6] Revert "fix: add cache header for cachedFetch" This reverts commit fe1fd2d1bf868fe491f885694e0f237ae51b8b92. --- app/composables/useCachedFetch.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/app/composables/useCachedFetch.ts b/app/composables/useCachedFetch.ts index cf6d8147c..23440fcbb 100644 --- a/app/composables/useCachedFetch.ts +++ b/app/composables/useCachedFetch.ts @@ -1,5 +1,4 @@ -import { type CachedFetchResult, FETCH_CACHE_DEFAULT_TTL } from '#shared/utils/fetch-cache-config' -import defu from 'defu' +import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' /** * Get the cachedFetch function from the current request context. @@ -31,20 +30,13 @@ import defu from 'defu' */ export function useCachedFetch(): CachedFetchFunction { // On client, return a function that just uses $fetch (no caching, not stale) - if (import.meta.client) { return async ( url: string, options: Parameters[1] = {}, - ttl: number = FETCH_CACHE_DEFAULT_TTL, + _ttl?: number, ): Promise> => { - const defaultFetchOptions: Parameters[1] = { - headers: { - 'Cache-Control': `max-age=${ttl}, must-revalidate`, - }, - } - - const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T + const data = (await $fetch(url, options)) as T return { data, isStale: false, cachedAt: null } } } @@ -63,15 +55,9 @@ export function useCachedFetch(): CachedFetchFunction { return async ( url: string, options: Parameters[1] = {}, - ttl: number = FETCH_CACHE_DEFAULT_TTL, + _ttl?: number, ): Promise> => { - const defaultFetchOptions: Parameters[1] = { - headers: { - 'Cache-Control': `max-age=${ttl}, must-revalidate`, - }, - } - - const data = (await $fetch(url, defu(options, defaultFetchOptions))) as T + const data = (await $fetch(url, options)) as T return { data, isStale: false, cachedAt: null } } } From 73d62d5426a32415ed1cbd51601dcfc0fcc2d284 Mon Sep 17 00:00:00 2001 From: OrbisK Date: Sun, 1 Feb 2026 14:44:20 +0100 Subject: [PATCH 4/6] fix: add force-cache for cachedFetch --- app/composables/useCachedFetch.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 } } } From 50718f59ed2292647307fc73b5959651ce51300d Mon Sep 17 00:00:00 2001 From: OrbisK Date: Sun, 1 Feb 2026 15:18:30 +0100 Subject: [PATCH 5/6] test: fix tests --- .gitignore | 2 ++ app/utils/package-name.ts | 1 + test/nuxt/composables/use-npm-registry.spec.ts | 8 +++----- vitest.config.ts | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) 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/utils/package-name.ts b/app/utils/package-name.ts index 0bac1fd7a..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. 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' }], }, From 477328084b2d6ddaee3ebf2ac223b0dade7de952 Mon Sep 17 00:00:00 2001 From: OrbisK Date: Sun, 1 Feb 2026 15:21:06 +0100 Subject: [PATCH 6/6] chore: add defu --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) 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)