@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.
Files changed (83) hide show
  1. package/.editorconfig +13 -0
  2. package/.prettierrc +7 -0
  3. package/CONTRIBUTING.md +228 -0
  4. package/LICENSE.md +110 -0
  5. package/README.md +373 -0
  6. package/bin/vsr.ts +29 -0
  7. package/config.ts +124 -0
  8. package/debug-npm.js +19 -0
  9. package/drizzle.config.js +33 -0
  10. package/package.json +80 -0
  11. package/pnpm-workspace.yaml +5 -0
  12. package/src/api.ts +2246 -0
  13. package/src/assets/public/images/bg.png +0 -0
  14. package/src/assets/public/images/clients/logo-bun.png +0 -0
  15. package/src/assets/public/images/clients/logo-deno.png +0 -0
  16. package/src/assets/public/images/clients/logo-npm.png +0 -0
  17. package/src/assets/public/images/clients/logo-pnpm.png +0 -0
  18. package/src/assets/public/images/clients/logo-vlt.png +0 -0
  19. package/src/assets/public/images/clients/logo-yarn.png +0 -0
  20. package/src/assets/public/images/favicon/apple-touch-icon.png +0 -0
  21. package/src/assets/public/images/favicon/favicon-96x96.png +0 -0
  22. package/src/assets/public/images/favicon/favicon.ico +0 -0
  23. package/src/assets/public/images/favicon/favicon.svg +3 -0
  24. package/src/assets/public/images/favicon/site.webmanifest +21 -0
  25. package/src/assets/public/images/favicon/web-app-manifest-192x192.png +0 -0
  26. package/src/assets/public/images/favicon/web-app-manifest-512x512.png +0 -0
  27. package/src/assets/public/styles/styles.css +219 -0
  28. package/src/db/client.ts +544 -0
  29. package/src/db/migrations/0000_faulty_ricochet.sql +14 -0
  30. package/src/db/migrations/0000_initial.sql +29 -0
  31. package/src/db/migrations/0001_uuid_validation.sql +35 -0
  32. package/src/db/migrations/0001_wealthy_magdalene.sql +7 -0
  33. package/src/db/migrations/drop.sql +3 -0
  34. package/src/db/migrations/meta/0000_snapshot.json +104 -0
  35. package/src/db/migrations/meta/0001_snapshot.json +155 -0
  36. package/src/db/migrations/meta/_journal.json +20 -0
  37. package/src/db/schema.ts +41 -0
  38. package/src/index.ts +709 -0
  39. package/src/routes/access.ts +263 -0
  40. package/src/routes/auth.ts +93 -0
  41. package/src/routes/index.ts +135 -0
  42. package/src/routes/packages.ts +924 -0
  43. package/src/routes/search.ts +50 -0
  44. package/src/routes/static.ts +53 -0
  45. package/src/routes/tokens.ts +102 -0
  46. package/src/routes/users.ts +14 -0
  47. package/src/utils/auth.ts +145 -0
  48. package/src/utils/cache.ts +466 -0
  49. package/src/utils/database.ts +44 -0
  50. package/src/utils/packages.ts +337 -0
  51. package/src/utils/response.ts +100 -0
  52. package/src/utils/routes.ts +47 -0
  53. package/src/utils/spa.ts +14 -0
  54. package/src/utils/tracing.ts +63 -0
  55. package/src/utils/upstream.ts +131 -0
  56. package/test/README.md +91 -0
  57. package/test/access.test.js +760 -0
  58. package/test/cloudflare-waituntil.test.js +141 -0
  59. package/test/db.test.js +447 -0
  60. package/test/dist-tag.test.js +415 -0
  61. package/test/e2e.test.js +904 -0
  62. package/test/hono-context.test.js +250 -0
  63. package/test/integrity-validation.test.js +183 -0
  64. package/test/json-response.test.js +76 -0
  65. package/test/manifest-slimming.test.js +449 -0
  66. package/test/packument-consistency.test.js +351 -0
  67. package/test/packument-version-range.test.js +144 -0
  68. package/test/performance.test.js +162 -0
  69. package/test/route-with-waituntil.test.js +298 -0
  70. package/test/run-tests.js +151 -0
  71. package/test/setup-cache-tests.js +190 -0
  72. package/test/setup.js +64 -0
  73. package/test/stale-while-revalidate.test.js +273 -0
  74. package/test/static-assets.test.js +85 -0
  75. package/test/upstream-routing.test.js +86 -0
  76. package/test/utils/test-helpers.js +84 -0
  77. package/test/waituntil-correct.test.js +208 -0
  78. package/test/waituntil-demo.test.js +138 -0
  79. package/test/waituntil-readme.md +113 -0
  80. package/tsconfig.json +37 -0
  81. package/types.ts +446 -0
  82. package/vitest.config.js +95 -0
  83. 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
+ }
@@ -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
+ }