Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 9 additions & 135 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
@@ -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<string, SlimVersion> = {}
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<string, string> = {}
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<string>,
requestedVersion?: MaybeRefOrGetter<string | null>,
) {
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<Packument>(`/${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<SlimPackument>(`/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
}
60 changes: 25 additions & 35 deletions app/composables/usePackageComparison.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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.
*
Expand All @@ -84,7 +69,6 @@ function resolveRequestedVersion(pkgData: Packument, requested?: string): string
*/
export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
const { t } = useI18n()
const { $npmRegistry } = useNuxtApp()
const numberFormatter = useNumberFormatter()
const compactNumberFormatter = useCompactNumberFormatter()
const bytesFormatter = useBytesFormatter()
Expand Down Expand Up @@ -138,13 +122,20 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
const results = await Promise.all(
namesToFetch.map(async (spec): Promise<PackageComparisonData | null> => {
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<Packument>(`/${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<SlimPackument>(
`/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
Expand All @@ -153,7 +144,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
: ''

// 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.
Expand Down Expand Up @@ -182,16 +173,15 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
.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
Expand All @@ -206,27 +196,27 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {

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: {
count: vulnsTotal,
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,
Expand Down
8 changes: 8 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**' },
Expand Down
61 changes: 61 additions & 0 deletions server/api/registry/packument/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 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
* 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 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
// 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 : ''}`
},
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -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'])

Expand Down
Loading
Loading