@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,924 @@
1
+ // @ts-ignore
2
+ import validate from 'validate-npm-package-name'
3
+ // @ts-ignore
4
+ import * as semver from 'semver'
5
+ import { accepts } from 'hono/accepts'
6
+ import { DOMAIN, PROXY, PROXY_URL } from '../../config.ts'
7
+ import {
8
+ parsePackageSpec,
9
+ getUpstreamConfig,
10
+ buildUpstreamUrl,
11
+ isProxyEnabled,
12
+ isValidUpstreamName,
13
+ getDefaultUpstream
14
+ } from '../utils/upstream.ts'
15
+ import {
16
+ extractPackageJSON,
17
+ packageSpec,
18
+ createFile,
19
+ createVersion,
20
+ slimManifest
21
+ } from '../utils/packages.ts'
22
+ import { getCachedPackageWithRefresh, getCachedVersionWithRefresh, isTarballCached, getTarballStoragePath, cacheTarball } from '../utils/cache.ts'
23
+ import type {
24
+ HonoContext,
25
+ PackageManifest,
26
+ SlimmedManifest,
27
+ ParsedPackage,
28
+ ParsedVersion,
29
+ UpstreamConfig,
30
+ PackageSpec
31
+ } from '../../types.ts'
32
+
33
+ interface SlimPackumentContext {
34
+ protocol?: string
35
+ host?: string
36
+ upstream?: string
37
+ }
38
+
39
+ interface TarballRequestParams {
40
+ scope: string
41
+ pkg: string
42
+ }
43
+
44
+ interface PackageRouteSegments {
45
+ upstream?: string
46
+ packageName: string
47
+ segments: string[]
48
+ }
49
+
50
+ /**
51
+ * Ultra-aggressive slimming for packument versions (used in /:upstream/:pkg responses)
52
+ * Only includes the absolute minimum fields needed for dependency resolution and installation
53
+ * Fields included: name, version, dependencies, peerDependencies, optionalDependencies, peerDependenciesMeta, bin, engines, dist.tarball
54
+ */
55
+ function slimPackumentVersion(manifest: any, context: SlimPackumentContext = {}): SlimmedManifest {
56
+ if (!manifest) return {} as SlimmedManifest
57
+
58
+ try {
59
+ // Parse manifest if it's a string
60
+ let parsed: any
61
+ if (typeof manifest === 'string') {
62
+ try {
63
+ parsed = JSON.parse(manifest)
64
+ } catch (e) {
65
+ parsed = manifest
66
+ }
67
+ } else {
68
+ parsed = manifest
69
+ }
70
+
71
+ // For packuments, only include the most essential fields
72
+ const slimmed: any = {
73
+ name: parsed.name,
74
+ version: parsed.version,
75
+ dependencies: parsed.dependencies || {},
76
+ peerDependencies: parsed.peerDependencies || {},
77
+ optionalDependencies: parsed.optionalDependencies || {},
78
+ peerDependenciesMeta: parsed.peerDependenciesMeta || {},
79
+ bin: parsed.bin,
80
+ engines: parsed.engines,
81
+ dist: {
82
+ tarball: rewriteTarballUrlIfNeeded(parsed.dist?.tarball || '', parsed.name, parsed.version, context)
83
+ }
84
+ }
85
+
86
+ // Remove undefined fields to keep response clean
87
+ Object.keys(slimmed).forEach(key => {
88
+ if (key !== 'dist' && slimmed[key] === undefined) {
89
+ delete slimmed[key]
90
+ }
91
+ })
92
+
93
+ // Remove empty objects
94
+ if (Object.keys(slimmed.dependencies || {}).length === 0) {
95
+ delete slimmed.dependencies
96
+ }
97
+ if (Object.keys(slimmed.peerDependencies || {}).length === 0) {
98
+ delete slimmed.peerDependencies
99
+ }
100
+ if (Object.keys(slimmed.peerDependenciesMeta || {}).length === 0) {
101
+ delete slimmed.peerDependenciesMeta
102
+ }
103
+ if (Object.keys(slimmed.optionalDependencies || {}).length === 0) {
104
+ delete slimmed.optionalDependencies
105
+ }
106
+ if (Object.keys(slimmed.engines || {}).length === 0) {
107
+ delete slimmed.engines
108
+ }
109
+
110
+ return slimmed as SlimmedManifest
111
+ } catch (err) {
112
+ console.error(`[ERROR] Failed to slim packument version: ${(err as Error).message}`)
113
+ return (manifest || {}) as SlimmedManifest
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Rewrite tarball URLs to point to our registry instead of the original registry
119
+ * Only rewrite if context is provided, otherwise return original URL
120
+ */
121
+ function rewriteTarballUrlIfNeeded(
122
+ originalUrl: string,
123
+ packageName: string,
124
+ version: string,
125
+ context: SlimPackumentContext = {}
126
+ ): string {
127
+ // Only rewrite if we have context indicating this is a proxied request
128
+ if (!context.upstream || !originalUrl || !packageName || !version) {
129
+ return originalUrl
130
+ }
131
+
132
+ try {
133
+ // Extract the protocol and host from the context or use defaults
134
+ const protocol = context.protocol || 'http'
135
+ const host = context.host || 'localhost:1337'
136
+ const upstream = context.upstream
137
+
138
+ // Create the new tarball URL pointing to our registry
139
+ // For scoped packages like @scope/package, the filename should be package-version.tgz
140
+ // For unscoped packages like package, the filename should be package-version.tgz
141
+ const packageBaseName = packageName.includes('/') ? packageName.split('/')[1] : packageName
142
+ const filename = `${packageBaseName}-${version}.tgz`
143
+ const newUrl = `${protocol}://${host}/${upstream}/${packageName}/-/${filename}`
144
+
145
+ console.log(`[TARBALL_REWRITE] ${originalUrl} -> ${newUrl}`)
146
+ return newUrl
147
+ } catch (err) {
148
+ console.error(`[ERROR] Failed to rewrite tarball URL: ${(err as Error).message}`)
149
+ return originalUrl
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Helper function to properly decode scoped package names from URL parameters
155
+ * Handles cases where special characters in package names are URL-encoded
156
+ */
157
+ function decodePackageName(scope: string, pkg?: string): string | null {
158
+ if (!scope) return null
159
+
160
+ // Decode URL-encoded characters in both scope and pkg
161
+ const decodedScope = decodeURIComponent(scope)
162
+ const decodedPkg = pkg ? decodeURIComponent(pkg) : null
163
+
164
+ // Handle scoped packages correctly
165
+ if (decodedScope.startsWith('@')) {
166
+ // If we have both scope and pkg, combine them
167
+ if (decodedPkg && decodedPkg !== '-') {
168
+ return `${decodedScope}/${decodedPkg}`
169
+ }
170
+
171
+ // If scope contains an encoded slash, it might be the full package name
172
+ if (decodedScope.includes('/')) {
173
+ return decodedScope
174
+ }
175
+
176
+ // Just the scope
177
+ return decodedScope
178
+ } else {
179
+ // Unscoped package - scope is actually the package name
180
+ return decodedScope
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Determines if a package is available only through proxy or is locally published
186
+ * A package is considered proxied if it doesn't exist locally but PROXY is enabled
187
+ */
188
+ function isProxiedPackage(packageData: ParsedPackage | null): boolean {
189
+ // If the package doesn't exist locally but PROXY is enabled
190
+ if (!packageData && PROXY) {
191
+ return true
192
+ }
193
+
194
+ // If the package is marked as proxied (has a source field indicating where it came from)
195
+ if (packageData && (packageData as any).source === 'proxy') {
196
+ return true
197
+ }
198
+
199
+ return false
200
+ }
201
+
202
+ export async function getPackageTarball(c: HonoContext) {
203
+ try {
204
+ let { scope, pkg } = c.req.param() as { scope: string; pkg: string }
205
+ const acceptsIntegrity = c.req.header('accepts-integrity')
206
+
207
+ console.log(`[DEBUG] getPackageTarball called with pkg="${pkg}", path="${c.req.path}"`)
208
+
209
+ // Handle scoped and unscoped packages correctly with URL decoding
210
+ try {
211
+ // For tarball requests, if scope is undefined/null, pkg should contain the package name
212
+ if (!scope || scope === 'undefined') {
213
+ if (!pkg) {
214
+ throw new Error('Missing package name')
215
+ }
216
+ pkg = decodeURIComponent(pkg)
217
+ console.log(`[DEBUG] Unscoped package: "${pkg}"`)
218
+ } else {
219
+ const packageName = decodePackageName(scope, pkg)
220
+ if (!packageName) {
221
+ throw new Error('Invalid scoped package name')
222
+ }
223
+ pkg = packageName
224
+ console.log(`[DEBUG] Scoped package: "${pkg}"`)
225
+ }
226
+ } catch (err) {
227
+ console.error(`[ERROR] Failed to parse package name: ${(err as Error).message}`)
228
+ console.error(`[ERROR] Input parameters: scope="${scope}", pkg="${pkg}"`)
229
+ return c.json({ error: 'Invalid package name' }, 400)
230
+ }
231
+
232
+ const tarball = c.req.path.split('/').pop()
233
+ if (!tarball || !tarball.endsWith('.tgz')) {
234
+ console.error(`[ERROR] Invalid tarball name: ${tarball}`)
235
+ return c.json({ error: 'Invalid tarball name' }, 400)
236
+ }
237
+
238
+ const filename = `${pkg}/${tarball}`
239
+
240
+ // If integrity checking is requested, get the expected integrity from manifest
241
+ let expectedIntegrity: string | null = null
242
+ if (acceptsIntegrity) {
243
+ try {
244
+ // Extract version from tarball name
245
+ const versionMatch = tarball.match(new RegExp(`${pkg.split('/').pop()}-(.*)\\.tgz`))
246
+ if (versionMatch) {
247
+ const version = versionMatch[1]
248
+ const spec = `${pkg}@${version}`
249
+
250
+ // Get the version from DB
251
+ const versionData = await c.db.getVersion(spec)
252
+
253
+ if (versionData && versionData.manifest) {
254
+ let manifest: any
255
+ try {
256
+ manifest = typeof versionData.manifest === 'string' ?
257
+ JSON.parse(versionData.manifest) : versionData.manifest
258
+ } catch (e) {
259
+ console.error(`[ERROR] Failed to parse manifest for ${spec}: ${(e as Error).message}`)
260
+ }
261
+
262
+ if (manifest && manifest.dist && manifest.dist.integrity) {
263
+ expectedIntegrity = manifest.dist.integrity
264
+ console.log(`[INTEGRITY] Found expected integrity for ${filename}: ${expectedIntegrity}`)
265
+
266
+ // Simple string comparison with the provided integrity
267
+ if (acceptsIntegrity !== expectedIntegrity) {
268
+ console.error(`[INTEGRITY ERROR] Provided integrity (${acceptsIntegrity}) does not match expected integrity (${expectedIntegrity}) for ${filename}`)
269
+ return c.json({
270
+ error: 'Integrity check failed',
271
+ code: 'EINTEGRITY',
272
+ expected: expectedIntegrity,
273
+ actual: acceptsIntegrity
274
+ }, 400)
275
+ }
276
+
277
+ console.log(`[INTEGRITY] Verified integrity for ${filename}`)
278
+ } else {
279
+ console.log(`[INTEGRITY] No integrity information found in manifest for ${spec}`)
280
+ }
281
+ } else {
282
+ console.log(`[INTEGRITY] No version data found for ${spec}`)
283
+ }
284
+ }
285
+ } catch (err) {
286
+ console.error(`[INTEGRITY ERROR] Error checking integrity for ${filename}: ${(err as Error).message}`)
287
+ }
288
+ }
289
+
290
+ // Try to get the file from our bucket first
291
+ try {
292
+ const file = await c.env.BUCKET.get(filename)
293
+
294
+ // If file exists locally, stream it
295
+ if (file) {
296
+ try {
297
+ // We've already verified integrity above if needed
298
+ const headers = new Headers({
299
+ 'Content-Type': 'application/octet-stream',
300
+ 'Cache-Control': 'public, max-age=31536000',
301
+ })
302
+
303
+ return new Response(file.body, {
304
+ status: 200,
305
+ headers
306
+ })
307
+ } catch (err) {
308
+ console.error(`[ERROR] Failed to stream local tarball ${filename}: ${(err as Error).message}`)
309
+ // Fall through to proxy if available
310
+ }
311
+ }
312
+ } catch (err) {
313
+ console.error(`[STORAGE ERROR] Failed to get tarball from bucket ${filename}: ${(err as Error).message}`)
314
+ // Continue to proxy if available, otherwise fall through to 404
315
+ }
316
+
317
+ // If file doesn't exist and proxying is enabled, try to get it from upstream
318
+ if (PROXY) {
319
+ try {
320
+ // Construct the correct URL for scoped and unscoped packages
321
+ const tarballPath = pkg.includes('/') ?
322
+ `${pkg}/-/${tarball}` :
323
+ `${pkg}/-/${tarball}`
324
+
325
+ const source = `${PROXY_URL}/${tarballPath}`
326
+ console.log(`[PROXY] Fetching tarball from ${source}`)
327
+
328
+ // First do a HEAD request to check size
329
+ const headResponse = await fetch(source, {
330
+ method: 'HEAD',
331
+ headers: {
332
+ 'User-Agent': 'vlt-serverless-registry'
333
+ }
334
+ })
335
+
336
+ if (!headResponse.ok) {
337
+ console.error(`[PROXY ERROR] HEAD request failed for ${filename}: ${headResponse.status}`)
338
+ return c.json({ error: 'Failed to check package size' }, 502)
339
+ }
340
+
341
+ const contentLength = parseInt(headResponse.headers.get('content-length') || '0', 10)
342
+
343
+ // Get the package response first, since we'll need it for all size cases
344
+ const response = await fetch(source, {
345
+ headers: {
346
+ 'Accept': 'application/octet-stream',
347
+ 'User-Agent': 'vlt-serverless-registry'
348
+ }
349
+ })
350
+
351
+ if (!response.ok || !response.body) {
352
+ console.error(`[PROXY ERROR] Failed to fetch package ${filename}: ${response.status}`)
353
+ return c.json({ error: 'Failed to fetch package' }, 502)
354
+ }
355
+
356
+ // For very large packages (100MB+), stream directly to client without storing
357
+ if (contentLength > 100_000_000) {
358
+ console.log(`[PROXY] Package is very large (${contentLength} bytes), streaming directly to client`)
359
+
360
+ const readable = response.body
361
+
362
+ // Return the stream to the client immediately
363
+ return new Response(readable, {
364
+ status: 200,
365
+ headers: new Headers({
366
+ 'Content-Type': 'application/octet-stream',
367
+ 'Content-Length': contentLength.toString(),
368
+ 'Cache-Control': 'public, max-age=31536000'
369
+ })
370
+ })
371
+ }
372
+
373
+ // For medium-sized packages (10-100MB), stream directly to client and store async
374
+ if (contentLength > 10_000_000) {
375
+ // Clone the response since we'll need it twice
376
+ const [clientResponse, storageResponse] = response.body.tee()
377
+
378
+ // No integrity check when storing proxied packages
379
+ c.executionCtx.waitUntil((async () => {
380
+ try {
381
+ await c.env.BUCKET.put(filename, storageResponse, {
382
+ httpMetadata: {
383
+ contentType: 'application/octet-stream',
384
+ cacheControl: 'public, max-age=31536000',
385
+ // Store the integrity value if we have it from the manifest
386
+ ...(expectedIntegrity && { integrity: expectedIntegrity })
387
+ }
388
+ })
389
+ console.log(`[PROXY] Successfully stored tarball ${filename}`)
390
+ } catch (err) {
391
+ console.error(`[STORAGE ERROR] Failed to store tarball ${filename}: ${(err as Error).message}`)
392
+ }
393
+ })())
394
+
395
+ // Stream directly to client
396
+ return new Response(clientResponse, {
397
+ status: 200,
398
+ headers: new Headers({
399
+ 'Content-Type': 'application/octet-stream',
400
+ 'Content-Length': contentLength.toString(),
401
+ 'Cache-Control': 'public, max-age=31536000'
402
+ })
403
+ })
404
+ }
405
+
406
+ // For smaller packages, we can use the tee() approach safely
407
+ const [stream1, stream2] = response.body.tee()
408
+
409
+ // Store in R2 bucket asynchronously without integrity check for proxied packages
410
+ c.executionCtx.waitUntil((async () => {
411
+ try {
412
+ await c.env.BUCKET.put(filename, stream1, {
413
+ httpMetadata: {
414
+ contentType: 'application/octet-stream',
415
+ cacheControl: 'public, max-age=31536000',
416
+ // Store the integrity value if we have it from the manifest
417
+ ...(expectedIntegrity && { integrity: expectedIntegrity })
418
+ }
419
+ })
420
+ console.log(`[PROXY] Successfully stored tarball ${filename}`)
421
+ } catch (err) {
422
+ console.error(`[STORAGE ERROR] Failed to store tarball ${filename}: ${(err as Error).message}`)
423
+ }
424
+ })())
425
+
426
+ // Return the second stream to the client immediately
427
+ return new Response(stream2, {
428
+ status: 200,
429
+ headers: new Headers({
430
+ 'Content-Type': 'application/octet-stream',
431
+ 'Content-Length': contentLength.toString(),
432
+ 'Cache-Control': 'public, max-age=31536000'
433
+ })
434
+ })
435
+
436
+ } catch (err) {
437
+ console.error(`[PROXY ERROR] Network error fetching tarball ${filename}: ${(err as Error).message}`)
438
+ return c.json({ error: 'Failed to contact upstream registry' }, 502)
439
+ }
440
+ }
441
+
442
+ return c.json({ error: 'Not found' }, 404)
443
+ } catch (err) {
444
+ console.error(`[ERROR] Unhandled error in getPackageTarball: ${(err as Error).message}`)
445
+ return c.json({ error: 'Internal server error' }, 500)
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Get a single package version manifest
451
+ */
452
+ export async function getPackageManifest(c: HonoContext) {
453
+ try {
454
+ let { scope, pkg } = c.req.param() as { scope: string; pkg: string }
455
+
456
+ // Handle scoped packages correctly with URL decoding
457
+ try {
458
+ const packageName = decodePackageName(scope, pkg)
459
+
460
+ if (!packageName) {
461
+ throw new Error('Invalid package name')
462
+ }
463
+ pkg = packageName
464
+ } catch (err) {
465
+ console.error(`[ERROR] Failed to parse package name: ${(err as Error).message}`)
466
+ return c.json({ error: 'Invalid package name' }, 400)
467
+ }
468
+
469
+ // Extract version from URL path
470
+ const pathParts = c.req.path.split('/')
471
+ const versionIndex = pathParts.findIndex(part => part === pkg) + 1
472
+ let version = pathParts[versionIndex] || 'latest'
473
+
474
+ // Decode URL-encoded version (e.g., %3E%3D1.0.0%20%3C2.0.0 becomes >=1.0.0 <2.0.0)
475
+ version = decodeURIComponent(version)
476
+
477
+ console.log(`[MANIFEST] Requesting manifest for ${pkg}@${version}`)
478
+
479
+ // If it's a semver range, try to resolve it to a specific version
480
+ let resolvedVersion = version
481
+ if (semver.validRange(version) && !semver.valid(version)) {
482
+ // This is a range, try to find the best matching version
483
+ try {
484
+ const packageData = await c.db.getPackage(pkg)
485
+ if (packageData) {
486
+ const versions = await c.db.getVersionsByPackage(pkg)
487
+ if (versions && versions.length > 0) {
488
+ const availableVersions = versions.map((v: any) => v.version)
489
+ const bestMatch = semver.maxSatisfying(availableVersions, version)
490
+ if (bestMatch) {
491
+ resolvedVersion = bestMatch
492
+ console.log(`[MANIFEST] Resolved range ${version} to version ${resolvedVersion}`)
493
+ }
494
+ }
495
+ }
496
+ } catch (err) {
497
+ console.error(`[ERROR] Failed to resolve version range: ${(err as Error).message}`)
498
+ }
499
+ }
500
+
501
+ // Get the version from our database
502
+ const versionData = await c.db.getVersion(`${pkg}@${resolvedVersion}`)
503
+
504
+ if (versionData) {
505
+ // Convert the full manifest to a slimmed version for the response
506
+ const slimmedManifest = slimManifest(versionData.manifest)
507
+
508
+ // Ensure we have correct name, version and tarball URL
509
+ const ret = {
510
+ ...slimmedManifest,
511
+ name: pkg,
512
+ version: resolvedVersion,
513
+ dist: {
514
+ ...slimmedManifest.dist,
515
+ tarball: `${DOMAIN}/${createFile({ pkg, version: resolvedVersion })}`,
516
+ }
517
+ }
518
+
519
+ // Set proper headers for npm/bun
520
+ c.header('Content-Type', 'application/json')
521
+ c.header('Cache-Control', 'public, max-age=300') // 5 minute cache
522
+
523
+ return c.json(ret, 200)
524
+ }
525
+
526
+ return c.json({ error: 'Version not found' }, 404)
527
+ } catch (err) {
528
+ console.error(`[ERROR] Failed to get manifest: ${(err as Error).message}`)
529
+ return c.json({ error: 'Internal server error' }, 500)
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Get package dist-tags
535
+ */
536
+ export async function getPackageDistTags(c: HonoContext) {
537
+ try {
538
+ let { scope, pkg } = c.req.param() as { scope: string; pkg: string }
539
+ const tag = c.req.param('tag')
540
+
541
+ // Handle scoped packages correctly with URL decoding
542
+ try {
543
+ const packageName = decodePackageName(scope, pkg)
544
+ if (!packageName) {
545
+ throw new Error('Invalid package name')
546
+ }
547
+ pkg = packageName
548
+ } catch (err) {
549
+ console.error(`[ERROR] Failed to parse package name: ${(err as Error).message}`)
550
+ return c.json({ error: 'Invalid package name' }, 400)
551
+ }
552
+
553
+ console.log(`[DIST-TAGS] Getting dist-tags for ${pkg}${tag ? ` (tag: ${tag})` : ''}`)
554
+
555
+ const packageData = await c.db.getPackage(pkg)
556
+
557
+ if (!packageData) {
558
+ return c.json({ error: 'Package not found' }, 404)
559
+ }
560
+
561
+ const distTags = packageData.tags || {}
562
+
563
+ if (tag) {
564
+ // Return specific tag
565
+ if (distTags[tag]) {
566
+ return c.json({ [tag]: distTags[tag] })
567
+ } else {
568
+ return c.json({ error: `Tag '${tag}' not found` }, 404)
569
+ }
570
+ } else {
571
+ // Return all tags
572
+ return c.json(distTags)
573
+ }
574
+ } catch (err) {
575
+ console.error(`[ERROR] Failed to get dist-tags: ${(err as Error).message}`)
576
+ return c.json({ error: 'Internal server error' }, 500)
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Set/update a package dist-tag
582
+ */
583
+ export async function putPackageDistTag(c: HonoContext) {
584
+ try {
585
+ let { scope, pkg } = c.req.param() as { scope: string; pkg: string }
586
+ const tag = c.req.param('tag')
587
+
588
+ // Handle scoped packages correctly with URL decoding
589
+ try {
590
+ const packageName = decodePackageName(scope, pkg)
591
+ if (!packageName) {
592
+ throw new Error('Invalid package name')
593
+ }
594
+ pkg = packageName
595
+ } catch (err) {
596
+ console.error(`[ERROR] Failed to parse package name: ${(err as Error).message}`)
597
+ return c.json({ error: 'Invalid package name' }, 400)
598
+ }
599
+
600
+ const version = await c.req.text()
601
+
602
+ if (!tag || !version) {
603
+ return c.json({ error: 'Tag and version are required' }, 400)
604
+ }
605
+
606
+ console.log(`[DIST-TAGS] Setting ${pkg}@${tag} -> ${version}`)
607
+
608
+ const packageData = await c.db.getPackage(pkg)
609
+
610
+ if (!packageData) {
611
+ return c.json({ error: 'Package not found' }, 404)
612
+ }
613
+
614
+ const distTags = packageData.tags || {}
615
+ distTags[tag] = version
616
+
617
+ await c.db.upsertPackage(pkg, distTags)
618
+
619
+ return c.json(distTags, 201)
620
+ } catch (err) {
621
+ console.error(`[ERROR] Failed to set dist-tag: ${(err as Error).message}`)
622
+ return c.json({ error: 'Internal server error' }, 500)
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Delete a package dist-tag
628
+ */
629
+ export async function deletePackageDistTag(c: HonoContext) {
630
+ try {
631
+ let { scope, pkg } = c.req.param() as { scope: string; pkg: string }
632
+ const tag = c.req.param('tag')
633
+
634
+ // Handle scoped packages correctly with URL decoding
635
+ try {
636
+ const packageName = decodePackageName(scope, pkg)
637
+ if (!packageName) {
638
+ throw new Error('Invalid package name')
639
+ }
640
+ pkg = packageName
641
+ } catch (err) {
642
+ console.error(`[ERROR] Failed to parse package name: ${(err as Error).message}`)
643
+ return c.json({ error: 'Invalid package name' }, 400)
644
+ }
645
+
646
+ if (!tag) {
647
+ return c.json({ error: 'Tag is required' }, 400)
648
+ }
649
+
650
+ if (tag === 'latest') {
651
+ return c.json({ error: 'Cannot delete latest tag' }, 400)
652
+ }
653
+
654
+ console.log(`[DIST-TAGS] Deleting ${pkg}@${tag}`)
655
+
656
+ const packageData = await c.db.getPackage(pkg)
657
+
658
+ if (!packageData) {
659
+ return c.json({ error: 'Package not found' }, 404)
660
+ }
661
+
662
+ const distTags = packageData.tags || {}
663
+
664
+ if (!distTags[tag]) {
665
+ return c.json({ error: `Tag '${tag}' not found` }, 404)
666
+ }
667
+
668
+ delete distTags[tag]
669
+
670
+ await c.db.upsertPackage(pkg, distTags)
671
+
672
+ return c.json(distTags)
673
+ } catch (err) {
674
+ console.error(`[ERROR] Failed to delete dist-tag: ${(err as Error).message}`)
675
+ return c.json({ error: 'Internal server error' }, 500)
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Handle general package routes (packument, manifest, tarball)
681
+ */
682
+ export async function handlePackageRoute(c: HonoContext) {
683
+ try {
684
+ const path = c.req.path
685
+
686
+ // Check if this is a tarball request
687
+ if (path.includes('/-/')) {
688
+ return getPackageTarball(c)
689
+ }
690
+
691
+ // Check if this has a version (manifest request)
692
+ const pathParts = path.split('/')
693
+ if (pathParts.length >= 3 && pathParts[2] && !pathParts[2].startsWith('-')) {
694
+ return getPackageManifest(c)
695
+ }
696
+
697
+ // Otherwise it's a packument request
698
+ return getPackagePackument(c)
699
+ } catch (err) {
700
+ console.error(`[ERROR] Failed to handle package route: ${(err as Error).message}`)
701
+ return c.json({ error: 'Internal server error' }, 500)
702
+ }
703
+ }
704
+
705
+ export async function getPackagePackument(c: HonoContext) {
706
+ try {
707
+ const name = c.req.param('pkg')
708
+ const scope = c.req.param('scope')
709
+ // Get the versionRange query parameter
710
+ const versionRange = c.req.query('versionRange')
711
+
712
+ console.log(`[DEBUG] getPackagePackument called for: name=${name}, scope=${scope}${versionRange ? `, with version range: ${versionRange}` : ''}`)
713
+
714
+ if (!name) {
715
+ console.log(`[ERROR] No package name provided in parameters`)
716
+ return c.json({ error: 'Package name is required' }, 400)
717
+ }
718
+
719
+ // Check if versionRange is a valid semver range
720
+ const isValidRange = versionRange && semver.validRange(versionRange)
721
+ if (versionRange && !isValidRange) {
722
+ console.log(`[DEBUG] Invalid semver range provided: ${versionRange}`)
723
+ return c.json({ error: `Invalid semver range: ${versionRange}` }, 400)
724
+ }
725
+
726
+ // Use racing cache strategy when PROXY is enabled or upstream is specified
727
+ const upstream = (c as any).upstream || (PROXY ? 'npm' : null)
728
+ if (upstream) {
729
+ console.log(`[RACING] Using racing cache strategy for packument: ${name} from upstream: ${upstream}`)
730
+
731
+ const fetchUpstreamFn = async () => {
732
+ console.log(`[RACING] Fetching packument from upstream for: ${name}`)
733
+
734
+ // Get the appropriate upstream configuration
735
+ const upstreamConfig = getUpstreamConfig(upstream)
736
+ if (!upstreamConfig) {
737
+ throw new Error(`Unknown upstream: ${upstream}`)
738
+ }
739
+
740
+ const upstreamUrl = buildUpstreamUrl(upstreamConfig, name)
741
+ console.log(`[RACING] Fetching from URL: ${upstreamUrl}`)
742
+
743
+ const response = await fetch(upstreamUrl, {
744
+ headers: {
745
+ 'Accept': 'application/json',
746
+ 'User-Agent': 'vlt-serverless-registry'
747
+ }
748
+ })
749
+
750
+ if (!response.ok) {
751
+ if (response.status === 404) {
752
+ throw new Error(`Package not found: ${name}`)
753
+ }
754
+ throw new Error(`Upstream returned ${response.status}`)
755
+ }
756
+
757
+ const upstreamData = await response.json() as any
758
+ console.log(`[RACING] Successfully fetched packument for: ${name}, has ${Object.keys(upstreamData.versions || {}).length} versions`)
759
+
760
+ // Prepare data for storage with consistent structure
761
+ const packageData = {
762
+ name,
763
+ 'dist-tags': upstreamData['dist-tags'] || { latest: Object.keys(upstreamData.versions || {}).pop() || '' },
764
+ versions: {} as Record<string, any>,
765
+ time: {
766
+ modified: upstreamData.time?.modified || new Date().toISOString()
767
+ } as Record<string, string>
768
+ }
769
+
770
+ // Store timing information for each version
771
+ if (upstreamData.time) {
772
+ Object.entries(upstreamData.time).forEach(([version, time]) => {
773
+ if (version !== 'modified' && version !== 'created') {
774
+ packageData.time[version] = time as string
775
+ }
776
+ })
777
+ }
778
+
779
+ // Process versions and apply version range filter if needed
780
+ if (upstreamData.versions) {
781
+ const protocol = new URL(c.req.url).protocol.slice(0, -1) // Remove trailing ':'
782
+ const host = c.req.header('host') || 'localhost:1337'
783
+ const context = { protocol, host, upstream }
784
+
785
+ Object.entries(upstreamData.versions).forEach(([version, manifest]) => {
786
+ // Skip versions that don't satisfy the range if a valid range is provided
787
+ if (isValidRange && !semver.satisfies(version, versionRange)) {
788
+ return
789
+ }
790
+
791
+ // Create a slimmed version of the manifest for the response with context for URL rewriting
792
+ packageData.versions[version] = slimManifest(manifest as any, context)
793
+ })
794
+ }
795
+
796
+ // Return just the packageData for caching - the cache function handles storage metadata separately
797
+ return packageData
798
+ }
799
+
800
+ try {
801
+ const result = await getCachedPackageWithRefresh(c, name, fetchUpstreamFn, {
802
+ packumentTtlMinutes: 5,
803
+ upstream
804
+ }) as any
805
+
806
+ if (result.fromCache) {
807
+ console.log(`[RACING] Using cached data for: ${name}${result.stale ? ' (stale)' : ''}`)
808
+
809
+ // If we have cached data, still need to check if we need to filter by version range
810
+ if (isValidRange && result.package?.versions) {
811
+ const filteredVersions: Record<string, any> = {}
812
+ Object.keys(result.package.versions).forEach(version => {
813
+ if (semver.satisfies(version, versionRange)) {
814
+ filteredVersions[version] = result.package.versions[version]
815
+ }
816
+ })
817
+ result.package.versions = filteredVersions
818
+ }
819
+
820
+ return c.json(result.package, 200)
821
+ } else {
822
+ console.log(`[RACING] Using fresh upstream data for: ${name}`)
823
+ return c.json(result.package, 200)
824
+ }
825
+ } catch (error) {
826
+ console.error(`[RACING ERROR] Failed to get package ${name}: ${(error as Error).message}`)
827
+
828
+ // Return more specific error codes
829
+ if ((error as Error).message.includes('Package not found')) {
830
+ return c.json({ error: `Package '${name}' not found` }, 404)
831
+ }
832
+
833
+ return c.json({ error: 'Failed to fetch package data' }, 502)
834
+ }
835
+ }
836
+
837
+ // Fallback to original logic when PROXY is disabled
838
+ const pkg = await c.db.getPackage(name)
839
+ const now = new Date()
840
+
841
+ // Initialize the consistent packument response structure
842
+ const packageData = {
843
+ name,
844
+ 'dist-tags': { latest: '' } as any,
845
+ versions: {} as Record<string, any>,
846
+ time: {
847
+ modified: now.toISOString()
848
+ } as Record<string, string>
849
+ }
850
+
851
+ if (pkg) {
852
+ // Update dist-tags from the database
853
+ packageData['dist-tags'] = pkg.tags || { latest: '' }
854
+
855
+ // Update modified time
856
+ if (pkg.lastUpdated) {
857
+ packageData.time.modified = pkg.lastUpdated
858
+ }
859
+ }
860
+
861
+ // Get all versions for this package
862
+ try {
863
+ const allVersions = await c.db.getVersionsByPackage(name)
864
+
865
+ if (allVersions && allVersions.length > 0) {
866
+ console.log(`[DEBUG] Found ${allVersions.length} versions for ${name} in the database`)
867
+
868
+ // Add all versions to the packument, use slimmed manifests
869
+ for (const versionData of allVersions) {
870
+ // Skip versions that don't satisfy the version range if provided
871
+ if (isValidRange && !semver.satisfies((versionData as any).version, versionRange)) {
872
+ continue
873
+ }
874
+
875
+ // Use slimManifest to create a smaller response
876
+ packageData.versions[(versionData as any).version] = slimManifest((versionData as any).manifest)
877
+ packageData.time[(versionData as any).version] = (versionData as any).published_at
878
+ }
879
+ } else {
880
+ console.log(`[DEBUG] No versions found for ${name} in the database`)
881
+
882
+ // Add at least the latest version as a fallback if it satisfies the range
883
+ const latestVersion = packageData['dist-tags'].latest
884
+ if (latestVersion && (!isValidRange || semver.satisfies(latestVersion, versionRange))) {
885
+ const versionData = await c.db.getVersion(`${name}@${latestVersion}`)
886
+ if (versionData) {
887
+ packageData.versions[latestVersion] = slimManifest(versionData.manifest)
888
+ packageData.time[latestVersion] = (versionData as any).published_at
889
+ } else {
890
+ // Create a mock version for testing
891
+ packageData.versions[latestVersion] = {
892
+ name: name,
893
+ version: latestVersion,
894
+ description: `Mock package for ${name}`,
895
+ dist: {
896
+ tarball: `${DOMAIN}/${name}/-/${name}-${latestVersion}.tgz`
897
+ }
898
+ }
899
+ }
900
+ }
901
+ }
902
+ } catch (err) {
903
+ console.error(`[DB ERROR] Failed to get versions for package ${name}: ${(err as Error).message}`)
904
+
905
+ // Create a basic version if none are found
906
+ const latestVersion = packageData['dist-tags'].latest
907
+ if (latestVersion) {
908
+ packageData.versions[latestVersion] = {
909
+ name: name,
910
+ version: latestVersion,
911
+ description: `Package ${name}`,
912
+ dist: {
913
+ tarball: `${DOMAIN}/${name}/-/${name}-${latestVersion}.tgz`
914
+ }
915
+ }
916
+ }
917
+ }
918
+
919
+ return c.json(packageData, 200)
920
+ } catch (err) {
921
+ console.error(`[ERROR] Failed to get packument: ${(err as Error).message}`)
922
+ return c.json({ error: 'Internal server error' }, 500)
923
+ }
924
+ }