|
| 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