Skip to content

Commit 7b488a3

Browse files
committed
feat: add valibot & implement validation/error handling for BE APIs
1 parent 0a8cd70 commit 7b488a3

14 files changed

Lines changed: 312 additions & 176 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"shiki": "^3.21.0",
4444
"ufo": "^1.6.3",
4545
"unplugin-vue-router": "^0.19.2",
46+
"valibot": "^1.2.0",
4647
"validate-npm-package-name": "^7.0.2",
4748
"virtua": "^0.48.3",
4849
"vue": "3.5.27",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/api/jsr/[...pkg].get.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
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'
14
import type { JsrPackageInfo } from '#shared/types/jsr'
25

36
/**
@@ -11,17 +14,25 @@ import type { JsrPackageInfo } from '#shared/types/jsr'
1114
export default defineCachedEventHandler<Promise<JsrPackageInfo>>(
1215
async event => {
1316
const pkgPath = getRouterParam(event, 'pkg')
14-
if (!pkgPath) {
15-
throw createError({ statusCode: 400, message: 'Package name is required' })
16-
}
17-
assertValidPackageName(pkgPath)
1817

19-
return await fetchJsrPackageInfo(pkgPath)
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+
})
27+
}
2028
},
2129
{
22-
maxAge: 60 * 60, // 1 hour
30+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
2331
swr: true,
2432
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+
},
2637
},
2738
)
Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
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+
15
export default defineCachedEventHandler(
26
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')
79

8-
const packageName = pkg.replace(/\//g, '/')
9-
assertValidPackageName(packageName)
10+
const packageName = v.parse(PackageNameSchema, pkg)
1011

11-
try {
1212
return await fetchNpmPackage(packageName)
13-
} catch (error) {
14-
if (error && typeof error === 'object' && 'statusCode' in error) {
15-
throw error
16-
}
17-
throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' })
13+
} catch (error: unknown) {
14+
handleApiError(error, {
15+
statusCode: 502,
16+
message: ERROR_NPM_FETCH_FAILED,
17+
})
1818
}
1919
},
2020
{
21-
maxAge: 60 * 60, // 1 hour
21+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
2222
swr: true,
23-
getKey: event => getRouterParam(event, 'pkg') ?? '',
23+
getKey: event => {
24+
const pkg = getRouterParam(event, 'pkg') ?? ''
25+
return `packument:v1:${pkg.replace(/\/+$/, '').trim()}`
26+
},
2427
},
2528
)

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

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
1+
import * as v from 'valibot'
2+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
13
import type { PackageAnalysis, ExtendedPackageJson } from '#shared/utils/package-analysis'
24
import {
35
analyzePackage,
46
getTypesPackageName,
57
hasBuiltInTypes,
68
} 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'
914

1015
export default defineCachedEventHandler(
1116
async event => {
12-
const pkgParam = getRouterParam(event, 'pkg')
13-
if (!pkgParam) {
14-
throw createError({ statusCode: 400, message: 'Package name is required' })
15-
}
16-
1717
// Parse package name and optional version from path
1818
// 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('/') ?? []
2220

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

3123
try {
24+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
25+
packageName: rawPackageName,
26+
version: rawVersion,
27+
})
28+
3229
// Fetch package data
3330
const encodedName = encodePackageName(packageName)
3431
const versionSuffix = version ? `/${version}` : '/latest'
@@ -39,8 +36,8 @@ export default defineCachedEventHandler(
3936
// Only check for @types package if the package doesn't ship its own types
4037
let typesPackageExists = false
4138
if (!hasBuiltInTypes(pkg)) {
42-
const typesPackageName = getTypesPackageName(packageName)
43-
typesPackageExists = await checkPackageExists(typesPackageName)
39+
const typesPkgName = getTypesPackageName(packageName)
40+
typesPackageExists = await checkPackageExists(typesPkgName)
4441
}
4542

4643
const analysis = analyzePackage(pkg, { typesPackageExists })
@@ -50,20 +47,20 @@ export default defineCachedEventHandler(
5047
version: pkg.version ?? version ?? 'latest',
5148
...analysis,
5249
} 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, {
5852
statusCode: 502,
59-
message: 'Failed to analyze package',
53+
message: ERROR_PACKAGE_ANALYSIS_FAILED,
6054
})
6155
}
6256
},
6357
{
64-
maxAge: 60 * 60 * 24, // 24 hours - analysis rarely changes
58+
maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes
6559
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+
},
6764
},
6865
)
6966

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

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
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+
18
const CACHE_VERSION = 2
29

310
// Maximum file size to fetch and highlight (500KB)
@@ -50,7 +57,10 @@ async function fetchFileContent(
5057
if (response.status === 404) {
5158
throw createError({ statusCode: 404, message: 'File not found' })
5259
}
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+
})
5464
}
5565

5666
// Check content-length header if available
@@ -84,38 +94,33 @@ async function fetchFileContent(
8494
*/
8595
export default defineCachedEventHandler(
8696
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-
9597
// 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('/') ?? []
10099

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

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('/') ?? []
109105

110-
if (!packageName || !version || !filePath) {
106+
if (versionSegments.length < 2) {
111107
throw createError({
112108
statusCode: 400,
113-
message: 'Package name, version, and file path are required',
109+
message: ERROR_PACKAGE_VERSION_AND_FILE_FAILED,
114110
})
115111
}
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('/')
117116

118117
try {
118+
const { packageName, version, filePath } = v.parse(PackageFileQuerySchema, {
119+
packageName: rawPackageName,
120+
version: rawVersion,
121+
filePath: rawFilePath,
122+
})
123+
119124
const content = await fetchFileContent(packageName, version, filePath)
120125
const language = getLanguageFromPath(filePath)
121126

@@ -156,7 +161,10 @@ export default defineCachedEventHandler(
156161
}
157162
}
158163

159-
const html = await highlightCode(content, language, { dependencies, resolveRelative })
164+
const html = await highlightCode(content, language, {
165+
dependencies,
166+
resolveRelative,
167+
})
160168

161169
return {
162170
package: packageName,
@@ -167,19 +175,19 @@ export default defineCachedEventHandler(
167175
html,
168176
lines: content.split('\n').length,
169177
}
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+
})
175183
}
176184
},
177185
{
178186
// 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
180188
getKey: event => {
181189
const pkg = getRouterParam(event, 'pkg') ?? ''
182-
return `file:v${CACHE_VERSION}:${pkg}`
190+
return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}`
183191
},
184192
},
185193
)
Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import * as v from 'valibot'
2+
import { PackageVersionQuerySchema } from '#shared/schemas/package'
13
import type { PackageFileTreeResponse } from '#shared/types'
4+
import { CACHE_MAX_AGE_ONE_YEAR, ERROR_FILE_LIST_FETCH_FAILED } from '#shared/utils/constants'
25

36
/**
47
* Returns the file tree for a package version.
@@ -9,27 +12,18 @@ import type { PackageFileTreeResponse } from '#shared/types'
912
*/
1013
export default defineCachedEventHandler(
1114
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-
1715
// Parse package name and version from URL segments
1816
// 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('/') ?? []
2318

24-
const packageName = segments.slice(0, vIndex).join('/')
25-
const version = segments.slice(vIndex + 1).join('/')
26-
27-
if (!packageName || !version) {
28-
throw createError({ statusCode: 400, message: 'Package name and version are required' })
29-
}
30-
assertValidPackageName(packageName)
19+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
3120

3221
try {
22+
const { packageName, version } = v.parse(PackageVersionQuerySchema, {
23+
packageName: rawPackageName,
24+
version: rawVersion,
25+
})
26+
3327
const jsDelivrData = await fetchFileTree(packageName, version)
3428
const tree = convertToFileTree(jsDelivrData.files)
3529

@@ -39,19 +33,20 @@ export default defineCachedEventHandler(
3933
default: jsDelivrData.default ?? undefined,
4034
tree,
4135
} 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+
})
4741
}
4842
},
4943
{
5044
// 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,
5247
getKey: event => {
5348
const pkg = getRouterParam(event, 'pkg') ?? ''
54-
return `files:${pkg}`
49+
return `files:v1:${pkg.replace(/\/+$/, '').trim()}`
5550
},
5651
},
5752
)

0 commit comments

Comments
 (0)