Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4f08506
fix: correctly detect type info in badge
gameroman Mar 20, 2026
cdb8b16
Revert "fix: correctly detect type info in badge"
gameroman Mar 20, 2026
42a0f22
feat: ai sloppity slop
gameroman Mar 20, 2026
715a3d7
feat: more ai slop
gameroman Mar 20, 2026
39e2967
Update [...pkg].get.ts
gameroman Mar 20, 2026
3bc3166
fix
gameroman Mar 20, 2026
7b62d5c
fix
gameroman Mar 20, 2026
c8a6737
Update package-analysis.ts
gameroman Mar 20, 2026
dc6d15d
Update [...pkg].get.ts
gameroman Mar 20, 2026
b93882b
fix
gameroman Mar 20, 2026
4eb0b00
fix
gameroman Mar 20, 2026
9c87ae2
Update [...pkg].get.ts
gameroman Mar 20, 2026
8626a61
wip
gameroman Mar 20, 2026
c6fc74e
wip
gameroman Mar 20, 2026
4550a9e
fix
gameroman Mar 20, 2026
5781426
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 20, 2026
33a2c90
Update [...pkg].get.ts
gameroman Mar 20, 2026
b419292
Update [...pkg].get.ts
gameroman Mar 20, 2026
79ded64
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 20, 2026
2377c31
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Mar 20, 2026
751f137
Update [...pkg].get.ts
gameroman Mar 20, 2026
04b30cc
wip
gameroman Mar 20, 2026
dc849ad
Update badge.spec.ts
gameroman Mar 20, 2026
d6fc700
Update badge.spec.ts
gameroman Mar 20, 2026
ff2c385
test: add types badge e2e test
gameroman Mar 20, 2026
85a9b25
test: add another types badge e2e test
gameroman Mar 20, 2026
e350ac7
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 20, 2026
a9c3974
Update nano-stringify-object.json
gameroman Mar 20, 2026
ab75cdf
update fixture
gameroman Mar 20, 2026
2ecefd9
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 20, 2026
21eca0e
fix
gameroman Mar 20, 2026
6516aa5
Merge branch 'fix-types-badge' of https://github.com/gameroman/npmx.d…
gameroman Mar 20, 2026
27e560d
fix: do something weird
gameroman Mar 20, 2026
1e742e6
fix: use a proper fixture
gameroman Mar 20, 2026
8000756
Update cache.ts
gameroman Mar 20, 2026
27cbf5c
Update cache.ts
gameroman Mar 20, 2026
d5c01ff
Update badge.spec.ts
gameroman Mar 20, 2026
f636f05
refactor: url.parse
ghostdevv Mar 21, 2026
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
102 changes: 51 additions & 51 deletions modules/runtime/server/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const FIXTURE_PATHS = {
esmTypes: 'esm-sh:types',
githubContributors: 'github:contributors.json',
githubContributorsStats: 'github:contributors-stats.json',
jsdelivr: 'jsdelivr',
} as const

type FixtureType = keyof typeof FIXTURE_PATHS
Expand Down Expand Up @@ -101,12 +102,8 @@ function parseScopedPackageWithVersion(input: string): { name: string; version?:
}

function getMockForUrl(url: string): MockResult | null {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
return null
}
const urlObj = URL.parse(url)
if (!urlObj) return null

const { host, pathname, searchParams } = urlObj

Expand Down Expand Up @@ -163,33 +160,6 @@ function getMockForUrl(url: string): MockResult | null {
}
}

// jsdelivr CDN - return 404 for README files, etc.
if (host === 'cdn.jsdelivr.net') {
// Return null data which will cause a 404 - README files are optional
return { data: null }
}

// jsdelivr data API - return mock file listing
if (host === 'data.jsdelivr.com') {
const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
if (packageMatch?.[1]) {
const pkgWithVersion = packageMatch[1]
const parsed = parseScopedPackageWithVersion(pkgWithVersion)
return {
data: {
type: 'npm',
name: parsed.name,
version: parsed.version || 'latest',
files: [
{ name: 'package.json', hash: 'abc123', size: 1000 },
{ name: 'index.js', hash: 'def456', size: 500 },
{ name: 'README.md', hash: 'ghi789', size: 2000 },
],
},
}
}
}

// Gravatar API - return 404 (avatars not needed in tests)
if (host === 'www.gravatar.com') {
return { data: null }
Expand Down Expand Up @@ -385,12 +355,8 @@ async function handleFastNpmMeta(
url: string,
storage: ReturnType<typeof useStorage>,
): Promise<MockResult | null> {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
return null
}
const urlObj = URL.parse(url)
if (!urlObj) return null

const { host, pathname, searchParams } = urlObj

Expand Down Expand Up @@ -430,12 +396,8 @@ async function handleGitHubApi(
url: string,
storage: ReturnType<typeof useStorage>,
): Promise<MockResult | null> {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
return null
}
const urlObj = URL.parse(url)
if (!urlObj) return null

const { host, pathname } = urlObj

Expand Down Expand Up @@ -486,12 +448,8 @@ interface FixtureMatchWithVersion extends FixtureMatch {
}

function matchUrlToFixture(url: string): FixtureMatchWithVersion | null {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
return null
}
const urlObj = URL.parse(url)
if (!urlObj) return null

const { host, pathname, searchParams } = urlObj

Expand Down Expand Up @@ -571,6 +529,42 @@ function logUnmockedRequest(type: string, detail: string, url: string): void {
)
}

async function handleJsdelivrDataApi(
url: string,
storage: ReturnType<typeof useStorage>,
): Promise<MockResult | null> {
const urlObj = URL.parse(url)
if (!urlObj) return null

if (urlObj.host !== 'data.jsdelivr.com') return null

const packageMatch = decodeURIComponent(urlObj.pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
if (!packageMatch?.[1]) return null

const parsed = parseScopedPackageWithVersion(packageMatch[1])

// Try per-package fixture first
const fixturePath = getFixturePath('jsdelivr', parsed.name)
const fixture = await storage.getItem<unknown>(fixturePath)
if (fixture) {
return { data: fixture }
}

// Fall back to generic stub (no declaration files)
return {
data: {
type: 'npm',
name: parsed.name,
version: parsed.version || 'latest',
files: [
{ name: 'package.json', hash: 'abc123', size: 1000 },
{ name: 'index.js', hash: 'def456', size: 500 },
{ name: 'README.md', hash: 'ghi789', size: 2000 },
],
},
}
}

/**
* Shared fixture-backed fetch implementation.
* This is used by both cachedFetch and the global $fetch override.
Expand All @@ -593,6 +587,12 @@ async function fetchFromFixtures<T>(
return { data: fastNpmMetaResult.data as T, isStale: false, cachedAt: Date.now() }
}

const jsdelivrResult = await handleJsdelivrDataApi(url, storage)
if (jsdelivrResult) {
if (VERBOSE) process.stdout.write(`[test-fixtures] jsDelivr Data API: ${url}\n`)
return { data: jsdelivrResult.data as T, isStale: false, cachedAt: Date.now() }
}

// Check for GitHub API
const githubResult = await handleGitHubApi(url, storage)
if (githubResult) {
Expand Down
65 changes: 4 additions & 61 deletions server/api/registry/analysis/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,9 @@ import { PackageRouteParamsSchema } from '#shared/schemas/package'
import type {
PackageAnalysis,
ExtendedPackageJson,
TypesPackageInfo,
CreatePackageInfo,
} from '#shared/utils/package-analysis'
import {
analyzePackage,
getTypesPackageName,
getCreatePackageName,
hasBuiltInTypes,
} from '#shared/utils/package-analysis'
import { analyzePackage, getCreatePackageName } from '#shared/utils/package-analysis'
import {
getDevDependencySuggestion,
type DevDependencySuggestion,
Expand All @@ -23,13 +17,8 @@ import {
} from '#shared/utils/constants'
import { parseRepoUrl } from '#shared/utils/git-providers'
import { encodePackageName } from '#shared/utils/npm'
import { flattenFileTree } from '#server/utils/import-resolver'
import { getPackageFileTree } from '#server/utils/file-tree'
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'

interface AnalysisPackageJson extends ExtendedPackageJson {
readme?: string
}
import { fetchPackageWithTypesAndFiles } from '#server/utils/file-tree'
import { getLatestVersionBatch } from 'fast-npm-meta'

export default defineCachedEventHandler(
async event => {
Expand All @@ -44,38 +33,7 @@ export default defineCachedEventHandler(
packageName: decodeURIComponent(rawPackageName),
version: rawVersion,
})

// Fetch package data
const encodedName = encodePackageName(packageName)
const versionSuffix = version ? `/${version}` : '/latest'
const pkg = await $fetch<AnalysisPackageJson>(
`${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
)

let typesPackage: TypesPackageInfo | undefined
let files: Set<string> | undefined

// Only check for @types and files when the package doesn't ship its own types
if (!hasBuiltInTypes(pkg)) {
const typesPkgName = getTypesPackageName(packageName)
const resolvedVersion = pkg.version ?? version ?? 'latest'

// Fetch @types info and file tree in parallel — they are independent
const [typesResult, fileTreeResult] = await Promise.allSettled([
fetchTypesPackageInfo(typesPkgName),
getPackageFileTree(packageName, resolvedVersion),
])

if (typesResult.status === 'fulfilled') {
typesPackage = typesResult.value
}
if (fileTreeResult.status === 'fulfilled') {
files = flattenFileTree(fileTreeResult.value.tree)
}
}

// Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app)
// Only show if the packages are actually associated (same maintainers or same org)
const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles(packageName, version)
const createPackage = await findAssociatedCreatePackage(packageName, pkg)
const analysis = analyzePackage(pkg, {
typesPackage,
Expand Down Expand Up @@ -107,21 +65,6 @@ export default defineCachedEventHandler(
},
)

/**
* Fetch @types package info including deprecation status using fast-npm-meta.
* Returns undefined if the package doesn't exist.
*/
async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> {
const result = await getLatestVersion(packageName, { metadata: true, throw: false })
if ('error' in result) {
return undefined
}
return {
packageName,
deprecated: result.deprecated,
}
}

/** Package metadata needed for association validation */
interface PackageWithMeta {
maintainers?: Array<{ name: string }>
Expand Down
47 changes: 41 additions & 6 deletions server/api/registry/badge/[type]/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PackageRouteParamsSchema } from '#shared/schemas/package'
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
import { fetchNpmPackage } from '#server/utils/npm'
import { assertValidPackageName } from '#shared/utils/npm'
import { fetchPackageWithTypesAndFiles } from '#server/utils/file-tree'
import { handleApiError } from '#server/utils/error-handler'

const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point'
Expand Down Expand Up @@ -372,12 +373,46 @@ const badgeStrategies = {
return { label: 'node', value: nodeVersion, color: COLORS.yellow }
},

'types': async (pkgData: globalThis.Packument) => {
const latest = getLatestVersion(pkgData)
const versionData = latest ? pkgData.versions?.[latest] : undefined
const hasTypes = !!(versionData?.types || versionData?.typings)
const value = hasTypes ? 'included' : 'missing'
const color = hasTypes ? COLORS.blue : COLORS.slate
'types': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
const targetVersion = requestedVersion ?? getLatestVersion(pkgData)
const versionData = targetVersion ? pkgData.versions?.[targetVersion] : undefined

if (versionData && hasBuiltInTypes(versionData)) {
return { label: 'types', value: 'included', color: COLORS.blue }
}

const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles(
pkgData.name,
targetVersion,
)

const typesStatus = detectTypesStatus(pkg, typesPackage, files)

let value: string
let color: string

switch (typesStatus.kind) {
case 'included':
value = 'included'
color = COLORS.blue
break

case '@types':
value = '@types'
color = COLORS.purple
if (typesStatus.deprecated) {
value += ' (deprecated)'
color = COLORS.red
}
break

case 'none':
default:
value = 'missing'
color = COLORS.slate
break
}

return { label: 'types', value, color }
},

Expand Down
63 changes: 63 additions & 0 deletions server/utils/file-tree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { getLatestVersion } from 'fast-npm-meta'
import { flattenFileTree } from '#server/utils/import-resolver'
import type { ExtendedPackageJson, TypesPackageInfo } from '#shared/utils/package-analysis'

/**
* Fetch the file tree from jsDelivr API.
* Returns a nested tree structure of all files in the package.
Expand Down Expand Up @@ -83,3 +87,62 @@ export async function getPackageFileTree(
tree,
}
}

/**
* Fetch @types package info including deprecation status using fast-npm-meta.
* Returns undefined if the package doesn't exist.
*/
async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> {
const result = await getLatestVersion(packageName, { metadata: true, throw: false })
if ('error' in result) {
return undefined
}
return {
packageName,
deprecated: result.deprecated,
}
}

interface AnalysisPackageJson extends ExtendedPackageJson {
readme?: string
}

export async function fetchPackageWithTypesAndFiles(
packageName: string,
version?: string,
): Promise<{
pkg: AnalysisPackageJson
typesPackage?: TypesPackageInfo
files?: Set<string>
}> {
// Fetch main package data
const encodedName = encodePackageName(packageName)
const versionSuffix = version ? `/${version}` : '/latest'

const pkg = await $fetch<AnalysisPackageJson>(`${NPM_REGISTRY}/${encodedName}${versionSuffix}`)

let typesPackage: TypesPackageInfo | undefined
let files: Set<string> | undefined

// Only attempt to fetch @types + file tree when the package doesn't ship its own types
if (!hasBuiltInTypes(pkg)) {
const typesPkgName = getTypesPackageName(packageName)
const resolvedVersion = pkg.version ?? version ?? 'latest'

// Fetch both in parallel — they're independent
const [typesResult, fileTreeResult] = await Promise.allSettled([
fetchTypesPackageInfo(typesPkgName),
getPackageFileTree(packageName, resolvedVersion),
])

if (typesResult.status === 'fulfilled') {
typesPackage = typesResult.value
}

if (fileTreeResult.status === 'fulfilled') {
files = flattenFileTree(fileTreeResult.value.tree)
}
}

return { pkg, typesPackage, files }
}
Comment on lines +110 to +148
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we reuse logic from other pages/endpoints? e.g. server/api/registry/analysis/[...pkg].get.ts

Loading
Loading