[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add valibot & implement validation/error handling for APIs (#84)

authored by

Brandon Hurrington and committed by
GitHub
79705e77 aa418a54

+365 -176
+53
CONTRIBUTING.md
··· 110 110 - We care about good types – never cast things to `any` 💪 111 111 - Validate rather than just assert 112 112 113 + ### Server API patterns 114 + 115 + #### Input validation with Valibot 116 + 117 + Use Valibot schemas from `#shared/schemas/` to validate API inputs. This ensures type safety and provides consistent error messages: 118 + 119 + ```typescript 120 + import * as v from 'valibot' 121 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 122 + 123 + // In your handler: 124 + const { packageName, version } = v.parse(PackageRouteParamsSchema, { 125 + packageName: rawPackageName, 126 + version: rawVersion, 127 + }) 128 + ``` 129 + 130 + #### Error handling with `handleApiError` 131 + 132 + Use the `handleApiError` utility for consistent error handling in API routes. It re-throws H3 errors (like 404s) and wraps other errors with a fallback message: 133 + 134 + ```typescript 135 + import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' 136 + 137 + try { 138 + // API logic... 139 + } catch (error: unknown) { 140 + handleApiError(error, { 141 + statusCode: 502, 142 + message: ERROR_NPM_FETCH_FAILED, 143 + }) 144 + } 145 + ``` 146 + 147 + #### URL parameter parsing with `parsePackageParams` 148 + 149 + Use `parsePackageParams` to extract package name and version from URL segments: 150 + 151 + ```typescript 152 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 153 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 154 + ``` 155 + 156 + This handles patterns like `/pkg`, `/pkg/v/1.0.0`, `/@scope/pkg`, and `/@scope/pkg/v/1.0.0`. 157 + 158 + #### Constants 159 + 160 + Define error messages and other string constants in `#shared/utils/constants.ts` to ensure consistency across the codebase: 161 + 162 + ```typescript 163 + export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.' 164 + ``` 165 + 113 166 ### Import order 114 167 115 168 1. Type imports first (`import type { ... }`)
+1
package.json
··· 44 44 "shiki": "^3.21.0", 45 45 "ufo": "^1.6.3", 46 46 "unplugin-vue-router": "^0.19.2", 47 + "valibot": "^1.2.0", 47 48 "validate-npm-package-name": "^7.0.2", 48 49 "virtua": "^0.48.3", 49 50 "vue": "3.5.27",
+3
pnpm-lock.yaml
··· 68 68 unplugin-vue-router: 69 69 specifier: ^0.19.2 70 70 version: 0.19.2(@vue/compiler-sfc@3.5.27)(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) 71 + valibot: 72 + specifier: ^1.2.0 73 + version: 1.2.0(typescript@5.9.3) 71 74 validate-npm-package-name: 72 75 specifier: ^7.0.2 73 76 version: 7.0.2
+18 -7
server/api/jsr/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageNameSchema } from '#shared/schemas/package' 3 + import { CACHE_MAX_AGE_ONE_HOUR, ERROR_JSR_FETCH_FAILED } from '#shared/utils/constants' 1 4 import type { JsrPackageInfo } from '#shared/types/jsr' 2 5 3 6 /** ··· 11 14 export default defineCachedEventHandler<Promise<JsrPackageInfo>>( 12 15 async event => { 13 16 const pkgPath = getRouterParam(event, 'pkg') 14 - if (!pkgPath) { 15 - throw createError({ statusCode: 400, message: 'Package name is required' }) 17 + 18 + try { 19 + const packageName = v.parse(PackageNameSchema, pkgPath) 20 + 21 + return await fetchJsrPackageInfo(packageName) 22 + } catch (error: unknown) { 23 + handleApiError(error, { 24 + statusCode: 502, 25 + message: ERROR_JSR_FETCH_FAILED, 26 + }) 16 27 } 17 - assertValidPackageName(pkgPath) 18 - 19 - return await fetchJsrPackageInfo(pkgPath) 20 28 }, 21 29 { 22 - maxAge: 60 * 60, // 1 hour 30 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 23 31 swr: true, 24 32 name: 'api-jsr-package', 25 - getKey: event => getRouterParam(event, 'pkg') ?? '', 33 + getKey: event => { 34 + const pkg = getRouterParam(event, 'pkg') ?? '' 35 + return `jsr:v1:${pkg.replace(/\/+$/, '').trim()}` 36 + }, 26 37 }, 27 38 )
+18 -14
server/api/registry/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageNameSchema } from '#shared/schemas/package' 3 + import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' 4 + 1 5 export default defineCachedEventHandler( 2 6 async event => { 3 - const pkg = getRouterParam(event, 'pkg') 4 - if (!pkg) { 5 - throw createError({ statusCode: 400, message: 'Package name is required' }) 6 - } 7 + try { 8 + const pkg = getRouterParam(event, 'pkg') 7 9 8 - assertValidPackageName(pkg) 10 + const packageName = v.parse(PackageNameSchema, pkg) 9 11 10 - try { 11 - return await fetchNpmPackage(pkg) 12 - } catch (error) { 13 - if (error && typeof error === 'object' && 'statusCode' in error) { 14 - throw error 15 - } 16 - throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' }) 12 + return await fetchNpmPackage(packageName) 13 + } catch (error: unknown) { 14 + handleApiError(error, { 15 + statusCode: 502, 16 + message: ERROR_NPM_FETCH_FAILED, 17 + }) 17 18 } 18 19 }, 19 20 { 20 - maxAge: 60 * 60, // 1 hour 21 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 21 22 swr: true, 22 - getKey: event => getRouterParam(event, 'pkg') ?? '', 23 + getKey: event => { 24 + const pkg = getRouterParam(event, 'pkg') ?? '' 25 + return `packument:v1:${pkg.replace(/\/+$/, '').trim()}` 26 + }, 23 27 }, 24 28 )
+24 -27
server/api/registry/analysis/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 1 3 import type { PackageAnalysis, ExtendedPackageJson } from '#shared/utils/package-analysis' 2 4 import { 3 5 analyzePackage, 4 6 getTypesPackageName, 5 7 hasBuiltInTypes, 6 8 } from '#shared/utils/package-analysis' 7 - 8 - const NPM_REGISTRY = 'https://registry.npmjs.org' 9 + import { 10 + NPM_REGISTRY, 11 + CACHE_MAX_AGE_ONE_DAY, 12 + ERROR_PACKAGE_ANALYSIS_FAILED, 13 + } from '#shared/utils/constants' 9 14 10 15 export default defineCachedEventHandler( 11 16 async event => { 12 - const pkgParam = getRouterParam(event, 'pkg') 13 - if (!pkgParam) { 14 - throw createError({ statusCode: 400, message: 'Package name is required' }) 15 - } 16 - 17 17 // Parse package name and optional version from path 18 18 // e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0" 19 - const segments = pkgParam.split('/') 20 - let packageName: string 21 - let version: string | undefined 19 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 22 20 23 - const vIndex = segments.indexOf('v') 24 - if (vIndex !== -1 && vIndex < segments.length - 1) { 25 - packageName = segments.slice(0, vIndex).join('/') 26 - version = segments.slice(vIndex + 1).join('/') 27 - } else { 28 - packageName = segments.join('/') 29 - } 21 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 30 22 31 23 try { 24 + const { packageName, version } = v.parse(PackageRouteParamsSchema, { 25 + packageName: rawPackageName, 26 + version: rawVersion, 27 + }) 28 + 32 29 // Fetch package data 33 30 const encodedName = encodePackageName(packageName) 34 31 const versionSuffix = version ? `/${version}` : '/latest' ··· 39 36 // Only check for @types package if the package doesn't ship its own types 40 37 let typesPackageExists = false 41 38 if (!hasBuiltInTypes(pkg)) { 42 - const typesPackageName = getTypesPackageName(packageName) 43 - typesPackageExists = await checkPackageExists(typesPackageName) 39 + const typesPkgName = getTypesPackageName(packageName) 40 + typesPackageExists = await checkPackageExists(typesPkgName) 44 41 } 45 42 46 43 const analysis = analyzePackage(pkg, { typesPackageExists }) ··· 50 47 version: pkg.version ?? version ?? 'latest', 51 48 ...analysis, 52 49 } satisfies PackageAnalysisResponse 53 - } catch (error) { 54 - if (error && typeof error === 'object' && 'statusCode' in error) { 55 - throw error 56 - } 57 - throw createError({ 50 + } catch (error: unknown) { 51 + handleApiError(error, { 58 52 statusCode: 502, 59 - message: 'Failed to analyze package', 53 + message: ERROR_PACKAGE_ANALYSIS_FAILED, 60 54 }) 61 55 } 62 56 }, 63 57 { 64 - maxAge: 60 * 60 * 24, // 24 hours - analysis rarely changes 58 + maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes 65 59 swr: true, 66 - getKey: event => getRouterParam(event, 'pkg') ?? '', 60 + getKey: event => { 61 + const pkg = getRouterParam(event, 'pkg') ?? '' 62 + return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}` 63 + }, 67 64 }, 68 65 ) 69 66
+39 -31
server/api/registry/file/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageFileQuerySchema } from '#shared/schemas/package' 3 + import { 4 + CACHE_MAX_AGE_ONE_YEAR, 5 + ERROR_PACKAGE_VERSION_AND_FILE_FAILED, 6 + } from '#shared/utils/constants' 7 + 1 8 const CACHE_VERSION = 2 2 9 3 10 // Maximum file size to fetch and highlight (500KB) ··· 50 57 if (response.status === 404) { 51 58 throw createError({ statusCode: 404, message: 'File not found' }) 52 59 } 53 - throw createError({ statusCode: 502, message: 'Failed to fetch file from jsDelivr' }) 60 + throw createError({ 61 + statusCode: 502, 62 + message: 'Failed to fetch file from jsDelivr', 63 + }) 54 64 } 55 65 56 66 // Check content-length header if available ··· 84 94 */ 85 95 export default defineCachedEventHandler( 86 96 async event => { 87 - const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] 88 - if (segments.length === 0) { 89 - throw createError({ 90 - statusCode: 400, 91 - message: 'Package name, version, and file path are required', 92 - }) 93 - } 94 - 95 97 // Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath] 96 - const vIndex = segments.indexOf('v') 97 - if (vIndex === -1 || vIndex >= segments.length - 2) { 98 - throw createError({ statusCode: 400, message: 'Version and file path are required' }) 99 - } 98 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 100 99 101 - const packageName = segments.slice(0, vIndex).join('/') 102 - // Find where version ends (next segment after 'v') and file path begins 103 - // Version could be like "1.2.3" or "1.2.3-beta.1" 104 - const versionAndPath = segments.slice(vIndex + 1) 100 + const { rawPackageName, rawVersion: fullPathAfterV } = parsePackageParams(pkgParamSegments) 105 101 106 - // The version is the first segment after 'v', and everything else is the file path 107 - const version = versionAndPath[0] 108 - const filePath = versionAndPath.slice(1).join('/') 102 + // Since version AND path route are required, we split the remainder 103 + // fullPathAfterV => "1.2.3/dist/index.mjs" 104 + const versionSegments = fullPathAfterV?.split('/') ?? [] 109 105 110 - if (!packageName || !version || !filePath) { 106 + if (versionSegments.length < 2) { 111 107 throw createError({ 112 108 statusCode: 400, 113 - message: 'Package name, version, and file path are required', 109 + message: ERROR_PACKAGE_VERSION_AND_FILE_FAILED, 114 110 }) 115 111 } 116 - assertValidPackageName(packageName) 112 + 113 + // The version is the first segment after 'v', and everything else is the file path 114 + const rawVersion = versionSegments[0] 115 + const rawFilePath = versionSegments.slice(1).join('/') 117 116 118 117 try { 118 + const { packageName, version, filePath } = v.parse(PackageFileQuerySchema, { 119 + packageName: rawPackageName, 120 + version: rawVersion, 121 + filePath: rawFilePath, 122 + }) 123 + 119 124 const content = await fetchFileContent(packageName, version, filePath) 120 125 const language = getLanguageFromPath(filePath) 121 126 ··· 156 161 } 157 162 } 158 163 159 - const html = await highlightCode(content, language, { dependencies, resolveRelative }) 164 + const html = await highlightCode(content, language, { 165 + dependencies, 166 + resolveRelative, 167 + }) 160 168 161 169 return { 162 170 package: packageName, ··· 167 175 html, 168 176 lines: content.split('\n').length, 169 177 } 170 - } catch (error) { 171 - if (error && typeof error === 'object' && 'statusCode' in error) { 172 - throw error 173 - } 174 - throw createError({ statusCode: 502, message: 'Failed to fetch file content' }) 178 + } catch (error: unknown) { 179 + handleApiError(error, { 180 + statusCode: 502, 181 + message: 'Failed to fetch file content', 182 + }) 175 183 } 176 184 }, 177 185 { 178 186 // File content for a specific version never changes - cache permanently 179 - maxAge: 60 * 60 * 24 * 365, // 1 year 187 + maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year 180 188 getKey: event => { 181 189 const pkg = getRouterParam(event, 'pkg') ?? '' 182 - return `file:v${CACHE_VERSION}:${pkg}` 190 + return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` 183 191 }, 184 192 }, 185 193 )
+18 -23
server/api/registry/files/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageVersionQuerySchema } from '#shared/schemas/package' 1 3 import type { PackageFileTreeResponse } from '#shared/types' 4 + import { CACHE_MAX_AGE_ONE_YEAR, ERROR_FILE_LIST_FETCH_FAILED } from '#shared/utils/constants' 2 5 3 6 /** 4 7 * Returns the file tree for a package version. ··· 9 12 */ 10 13 export default defineCachedEventHandler( 11 14 async event => { 12 - const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] 13 - if (segments.length === 0) { 14 - throw createError({ statusCode: 400, message: 'Package name and version are required' }) 15 - } 16 - 17 15 // Parse package name and version from URL segments 18 16 // Patterns: [pkg, 'v', version] or [@scope, pkg, 'v', version] 19 - const vIndex = segments.indexOf('v') 20 - if (vIndex === -1 || vIndex >= segments.length - 1) { 21 - throw createError({ statusCode: 400, message: 'Version is required (use /v/{version})' }) 22 - } 17 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 23 18 24 - const packageName = segments.slice(0, vIndex).join('/') 25 - const version = segments.slice(vIndex + 1).join('/') 19 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 26 20 27 - if (!packageName || !version) { 28 - throw createError({ statusCode: 400, message: 'Package name and version are required' }) 29 - } 30 - assertValidPackageName(packageName) 21 + try { 22 + const { packageName, version } = v.parse(PackageVersionQuerySchema, { 23 + packageName: rawPackageName, 24 + version: rawVersion, 25 + }) 31 26 32 - try { 33 27 const jsDelivrData = await fetchFileTree(packageName, version) 34 28 const tree = convertToFileTree(jsDelivrData.files) 35 29 ··· 39 33 default: jsDelivrData.default ?? undefined, 40 34 tree, 41 35 } satisfies PackageFileTreeResponse 42 - } catch (error) { 43 - if (error && typeof error === 'object' && 'statusCode' in error) { 44 - throw error 45 - } 46 - throw createError({ statusCode: 502, message: 'Failed to fetch file list' }) 36 + } catch (error: unknown) { 37 + handleApiError(error, { 38 + statusCode: 502, 39 + message: ERROR_FILE_LIST_FETCH_FAILED, 40 + }) 47 41 } 48 42 }, 49 43 { 50 44 // Files for a specific version never change - cache permanently 51 - maxAge: 60 * 60 * 24 * 365, // 1 year 45 + maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year 46 + swr: true, 52 47 getKey: event => { 53 48 const pkg = getRouterParam(event, 'pkg') ?? '' 54 - return `files:${pkg}` 49 + return `files:v1:${pkg.replace(/\/+$/, '').trim()}` 55 50 }, 56 51 }, 57 52 )
+28 -47
server/api/registry/install-size/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 3 + import { CACHE_MAX_AGE_ONE_HOUR, ERROR_CALC_INSTALL_SIZE_FAILED } from '#shared/utils/constants' 4 + 1 5 /** 2 6 * GET /api/registry/install-size/:name or /api/registry/install-size/:name/v/:version 3 7 * ··· 6 10 */ 7 11 export default defineCachedEventHandler( 8 12 async event => { 9 - const pkgParam = getRouterParam(event, 'pkg') 10 - if (!pkgParam) { 11 - throw createError({ statusCode: 400, message: 'Package name is required' }) 12 - } 13 - 14 13 // Parse package name and optional version from path segments 15 14 // Supports: /install-size/lodash, /install-size/lodash/v/4.17.21, /install-size/@scope/name, /install-size/@scope/name/v/1.0.0 16 - const segments = pkgParam.split('/') 17 - let packageName: string 18 - let requestedVersion: string | undefined 15 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 19 16 20 - if (segments[0]?.startsWith('@')) { 21 - // Scoped package: @scope/name or @scope/name/v/version 22 - if (segments.length < 2) { 23 - throw createError({ statusCode: 400, message: 'Invalid scoped package name' }) 24 - } 25 - packageName = `@${segments[0]?.slice(1)}/${segments[1]}` 26 - if (segments[2] === 'v' && segments[3]) { 27 - requestedVersion = segments[3] 28 - } 29 - } else { 30 - // Unscoped package: name or name/v/version 31 - packageName = segments[0] ?? '' 32 - if (segments[1] === 'v' && segments[2]) { 33 - requestedVersion = segments[2] 34 - } 35 - } 17 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 36 18 37 - if (!packageName) { 38 - throw createError({ statusCode: 400, message: 'Package name is required' }) 39 - } 40 - assertValidPackageName(packageName) 19 + try { 20 + const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { 21 + packageName: rawPackageName, 22 + version: rawVersion, 23 + }) 41 24 42 - // If no version specified, resolve to latest 43 - let version = requestedVersion 44 - if (!version) { 45 - try { 25 + // If no version specified, resolve to latest 26 + let version = requestedVersion 27 + if (!version) { 46 28 const packument = await fetchNpmPackage(packageName) 47 29 version = packument['dist-tags']?.latest 48 30 if (!version) { 49 - throw createError({ statusCode: 404, message: 'No latest version found' }) 31 + throw createError({ 32 + statusCode: 404, 33 + message: 'No latest version found', 34 + }) 50 35 } 51 - } catch (error) { 52 - if (error && typeof error === 'object' && 'statusCode' in error) { 53 - throw error 54 - } 55 - throw createError({ statusCode: 502, message: 'Failed to fetch package info' }) 56 36 } 57 - } 58 37 59 - try { 60 38 return await calculateInstallSize(packageName, version) 61 - } catch (error) { 62 - if (error && typeof error === 'object' && 'statusCode' in error) { 63 - throw error 64 - } 65 - throw createError({ statusCode: 502, message: 'Failed to calculate install size' }) 39 + } catch (error: unknown) { 40 + handleApiError(error, { 41 + statusCode: 502, 42 + message: ERROR_CALC_INSTALL_SIZE_FAILED, 43 + }) 66 44 } 67 45 }, 68 46 { 69 - maxAge: 60 * 60, // 1 hour 47 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 70 48 swr: true, 71 - getKey: event => getRouterParam(event, 'pkg') ?? '', 49 + getKey: event => { 50 + const pkg = getRouterParam(event, 'pkg') ?? '' 51 + return `install-size:v1:${pkg.replace(/\/+$/, '').trim()}` 52 + }, 72 53 }, 73 54 )
+24 -27
server/api/registry/readme/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 3 + import { 4 + CACHE_MAX_AGE_ONE_HOUR, 5 + NPM_MISSING_README_SENTINEL, 6 + ERROR_NPM_FETCH_FAILED, 7 + } from '#shared/utils/constants' 8 + 1 9 /** 2 10 * Fetch README from jsdelivr CDN for a specific package version. 3 11 * Falls back through common README filenames. ··· 35 43 */ 36 44 export default defineCachedEventHandler( 37 45 async event => { 38 - const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] 39 - if (segments.length === 0) { 40 - throw createError({ statusCode: 400, message: 'Package name is required' }) 41 - } 42 - 43 46 // Parse package name and optional version from URL segments 44 47 // Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version] 45 - let packageName: string 46 - let version: string | undefined 48 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 47 49 48 - const vIndex = segments.indexOf('v') 49 - if (vIndex !== -1 && vIndex < segments.length - 1) { 50 - packageName = segments.slice(0, vIndex).join('/') 51 - version = segments.slice(vIndex + 1).join('/') 52 - } else { 53 - packageName = segments.join('/') 54 - } 50 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 55 51 56 - if (!packageName) { 57 - throw createError({ statusCode: 400, message: 'Package name is required' }) 58 - } 59 - assertValidPackageName(packageName) 52 + try { 53 + // 1. Validate 54 + const { packageName, version } = v.parse(PackageRouteParamsSchema, { 55 + packageName: rawPackageName, 56 + version: rawVersion, 57 + }) 60 58 61 - try { 62 59 const packageData = await fetchNpmPackage(packageName) 63 60 64 61 let readmeContent: string | undefined ··· 75 72 } 76 73 77 74 // If no README in packument, try fetching from jsdelivr (package tarball) 78 - if (!readmeContent || readmeContent === 'ERROR: No README data found!') { 75 + if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) { 79 76 readmeContent = (await fetchReadmeFromJsdelivr(packageName, version)) ?? undefined 80 77 } 81 78 ··· 87 84 const repoInfo = parseRepositoryInfo(packageData.repository) 88 85 89 86 return await renderReadmeHtml(readmeContent, packageName, repoInfo) 90 - } catch (error) { 91 - if (error && typeof error === 'object' && 'statusCode' in error) { 92 - throw error 93 - } 94 - throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' }) 87 + } catch (error: unknown) { 88 + handleApiError(error, { 89 + statusCode: 502, 90 + message: ERROR_NPM_FETCH_FAILED, 91 + }) 95 92 } 96 93 }, 97 94 { 98 - maxAge: 60 * 60, // 1 hour 95 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 99 96 swr: true, 100 97 getKey: event => { 101 98 const pkg = getRouterParam(event, 'pkg') ?? '' 102 - return `readme:v2:${pkg}` 99 + return `readme:v3:${pkg.replace(/\/+$/, '').trim()}` 103 100 }, 104 101 }, 105 102 )
+28
server/utils/error-handler.ts
··· 1 + import { isError, createError } from 'h3' 2 + import * as v from 'valibot' 3 + import type { ErrorOptions } from '#shared/types/error' 4 + 5 + /** 6 + * Generic error handler for Nitro routes 7 + * Handles H3 errors, Valibot, and fallbacks in that order 8 + */ 9 + export function handleApiError(error: unknown, fallback: ErrorOptions): never { 10 + // If already a known Nuxt/H3 Error, re-throw 11 + if (isError(error)) { 12 + throw error 13 + } 14 + 15 + // Handle Valibot validation errors 16 + if (v.isValiError(error)) { 17 + throw createError({ 18 + statusCode: 400, 19 + message: error.issues[0].message, 20 + }) 21 + } 22 + 23 + // Generic fallback 24 + throw createError({ 25 + statusCode: fallback.statusCode ?? 502, 26 + message: fallback.message, 27 + }) 28 + }
+22
server/utils/parse-package-params.ts
··· 1 + /** 2 + * Parses Nitro router segments into packageName and an optional version 3 + * Handles patterns: [pkg], [pkg, 'v', version], [@scope, pkg], [@scope, pkg, 'v', version] 4 + */ 5 + export function parsePackageParams(segments: string[]): { 6 + rawPackageName: string 7 + rawVersion: string | undefined 8 + } { 9 + const vIndex = segments.indexOf('v') 10 + 11 + if (vIndex !== -1 && vIndex < segments.length - 1) { 12 + return { 13 + rawPackageName: segments.slice(0, vIndex).join('/'), 14 + rawVersion: segments.slice(vIndex + 1).join('/'), 15 + } 16 + } 17 + 18 + return { 19 + rawPackageName: segments.join('/'), 20 + rawVersion: undefined, 21 + } 22 + }
+69
shared/schemas/package.ts
··· 1 + import * as v from 'valibot' 2 + import validateNpmPackageName from 'validate-npm-package-name' 3 + 4 + /** 5 + * Enforces only valid NPM package names 6 + * Leverages 'validate-npm-package-name' 7 + */ 8 + export const PackageNameSchema = v.pipe( 9 + v.string(), 10 + v.nonEmpty('Package name is required'), 11 + v.check(input => { 12 + const result = validateNpmPackageName(input) 13 + return result.validForNewPackages || result.validForOldPackages 14 + }, 'Invalid package name format'), 15 + ) 16 + 17 + /** 18 + * Enforces a SemVer-like pattern to prevent directory traversal or complex injection attacks 19 + * includes: alphanumeric, dots, underscores, dashes, and plus signs (for build metadata) 20 + */ 21 + export const VersionSchema = v.pipe( 22 + v.string(), 23 + v.nonEmpty('Version is required'), 24 + v.regex(/^[a-z0-9._+-]+$/i, 'Invalid version format'), 25 + ) 26 + 27 + /** 28 + * 29 + * Allows standard subdirectories and extensions but prevents directory traversal 30 + */ 31 + export const FilePathSchema = v.pipe( 32 + v.string(), 33 + v.nonEmpty('File path is required'), 34 + v.check(input => !input.includes('..'), 'Invalid path: directory traversal not allowed'), 35 + v.check(input => !input.startsWith('/'), 'Invalid path: must be relative to package root'), 36 + ) 37 + 38 + /** 39 + * Schema for package fetching where version is not required 40 + */ 41 + export const PackageRouteParamsSchema = v.object({ 42 + packageName: PackageNameSchema, 43 + version: v.optional(VersionSchema), 44 + }) 45 + 46 + /** 47 + * Schema for package fetching where packageName and version are required 48 + */ 49 + export const PackageVersionQuerySchema = v.object({ 50 + packageName: PackageNameSchema, 51 + version: VersionSchema, 52 + }) 53 + 54 + /** 55 + * Schema for file fetching where version and filePath are required 56 + */ 57 + export const PackageFileQuerySchema = v.object({ 58 + packageName: PackageNameSchema, 59 + version: VersionSchema, 60 + filePath: FilePathSchema, 61 + }) 62 + 63 + /** 64 + * Automatically infer types for routes 65 + * Usage - prefer this over manually defining interfaces 66 + */ 67 + export type PackageRouteParams = v.InferOutput<typeof PackageRouteParamsSchema> 68 + export type PackageVersionQuery = v.InferOutput<typeof PackageVersionQuerySchema> 69 + export type PackageFileQuery = v.InferOutput<typeof PackageFileQuerySchema>
+4
shared/types/error.ts
··· 1 + export interface ErrorOptions { 2 + message: string 3 + statusCode?: number 4 + }
+16
shared/utils/constants.ts
··· 1 + // Duration 2 + export const CACHE_MAX_AGE_ONE_HOUR = 60 * 60 3 + export const CACHE_MAX_AGE_ONE_DAY = 60 * 60 * 24 4 + export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365 5 + 6 + // API Strings 7 + export const NPM_REGISTRY = 'https://registry.npmjs.org' 8 + export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' 9 + export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' 10 + export const ERROR_PACKAGE_REQUIREMENTS_FAILED = 11 + 'Package name, version, and file path are required.' 12 + export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.' 13 + export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.' 14 + export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!' 15 + export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry.' 16 + export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'