Skip to content

Commit d105dcc

Browse files
committed
feat: more badges
1 parent 77769b1 commit d105dcc

File tree

2 files changed

+283
-75
lines changed

2 files changed

+283
-75
lines changed

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

Lines changed: 0 additions & 75 deletions
This file was deleted.
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import * as v from 'valibot'
2+
import { createError, getRouterParam, getQuery, setHeader } from 'h3'
3+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
4+
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
5+
import { fetchNpmPackage } from '#server/utils/npm'
6+
import { assertValidPackageName } from '#shared/utils/npm'
7+
import { handleApiError } from '#server/utils/error-handler'
8+
9+
const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point'
10+
const OSV_QUERY_API = 'https://api.osv.dev/v1/query'
11+
const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size'
12+
13+
const QUERY_SCHEMA = v.object({
14+
color: v.optional(v.string()),
15+
name: v.optional(v.string()),
16+
})
17+
18+
const COLORS = {
19+
blue: '#3b82f6',
20+
green: '#22c55e',
21+
purple: '#a855f7',
22+
orange: '#f97316',
23+
red: '#ef4444',
24+
cyan: '#06b6d4',
25+
slate: '#64748b',
26+
yellow: '#eab308',
27+
black: '#0a0a0a',
28+
white: '#ffffff',
29+
}
30+
31+
function measureTextWidth(text: string): number {
32+
const charWidth = 7
33+
const paddingX = 8
34+
return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2)
35+
}
36+
37+
function formatBytes(bytes: number): string {
38+
if (!+bytes) return '0 B'
39+
const k = 1024
40+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
41+
const i = Math.floor(Math.log(bytes) / Math.log(k))
42+
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
43+
return `${value} ${sizes[i]}`
44+
}
45+
46+
function formatNumber(num: number): string {
47+
return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(
48+
num,
49+
)
50+
}
51+
52+
function formatDate(dateString: string): string {
53+
return new Date(dateString).toLocaleDateString('en-US', {
54+
month: 'short',
55+
day: 'numeric',
56+
year: 'numeric',
57+
})
58+
}
59+
60+
function getLatestVersion(pkgData: globalThis.Packument): string | undefined {
61+
return pkgData['dist-tags']?.latest
62+
}
63+
64+
async function fetchDownloads(
65+
packageName: string,
66+
period: 'last-month' | 'last-week',
67+
): Promise<number> {
68+
try {
69+
const response = await fetch(`${NPM_DOWNLOADS_API}/${period}/${packageName}`)
70+
const data = await response.json()
71+
return data.downloads ?? 0
72+
} catch {
73+
return 0
74+
}
75+
}
76+
77+
async function fetchVulnerabilities(packageName: string, version: string): Promise<number> {
78+
try {
79+
const response = await fetch(OSV_QUERY_API, {
80+
method: 'POST',
81+
body: JSON.stringify({
82+
version,
83+
package: { name: packageName, ecosystem: 'npm' },
84+
}),
85+
})
86+
const data = await response.json()
87+
return data.vulns?.length ?? 0
88+
} catch {
89+
return 0
90+
}
91+
}
92+
93+
async function fetchInstallSize(packageName: string, version: string): Promise<number | null> {
94+
try {
95+
const response = await fetch(`${BUNDLEPHOBIA_API}?package=${packageName}@${version}`)
96+
const data = await response.json()
97+
return data.size ?? null
98+
} catch {
99+
return null
100+
}
101+
}
102+
103+
const badgeStrategies = {
104+
'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
105+
const value = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown'
106+
return { label: 'version', value, color: COLORS.blue }
107+
},
108+
109+
'license': async (pkgData: globalThis.Packument) => {
110+
const latest = getLatestVersion(pkgData)
111+
const versionData = latest ? pkgData.versions?.[latest] : undefined
112+
const value = versionData?.license ?? 'unknown'
113+
return { label: 'license', value, color: COLORS.green }
114+
},
115+
116+
'size': async (pkgData: globalThis.Packument) => {
117+
const latest = getLatestVersion(pkgData)
118+
const versionData = latest ? pkgData.versions?.[latest] : undefined
119+
120+
// Fallback to unpacked size if bundlephobia fails or latest is missing
121+
let bytes = versionData?.dist?.unpackedSize ?? 0
122+
123+
if (latest) {
124+
const installSize = await fetchInstallSize(pkgData.name, latest)
125+
if (installSize !== null) bytes = installSize
126+
}
127+
128+
return { label: 'install size', value: formatBytes(bytes), color: COLORS.purple }
129+
},
130+
131+
'downloads': async (pkgData: globalThis.Packument) => {
132+
const count = await fetchDownloads(pkgData.name, 'last-month')
133+
return { label: 'downloads/mo', value: formatNumber(count), color: COLORS.orange }
134+
},
135+
136+
'downloads-week': async (pkgData: globalThis.Packument) => {
137+
const count = await fetchDownloads(pkgData.name, 'last-week')
138+
return { label: 'downloads/wk', value: formatNumber(count), color: COLORS.orange }
139+
},
140+
141+
'vulnerabilities': async (pkgData: globalThis.Packument) => {
142+
const latest = getLatestVersion(pkgData)
143+
const count = latest ? await fetchVulnerabilities(pkgData.name, latest) : 0
144+
const isSafe = count === 0
145+
const color = isSafe ? COLORS.green : COLORS.red
146+
return { label: 'vulns', value: String(count), color }
147+
},
148+
149+
'dependencies': async (pkgData: globalThis.Packument) => {
150+
const latest = getLatestVersion(pkgData)
151+
const versionData = latest ? pkgData.versions?.[latest] : undefined
152+
const count = Object.keys(versionData?.dependencies ?? {}).length
153+
return { label: 'dependencies', value: String(count), color: COLORS.cyan }
154+
},
155+
156+
'created': async (pkgData: globalThis.Packument) => {
157+
const dateStr = pkgData.time?.created ?? pkgData.time?.modified
158+
return { label: 'created', value: formatDate(dateStr), color: COLORS.slate }
159+
},
160+
161+
'updated': async (pkgData: globalThis.Packument) => {
162+
const dateStr = pkgData.time?.modified ?? pkgData.time?.created ?? new Date().toISOString()
163+
return { label: 'updated', value: formatDate(dateStr), color: COLORS.slate }
164+
},
165+
166+
'engines': async (pkgData: globalThis.Packument) => {
167+
const latest = getLatestVersion(pkgData)
168+
const nodeVersion = (latest && pkgData.versions?.[latest]?.engines?.node) ?? '*'
169+
return { label: 'node', value: nodeVersion, color: COLORS.yellow }
170+
},
171+
172+
'types': async (pkgData: globalThis.Packument) => {
173+
const latest = getLatestVersion(pkgData)
174+
const versionData = latest ? pkgData.versions?.[latest] : undefined
175+
const hasTypes = !!(versionData?.types || versionData?.typings)
176+
const value = hasTypes ? 'included' : 'missing'
177+
const color = hasTypes ? COLORS.blue : COLORS.slate
178+
return { label: 'types', value, color }
179+
},
180+
181+
'maintainers': async (pkgData: globalThis.Packument) => {
182+
const count = pkgData.maintainers?.length ?? 0
183+
return { label: 'maintainers', value: String(count), color: COLORS.cyan }
184+
},
185+
186+
'deprecated': async (pkgData: globalThis.Packument) => {
187+
const latest = getLatestVersion(pkgData)
188+
const isDeprecated = !!(latest && pkgData.versions?.[latest]?.deprecated)
189+
return {
190+
label: 'status',
191+
value: isDeprecated ? 'deprecated' : 'active',
192+
color: isDeprecated ? COLORS.red : COLORS.green,
193+
}
194+
},
195+
}
196+
197+
const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies))
198+
199+
export default defineCachedEventHandler(
200+
async event => {
201+
const query = getQuery(event)
202+
const typeParam = getRouterParam(event, 'type')
203+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
204+
205+
if (pkgParamSegments.length === 0) {
206+
// TODO: throwing 404 rather than 400 as it's cacheable
207+
throw createError({ statusCode: 404, message: 'Package name is required.' })
208+
}
209+
210+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
211+
212+
try {
213+
const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
214+
packageName: rawPackageName,
215+
version: rawVersion,
216+
})
217+
218+
const queryParams = v.safeParse(QUERY_SCHEMA, query)
219+
const userColor = queryParams.success ? queryParams.output.color : undefined
220+
const showName = queryParams.success && queryParams.output.name === 'true'
221+
222+
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
223+
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
224+
const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]
225+
226+
assertValidPackageName(packageName)
227+
228+
const pkgData = await fetchNpmPackage(packageName)
229+
const strategyResult = await strategy(pkgData, requestedVersion)
230+
231+
const finalLabel = showName ? packageName : strategyResult.label
232+
const finalValue = strategyResult.value
233+
234+
const rawColor = userColor ?? strategyResult.color
235+
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
236+
237+
const leftWidth = measureTextWidth(finalLabel)
238+
const rightWidth = measureTextWidth(finalValue)
239+
const totalWidth = leftWidth + rightWidth
240+
const height = 20
241+
242+
const svg = `
243+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${finalLabel}: ${finalValue}">
244+
<clipPath id="r">
245+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
246+
</clipPath>
247+
<g clip-path="url(#r)">
248+
<rect width="${leftWidth}" height="${height}" fill="#0a0a0a"/>
249+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
250+
</g>
251+
<g text-anchor="middle" font-family="'Geist', system-ui, -apple-system, sans-serif" font-size="11">
252+
<text x="${leftWidth / 2}" y="14" fill="#ffffff">${finalLabel}</text>
253+
<text x="${leftWidth + rightWidth / 2}" y="14" fill="#ffffff">${finalValue}</text>
254+
</g>
255+
</svg>
256+
`.trim()
257+
258+
setHeader(event, 'Content-Type', 'image/svg+xml')
259+
setHeader(
260+
event,
261+
'Cache-Control',
262+
`public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
263+
)
264+
265+
return svg
266+
} catch (error: unknown) {
267+
handleApiError(error, {
268+
statusCode: 502,
269+
message: ERROR_NPM_FETCH_FAILED,
270+
})
271+
}
272+
},
273+
{
274+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
275+
swr: true,
276+
getKey: event => {
277+
const type = getRouterParam(event, 'type') ?? 'version'
278+
const pkg = getRouterParam(event, 'pkg') ?? ''
279+
const query = getQuery(event)
280+
return `badge:${type}:${pkg}:${JSON.stringify(query)}`
281+
},
282+
},
283+
)

0 commit comments

Comments
 (0)