hono-universal-cache 0.1.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.
- package/README.md +400 -0
- package/dist/index.d.mts +181 -0
- package/dist/index.mjs +248 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# hono-universal-cache
|
|
2
|
+
|
|
3
|
+
Universal cache middleware for [Hono](https://hono.dev) powered by [unstorage](https://unstorage.unjs.io).
|
|
4
|
+
|
|
5
|
+
Cache **API responses** across **any runtime** - Cloudflare Workers, Vercel Edge, Node.js, Bun, Deno, and more.
|
|
6
|
+
|
|
7
|
+
> **Note:** Optimized for API responses (JSON, text, HTML). For static assets (images, videos, files), use CDN/edge caching instead.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
⨠**Universal Runtime Support** - Works everywhere Hono works
|
|
12
|
+
đī¸ **Multiple Storage Drivers** - Memory, Redis, Cloudflare KV, Vercel KV, filesystem, and [more](https://unstorage.unjs.io/drivers)
|
|
13
|
+
⥠**TTL Support** - Automatic expiration with configurable time-to-live
|
|
14
|
+
đ¯ **Selective Caching** - Control what gets cached by status code
|
|
15
|
+
đ **Custom Key Generation** - Flexible cache key strategies
|
|
16
|
+
đĒļ **Lightweight** - Minimal overhead, focused on storage operations
|
|
17
|
+
đ¨ **HTTP Header Agnostic** - You control your own Cache-Control headers
|
|
18
|
+
đĻ **Efficient Storage** - Optimized for text-based API responses
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install hono-universal-cache
|
|
24
|
+
# or
|
|
25
|
+
pnpm add hono-universal-cache
|
|
26
|
+
# or
|
|
27
|
+
yarn add hono-universal-cache
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> **Note:** `unstorage` is included as a dependency - no need to install it separately!
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### Basic Usage (In-Memory)
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { Hono } from 'hono'
|
|
38
|
+
import { universalCache } from 'hono-universal-cache'
|
|
39
|
+
|
|
40
|
+
const app = new Hono()
|
|
41
|
+
|
|
42
|
+
app.use('*', universalCache({
|
|
43
|
+
cacheName: 'my-app-cache',
|
|
44
|
+
ttl: 3600 // 1 hour
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
app.get('/api/data', (c) => {
|
|
48
|
+
// Set your own Cache-Control headers
|
|
49
|
+
c.header('Cache-Control', 'public, max-age=3600')
|
|
50
|
+
return c.json({ timestamp: Date.now() })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export default app
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### With Custom Storage Driver
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { createStorage } from 'unstorage'
|
|
60
|
+
import redisDriver from 'unstorage/drivers/redis'
|
|
61
|
+
|
|
62
|
+
const storage = createStorage({
|
|
63
|
+
driver: redisDriver({
|
|
64
|
+
host: 'localhost',
|
|
65
|
+
port: 6379
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
app.use('*', universalCache({
|
|
70
|
+
cacheName: 'api-cache',
|
|
71
|
+
storage,
|
|
72
|
+
ttl: 3600
|
|
73
|
+
}))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Runtime-Specific Examples
|
|
77
|
+
|
|
78
|
+
### Cloudflare Workers
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { Hono } from 'hono'
|
|
82
|
+
import { universalCache } from 'hono-universal-cache'
|
|
83
|
+
import { createStorage } from 'unstorage'
|
|
84
|
+
import cloudflareKVBindingDriver from 'unstorage/drivers/cloudflare-kv-binding'
|
|
85
|
+
|
|
86
|
+
type Env = {
|
|
87
|
+
MY_KV: KVNamespace
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const app = new Hono<{ Bindings: Env }>()
|
|
91
|
+
|
|
92
|
+
app.use('*', async (c, next) => {
|
|
93
|
+
const storage = createStorage({
|
|
94
|
+
driver: cloudflareKVBindingDriver({
|
|
95
|
+
binding: c.env.MY_KV
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return universalCache({
|
|
100
|
+
cacheName: 'worker-cache',
|
|
101
|
+
storage,
|
|
102
|
+
ttl: 3600
|
|
103
|
+
})(c, next)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
export default app
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Vercel Edge
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { createStorage } from 'unstorage'
|
|
113
|
+
import vercelKVDriver from 'unstorage/drivers/vercel-kv'
|
|
114
|
+
|
|
115
|
+
const storage = createStorage({
|
|
116
|
+
driver: vercelKVDriver({
|
|
117
|
+
// Auto-detects from environment:
|
|
118
|
+
// KV_REST_API_URL and KV_REST_API_TOKEN
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
app.use('*', universalCache({
|
|
123
|
+
cacheName: 'edge-cache',
|
|
124
|
+
storage,
|
|
125
|
+
ttl: 3600
|
|
126
|
+
}))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Node.js / Bun (Filesystem)
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { createStorage } from 'unstorage'
|
|
133
|
+
import fsDriver from 'unstorage/drivers/fs'
|
|
134
|
+
|
|
135
|
+
const storage = createStorage({
|
|
136
|
+
driver: fsDriver({
|
|
137
|
+
base: './cache'
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
app.use('*', universalCache({
|
|
142
|
+
cacheName: 'fs-cache',
|
|
143
|
+
storage,
|
|
144
|
+
ttl: 3600
|
|
145
|
+
}))
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Redis
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { createStorage } from 'unstorage'
|
|
152
|
+
import redisDriver from 'unstorage/drivers/redis'
|
|
153
|
+
|
|
154
|
+
const storage = createStorage({
|
|
155
|
+
driver: redisDriver({
|
|
156
|
+
host: 'localhost',
|
|
157
|
+
port: 6379,
|
|
158
|
+
// password: 'your-password'
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
app.use('*', universalCache({
|
|
163
|
+
cacheName: 'redis-cache',
|
|
164
|
+
storage,
|
|
165
|
+
ttl: 3600
|
|
166
|
+
}))
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## API Reference
|
|
170
|
+
|
|
171
|
+
### `universalCache(options)`
|
|
172
|
+
|
|
173
|
+
Creates a Hono middleware for response caching.
|
|
174
|
+
|
|
175
|
+
#### Options
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
type CacheOptions = {
|
|
179
|
+
// Required: Cache namespace
|
|
180
|
+
cacheName: string | ((c: Context) => Promise<string> | string)
|
|
181
|
+
|
|
182
|
+
// Optional: Unstorage instance (defaults to in-memory)
|
|
183
|
+
storage?: Storage
|
|
184
|
+
|
|
185
|
+
// Optional: Time-to-live in seconds
|
|
186
|
+
ttl?: number
|
|
187
|
+
|
|
188
|
+
// Optional: Status codes to cache (default: [200])
|
|
189
|
+
cacheableStatusCodes?: number[]
|
|
190
|
+
|
|
191
|
+
// Optional: Custom cache key generator
|
|
192
|
+
keyGenerator?: (c: Context) => Promise<string> | string
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Common Use Cases
|
|
197
|
+
|
|
198
|
+
### Dynamic Cache Names
|
|
199
|
+
|
|
200
|
+
Cache different tenants or users separately:
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
app.use('*', universalCache({
|
|
204
|
+
cacheName: (c) => {
|
|
205
|
+
const tenant = c.req.header('X-Tenant-ID') || 'default'
|
|
206
|
+
return `cache:${tenant}`
|
|
207
|
+
},
|
|
208
|
+
storage,
|
|
209
|
+
ttl: 3600
|
|
210
|
+
}))
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Custom Key Generation
|
|
214
|
+
|
|
215
|
+
Cache based on custom logic (e.g., ignore specific query params):
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
app.use('*', universalCache({
|
|
219
|
+
cacheName: 'api-cache',
|
|
220
|
+
keyGenerator: (c) => {
|
|
221
|
+
const url = new URL(c.req.url)
|
|
222
|
+
// Ignore tracking parameters
|
|
223
|
+
url.searchParams.delete('utm_source')
|
|
224
|
+
url.searchParams.delete('utm_campaign')
|
|
225
|
+
return url.toString()
|
|
226
|
+
},
|
|
227
|
+
storage
|
|
228
|
+
}))
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Selective Caching by Status Code
|
|
232
|
+
|
|
233
|
+
Cache successful and redirect responses:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
app.use('*', universalCache({
|
|
237
|
+
cacheName: 'selective-cache',
|
|
238
|
+
cacheableStatusCodes: [200, 201, 301, 302],
|
|
239
|
+
storage
|
|
240
|
+
}))
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Managing HTTP Headers
|
|
244
|
+
|
|
245
|
+
The middleware focuses on storage-level caching. You control HTTP headers in your route handlers:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
app.get('/api/public', (c) => {
|
|
249
|
+
// Set headers for browser/CDN caching
|
|
250
|
+
c.header('Cache-Control', 'public, max-age=3600')
|
|
251
|
+
c.header('Vary', 'Accept-Encoding')
|
|
252
|
+
|
|
253
|
+
return c.json({ data: 'public data' })
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
app.get('/api/private', (c) => {
|
|
257
|
+
// Private data - browser should not cache
|
|
258
|
+
c.header('Cache-Control', 'private, no-cache')
|
|
259
|
+
|
|
260
|
+
// But server-side cache can still store it
|
|
261
|
+
return c.json({ data: 'user-specific data' })
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Storage Drivers
|
|
266
|
+
|
|
267
|
+
Use any [unstorage driver](https://unstorage.unjs.io/drivers):
|
|
268
|
+
|
|
269
|
+
### Popular Drivers
|
|
270
|
+
|
|
271
|
+
- **Memory** - `unstorage/drivers/memory` (default, ephemeral)
|
|
272
|
+
- **Filesystem** - `unstorage/drivers/fs` (Node.js/Bun)
|
|
273
|
+
- **Redis** - `unstorage/drivers/redis` (persistent, distributed)
|
|
274
|
+
- **Cloudflare KV** - `unstorage/drivers/cloudflare-kv-binding`
|
|
275
|
+
- **Vercel KV** - `unstorage/drivers/vercel-kv`
|
|
276
|
+
- **MongoDB** - `unstorage/drivers/mongodb`
|
|
277
|
+
- **Upstash Redis** - `unstorage/drivers/upstash`
|
|
278
|
+
- **LRU Cache** - `unstorage/drivers/lru-cache` (in-memory with eviction)
|
|
279
|
+
|
|
280
|
+
### Cloud Storage
|
|
281
|
+
|
|
282
|
+
- **AWS S3** - `unstorage/drivers/s3`
|
|
283
|
+
- **Azure Blob** - `unstorage/drivers/azure-storage-blob`
|
|
284
|
+
- **Cloudflare R2** - `unstorage/drivers/cloudflare-r2-binding`
|
|
285
|
+
- **Vercel Blob** - `unstorage/drivers/vercel-blob`
|
|
286
|
+
|
|
287
|
+
See [all 50+ drivers](https://unstorage.unjs.io/drivers) in the unstorage documentation.
|
|
288
|
+
|
|
289
|
+
## How It Works
|
|
290
|
+
|
|
291
|
+
1. **Request arrives** â Middleware generates cache key
|
|
292
|
+
2. **Check cache** â Retrieve from storage if exists and not expired
|
|
293
|
+
3. **Cache hit** â Return cached response immediately
|
|
294
|
+
4. **Cache miss** â Execute route handler
|
|
295
|
+
5. **Check cacheability** â Verify status code and Vary header
|
|
296
|
+
6. **Store response** â Save text body to storage with TTL metadata (non-blocking)
|
|
297
|
+
7. **Return response** â Send to client
|
|
298
|
+
|
|
299
|
+
## Important Notes
|
|
300
|
+
|
|
301
|
+
### Optimized for API Responses
|
|
302
|
+
|
|
303
|
+
This middleware is designed for **text-based API responses**:
|
|
304
|
+
|
|
305
|
+
- â
**JSON APIs** - Perfect use case
|
|
306
|
+
- â
**Text responses** - Works great
|
|
307
|
+
- â
**HTML pages** - Fully supported
|
|
308
|
+
- â
**XML/RSS feeds** - No problem
|
|
309
|
+
- â **Binary assets** (images, PDFs, videos) - Use CDN/edge caching instead
|
|
310
|
+
|
|
311
|
+
**Why not binary?** The middleware uses `response.text()` for optimal storage efficiency. For static assets, use:
|
|
312
|
+
- CDN caching (Cloudflare, CloudFront)
|
|
313
|
+
- Object storage (S3, R2, Blob Storage)
|
|
314
|
+
- Hono's built-in static file serving with CDN
|
|
315
|
+
|
|
316
|
+
### Vary: * Behavior
|
|
317
|
+
|
|
318
|
+
Responses with `Vary: *` header are **never cached**, per RFC 9111:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
app.get('/api/uncacheable', (c) => {
|
|
322
|
+
c.header('Vary', '*') // This response will not be cached
|
|
323
|
+
return c.json({ random: Math.random() })
|
|
324
|
+
})
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### HTTP Headers vs Storage Caching
|
|
328
|
+
|
|
329
|
+
This middleware handles **server-side storage caching only**:
|
|
330
|
+
|
|
331
|
+
- â
Stores responses in Redis, KV, filesystem, etc.
|
|
332
|
+
- â Does NOT modify Cache-Control or other HTTP headers
|
|
333
|
+
- đĄ You control browser/CDN caching via headers in your routes
|
|
334
|
+
|
|
335
|
+
### Non-blocking Cache Writes
|
|
336
|
+
|
|
337
|
+
Cache writes happen asynchronously and don't block responses:
|
|
338
|
+
|
|
339
|
+
- **Cloudflare Workers/Vercel Edge**: Uses `waitUntil()` for background writes
|
|
340
|
+
- **Other runtimes**: Uses promises with error handling
|
|
341
|
+
|
|
342
|
+
## Advanced Usage
|
|
343
|
+
|
|
344
|
+
### CacheManager API
|
|
345
|
+
|
|
346
|
+
Access the low-level cache manager for manual operations:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
import { CacheManager } from 'hono-universal-cache'
|
|
350
|
+
import { createStorage } from 'unstorage'
|
|
351
|
+
|
|
352
|
+
const storage = createStorage()
|
|
353
|
+
const cache = new CacheManager(storage, 3600) // 1 hour TTL
|
|
354
|
+
|
|
355
|
+
// Manual cache operations
|
|
356
|
+
await cache.set('key', response)
|
|
357
|
+
const cached = await cache.get('key')
|
|
358
|
+
const exists = await cache.has('key')
|
|
359
|
+
await cache.delete('key')
|
|
360
|
+
await cache.clear()
|
|
361
|
+
const keys = await cache.keys()
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Per-Route Caching
|
|
365
|
+
|
|
366
|
+
Apply caching to specific routes only:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
const app = new Hono()
|
|
370
|
+
|
|
371
|
+
// Global middleware without cache
|
|
372
|
+
app.use('*', logger())
|
|
373
|
+
|
|
374
|
+
// Cache only API routes
|
|
375
|
+
app.use('/api/*', universalCache({
|
|
376
|
+
cacheName: 'api-cache',
|
|
377
|
+
storage,
|
|
378
|
+
ttl: 300 // 5 minutes
|
|
379
|
+
}))
|
|
380
|
+
|
|
381
|
+
// Cache product pages longer
|
|
382
|
+
app.use('/products/*', universalCache({
|
|
383
|
+
cacheName: 'products-cache',
|
|
384
|
+
storage,
|
|
385
|
+
ttl: 3600 // 1 hour
|
|
386
|
+
}))
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## License
|
|
390
|
+
|
|
391
|
+
MIT
|
|
392
|
+
|
|
393
|
+
## Contributing
|
|
394
|
+
|
|
395
|
+
Contributions welcome! Please open an issue or PR.
|
|
396
|
+
|
|
397
|
+
## Credits
|
|
398
|
+
|
|
399
|
+
- [Hono](https://hono.dev) - Ultrafast web framework
|
|
400
|
+
- [unstorage](https://unstorage.unjs.io) - Universal storage layer
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Storage } from "unstorage";
|
|
2
|
+
import { Context, MiddlewareHandler } from "hono";
|
|
3
|
+
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for configuring the universal cache middleware.
|
|
8
|
+
*/
|
|
9
|
+
type CacheOptions = {
|
|
10
|
+
/**
|
|
11
|
+
* Name or function to generate cache namespace.
|
|
12
|
+
* Used to organize cached entries.
|
|
13
|
+
*/
|
|
14
|
+
cacheName: string | ((c: Context) => Promise<string> | string);
|
|
15
|
+
/**
|
|
16
|
+
* HTTP status codes that should be cached.
|
|
17
|
+
* @default [200]
|
|
18
|
+
*/
|
|
19
|
+
cacheableStatusCodes?: number[];
|
|
20
|
+
/**
|
|
21
|
+
* Custom function to generate cache keys.
|
|
22
|
+
* @default Uses request URL
|
|
23
|
+
*/
|
|
24
|
+
keyGenerator?: (c: Context) => Promise<string> | string;
|
|
25
|
+
/**
|
|
26
|
+
* Unstorage instance for cache persistence.
|
|
27
|
+
* If not provided, defaults to in-memory storage.
|
|
28
|
+
*/
|
|
29
|
+
storage?: Storage;
|
|
30
|
+
/**
|
|
31
|
+
* Time-to-live for cached entries in seconds.
|
|
32
|
+
* After this duration, cached entries are considered stale.
|
|
33
|
+
*/
|
|
34
|
+
ttl?: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Serialized representation of a cached Response.
|
|
38
|
+
* Stored in unstorage with metadata.
|
|
39
|
+
* Optimized for text-based API responses (JSON, text, HTML).
|
|
40
|
+
*/
|
|
41
|
+
type CachedResponse = {
|
|
42
|
+
/**
|
|
43
|
+
* Response body as text string.
|
|
44
|
+
* Works for JSON, text, HTML, and other text-based responses.
|
|
45
|
+
*/
|
|
46
|
+
body: string;
|
|
47
|
+
/**
|
|
48
|
+
* Response headers as key-value pairs.
|
|
49
|
+
*/
|
|
50
|
+
headers: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* HTTP status code.
|
|
53
|
+
*/
|
|
54
|
+
status: number;
|
|
55
|
+
/**
|
|
56
|
+
* HTTP status text.
|
|
57
|
+
*/
|
|
58
|
+
statusText: string;
|
|
59
|
+
/**
|
|
60
|
+
* Timestamp when the response was cached (Unix epoch ms).
|
|
61
|
+
*/
|
|
62
|
+
cachedAt: number;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Metadata stored alongside cached entries.
|
|
66
|
+
*/
|
|
67
|
+
type CacheMetadata = {
|
|
68
|
+
/**
|
|
69
|
+
* Timestamp when entry was created (Date object for unstorage compatibility).
|
|
70
|
+
*/
|
|
71
|
+
mtime: Date;
|
|
72
|
+
/**
|
|
73
|
+
* Expiration timestamp (Unix epoch ms).
|
|
74
|
+
* Calculated as cachedAt + ttl.
|
|
75
|
+
*/
|
|
76
|
+
expires?: number;
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/cache.d.ts
|
|
80
|
+
/**
|
|
81
|
+
* CacheManager handles Response serialization, storage, and retrieval.
|
|
82
|
+
*
|
|
83
|
+
* Wraps unstorage to provide Response-specific caching with TTL support.
|
|
84
|
+
* Designed for API responses (JSON, text, HTML) - not binary assets.
|
|
85
|
+
*/
|
|
86
|
+
declare class CacheManager {
|
|
87
|
+
private storage;
|
|
88
|
+
private ttl?;
|
|
89
|
+
/**
|
|
90
|
+
* Creates a new CacheManager instance.
|
|
91
|
+
*
|
|
92
|
+
* @param storage - Unstorage instance
|
|
93
|
+
* @param ttl - Optional time-to-live in seconds
|
|
94
|
+
*/
|
|
95
|
+
constructor(storage: Storage, ttl?: number);
|
|
96
|
+
/**
|
|
97
|
+
* Serializes a Response object for storage.
|
|
98
|
+
*
|
|
99
|
+
* Converts Response to a plain object with text body.
|
|
100
|
+
* Optimized for API responses (JSON, text, HTML).
|
|
101
|
+
*
|
|
102
|
+
* @param response - Response to serialize
|
|
103
|
+
* @returns Serialized response object
|
|
104
|
+
*/
|
|
105
|
+
private serializeResponse;
|
|
106
|
+
/**
|
|
107
|
+
* Deserializes a cached response back to Response object.
|
|
108
|
+
*
|
|
109
|
+
* @param cached - Serialized response
|
|
110
|
+
* @returns Reconstructed Response object
|
|
111
|
+
*/
|
|
112
|
+
private deserializeResponse;
|
|
113
|
+
/**
|
|
114
|
+
* Retrieves a cached response by key.
|
|
115
|
+
*
|
|
116
|
+
* Returns null if:
|
|
117
|
+
* - Entry doesn't exist
|
|
118
|
+
* - Entry has expired (based on TTL)
|
|
119
|
+
*
|
|
120
|
+
* @param key - Cache key
|
|
121
|
+
* @returns Cached Response or null
|
|
122
|
+
*/
|
|
123
|
+
get(key: string): Promise<Response | null>;
|
|
124
|
+
/**
|
|
125
|
+
* Stores a Response in cache.
|
|
126
|
+
*
|
|
127
|
+
* @param key - Cache key
|
|
128
|
+
* @param response - Response to cache
|
|
129
|
+
*/
|
|
130
|
+
set(key: string, response: Response): Promise<void>;
|
|
131
|
+
/**
|
|
132
|
+
* Checks if a key exists in cache and is not expired.
|
|
133
|
+
*
|
|
134
|
+
* @param key - Cache key
|
|
135
|
+
* @returns True if key exists and is valid
|
|
136
|
+
*/
|
|
137
|
+
has(key: string): Promise<boolean>;
|
|
138
|
+
/**
|
|
139
|
+
* Deletes a cached entry.
|
|
140
|
+
*
|
|
141
|
+
* @param key - Cache key
|
|
142
|
+
*/
|
|
143
|
+
delete(key: string): Promise<void>;
|
|
144
|
+
/**
|
|
145
|
+
* Clears all cached entries.
|
|
146
|
+
*/
|
|
147
|
+
clear(): Promise<void>;
|
|
148
|
+
/**
|
|
149
|
+
* Gets all cache keys.
|
|
150
|
+
*
|
|
151
|
+
* @returns Array of cache keys
|
|
152
|
+
*/
|
|
153
|
+
keys(): Promise<string[]>;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/index.d.ts
|
|
157
|
+
/**
|
|
158
|
+
* Universal cache middleware for Hono.
|
|
159
|
+
*
|
|
160
|
+
* Caches responses using unstorage for cross-runtime compatibility.
|
|
161
|
+
* Works with Cloudflare Workers, Vercel Edge, Node.js, Bun, Deno, and more.
|
|
162
|
+
*
|
|
163
|
+
* @param options - Cache configuration options
|
|
164
|
+
* @returns Hono middleware handler
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* import { Hono } from 'hono'
|
|
169
|
+
* import { universalCache } from 'hono-universal-cache'
|
|
170
|
+
*
|
|
171
|
+
* const app = new Hono()
|
|
172
|
+
*
|
|
173
|
+
* app.use('*', universalCache({
|
|
174
|
+
* cacheName: 'my-app-cache',
|
|
175
|
+
* ttl: 3600
|
|
176
|
+
* }))
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
declare const universalCache: (options: CacheOptions) => MiddlewareHandler;
|
|
180
|
+
//#endregion
|
|
181
|
+
export { CacheManager, type CacheMetadata, type CacheOptions, type CachedResponse, universalCache };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createStorage } from "unstorage";
|
|
2
|
+
import { getRuntimeKey } from "hono/adapter";
|
|
3
|
+
|
|
4
|
+
//#region src/utils.ts
|
|
5
|
+
/**
|
|
6
|
+
* Generates a cache key for the given context.
|
|
7
|
+
*
|
|
8
|
+
* @param c - Hono context
|
|
9
|
+
* @param keyGenerator - Optional custom key generator function
|
|
10
|
+
* @returns Cache key string
|
|
11
|
+
*/
|
|
12
|
+
async function generateCacheKey(c, keyGenerator) {
|
|
13
|
+
if (keyGenerator) return await keyGenerator(c);
|
|
14
|
+
return c.req.url;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Checks if a status code is cacheable.
|
|
18
|
+
*
|
|
19
|
+
* @param status - HTTP status code
|
|
20
|
+
* @param cacheableStatusCodes - Set of allowed status codes
|
|
21
|
+
* @returns True if status code is cacheable
|
|
22
|
+
*/
|
|
23
|
+
function isCacheableStatus(status, cacheableStatusCodes) {
|
|
24
|
+
return cacheableStatusCodes.has(status);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Checks if a cached entry has expired based on TTL.
|
|
28
|
+
*
|
|
29
|
+
* @param cachedAt - Timestamp when entry was cached (Unix epoch ms)
|
|
30
|
+
* @param ttl - Time-to-live in seconds
|
|
31
|
+
* @returns True if entry has expired
|
|
32
|
+
*/
|
|
33
|
+
function isExpired(cachedAt, ttl) {
|
|
34
|
+
if (!ttl) return false;
|
|
35
|
+
return Date.now() > cachedAt + ttl * 1e3;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/cache.ts
|
|
40
|
+
/**
|
|
41
|
+
* CacheManager handles Response serialization, storage, and retrieval.
|
|
42
|
+
*
|
|
43
|
+
* Wraps unstorage to provide Response-specific caching with TTL support.
|
|
44
|
+
* Designed for API responses (JSON, text, HTML) - not binary assets.
|
|
45
|
+
*/
|
|
46
|
+
var CacheManager = class {
|
|
47
|
+
storage;
|
|
48
|
+
ttl;
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new CacheManager instance.
|
|
51
|
+
*
|
|
52
|
+
* @param storage - Unstorage instance
|
|
53
|
+
* @param ttl - Optional time-to-live in seconds
|
|
54
|
+
*/
|
|
55
|
+
constructor(storage, ttl) {
|
|
56
|
+
this.storage = storage;
|
|
57
|
+
this.ttl = ttl;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Serializes a Response object for storage.
|
|
61
|
+
*
|
|
62
|
+
* Converts Response to a plain object with text body.
|
|
63
|
+
* Optimized for API responses (JSON, text, HTML).
|
|
64
|
+
*
|
|
65
|
+
* @param response - Response to serialize
|
|
66
|
+
* @returns Serialized response object
|
|
67
|
+
*/
|
|
68
|
+
async serializeResponse(response) {
|
|
69
|
+
const body = await response.text();
|
|
70
|
+
const headers = {};
|
|
71
|
+
response.headers.forEach((value, key) => {
|
|
72
|
+
headers[key] = value;
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
body,
|
|
76
|
+
headers,
|
|
77
|
+
status: response.status,
|
|
78
|
+
statusText: response.statusText,
|
|
79
|
+
cachedAt: Date.now()
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Deserializes a cached response back to Response object.
|
|
84
|
+
*
|
|
85
|
+
* @param cached - Serialized response
|
|
86
|
+
* @returns Reconstructed Response object
|
|
87
|
+
*/
|
|
88
|
+
deserializeResponse(cached) {
|
|
89
|
+
const headers = new Headers(cached.headers);
|
|
90
|
+
const body = cached.status === 204 || cached.status === 205 ? null : cached.body;
|
|
91
|
+
return new Response(body, {
|
|
92
|
+
status: cached.status,
|
|
93
|
+
statusText: cached.statusText,
|
|
94
|
+
headers
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Retrieves a cached response by key.
|
|
99
|
+
*
|
|
100
|
+
* Returns null if:
|
|
101
|
+
* - Entry doesn't exist
|
|
102
|
+
* - Entry has expired (based on TTL)
|
|
103
|
+
*
|
|
104
|
+
* @param key - Cache key
|
|
105
|
+
* @returns Cached Response or null
|
|
106
|
+
*/
|
|
107
|
+
async get(key) {
|
|
108
|
+
try {
|
|
109
|
+
const cached = await this.storage.getItem(key);
|
|
110
|
+
if (!cached) return null;
|
|
111
|
+
if (isExpired(cached.cachedAt, this.ttl)) {
|
|
112
|
+
await this.delete(key);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return this.deserializeResponse(cached);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error("Cache retrieval error:", error);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Stores a Response in cache.
|
|
123
|
+
*
|
|
124
|
+
* @param key - Cache key
|
|
125
|
+
* @param response - Response to cache
|
|
126
|
+
*/
|
|
127
|
+
async set(key, response) {
|
|
128
|
+
try {
|
|
129
|
+
const serialized = await this.serializeResponse(response);
|
|
130
|
+
await this.storage.setItem(key, serialized);
|
|
131
|
+
if (this.ttl) {
|
|
132
|
+
const metadata = {
|
|
133
|
+
mtime: new Date(serialized.cachedAt),
|
|
134
|
+
expires: serialized.cachedAt + this.ttl * 1e3
|
|
135
|
+
};
|
|
136
|
+
await this.storage.setMeta(key, metadata);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error("Cache storage error:", error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Checks if a key exists in cache and is not expired.
|
|
144
|
+
*
|
|
145
|
+
* @param key - Cache key
|
|
146
|
+
* @returns True if key exists and is valid
|
|
147
|
+
*/
|
|
148
|
+
async has(key) {
|
|
149
|
+
try {
|
|
150
|
+
const cached = await this.storage.getItem(key);
|
|
151
|
+
if (!cached) return false;
|
|
152
|
+
return !isExpired(cached.cachedAt, this.ttl);
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Deletes a cached entry.
|
|
159
|
+
*
|
|
160
|
+
* @param key - Cache key
|
|
161
|
+
*/
|
|
162
|
+
async delete(key) {
|
|
163
|
+
try {
|
|
164
|
+
await this.storage.removeItem(key);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error("Cache deletion error:", error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Clears all cached entries.
|
|
171
|
+
*/
|
|
172
|
+
async clear() {
|
|
173
|
+
try {
|
|
174
|
+
await this.storage.clear();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error("Cache clear error:", error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Gets all cache keys.
|
|
181
|
+
*
|
|
182
|
+
* @returns Array of cache keys
|
|
183
|
+
*/
|
|
184
|
+
async keys() {
|
|
185
|
+
try {
|
|
186
|
+
return await this.storage.getKeys();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error("Cache keys retrieval error:", error);
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/types.ts
|
|
196
|
+
/**
|
|
197
|
+
* Status codes that can be cached by default.
|
|
198
|
+
*/
|
|
199
|
+
const DEFAULT_CACHEABLE_STATUS_CODES = [200];
|
|
200
|
+
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/index.ts
|
|
203
|
+
/**
|
|
204
|
+
* Universal cache middleware for Hono.
|
|
205
|
+
*
|
|
206
|
+
* Caches responses using unstorage for cross-runtime compatibility.
|
|
207
|
+
* Works with Cloudflare Workers, Vercel Edge, Node.js, Bun, Deno, and more.
|
|
208
|
+
*
|
|
209
|
+
* @param options - Cache configuration options
|
|
210
|
+
* @returns Hono middleware handler
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```ts
|
|
214
|
+
* import { Hono } from 'hono'
|
|
215
|
+
* import { universalCache } from 'hono-universal-cache'
|
|
216
|
+
*
|
|
217
|
+
* const app = new Hono()
|
|
218
|
+
*
|
|
219
|
+
* app.use('*', universalCache({
|
|
220
|
+
* cacheName: 'my-app-cache',
|
|
221
|
+
* ttl: 3600
|
|
222
|
+
* }))
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
const universalCache = (options) => {
|
|
226
|
+
let storage = options.storage;
|
|
227
|
+
if (!storage) {
|
|
228
|
+
console.warn("No storage provided, using default in-memory storage");
|
|
229
|
+
storage = createStorage();
|
|
230
|
+
}
|
|
231
|
+
const cacheManager = new CacheManager(storage, options.ttl);
|
|
232
|
+
const cacheableStatusCodes = new Set(options.cacheableStatusCodes ?? DEFAULT_CACHEABLE_STATUS_CODES);
|
|
233
|
+
return async (c, next) => {
|
|
234
|
+
const baseKey = await generateCacheKey(c, options.keyGenerator);
|
|
235
|
+
const key = `${typeof options.cacheName === "function" ? await options.cacheName(c) : options.cacheName}:${baseKey}`;
|
|
236
|
+
const cachedResponse = await cacheManager.get(key);
|
|
237
|
+
if (cachedResponse) return new Response(cachedResponse.body, cachedResponse);
|
|
238
|
+
await next();
|
|
239
|
+
if (!isCacheableStatus(c.res.status, cacheableStatusCodes)) return;
|
|
240
|
+
const res = c.res.clone();
|
|
241
|
+
const cachePromise = cacheManager.set(key, res);
|
|
242
|
+
if (getRuntimeKey() === "workerd") c?.executionCtx?.waitUntil?.(cachePromise);
|
|
243
|
+
else await cachePromise;
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
export { CacheManager, universalCache };
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hono-universal-cache",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.2",
|
|
5
|
+
"description": "Universal cache middleware for Hono powered by unstorage - works across Cloudflare Workers, Vercel Edge, Node.js, Bun, Deno, and more.",
|
|
6
|
+
"author": "Anas Mohammed",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/anasmohammed361/hono-universal-cache#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/anasmohammed361/hono-universal-cache.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/anasmohammed361/hono-universal-cache/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"hono",
|
|
18
|
+
"cache",
|
|
19
|
+
"middleware",
|
|
20
|
+
"universal",
|
|
21
|
+
"unstorage",
|
|
22
|
+
"cloudflare",
|
|
23
|
+
"vercel",
|
|
24
|
+
"edge",
|
|
25
|
+
"workers",
|
|
26
|
+
"redis",
|
|
27
|
+
"kv"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.mjs",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.mjs",
|
|
34
|
+
"module": "./dist/index.mjs",
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^25.0.3",
|
|
41
|
+
"bumpp": "^10.3.2",
|
|
42
|
+
"hono": "^4.11.3",
|
|
43
|
+
"tsdown": "^0.18.1",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^4.0.16"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"unstorage": "^1.17.3"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"hono": ">=4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsdown",
|
|
55
|
+
"dev": "tsdown --watch",
|
|
56
|
+
"test": "vitest",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"release": "pnpm build && pnpm typecheck && bumpp && pnpm run release:publish",
|
|
59
|
+
"release:publish": "gh release create v$(node -p \"require('./package.json').version\") --generate-notes"
|
|
60
|
+
}
|
|
61
|
+
}
|