@vltpkg/vsr 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +13 -0
- package/.prettierrc +7 -0
- package/CONTRIBUTING.md +228 -0
- package/LICENSE.md +110 -0
- package/README.md +373 -0
- package/bin/vsr.ts +29 -0
- package/config.ts +124 -0
- package/debug-npm.js +19 -0
- package/drizzle.config.js +33 -0
- package/package.json +80 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/api.ts +2246 -0
- package/src/assets/public/images/bg.png +0 -0
- package/src/assets/public/images/clients/logo-bun.png +0 -0
- package/src/assets/public/images/clients/logo-deno.png +0 -0
- package/src/assets/public/images/clients/logo-npm.png +0 -0
- package/src/assets/public/images/clients/logo-pnpm.png +0 -0
- package/src/assets/public/images/clients/logo-vlt.png +0 -0
- package/src/assets/public/images/clients/logo-yarn.png +0 -0
- package/src/assets/public/images/favicon/apple-touch-icon.png +0 -0
- package/src/assets/public/images/favicon/favicon-96x96.png +0 -0
- package/src/assets/public/images/favicon/favicon.ico +0 -0
- package/src/assets/public/images/favicon/favicon.svg +3 -0
- package/src/assets/public/images/favicon/site.webmanifest +21 -0
- package/src/assets/public/images/favicon/web-app-manifest-192x192.png +0 -0
- package/src/assets/public/images/favicon/web-app-manifest-512x512.png +0 -0
- package/src/assets/public/styles/styles.css +219 -0
- package/src/db/client.ts +544 -0
- package/src/db/migrations/0000_faulty_ricochet.sql +14 -0
- package/src/db/migrations/0000_initial.sql +29 -0
- package/src/db/migrations/0001_uuid_validation.sql +35 -0
- package/src/db/migrations/0001_wealthy_magdalene.sql +7 -0
- package/src/db/migrations/drop.sql +3 -0
- package/src/db/migrations/meta/0000_snapshot.json +104 -0
- package/src/db/migrations/meta/0001_snapshot.json +155 -0
- package/src/db/migrations/meta/_journal.json +20 -0
- package/src/db/schema.ts +41 -0
- package/src/index.ts +709 -0
- package/src/routes/access.ts +263 -0
- package/src/routes/auth.ts +93 -0
- package/src/routes/index.ts +135 -0
- package/src/routes/packages.ts +924 -0
- package/src/routes/search.ts +50 -0
- package/src/routes/static.ts +53 -0
- package/src/routes/tokens.ts +102 -0
- package/src/routes/users.ts +14 -0
- package/src/utils/auth.ts +145 -0
- package/src/utils/cache.ts +466 -0
- package/src/utils/database.ts +44 -0
- package/src/utils/packages.ts +337 -0
- package/src/utils/response.ts +100 -0
- package/src/utils/routes.ts +47 -0
- package/src/utils/spa.ts +14 -0
- package/src/utils/tracing.ts +63 -0
- package/src/utils/upstream.ts +131 -0
- package/test/README.md +91 -0
- package/test/access.test.js +760 -0
- package/test/cloudflare-waituntil.test.js +141 -0
- package/test/db.test.js +447 -0
- package/test/dist-tag.test.js +415 -0
- package/test/e2e.test.js +904 -0
- package/test/hono-context.test.js +250 -0
- package/test/integrity-validation.test.js +183 -0
- package/test/json-response.test.js +76 -0
- package/test/manifest-slimming.test.js +449 -0
- package/test/packument-consistency.test.js +351 -0
- package/test/packument-version-range.test.js +144 -0
- package/test/performance.test.js +162 -0
- package/test/route-with-waituntil.test.js +298 -0
- package/test/run-tests.js +151 -0
- package/test/setup-cache-tests.js +190 -0
- package/test/setup.js +64 -0
- package/test/stale-while-revalidate.test.js +273 -0
- package/test/static-assets.test.js +85 -0
- package/test/upstream-routing.test.js +86 -0
- package/test/utils/test-helpers.js +84 -0
- package/test/waituntil-correct.test.js +208 -0
- package/test/waituntil-demo.test.js +138 -0
- package/test/waituntil-readme.md +113 -0
- package/tsconfig.json +37 -0
- package/types.ts +446 -0
- package/vitest.config.js +95 -0
- package/wrangler.json +58 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import * as semver from 'semver'
|
|
3
|
+
import validate from 'validate-npm-package-name'
|
|
4
|
+
import type {
|
|
5
|
+
HonoContext,
|
|
6
|
+
PackageSpec,
|
|
7
|
+
PackageManifest,
|
|
8
|
+
SlimmedManifest,
|
|
9
|
+
RequestContext,
|
|
10
|
+
ValidationResult,
|
|
11
|
+
FileInfo
|
|
12
|
+
} from '../../types.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extracts package.json from a tarball buffer
|
|
16
|
+
* @param tarballBuffer - The tarball as a Buffer
|
|
17
|
+
* @returns The parsed package.json content
|
|
18
|
+
*/
|
|
19
|
+
export async function extractPackageJSON(tarballBuffer: Buffer): Promise<PackageManifest | null> {
|
|
20
|
+
try {
|
|
21
|
+
// This would need to be implemented with a tarball extraction library
|
|
22
|
+
// For now, return null as a placeholder
|
|
23
|
+
console.log('[PACKAGES] Extracting package.json from tarball')
|
|
24
|
+
return null
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(`[ERROR] Failed to extract package.json: ${(error as Error).message}`)
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extracts package specification from context
|
|
33
|
+
* @param c - The Hono context
|
|
34
|
+
* @returns Package specification object
|
|
35
|
+
*/
|
|
36
|
+
export function packageSpec(c: HonoContext): PackageSpec {
|
|
37
|
+
const { scope, pkg } = c.req.param()
|
|
38
|
+
|
|
39
|
+
if (scope && pkg) {
|
|
40
|
+
// Scoped package
|
|
41
|
+
const name = scope.startsWith('@') ? `${scope}/${pkg}` : `@${scope}/${pkg}`
|
|
42
|
+
return { name, scope, pkg }
|
|
43
|
+
} else if (scope) {
|
|
44
|
+
// Unscoped package (scope is actually the package name)
|
|
45
|
+
return { name: scope, pkg: scope }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a file path for a package tarball
|
|
53
|
+
* @param options - Object with pkg and version
|
|
54
|
+
* @returns Tarball file path
|
|
55
|
+
*/
|
|
56
|
+
export function createFile({ pkg, version }: { pkg: string; version: string }): string {
|
|
57
|
+
try {
|
|
58
|
+
if (!pkg || !version) {
|
|
59
|
+
throw new Error('Missing required parameters')
|
|
60
|
+
}
|
|
61
|
+
// Generate the tarball path similar to npm registry format
|
|
62
|
+
const packageName = pkg.split('/').pop() || pkg
|
|
63
|
+
return `${pkg}/-/${packageName}-${version}.tgz`
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`[ERROR] Failed to create file path for ${pkg}@${version}: ${(err as Error).message}`)
|
|
66
|
+
throw new Error('Failed to generate tarball path')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates a version specification string
|
|
72
|
+
* @param packageName - The package name
|
|
73
|
+
* @param version - The version
|
|
74
|
+
* @returns Version specification string
|
|
75
|
+
*/
|
|
76
|
+
export function createVersionSpec(packageName: string, version: string): string {
|
|
77
|
+
return `${packageName}@${version}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Creates a full version object with proper manifest structure
|
|
82
|
+
* @param options - Object with pkg, version, and manifest
|
|
83
|
+
* @returns The manifest with proper name, version, and dist fields
|
|
84
|
+
*/
|
|
85
|
+
export function createVersion({ pkg, version, manifest }: { pkg: string; version: string; manifest: any }): any {
|
|
86
|
+
// If manifest is a string, parse it
|
|
87
|
+
let parsedManifest: any
|
|
88
|
+
if (typeof manifest === 'string') {
|
|
89
|
+
try {
|
|
90
|
+
parsedManifest = JSON.parse(manifest)
|
|
91
|
+
} catch (e) {
|
|
92
|
+
parsedManifest = {}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
parsedManifest = manifest || {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Create the final manifest with proper structure
|
|
99
|
+
const result = {
|
|
100
|
+
...parsedManifest,
|
|
101
|
+
name: pkg,
|
|
102
|
+
version: version,
|
|
103
|
+
dist: {
|
|
104
|
+
...(parsedManifest.dist || {}),
|
|
105
|
+
tarball: parsedManifest.dist?.tarball || `https://registry.npmjs.org/${pkg}/-/${pkg.split('/').pop()}-${version}.tgz`
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Creates a slimmed down version of a package manifest
|
|
114
|
+
* Removes sensitive or unnecessary fields for public consumption
|
|
115
|
+
* @param manifest - The full package manifest
|
|
116
|
+
* @param context - Optional context for URL rewriting
|
|
117
|
+
* @returns Slimmed manifest
|
|
118
|
+
*/
|
|
119
|
+
export function slimManifest(manifest: any, context?: any): any {
|
|
120
|
+
if (!manifest) return {}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// Parse manifest if it's a string
|
|
124
|
+
let parsed: any
|
|
125
|
+
if (typeof manifest === 'string') {
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(manifest)
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// If parsing fails, use the original
|
|
130
|
+
parsed = manifest
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
parsed = manifest
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Create a new object with only the fields we want to keep
|
|
137
|
+
const slimmed: any = {
|
|
138
|
+
name: parsed.name,
|
|
139
|
+
version: parsed.version,
|
|
140
|
+
description: parsed.description,
|
|
141
|
+
keywords: parsed.keywords,
|
|
142
|
+
homepage: parsed.homepage,
|
|
143
|
+
bugs: parsed.bugs,
|
|
144
|
+
license: parsed.license,
|
|
145
|
+
author: parsed.author,
|
|
146
|
+
contributors: parsed.contributors,
|
|
147
|
+
funding: parsed.funding,
|
|
148
|
+
files: parsed.files,
|
|
149
|
+
main: parsed.main,
|
|
150
|
+
browser: parsed.browser,
|
|
151
|
+
bin: parsed.bin || {},
|
|
152
|
+
man: parsed.man,
|
|
153
|
+
directories: parsed.directories,
|
|
154
|
+
repository: parsed.repository,
|
|
155
|
+
scripts: parsed.scripts,
|
|
156
|
+
dependencies: parsed.dependencies || {},
|
|
157
|
+
devDependencies: parsed.devDependencies || {},
|
|
158
|
+
peerDependencies: parsed.peerDependencies || {},
|
|
159
|
+
optionalDependencies: parsed.optionalDependencies || {},
|
|
160
|
+
bundledDependencies: parsed.bundledDependencies,
|
|
161
|
+
peerDependenciesMeta: parsed.peerDependenciesMeta || {},
|
|
162
|
+
engines: parsed.engines || {},
|
|
163
|
+
os: parsed.os || [],
|
|
164
|
+
cpu: parsed.cpu || [],
|
|
165
|
+
types: parsed.types,
|
|
166
|
+
typings: parsed.typings,
|
|
167
|
+
module: parsed.module,
|
|
168
|
+
exports: parsed.exports,
|
|
169
|
+
imports: parsed.imports,
|
|
170
|
+
type: parsed.type,
|
|
171
|
+
dist: {
|
|
172
|
+
...(parsed.dist || {}),
|
|
173
|
+
tarball: rewriteTarballUrlIfNeeded(parsed.dist?.tarball || '', parsed.name, parsed.version, context),
|
|
174
|
+
integrity: parsed.dist?.integrity || '',
|
|
175
|
+
shasum: parsed.dist?.shasum || ''
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Only include fields that were actually in the original manifest
|
|
180
|
+
// to avoid empty objects cluttering the response
|
|
181
|
+
Object.keys(slimmed).forEach(key => {
|
|
182
|
+
if (key !== 'dist' && slimmed[key] === undefined) {
|
|
183
|
+
delete slimmed[key]
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return slimmed
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`[ERROR] Failed to slim manifest: ${(err as Error).message}`)
|
|
190
|
+
return manifest || {} // Return the original if slimming fails
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validates a package name using npm validation rules
|
|
196
|
+
* @param packageName - The package name to validate
|
|
197
|
+
* @returns Validation result
|
|
198
|
+
*/
|
|
199
|
+
export function validatePackageName(packageName: string): ValidationResult {
|
|
200
|
+
const result = validate(packageName)
|
|
201
|
+
return {
|
|
202
|
+
valid: result.validForNewPackages || result.validForOldPackages,
|
|
203
|
+
errors: result.errors || []
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validates a semver version string
|
|
209
|
+
* @param version - The version to validate
|
|
210
|
+
* @returns True if valid semver
|
|
211
|
+
*/
|
|
212
|
+
export function validateVersion(version: string): boolean {
|
|
213
|
+
return semver.valid(version) !== null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parses a version range and returns the best matching version from a list
|
|
218
|
+
* @param range - The semver range
|
|
219
|
+
* @param versions - Available versions
|
|
220
|
+
* @returns Best matching version or null
|
|
221
|
+
*/
|
|
222
|
+
export function getBestMatchingVersion(range: string, versions: string[]): string | null {
|
|
223
|
+
try {
|
|
224
|
+
return semver.maxSatisfying(versions, range)
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(`[ERROR] Invalid semver range: ${range}`)
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Extracts the package name from a scoped or unscoped package identifier
|
|
233
|
+
* @param identifier - Package identifier (e.g., "@scope/package" or "package")
|
|
234
|
+
* @returns Package name components
|
|
235
|
+
*/
|
|
236
|
+
export function parsePackageIdentifier(identifier: string): { scope?: string; name: string; fullName: string } {
|
|
237
|
+
if (identifier.startsWith('@')) {
|
|
238
|
+
const parts = identifier.split('/')
|
|
239
|
+
if (parts.length >= 2) {
|
|
240
|
+
return {
|
|
241
|
+
scope: parts[0],
|
|
242
|
+
name: parts.slice(1).join('/'),
|
|
243
|
+
fullName: identifier
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
name: identifier,
|
|
250
|
+
fullName: identifier
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Generates a tarball filename for a package version
|
|
256
|
+
* @param packageName - The package name
|
|
257
|
+
* @param version - The package version
|
|
258
|
+
* @returns Tarball filename
|
|
259
|
+
*/
|
|
260
|
+
export function generateTarballFilename(packageName: string, version: string): string {
|
|
261
|
+
const name = packageName.split('/').pop() || packageName
|
|
262
|
+
return `${name}-${version}.tgz`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Rewrite tarball URLs to point to our registry instead of the original registry
|
|
267
|
+
* Only rewrite if context is provided, otherwise return original URL
|
|
268
|
+
* @param originalUrl - The original tarball URL
|
|
269
|
+
* @param packageName - The package name
|
|
270
|
+
* @param version - The package version
|
|
271
|
+
* @param context - Context containing request info (host, upstream, etc.)
|
|
272
|
+
* @returns The rewritten or original tarball URL
|
|
273
|
+
*/
|
|
274
|
+
function rewriteTarballUrlIfNeeded(
|
|
275
|
+
originalUrl: string,
|
|
276
|
+
packageName: string,
|
|
277
|
+
version: string,
|
|
278
|
+
context?: any
|
|
279
|
+
): string {
|
|
280
|
+
// Only rewrite if we have context indicating this is a proxied request
|
|
281
|
+
if (!context?.upstream || !originalUrl || !packageName || !version) {
|
|
282
|
+
return originalUrl
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
// Extract the protocol and host from the context or use defaults
|
|
287
|
+
const protocol = context.protocol || 'http'
|
|
288
|
+
const host = context.host || 'localhost:1337'
|
|
289
|
+
const upstream = context.upstream
|
|
290
|
+
|
|
291
|
+
// Create the new tarball URL pointing to our registry
|
|
292
|
+
const filename = generateTarballFilename(packageName, version)
|
|
293
|
+
const newUrl = `${protocol}://${host}/${upstream}/${packageName}/-/${filename}`
|
|
294
|
+
|
|
295
|
+
console.log(`[TARBALL_REWRITE] ${originalUrl} -> ${newUrl}`)
|
|
296
|
+
return newUrl
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error(`[ERROR] Failed to rewrite tarball URL: ${(err as Error).message}`)
|
|
299
|
+
return originalUrl
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Checks if a package version satisfies a given semver range
|
|
305
|
+
* @param version - The version to check
|
|
306
|
+
* @param range - The semver range
|
|
307
|
+
* @returns True if version satisfies range
|
|
308
|
+
*/
|
|
309
|
+
export function satisfiesRange(version: string, range: string): boolean {
|
|
310
|
+
try {
|
|
311
|
+
return semver.satisfies(version, range)
|
|
312
|
+
} catch (error) {
|
|
313
|
+
console.error(`[ERROR] Invalid semver comparison: ${version} vs ${range}`)
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Sorts versions in descending order (newest first)
|
|
320
|
+
* @param versions - Array of version strings
|
|
321
|
+
* @returns Sorted versions array
|
|
322
|
+
*/
|
|
323
|
+
export function sortVersionsDescending(versions: string[]): string[] {
|
|
324
|
+
return versions.sort((a, b) => semver.rcompare(a, b))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Gets the latest version from an array of versions
|
|
329
|
+
* @param versions - Array of version strings
|
|
330
|
+
* @returns Latest version or null if no valid versions
|
|
331
|
+
*/
|
|
332
|
+
export function getLatestVersion(versions: string[]): string | null {
|
|
333
|
+
const validVersions = versions.filter(v => semver.valid(v))
|
|
334
|
+
if (validVersions.length === 0) return null
|
|
335
|
+
|
|
336
|
+
return semver.maxSatisfying(validVersions, '*')
|
|
337
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { HonoContext, ApiError } from '../../types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON response handler middleware that formats JSON based on Accept headers
|
|
5
|
+
* @returns Hono middleware function
|
|
6
|
+
*/
|
|
7
|
+
export function jsonResponseHandler() {
|
|
8
|
+
return async (c: any, next: any) => {
|
|
9
|
+
// Override the json method to handle formatting
|
|
10
|
+
const originalJson = c.json.bind(c)
|
|
11
|
+
|
|
12
|
+
c.json = (data: any, status?: number) => {
|
|
13
|
+
const acceptHeader = c.req.header('accept') || ''
|
|
14
|
+
|
|
15
|
+
// If the client accepts the npm install format, return minimal JSON
|
|
16
|
+
if (acceptHeader.includes('application/vnd.npm.install-v1+json')) {
|
|
17
|
+
// Use original json method for minimal output
|
|
18
|
+
return originalJson(data, status)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// For other requests, return pretty-printed JSON
|
|
22
|
+
const prettyJson = JSON.stringify(data, null, 2)
|
|
23
|
+
c.res = new Response(prettyJson, {
|
|
24
|
+
status: status || 200,
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
return c.res
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await next()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a standardized JSON error response
|
|
38
|
+
* @param c - The Hono context
|
|
39
|
+
* @param error - Error message or object
|
|
40
|
+
* @param status - HTTP status code
|
|
41
|
+
* @returns JSON error response
|
|
42
|
+
*/
|
|
43
|
+
export function jsonError(c: HonoContext, error: string | ApiError, status: number = 400) {
|
|
44
|
+
const errorObj: ApiError = typeof error === 'string'
|
|
45
|
+
? { error }
|
|
46
|
+
: error
|
|
47
|
+
|
|
48
|
+
return c.json(errorObj, status as any)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a standardized JSON success response
|
|
53
|
+
* @param c - The Hono context
|
|
54
|
+
* @param data - Response data
|
|
55
|
+
* @param status - HTTP status code
|
|
56
|
+
* @returns JSON success response
|
|
57
|
+
*/
|
|
58
|
+
export function jsonSuccess(c: HonoContext, data: any, status: number = 200) {
|
|
59
|
+
return c.json(data, status as any)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a 404 Not Found response
|
|
64
|
+
* @param c - The Hono context
|
|
65
|
+
* @param message - Optional custom message
|
|
66
|
+
* @returns 404 JSON response
|
|
67
|
+
*/
|
|
68
|
+
export function notFound(c: HonoContext, message: string = 'Not Found') {
|
|
69
|
+
return jsonError(c, { error: message }, 404)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a 401 Unauthorized response
|
|
74
|
+
* @param c - The Hono context
|
|
75
|
+
* @param message - Optional custom message
|
|
76
|
+
* @returns 401 JSON response
|
|
77
|
+
*/
|
|
78
|
+
export function unauthorized(c: HonoContext, message: string = 'Unauthorized') {
|
|
79
|
+
return jsonError(c, { error: message }, 401)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a 403 Forbidden response
|
|
84
|
+
* @param c - The Hono context
|
|
85
|
+
* @param message - Optional custom message
|
|
86
|
+
* @returns 403 JSON response
|
|
87
|
+
*/
|
|
88
|
+
export function forbidden(c: HonoContext, message: string = 'Forbidden') {
|
|
89
|
+
return jsonError(c, { error: message }, 403)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates a 500 Internal Server Error response
|
|
94
|
+
* @param c - The Hono context
|
|
95
|
+
* @param message - Optional custom message
|
|
96
|
+
* @returns 500 JSON response
|
|
97
|
+
*/
|
|
98
|
+
export function internalServerError(c: HonoContext, message: string = 'Internal Server Error') {
|
|
99
|
+
return jsonError(c, { error: message }, 500)
|
|
100
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HonoContext } from '../../types.ts'
|
|
2
|
+
|
|
3
|
+
// Public / Static Assets
|
|
4
|
+
export function requiresToken(c: HonoContext): boolean {
|
|
5
|
+
const { path } = c.req
|
|
6
|
+
const publicRoutes = [
|
|
7
|
+
'/images',
|
|
8
|
+
'/styles',
|
|
9
|
+
'/-/auth/*',
|
|
10
|
+
'/-/ping',
|
|
11
|
+
'/-/docs',
|
|
12
|
+
'/'
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
// Package routes should be public for downloads
|
|
16
|
+
// This includes hash-based routes, upstream routes, and legacy redirects
|
|
17
|
+
const isPackageRoute = (
|
|
18
|
+
path.startsWith('/*/') || // Hash-based routes (literal asterisk)
|
|
19
|
+
/^\/[^-/][^/]*\/[^/]/.test(path) || // Upstream or package routes
|
|
20
|
+
/^\/[^-/][^/]*$/.test(path) || // Root package routes
|
|
21
|
+
/^\/@[^/]+\/[^/]+/.test(path) // Scoped packages
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// Exclude PUT requests (publishing) from being public
|
|
25
|
+
const isPutRequest = c.req.method === 'PUT'
|
|
26
|
+
const isPublicPackageRoute = isPackageRoute && !isPutRequest
|
|
27
|
+
|
|
28
|
+
return publicRoutes.some(route => {
|
|
29
|
+
if (route.endsWith('/*')) {
|
|
30
|
+
return path.startsWith(route.slice(0, -2))
|
|
31
|
+
}
|
|
32
|
+
return path === route || path.startsWith(route)
|
|
33
|
+
}) || isPublicPackageRoute
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Catch-all for non-GET methods
|
|
37
|
+
export function catchAll(c: HonoContext) {
|
|
38
|
+
return c.json({ error: 'Method not allowed' }, 405)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function notFound(c: HonoContext) {
|
|
42
|
+
return c.json({ error: 'Not found' }, 404)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isOK(c: HonoContext) {
|
|
46
|
+
return c.json({ ok: true }, 200)
|
|
47
|
+
}
|
package/src/utils/spa.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DOMAIN } from '../../config.ts'
|
|
2
|
+
|
|
3
|
+
export const getApp = async (): Promise<string> => {
|
|
4
|
+
const app = await fetch(`${DOMAIN}/public/dist/index.html`)
|
|
5
|
+
return changeSourceReferences(await app.text())
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const changeSourceReferences = (html: string): string => {
|
|
9
|
+
html = html.replace('href="/main.css', 'href="/public/dist/main.css')
|
|
10
|
+
html = html.replace('href="/favicon.ico"', 'href="/public/dist/favicon.ico"')
|
|
11
|
+
html = html.replace('href="/fonts/', 'href="/public/dist/fonts/')
|
|
12
|
+
html = html.replace('src="/index.js"', 'src="/public/dist/index.js"')
|
|
13
|
+
return html
|
|
14
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple tracing utility for debugging and monitoring
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Logs a trace message with timestamp
|
|
7
|
+
* @param message - The message to log
|
|
8
|
+
* @param data - Optional additional data to log
|
|
9
|
+
*/
|
|
10
|
+
export function trace(message: string, data?: any): void {
|
|
11
|
+
const timestamp = new Date().toISOString()
|
|
12
|
+
if (data) {
|
|
13
|
+
console.log(`[TRACE ${timestamp}] ${message}`, data)
|
|
14
|
+
} else {
|
|
15
|
+
console.log(`[TRACE ${timestamp}] ${message}`)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Measures execution time of a function
|
|
21
|
+
* @param name - Name of the operation being measured
|
|
22
|
+
* @param fn - Function to measure
|
|
23
|
+
* @returns Result of the function
|
|
24
|
+
*/
|
|
25
|
+
export async function measureTime<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
26
|
+
const start = performance.now()
|
|
27
|
+
try {
|
|
28
|
+
const result = await fn()
|
|
29
|
+
const duration = performance.now() - start
|
|
30
|
+
trace(`${name} completed in ${duration.toFixed(2)}ms`)
|
|
31
|
+
return result
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const duration = performance.now() - start
|
|
34
|
+
trace(`${name} failed after ${duration.toFixed(2)}ms`, error)
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Session monitoring middleware for tracking requests with Sentry integration
|
|
41
|
+
* @param c - The Hono context
|
|
42
|
+
* @param next - The next middleware function
|
|
43
|
+
*/
|
|
44
|
+
export function sessionMonitor(c: any, next: any) {
|
|
45
|
+
// Import Sentry dynamically to avoid issues if not available
|
|
46
|
+
try {
|
|
47
|
+
const Sentry = require('@sentry/cloudflare')
|
|
48
|
+
|
|
49
|
+
if (c.session?.user) {
|
|
50
|
+
Sentry.setUser({
|
|
51
|
+
email: c.session.user.email,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
if (c.session?.projectId) {
|
|
55
|
+
Sentry.setTag('project_id', c.session.projectId)
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Sentry not available, continue without it
|
|
59
|
+
trace('Sentry not available for session monitoring')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return next()
|
|
63
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ORIGIN_CONFIG, RESERVED_ROUTES } from '../../config.ts'
|
|
2
|
+
import type { UpstreamConfig, ParsedPackageInfo } from '../../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates if an upstream name is allowed (not reserved)
|
|
6
|
+
* @param upstreamName - The upstream name to validate
|
|
7
|
+
* @returns True if valid, false if reserved
|
|
8
|
+
*/
|
|
9
|
+
export function isValidUpstreamName(upstreamName: string): boolean {
|
|
10
|
+
return !RESERVED_ROUTES.includes(upstreamName)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gets the upstream configuration by name
|
|
15
|
+
* @param upstreamName - The upstream name
|
|
16
|
+
* @returns The upstream config or null if not found
|
|
17
|
+
*/
|
|
18
|
+
export function getUpstreamConfig(upstreamName: string): UpstreamConfig | null {
|
|
19
|
+
return ORIGIN_CONFIG.upstreams[upstreamName] || null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gets the default upstream name
|
|
24
|
+
* @returns The default upstream name
|
|
25
|
+
*/
|
|
26
|
+
export function getDefaultUpstream(): string {
|
|
27
|
+
return ORIGIN_CONFIG.default
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generates a cache key for upstream package data
|
|
32
|
+
* @param upstreamName - The upstream name
|
|
33
|
+
* @param packageName - The package name
|
|
34
|
+
* @param version - The package version (optional)
|
|
35
|
+
* @returns A deterministic hash ID
|
|
36
|
+
*/
|
|
37
|
+
export function generateCacheKey(upstreamName: string, packageName: string, version?: string): string {
|
|
38
|
+
const key = version ? `${upstreamName}:${packageName}:${version}` : `${upstreamName}:${packageName}`
|
|
39
|
+
return Buffer.from(key).toString('base64url')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parses a request path to extract package information
|
|
44
|
+
* @param path - The request path
|
|
45
|
+
* @returns Parsed package info
|
|
46
|
+
*/
|
|
47
|
+
export function parsePackageSpec(path: string): ParsedPackageInfo {
|
|
48
|
+
// Remove leading slash and split by '/'
|
|
49
|
+
const segments = path.replace(/^\/+/, '').split('/')
|
|
50
|
+
|
|
51
|
+
// Handle different path patterns
|
|
52
|
+
if (segments.length === 0) {
|
|
53
|
+
return { packageName: '', segments }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if first segment is an upstream name
|
|
57
|
+
const firstSegment = segments[0]
|
|
58
|
+
if (ORIGIN_CONFIG.upstreams[firstSegment]) {
|
|
59
|
+
// Path starts with upstream name: /upstream/package/version
|
|
60
|
+
const upstream = firstSegment
|
|
61
|
+
const packageSegments = segments.slice(1)
|
|
62
|
+
|
|
63
|
+
if (packageSegments.length === 0) {
|
|
64
|
+
return { upstream, packageName: '', segments: packageSegments }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Handle scoped packages: @scope/package
|
|
68
|
+
if (packageSegments[0]?.startsWith('@') && packageSegments.length > 1) {
|
|
69
|
+
const packageName = `${packageSegments[0]}/${packageSegments[1]}`
|
|
70
|
+
const version = packageSegments[2]
|
|
71
|
+
const remainingSegments = packageSegments.slice(2)
|
|
72
|
+
return { upstream, packageName, version, segments: remainingSegments }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Handle regular packages
|
|
76
|
+
const packageName = packageSegments[0]
|
|
77
|
+
const version = packageSegments[1]
|
|
78
|
+
const remainingSegments = packageSegments.slice(1)
|
|
79
|
+
return { upstream, packageName, version, segments: remainingSegments }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// No upstream in path, treat as package name
|
|
83
|
+
if (firstSegment?.startsWith('@') && segments.length > 1) {
|
|
84
|
+
// Scoped package: @scope/package/version
|
|
85
|
+
const packageName = `${segments[0]}/${segments[1]}`
|
|
86
|
+
const version = segments[2]
|
|
87
|
+
const remainingSegments = segments.slice(2)
|
|
88
|
+
return { packageName, version, segments: remainingSegments }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Regular package: package/version
|
|
92
|
+
const packageName = segments[0]
|
|
93
|
+
const version = segments[1]
|
|
94
|
+
const remainingSegments = segments.slice(1)
|
|
95
|
+
return { packageName, version, segments: remainingSegments }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Constructs the upstream URL for a package request
|
|
100
|
+
* @param upstreamConfig - The upstream configuration
|
|
101
|
+
* @param packageName - The package name
|
|
102
|
+
* @param path - Additional path segments
|
|
103
|
+
* @returns The full upstream URL
|
|
104
|
+
*/
|
|
105
|
+
export function buildUpstreamUrl(upstreamConfig: UpstreamConfig, packageName: string, path: string = ''): string {
|
|
106
|
+
const baseUrl = upstreamConfig.url.replace(/\/$/, '')
|
|
107
|
+
const encodedPackage = encodeURIComponent(packageName)
|
|
108
|
+
|
|
109
|
+
switch (upstreamConfig.type) {
|
|
110
|
+
case 'npm':
|
|
111
|
+
case 'vsr':
|
|
112
|
+
return `${baseUrl}/${encodedPackage}${path ? `/${path}` : ''}`
|
|
113
|
+
case 'jsr':
|
|
114
|
+
// JSR has a different URL structure
|
|
115
|
+
return `${baseUrl}/${encodedPackage}${path ? `/${path}` : ''}`
|
|
116
|
+
case 'local':
|
|
117
|
+
return `${baseUrl}/${encodedPackage}${path ? `/${path}` : ''}`
|
|
118
|
+
default:
|
|
119
|
+
return `${baseUrl}/${encodedPackage}${path ? `/${path}` : ''}`
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Checks if proxying is enabled for an upstream
|
|
125
|
+
* @param upstreamName - The upstream name
|
|
126
|
+
* @returns True if proxying is enabled
|
|
127
|
+
*/
|
|
128
|
+
export function isProxyEnabled(upstreamName: string): boolean {
|
|
129
|
+
const config = getUpstreamConfig(upstreamName)
|
|
130
|
+
return config !== null && config.type !== 'local'
|
|
131
|
+
}
|