@tekir/cache 0.1.8 → 0.1.10
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/dist/http-cache.d.ts +2 -1
- package/dist/index.js +538 -6
- package/dist/stores/database.d.ts +3 -0
- package/package.json +4 -4
- package/src/http-cache.ts +52 -14
- package/src/stores/database.ts +21 -12
- package/dist/cache.js +0 -151
- package/dist/http-cache.js +0 -198
- package/dist/provider.js +0 -94
- package/dist/stores/database.js +0 -129
- package/dist/stores/memory.js +0 -108
- package/dist/stores/redis.js +0 -143
- package/dist/types.js +0 -1
package/src/http-cache.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { Cache } from "./cache"
|
|
26
26
|
import type { CacheStore } from "./types"
|
|
27
|
+
import { finalizeResponse, hasPendingResponseCookies } from "@tekir/core"
|
|
27
28
|
|
|
28
29
|
export type HttpCacheOptions = {
|
|
29
30
|
/**
|
|
@@ -85,7 +86,13 @@ export type HttpCacheOptions = {
|
|
|
85
86
|
const CREDENTIAL_HEADERS = ["authorization", "cookie"]
|
|
86
87
|
|
|
87
88
|
export type HttpCacheCtx = {
|
|
88
|
-
request: {
|
|
89
|
+
request: {
|
|
90
|
+
url: string
|
|
91
|
+
method: string
|
|
92
|
+
raw?: Request
|
|
93
|
+
header?: (name: string, defaultValue?: string) => string | undefined
|
|
94
|
+
headers: Headers
|
|
95
|
+
}
|
|
89
96
|
params?: Record<string, string>
|
|
90
97
|
query?: Record<string, string | string[]>
|
|
91
98
|
}
|
|
@@ -93,7 +100,7 @@ export type HttpCacheCtx = {
|
|
|
93
100
|
type CachedEntry = {
|
|
94
101
|
status: number
|
|
95
102
|
headers: Record<string, string>
|
|
96
|
-
|
|
103
|
+
bodyBase64: string
|
|
97
104
|
etag: string
|
|
98
105
|
storedAt: number
|
|
99
106
|
}
|
|
@@ -146,14 +153,33 @@ const hash = (s: string): string => {
|
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
const defaultKey = (ctx: HttpCacheCtx, vary: string[]): string => {
|
|
149
|
-
|
|
156
|
+
let host = requestHeader(ctx.request, "host")
|
|
157
|
+
if (!host) {
|
|
158
|
+
try { host = new URL(ctx.request.url).host } catch {}
|
|
159
|
+
}
|
|
160
|
+
const parts = [ctx.request.method, host ? `host=${host.toLowerCase()}` : "", ctx.request.url]
|
|
150
161
|
for (const h of vary) {
|
|
151
|
-
const v = ctx.request
|
|
162
|
+
const v = requestHeader(ctx.request, h)
|
|
152
163
|
if (v) parts.push(`${h}=${v}`)
|
|
153
164
|
}
|
|
154
165
|
return parts.join("|")
|
|
155
166
|
}
|
|
156
167
|
|
|
168
|
+
const requestHeader = (req: HttpCacheCtx["request"], name: string): string | undefined => {
|
|
169
|
+
if (typeof req.header === "function") return req.header(name)
|
|
170
|
+
const rawValue = req.raw?.headers?.get(name)
|
|
171
|
+
if (rawValue != null) return rawValue
|
|
172
|
+
const headers: any = req.headers
|
|
173
|
+
if (headers && typeof headers !== "function" && typeof headers.get === "function") {
|
|
174
|
+
return headers.get(name) ?? undefined
|
|
175
|
+
}
|
|
176
|
+
if (typeof headers === "function") {
|
|
177
|
+
const values = headers()
|
|
178
|
+
return values[name.toLowerCase()] ?? values[name]
|
|
179
|
+
}
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
157
183
|
const resolveStore = (
|
|
158
184
|
s: HttpCacheOptions["store"] | null | undefined,
|
|
159
185
|
): CacheStore | null => {
|
|
@@ -164,15 +190,15 @@ const resolveStore = (
|
|
|
164
190
|
}
|
|
165
191
|
|
|
166
192
|
const responseToEntry = async (resp: Response): Promise<CachedEntry> => {
|
|
167
|
-
const
|
|
193
|
+
const bodyBase64 = Buffer.from(await resp.clone().arrayBuffer()).toString("base64")
|
|
168
194
|
const headers: Record<string, string> = {}
|
|
169
195
|
resp.headers.forEach((v, k) => {
|
|
170
196
|
// Skip hop-by-hop and connection-specific headers
|
|
171
197
|
if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") return
|
|
172
198
|
headers[k] = v
|
|
173
199
|
})
|
|
174
|
-
const etag = `W/"${hash(
|
|
175
|
-
return { status: resp.status, headers,
|
|
200
|
+
const etag = `W/"${hash(bodyBase64)}"`
|
|
201
|
+
return { status: resp.status, headers, bodyBase64, etag, storedAt: Date.now() }
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
const entryToResponse = (e: CachedEntry, opts: HttpCacheOptions): Response => {
|
|
@@ -181,13 +207,16 @@ const entryToResponse = (e: CachedEntry, opts: HttpCacheOptions): Response => {
|
|
|
181
207
|
headers["cache-control"] = `public, max-age=${opts.ttl ?? 60}`
|
|
182
208
|
}
|
|
183
209
|
headers["x-tekir-cache"] = "HIT"
|
|
184
|
-
|
|
210
|
+
const body = e.status === 204 || e.status === 205 || e.status === 304
|
|
211
|
+
? null
|
|
212
|
+
: Buffer.from(e.bodyBase64, "base64")
|
|
213
|
+
return new Response(body, { status: e.status, headers })
|
|
185
214
|
}
|
|
186
215
|
|
|
187
216
|
/** True if the request carries an Authorization or Cookie header. */
|
|
188
217
|
const hasCredentials = (req: HttpCacheCtx["request"]): boolean => {
|
|
189
218
|
for (const h of CREDENTIAL_HEADERS) {
|
|
190
|
-
if (req
|
|
219
|
+
if (requestHeader(req, h)) return true
|
|
191
220
|
}
|
|
192
221
|
return false
|
|
193
222
|
}
|
|
@@ -218,7 +247,7 @@ export function cache(opts: HttpCacheOptions = {}) {
|
|
|
218
247
|
return
|
|
219
248
|
}
|
|
220
249
|
|
|
221
|
-
const cacheControl = req
|
|
250
|
+
const cacheControl = requestHeader(req, "cache-control") ?? ""
|
|
222
251
|
if (cacheControl.includes("no-store")) {
|
|
223
252
|
await next()
|
|
224
253
|
return
|
|
@@ -247,7 +276,7 @@ export function cache(opts: HttpCacheOptions = {}) {
|
|
|
247
276
|
const cached = await store.get<CachedEntry>(key)
|
|
248
277
|
|
|
249
278
|
// Conditional request: client sent If-None-Match
|
|
250
|
-
const ifNoneMatch = req
|
|
279
|
+
const ifNoneMatch = requestHeader(req, "if-none-match") ?? ""
|
|
251
280
|
if (cached) {
|
|
252
281
|
if (ifNoneMatch && ifNoneMatch === cached.etag) {
|
|
253
282
|
ctx.$result = new Response(null, {
|
|
@@ -262,12 +291,18 @@ export function cache(opts: HttpCacheOptions = {}) {
|
|
|
262
291
|
|
|
263
292
|
// Miss: run the handler chain, capture, store.
|
|
264
293
|
await next()
|
|
265
|
-
|
|
294
|
+
let result = ctx.$result
|
|
295
|
+
if (!(result instanceof Response) && result !== null && typeof result === "object" && typeof result.next !== "function") {
|
|
296
|
+
result = new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" } })
|
|
297
|
+
}
|
|
298
|
+
if (result instanceof Response && ctx.response) result = finalizeResponse(ctx.response, result)
|
|
299
|
+
if (result instanceof Response) ctx.$result = result
|
|
266
300
|
if (!(result instanceof Response)) return
|
|
267
|
-
if (result.status >= 500 || result.status === 204
|
|
301
|
+
if (result.status >= 500 || result.status === 204 || result.status === 304) return
|
|
268
302
|
if (cacheControl.includes("no-cache")) return
|
|
269
303
|
const respCacheControl = result.headers.get("cache-control") ?? ""
|
|
270
304
|
if (respCacheControl.includes("private") || respCacheControl.includes("no-store")) return
|
|
305
|
+
if (ctx.$willSetCookie || result.headers.has("set-cookie") || (ctx.response && hasPendingResponseCookies(ctx.response))) return
|
|
271
306
|
|
|
272
307
|
const entry = await responseToEntry(result)
|
|
273
308
|
await store.set(key, entry, ttl)
|
|
@@ -280,6 +315,9 @@ export function cache(opts: HttpCacheOptions = {}) {
|
|
|
280
315
|
out["cache-control"] = `public, max-age=${ttl}`
|
|
281
316
|
}
|
|
282
317
|
out["x-tekir-cache"] = "MISS"
|
|
283
|
-
|
|
318
|
+
const body = result.status === 204 || result.status === 205 || result.status === 304
|
|
319
|
+
? null
|
|
320
|
+
: Buffer.from(entry.bodyBase64, "base64")
|
|
321
|
+
ctx.$result = new Response(body, { status: result.status, headers: out })
|
|
284
322
|
}
|
|
285
323
|
}
|
package/src/stores/database.ts
CHANGED
|
@@ -30,10 +30,19 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
30
30
|
this.table = table
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
private get driver(): string { return this.db.driver || 'sqlite' }
|
|
34
|
+
private get quotedTable(): string { return this.driver === 'mysql' ? `\`${this.table}\`` : `"${this.table}"` }
|
|
35
|
+
private sql(statement: string): string {
|
|
36
|
+
if (this.driver !== 'postgres') return statement
|
|
37
|
+
let index = 0
|
|
38
|
+
return statement.replace(/\?/g, () => `$${++index}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
private async _ensureTable() {
|
|
34
42
|
if (this._ready) return
|
|
35
43
|
try {
|
|
36
|
-
|
|
44
|
+
const expiresType = this.driver === 'sqlite' ? 'INTEGER' : 'BIGINT'
|
|
45
|
+
await this.db.exec(`CREATE TABLE IF NOT EXISTS ${this.quotedTable} (key ${this.driver === 'mysql' ? 'VARCHAR(255)' : 'TEXT'} PRIMARY KEY, value TEXT, expires_at ${expiresType})`)
|
|
37
46
|
this._ready = true
|
|
38
47
|
} catch (e) {
|
|
39
48
|
// Don't silently swallow: an unset _ready means every later get/set blows
|
|
@@ -52,10 +61,10 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
52
61
|
*/
|
|
53
62
|
async get<T = any>(key: string): Promise<T | null> {
|
|
54
63
|
await this._ensureTable()
|
|
55
|
-
const row = await this.db.queryOne(`SELECT value, expires_at FROM
|
|
64
|
+
const row = await this.db.queryOne(this.sql(`SELECT value, expires_at FROM ${this.quotedTable} WHERE key = ?`), [key]) as CacheDbRow | null
|
|
56
65
|
if (!row) return null
|
|
57
66
|
if (row.expires_at && Date.now() > row.expires_at) {
|
|
58
|
-
await this.db.run(`DELETE FROM
|
|
67
|
+
await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE key = ?`), [key])
|
|
59
68
|
return null
|
|
60
69
|
}
|
|
61
70
|
try { return JSON.parse(row.value) } catch { return row.value as T }
|
|
@@ -72,10 +81,10 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
72
81
|
await this._ensureTable()
|
|
73
82
|
const val = JSON.stringify(value)
|
|
74
83
|
const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null
|
|
75
|
-
|
|
76
|
-
`INSERT
|
|
77
|
-
|
|
78
|
-
)
|
|
84
|
+
const insert = this.driver === 'mysql'
|
|
85
|
+
? `INSERT INTO ${this.quotedTable} (key, value, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), expires_at = VALUES(expires_at)`
|
|
86
|
+
: `INSERT INTO ${this.quotedTable} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`
|
|
87
|
+
await this.db.run(this.sql(insert), [key, val, expiresAt])
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/**
|
|
@@ -86,10 +95,10 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
86
95
|
*/
|
|
87
96
|
async has(key: string): Promise<boolean> {
|
|
88
97
|
await this._ensureTable()
|
|
89
|
-
const row = await this.db.queryOne(`SELECT expires_at FROM
|
|
98
|
+
const row = await this.db.queryOne(this.sql(`SELECT expires_at FROM ${this.quotedTable} WHERE key = ?`), [key]) as { expires_at: number | null } | null
|
|
90
99
|
if (!row) return false
|
|
91
100
|
if (row.expires_at && Date.now() > row.expires_at) {
|
|
92
|
-
await this.db.run(`DELETE FROM
|
|
101
|
+
await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE key = ?`), [key])
|
|
93
102
|
return false
|
|
94
103
|
}
|
|
95
104
|
// Present even if the stored value is `null` (negative caching).
|
|
@@ -104,7 +113,7 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
104
113
|
*/
|
|
105
114
|
async delete(key: string): Promise<boolean> {
|
|
106
115
|
await this._ensureTable()
|
|
107
|
-
await this.db.run(`DELETE FROM
|
|
116
|
+
await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE key = ?`), [key])
|
|
108
117
|
return true
|
|
109
118
|
}
|
|
110
119
|
|
|
@@ -116,7 +125,7 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
116
125
|
*/
|
|
117
126
|
async prune(): Promise<void> {
|
|
118
127
|
await this._ensureTable()
|
|
119
|
-
await this.db.run(`DELETE FROM
|
|
128
|
+
await this.db.run(this.sql(`DELETE FROM ${this.quotedTable} WHERE expires_at IS NOT NULL AND expires_at < ?`), [Date.now()])
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
/**
|
|
@@ -129,6 +138,6 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
129
138
|
*/
|
|
130
139
|
async flush(): Promise<void> {
|
|
131
140
|
await this._ensureTable()
|
|
132
|
-
await this.db.run(`DELETE FROM
|
|
141
|
+
await this.db.run(`DELETE FROM ${this.quotedTable}`)
|
|
133
142
|
}
|
|
134
143
|
}
|
package/dist/cache.js
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { MemoryCacheStore } from './stores/memory';
|
|
2
|
-
/**
|
|
3
|
-
* Multi-store cache manager that delegates to configured {@link CacheStore}
|
|
4
|
-
* implementations. Supports named stores, a default TTL, and convenience
|
|
5
|
-
* methods like {@link getOrSet} and {@link pull}.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* ```ts
|
|
9
|
-
* const cache = new Cache({ stores: { memory: new MemoryCacheStore() }, ttl: 60 })
|
|
10
|
-
* await cache.set('key', 'value')
|
|
11
|
-
* const val = await cache.get<string>('key')
|
|
12
|
-
* ```
|
|
13
|
-
*/
|
|
14
|
-
export class Cache {
|
|
15
|
-
stores;
|
|
16
|
-
defaultStore;
|
|
17
|
-
defaultTtl;
|
|
18
|
-
// In-flight factory promises keyed by `<store>:<key>` for single-flight
|
|
19
|
-
// stampede protection in getOrSet.
|
|
20
|
-
inFlight = new Map();
|
|
21
|
-
/**
|
|
22
|
-
* Create a new Cache instance.
|
|
23
|
-
*
|
|
24
|
-
* @param config - Cache configuration including stores, default store name, and TTL.
|
|
25
|
-
*/
|
|
26
|
-
constructor(config = {}) {
|
|
27
|
-
this.stores = config.stores || { memory: new MemoryCacheStore() };
|
|
28
|
-
this.defaultStore = config.default || Object.keys(this.stores)[0];
|
|
29
|
-
this.defaultTtl = config.ttl || 3600;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Retrieve a specific named store, or the default store if no name is given.
|
|
33
|
-
*
|
|
34
|
-
* @param name - The store name. Omit to use the default store.
|
|
35
|
-
* @returns The resolved {@link CacheStore} instance.
|
|
36
|
-
* @throws Error if the requested store is not configured.
|
|
37
|
-
*
|
|
38
|
-
* @example
|
|
39
|
-
* ```ts
|
|
40
|
-
* const redis = cache.store('redis')
|
|
41
|
-
* await redis.get('key')
|
|
42
|
-
* ```
|
|
43
|
-
*/
|
|
44
|
-
store(name) {
|
|
45
|
-
const storeName = name || this.defaultStore;
|
|
46
|
-
const s = this.stores[storeName];
|
|
47
|
-
if (!s)
|
|
48
|
-
throw new Error(`Cache store "${storeName}" not configured`);
|
|
49
|
-
return s;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Get a value from the default store.
|
|
53
|
-
*
|
|
54
|
-
* @param key - The cache key.
|
|
55
|
-
* @returns The cached value, or `null` if not found or expired.
|
|
56
|
-
*/
|
|
57
|
-
async get(key) { return this.store().get(key); }
|
|
58
|
-
/**
|
|
59
|
-
* Set a value in the default store.
|
|
60
|
-
*
|
|
61
|
-
* @param key - The cache key.
|
|
62
|
-
* @param value - The value to store.
|
|
63
|
-
* @param ttl - Time-to-live in seconds. Falls back to the default TTL.
|
|
64
|
-
*/
|
|
65
|
-
async set(key, value, ttl) { return this.store().set(key, value, ttl ?? this.defaultTtl); }
|
|
66
|
-
/**
|
|
67
|
-
* Check whether a key exists (and is not expired) in the default store.
|
|
68
|
-
*
|
|
69
|
-
* @param key - The cache key.
|
|
70
|
-
* @returns `true` if the key exists.
|
|
71
|
-
*/
|
|
72
|
-
async has(key) { return this.store().has(key); }
|
|
73
|
-
/**
|
|
74
|
-
* Delete a key from the default store.
|
|
75
|
-
*
|
|
76
|
-
* @param key - The cache key.
|
|
77
|
-
* @returns `true` if the key was deleted.
|
|
78
|
-
*/
|
|
79
|
-
async delete(key) { return this.store().delete(key); }
|
|
80
|
-
/**
|
|
81
|
-
* Flush all entries from the default store.
|
|
82
|
-
*/
|
|
83
|
-
async flush() { return this.store().flush(); }
|
|
84
|
-
/**
|
|
85
|
-
* Get a cached value or compute and store it if missing. Inspired by AdonisJS.
|
|
86
|
-
*
|
|
87
|
-
* @param key - The cache key.
|
|
88
|
-
* @param ttl - Time-to-live in seconds for the computed value.
|
|
89
|
-
* @param factory - An async function that produces the value when not cached.
|
|
90
|
-
* @returns The cached or freshly-computed value.
|
|
91
|
-
*
|
|
92
|
-
* Concurrent misses for the same key share a single `factory()` execution
|
|
93
|
-
* (single-flight) so a popular key expiring does not trigger a stampede
|
|
94
|
-
* (thundering herd) against the backend.
|
|
95
|
-
*
|
|
96
|
-
* @example
|
|
97
|
-
* ```ts
|
|
98
|
-
* const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
|
|
99
|
-
* ```
|
|
100
|
-
*/
|
|
101
|
-
async getOrSet(key, ttl, factory) {
|
|
102
|
-
const cached = await this.get(key);
|
|
103
|
-
if (cached !== null)
|
|
104
|
-
return cached;
|
|
105
|
-
const flightKey = `${this.defaultStore}:${key}`;
|
|
106
|
-
const existing = this.inFlight.get(flightKey);
|
|
107
|
-
if (existing)
|
|
108
|
-
return existing;
|
|
109
|
-
const promise = (async () => {
|
|
110
|
-
const value = await factory();
|
|
111
|
-
await this.set(key, value, ttl);
|
|
112
|
-
return value;
|
|
113
|
-
})().finally(() => this.inFlight.delete(flightKey));
|
|
114
|
-
this.inFlight.set(flightKey, promise);
|
|
115
|
-
return promise;
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Get a value and immediately delete it from the cache (atomic get-and-remove).
|
|
119
|
-
*
|
|
120
|
-
* @param key - The cache key.
|
|
121
|
-
* @returns The cached value, or `null` if not found.
|
|
122
|
-
*
|
|
123
|
-
* @example
|
|
124
|
-
* ```ts
|
|
125
|
-
* const token = await cache.pull<string>('one-time-token')
|
|
126
|
-
* ```
|
|
127
|
-
*/
|
|
128
|
-
async pull(key) {
|
|
129
|
-
const value = await this.get(key);
|
|
130
|
-
// Use has() rather than a null-check so that an explicitly cached `null`
|
|
131
|
-
// value is still evicted (get() alone cannot distinguish "absent" from
|
|
132
|
-
// "stored null").
|
|
133
|
-
if (value !== null || (await this.has(key)))
|
|
134
|
-
await this.delete(key);
|
|
135
|
-
return value;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Create a new {@link Cache} instance with the given configuration.
|
|
140
|
-
*
|
|
141
|
-
* @param config - Optional cache configuration.
|
|
142
|
-
* @returns A new Cache instance.
|
|
143
|
-
*
|
|
144
|
-
* @example
|
|
145
|
-
* ```ts
|
|
146
|
-
* const cache = createCache({ ttl: 120 })
|
|
147
|
-
* ```
|
|
148
|
-
*/
|
|
149
|
-
export function createCache(config) {
|
|
150
|
-
return new Cache(config);
|
|
151
|
-
}
|
package/dist/http-cache.js
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP response cache middleware.
|
|
3
|
-
*
|
|
4
|
-
* Caches the full Response (status + headers + body) under a key derived
|
|
5
|
-
* from the request. On hit: short-circuits the handler chain and returns
|
|
6
|
-
* the cached payload, also producing `304 Not Modified` when the client
|
|
7
|
-
* sends a matching `If-None-Match`.
|
|
8
|
-
*
|
|
9
|
-
* Storage is delegated to any `CacheStore` (memory, redis, database).
|
|
10
|
-
* Only safe methods (GET, HEAD) are cached by default.
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```ts
|
|
14
|
-
* import { Cache, cache, MemoryCacheStore } from '@tekir/cache'
|
|
15
|
-
*
|
|
16
|
-
* const store = new Cache({ stores: { memory: new MemoryCacheStore() } })
|
|
17
|
-
*
|
|
18
|
-
* router.get(
|
|
19
|
-
* '/api/posts',
|
|
20
|
-
* cache({ store, ttl: 60 }),
|
|
21
|
-
* async () => Post.all(),
|
|
22
|
-
* )
|
|
23
|
-
* ```
|
|
24
|
-
*/
|
|
25
|
-
import { Cache } from "./cache";
|
|
26
|
-
/** Request headers that indicate a credentialed/per-user request. */
|
|
27
|
-
const CREDENTIAL_HEADERS = ["authorization", "cookie"];
|
|
28
|
-
const SAFE_METHODS = ["GET", "HEAD"];
|
|
29
|
-
const isStore = (s) => !!s &&
|
|
30
|
-
typeof s.get === "function" &&
|
|
31
|
-
typeof s.set === "function";
|
|
32
|
-
/**
|
|
33
|
-
* Module-level default store. CacheProvider sets this at register time so
|
|
34
|
-
* `cache({ ttl: 60 })` works without an explicit `store` option once the
|
|
35
|
-
* provider is wired into the app. Stays null if the provider isn't used,
|
|
36
|
-
* in which case the middleware no-ops (passes through).
|
|
37
|
-
*/
|
|
38
|
-
let _defaultStore = null;
|
|
39
|
-
/**
|
|
40
|
-
* Register the default backing store for the `cache()` middleware. Called
|
|
41
|
-
* by `CacheProvider` after it builds the Cache manager from config.
|
|
42
|
-
*
|
|
43
|
-
* Users can also call this directly if they don't use providers:
|
|
44
|
-
*
|
|
45
|
-
* ```ts
|
|
46
|
-
* import { setDefaultCacheStore, Cache, MemoryCacheStore } from '@tekir/cache'
|
|
47
|
-
* setDefaultCacheStore(new Cache({ stores: { memory: new MemoryCacheStore() } }))
|
|
48
|
-
* ```
|
|
49
|
-
*/
|
|
50
|
-
export function setDefaultCacheStore(s) {
|
|
51
|
-
_defaultStore = s;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Returns the currently registered default store, or null if none.
|
|
55
|
-
*/
|
|
56
|
-
export function getDefaultCacheStore() {
|
|
57
|
-
return _defaultStore;
|
|
58
|
-
}
|
|
59
|
-
const hash = (s) => {
|
|
60
|
-
// FNV-1a 32-bit. Good enough for ETags; not crypto.
|
|
61
|
-
let h = 0x811c9dc5;
|
|
62
|
-
for (let i = 0; i < s.length; i++) {
|
|
63
|
-
h ^= s.charCodeAt(i);
|
|
64
|
-
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
65
|
-
}
|
|
66
|
-
return h.toString(16).padStart(8, "0");
|
|
67
|
-
};
|
|
68
|
-
const defaultKey = (ctx, vary) => {
|
|
69
|
-
const parts = [ctx.request.method, ctx.request.url];
|
|
70
|
-
for (const h of vary) {
|
|
71
|
-
const v = ctx.request.headers.get(h);
|
|
72
|
-
if (v)
|
|
73
|
-
parts.push(`${h}=${v}`);
|
|
74
|
-
}
|
|
75
|
-
return parts.join("|");
|
|
76
|
-
};
|
|
77
|
-
const resolveStore = (s) => {
|
|
78
|
-
if (!s)
|
|
79
|
-
return null;
|
|
80
|
-
if (s instanceof Cache)
|
|
81
|
-
return s.store();
|
|
82
|
-
if (isStore(s))
|
|
83
|
-
return s;
|
|
84
|
-
return null;
|
|
85
|
-
};
|
|
86
|
-
const responseToEntry = async (resp) => {
|
|
87
|
-
const body = await resp.clone().text();
|
|
88
|
-
const headers = {};
|
|
89
|
-
resp.headers.forEach((v, k) => {
|
|
90
|
-
// Skip hop-by-hop and connection-specific headers
|
|
91
|
-
if (k === "connection" || k === "keep-alive" || k === "transfer-encoding")
|
|
92
|
-
return;
|
|
93
|
-
headers[k] = v;
|
|
94
|
-
});
|
|
95
|
-
const etag = `W/"${hash(body)}"`;
|
|
96
|
-
return { status: resp.status, headers, body, etag, storedAt: Date.now() };
|
|
97
|
-
};
|
|
98
|
-
const entryToResponse = (e, opts) => {
|
|
99
|
-
const headers = { ...e.headers, etag: e.etag };
|
|
100
|
-
if (opts.setCacheControl !== false && !headers["cache-control"]) {
|
|
101
|
-
headers["cache-control"] = `public, max-age=${opts.ttl ?? 60}`;
|
|
102
|
-
}
|
|
103
|
-
headers["x-tekir-cache"] = "HIT";
|
|
104
|
-
return new Response(e.body, { status: e.status, headers });
|
|
105
|
-
};
|
|
106
|
-
/** True if the request carries an Authorization or Cookie header. */
|
|
107
|
-
const hasCredentials = (req) => {
|
|
108
|
-
for (const h of CREDENTIAL_HEADERS) {
|
|
109
|
-
if (req.headers?.get?.(h))
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
return false;
|
|
113
|
-
};
|
|
114
|
-
export function cache(opts = {}) {
|
|
115
|
-
const ttl = opts.ttl ?? 60;
|
|
116
|
-
const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()));
|
|
117
|
-
const vary = opts.vary ?? [];
|
|
118
|
-
const prefix = opts.prefix ?? "http:";
|
|
119
|
-
const hasCustomKey = typeof opts.key === "function";
|
|
120
|
-
const authMode = opts.authenticated ?? "bypass";
|
|
121
|
-
// When varying by credentials, fold the credential headers into the key so
|
|
122
|
-
// each identity gets a private entry.
|
|
123
|
-
const effectiveVary = authMode === "vary" ? [...vary, ...CREDENTIAL_HEADERS] : vary;
|
|
124
|
-
const buildKey = opts.key ?? ((ctx) => defaultKey(ctx, effectiveVary));
|
|
125
|
-
const directStore = resolveStore(opts.store);
|
|
126
|
-
return async function cacheMiddleware(ctx, next) {
|
|
127
|
-
const req = ctx.request;
|
|
128
|
-
if (!req || !methods.has(String(req.method ?? "GET").toUpperCase())) {
|
|
129
|
-
await next();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
if (opts.skip && (await opts.skip(ctx))) {
|
|
133
|
-
await next();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const cacheControl = req.headers?.get?.("cache-control") ?? "";
|
|
137
|
-
if (cacheControl.includes("no-store")) {
|
|
138
|
-
await next();
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
// Secure-by-default: a credentialed (Authorization/Cookie) request usually
|
|
142
|
-
// produces a per-user response. Caching it under a shared key would leak it
|
|
143
|
-
// to other users. Unless the caller opted into 'vary'/'allow' or supplied a
|
|
144
|
-
// custom identity-aware key, bypass the cache for such requests.
|
|
145
|
-
if (authMode === "bypass" && !hasCustomKey && hasCredentials(req)) {
|
|
146
|
-
await next();
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
// Resolve store: option > module-level default (set by CacheProvider).
|
|
150
|
-
// No store anywhere → middleware acts as a transparent no-op so the
|
|
151
|
-
// route still works without a registered cache backend.
|
|
152
|
-
let store = directStore;
|
|
153
|
-
if (!store)
|
|
154
|
-
store = resolveStore(_defaultStore);
|
|
155
|
-
if (!store) {
|
|
156
|
-
await next();
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const key = prefix + buildKey(ctx);
|
|
160
|
-
const cached = await store.get(key);
|
|
161
|
-
// Conditional request: client sent If-None-Match
|
|
162
|
-
const ifNoneMatch = req.headers?.get?.("if-none-match") ?? "";
|
|
163
|
-
if (cached) {
|
|
164
|
-
if (ifNoneMatch && ifNoneMatch === cached.etag) {
|
|
165
|
-
ctx.$result = new Response(null, {
|
|
166
|
-
status: 304,
|
|
167
|
-
headers: { etag: cached.etag, "x-tekir-cache": "REVALIDATED" },
|
|
168
|
-
});
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
ctx.$result = entryToResponse(cached, opts);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
// Miss: run the handler chain, capture, store.
|
|
175
|
-
await next();
|
|
176
|
-
const result = ctx.$result;
|
|
177
|
-
if (!(result instanceof Response))
|
|
178
|
-
return;
|
|
179
|
-
if (result.status >= 500 || result.status === 204)
|
|
180
|
-
return; // don't cache errors / empty
|
|
181
|
-
if (cacheControl.includes("no-cache"))
|
|
182
|
-
return;
|
|
183
|
-
const respCacheControl = result.headers.get("cache-control") ?? "";
|
|
184
|
-
if (respCacheControl.includes("private") || respCacheControl.includes("no-store"))
|
|
185
|
-
return;
|
|
186
|
-
const entry = await responseToEntry(result);
|
|
187
|
-
await store.set(key, entry, ttl);
|
|
188
|
-
// Re-emit with x-tekir-cache: MISS so the client can see it
|
|
189
|
-
const out = {};
|
|
190
|
-
result.headers.forEach((v, k) => (out[k] = v));
|
|
191
|
-
out["etag"] = entry.etag;
|
|
192
|
-
if (opts.setCacheControl !== false && !out["cache-control"]) {
|
|
193
|
-
out["cache-control"] = `public, max-age=${ttl}`;
|
|
194
|
-
}
|
|
195
|
-
out["x-tekir-cache"] = "MISS";
|
|
196
|
-
ctx.$result = new Response(entry.body, { status: result.status, headers: out });
|
|
197
|
-
};
|
|
198
|
-
}
|
package/dist/provider.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { Cache } from './cache';
|
|
2
|
-
import { MemoryCacheStore } from './stores/memory';
|
|
3
|
-
/**
|
|
4
|
-
* Service provider that registers a {@link Cache} instance into the application
|
|
5
|
-
* container. Reads the `cache` configuration to create stores for each configured
|
|
6
|
-
* driver (`memory`, `redis`, or `database`).
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```ts
|
|
10
|
-
* // In your kernel:
|
|
11
|
-
* app.register(new CacheProvider())
|
|
12
|
-
* ```
|
|
13
|
-
*/
|
|
14
|
-
export class CacheProvider {
|
|
15
|
-
/**
|
|
16
|
-
* Register the cache service with the application. Reads `cache.stores`,
|
|
17
|
-
* `cache.ttl`, and `cache.default` from the application config.
|
|
18
|
-
*
|
|
19
|
-
* @param app - The application instance.
|
|
20
|
-
*/
|
|
21
|
-
async register(app) {
|
|
22
|
-
const config = app.use('config');
|
|
23
|
-
if (!config('cache'))
|
|
24
|
-
return;
|
|
25
|
-
const storesConfig = config('cache.stores', {});
|
|
26
|
-
const stores = {};
|
|
27
|
-
for (const [name, storeConfig] of Object.entries(storesConfig)) {
|
|
28
|
-
// Already a CacheStore instance (backwards compat)
|
|
29
|
-
if (storeConfig && typeof storeConfig.get === 'function') {
|
|
30
|
-
stores[name] = storeConfig;
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
const driver = storeConfig?.driver || name;
|
|
34
|
-
if (driver === 'memory') {
|
|
35
|
-
stores[name] = new MemoryCacheStore();
|
|
36
|
-
}
|
|
37
|
-
else if (driver === 'redis') {
|
|
38
|
-
let redis;
|
|
39
|
-
try {
|
|
40
|
-
redis = app.use('redis');
|
|
41
|
-
}
|
|
42
|
-
catch { }
|
|
43
|
-
if (!redis) {
|
|
44
|
-
// Fallback: create instance from config
|
|
45
|
-
let Redis;
|
|
46
|
-
try {
|
|
47
|
-
// @ts-ignore optional peer; resolved at runtime, falls through
|
|
48
|
-
// to the catch below if not installed.
|
|
49
|
-
Redis = (await import('@tekir/redis')).Redis;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
throw new Error(`[@tekir/cache] Store "${name}" uses the redis driver but @tekir/redis is not installed. ` +
|
|
53
|
-
'Run: bun add @tekir/redis and register RedisProvider before CacheProvider.');
|
|
54
|
-
}
|
|
55
|
-
redis = new Redis({ ...config('redis', {}), ...storeConfig });
|
|
56
|
-
}
|
|
57
|
-
const { RedisCacheStore } = await import('./stores/redis');
|
|
58
|
-
stores[name] = new RedisCacheStore(redis, storeConfig?.prefix);
|
|
59
|
-
}
|
|
60
|
-
else if (driver === 'database') {
|
|
61
|
-
let db;
|
|
62
|
-
try {
|
|
63
|
-
db = app.use('db');
|
|
64
|
-
}
|
|
65
|
-
catch { }
|
|
66
|
-
if (!db) {
|
|
67
|
-
throw new Error(`[@tekir/cache] Store "${name}" uses the database driver but no database service is registered. ` +
|
|
68
|
-
'Add DatabaseProvider to your kernel before CacheProvider.');
|
|
69
|
-
}
|
|
70
|
-
const { DatabaseCacheStore } = await import('./stores/database');
|
|
71
|
-
stores[name] = new DatabaseCacheStore(db, storeConfig?.table);
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
throw new Error(`[@tekir/cache] Unknown cache driver "${driver}" for store "${name}". ` +
|
|
75
|
-
'Supported drivers: memory, redis, database');
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
// Fallback to memory if no stores configured
|
|
79
|
-
if (Object.keys(stores).length === 0) {
|
|
80
|
-
stores.memory = new MemoryCacheStore();
|
|
81
|
-
}
|
|
82
|
-
const cacheInstance = new Cache({
|
|
83
|
-
stores,
|
|
84
|
-
ttl: config('cache.ttl', 60),
|
|
85
|
-
default: config('cache.default', Object.keys(stores)[0]),
|
|
86
|
-
});
|
|
87
|
-
app.instance('cache', cacheInstance);
|
|
88
|
-
// Wire the cache() HTTP middleware so route-level `cache({ ttl: 60 })`
|
|
89
|
-
// works without an explicit `store` option once this provider is
|
|
90
|
-
// registered.
|
|
91
|
-
const { setDefaultCacheStore } = await import('./http-cache');
|
|
92
|
-
setDefaultCacheStore(cacheInstance);
|
|
93
|
-
}
|
|
94
|
-
}
|