@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,466 @@
1
+ /**
2
+ * Cache utilities for efficient upstream package management
3
+ *
4
+ * Strategy:
5
+ * - Return cached data immediately when available (even if stale)
6
+ * - Queue background refresh if cache is stale
7
+ * - Only fetch upstream synchronously if no cache exists
8
+ * - Use Cloudflare Queues for reliable background processing
9
+ */
10
+
11
+ import type {
12
+ HonoContext,
13
+ CacheOptions,
14
+ CacheResult,
15
+ CacheValidation,
16
+ QueueMessage,
17
+ ParsedPackage,
18
+ ParsedVersion,
19
+ PackageManifest
20
+ } from '../../types.ts'
21
+
22
+ /**
23
+ * Stale-while-revalidate cache strategy for package data
24
+ * Returns stale cache immediately and refreshes in background via queue
25
+ */
26
+ export async function getCachedPackageWithRefresh<T>(
27
+ c: HonoContext,
28
+ packageName: string,
29
+ fetchUpstreamFn: () => Promise<T>,
30
+ options: CacheOptions = {}
31
+ ): Promise<CacheResult<T>> {
32
+ const {
33
+ packumentTtlMinutes = 5, // Short TTL for packuments (they change frequently)
34
+ staleWhileRevalidateMinutes = 60, // Allow stale data for up to 1 hour while refreshing
35
+ forceRefresh = false,
36
+ upstream = 'npm'
37
+ } = options
38
+
39
+ // If forcing refresh, skip cache entirely
40
+ if (forceRefresh) {
41
+ console.log(`[CACHE] Force refresh requested for: ${packageName}`)
42
+ const upstreamData = await fetchUpstreamFn()
43
+ if (upstreamData) {
44
+ c.waitUntil?.(cachePackageData(c, packageName, upstreamData, options))
45
+ }
46
+ return {
47
+ package: upstreamData,
48
+ fromCache: false,
49
+ }
50
+ }
51
+
52
+ try {
53
+ // Get cached data first
54
+ const cachedResult = await getCachedPackageData(c, packageName, packumentTtlMinutes, staleWhileRevalidateMinutes)
55
+
56
+ if (cachedResult.data) {
57
+ const { valid, stale, data } = cachedResult
58
+
59
+ if (valid) {
60
+ // Cache is fresh - return immediately
61
+ console.log(`[CACHE] Using fresh cached package: ${packageName}`)
62
+ return {
63
+ package: data as T,
64
+ fromCache: true,
65
+ stale: false
66
+ }
67
+ } else if (stale) {
68
+ // Cache is stale but within stale-while-revalidate window
69
+ // Return stale data immediately and queue background refresh
70
+ console.log(`[CACHE] Using stale data for ${packageName}, queuing background refresh`)
71
+
72
+ // Queue background refresh using Cloudflare Queues
73
+ if (c.env.CACHE_REFRESH_QUEUE) {
74
+ await queuePackageRefresh(c, packageName, upstream, fetchUpstreamFn, options)
75
+ } else {
76
+ // Fallback to waitUntil if queue not available
77
+ console.log(`[CACHE] Queue not available, using waitUntil fallback for ${packageName}`)
78
+ c.waitUntil?.(refreshPackageInBackground(c, packageName, fetchUpstreamFn, options))
79
+ }
80
+
81
+ return {
82
+ package: data as T,
83
+ fromCache: true,
84
+ stale: true
85
+ }
86
+ }
87
+ }
88
+
89
+ // No cache data available - fetch upstream synchronously
90
+ console.log(`[CACHE] No cache available for ${packageName}, fetching upstream`)
91
+ const upstreamData = await fetchUpstreamFn()
92
+
93
+ // Cache the fresh data in background
94
+ c.waitUntil?.(cachePackageData(c, packageName, upstreamData, options))
95
+
96
+ return {
97
+ package: upstreamData,
98
+ fromCache: false,
99
+ stale: false
100
+ }
101
+
102
+ } catch (error) {
103
+ console.error(`[CACHE ERROR] Failed to get cached package ${packageName}: ${(error as Error).message}`)
104
+ throw error
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Queue a package refresh job using Cloudflare Queues
110
+ * Note: We can't serialize functions, so we'll need to recreate the fetch function in the queue consumer
111
+ */
112
+ async function queuePackageRefresh<T>(
113
+ c: HonoContext,
114
+ packageName: string,
115
+ upstream: string,
116
+ fetchUpstreamFn: () => Promise<T>,
117
+ options: CacheOptions
118
+ ): Promise<void> {
119
+ try {
120
+ const message: QueueMessage = {
121
+ type: 'package_refresh',
122
+ packageName,
123
+ upstream,
124
+ timestamp: Date.now(),
125
+ options: {
126
+ packumentTtlMinutes: options.packumentTtlMinutes || 5,
127
+ upstream: options.upstream || 'npm'
128
+ }
129
+ }
130
+
131
+ await c.env.CACHE_REFRESH_QUEUE.send(message)
132
+ console.log(`[QUEUE] Queued refresh for package: ${packageName}`)
133
+ } catch (error) {
134
+ console.error(`[QUEUE ERROR] Failed to queue refresh for ${packageName}: ${(error as Error).message}`)
135
+ // Fallback to immediate background refresh
136
+ c.waitUntil?.(refreshPackageInBackground(c, packageName, fetchUpstreamFn, options))
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Background refresh function (fallback when queue is not available)
142
+ */
143
+ async function refreshPackageInBackground<T>(
144
+ c: HonoContext,
145
+ packageName: string,
146
+ fetchUpstreamFn: () => Promise<T>,
147
+ options: CacheOptions
148
+ ): Promise<void> {
149
+ try {
150
+ console.log(`[BACKGROUND] Refreshing package data for: ${packageName}`)
151
+ const upstreamData = await fetchUpstreamFn()
152
+ await cachePackageData(c, packageName, upstreamData, options)
153
+ console.log(`[BACKGROUND] Successfully refreshed package: ${packageName}`)
154
+ } catch (error) {
155
+ console.error(`[BACKGROUND ERROR] Failed to refresh package ${packageName}: ${(error as Error).message}`)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Enhanced cache data retrieval with stale-while-revalidate support
161
+ */
162
+ async function getCachedPackageData(
163
+ c: HonoContext,
164
+ packageName: string,
165
+ ttlMinutes: number,
166
+ staleWhileRevalidateMinutes: number
167
+ ): Promise<CacheValidation> {
168
+ try {
169
+ const cachedPackage = await c.db.getCachedPackage(packageName)
170
+
171
+ if (!cachedPackage || !cachedPackage.cachedAt || cachedPackage.origin !== 'upstream') {
172
+ return { valid: false, stale: false, data: null }
173
+ }
174
+
175
+ const cacheTime = new Date(cachedPackage.cachedAt).getTime()
176
+ const now = new Date().getTime()
177
+ const ttlMs = ttlMinutes * 60 * 1000
178
+ const staleMs = staleWhileRevalidateMinutes * 60 * 1000
179
+
180
+ const age = now - cacheTime
181
+ const isValid = age < ttlMs
182
+ const isStale = age < staleMs // Still usable even if stale
183
+
184
+ return {
185
+ valid: isValid,
186
+ stale: isStale && !isValid, // Stale means expired but within stale window
187
+ data: cachedPackage,
188
+ }
189
+ } catch (error) {
190
+ console.error(`[DB ERROR] Failed to get cached package data for ${packageName}: ${(error as Error).message}`)
191
+ return { valid: false, stale: false, data: null }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Stale-while-revalidate cache strategy for version data
197
+ * Returns stale cache immediately and refreshes in background via queue
198
+ */
199
+ export async function getCachedVersionWithRefresh<T>(
200
+ c: HonoContext,
201
+ spec: string,
202
+ fetchUpstreamFn: () => Promise<T>,
203
+ options: CacheOptions = {}
204
+ ): Promise<CacheResult<T>> {
205
+ const {
206
+ manifestTtlMinutes = 525600, // 1 year TTL for manifests
207
+ staleWhileRevalidateMinutes = 1051200, // Allow stale for 2 years (manifests rarely change)
208
+ forceRefresh = false,
209
+ upstream = 'npm'
210
+ } = options
211
+
212
+ // If forcing refresh, skip cache entirely
213
+ if (forceRefresh) {
214
+ console.log(`[CACHE] Force refresh requested for version: ${spec}`)
215
+ const upstreamData = await fetchUpstreamFn()
216
+ if (upstreamData) {
217
+ c.waitUntil?.(cacheVersionData(c, spec, upstreamData, options))
218
+ }
219
+ return {
220
+ version: upstreamData,
221
+ fromCache: false,
222
+ }
223
+ }
224
+
225
+ try {
226
+ // Get cached data first
227
+ const cachedResult = await getCachedVersionData(c, spec, manifestTtlMinutes, staleWhileRevalidateMinutes)
228
+
229
+ if (cachedResult.data) {
230
+ const { valid, stale, data } = cachedResult
231
+
232
+ if (valid) {
233
+ // Cache is fresh - return immediately
234
+ console.log(`[CACHE] Using fresh cached version: ${spec}`)
235
+ return {
236
+ version: data as T,
237
+ fromCache: true,
238
+ stale: false
239
+ }
240
+ } else if (stale) {
241
+ // Cache is stale but within stale-while-revalidate window
242
+ // Return stale data immediately and queue background refresh
243
+ console.log(`[CACHE] Using stale data for ${spec}, queuing background refresh`)
244
+
245
+ // Queue background refresh using Cloudflare Queues
246
+ if (c.env.CACHE_REFRESH_QUEUE) {
247
+ await queueVersionRefresh(c, spec, upstream, fetchUpstreamFn, options)
248
+ } else {
249
+ // Fallback to waitUntil if queue not available
250
+ console.log(`[CACHE] Queue not available, using waitUntil fallback for ${spec}`)
251
+ c.waitUntil?.(refreshVersionInBackground(c, spec, fetchUpstreamFn, options))
252
+ }
253
+
254
+ return {
255
+ version: data as T,
256
+ fromCache: true,
257
+ stale: true
258
+ }
259
+ }
260
+ }
261
+
262
+ // No cache data available - fetch upstream synchronously
263
+ console.log(`[CACHE] No cache available for ${spec}, fetching upstream`)
264
+ const upstreamData = await fetchUpstreamFn()
265
+
266
+ // Cache the fresh data in background
267
+ c.waitUntil?.(cacheVersionData(c, spec, upstreamData, options))
268
+
269
+ return {
270
+ version: upstreamData,
271
+ fromCache: false,
272
+ stale: false
273
+ }
274
+
275
+ } catch (error) {
276
+ console.error(`[CACHE ERROR] Failed to get cached version ${spec}: ${(error as Error).message}`)
277
+ throw error
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Queue a version refresh job using Cloudflare Queues
283
+ */
284
+ async function queueVersionRefresh<T>(
285
+ c: HonoContext,
286
+ spec: string,
287
+ upstream: string,
288
+ fetchUpstreamFn: () => Promise<T>,
289
+ options: CacheOptions
290
+ ): Promise<void> {
291
+ try {
292
+ const message: QueueMessage = {
293
+ type: 'version_refresh',
294
+ spec,
295
+ upstream,
296
+ timestamp: Date.now(),
297
+ options: {
298
+ manifestTtlMinutes: options.manifestTtlMinutes || 525600,
299
+ upstream: options.upstream || 'npm'
300
+ }
301
+ }
302
+
303
+ await c.env.CACHE_REFRESH_QUEUE.send(message)
304
+ console.log(`[QUEUE] Queued refresh for version: ${spec}`)
305
+ } catch (error) {
306
+ console.error(`[QUEUE ERROR] Failed to queue refresh for ${spec}: ${(error as Error).message}`)
307
+ // Fallback to immediate background refresh
308
+ c.waitUntil?.(refreshVersionInBackground(c, spec, fetchUpstreamFn, options))
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Background refresh function for versions (fallback when queue is not available)
314
+ */
315
+ async function refreshVersionInBackground<T>(
316
+ c: HonoContext,
317
+ spec: string,
318
+ fetchUpstreamFn: () => Promise<T>,
319
+ options: CacheOptions
320
+ ): Promise<void> {
321
+ try {
322
+ console.log(`[BACKGROUND] Refreshing version data for: ${spec}`)
323
+ const upstreamData = await fetchUpstreamFn()
324
+ await cacheVersionData(c, spec, upstreamData, options)
325
+ console.log(`[BACKGROUND] Successfully refreshed version: ${spec}`)
326
+ } catch (error) {
327
+ console.error(`[BACKGROUND ERROR] Failed to refresh version ${spec}: ${(error as Error).message}`)
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Enhanced cache data retrieval for versions with stale-while-revalidate support
333
+ */
334
+ async function getCachedVersionData(
335
+ c: HonoContext,
336
+ spec: string,
337
+ ttlMinutes: number,
338
+ staleWhileRevalidateMinutes: number
339
+ ): Promise<CacheValidation> {
340
+ try {
341
+ const cachedVersion = await c.db.getCachedVersion(spec)
342
+
343
+ if (!cachedVersion || !cachedVersion.cachedAt || cachedVersion.origin !== 'upstream') {
344
+ return { valid: false, stale: false, data: null }
345
+ }
346
+
347
+ const cacheTime = new Date(cachedVersion.cachedAt).getTime()
348
+ const now = new Date().getTime()
349
+ const ttlMs = ttlMinutes * 60 * 1000
350
+ const staleMs = staleWhileRevalidateMinutes * 60 * 1000
351
+
352
+ const age = now - cacheTime
353
+ const isValid = age < ttlMs
354
+ const isStale = age < staleMs // Still usable even if stale
355
+
356
+ return {
357
+ valid: isValid,
358
+ stale: isStale && !isValid, // Stale means expired but within stale window
359
+ data: cachedVersion,
360
+ }
361
+ } catch (error) {
362
+ console.error(`[DB ERROR] Failed to get cached version data for ${spec}: ${(error as Error).message}`)
363
+ return { valid: false, stale: false, data: null }
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Cache package data in the database
369
+ */
370
+ async function cachePackageData<T>(
371
+ c: HonoContext,
372
+ packageName: string,
373
+ packageData: T,
374
+ options: CacheOptions
375
+ ): Promise<void> {
376
+ try {
377
+ if (packageData && typeof packageData === 'object' && 'tags' in packageData) {
378
+ const tags = (packageData as any).tags || {}
379
+ await c.db.upsertCachedPackage(packageName, tags, options.upstream || 'npm')
380
+ console.log(`[CACHE] Successfully cached package data: ${packageName}`)
381
+ }
382
+ } catch (error) {
383
+ console.error(`[CACHE ERROR] Failed to cache package data for ${packageName}: ${(error as Error).message}`)
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Cache version data in the database
389
+ */
390
+ async function cacheVersionData<T>(
391
+ c: HonoContext,
392
+ spec: string,
393
+ versionData: T,
394
+ options: CacheOptions
395
+ ): Promise<void> {
396
+ try {
397
+ if (versionData && typeof versionData === 'object') {
398
+ const manifest = versionData as unknown as PackageManifest
399
+ const publishedAt = new Date().toISOString()
400
+ await c.db.upsertCachedVersion(spec, manifest, options.upstream || 'npm', publishedAt)
401
+ console.log(`[CACHE] Successfully cached version data: ${spec}`)
402
+ }
403
+ } catch (error) {
404
+ console.error(`[CACHE ERROR] Failed to cache version data for ${spec}: ${(error as Error).message}`)
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Generate storage path for tarball cache
410
+ */
411
+ export function getTarballStoragePath(
412
+ packageName: string,
413
+ version: string,
414
+ origin: 'local' | 'upstream' = 'local',
415
+ upstream: string | null = null
416
+ ): string {
417
+ const sanitizedName = packageName.replace(/[@\/]/g, '_')
418
+ const sanitizedVersion = version.replace(/[^a-zA-Z0-9.-]/g, '_')
419
+
420
+ if (origin === 'upstream' && upstream) {
421
+ return `tarballs/${upstream}/${sanitizedName}/${sanitizedVersion}.tgz`
422
+ }
423
+
424
+ return `tarballs/local/${sanitizedName}/${sanitizedVersion}.tgz`
425
+ }
426
+
427
+ /**
428
+ * Check if tarball is cached
429
+ */
430
+ export async function isTarballCached(
431
+ c: HonoContext,
432
+ packageName: string,
433
+ version: string,
434
+ origin: 'local' | 'upstream' = 'local',
435
+ upstream: string | null = null
436
+ ): Promise<boolean> {
437
+ try {
438
+ const storagePath = getTarballStoragePath(packageName, version, origin, upstream)
439
+ // This would need to be implemented based on your storage solution
440
+ // For now, return false as a placeholder
441
+ return false
442
+ } catch (error) {
443
+ console.error(`[CACHE ERROR] Failed to check tarball cache: ${(error as Error).message}`)
444
+ return false
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Cache tarball data
450
+ */
451
+ export async function cacheTarball(
452
+ c: HonoContext,
453
+ packageName: string,
454
+ version: string,
455
+ tarballStream: ReadableStream,
456
+ origin: 'local' | 'upstream' = 'local',
457
+ upstream: string | null = null
458
+ ): Promise<void> {
459
+ try {
460
+ const storagePath = getTarballStoragePath(packageName, version, origin, upstream)
461
+ // This would need to be implemented based on your storage solution
462
+ console.log(`[CACHE] Would cache tarball at: ${storagePath}`)
463
+ } catch (error) {
464
+ console.error(`[CACHE ERROR] Failed to cache tarball: ${(error as Error).message}`)
465
+ }
466
+ }
@@ -0,0 +1,44 @@
1
+ import { createDatabaseOperations } from '../db/client.ts'
2
+ import type { D1Database } from '@cloudflare/workers-types'
3
+ import type { DatabaseOperations } from '../../types.ts'
4
+
5
+ /**
6
+ * Creates database operations instance from D1 database
7
+ * @param d1 - The D1 database instance
8
+ * @returns Database operations interface
9
+ */
10
+ export function createDB(d1: D1Database) {
11
+ return createDatabaseOperations(d1)
12
+ }
13
+
14
+ /**
15
+ * Type guard to check if database is available
16
+ * @param db - The database instance to check
17
+ * @returns True if database is available and functional
18
+ */
19
+ export function isDatabaseAvailable(db: any): db is DatabaseOperations {
20
+ return db && typeof db.getPackage === 'function'
21
+ }
22
+
23
+ // Cache the database operations to avoid recreating on every request
24
+ let cachedDbOperations: any = null
25
+
26
+ /**
27
+ * Middleware to mount database operations on the context
28
+ * @param c - The Hono context
29
+ * @param next - The next middleware function
30
+ */
31
+ export async function mountDatabase(c: any, next: any) {
32
+ if (!c.env.DB) {
33
+ throw new Error('Database not found in environment')
34
+ }
35
+
36
+ // Reuse existing database operations if available
37
+ if (!cachedDbOperations) {
38
+ console.log('[DB] Creating new database operations instance')
39
+ cachedDbOperations = createDatabaseOperations(c.env.DB)
40
+ }
41
+
42
+ c.db = cachedDbOperations as any
43
+ await next()
44
+ }