@stacksjs/bun-router 0.0.17 → 0.0.19

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 (153) hide show
  1. package/dist/chunk-2mx7fq49.js +4 -0
  2. package/dist/chunk-a8p83068.js +103 -0
  3. package/dist/{chunk-cgptvjdf.js → chunk-y3xq6wq6.js} +1 -1
  4. package/dist/cli.js +4 -3
  5. package/dist/container/index.js +1149 -211
  6. package/dist/container/service-provider.d.ts +3 -3
  7. package/dist/development/performance-profiler.d.ts +3 -3
  8. package/dist/development/route-debugger.d.ts +1 -1
  9. package/dist/development/route-inspector.d.ts +2 -2
  10. package/dist/index.js +369 -2209
  11. package/dist/observability/integration.d.ts +1 -1
  12. package/dist/response/macros.d.ts +1 -1
  13. package/dist/router/index.d.ts +7 -7
  14. package/dist/router/router.d.ts +0 -32
  15. package/dist/testing/performance-testing.d.ts +2 -2
  16. package/dist/testing/test-client.d.ts +1 -1
  17. package/dist/testing/websocket-testing.d.ts +1 -1
  18. package/package.json +7 -7
  19. package/dist/chunk-1ahs68ys.js +0 -18
  20. package/dist/chunk-g3ybefhg.js +0 -1923
  21. package/src/auth.ts +0 -489
  22. package/src/cache/lru-cache.ts +0 -457
  23. package/src/cache/middleware-memoization.ts +0 -531
  24. package/src/cache/route-cache-warmer.ts +0 -486
  25. package/src/cache/sqlite-cache.ts +0 -783
  26. package/src/cache/streaming-cache.ts +0 -572
  27. package/src/cli/colors.ts +0 -29
  28. package/src/cli/index.ts +0 -287
  29. package/src/cli/middleware.ts +0 -291
  30. package/src/cli/openapi.ts +0 -407
  31. package/src/cli/router.ts +0 -188
  32. package/src/cli/routes.ts +0 -265
  33. package/src/cli/utils.ts +0 -531
  34. package/src/cli.ts +0 -5
  35. package/src/config.ts +0 -322
  36. package/src/container/container.ts +0 -786
  37. package/src/container/contextual-binding.ts +0 -606
  38. package/src/container/decorators.ts +0 -368
  39. package/src/container/index.ts +0 -49
  40. package/src/container/service-provider.ts +0 -596
  41. package/src/development/hot-reload.ts +0 -673
  42. package/src/development/index.ts +0 -499
  43. package/src/development/performance-profiler.ts +0 -717
  44. package/src/development/route-debugger.ts +0 -527
  45. package/src/development/route-inspector.ts +0 -749
  46. package/src/development/typescript-utilities.ts +0 -682
  47. package/src/docs.ts +0 -397
  48. package/src/errors/circuit-breaker.ts +0 -733
  49. package/src/errors/error-handler.ts +0 -569
  50. package/src/errors/error-reporting.ts +0 -672
  51. package/src/errors/exceptions.ts +0 -536
  52. package/src/errors/graceful-degradation.ts +0 -623
  53. package/src/errors/index.ts +0 -21
  54. package/src/errors/router-errors.ts +0 -632
  55. package/src/file-serving/static-files.ts +0 -718
  56. package/src/index.ts +0 -66
  57. package/src/middleware/auth.ts +0 -220
  58. package/src/middleware/content_security_policy.ts +0 -215
  59. package/src/middleware/cors.ts +0 -142
  60. package/src/middleware/csrf.ts +0 -166
  61. package/src/middleware/ddos_protection.ts +0 -279
  62. package/src/middleware/file_security.ts +0 -191
  63. package/src/middleware/file_upload.ts +0 -275
  64. package/src/middleware/helmet.ts +0 -268
  65. package/src/middleware/index.ts +0 -117
  66. package/src/middleware/input_validation.ts +0 -453
  67. package/src/middleware/json_body.ts +0 -37
  68. package/src/middleware/performance_alerting.ts +0 -540
  69. package/src/middleware/performance_dashboard.ts +0 -661
  70. package/src/middleware/performance_monitor.ts +0 -945
  71. package/src/middleware/pipeline.ts +0 -489
  72. package/src/middleware/rate_limit.ts +0 -251
  73. package/src/middleware/request_id.ts +0 -36
  74. package/src/middleware/request_signing.ts +0 -638
  75. package/src/middleware/request_tracer.ts +0 -653
  76. package/src/middleware/response_cache.ts +0 -778
  77. package/src/middleware/security.ts +0 -482
  78. package/src/middleware/security_suite.ts +0 -257
  79. package/src/middleware/session.ts +0 -91
  80. package/src/model-binding/index.ts +0 -18
  81. package/src/model-binding/model-middleware.ts +0 -425
  82. package/src/model-binding/model-registry.ts +0 -550
  83. package/src/model-binding.ts +0 -370
  84. package/src/model-resolver-factory.ts +0 -106
  85. package/src/observability/correlation.ts +0 -691
  86. package/src/observability/health-checks.ts +0 -729
  87. package/src/observability/index.ts +0 -184
  88. package/src/observability/integration.ts +0 -548
  89. package/src/observability/metrics.ts +0 -753
  90. package/src/observability/tracing.ts +0 -638
  91. package/src/optimization/bun-utilities.ts +0 -778
  92. package/src/query-builder-integration.ts +0 -137
  93. package/src/request/context.ts +0 -109
  94. package/src/request/enhanced-request.ts +0 -876
  95. package/src/request/macros.ts +0 -822
  96. package/src/response/macros.ts +0 -665
  97. package/src/response/response-factory.ts +0 -596
  98. package/src/router/api-routes.ts +0 -243
  99. package/src/router/file-based-routing.ts +0 -697
  100. package/src/router/file-streaming.ts +0 -383
  101. package/src/router/fluent-routing.ts +0 -866
  102. package/src/router/group-organization.ts +0 -229
  103. package/src/router/handler-resolver.ts +0 -327
  104. package/src/router/http-methods.ts +0 -407
  105. package/src/router/index.ts +0 -193
  106. package/src/router/middleware-groups.ts +0 -222
  107. package/src/router/middleware-integration.ts +0 -399
  108. package/src/router/middleware.ts +0 -234
  109. package/src/router/model-binding.ts +0 -215
  110. package/src/router/optimized-route-matching.ts +0 -302
  111. package/src/router/route-building.ts +0 -221
  112. package/src/router/route-compiler.ts +0 -691
  113. package/src/router/route-matching.ts +0 -368
  114. package/src/router/route-trie.ts +0 -450
  115. package/src/router/router.ts +0 -1616
  116. package/src/router/server.ts +0 -684
  117. package/src/router/validation-integration.ts +0 -449
  118. package/src/router/view-rendering.ts +0 -233
  119. package/src/router/websocket.ts +0 -100
  120. package/src/routing/route-caching.ts +0 -402
  121. package/src/routing/route-throttling.ts +0 -469
  122. package/src/routing/subdomain-routing.ts +0 -492
  123. package/src/session/database-store.ts +0 -109
  124. package/src/session/file-store.ts +0 -148
  125. package/src/session/index.ts +0 -244
  126. package/src/session/memory-store.ts +0 -88
  127. package/src/session/redis-store.ts +0 -93
  128. package/src/streaming/index.ts +0 -17
  129. package/src/streaming/sse-handler.ts +0 -482
  130. package/src/streaming/stream-handler.ts +0 -552
  131. package/src/testing/auth-testing.ts +0 -448
  132. package/src/testing/file-upload-testing.ts +0 -543
  133. package/src/testing/index.ts +0 -10
  134. package/src/testing/middleware-testing.ts +0 -322
  135. package/src/testing/model-binding-testing.ts +0 -645
  136. package/src/testing/performance-testing.ts +0 -738
  137. package/src/testing/test-client.ts +0 -310
  138. package/src/testing/test-request.ts +0 -315
  139. package/src/testing/test-response.ts +0 -332
  140. package/src/testing/types.ts +0 -224
  141. package/src/testing/websocket-testing.ts +0 -590
  142. package/src/types/controller-types.ts +0 -385
  143. package/src/types/core.ts +0 -683
  144. package/src/types/middleware-types.ts +0 -422
  145. package/src/types/request-response-augmentation.ts +0 -489
  146. package/src/types/route-inference.ts +0 -372
  147. package/src/types.ts +0 -2123
  148. package/src/url.ts +0 -131
  149. package/src/utils/index.ts +0 -1
  150. package/src/utils/query-preservation.ts +0 -201
  151. package/src/utils.ts +0 -395
  152. package/src/validation/validator.ts +0 -688
  153. package/src/websocket/clustering.ts +0 -769
@@ -1,778 +0,0 @@
1
- import type { EnhancedRequest, Middleware, NextFunction } from '../types'
2
- import crypto from 'node:crypto'
3
- import { mkdir } from 'node:fs/promises'
4
- import { join } from 'node:path'
5
-
6
- export interface CacheEntry {
7
- key: string
8
- response: {
9
- status: number
10
- statusText: string
11
- headers: Record<string, string>
12
- // In-memory entries hold the raw bytes; entries round-tripped through
13
- // the JSON file cache come back as a plain number array
14
- body: Uint8Array | number[]
15
- contentType: string
16
- }
17
- createdAt: number
18
- expiresAt: number
19
- etag?: string
20
- lastModified?: string
21
- size: number
22
- }
23
-
24
- export interface ResponseCacheOptions {
25
- enabled?: boolean
26
- storage?: {
27
- type: 'memory' | 'file' | 'hybrid'
28
- directory?: string
29
- maxSize?: number
30
- maxEntries?: number
31
- }
32
- ttl?: {
33
- default: number
34
- routes?: Record<string, number>
35
- methods?: Record<string, number>
36
- }
37
- keyGenerator?: (req: EnhancedRequest) => string
38
- shouldCache?: (req: EnhancedRequest, res: Response) => boolean
39
- varyHeaders?: string[]
40
- compression?: {
41
- enabled: boolean
42
- threshold: number
43
- algorithms: ('gzip' | 'deflate' | 'br')[]
44
- }
45
- invalidation?: {
46
- patterns?: string[]
47
- methods?: string[]
48
- headers?: string[]
49
- }
50
- etag?: {
51
- enabled: boolean
52
- weak: boolean
53
- }
54
- staleWhileRevalidate?: {
55
- enabled: boolean
56
- maxAge: number
57
- }
58
- }
59
-
60
- interface MemoryCacheEntry extends CacheEntry {
61
- accessCount: number
62
- lastAccessed: number
63
- }
64
-
65
- export class ResponseCache implements Middleware {
66
- private options: Required<ResponseCacheOptions>
67
- private memoryCache = new Map<string, MemoryCacheEntry>()
68
- private cacheStats = {
69
- hits: 0,
70
- misses: 0,
71
- sets: 0,
72
- deletes: 0,
73
- size: 0,
74
- memoryUsage: 0,
75
- }
76
-
77
- private cleanupInterval?: ReturnType<typeof setInterval>
78
- private compiledInvalidationPatterns: RegExp[] = []
79
-
80
- constructor(options: ResponseCacheOptions = {}) {
81
- this.options = {
82
- enabled: options.enabled ?? true,
83
- storage: {
84
- type: options.storage?.type ?? 'hybrid',
85
- directory: options.storage?.directory ?? '.cache/responses',
86
- maxSize: options.storage?.maxSize ?? 100 * 1024 * 1024, // 100MB
87
- maxEntries: options.storage?.maxEntries ?? 10000,
88
- ...options.storage,
89
- },
90
- ttl: {
91
- default: options.ttl?.default ?? 300000, // 5 minutes
92
- routes: options.ttl?.routes ?? {},
93
- methods: options.ttl?.methods ?? {
94
- GET: 300000,
95
- HEAD: 300000,
96
- OPTIONS: 60000,
97
- },
98
- ...options.ttl,
99
- },
100
- keyGenerator: options.keyGenerator ?? this.defaultKeyGenerator.bind(this),
101
- shouldCache: options.shouldCache ?? this.defaultShouldCache.bind(this),
102
- varyHeaders: options.varyHeaders ?? ['Accept', 'Accept-Encoding', 'Authorization'],
103
- compression: {
104
- enabled: options.compression?.enabled ?? true,
105
- threshold: options.compression?.threshold ?? 1024,
106
- algorithms: options.compression?.algorithms ?? ['gzip', 'deflate'],
107
- ...options.compression,
108
- },
109
- invalidation: {
110
- patterns: options.invalidation?.patterns ?? [],
111
- methods: options.invalidation?.methods ?? ['POST', 'PUT', 'PATCH', 'DELETE'],
112
- headers: options.invalidation?.headers ?? ['Cache-Control'],
113
- ...options.invalidation,
114
- },
115
- etag: {
116
- enabled: options.etag?.enabled ?? true,
117
- weak: options.etag?.weak ?? true,
118
- ...options.etag,
119
- },
120
- staleWhileRevalidate: {
121
- enabled: options.staleWhileRevalidate?.enabled ?? false,
122
- maxAge: options.staleWhileRevalidate?.maxAge ?? 60000,
123
- ...options.staleWhileRevalidate,
124
- },
125
- } as Required<ResponseCacheOptions>
126
-
127
- // Compile invalidation patterns once — matching per request with
128
- // `new RegExp(pattern)` recompiled the regex on every invalidation check
129
- this.compiledInvalidationPatterns = (this.options.invalidation.patterns ?? [])
130
- .map((pattern) => {
131
- try {
132
- return new RegExp(pattern)
133
- }
134
- catch {
135
- console.warn(`[ResponseCache] Invalid invalidation pattern: ${pattern}`)
136
- return null
137
- }
138
- })
139
- .filter((re): re is RegExp => re !== null)
140
-
141
- this.initializeStorage()
142
- this.startCleanupInterval()
143
- }
144
-
145
- private async initializeStorage(): Promise<void> {
146
- if (this.options.storage.type === 'file' || this.options.storage.type === 'hybrid') {
147
- try {
148
- if (this.options.storage?.directory) {
149
- await mkdir(this.options.storage.directory, { recursive: true })
150
- }
151
- }
152
- catch (error) {
153
- console.warn(`[ResponseCache] Failed to create cache directory: ${error}`)
154
- }
155
- }
156
- }
157
-
158
- private startCleanupInterval(): void {
159
- this.cleanupInterval = setInterval(() => {
160
- void this.cleanup()
161
- }, 60000) // Cleanup every minute
162
- // Background housekeeping must not keep the process alive
163
- this.cleanupInterval.unref?.()
164
- }
165
-
166
- public async cleanup(): Promise<void> {
167
- const now = Date.now()
168
-
169
- // Clean memory cache - check expiration properly
170
- const keysToDelete: string[] = []
171
- for (const [key, entry] of this.memoryCache.entries()) {
172
- if (entry.expiresAt <= now) {
173
- keysToDelete.push(key)
174
- }
175
- }
176
-
177
- // Delete expired keys
178
- for (const key of keysToDelete) {
179
- this.memoryCache.delete(key)
180
- this.cacheStats.deletes++
181
- }
182
-
183
- // Clean file cache if using file storage
184
- if (this.options.storage.type === 'file' || this.options.storage.type === 'hybrid') {
185
- await this.cleanupFileCache()
186
- }
187
-
188
- // Update cache stats after cleanup
189
- this.updateCacheStats()
190
- }
191
-
192
- private async cleanupFileCache(): Promise<void> {
193
- if (!this.options.storage.directory)
194
- return
195
-
196
- try {
197
- const { readdir } = await import('node:fs/promises')
198
- const files = await readdir(this.options.storage.directory)
199
-
200
- for (const file of files) {
201
- if (file.endsWith('.cache')) {
202
- const filePath = join(this.options.storage.directory, file)
203
- const bunFile = Bun.file(filePath)
204
-
205
- if (await bunFile.exists()) {
206
- try {
207
- const content = await bunFile.text()
208
- const entry: CacheEntry = JSON.parse(content)
209
-
210
- if (entry.expiresAt < Date.now()) {
211
- await bunFile.delete()
212
- this.cacheStats.deletes++
213
- }
214
- }
215
- catch {
216
- // Invalid cache file, delete it
217
- await bunFile.delete()
218
- }
219
- }
220
- }
221
- }
222
- }
223
- catch (error) {
224
- console.warn(`[ResponseCache] File cache cleanup failed: ${error}`)
225
- }
226
- }
227
-
228
- private defaultKeyGenerator(req: EnhancedRequest): string {
229
- const url = new URL(req.url)
230
- const varyParts: string[] = []
231
-
232
- // Include vary headers in key
233
- for (const header of this.options.varyHeaders) {
234
- const value = req.headers.get(header)
235
- if (value) {
236
- varyParts.push(`${header}:${value}`)
237
- }
238
- }
239
-
240
- const keyData = [
241
- req.method,
242
- url.pathname,
243
- url.search,
244
- ...varyParts,
245
- ].join('|')
246
-
247
- return crypto.createHash('sha256').update(keyData).digest('hex')
248
- }
249
-
250
- private defaultShouldCache(req: EnhancedRequest, res: Response): boolean {
251
- // Don't cache if disabled
252
- if (!this.options.enabled)
253
- return false
254
-
255
- // Don't cache non-GET/HEAD requests by default
256
- if (!['GET', 'HEAD'].includes(req.method))
257
- return false
258
-
259
- // Don't cache responses with Cache-Control: no-cache or no-store
260
- const cacheControl = res.headers.get('Cache-Control')
261
- if (cacheControl?.includes('no-cache') || cacheControl?.includes('no-store')) {
262
- return false
263
- }
264
-
265
- // Don't cache error responses (4xx, 5xx)
266
- if (res.status >= 400)
267
- return false
268
-
269
- // Don't cache responses with Set-Cookie header
270
- if (res.headers.get('Set-Cookie'))
271
- return false
272
-
273
- return true
274
- }
275
-
276
- private getTTL(req: EnhancedRequest): number {
277
- const method = req.method.toLowerCase()
278
- const url = new URL(req.url)
279
- const pathname = url.pathname
280
-
281
- // Check for route-specific TTL
282
- if (this.options.ttl?.routes) {
283
- for (const [pattern, ttl] of Object.entries(this.options.ttl.routes)) {
284
- if (pathname.includes(pattern)) {
285
- return ttl
286
- }
287
- }
288
- }
289
-
290
- // Check for method-specific TTL
291
- if (this.options.ttl?.methods?.[method]) {
292
- return this.options.ttl.methods[method]
293
- }
294
-
295
- // Return default TTL
296
- return this.options.ttl?.default || 300000 // 5 minutes default
297
- }
298
-
299
- private generateETag(content: string | Uint8Array): string {
300
- const hash = crypto.createHash('sha1')
301
- hash.update(content)
302
- const etag = hash.digest('hex').substring(0, 16)
303
- return this.options.etag.weak ? `W/"${etag}"` : `"${etag}"`
304
- }
305
-
306
- private async getFromMemoryCache(key: string): Promise<CacheEntry | null> {
307
- const entry = this.memoryCache.get(key)
308
- if (!entry)
309
- return null
310
-
311
- // Check if entry is expired
312
- const now = Date.now()
313
- if (entry.expiresAt <= now) {
314
- this.memoryCache.delete(key)
315
- this.cacheStats.deletes++
316
- return null
317
- }
318
-
319
- // Update access time for LRU
320
- entry.lastAccessed = Date.now()
321
- entry.accessCount++
322
-
323
- // Re-insert to update the entry in the map
324
- this.memoryCache.set(key, entry)
325
- return entry
326
- }
327
-
328
- private async getFromFileCache(key: string): Promise<CacheEntry | null> {
329
- if (!this.options.storage.directory)
330
- return null
331
-
332
- try {
333
- const filePath = join(this.options.storage.directory, `${key}.cache`)
334
- const file = Bun.file(filePath)
335
-
336
- if (!(await file.exists())) {
337
- return null
338
- }
339
-
340
- const data = await file.json() as CacheEntry
341
-
342
- // Check if entry is expired
343
- const now = Date.now()
344
- if (data.expiresAt <= now) {
345
- // Delete expired file
346
- try {
347
- const { unlink } = await import('node:fs/promises')
348
- await unlink(filePath)
349
- }
350
- catch (deleteError) {
351
- console.warn(`[ResponseCache] Error deleting expired file cache:`, deleteError)
352
- }
353
- return null
354
- }
355
-
356
- return data
357
- }
358
- catch (error) {
359
- console.warn(`[ResponseCache] Error reading file cache for key ${key}:`, error)
360
- return null
361
- }
362
- }
363
-
364
- private async setMemoryCache(key: string, entry: CacheEntry): Promise<void> {
365
- // If key already exists, don't add as new entry
366
- if (this.memoryCache.has(key)) {
367
- return
368
- }
369
-
370
- // Check memory limits and evict if necessary before adding new entry
371
- while (this.memoryCache.size >= (this.options.storage.maxEntries || 100)) {
372
- this.evictLRU()
373
- }
374
-
375
- const now = Date.now()
376
- const memoryEntry: MemoryCacheEntry = {
377
- ...entry,
378
- accessCount: 1,
379
- lastAccessed: now,
380
- }
381
-
382
- this.memoryCache.set(key, memoryEntry)
383
- this.updateCacheStats()
384
- }
385
-
386
- private async setFileCache(key: string, entry: CacheEntry): Promise<void> {
387
- if (!this.options.storage.directory)
388
- return
389
-
390
- try {
391
- const filePath = join(this.options.storage.directory, `${key}.cache`)
392
- // Uint8Array doesn't JSON-serialize as an array — convert at the
393
- // file boundary only (memory entries keep the compact byte view)
394
- const serializable = entry.response.body instanceof Uint8Array
395
- ? { ...entry, response: { ...entry.response, body: Array.from(entry.response.body) } }
396
- : entry
397
- const content = JSON.stringify(serializable)
398
- await Bun.write(filePath, content)
399
- }
400
- catch (error) {
401
- console.warn(`[ResponseCache] Failed to write file cache: ${error}`)
402
- }
403
- }
404
-
405
- private evictLRU(): void {
406
- if (this.memoryCache.size === 0)
407
- return
408
-
409
- // Find the entry with the oldest lastAccessed time
410
- let oldestKey: string | null = null
411
- let oldestTime = Number.MAX_SAFE_INTEGER
412
-
413
- for (const [key, entry] of this.memoryCache.entries()) {
414
- if (entry.lastAccessed < oldestTime) {
415
- oldestTime = entry.lastAccessed
416
- oldestKey = key
417
- }
418
- }
419
-
420
- if (oldestKey) {
421
- this.memoryCache.delete(oldestKey)
422
- this.cacheStats.deletes++
423
- }
424
- }
425
-
426
- private updateCacheStats(): void {
427
- this.cacheStats.size = this.memoryCache.size
428
- this.cacheStats.memoryUsage = Array.from(this.memoryCache.values())
429
- .reduce((total, entry) => total + entry.size, 0)
430
- }
431
-
432
- private generateCacheKey(req: EnhancedRequest): string {
433
- return this.options.keyGenerator(req)
434
- }
435
-
436
- private async getCachedResponse(key: string): Promise<CacheEntry | null> {
437
- // Try memory cache first
438
- if (this.options.storage.type === 'memory' || this.options.storage.type === 'hybrid') {
439
- const memoryEntry = await this.getFromMemoryCache(key)
440
- if (memoryEntry) {
441
- return memoryEntry
442
- }
443
- }
444
-
445
- // Try file cache
446
- if (this.options.storage.type === 'file' || this.options.storage.type === 'hybrid') {
447
- const fileEntry = await this.getFromFileCache(key)
448
- if (fileEntry) {
449
- // Store in memory cache for faster access
450
- if (this.options.storage.type === 'hybrid') {
451
- await this.setMemoryCache(key, fileEntry)
452
- }
453
- return fileEntry
454
- }
455
- }
456
-
457
- return null
458
- }
459
-
460
- private async setCachedResponse(key: string, entry: CacheEntry): Promise<void> {
461
- // Only increment sets if this is a new entry
462
- const isNewEntry = !this.memoryCache.has(key)
463
-
464
- // Store in memory cache
465
- if (this.options.storage.type === 'memory' || this.options.storage.type === 'hybrid') {
466
- await this.setMemoryCache(key, entry)
467
- }
468
-
469
- // Store in file cache
470
- if (this.options.storage.type === 'file' || this.options.storage.type === 'hybrid') {
471
- await this.setFileCache(key, entry)
472
- }
473
-
474
- if (isNewEntry) {
475
- this.cacheStats.sets++
476
- }
477
- this.updateCacheStats()
478
- }
479
-
480
- private handleConditionalRequest(req: EnhancedRequest, entry: CacheEntry): Response | null {
481
- const ifNoneMatch = req.headers.get('If-None-Match')
482
- const ifModifiedSince = req.headers.get('If-Modified-Since')
483
-
484
- // Check ETag
485
- if (ifNoneMatch && entry.etag) {
486
- if (ifNoneMatch === entry.etag || ifNoneMatch === '*') {
487
- return new Response(null, { status: 304 })
488
- }
489
- }
490
-
491
- // Check Last-Modified
492
- if (ifModifiedSince && entry.lastModified) {
493
- const modifiedSince = new Date(ifModifiedSince)
494
- const lastModified = new Date(entry.lastModified)
495
-
496
- if (lastModified <= modifiedSince) {
497
- return new Response(null, { status: 304 })
498
- }
499
- }
500
-
501
- return null
502
- }
503
-
504
- async handle(req: EnhancedRequest, next: NextFunction): Promise<Response> {
505
- const cacheKey = this.generateCacheKey(req)
506
-
507
- // Check if this is an invalidating request
508
- if (this.options.invalidation.methods?.includes(req.method)) {
509
- const response = await next()
510
- if (response) {
511
- await this.invalidateCache(req)
512
- response.headers.set('X-Cache', 'MISS')
513
- return response
514
- }
515
- return new Response('Internal Server Error', { status: 500 })
516
- }
517
-
518
- // Try to get cached response
519
- let cachedEntry = await this.getCachedResponse(cacheKey)
520
-
521
- // Check if cached entry is expired
522
- if (cachedEntry && cachedEntry.expiresAt <= Date.now()) {
523
- // Remove expired entry
524
- await this.deleteCachedResponse(cacheKey)
525
- cachedEntry = null
526
- }
527
-
528
- if (cachedEntry) {
529
- // Track cache hit
530
- this.cacheStats.hits++
531
-
532
- // Handle conditional requests
533
- const conditionalResponse = this.handleConditionalRequest(req, cachedEntry)
534
- if (conditionalResponse) {
535
- conditionalResponse.headers.set('X-Cache', 'HIT')
536
- return conditionalResponse
537
- }
538
-
539
- // Return cached response
540
- const response = this.createResponseFromEntry(cachedEntry)
541
- if (response) {
542
- response.headers.set('X-Cache', 'HIT')
543
- return response
544
- }
545
- }
546
-
547
- // Track cache miss
548
- this.cacheStats.misses++
549
-
550
- // Cache miss - get response from next middleware
551
- const response = await next()
552
-
553
- if (!response) {
554
- return new Response('Internal Server Error', { status: 500 })
555
- }
556
-
557
- // Set X-Cache header for miss
558
- response.headers.set('X-Cache', 'MISS')
559
-
560
- // Check if response should be cached
561
- if (this.options.shouldCache(req, response)) {
562
- try {
563
- const { entry, response: updatedResponse } = await this.createCacheEntry(req, response)
564
- await this.setCachedResponse(cacheKey, entry)
565
- return updatedResponse
566
- }
567
- catch (error) {
568
- console.warn(`[ResponseCache] Failed to cache response: ${error}`)
569
- }
570
- }
571
-
572
- return response
573
- }
574
-
575
- private async deleteCachedResponse(key: string): Promise<void> {
576
- // Remove from memory cache
577
- if (this.memoryCache.has(key)) {
578
- this.memoryCache.delete(key)
579
- this.cacheStats.deletes++
580
- }
581
-
582
- // Remove from file cache
583
- if (this.options.storage.type === 'file' || this.options.storage.type === 'hybrid') {
584
- try {
585
- const filePath = join(this.options.storage.directory || '.cache/responses', `${key}.cache`)
586
- const bunFile = Bun.file(filePath)
587
- if (await bunFile.exists()) {
588
- await bunFile.delete()
589
- this.cacheStats.deletes++
590
- }
591
- }
592
- catch (error) {
593
- console.warn(`[ResponseCache] Failed to delete file cache: ${error}`)
594
- }
595
- }
596
-
597
- this.updateCacheStats()
598
- }
599
-
600
- private async createCacheEntry(req: EnhancedRequest, res: Response): Promise<{ entry: CacheEntry, response: Response }> {
601
- // Read the body once and rebuild the outgoing response from the same
602
- // bytes — cheaper than clone(), which tees the stream and buffers it
603
- // twice. Bytes are kept as a Uint8Array (the old number[] conversion
604
- // boxed every byte into a JS number array element).
605
- const body = await res.arrayBuffer()
606
- const bodyBytes = new Uint8Array(body)
607
- const ttl = this.getTTL(req)
608
- const now = Date.now()
609
-
610
- const entry: CacheEntry = {
611
- key: this.generateCacheKey(req),
612
- response: {
613
- status: res.status,
614
- statusText: res.statusText,
615
- headers: Object.fromEntries(res.headers.entries()),
616
- body: bodyBytes,
617
- contentType: res.headers.get('Content-Type') || 'text/plain',
618
- },
619
- createdAt: now,
620
- expiresAt: now + ttl,
621
- size: bodyBytes.length,
622
- }
623
-
624
- const outHeaders = new Headers(res.headers)
625
-
626
- // Generate ETag if enabled
627
- if (this.options.etag && this.options.etag.enabled) {
628
- entry.etag = this.generateETag(bodyBytes)
629
- if (entry.etag) {
630
- entry.response.headers.ETag = entry.etag
631
- outHeaders.set('ETag', entry.etag)
632
- }
633
- }
634
-
635
- // Set Last-Modified
636
- entry.lastModified = new Date(now).toUTCString()
637
- entry.response.headers['Last-Modified'] = entry.lastModified
638
- outHeaders.set('Last-Modified', entry.lastModified)
639
-
640
- const outBody = res.status === 204 || res.status === 304 ? null : bodyBytes
641
- return {
642
- entry,
643
- response: new Response(outBody, {
644
- status: res.status,
645
- statusText: res.statusText,
646
- headers: outHeaders,
647
- }),
648
- }
649
- }
650
-
651
- private createResponseFromEntry(entry: CacheEntry): Response {
652
- const headers = new Headers(entry.response.headers)
653
-
654
- // Add cache headers
655
- headers.set('Age', Math.floor((Date.now() - entry.createdAt) / 1000).toString())
656
-
657
- // Convert body back to Uint8Array if it's an array
658
- const body = Array.isArray(entry.response.body)
659
- ? new Uint8Array(entry.response.body)
660
- : entry.response.body
661
-
662
- return new Response(body, {
663
- status: entry.response.status,
664
- statusText: entry.response.statusText,
665
- headers,
666
- })
667
- }
668
-
669
- private async backgroundRevalidate(cacheKey: string, req: EnhancedRequest, next: NextFunction): Promise<void> {
670
- try {
671
- const response = await next()
672
- if (response && this.options.shouldCache(req, response)) {
673
- const { entry } = await this.createCacheEntry(req, response)
674
- await this.setCachedResponse(cacheKey, entry)
675
- }
676
- }
677
- catch (error) {
678
- console.warn(`[ResponseCache] Background revalidation failed: ${error}`)
679
- }
680
- }
681
-
682
- async invalidateCache(req?: EnhancedRequest): Promise<void> {
683
- if (req) {
684
- if (this.compiledInvalidationPatterns.length > 0) {
685
- // Invalidate specific patterns
686
- const url = new URL(req.url)
687
-
688
- for (const pattern of this.compiledInvalidationPatterns) {
689
- if (pattern.test(url.pathname)) {
690
- await this.clearCache()
691
- return
692
- }
693
- }
694
- }
695
- else {
696
- // No specific patterns, clear all cache for invalidating methods
697
- await this.clearCache()
698
- }
699
- }
700
- else {
701
- // Clear all cache
702
- await this.clearCache()
703
- }
704
- }
705
-
706
- async clearCache(): Promise<void> {
707
- // Get current cache size before clearing
708
- const currentSize = this.memoryCache.size
709
-
710
- // Clear memory cache
711
- this.memoryCache.clear()
712
- this.cacheStats.deletes += currentSize
713
-
714
- // Clear file cache if using file storage
715
- if (this.options.storage.type === 'file' || this.options.storage.type === 'hybrid') {
716
- try {
717
- const { readdir, unlink } = await import('node:fs/promises')
718
- const files = await readdir(this.options.storage.directory || '.cache/responses')
719
-
720
- for (const file of files) {
721
- if (file.endsWith('.cache')) {
722
- const filePath = `${this.options.storage.directory || '.cache/responses'}/${file}`
723
- await unlink(filePath)
724
- }
725
- }
726
- }
727
- catch (error) {
728
- console.warn(`[ResponseCache] Failed to clear file cache: ${error}`)
729
- }
730
- }
731
- this.updateCacheStats()
732
- }
733
-
734
- getStats(): { hits: number, misses: number, sets: number, deletes: number, size: number, memoryUsage: number, hitRate: number } {
735
- return {
736
- hits: this.cacheStats.hits,
737
- misses: this.cacheStats.misses,
738
- sets: this.cacheStats.sets,
739
- deletes: this.cacheStats.deletes,
740
- size: this.cacheStats.size,
741
- memoryUsage: this.cacheStats.memoryUsage,
742
- hitRate: this.cacheStats.hits / (this.cacheStats.hits + this.cacheStats.misses) || 0,
743
- }
744
- }
745
-
746
- destroy(): void {
747
- if (this.cleanupInterval) {
748
- clearInterval(this.cleanupInterval)
749
- }
750
- this.memoryCache.clear()
751
- }
752
- }
753
-
754
- // Factory functions
755
- export function responseCache(options?: ResponseCacheOptions): ResponseCache {
756
- return new ResponseCache(options)
757
- }
758
-
759
- export function memoryCache(options?: Omit<ResponseCacheOptions, 'storage'>): ResponseCache {
760
- return new ResponseCache({
761
- ...options,
762
- storage: { type: 'memory' },
763
- })
764
- }
765
-
766
- export function fileCache(directory?: string, options?: Omit<ResponseCacheOptions, 'storage'>): ResponseCache {
767
- return new ResponseCache({
768
- ...options,
769
- storage: { type: 'file', directory },
770
- })
771
- }
772
-
773
- export function hybridCache(options?: ResponseCacheOptions): ResponseCache {
774
- return new ResponseCache({
775
- ...options,
776
- storage: { type: 'hybrid' },
777
- })
778
- }