From ef4cd90c83734984e22aa442a743093f10f3d99e Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 14 Oct 2026 22:40:03 +0000 Subject: [PATCH 1/2] perf: cache normalised (trimmed) packument instead of raw (#3320) --- app/composables/npm/usePackage.ts | 144 +------- app/composables/usePackageComparison.ts | 60 ++- nuxt.config.ts | 8 + server/api/registry/packument/[...pkg].get.ts | 68 ++++ {app => shared}/utils/install-scripts.ts | 2 + shared/utils/packument.ts | 136 +++++++ .../use-package-comparison.spec.ts | 347 +++++++++++------- .../api/registry/packument/pkg.get.spec.ts | 153 ++++++++ .../utils/install-scripts.spec.ts | 2 +- 9 files changed, 618 insertions(+), 302 deletions(-) create mode 100644 server/api/registry/packument/[...pkg].get.ts rename {app => shared}/utils/install-scripts.ts (98%) create mode 100644 shared/utils/packument.ts create mode 100644 test/unit/server/api/registry/packument/pkg.get.spec.ts rename test/unit/{app => shared}/utils/install-scripts.spec.ts (99%) diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index 7f16200f87..2c4a0af93f 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -1,149 +1,23 @@ -import { getTrustStatus } from 'packumeta' -import { normalizeLicense } from '#shared/utils/npm' -import { detectSecurityDowngradeForVersion } from '~/utils/publish-security' - -/** Number of recent versions to include in initial payload */ -const RECENT_VERSIONS_COUNT = 5 - -/** - * Transform a full Packument into a slimmed version for client-side use. - * Reduces payload size by: - * - Removing readme (fetched separately) - * - Including only: 5 most recent versions + one version per dist-tag + requested version - * - Stripping unnecessary fields from version objects - */ -export function transformPackument( - pkg: Packument, - requestedVersion?: string | null, -): SlimPackument { - // Get versions pointed to by dist-tags - const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) - - // Get 5 most recent versions by publish time - const recentVersions = Object.keys(pkg.versions) - .filter(v => pkg.time[v]) - .sort((a, b) => { - const timeA = pkg.time[a] - const timeB = pkg.time[b] - if (!timeA || !timeB) return 0 - return Date.parse(timeB) - Date.parse(timeA) - }) - .slice(0, RECENT_VERSIONS_COUNT) - - // Combine: recent versions + dist-tag versions + requested version (deduplicated) - const includedVersions = new Set([...recentVersions, ...distTagVersions]) - - // Add the requested version if it exists in the package - if (requestedVersion && pkg.versions[requestedVersion]) { - includedVersions.add(requestedVersion) - } - - // Computed from the full version history so only the small result is serialized. - const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => ({ - version, - time: pkg.time[version], - trustStatus: getTrustStatus(metadata), - deprecated: metadata.deprecated, - })) - const publishSecurityDowngrade = requestedVersion - ? detectSecurityDowngradeForVersion(securityVersionEntries, requestedVersion) - : null - - // Build filtered versions object with install scripts info per version - const filteredVersions: Record = {} - let versionData: SlimPackumentVersion | null = null - for (const v of includedVersions) { - const version = pkg.versions[v] - if (version) { - const versionLicense = normalizeLicense(version.license) - if (version.version === requestedVersion) { - // Strip readme from each version, extract install scripts info - const { readme: _readme, scripts, ...slimVersion } = version - - // Extract install scripts info (which scripts exist + npx deps) - const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null - versionData = { - ...slimVersion, - license: versionLicense, - installScripts: installScripts ?? undefined, - } - } - - filteredVersions[v] = { - trustStatus: getTrustStatus(version), - version: version.version, - deprecated: version.deprecated, - tags: version.tags as string[], - license: versionLicense, - type: typeof version.type === 'string' ? version.type : undefined, - } - } - } - - // Build filtered time object (only for included versions + metadata) - const filteredTime: Record = {} - if (pkg.time.modified) filteredTime.modified = pkg.time.modified - if (pkg.time.created) filteredTime.created = pkg.time.created - for (const v of includedVersions) { - if (pkg.time[v]) filteredTime[v] = pkg.time[v] - } - - // Normalize license field - const license = normalizeLicense(requestedVersion ? versionData?.license : pkg.license) - - // Extract storybook field from the requested version (custom package.json field) - const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null - const rawStorybook = requestedPkgVersion?.storybook - const storybook = - rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook - ? ({ url: rawStorybook.url } as { url: string }) - : undefined - - return { - // oxlint-disable-next-line eslint/no-underscore-dangle - '_id': pkg._id, - // oxlint-disable-next-line eslint/no-underscore-dangle - '_rev': pkg._rev, - 'name': pkg.name, - 'description': pkg.description, - 'dist-tags': pkg['dist-tags'], - 'time': filteredTime, - 'maintainers': pkg.maintainers, - 'author': pkg.author, - 'license': license, - 'homepage': pkg.homepage, - 'keywords': pkg.keywords, - 'repository': pkg.repository, - 'bugs': pkg.bugs, - ...(storybook && { storybook }), - 'requestedVersion': versionData, - 'versions': filteredVersions, - 'publishSecurityDowngrade': publishSecurityDowngrade, - } -} - export function usePackage( name: MaybeRefOrGetter, requestedVersion?: MaybeRefOrGetter, ) { const asyncData = useLazyAsyncData( () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, - async ({ $npmRegistry }, { signal }) => { + async (_nuxtApp, { signal }) => { const encodedName = encodePackageName(toValue(name)) - const { data: r, isStale } = await $npmRegistry(`/${encodedName}`, { + const reqVer = toValue(requestedVersion) + // Fetch the already-trimmed packument from our own cached route so the + // full (often multi-MB) document is never sent to or parsed by the client. + const pkg = await $fetch(`/api/registry/packument/${encodedName}`, { signal, + params: reqVer ? { version: reqVer } : undefined, }) - const reqVer = toValue(requestedVersion) - const pkg = transformPackument(r, reqVer) - return { ...pkg, isStale } + // Staleness is handled server-side by the route's SWR cache, so the + // response is always considered fresh from the client's perspective. + return { ...pkg, isStale: false } }, ) - if (import.meta.client && asyncData.data.value?.isStale) { - onMounted(() => { - asyncData.refresh() - }) - } - return asyncData } diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 3e47b0028e..7d938d69e8 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -1,4 +1,3 @@ -import { normalizeLicense } from '#shared/utils/npm' import { parsePackageSpec } from '#shared/utils/parse-package-param' import { getDependencyCount } from '~/utils/npm/dependency-count' @@ -62,20 +61,6 @@ export interface PackageComparisonData { isNoDependency?: boolean } -/** - * Resolve a requested version (exact version or dist-tag) against a packument. - * - * @returns The concrete version string, or `undefined` if it cannot be resolved. - */ -function resolveRequestedVersion(pkgData: Packument, requested?: string): string | undefined { - if (!requested) return pkgData['dist-tags']?.latest - // Allow dist-tags such as "next" or "beta" in addition to exact versions. - const fromTag = pkgData['dist-tags']?.[requested] - if (fromTag) return fromTag - if (pkgData.versions?.[requested]) return requested - return undefined -} - /** * Composable for fetching and comparing multiple packages. * @@ -84,7 +69,6 @@ function resolveRequestedVersion(pkgData: Packument, requested?: string): string */ export function usePackageComparison(packageNames: MaybeRefOrGetter) { const { t } = useI18n() - const { $npmRegistry } = useNuxtApp() const numberFormatter = useNumberFormatter() const compactNumberFormatter = useCompactNumberFormatter() const bytesFormatter = useBytesFormatter() @@ -138,13 +122,20 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { const results = await Promise.all( namesToFetch.map(async (spec): Promise => { try { - // A spec may include a version (vue@3.6.0) + // A spec may include a version or dist-tag (vue@3.6.0, nuxt@next) const { name, version: requestedVersion } = parsePackageSpec(spec) - // Fetch basic package info first (required) - const { data: pkgData } = await $npmRegistry(`/${encodePackageName(name)}`) - const resolvedVersion = resolveRequestedVersion(pkgData, requestedVersion) - if (!resolvedVersion) return null + // Fetch the already-trimmed packument from our own cached route. The + // route resolves the requested version (exact version or dist-tag, + // defaulting to latest) and returns that version's full manifest as + // `requestedVersion`. + const pkg = await $fetch( + `/api/registry/packument/${encodePackageName(name)}`, + { params: requestedVersion ? { version: requestedVersion } : undefined }, + ) + const versionData = pkg.requestedVersion + const resolvedVersion = versionData?.version + if (!versionData || !resolvedVersion) return null // Only target a specific version on the API endpoints when one was // explicitly requested @@ -153,7 +144,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { : '' // Fetch fast additional data in parallel (optional - failures are ok) - const repoInfo = parseRepositoryInfo(pkgData.repository) + const repoInfo = parseRepositoryInfo(pkg.repository) const isGitHub = repoInfo?.provider === 'github' const [downloads, analysis, vulns, likes, ghRepo, ghIssues] = await Promise.all([ // Download counts are per-package, not per-version. @@ -182,16 +173,15 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { .catch(() => null) : Promise.resolve(null), ]) - const versionData = pkgData.versions[resolvedVersion] - const packageSize = versionData?.dist?.unpackedSize + const packageSize = versionData.dist?.unpackedSize // Detect if package is binary-only const isBinary = isBinaryOnlyPackage({ - name: pkgData.name, - bin: versionData?.bin, - main: versionData?.main, - module: versionData?.module, - exports: versionData?.exports, + name: pkg.name, + bin: versionData.bin, + main: versionData.main, + module: versionData.module, + exports: versionData.exports, }) // Vulnerabilities @@ -206,13 +196,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return { package: { - name: pkgData.name, + name: pkg.name, version: resolvedVersion, description: undefined, }, downloads: downloads?.downloads, packageSize, - directDeps: versionData ? getDependencyCount(versionData) : null, + directDeps: getDependencyCount(versionData), installSize: undefined, // Will be filled in second pass analysis: analysis ?? undefined, vulnerabilities: { @@ -220,13 +210,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { severity: vulnsSeverity, }, metadata: { - license: normalizeLicense(pkgData.license), + license: pkg.license, // Use version-specific publish time, NOT time.modified (which can be // updated by metadata changes like maintainer additions) - lastUpdated: pkgData.time?.[resolvedVersion], - createdAt: pkgData.time?.created, + lastUpdated: pkg.time?.[resolvedVersion], + createdAt: pkg.time?.created, engines: analysis?.engines, - deprecated: versionData?.deprecated, + deprecated: versionData.deprecated, github: { stars: typeof ghRepo?.repo?.stars === 'number' ? ghRepo.repo.stars : undefined, forks: typeof ghRepo?.repo?.forks === 'number' ? ghRepo.repo.forks : undefined, diff --git a/nuxt.config.ts b/nuxt.config.ts index cd352101a1..69107bd127 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -148,6 +148,14 @@ export default defineNuxtConfig({ '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/package-meta/**': { isr: 300 }, + '/api/registry/packument/**': { + isr: { + expiration: 300, + // The transform is version-aware, so the version must be part of the cache key + passQuery: true, + allowQuery: ['version'], + }, + }, '/:pkg/.well-known/skills/**': { isr: 3600 }, '/:scope/:pkg/.well-known/skills/**': { isr: 3600 }, '/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' }, diff --git a/server/api/registry/packument/[...pkg].get.ts b/server/api/registry/packument/[...pkg].get.ts new file mode 100644 index 0000000000..b187373ba9 --- /dev/null +++ b/server/api/registry/packument/[...pkg].get.ts @@ -0,0 +1,68 @@ +import { + CACHE_MAX_AGE_FIVE_MINUTES, + ERROR_NPM_FETCH_FAILED, + NPM_REGISTRY, +} from '#shared/utils/constants' +import { encodePackageName } from '#shared/utils/npm' +import { transformPackument } from '#shared/utils/packument' + +/** + * Returns an already-trimmed ("slim") packument for a package page render. + * + * Fetches the full packument from the npm registry, runs the same + * `transformPackument` the app uses, and returns only the small subset the + * page needs. This means: + * - The full packument is fetched and parsed at most once per cache period + * and is never stored whole (only the trimmed result is cached). + * - Client-side navigation downloads a few KB instead of potentially tens of MB. + * + * The transform depends on the requested version, so the `version` query + * parameter is part of the cache key. The value may be an exact version or a + * dist-tag (e.g. `latest`, `next`); it is resolved against the packument before + * transforming. When omitted it defaults to the `latest` dist-tag, so callers + * that only need the current version get its full manifest in the response. + * + * URL patterns: + * - /api/registry/packument/packageName?version=1.2.3 + * - /api/registry/packument/@scope/packageName?version=next + */ +export default defineCachedEventHandler( + async event => { + const pkgParam = getRouterParam(event, 'pkg') + if (!pkgParam) { + throw createError({ statusCode: 404, message: 'Package name is required' }) + } + + const packageName = decodeURIComponent(pkgParam) + const rawVersion = getQuery(event).version + const requestedVersion = typeof rawVersion === 'string' ? rawVersion : null + + try { + const encodedName = encodePackageName(packageName) + const packument = await $fetch(`${NPM_REGISTRY}/${encodedName}`) + + // Resolve the requested version (an exact version or a dist-tag) to a + // concrete version so the transform includes that version's full + // manifest. Defaults to the `latest` dist-tag when none was requested. + const resolvedVersion = requestedVersion + ? (packument['dist-tags']?.[requestedVersion] ?? + (packument.versions[requestedVersion] ? requestedVersion : null)) + : (packument['dist-tags']?.latest ?? null) + + return transformPackument(packument, resolvedVersion) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_NPM_FETCH_FAILED, + }) + } + }, + { + maxAge: CACHE_MAX_AGE_FIVE_MINUTES, + swr: true, + getKey: event => { + const version = getQuery(event).version + return `packument:v1:${getRouterParam(event, 'pkg') ?? ''}:${typeof version === 'string' ? version : ''}` + }, + }, +) diff --git a/app/utils/install-scripts.ts b/shared/utils/install-scripts.ts similarity index 98% rename from app/utils/install-scripts.ts rename to shared/utils/install-scripts.ts index 9cbf28bd53..501111de67 100644 --- a/app/utils/install-scripts.ts +++ b/shared/utils/install-scripts.ts @@ -7,6 +7,8 @@ * Also extracts npx package calls from those scripts. */ +import type { InstallScriptsInfo } from '#shared/types/npm-registry' + // Scripts that run when installing a package as a dependency const INSTALL_SCRIPTS = new Set(['preinstall', 'install', 'postinstall']) diff --git a/shared/utils/packument.ts b/shared/utils/packument.ts new file mode 100644 index 0000000000..d1a9dcae40 --- /dev/null +++ b/shared/utils/packument.ts @@ -0,0 +1,136 @@ +import { getTrustStatus } from 'packumeta' +import { extractInstallScriptsInfo } from '#shared/utils/install-scripts' +import { normalizeLicense } from '#shared/utils/npm' +import { detectSecurityDowngradeForVersion } from '~/utils/publish-security' +import type { + Packument, + SlimPackument, + SlimPackumentVersion, + SlimVersion, +} from '#shared/types/npm-registry' + +/** Number of recent versions to include in initial payload */ +const RECENT_VERSIONS_COUNT = 5 + +/** + * Transform a full Packument into a slimmed version for client-side use. + * Reduces payload size by: + * - Removing readme (fetched separately) + * - Including only: 5 most recent versions + one version per dist-tag + requested version + * - Stripping unnecessary fields from version objects + * + * This runs server-side in the `/api/registry/packument` route, so the full + * packument never has to be sent to (or parsed by) the client. + * + * @param pkg Full packument as returned by the npm registry + * @param requestedVersion Version the caller wants full metadata for (optional) + */ +export function transformPackument( + pkg: Packument, + requestedVersion?: string | null, +): SlimPackument { + // Get versions pointed to by dist-tags + const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) + + // Get 5 most recent versions by publish time + const recentVersions = Object.keys(pkg.versions) + .filter(v => pkg.time[v]) + .sort((a, b) => { + const timeA = pkg.time[a] + const timeB = pkg.time[b] + if (!timeA || !timeB) return 0 + return Date.parse(timeB) - Date.parse(timeA) + }) + .slice(0, RECENT_VERSIONS_COUNT) + + // Combine: recent versions + dist-tag versions + requested version (deduplicated) + const includedVersions = new Set([...recentVersions, ...distTagVersions]) + + // Add the requested version if it exists in the package + if (requestedVersion && pkg.versions[requestedVersion]) { + includedVersions.add(requestedVersion) + } + + // Computed from the full version history so only the small result is serialized. + const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => ({ + version, + time: pkg.time[version], + trustStatus: getTrustStatus(metadata), + deprecated: metadata.deprecated, + })) + const publishSecurityDowngrade = requestedVersion + ? detectSecurityDowngradeForVersion(securityVersionEntries, requestedVersion) + : null + + // Build filtered versions object with install scripts info per version + const filteredVersions: Record = {} + let versionData: SlimPackumentVersion | null = null + for (const v of includedVersions) { + const version = pkg.versions[v] + if (version) { + const versionLicense = normalizeLicense(version.license) + if (version.version === requestedVersion) { + // Strip readme from each version, extract install scripts info + const { readme: _readme, scripts, ...slimVersion } = version + + // Extract install scripts info (which scripts exist + npx deps) + const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null + versionData = { + ...slimVersion, + license: versionLicense, + installScripts: installScripts ?? undefined, + } + } + + filteredVersions[v] = { + trustStatus: getTrustStatus(version), + version: version.version, + deprecated: version.deprecated, + tags: version.tags as string[], + license: versionLicense, + type: typeof version.type === 'string' ? version.type : undefined, + } + } + } + + // Build filtered time object (only for included versions + metadata) + const filteredTime: Record = {} + if (pkg.time.modified) filteredTime.modified = pkg.time.modified + if (pkg.time.created) filteredTime.created = pkg.time.created + for (const v of includedVersions) { + if (pkg.time[v]) filteredTime[v] = pkg.time[v] + } + + // Normalize license field + const license = normalizeLicense(requestedVersion ? versionData?.license : pkg.license) + + // Extract storybook field from the requested version (custom package.json field) + const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null + const rawStorybook = requestedPkgVersion?.storybook + const storybook = + rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook + ? ({ url: rawStorybook.url } as { url: string }) + : undefined + + return { + // oxlint-disable-next-line eslint/no-underscore-dangle + '_id': pkg._id, + // oxlint-disable-next-line eslint/no-underscore-dangle + '_rev': pkg._rev, + 'name': pkg.name, + 'description': pkg.description, + 'dist-tags': pkg['dist-tags'], + 'time': filteredTime, + 'maintainers': pkg.maintainers, + 'author': pkg.author, + 'license': license, + 'homepage': pkg.homepage, + 'keywords': pkg.keywords, + 'repository': pkg.repository, + 'bugs': pkg.bugs, + ...(storybook && { storybook }), + 'requestedVersion': versionData, + 'versions': filteredVersions, + 'publishSecurityDowngrade': publishSecurityDowngrade, + } +} diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index 8067881869..56d81fbdec 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -2,6 +2,43 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import type { PackageComparisonData } from '~/composables/usePackageComparison' +interface FetchOptions { + baseURL?: string + params?: Record +} + +interface PackumentFixture { + 'name'?: string + 'dist-tags'?: Record + 'time'?: Record + 'license'?: string + 'repository'?: { type?: string; url?: string } | string + 'versions'?: Record> +} + +/** + * Emulates the `/api/registry/packument` route for these tests: given the full + * packument a test wants the registry to hold, produce the trimmed response the + * compare composable consumes — the resolved version's full manifest as + * `requestedVersion`, plus the package-level metadata. + */ +function trimmedPackument(pkg: PackumentFixture, version?: unknown) { + const distTags = pkg['dist-tags'] ?? {} + const requested = typeof version === 'string' ? version : null + const resolved = requested + ? (distTags[requested] ?? (pkg.versions?.[requested] ? requested : null)) + : (distTags.latest ?? null) + const versionData = resolved ? (pkg.versions?.[resolved] ?? null) : null + return { + 'name': pkg.name, + 'dist-tags': distTags, + 'time': pkg['time'] ?? {}, + 'license': pkg.license, + 'repository': pkg.repository, + 'requestedVersion': versionData ? { ...versionData, version: resolved } : null, + } +} + /** * Helper to test usePackageComparison by wrapping it in a component. * This is required because the composable uses useI18n which must be @@ -64,10 +101,10 @@ describe('usePackageComparison', () => { } vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve(registryData) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve(trimmedPackument(registryData, options?.params?.version)) } return Promise.resolve(null) }), @@ -95,21 +132,26 @@ describe('usePackageComparison', () => { it('stores version-specific time in metadata', async () => { vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'test-package', - 'dist-tags': { latest: '1.0.0' }, - 'time': { - 'modified': '2025-01-01T00:00:00.000Z', - '1.0.0': '2023-03-20T00:00:00.000Z', - }, - 'license': 'MIT', - 'versions': { - '1.0.0': { dist: { unpackedSize: 10000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'time': { + 'modified': '2025-01-01T00:00:00.000Z', + '1.0.0': '2023-03-20T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + }, + }, + options?.params?.version, + ), + ) } return Promise.resolve(null) }), @@ -131,20 +173,25 @@ describe('usePackageComparison', () => { it('marks packages not published in 2+ years as stale', async () => { vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'old-package', - 'dist-tags': { latest: '1.0.0' }, - 'time': { - '1.0.0': '2020-01-01T00:00:00.000Z', // More than 2 years ago - }, - 'license': 'MIT', - 'versions': { - '1.0.0': { dist: { unpackedSize: 5000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'old-package', + 'dist-tags': { latest: '1.0.0' }, + 'time': { + '1.0.0': '2020-01-01T00:00:00.000Z', // More than 2 years ago + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 5000 } }, + }, + }, + options?.params?.version, + ), + ) } return Promise.resolve(null) }), @@ -163,20 +210,25 @@ describe('usePackageComparison', () => { it('marks recently published packages as neutral', async () => { vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'fresh-package', - 'dist-tags': { latest: '1.0.0' }, - 'time': { - '1.0.0': new Date().toISOString(), // Today - }, - 'license': 'MIT', - 'versions': { - '1.0.0': { dist: { unpackedSize: 5000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'fresh-package', + 'dist-tags': { latest: '1.0.0' }, + 'time': { + '1.0.0': new Date().toISOString(), // Today + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 5000 } }, + }, + }, + options?.params?.version, + ), + ) } return Promise.resolve(null) }), @@ -198,17 +250,22 @@ describe('usePackageComparison', () => { const pkgName = 'github-pkg' vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': pkgName, - 'dist-tags': { latest: '1.0.0' }, - 'repository': { type: 'git', url: 'https://github.com/owner/repo' }, - 'versions': { - '1.0.0': { dist: { unpackedSize: 1000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': pkgName, + 'dist-tags': { latest: '1.0.0' }, + 'repository': { type: 'git', url: 'https://github.com/owner/repo' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }, + options?.params?.version, + ), + ) } if (fullUrl.includes('ungh.cc/repos/owner/repo')) { return Promise.resolve({ repo: { stars: 1500, forks: 100 } }) @@ -238,17 +295,22 @@ describe('usePackageComparison', () => { const pkgName = 'missing-metrics-pkg' vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': pkgName, - 'dist-tags': { latest: '1.0.0' }, - 'repository': { type: 'git', url: 'https://github.com/owner/repo' }, - 'versions': { - '1.0.0': { dist: { unpackedSize: 1000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': pkgName, + 'dist-tags': { latest: '1.0.0' }, + 'repository': { type: 'git', url: 'https://github.com/owner/repo' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }, + options?.params?.version, + ), + ) } if (fullUrl.includes('ungh.cc/repos/owner/repo')) { // Return malformed data (stars missing) @@ -274,22 +336,25 @@ describe('usePackageComparison', () => { it('skips github fetches for non-github repositories', async () => { const pkgName = 'gitlab-pkg' - const fetchMock = vi - .fn() - .mockImplementation((url: string, options?: { baseURL?: string }) => { - const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': pkgName, - 'dist-tags': { latest: '1.0.0' }, - 'repository': { type: 'git', url: 'https://gitlab.com/owner/repo' }, - 'versions': { - '1.0.0': { dist: { unpackedSize: 1000 } }, + const fetchMock = vi.fn().mockImplementation((url: string, options?: FetchOptions) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': pkgName, + 'dist-tags': { latest: '1.0.0' }, + 'repository': { type: 'git', url: 'https://gitlab.com/owner/repo' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, }, - }) - } - return Promise.resolve(null) - }) + options?.params?.version, + ), + ) + } + return Promise.resolve(null) + }) vi.stubGlobal('$fetch', fetchMock) const { status, getFacetValues } = await usePackageComparisonInComponent([pkgName]) @@ -311,24 +376,29 @@ describe('usePackageComparison', () => { const requestedUrls: string[] = [] vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url requestedUrls.push(fullUrl) - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'test-package', - 'dist-tags': { latest: '2.0.0' }, - 'time': { - 'modified': '2025-01-01T00:00:00.000Z', - '1.0.0': '2022-03-20T00:00:00.000Z', - '2.0.0': '2025-01-01T00:00:00.000Z', - }, - 'license': 'MIT', - 'versions': { - '1.0.0': { dist: { unpackedSize: 10000 } }, - '2.0.0': { dist: { unpackedSize: 20000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'test-package', + 'dist-tags': { latest: '2.0.0' }, + 'time': { + 'modified': '2025-01-01T00:00:00.000Z', + '1.0.0': '2022-03-20T00:00:00.000Z', + '2.0.0': '2025-01-01T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + '2.0.0': { dist: { unpackedSize: 20000 } }, + }, + }, + options?.params?.version, + ), + ) } if (fullUrl.includes('/api/registry/install-size/')) { return Promise.resolve({ selfSize: 1, totalSize: 2, dependencyCount: 3 }) @@ -369,22 +439,27 @@ describe('usePackageComparison', () => { it('resolves a dist-tag spec to its concrete version', async () => { vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'test-package', - 'dist-tags': { latest: '1.0.0', next: '2.0.0-beta.1' }, - 'time': { - '1.0.0': '2024-01-01T00:00:00.000Z', - '2.0.0-beta.1': '2025-01-01T00:00:00.000Z', - }, - 'license': 'MIT', - 'versions': { - '1.0.0': { dist: { unpackedSize: 10000 } }, - '2.0.0-beta.1': { dist: { unpackedSize: 20000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0', next: '2.0.0-beta.1' }, + 'time': { + '1.0.0': '2024-01-01T00:00:00.000Z', + '2.0.0-beta.1': '2025-01-01T00:00:00.000Z', + }, + 'license': 'MIT', + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + '2.0.0-beta.1': { dist: { unpackedSize: 20000 } }, + }, + }, + options?.params?.version, + ), + ) } return Promise.resolve(null) }), @@ -402,16 +477,21 @@ describe('usePackageComparison', () => { it('returns null when the pinned version cannot be resolved', async () => { vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'test-package', - 'dist-tags': { latest: '1.0.0' }, - 'versions': { - '1.0.0': { dist: { unpackedSize: 10000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 10000 } }, + }, + }, + options?.params?.version, + ), + ) } return Promise.resolve(null) }), @@ -432,20 +512,25 @@ describe('usePackageComparison', () => { const createdDate = '2020-01-01T00:00:00.000Z' vi.stubGlobal( '$fetch', - vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + vi.fn().mockImplementation((url: string, options?: FetchOptions) => { const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url - if (fullUrl.startsWith('https://registry.npmjs.org/')) { - return Promise.resolve({ - 'name': 'test-package', - 'dist-tags': { latest: '1.0.0' }, - 'time': { - 'created': createdDate, - '1.0.0': createdDate, - }, - 'versions': { - '1.0.0': { dist: { unpackedSize: 1000 } }, - }, - }) + if (fullUrl.startsWith('/api/registry/packument/')) { + return Promise.resolve( + trimmedPackument( + { + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'time': { + 'created': createdDate, + '1.0.0': createdDate, + }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }, + options?.params?.version, + ), + ) } return Promise.resolve(null) }), diff --git a/test/unit/server/api/registry/packument/pkg.get.spec.ts b/test/unit/server/api/registry/packument/pkg.get.spec.ts new file mode 100644 index 0000000000..e0fcb7b94b --- /dev/null +++ b/test/unit/server/api/registry/packument/pkg.get.spec.ts @@ -0,0 +1,153 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createError, type H3Event } from 'h3' +import { NPM_REGISTRY } from '#shared/utils/constants' +import { encodePackageName } from '#shared/utils/npm' +import type { Packument, PackumentVersion } from '#shared/types/npm-registry' + +const npmFetchMock = vi.fn() +vi.stubGlobal('$fetch', npmFetchMock) +vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) +vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300) + +const handleApiErrorMock = vi.fn( + (_error: unknown, fallback: { statusCode: number; message: string }) => { + throw createError(fallback) + }, +) as unknown as typeof handleApiError +vi.stubGlobal('handleApiError', handleApiErrorMock) + +let routerParam: string | undefined +let queryParams: Record = {} + +vi.stubGlobal('getRouterParam', () => routerParam) +vi.stubGlobal('getQuery', () => queryParams) +vi.stubGlobal('createError', createError) + +const handler = (await import('#server/api/registry/packument/[...pkg].get')).default + +function makePackument(opts: { + versions: Record> + time: Record + distTags?: Record + license?: string +}): Packument { + return { + 'name': 'my-pkg', + 'dist-tags': opts.distTags ?? {}, + 'versions': Object.fromEntries( + Object.entries(opts.versions).map(([v, data]) => [v, { version: v, ...data }]), + ), + 'time': opts.time, + 'license': opts.license, + } as unknown as Packument +} + +const fakeEvent = {} as H3Event + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('packument API', () => { + beforeEach(() => { + vi.clearAllMocks() + routerParam = undefined + queryParams = {} + }) + + it('throws 404 when the package name param is missing', async () => { + routerParam = undefined + await expect(handler(fakeEvent)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('defaults to the latest dist-tag when no version is requested', async () => { + routerParam = 'my-pkg' + npmFetchMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {}, '2.0.0': {} }, + time: { '1.0.0': '2024-01-01T00:00:00Z', '2.0.0': '2025-01-01T00:00:00Z' }, + distTags: { latest: '2.0.0' }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.requestedVersion?.version).toBe('2.0.0') + expect(result['dist-tags'].latest).toBe('2.0.0') + }) + + it('resolves an exact version', async () => { + routerParam = 'my-pkg' + queryParams = { version: '1.0.0' } + npmFetchMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {}, '2.0.0': {} }, + time: { '1.0.0': '2024-01-01T00:00:00Z', '2.0.0': '2025-01-01T00:00:00Z' }, + distTags: { latest: '2.0.0' }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.requestedVersion?.version).toBe('1.0.0') + }) + + it('resolves a dist-tag to its concrete version', async () => { + routerParam = 'my-pkg' + queryParams = { version: 'next' } + npmFetchMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {}, '2.0.0-beta.1': {} }, + time: { + '1.0.0': '2024-01-01T00:00:00Z', + '2.0.0-beta.1': '2025-01-01T00:00:00Z', + }, + distTags: { latest: '1.0.0', next: '2.0.0-beta.1' }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.requestedVersion?.version).toBe('2.0.0-beta.1') + }) + + it('includes no requestedVersion for an unresolvable version', async () => { + routerParam = 'my-pkg' + queryParams = { version: '9.9.9' } + npmFetchMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {} }, + time: { '1.0.0': '2024-01-01T00:00:00Z' }, + distTags: { latest: '1.0.0' }, + }), + ) + + const result = await handler(fakeEvent) + expect(result.requestedVersion).toBeNull() + }) + + it('fetches the encoded package name from the npm registry', async () => { + routerParam = '%40scope%2Fmy-pkg' + npmFetchMock.mockResolvedValue( + makePackument({ + versions: { '1.0.0': {} }, + time: { '1.0.0': '2024-01-01T00:00:00Z' }, + distTags: { latest: '1.0.0' }, + }), + ) + + await handler(fakeEvent) + expect(npmFetchMock).toHaveBeenCalledWith( + `${NPM_REGISTRY}/${encodePackageName('@scope/my-pkg')}`, + ) + }) + + it('calls handleApiError when the registry fetch fails', async () => { + routerParam = 'my-pkg' + const error = new Error('upstream failure') + npmFetchMock.mockRejectedValue(error) + + await expect(handler(fakeEvent)).rejects.toMatchObject({ statusCode: 502 }) + expect(handleApiErrorMock).toHaveBeenCalledWith(error, { + statusCode: 502, + message: expect.any(String), + }) + }) +}) diff --git a/test/unit/app/utils/install-scripts.spec.ts b/test/unit/shared/utils/install-scripts.spec.ts similarity index 99% rename from test/unit/app/utils/install-scripts.spec.ts rename to test/unit/shared/utils/install-scripts.spec.ts index 187be050b5..ca79cd4ad7 100644 --- a/test/unit/app/utils/install-scripts.spec.ts +++ b/test/unit/shared/utils/install-scripts.spec.ts @@ -3,7 +3,7 @@ import { extractInstallScriptsInfo, getInstallScriptFilePath, parseNodeScript, -} from '~/utils/install-scripts' +} from '#shared/utils/install-scripts' describe('extractInstallScriptsInfo', () => { it('returns null when no install scripts exist', () => { From eafa727d13b1296938fb9f6d1ca0d8899f6bdf93 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 14 Oct 2026 23:31:51 +0000 Subject: [PATCH 2/2] perf: avoid redundant packument fetch for license-change check (#3322) --- server/api/registry/packument/[...pkg].get.ts | 21 +++++---------- .../api/registry/packument/pkg.get.spec.ts | 26 ++++++++----------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/server/api/registry/packument/[...pkg].get.ts b/server/api/registry/packument/[...pkg].get.ts index b187373ba9..8d1158953c 100644 --- a/server/api/registry/packument/[...pkg].get.ts +++ b/server/api/registry/packument/[...pkg].get.ts @@ -1,20 +1,14 @@ -import { - CACHE_MAX_AGE_FIVE_MINUTES, - ERROR_NPM_FETCH_FAILED, - NPM_REGISTRY, -} from '#shared/utils/constants' -import { encodePackageName } from '#shared/utils/npm' import { transformPackument } from '#shared/utils/packument' +import { CACHE_MAX_AGE_FIVE_MINUTES, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' /** * Returns an already-trimmed ("slim") packument for a package page render. * - * Fetches the full packument from the npm registry, runs the same - * `transformPackument` the app uses, and returns only the small subset the - * page needs. This means: - * - The full packument is fetched and parsed at most once per cache period - * and is never stored whole (only the trimmed result is cached). - * - Client-side navigation downloads a few KB instead of potentially tens of MB. + * Fetches the full packument via `fetchNpmPackage` so a server-rendered package + * page shares a single (Nitro-cached) download of the potentially huge document + * with the other server routes that need it (license-change, readme, ...). Only + * the small trimmed subset is returned and cached here, so clients download a + * few KB instead of potentially tens of MB. * * The transform depends on the requested version, so the `version` query * parameter is part of the cache key. The value may be an exact version or a @@ -38,8 +32,7 @@ export default defineCachedEventHandler( const requestedVersion = typeof rawVersion === 'string' ? rawVersion : null try { - const encodedName = encodePackageName(packageName) - const packument = await $fetch(`${NPM_REGISTRY}/${encodedName}`) + const packument = await fetchNpmPackage(packageName) // Resolve the requested version (an exact version or a dist-tag) to a // concrete version so the transform includes that version's full diff --git a/test/unit/server/api/registry/packument/pkg.get.spec.ts b/test/unit/server/api/registry/packument/pkg.get.spec.ts index e0fcb7b94b..15be07692b 100644 --- a/test/unit/server/api/registry/packument/pkg.get.spec.ts +++ b/test/unit/server/api/registry/packument/pkg.get.spec.ts @@ -1,11 +1,9 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { createError, type H3Event } from 'h3' -import { NPM_REGISTRY } from '#shared/utils/constants' -import { encodePackageName } from '#shared/utils/npm' import type { Packument, PackumentVersion } from '#shared/types/npm-registry' -const npmFetchMock = vi.fn() -vi.stubGlobal('$fetch', npmFetchMock) +const fetchNpmPackageMock = vi.fn() +vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock) vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300) @@ -62,7 +60,7 @@ describe('packument API', () => { it('defaults to the latest dist-tag when no version is requested', async () => { routerParam = 'my-pkg' - npmFetchMock.mockResolvedValue( + fetchNpmPackageMock.mockResolvedValue( makePackument({ versions: { '1.0.0': {}, '2.0.0': {} }, time: { '1.0.0': '2024-01-01T00:00:00Z', '2.0.0': '2025-01-01T00:00:00Z' }, @@ -78,7 +76,7 @@ describe('packument API', () => { it('resolves an exact version', async () => { routerParam = 'my-pkg' queryParams = { version: '1.0.0' } - npmFetchMock.mockResolvedValue( + fetchNpmPackageMock.mockResolvedValue( makePackument({ versions: { '1.0.0': {}, '2.0.0': {} }, time: { '1.0.0': '2024-01-01T00:00:00Z', '2.0.0': '2025-01-01T00:00:00Z' }, @@ -93,7 +91,7 @@ describe('packument API', () => { it('resolves a dist-tag to its concrete version', async () => { routerParam = 'my-pkg' queryParams = { version: 'next' } - npmFetchMock.mockResolvedValue( + fetchNpmPackageMock.mockResolvedValue( makePackument({ versions: { '1.0.0': {}, '2.0.0-beta.1': {} }, time: { @@ -111,7 +109,7 @@ describe('packument API', () => { it('includes no requestedVersion for an unresolvable version', async () => { routerParam = 'my-pkg' queryParams = { version: '9.9.9' } - npmFetchMock.mockResolvedValue( + fetchNpmPackageMock.mockResolvedValue( makePackument({ versions: { '1.0.0': {} }, time: { '1.0.0': '2024-01-01T00:00:00Z' }, @@ -123,9 +121,9 @@ describe('packument API', () => { expect(result.requestedVersion).toBeNull() }) - it('fetches the encoded package name from the npm registry', async () => { + it('fetches the decoded package name via fetchNpmPackage', async () => { routerParam = '%40scope%2Fmy-pkg' - npmFetchMock.mockResolvedValue( + fetchNpmPackageMock.mockResolvedValue( makePackument({ versions: { '1.0.0': {} }, time: { '1.0.0': '2024-01-01T00:00:00Z' }, @@ -134,15 +132,13 @@ describe('packument API', () => { ) await handler(fakeEvent) - expect(npmFetchMock).toHaveBeenCalledWith( - `${NPM_REGISTRY}/${encodePackageName('@scope/my-pkg')}`, - ) + expect(fetchNpmPackageMock).toHaveBeenCalledWith('@scope/my-pkg') }) - it('calls handleApiError when the registry fetch fails', async () => { + it('calls handleApiError when the packument fetch fails', async () => { routerParam = 'my-pkg' const error = new Error('upstream failure') - npmFetchMock.mockRejectedValue(error) + fetchNpmPackageMock.mockRejectedValue(error) await expect(handler(fakeEvent)).rejects.toMatchObject({ statusCode: 502 }) expect(handleApiErrorMock).toHaveBeenCalledWith(error, {