@stacksjs/bun-router 0.0.17 → 0.0.18

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 (146) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/container/service-provider.d.ts +3 -3
  3. package/dist/development/performance-profiler.d.ts +3 -3
  4. package/dist/development/route-debugger.d.ts +1 -1
  5. package/dist/development/route-inspector.d.ts +2 -2
  6. package/dist/observability/integration.d.ts +1 -1
  7. package/dist/response/macros.d.ts +1 -1
  8. package/dist/router/index.d.ts +7 -7
  9. package/dist/router/router.d.ts +0 -32
  10. package/dist/testing/performance-testing.d.ts +2 -2
  11. package/dist/testing/test-client.d.ts +1 -1
  12. package/dist/testing/websocket-testing.d.ts +1 -1
  13. package/package.json +2 -3
  14. package/src/auth.ts +0 -489
  15. package/src/cache/lru-cache.ts +0 -457
  16. package/src/cache/middleware-memoization.ts +0 -531
  17. package/src/cache/route-cache-warmer.ts +0 -486
  18. package/src/cache/sqlite-cache.ts +0 -783
  19. package/src/cache/streaming-cache.ts +0 -572
  20. package/src/cli/colors.ts +0 -29
  21. package/src/cli/index.ts +0 -287
  22. package/src/cli/middleware.ts +0 -291
  23. package/src/cli/openapi.ts +0 -407
  24. package/src/cli/router.ts +0 -188
  25. package/src/cli/routes.ts +0 -265
  26. package/src/cli/utils.ts +0 -531
  27. package/src/cli.ts +0 -5
  28. package/src/config.ts +0 -322
  29. package/src/container/container.ts +0 -786
  30. package/src/container/contextual-binding.ts +0 -606
  31. package/src/container/decorators.ts +0 -368
  32. package/src/container/index.ts +0 -49
  33. package/src/container/service-provider.ts +0 -596
  34. package/src/development/hot-reload.ts +0 -673
  35. package/src/development/index.ts +0 -499
  36. package/src/development/performance-profiler.ts +0 -717
  37. package/src/development/route-debugger.ts +0 -527
  38. package/src/development/route-inspector.ts +0 -749
  39. package/src/development/typescript-utilities.ts +0 -682
  40. package/src/docs.ts +0 -397
  41. package/src/errors/circuit-breaker.ts +0 -733
  42. package/src/errors/error-handler.ts +0 -569
  43. package/src/errors/error-reporting.ts +0 -672
  44. package/src/errors/exceptions.ts +0 -536
  45. package/src/errors/graceful-degradation.ts +0 -623
  46. package/src/errors/index.ts +0 -21
  47. package/src/errors/router-errors.ts +0 -632
  48. package/src/file-serving/static-files.ts +0 -718
  49. package/src/index.ts +0 -66
  50. package/src/middleware/auth.ts +0 -220
  51. package/src/middleware/content_security_policy.ts +0 -215
  52. package/src/middleware/cors.ts +0 -142
  53. package/src/middleware/csrf.ts +0 -166
  54. package/src/middleware/ddos_protection.ts +0 -279
  55. package/src/middleware/file_security.ts +0 -191
  56. package/src/middleware/file_upload.ts +0 -275
  57. package/src/middleware/helmet.ts +0 -268
  58. package/src/middleware/index.ts +0 -117
  59. package/src/middleware/input_validation.ts +0 -453
  60. package/src/middleware/json_body.ts +0 -37
  61. package/src/middleware/performance_alerting.ts +0 -540
  62. package/src/middleware/performance_dashboard.ts +0 -661
  63. package/src/middleware/performance_monitor.ts +0 -945
  64. package/src/middleware/pipeline.ts +0 -489
  65. package/src/middleware/rate_limit.ts +0 -251
  66. package/src/middleware/request_id.ts +0 -36
  67. package/src/middleware/request_signing.ts +0 -638
  68. package/src/middleware/request_tracer.ts +0 -653
  69. package/src/middleware/response_cache.ts +0 -778
  70. package/src/middleware/security.ts +0 -482
  71. package/src/middleware/security_suite.ts +0 -257
  72. package/src/middleware/session.ts +0 -91
  73. package/src/model-binding/index.ts +0 -18
  74. package/src/model-binding/model-middleware.ts +0 -425
  75. package/src/model-binding/model-registry.ts +0 -550
  76. package/src/model-binding.ts +0 -370
  77. package/src/model-resolver-factory.ts +0 -106
  78. package/src/observability/correlation.ts +0 -691
  79. package/src/observability/health-checks.ts +0 -729
  80. package/src/observability/index.ts +0 -184
  81. package/src/observability/integration.ts +0 -548
  82. package/src/observability/metrics.ts +0 -753
  83. package/src/observability/tracing.ts +0 -638
  84. package/src/optimization/bun-utilities.ts +0 -778
  85. package/src/query-builder-integration.ts +0 -137
  86. package/src/request/context.ts +0 -109
  87. package/src/request/enhanced-request.ts +0 -876
  88. package/src/request/macros.ts +0 -822
  89. package/src/response/macros.ts +0 -665
  90. package/src/response/response-factory.ts +0 -596
  91. package/src/router/api-routes.ts +0 -243
  92. package/src/router/file-based-routing.ts +0 -697
  93. package/src/router/file-streaming.ts +0 -383
  94. package/src/router/fluent-routing.ts +0 -866
  95. package/src/router/group-organization.ts +0 -229
  96. package/src/router/handler-resolver.ts +0 -327
  97. package/src/router/http-methods.ts +0 -407
  98. package/src/router/index.ts +0 -193
  99. package/src/router/middleware-groups.ts +0 -222
  100. package/src/router/middleware-integration.ts +0 -399
  101. package/src/router/middleware.ts +0 -234
  102. package/src/router/model-binding.ts +0 -215
  103. package/src/router/optimized-route-matching.ts +0 -302
  104. package/src/router/route-building.ts +0 -221
  105. package/src/router/route-compiler.ts +0 -691
  106. package/src/router/route-matching.ts +0 -368
  107. package/src/router/route-trie.ts +0 -450
  108. package/src/router/router.ts +0 -1616
  109. package/src/router/server.ts +0 -684
  110. package/src/router/validation-integration.ts +0 -449
  111. package/src/router/view-rendering.ts +0 -233
  112. package/src/router/websocket.ts +0 -100
  113. package/src/routing/route-caching.ts +0 -402
  114. package/src/routing/route-throttling.ts +0 -469
  115. package/src/routing/subdomain-routing.ts +0 -492
  116. package/src/session/database-store.ts +0 -109
  117. package/src/session/file-store.ts +0 -148
  118. package/src/session/index.ts +0 -244
  119. package/src/session/memory-store.ts +0 -88
  120. package/src/session/redis-store.ts +0 -93
  121. package/src/streaming/index.ts +0 -17
  122. package/src/streaming/sse-handler.ts +0 -482
  123. package/src/streaming/stream-handler.ts +0 -552
  124. package/src/testing/auth-testing.ts +0 -448
  125. package/src/testing/file-upload-testing.ts +0 -543
  126. package/src/testing/index.ts +0 -10
  127. package/src/testing/middleware-testing.ts +0 -322
  128. package/src/testing/model-binding-testing.ts +0 -645
  129. package/src/testing/performance-testing.ts +0 -738
  130. package/src/testing/test-client.ts +0 -310
  131. package/src/testing/test-request.ts +0 -315
  132. package/src/testing/test-response.ts +0 -332
  133. package/src/testing/types.ts +0 -224
  134. package/src/testing/websocket-testing.ts +0 -590
  135. package/src/types/controller-types.ts +0 -385
  136. package/src/types/core.ts +0 -683
  137. package/src/types/middleware-types.ts +0 -422
  138. package/src/types/request-response-augmentation.ts +0 -489
  139. package/src/types/route-inference.ts +0 -372
  140. package/src/types.ts +0 -2123
  141. package/src/url.ts +0 -131
  142. package/src/utils/index.ts +0 -1
  143. package/src/utils/query-preservation.ts +0 -201
  144. package/src/utils.ts +0 -395
  145. package/src/validation/validator.ts +0 -688
  146. 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
- }