Skip to content

Commit 5aff68f

Browse files
gameromanautofix-ci[bot]ghostdevv
authored
fix: correctly detect type info in badge (#2173)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent 1dd1be9 commit 5aff68f

File tree

10 files changed

+717
-130
lines changed

10 files changed

+717
-130
lines changed

modules/runtime/server/cache.ts

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const FIXTURE_PATHS = {
2828
esmTypes: 'esm-sh:types',
2929
githubContributors: 'github:contributors.json',
3030
githubContributorsStats: 'github:contributors-stats.json',
31+
jsdelivr: 'jsdelivr',
3132
} as const
3233

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

103104
function getMockForUrl(url: string): MockResult | null {
104-
let urlObj: URL
105-
try {
106-
urlObj = new URL(url)
107-
} catch {
108-
return null
109-
}
105+
const urlObj = URL.parse(url)
106+
if (!urlObj) return null
110107

111108
const { host, pathname, searchParams } = urlObj
112109

@@ -140,33 +137,6 @@ function getMockForUrl(url: string): MockResult | null {
140137
}
141138
}
142139

143-
// jsdelivr CDN - return 404 for README files, etc.
144-
if (host === 'cdn.jsdelivr.net') {
145-
// Return null data which will cause a 404 - README files are optional
146-
return { data: null }
147-
}
148-
149-
// jsdelivr data API - return mock file listing
150-
if (host === 'data.jsdelivr.com') {
151-
const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
152-
if (packageMatch?.[1]) {
153-
const pkgWithVersion = packageMatch[1]
154-
const parsed = parseScopedPackageWithVersion(pkgWithVersion)
155-
return {
156-
data: {
157-
type: 'npm',
158-
name: parsed.name,
159-
version: parsed.version || 'latest',
160-
files: [
161-
{ name: 'package.json', hash: 'abc123', size: 1000 },
162-
{ name: 'index.js', hash: 'def456', size: 500 },
163-
{ name: 'README.md', hash: 'ghi789', size: 2000 },
164-
],
165-
},
166-
}
167-
}
168-
}
169-
170140
// Gravatar API - return 404 (avatars not needed in tests)
171141
if (host === 'www.gravatar.com') {
172142
return { data: null }
@@ -362,12 +332,8 @@ async function handleFastNpmMeta(
362332
url: string,
363333
storage: ReturnType<typeof useStorage>,
364334
): Promise<MockResult | null> {
365-
let urlObj: URL
366-
try {
367-
urlObj = new URL(url)
368-
} catch {
369-
return null
370-
}
335+
const urlObj = URL.parse(url)
336+
if (!urlObj) return null
371337

372338
const { host, pathname, searchParams } = urlObj
373339

@@ -407,12 +373,8 @@ async function handleGitHubApi(
407373
url: string,
408374
storage: ReturnType<typeof useStorage>,
409375
): Promise<MockResult | null> {
410-
let urlObj: URL
411-
try {
412-
urlObj = new URL(url)
413-
} catch {
414-
return null
415-
}
376+
const urlObj = URL.parse(url)
377+
if (!urlObj) return null
416378

417379
const { host, pathname } = urlObj
418380

@@ -463,12 +425,8 @@ interface FixtureMatchWithVersion extends FixtureMatch {
463425
}
464426

465427
function matchUrlToFixture(url: string): FixtureMatchWithVersion | null {
466-
let urlObj: URL
467-
try {
468-
urlObj = new URL(url)
469-
} catch {
470-
return null
471-
}
428+
const urlObj = URL.parse(url)
429+
if (!urlObj) return null
472430

473431
const { host, pathname, searchParams } = urlObj
474432

@@ -548,6 +506,42 @@ function logUnmockedRequest(type: string, detail: string, url: string): void {
548506
)
549507
}
550508

509+
async function handleJsdelivrDataApi(
510+
url: string,
511+
storage: ReturnType<typeof useStorage>,
512+
): Promise<MockResult | null> {
513+
const urlObj = URL.parse(url)
514+
if (!urlObj) return null
515+
516+
if (urlObj.host !== 'data.jsdelivr.com') return null
517+
518+
const packageMatch = decodeURIComponent(urlObj.pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
519+
if (!packageMatch?.[1]) return null
520+
521+
const parsed = parseScopedPackageWithVersion(packageMatch[1])
522+
523+
// Try per-package fixture first
524+
const fixturePath = getFixturePath('jsdelivr', parsed.name)
525+
const fixture = await storage.getItem<unknown>(fixturePath)
526+
if (fixture) {
527+
return { data: fixture }
528+
}
529+
530+
// Fall back to generic stub (no declaration files)
531+
return {
532+
data: {
533+
type: 'npm',
534+
name: parsed.name,
535+
version: parsed.version || 'latest',
536+
files: [
537+
{ name: 'package.json', hash: 'abc123', size: 1000 },
538+
{ name: 'index.js', hash: 'def456', size: 500 },
539+
{ name: 'README.md', hash: 'ghi789', size: 2000 },
540+
],
541+
},
542+
}
543+
}
544+
551545
/**
552546
* Shared fixture-backed fetch implementation.
553547
* This is used by both cachedFetch and the global $fetch override.
@@ -570,6 +564,12 @@ async function fetchFromFixtures<T>(
570564
return { data: fastNpmMetaResult.data as T, isStale: false, cachedAt: Date.now() }
571565
}
572566

567+
const jsdelivrResult = await handleJsdelivrDataApi(url, storage)
568+
if (jsdelivrResult) {
569+
if (VERBOSE) process.stdout.write(`[test-fixtures] jsDelivr Data API: ${url}\n`)
570+
return { data: jsdelivrResult.data as T, isStale: false, cachedAt: Date.now() }
571+
}
572+
573573
// Check for GitHub API
574574
const githubResult = await handleGitHubApi(url, storage)
575575
if (githubResult) {

server/api/registry/analysis/[...pkg].get.ts

Lines changed: 4 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,9 @@ import { PackageRouteParamsSchema } from '#shared/schemas/package'
33
import type {
44
PackageAnalysis,
55
ExtendedPackageJson,
6-
TypesPackageInfo,
76
CreatePackageInfo,
87
} from '#shared/utils/package-analysis'
9-
import {
10-
analyzePackage,
11-
getTypesPackageName,
12-
getCreatePackageName,
13-
hasBuiltInTypes,
14-
} from '#shared/utils/package-analysis'
8+
import { analyzePackage, getCreatePackageName } from '#shared/utils/package-analysis'
159
import {
1610
getDevDependencySuggestion,
1711
type DevDependencySuggestion,
@@ -23,13 +17,8 @@ import {
2317
} from '#shared/utils/constants'
2418
import { parseRepoUrl } from '#shared/utils/git-providers'
2519
import { encodePackageName } from '#shared/utils/npm'
26-
import { flattenFileTree } from '#server/utils/import-resolver'
27-
import { getPackageFileTree } from '#server/utils/file-tree'
28-
import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'
29-
30-
interface AnalysisPackageJson extends ExtendedPackageJson {
31-
readme?: string
32-
}
20+
import { fetchPackageWithTypesAndFiles } from '#server/utils/file-tree'
21+
import { getLatestVersionBatch } from 'fast-npm-meta'
3322

3423
export default defineCachedEventHandler(
3524
async event => {
@@ -44,38 +33,7 @@ export default defineCachedEventHandler(
4433
packageName: decodeURIComponent(rawPackageName),
4534
version: rawVersion,
4635
})
47-
48-
// Fetch package data
49-
const encodedName = encodePackageName(packageName)
50-
const versionSuffix = version ? `/${version}` : '/latest'
51-
const pkg = await $fetch<AnalysisPackageJson>(
52-
`${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
53-
)
54-
55-
let typesPackage: TypesPackageInfo | undefined
56-
let files: Set<string> | undefined
57-
58-
// Only check for @types and files when the package doesn't ship its own types
59-
if (!hasBuiltInTypes(pkg)) {
60-
const typesPkgName = getTypesPackageName(packageName)
61-
const resolvedVersion = pkg.version ?? version ?? 'latest'
62-
63-
// Fetch @types info and file tree in parallel — they are independent
64-
const [typesResult, fileTreeResult] = await Promise.allSettled([
65-
fetchTypesPackageInfo(typesPkgName),
66-
getPackageFileTree(packageName, resolvedVersion),
67-
])
68-
69-
if (typesResult.status === 'fulfilled') {
70-
typesPackage = typesResult.value
71-
}
72-
if (fileTreeResult.status === 'fulfilled') {
73-
files = flattenFileTree(fileTreeResult.value.tree)
74-
}
75-
}
76-
77-
// Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app)
78-
// Only show if the packages are actually associated (same maintainers or same org)
36+
const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles(packageName, version)
7937
const createPackage = await findAssociatedCreatePackage(packageName, pkg)
8038
const analysis = analyzePackage(pkg, {
8139
typesPackage,
@@ -107,21 +65,6 @@ export default defineCachedEventHandler(
10765
},
10866
)
10967

110-
/**
111-
* Fetch @types package info including deprecation status using fast-npm-meta.
112-
* Returns undefined if the package doesn't exist.
113-
*/
114-
async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> {
115-
const result = await getLatestVersion(packageName, { metadata: true, throw: false })
116-
if ('error' in result) {
117-
return undefined
118-
}
119-
return {
120-
packageName,
121-
deprecated: result.deprecated,
122-
}
123-
}
124-
12568
/** Package metadata needed for association validation */
12669
interface PackageWithMeta {
12770
maintainers?: Array<{ name: string }>

server/api/registry/badge/[type]/[...pkg].get.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PackageRouteParamsSchema } from '#shared/schemas/package'
66
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
77
import { fetchNpmPackage } from '#server/utils/npm'
88
import { assertValidPackageName } from '#shared/utils/npm'
9+
import { fetchPackageWithTypesAndFiles } from '#server/utils/file-tree'
910
import { handleApiError } from '#server/utils/error-handler'
1011

1112
const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point'
@@ -438,12 +439,46 @@ const badgeStrategies = {
438439
return { label: 'node', value: nodeVersion, color: COLORS.yellow }
439440
},
440441

441-
'types': async (pkgData: globalThis.Packument) => {
442-
const latest = getLatestVersion(pkgData)
443-
const versionData = latest ? pkgData.versions?.[latest] : undefined
444-
const hasTypes = !!(versionData?.types || versionData?.typings)
445-
const value = hasTypes ? 'included' : 'missing'
446-
const color = hasTypes ? COLORS.blue : COLORS.slate
442+
'types': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
443+
const targetVersion = requestedVersion ?? getLatestVersion(pkgData)
444+
const versionData = targetVersion ? pkgData.versions?.[targetVersion] : undefined
445+
446+
if (versionData && hasBuiltInTypes(versionData)) {
447+
return { label: 'types', value: 'included', color: COLORS.blue }
448+
}
449+
450+
const { pkg, typesPackage, files } = await fetchPackageWithTypesAndFiles(
451+
pkgData.name,
452+
targetVersion,
453+
)
454+
455+
const typesStatus = detectTypesStatus(pkg, typesPackage, files)
456+
457+
let value: string
458+
let color: string
459+
460+
switch (typesStatus.kind) {
461+
case 'included':
462+
value = 'included'
463+
color = COLORS.blue
464+
break
465+
466+
case '@types':
467+
value = '@types'
468+
color = COLORS.purple
469+
if (typesStatus.deprecated) {
470+
value += ' (deprecated)'
471+
color = COLORS.red
472+
}
473+
break
474+
475+
case 'none':
476+
default:
477+
value = 'missing'
478+
color = COLORS.slate
479+
break
480+
}
481+
447482
return { label: 'types', value, color }
448483
},
449484

server/utils/file-tree.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { getLatestVersion } from 'fast-npm-meta'
2+
import { flattenFileTree } from '#server/utils/import-resolver'
3+
import type { ExtendedPackageJson, TypesPackageInfo } from '#shared/utils/package-analysis'
4+
15
/**
26
* Fetch the file tree from jsDelivr API.
37
* Returns a nested tree structure of all files in the package.
@@ -83,3 +87,62 @@ export async function getPackageFileTree(
8387
tree,
8488
}
8589
}
90+
91+
/**
92+
* Fetch @types package info including deprecation status using fast-npm-meta.
93+
* Returns undefined if the package doesn't exist.
94+
*/
95+
async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> {
96+
const result = await getLatestVersion(packageName, { metadata: true, throw: false })
97+
if ('error' in result) {
98+
return undefined
99+
}
100+
return {
101+
packageName,
102+
deprecated: result.deprecated,
103+
}
104+
}
105+
106+
interface AnalysisPackageJson extends ExtendedPackageJson {
107+
readme?: string
108+
}
109+
110+
export async function fetchPackageWithTypesAndFiles(
111+
packageName: string,
112+
version?: string,
113+
): Promise<{
114+
pkg: AnalysisPackageJson
115+
typesPackage?: TypesPackageInfo
116+
files?: Set<string>
117+
}> {
118+
// Fetch main package data
119+
const encodedName = encodePackageName(packageName)
120+
const versionSuffix = version ? `/${version}` : '/latest'
121+
122+
const pkg = await $fetch<AnalysisPackageJson>(`${NPM_REGISTRY}/${encodedName}${versionSuffix}`)
123+
124+
let typesPackage: TypesPackageInfo | undefined
125+
let files: Set<string> | undefined
126+
127+
// Only attempt to fetch @types + file tree when the package doesn't ship its own types
128+
if (!hasBuiltInTypes(pkg)) {
129+
const typesPkgName = getTypesPackageName(packageName)
130+
const resolvedVersion = pkg.version ?? version ?? 'latest'
131+
132+
// Fetch both in parallel — they're independent
133+
const [typesResult, fileTreeResult] = await Promise.allSettled([
134+
fetchTypesPackageInfo(typesPkgName),
135+
getPackageFileTree(packageName, resolvedVersion),
136+
])
137+
138+
if (typesResult.status === 'fulfilled') {
139+
typesPackage = typesResult.value
140+
}
141+
142+
if (fileTreeResult.status === 'fulfilled') {
143+
files = flattenFileTree(fileTreeResult.value.tree)
144+
}
145+
}
146+
147+
return { pkg, typesPackage, files }
148+
}

0 commit comments

Comments
 (0)