@vltpkg/vsr 0.2.0 → 0.2.2

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.
@@ -1,273 +0,0 @@
1
- /**
2
- * Test suite for stale-while-revalidate cache strategy
3
- */
4
- import { describe, it, expect, beforeEach, vi } from 'vitest'
5
- import { getCachedPackageWithRefresh, getCachedVersionWithRefresh } from '../src/utils/cache.ts'
6
-
7
- describe('Stale-while-revalidate cache strategy', () => {
8
- let mockContext
9
- let mockFetchUpstream
10
- let mockQueue
11
-
12
- beforeEach(() => {
13
- // Mock database operations
14
- const mockDb = {
15
- getCachedPackage: vi.fn(),
16
- upsertCachedPackage: vi.fn(),
17
- getCachedVersion: vi.fn(),
18
- upsertCachedVersion: vi.fn()
19
- }
20
-
21
- // Mock Cloudflare Queue
22
- mockQueue = {
23
- send: vi.fn().mockResolvedValue(true)
24
- }
25
-
26
- // Mock context
27
- mockContext = {
28
- db: mockDb,
29
- env: {
30
- CACHE_REFRESH_QUEUE: mockQueue
31
- },
32
- waitUntil: vi.fn()
33
- }
34
-
35
- // Mock upstream fetch function
36
- mockFetchUpstream = vi.fn().mockResolvedValue({
37
- 'dist-tags': { latest: '1.0.0' },
38
- versions: { '1.0.0': { name: 'test-package', version: '1.0.0' } },
39
- time: { modified: new Date().toISOString() }
40
- })
41
- })
42
-
43
- describe('getCachedPackageWithRefresh', () => {
44
- it('should return fresh cache data immediately without queuing refresh', async () => {
45
- // Mock fresh cache data (within TTL)
46
- const freshCacheTime = new Date(Date.now() - 2 * 60 * 1000) // 2 minutes ago
47
- mockContext.db.getCachedPackage.mockResolvedValue({
48
- name: 'test-package',
49
- tags: { latest: '1.0.0' },
50
- origin: 'upstream',
51
- cachedAt: freshCacheTime.toISOString()
52
- })
53
-
54
- const result = await getCachedPackageWithRefresh(
55
- mockContext,
56
- 'test-package',
57
- mockFetchUpstream,
58
- { packumentTtlMinutes: 5 }
59
- )
60
-
61
- expect(result.fromCache).toBe(true)
62
- expect(result.stale).toBe(false)
63
- expect(mockQueue.send).not.toHaveBeenCalled()
64
- expect(mockFetchUpstream).not.toHaveBeenCalled()
65
- })
66
-
67
- it('should return stale cache data and queue background refresh', async () => {
68
- // Mock stale cache data (beyond TTL but within stale window)
69
- const staleCacheTime = new Date(Date.now() - 10 * 60 * 1000) // 10 minutes ago
70
- mockContext.db.getCachedPackage.mockResolvedValue({
71
- name: 'test-package',
72
- tags: { latest: '1.0.0' },
73
- origin: 'upstream',
74
- cachedAt: staleCacheTime.toISOString()
75
- })
76
-
77
- const result = await getCachedPackageWithRefresh(
78
- mockContext,
79
- 'test-package',
80
- mockFetchUpstream,
81
- {
82
- packumentTtlMinutes: 5,
83
- staleWhileRevalidateMinutes: 60
84
- }
85
- )
86
-
87
- expect(result.fromCache).toBe(true)
88
- expect(result.stale).toBe(true)
89
- expect(mockQueue.send).toHaveBeenCalledWith({
90
- type: 'package_refresh',
91
- packageName: 'test-package',
92
- upstream: 'npm',
93
- timestamp: expect.any(Number),
94
- options: {
95
- packumentTtlMinutes: 5,
96
- upstream: 'npm'
97
- }
98
- })
99
- expect(mockFetchUpstream).not.toHaveBeenCalled()
100
- })
101
-
102
- it('should fetch upstream synchronously when no cache exists', async () => {
103
- // Mock no cache data
104
- mockContext.db.getCachedPackage.mockResolvedValue(null)
105
-
106
- const result = await getCachedPackageWithRefresh(
107
- mockContext,
108
- 'test-package',
109
- mockFetchUpstream,
110
- { packumentTtlMinutes: 5 }
111
- )
112
-
113
- expect(result.fromCache).toBe(false)
114
- expect(result.stale).toBe(false)
115
- expect(mockFetchUpstream).toHaveBeenCalled()
116
- expect(mockContext.waitUntil).toHaveBeenCalled()
117
- })
118
-
119
- it('should fallback to waitUntil when queue is not available', async () => {
120
- // Remove queue from context
121
- mockContext.env.CACHE_REFRESH_QUEUE = null
122
-
123
- // Mock stale cache data
124
- const staleCacheTime = new Date(Date.now() - 10 * 60 * 1000)
125
- mockContext.db.getCachedPackage.mockResolvedValue({
126
- name: 'test-package',
127
- tags: { latest: '1.0.0' },
128
- origin: 'upstream',
129
- cachedAt: staleCacheTime.toISOString()
130
- })
131
-
132
- const result = await getCachedPackageWithRefresh(
133
- mockContext,
134
- 'test-package',
135
- mockFetchUpstream,
136
- {
137
- packumentTtlMinutes: 5,
138
- staleWhileRevalidateMinutes: 60
139
- }
140
- )
141
-
142
- expect(result.fromCache).toBe(true)
143
- expect(result.stale).toBe(true)
144
- expect(mockContext.waitUntil).toHaveBeenCalled()
145
- })
146
-
147
- it('should handle very stale cache data (beyond stale window)', async () => {
148
- // Mock very stale cache data (beyond stale window)
149
- const veryOldCacheTime = new Date(Date.now() - 2 * 60 * 60 * 1000) // 2 hours ago
150
- mockContext.db.getCachedPackage.mockResolvedValue({
151
- name: 'test-package',
152
- tags: { latest: '1.0.0' },
153
- origin: 'upstream',
154
- cachedAt: veryOldCacheTime.toISOString()
155
- })
156
-
157
- const result = await getCachedPackageWithRefresh(
158
- mockContext,
159
- 'test-package',
160
- mockFetchUpstream,
161
- {
162
- packumentTtlMinutes: 5,
163
- staleWhileRevalidateMinutes: 60 // 1 hour stale window
164
- }
165
- )
166
-
167
- // Should fetch fresh data since cache is beyond stale window
168
- expect(result.fromCache).toBe(false)
169
- expect(mockFetchUpstream).toHaveBeenCalled()
170
- })
171
- })
172
-
173
- describe('getCachedVersionWithRefresh', () => {
174
- it('should use longer TTL and stale windows for manifests', async () => {
175
- // Mock stale manifest data (1 year + 1 day old)
176
- const staleManifestTime = new Date(Date.now() - (365 + 1) * 24 * 60 * 60 * 1000)
177
- mockContext.db.getCachedVersion.mockResolvedValue({
178
- spec: 'test-package@1.0.0',
179
- manifest: { name: 'test-package', version: '1.0.0' },
180
- cachedAt: staleManifestTime.toISOString()
181
- })
182
-
183
- const mockFetchManifest = vi.fn().mockResolvedValue({
184
- manifest: { name: 'test-package', version: '1.0.0' },
185
- publishedAt: new Date().toISOString()
186
- })
187
-
188
- const result = await getCachedVersionWithRefresh(
189
- mockContext,
190
- 'test-package@1.0.0',
191
- mockFetchManifest,
192
- {
193
- manifestTtlMinutes: 525600, // 1 year
194
- staleWhileRevalidateMinutes: 1051200 // 2 years
195
- }
196
- )
197
-
198
- expect(result.fromCache).toBe(true)
199
- expect(result.stale).toBe(true)
200
- expect(mockQueue.send).toHaveBeenCalledWith({
201
- type: 'version_refresh',
202
- spec: 'test-package@1.0.0',
203
- upstream: 'npm',
204
- timestamp: expect.any(Number),
205
- options: {
206
- manifestTtlMinutes: 525600,
207
- upstream: 'npm'
208
- }
209
- })
210
- })
211
- })
212
-
213
- describe('TTL and stale window calculations', () => {
214
- it('should correctly calculate cache validity based on age', async () => {
215
- const testCases = [
216
- {
217
- ageMinutes: 3,
218
- ttlMinutes: 5,
219
- staleMinutes: 60,
220
- expectedValid: true,
221
- expectedStale: false,
222
- description: 'fresh cache'
223
- },
224
- {
225
- ageMinutes: 10,
226
- ttlMinutes: 5,
227
- staleMinutes: 60,
228
- expectedValid: false,
229
- expectedStale: true,
230
- description: 'stale but within stale window'
231
- },
232
- {
233
- ageMinutes: 70,
234
- ttlMinutes: 5,
235
- staleMinutes: 60,
236
- expectedValid: false,
237
- expectedStale: false,
238
- description: 'beyond stale window'
239
- }
240
- ]
241
-
242
- for (const testCase of testCases) {
243
- const cacheTime = new Date(Date.now() - testCase.ageMinutes * 60 * 1000)
244
- mockContext.db.getCachedPackage.mockResolvedValue({
245
- name: 'test-package',
246
- tags: { latest: '1.0.0' },
247
- origin: 'upstream',
248
- cachedAt: cacheTime.toISOString()
249
- })
250
-
251
- const result = await getCachedPackageWithRefresh(
252
- mockContext,
253
- 'test-package',
254
- mockFetchUpstream,
255
- {
256
- packumentTtlMinutes: testCase.ttlMinutes,
257
- staleWhileRevalidateMinutes: testCase.staleMinutes
258
- }
259
- )
260
-
261
- if (testCase.expectedValid) {
262
- expect(result.fromCache, testCase.description).toBe(true)
263
- expect(result.stale, testCase.description).toBe(false)
264
- } else if (testCase.expectedStale) {
265
- expect(result.fromCache, testCase.description).toBe(true)
266
- expect(result.stale, testCase.description).toBe(true)
267
- } else {
268
- expect(result.fromCache, testCase.description).toBe(false)
269
- }
270
- }
271
- })
272
- })
273
- })
@@ -1,85 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import app from '../src/index.ts'
3
-
4
- describe('Static Asset Handling', () => {
5
- const mockEnv = {
6
- DB: {
7
- prepare: () => ({
8
- bind: () => ({
9
- all: () => [],
10
- get: () => null,
11
- run: () => ({ success: true })
12
- })
13
- })
14
- },
15
- BUCKET: {
16
- get: () => null,
17
- put: () => Promise.resolve()
18
- }
19
- }
20
-
21
- it('should handle favicon.ico requests gracefully', async () => {
22
- const response = await app.request('/favicon.ico', {}, mockEnv)
23
- expect(response.status).toBe(404)
24
-
25
- const data = await response.json()
26
- expect(data.error).toBe('Not found')
27
- })
28
-
29
- it('should handle robots.txt requests gracefully', async () => {
30
- const response = await app.request('/robots.txt', {}, mockEnv)
31
- expect(response.status).toBe(404)
32
-
33
- const data = await response.json()
34
- expect(data.error).toBe('Not found')
35
- })
36
-
37
- it('should allow asset paths to pass through (handled by Wrangler in production)', async () => {
38
- // Wrangler serves files from src/assets/ at root level, now under /public/ prefix
39
- // In our test environment, these reach our package handler since Wrangler isn't serving them
40
- // In production, Wrangler serves these before they reach Hono
41
- const response = await app.request('/public/styles/styles.css', {}, mockEnv)
42
-
43
- // In test environment, should return 404 from our handler (not blocked by static patterns)
44
- // This proves our static asset detection is not interfering with asset paths
45
- expect(response.status).toBe(404)
46
-
47
- const data = await response.json()
48
- expect(data.error).toBe('Not found')
49
- })
50
-
51
- it('should allow image paths to pass through (handled by Wrangler in production)', async () => {
52
- // Test another asset path that Wrangler would serve
53
- const response = await app.request('/public/images/logo.png', {}, mockEnv)
54
-
55
- // Should not be blocked by static asset pattern detection
56
- expect(response.status).toBe(404)
57
-
58
- const data = await response.json()
59
- expect(data.error).toBe('Not found')
60
- })
61
-
62
- it('should reject static assets that slip through to package handler', async () => {
63
- // Test a file extension that might slip through to the package handler
64
- const response = await app.request('/some-file.png', {}, mockEnv)
65
- expect(response.status).toBe(404)
66
-
67
- const data = await response.json()
68
- expect(data.error).toBe('Not found')
69
- })
70
-
71
- it('should reject manifest.json requests gracefully', async () => {
72
- const response = await app.request('/manifest.json', {}, mockEnv)
73
- expect(response.status).toBe(404)
74
-
75
- const data = await response.json()
76
- expect(data.error).toBe('Not found')
77
- })
78
-
79
- it('should still handle legitimate package requests', async () => {
80
- // Make sure we didn't break legitimate package requests
81
- const response = await app.request('/lodash', {}, mockEnv)
82
- expect(response.status).toBe(302) // Should redirect to default upstream
83
- expect(response.headers.get('location')).toBe('/local/lodash')
84
- })
85
- })
@@ -1,86 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import app from '../src/index.ts'
3
-
4
- describe('Upstream Routing', () => {
5
- const mockEnv = {
6
- DB: {
7
- prepare: () => ({
8
- bind: () => ({
9
- all: () => [],
10
- get: () => null,
11
- run: () => ({ success: true })
12
- })
13
- })
14
- },
15
- BUCKET: {
16
- get: () => null,
17
- put: () => Promise.resolve()
18
- }
19
- }
20
-
21
- beforeEach(() => {
22
- // Reset any mocks
23
- })
24
-
25
- afterEach(() => {
26
- // Clean up
27
- })
28
-
29
- it('should reject reserved upstream names for upstream routes only', async () => {
30
- // Internal routes should work fine
31
- const response1 = await app.request('/-/ping', {}, mockEnv)
32
- expect(response1.status).toBe(200)
33
-
34
- // Hash-based routes should work even though * is reserved
35
- const response2 = await app.request('/*/abc123def456', {}, mockEnv)
36
- expect(response2.status).toBe(501) // Not implemented, but not blocked by validation
37
- const data2 = await response2.json()
38
- expect(data2.error).toContain('Hash-based package lookup not yet implemented')
39
-
40
- // Reserved upstream names should be rejected for upstream routes
41
- const response3 = await app.request('/docs/some-package', {}, mockEnv)
42
- expect(response3.status).toBe(400)
43
- const data3 = await response3.json()
44
- expect(data3.error).toContain('reserved')
45
- })
46
-
47
- it('should redirect root-level packages to default upstream', async () => {
48
- const response = await app.request('/lodash', {}, mockEnv)
49
- expect(response.status).toBe(302)
50
- expect(response.headers.get('location')).toBe('/local/lodash')
51
- })
52
-
53
- it('should redirect scoped packages to default upstream', async () => {
54
- const response = await app.request('/@babel/core', {}, mockEnv)
55
- expect(response.status).toBe(302)
56
- expect(response.headers.get('location')).toBe('/local/@babel/core')
57
- })
58
-
59
- it('should handle upstream package requests', async () => {
60
- const response = await app.request('/npm/lodash', {}, mockEnv)
61
- // The npm upstream is configured and should work correctly
62
- // It might succeed (200) if network is available, or fail with various error codes
63
- expect([200, 400, 404, 501, 502, 503]).toContain(response.status)
64
- })
65
-
66
- it('should handle hash-based package requests', async () => {
67
- const response = await app.request('/*/abc123def456', {}, mockEnv)
68
- expect(response.status).toBe(501)
69
- const data = await response.json()
70
- expect(data.error).toContain('Hash-based package lookup not yet implemented')
71
- })
72
-
73
- it('should handle hash-based tarball requests', async () => {
74
- const response = await app.request('/*/abc123def456/-/package-1.0.0.tgz', {}, mockEnv)
75
- expect(response.status).toBe(501)
76
- const data = await response.json()
77
- expect(data.error).toContain('Hash-based tarball lookup not yet implemented')
78
- })
79
-
80
- it('should preserve existing internal routes', async () => {
81
- const response = await app.request('/-/ping', {}, mockEnv)
82
- expect(response.status).toBe(200)
83
- const data = await response.json()
84
- expect(data.ok).toBe(true)
85
- })
86
- })
@@ -1,84 +0,0 @@
1
- /**
2
- * Creates a mock Hono context for testing
3
- *
4
- * @param {Object} options - Options for creating the context
5
- * @param {Object} options.req - Request properties
6
- * @param {Object} options.db - Mock database client
7
- * @param {string} [options.pkg] - Optional package name (for packageSpec mock)
8
- * @param {string} [options.username] - Optional username (for param mock)
9
- * @returns {Object} Mock Hono context
10
- */
11
- export function createContext(options = {}) {
12
- const { req = {}, db = {}, pkg = null, username = null } = options;
13
-
14
- // Create a mock response
15
- let statusCode = 200;
16
- let responseBody = null;
17
- let responseHeaders = new Map();
18
-
19
- // Create the context object
20
- const context = {
21
- // Request properties
22
- req: {
23
- method: req.method || 'GET',
24
- header: (name) => req.headers?.[name] || null,
25
- param: (name) => {
26
- if (name === 'username' && username) {
27
- return username;
28
- }
29
- if (req.param instanceof Map) {
30
- return req.param.get(name);
31
- }
32
- if (typeof req.param === 'function') {
33
- return req.param(name);
34
- }
35
- return req.param?.[name] || null;
36
- },
37
- query: (name) => {
38
- if (req.query instanceof Map) {
39
- return req.query.get(name);
40
- }
41
- if (typeof req.query === 'function') {
42
- return req.query(name);
43
- }
44
- return req.query?.[name] || null;
45
- },
46
- json: req.json || (async () => (req.body || {})),
47
- body: req.body || {},
48
- ...req
49
- },
50
-
51
- // Database client
52
- db,
53
-
54
- // Mock packageSpec if pkg is provided
55
- pkg,
56
-
57
- // Mock username for parameter access
58
- username,
59
-
60
- // Response methods
61
- status: (code) => {
62
- statusCode = code;
63
- return context;
64
- },
65
-
66
- json: (body, status) => {
67
- responseBody = body;
68
- if (status) statusCode = status;
69
-
70
- return {
71
- status: statusCode,
72
- headers: responseHeaders,
73
- json: async () => responseBody
74
- };
75
- },
76
-
77
- header: (name, value) => {
78
- responseHeaders.set(name, value);
79
- return context;
80
- }
81
- };
82
-
83
- return context;
84
- }