diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d8568ac87..d8fcc41d1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,6 +110,59 @@ The connector will check your npm authentication, generate a connection token, a - We care about good types – never cast things to `any` 💪 - Validate rather than just assert +### Server API patterns + +#### Input validation with Valibot + +Use Valibot schemas from `#shared/schemas/` to validate API inputs. This ensures type safety and provides consistent error messages: + +```typescript +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' + +// In your handler: +const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, +}) +``` + +#### Error handling with `handleApiError` + +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: + +```typescript +import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' + +try { + // API logic... +} catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_NPM_FETCH_FAILED, + }) +} +``` + +#### URL parameter parsing with `parsePackageParams` + +Use `parsePackageParams` to extract package name and version from URL segments: + +```typescript +const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] +const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) +``` + +This handles patterns like `/pkg`, `/pkg/v/1.0.0`, `/@scope/pkg`, and `/@scope/pkg/v/1.0.0`. + +#### Constants + +Define error messages and other string constants in `#shared/utils/constants.ts` to ensure consistency across the codebase: + +```typescript +export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.' +``` + ### Import order 1. Type imports first (`import type { ... }`) diff --git a/package.json b/package.json index b54b6876c5..6e02c01f87 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "shiki": "^3.21.0", "ufo": "^1.6.3", "unplugin-vue-router": "^0.19.2", + "valibot": "^1.2.0", "validate-npm-package-name": "^7.0.2", "virtua": "^0.48.3", "vue": "3.5.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c71667b417..53d9a35cfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: unplugin-vue-router: specifier: ^0.19.2 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)) + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) validate-npm-package-name: specifier: ^7.0.2 version: 7.0.2 diff --git a/server/api/jsr/[...pkg].get.ts b/server/api/jsr/[...pkg].get.ts index 3dac476ec4..33bf631e66 100644 --- a/server/api/jsr/[...pkg].get.ts +++ b/server/api/jsr/[...pkg].get.ts @@ -1,3 +1,6 @@ +import * as v from 'valibot' +import { PackageNameSchema } from '#shared/schemas/package' +import { CACHE_MAX_AGE_ONE_HOUR, ERROR_JSR_FETCH_FAILED } from '#shared/utils/constants' import type { JsrPackageInfo } from '#shared/types/jsr' /** @@ -11,17 +14,25 @@ import type { JsrPackageInfo } from '#shared/types/jsr' export default defineCachedEventHandler>( async event => { const pkgPath = getRouterParam(event, 'pkg') - if (!pkgPath) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } - assertValidPackageName(pkgPath) - return await fetchJsrPackageInfo(pkgPath) + try { + const packageName = v.parse(PackageNameSchema, pkgPath) + + return await fetchJsrPackageInfo(packageName) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_JSR_FETCH_FAILED, + }) + } }, { - maxAge: 60 * 60, // 1 hour + maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, name: 'api-jsr-package', - getKey: event => getRouterParam(event, 'pkg') ?? '', + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `jsr:v1:${pkg.replace(/\/+$/, '').trim()}` + }, }, ) diff --git a/server/api/registry/[...pkg].get.ts b/server/api/registry/[...pkg].get.ts index 1d53e61a03..21ddafaf51 100644 --- a/server/api/registry/[...pkg].get.ts +++ b/server/api/registry/[...pkg].get.ts @@ -1,24 +1,28 @@ +import * as v from 'valibot' +import { PackageNameSchema } from '#shared/schemas/package' +import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' + export default defineCachedEventHandler( async event => { - const pkg = getRouterParam(event, 'pkg') - if (!pkg) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } + try { + const pkg = getRouterParam(event, 'pkg') - assertValidPackageName(pkg) + const packageName = v.parse(PackageNameSchema, pkg) - try { - return await fetchNpmPackage(pkg) - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' }) + return await fetchNpmPackage(packageName) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_NPM_FETCH_FAILED, + }) } }, { - maxAge: 60 * 60, // 1 hour + maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, - getKey: event => getRouterParam(event, 'pkg') ?? '', + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `packument:v1:${pkg.replace(/\/+$/, '').trim()}` + }, }, ) diff --git a/server/api/registry/analysis/[...pkg].get.ts b/server/api/registry/analysis/[...pkg].get.ts index d2becbc605..646d3a7c6a 100644 --- a/server/api/registry/analysis/[...pkg].get.ts +++ b/server/api/registry/analysis/[...pkg].get.ts @@ -1,34 +1,31 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' import type { PackageAnalysis, ExtendedPackageJson } from '#shared/utils/package-analysis' import { analyzePackage, getTypesPackageName, hasBuiltInTypes, } from '#shared/utils/package-analysis' - -const NPM_REGISTRY = 'https://registry.npmjs.org' +import { + NPM_REGISTRY, + CACHE_MAX_AGE_ONE_DAY, + ERROR_PACKAGE_ANALYSIS_FAILED, +} from '#shared/utils/constants' export default defineCachedEventHandler( async event => { - const pkgParam = getRouterParam(event, 'pkg') - if (!pkgParam) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } - // Parse package name and optional version from path // e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0" - const segments = pkgParam.split('/') - let packageName: string - let version: string | undefined + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] - const vIndex = segments.indexOf('v') - if (vIndex !== -1 && vIndex < segments.length - 1) { - packageName = segments.slice(0, vIndex).join('/') - version = segments.slice(vIndex + 1).join('/') - } else { - packageName = segments.join('/') - } + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) try { + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + // Fetch package data const encodedName = encodePackageName(packageName) const versionSuffix = version ? `/${version}` : '/latest' @@ -39,8 +36,8 @@ export default defineCachedEventHandler( // Only check for @types package if the package doesn't ship its own types let typesPackageExists = false if (!hasBuiltInTypes(pkg)) { - const typesPackageName = getTypesPackageName(packageName) - typesPackageExists = await checkPackageExists(typesPackageName) + const typesPkgName = getTypesPackageName(packageName) + typesPackageExists = await checkPackageExists(typesPkgName) } const analysis = analyzePackage(pkg, { typesPackageExists }) @@ -50,20 +47,20 @@ export default defineCachedEventHandler( version: pkg.version ?? version ?? 'latest', ...analysis, } satisfies PackageAnalysisResponse - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - throw createError({ + } catch (error: unknown) { + handleApiError(error, { statusCode: 502, - message: 'Failed to analyze package', + message: ERROR_PACKAGE_ANALYSIS_FAILED, }) } }, { - maxAge: 60 * 60 * 24, // 24 hours - analysis rarely changes + maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes swr: true, - getKey: event => getRouterParam(event, 'pkg') ?? '', + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}` + }, }, ) diff --git a/server/api/registry/file/[...pkg].get.ts b/server/api/registry/file/[...pkg].get.ts index 0acd11541b..028469e439 100644 --- a/server/api/registry/file/[...pkg].get.ts +++ b/server/api/registry/file/[...pkg].get.ts @@ -1,3 +1,10 @@ +import * as v from 'valibot' +import { PackageFileQuerySchema } from '#shared/schemas/package' +import { + CACHE_MAX_AGE_ONE_YEAR, + ERROR_PACKAGE_VERSION_AND_FILE_FAILED, +} from '#shared/utils/constants' + const CACHE_VERSION = 2 // Maximum file size to fetch and highlight (500KB) @@ -50,7 +57,10 @@ async function fetchFileContent( if (response.status === 404) { throw createError({ statusCode: 404, message: 'File not found' }) } - throw createError({ statusCode: 502, message: 'Failed to fetch file from jsDelivr' }) + throw createError({ + statusCode: 502, + message: 'Failed to fetch file from jsDelivr', + }) } // Check content-length header if available @@ -84,38 +94,33 @@ async function fetchFileContent( */ export default defineCachedEventHandler( async event => { - const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] - if (segments.length === 0) { - throw createError({ - statusCode: 400, - message: 'Package name, version, and file path are required', - }) - } - // Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath] - const vIndex = segments.indexOf('v') - if (vIndex === -1 || vIndex >= segments.length - 2) { - throw createError({ statusCode: 400, message: 'Version and file path are required' }) - } + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] - const packageName = segments.slice(0, vIndex).join('/') - // Find where version ends (next segment after 'v') and file path begins - // Version could be like "1.2.3" or "1.2.3-beta.1" - const versionAndPath = segments.slice(vIndex + 1) + const { rawPackageName, rawVersion: fullPathAfterV } = parsePackageParams(pkgParamSegments) - // The version is the first segment after 'v', and everything else is the file path - const version = versionAndPath[0] - const filePath = versionAndPath.slice(1).join('/') + // Since version AND path route are required, we split the remainder + // fullPathAfterV => "1.2.3/dist/index.mjs" + const versionSegments = fullPathAfterV?.split('/') ?? [] - if (!packageName || !version || !filePath) { + if (versionSegments.length < 2) { throw createError({ statusCode: 400, - message: 'Package name, version, and file path are required', + message: ERROR_PACKAGE_VERSION_AND_FILE_FAILED, }) } - assertValidPackageName(packageName) + + // The version is the first segment after 'v', and everything else is the file path + const rawVersion = versionSegments[0] + const rawFilePath = versionSegments.slice(1).join('/') try { + const { packageName, version, filePath } = v.parse(PackageFileQuerySchema, { + packageName: rawPackageName, + version: rawVersion, + filePath: rawFilePath, + }) + const content = await fetchFileContent(packageName, version, filePath) const language = getLanguageFromPath(filePath) @@ -156,7 +161,10 @@ export default defineCachedEventHandler( } } - const html = await highlightCode(content, language, { dependencies, resolveRelative }) + const html = await highlightCode(content, language, { + dependencies, + resolveRelative, + }) return { package: packageName, @@ -167,19 +175,19 @@ export default defineCachedEventHandler( html, lines: content.split('\n').length, } - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - throw createError({ statusCode: 502, message: 'Failed to fetch file content' }) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to fetch file content', + }) } }, { // File content for a specific version never changes - cache permanently - maxAge: 60 * 60 * 24 * 365, // 1 year + maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `file:v${CACHE_VERSION}:${pkg}` + return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` }, }, ) diff --git a/server/api/registry/files/[...pkg].get.ts b/server/api/registry/files/[...pkg].get.ts index 5106336acf..467ae9de5e 100644 --- a/server/api/registry/files/[...pkg].get.ts +++ b/server/api/registry/files/[...pkg].get.ts @@ -1,4 +1,7 @@ +import * as v from 'valibot' +import { PackageVersionQuerySchema } from '#shared/schemas/package' import type { PackageFileTreeResponse } from '#shared/types' +import { CACHE_MAX_AGE_ONE_YEAR, ERROR_FILE_LIST_FETCH_FAILED } from '#shared/utils/constants' /** * Returns the file tree for a package version. @@ -9,27 +12,18 @@ import type { PackageFileTreeResponse } from '#shared/types' */ export default defineCachedEventHandler( async event => { - const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] - if (segments.length === 0) { - throw createError({ statusCode: 400, message: 'Package name and version are required' }) - } - // Parse package name and version from URL segments // Patterns: [pkg, 'v', version] or [@scope, pkg, 'v', version] - const vIndex = segments.indexOf('v') - if (vIndex === -1 || vIndex >= segments.length - 1) { - throw createError({ statusCode: 400, message: 'Version is required (use /v/{version})' }) - } + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] - const packageName = segments.slice(0, vIndex).join('/') - const version = segments.slice(vIndex + 1).join('/') - - if (!packageName || !version) { - throw createError({ statusCode: 400, message: 'Package name and version are required' }) - } - assertValidPackageName(packageName) + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) try { + const { packageName, version } = v.parse(PackageVersionQuerySchema, { + packageName: rawPackageName, + version: rawVersion, + }) + const jsDelivrData = await fetchFileTree(packageName, version) const tree = convertToFileTree(jsDelivrData.files) @@ -39,19 +33,20 @@ export default defineCachedEventHandler( default: jsDelivrData.default ?? undefined, tree, } satisfies PackageFileTreeResponse - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - throw createError({ statusCode: 502, message: 'Failed to fetch file list' }) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_FILE_LIST_FETCH_FAILED, + }) } }, { // Files for a specific version never change - cache permanently - maxAge: 60 * 60 * 24 * 365, // 1 year + maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year + swr: true, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `files:${pkg}` + return `files:v1:${pkg.replace(/\/+$/, '').trim()}` }, }, ) diff --git a/server/api/registry/install-size/[...pkg].get.ts b/server/api/registry/install-size/[...pkg].get.ts index 4f7bd52abe..ad5d2ee999 100644 --- a/server/api/registry/install-size/[...pkg].get.ts +++ b/server/api/registry/install-size/[...pkg].get.ts @@ -1,3 +1,7 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { CACHE_MAX_AGE_ONE_HOUR, ERROR_CALC_INSTALL_SIZE_FAILED } from '#shared/utils/constants' + /** * GET /api/registry/install-size/:name or /api/registry/install-size/:name/v/:version * @@ -6,68 +10,45 @@ */ export default defineCachedEventHandler( async event => { - const pkgParam = getRouterParam(event, 'pkg') - if (!pkgParam) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } - // Parse package name and optional version from path segments // Supports: /install-size/lodash, /install-size/lodash/v/4.17.21, /install-size/@scope/name, /install-size/@scope/name/v/1.0.0 - const segments = pkgParam.split('/') - let packageName: string - let requestedVersion: string | undefined + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] - if (segments[0]?.startsWith('@')) { - // Scoped package: @scope/name or @scope/name/v/version - if (segments.length < 2) { - throw createError({ statusCode: 400, message: 'Invalid scoped package name' }) - } - packageName = `@${segments[0]?.slice(1)}/${segments[1]}` - if (segments[2] === 'v' && segments[3]) { - requestedVersion = segments[3] - } - } else { - // Unscoped package: name or name/v/version - packageName = segments[0] ?? '' - if (segments[1] === 'v' && segments[2]) { - requestedVersion = segments[2] - } - } + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) - if (!packageName) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } - assertValidPackageName(packageName) + try { + const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) - // If no version specified, resolve to latest - let version = requestedVersion - if (!version) { - try { + // If no version specified, resolve to latest + let version = requestedVersion + if (!version) { const packument = await fetchNpmPackage(packageName) version = packument['dist-tags']?.latest if (!version) { - throw createError({ statusCode: 404, message: 'No latest version found' }) - } - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error + throw createError({ + statusCode: 404, + message: 'No latest version found', + }) } - throw createError({ statusCode: 502, message: 'Failed to fetch package info' }) } - } - try { return await calculateInstallSize(packageName, version) - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - throw createError({ statusCode: 502, message: 'Failed to calculate install size' }) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_CALC_INSTALL_SIZE_FAILED, + }) } }, { - maxAge: 60 * 60, // 1 hour + maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, - getKey: event => getRouterParam(event, 'pkg') ?? '', + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `install-size:v1:${pkg.replace(/\/+$/, '').trim()}` + }, }, ) diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts index 9450dd3438..b87ccc955b 100644 --- a/server/api/registry/readme/[...pkg].get.ts +++ b/server/api/registry/readme/[...pkg].get.ts @@ -1,3 +1,11 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { + CACHE_MAX_AGE_ONE_HOUR, + NPM_MISSING_README_SENTINEL, + ERROR_NPM_FETCH_FAILED, +} from '#shared/utils/constants' + /** * Fetch README from jsdelivr CDN for a specific package version. * Falls back through common README filenames. @@ -35,30 +43,19 @@ async function fetchReadmeFromJsdelivr( */ export default defineCachedEventHandler( async event => { - const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] - if (segments.length === 0) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } - // Parse package name and optional version from URL segments // Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version] - let packageName: string - let version: string | undefined - - const vIndex = segments.indexOf('v') - if (vIndex !== -1 && vIndex < segments.length - 1) { - packageName = segments.slice(0, vIndex).join('/') - version = segments.slice(vIndex + 1).join('/') - } else { - packageName = segments.join('/') - } + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] - if (!packageName) { - throw createError({ statusCode: 400, message: 'Package name is required' }) - } - assertValidPackageName(packageName) + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) try { + // 1. Validate + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + const packageData = await fetchNpmPackage(packageName) let readmeContent: string | undefined @@ -75,7 +72,7 @@ export default defineCachedEventHandler( } // If no README in packument, try fetching from jsdelivr (package tarball) - if (!readmeContent || readmeContent === 'ERROR: No README data found!') { + if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) { readmeContent = (await fetchReadmeFromJsdelivr(packageName, version)) ?? undefined } @@ -87,19 +84,19 @@ export default defineCachedEventHandler( const repoInfo = parseRepositoryInfo(packageData.repository) return await renderReadmeHtml(readmeContent, packageName, repoInfo) - } catch (error) { - if (error && typeof error === 'object' && 'statusCode' in error) { - throw error - } - throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' }) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_NPM_FETCH_FAILED, + }) } }, { - maxAge: 60 * 60, // 1 hour + maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `readme:v2:${pkg}` + return `readme:v3:${pkg.replace(/\/+$/, '').trim()}` }, }, ) diff --git a/server/utils/error-handler.ts b/server/utils/error-handler.ts new file mode 100644 index 0000000000..75d9f67164 --- /dev/null +++ b/server/utils/error-handler.ts @@ -0,0 +1,28 @@ +import { isError, createError } from 'h3' +import * as v from 'valibot' +import type { ErrorOptions } from '#shared/types/error' + +/** + * Generic error handler for Nitro routes + * Handles H3 errors, Valibot, and fallbacks in that order + */ +export function handleApiError(error: unknown, fallback: ErrorOptions): never { + // If already a known Nuxt/H3 Error, re-throw + if (isError(error)) { + throw error + } + + // Handle Valibot validation errors + if (v.isValiError(error)) { + throw createError({ + statusCode: 400, + message: error.issues[0].message, + }) + } + + // Generic fallback + throw createError({ + statusCode: fallback.statusCode ?? 502, + message: fallback.message, + }) +} diff --git a/server/utils/parse-package-params.ts b/server/utils/parse-package-params.ts new file mode 100644 index 0000000000..4eb44f91f3 --- /dev/null +++ b/server/utils/parse-package-params.ts @@ -0,0 +1,22 @@ +/** + * Parses Nitro router segments into packageName and an optional version + * Handles patterns: [pkg], [pkg, 'v', version], [@scope, pkg], [@scope, pkg, 'v', version] + */ +export function parsePackageParams(segments: string[]): { + rawPackageName: string + rawVersion: string | undefined +} { + const vIndex = segments.indexOf('v') + + if (vIndex !== -1 && vIndex < segments.length - 1) { + return { + rawPackageName: segments.slice(0, vIndex).join('/'), + rawVersion: segments.slice(vIndex + 1).join('/'), + } + } + + return { + rawPackageName: segments.join('/'), + rawVersion: undefined, + } +} diff --git a/shared/schemas/package.ts b/shared/schemas/package.ts new file mode 100644 index 0000000000..d4d9fc730b --- /dev/null +++ b/shared/schemas/package.ts @@ -0,0 +1,69 @@ +import * as v from 'valibot' +import validateNpmPackageName from 'validate-npm-package-name' + +/** + * Enforces only valid NPM package names + * Leverages 'validate-npm-package-name' + */ +export const PackageNameSchema = v.pipe( + v.string(), + v.nonEmpty('Package name is required'), + v.check(input => { + const result = validateNpmPackageName(input) + return result.validForNewPackages || result.validForOldPackages + }, 'Invalid package name format'), +) + +/** + * Enforces a SemVer-like pattern to prevent directory traversal or complex injection attacks + * includes: alphanumeric, dots, underscores, dashes, and plus signs (for build metadata) + */ +export const VersionSchema = v.pipe( + v.string(), + v.nonEmpty('Version is required'), + v.regex(/^[a-z0-9._+-]+$/i, 'Invalid version format'), +) + +/** + * + * Allows standard subdirectories and extensions but prevents directory traversal + */ +export const FilePathSchema = v.pipe( + v.string(), + v.nonEmpty('File path is required'), + v.check(input => !input.includes('..'), 'Invalid path: directory traversal not allowed'), + v.check(input => !input.startsWith('/'), 'Invalid path: must be relative to package root'), +) + +/** + * Schema for package fetching where version is not required + */ +export const PackageRouteParamsSchema = v.object({ + packageName: PackageNameSchema, + version: v.optional(VersionSchema), +}) + +/** + * Schema for package fetching where packageName and version are required + */ +export const PackageVersionQuerySchema = v.object({ + packageName: PackageNameSchema, + version: VersionSchema, +}) + +/** + * Schema for file fetching where version and filePath are required + */ +export const PackageFileQuerySchema = v.object({ + packageName: PackageNameSchema, + version: VersionSchema, + filePath: FilePathSchema, +}) + +/** + * Automatically infer types for routes + * Usage - prefer this over manually defining interfaces + */ +export type PackageRouteParams = v.InferOutput +export type PackageVersionQuery = v.InferOutput +export type PackageFileQuery = v.InferOutput diff --git a/shared/types/error.ts b/shared/types/error.ts new file mode 100644 index 0000000000..0736c27da1 --- /dev/null +++ b/shared/types/error.ts @@ -0,0 +1,4 @@ +export interface ErrorOptions { + message: string + statusCode?: number +} diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts new file mode 100644 index 0000000000..5ec5f49200 --- /dev/null +++ b/shared/utils/constants.ts @@ -0,0 +1,16 @@ +// Duration +export const CACHE_MAX_AGE_ONE_HOUR = 60 * 60 +export const CACHE_MAX_AGE_ONE_DAY = 60 * 60 * 24 +export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365 + +// API Strings +export const NPM_REGISTRY = 'https://registry.npmjs.org' +export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' +export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' +export const ERROR_PACKAGE_REQUIREMENTS_FAILED = + 'Package name, version, and file path are required.' +export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.' +export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.' +export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!' +export const ERROR_JSR_FETCH_FAILED = 'Failed to fetch package from JSR registry.' +export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'