@tekir/cache 0.1.7 → 0.1.9
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/cache.d.ts +5 -0
- package/dist/cache.js +22 -4
- package/dist/http-cache.d.ts +19 -0
- package/dist/http-cache.js +24 -1
- package/dist/stores/database.d.ts +7 -0
- package/dist/stores/database.js +27 -2
- package/dist/stores/memory.d.ts +13 -0
- package/dist/stores/memory.js +41 -1
- package/dist/stores/redis.d.ts +7 -2
- package/dist/stores/redis.js +33 -6
- package/package.json +2 -2
- package/src/cache.ts +24 -4
- package/src/http-cache.ts +46 -1
- package/src/stores/database.ts +27 -2
- package/src/stores/memory.ts +39 -1
- package/src/stores/redis.ts +33 -6
package/dist/cache.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare class Cache {
|
|
|
15
15
|
private stores;
|
|
16
16
|
private defaultStore;
|
|
17
17
|
private defaultTtl;
|
|
18
|
+
private inFlight;
|
|
18
19
|
/**
|
|
19
20
|
* Create a new Cache instance.
|
|
20
21
|
*
|
|
@@ -76,6 +77,10 @@ export declare class Cache {
|
|
|
76
77
|
* @param factory - An async function that produces the value when not cached.
|
|
77
78
|
* @returns The cached or freshly-computed value.
|
|
78
79
|
*
|
|
80
|
+
* Concurrent misses for the same key share a single `factory()` execution
|
|
81
|
+
* (single-flight) so a popular key expiring does not trigger a stampede
|
|
82
|
+
* (thundering herd) against the backend.
|
|
83
|
+
*
|
|
79
84
|
* @example
|
|
80
85
|
* ```ts
|
|
81
86
|
* const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
|
package/dist/cache.js
CHANGED
|
@@ -15,6 +15,9 @@ export class Cache {
|
|
|
15
15
|
stores;
|
|
16
16
|
defaultStore;
|
|
17
17
|
defaultTtl;
|
|
18
|
+
// In-flight factory promises keyed by `<store>:<key>` for single-flight
|
|
19
|
+
// stampede protection in getOrSet.
|
|
20
|
+
inFlight = new Map();
|
|
18
21
|
/**
|
|
19
22
|
* Create a new Cache instance.
|
|
20
23
|
*
|
|
@@ -86,6 +89,10 @@ export class Cache {
|
|
|
86
89
|
* @param factory - An async function that produces the value when not cached.
|
|
87
90
|
* @returns The cached or freshly-computed value.
|
|
88
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
|
+
*
|
|
89
96
|
* @example
|
|
90
97
|
* ```ts
|
|
91
98
|
* const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
|
|
@@ -95,9 +102,17 @@ export class Cache {
|
|
|
95
102
|
const cached = await this.get(key);
|
|
96
103
|
if (cached !== null)
|
|
97
104
|
return cached;
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
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;
|
|
101
116
|
}
|
|
102
117
|
/**
|
|
103
118
|
* Get a value and immediately delete it from the cache (atomic get-and-remove).
|
|
@@ -112,7 +127,10 @@ export class Cache {
|
|
|
112
127
|
*/
|
|
113
128
|
async pull(key) {
|
|
114
129
|
const value = await this.get(key);
|
|
115
|
-
|
|
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)))
|
|
116
134
|
await this.delete(key);
|
|
117
135
|
return value;
|
|
118
136
|
}
|
package/dist/http-cache.d.ts
CHANGED
|
@@ -59,6 +59,25 @@ export type HttpCacheOptions = {
|
|
|
59
59
|
* responses. Default: true.
|
|
60
60
|
*/
|
|
61
61
|
setCacheControl?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* How to handle requests that carry credentials (`Authorization` or `Cookie`).
|
|
64
|
+
*
|
|
65
|
+
* Caching a per-user response under a shared key leaks one user's response to
|
|
66
|
+
* another. To prevent that, the default behaviour is `'bypass'`: authenticated
|
|
67
|
+
* requests skip the cache entirely unless you opt in.
|
|
68
|
+
*
|
|
69
|
+
* - `'bypass'` (default): never read or write the cache for credentialed
|
|
70
|
+
* requests.
|
|
71
|
+
* - `'vary'`: include the credential headers in the cache key so each
|
|
72
|
+
* identity gets its own entry. Use this only when you understand the cache
|
|
73
|
+
* size implications.
|
|
74
|
+
* - `'allow'`: cache credentialed requests under the same key as anonymous
|
|
75
|
+
* ones. DANGEROUS: only safe when the response is identical for every user.
|
|
76
|
+
*
|
|
77
|
+
* Note: providing a custom `key` builder that already incorporates identity
|
|
78
|
+
* overrides this and is always honoured.
|
|
79
|
+
*/
|
|
80
|
+
authenticated?: "bypass" | "vary" | "allow";
|
|
62
81
|
};
|
|
63
82
|
export type HttpCacheCtx = {
|
|
64
83
|
request: {
|
package/dist/http-cache.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
import { Cache } from "./cache";
|
|
26
|
+
/** Request headers that indicate a credentialed/per-user request. */
|
|
27
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie"];
|
|
26
28
|
const SAFE_METHODS = ["GET", "HEAD"];
|
|
27
29
|
const isStore = (s) => !!s &&
|
|
28
30
|
typeof s.get === "function" &&
|
|
@@ -101,12 +103,25 @@ const entryToResponse = (e, opts) => {
|
|
|
101
103
|
headers["x-tekir-cache"] = "HIT";
|
|
102
104
|
return new Response(e.body, { status: e.status, headers });
|
|
103
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
|
+
};
|
|
104
114
|
export function cache(opts = {}) {
|
|
105
115
|
const ttl = opts.ttl ?? 60;
|
|
106
116
|
const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()));
|
|
107
117
|
const vary = opts.vary ?? [];
|
|
108
118
|
const prefix = opts.prefix ?? "http:";
|
|
109
|
-
const
|
|
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));
|
|
110
125
|
const directStore = resolveStore(opts.store);
|
|
111
126
|
return async function cacheMiddleware(ctx, next) {
|
|
112
127
|
const req = ctx.request;
|
|
@@ -123,6 +138,14 @@ export function cache(opts = {}) {
|
|
|
123
138
|
await next();
|
|
124
139
|
return;
|
|
125
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
|
+
}
|
|
126
149
|
// Resolve store: option > module-level default (set by CacheProvider).
|
|
127
150
|
// No store anywhere → middleware acts as a transparent no-op so the
|
|
128
151
|
// route still works without a registered cache backend.
|
|
@@ -51,6 +51,13 @@ export declare class DatabaseCacheStore implements CacheStore {
|
|
|
51
51
|
* @returns Always returns `true`.
|
|
52
52
|
*/
|
|
53
53
|
delete(key: string): Promise<boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* Delete all expired entries. Entries are otherwise only removed when read,
|
|
56
|
+
* so call this periodically to stop never-read expired rows from accumulating.
|
|
57
|
+
*
|
|
58
|
+
* @returns A promise that resolves once expired rows have been removed.
|
|
59
|
+
*/
|
|
60
|
+
prune(): Promise<void>;
|
|
54
61
|
/**
|
|
55
62
|
* Remove all entries from the cache table.
|
|
56
63
|
*
|
package/dist/stores/database.js
CHANGED
|
@@ -32,7 +32,13 @@ export class DatabaseCacheStore {
|
|
|
32
32
|
await this.db.exec(`CREATE TABLE IF NOT EXISTS "${this.table}" (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)`);
|
|
33
33
|
this._ready = true;
|
|
34
34
|
}
|
|
35
|
-
catch {
|
|
35
|
+
catch (e) {
|
|
36
|
+
// Don't silently swallow: an unset _ready means every later get/set blows
|
|
37
|
+
// up with a confusing SQL error. Surface the real cause and rethrow so the
|
|
38
|
+
// misconfiguration is visible at the point of failure.
|
|
39
|
+
console.error(`[@tekir/cache] Failed to create cache table "${this.table}": ${e.message}`);
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
36
42
|
}
|
|
37
43
|
/**
|
|
38
44
|
* Retrieve a cached value by key. Expired entries are deleted and `null` is returned.
|
|
@@ -76,7 +82,16 @@ export class DatabaseCacheStore {
|
|
|
76
82
|
* @returns `true` if the key exists and has not expired.
|
|
77
83
|
*/
|
|
78
84
|
async has(key) {
|
|
79
|
-
|
|
85
|
+
await this._ensureTable();
|
|
86
|
+
const row = await this.db.queryOne(`SELECT expires_at FROM "${this.table}" WHERE key = ?`, [key]);
|
|
87
|
+
if (!row)
|
|
88
|
+
return false;
|
|
89
|
+
if (row.expires_at && Date.now() > row.expires_at) {
|
|
90
|
+
await this.db.run(`DELETE FROM "${this.table}" WHERE key = ?`, [key]);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
// Present even if the stored value is `null` (negative caching).
|
|
94
|
+
return true;
|
|
80
95
|
}
|
|
81
96
|
/**
|
|
82
97
|
* Delete a key from the database.
|
|
@@ -89,6 +104,16 @@ export class DatabaseCacheStore {
|
|
|
89
104
|
await this.db.run(`DELETE FROM "${this.table}" WHERE key = ?`, [key]);
|
|
90
105
|
return true;
|
|
91
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Delete all expired entries. Entries are otherwise only removed when read,
|
|
109
|
+
* so call this periodically to stop never-read expired rows from accumulating.
|
|
110
|
+
*
|
|
111
|
+
* @returns A promise that resolves once expired rows have been removed.
|
|
112
|
+
*/
|
|
113
|
+
async prune() {
|
|
114
|
+
await this._ensureTable();
|
|
115
|
+
await this.db.run(`DELETE FROM "${this.table}" WHERE expires_at IS NOT NULL AND expires_at < ?`, [Date.now()]);
|
|
116
|
+
}
|
|
92
117
|
/**
|
|
93
118
|
* Remove all entries from the cache table.
|
|
94
119
|
*
|
package/dist/stores/memory.d.ts
CHANGED
|
@@ -12,6 +12,19 @@ import type { CacheStore } from '../types';
|
|
|
12
12
|
*/
|
|
13
13
|
export declare class MemoryCacheStore implements CacheStore {
|
|
14
14
|
private data;
|
|
15
|
+
private maxEntries;
|
|
16
|
+
private writes;
|
|
17
|
+
/**
|
|
18
|
+
* @param options.maxEntries - Hard cap on stored entries. When exceeded, the
|
|
19
|
+
* oldest insertion-order entry is evicted (after pruning expired ones).
|
|
20
|
+
* Defaults to 10000 to bound memory growth from never-read keys. Set to 0
|
|
21
|
+
* to disable the cap.
|
|
22
|
+
*/
|
|
23
|
+
constructor(options?: {
|
|
24
|
+
maxEntries?: number;
|
|
25
|
+
});
|
|
26
|
+
/** Remove every entry whose TTL has elapsed. */
|
|
27
|
+
prune(): void;
|
|
15
28
|
/**
|
|
16
29
|
* Retrieve a cached value by key. Returns `null` if the key does not exist
|
|
17
30
|
* or has expired.
|
package/dist/stores/memory.js
CHANGED
|
@@ -11,6 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export class MemoryCacheStore {
|
|
13
13
|
data = new Map();
|
|
14
|
+
maxEntries;
|
|
15
|
+
writes = 0;
|
|
16
|
+
/**
|
|
17
|
+
* @param options.maxEntries - Hard cap on stored entries. When exceeded, the
|
|
18
|
+
* oldest insertion-order entry is evicted (after pruning expired ones).
|
|
19
|
+
* Defaults to 10000 to bound memory growth from never-read keys. Set to 0
|
|
20
|
+
* to disable the cap.
|
|
21
|
+
*/
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.maxEntries = options.maxEntries ?? 10000;
|
|
24
|
+
}
|
|
25
|
+
/** Remove every entry whose TTL has elapsed. */
|
|
26
|
+
prune() {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [k, entry] of this.data) {
|
|
29
|
+
if (entry.expiresAt && now > entry.expiresAt)
|
|
30
|
+
this.data.delete(k);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
14
33
|
/**
|
|
15
34
|
* Retrieve a cached value by key. Returns `null` if the key does not exist
|
|
16
35
|
* or has expired.
|
|
@@ -40,6 +59,18 @@ export class MemoryCacheStore {
|
|
|
40
59
|
value,
|
|
41
60
|
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
|
|
42
61
|
});
|
|
62
|
+
// Periodically sweep expired entries so keys that are never read again don't
|
|
63
|
+
// accumulate unbounded, then enforce the size cap.
|
|
64
|
+
if (this.maxEntries > 0) {
|
|
65
|
+
if (++this.writes % 256 === 0)
|
|
66
|
+
this.prune();
|
|
67
|
+
while (this.data.size > this.maxEntries) {
|
|
68
|
+
const oldest = this.data.keys().next().value;
|
|
69
|
+
if (oldest === undefined)
|
|
70
|
+
break;
|
|
71
|
+
this.data.delete(oldest);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
43
74
|
}
|
|
44
75
|
/**
|
|
45
76
|
* Check whether a key exists and is not expired.
|
|
@@ -48,7 +79,16 @@ export class MemoryCacheStore {
|
|
|
48
79
|
* @returns `true` if the key exists and has not expired.
|
|
49
80
|
*/
|
|
50
81
|
async has(key) {
|
|
51
|
-
|
|
82
|
+
// Check the map directly so an explicitly stored `null` value still counts
|
|
83
|
+
// as present (get() alone can't distinguish stored-null from absent).
|
|
84
|
+
const entry = this.data.get(key);
|
|
85
|
+
if (!entry)
|
|
86
|
+
return false;
|
|
87
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
88
|
+
this.data.delete(key);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
52
92
|
}
|
|
53
93
|
/**
|
|
54
94
|
* Delete a key from the store.
|
package/dist/stores/redis.d.ts
CHANGED
|
@@ -79,8 +79,13 @@ export declare class RedisCacheStore implements CacheStore {
|
|
|
79
79
|
*/
|
|
80
80
|
delete(key: string): Promise<boolean>;
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* Remove only this store's keys (those under its prefix) using a non-blocking
|
|
83
|
+
* SCAN + DEL. This no longer flushes the entire Redis database, so data owned
|
|
84
|
+
* by other stores sharing the same database (sessions, queues, ...) is left
|
|
85
|
+
* intact.
|
|
86
|
+
*
|
|
87
|
+
* If the prefix is empty (which would match every key) this throws rather than
|
|
88
|
+
* risk wiping unrelated data; configure a non-empty prefix to use flush.
|
|
84
89
|
*
|
|
85
90
|
* @example
|
|
86
91
|
* ```ts
|
package/dist/stores/redis.js
CHANGED
|
@@ -60,12 +60,22 @@ export class RedisCacheStore {
|
|
|
60
60
|
*/
|
|
61
61
|
async set(key, value, ttlSeconds) {
|
|
62
62
|
const val = JSON.stringify(value);
|
|
63
|
+
const fullKey = this.prefix + key;
|
|
63
64
|
if (ttlSeconds) {
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
const sendable = this.redis;
|
|
66
|
+
if (typeof sendable.send === "function") {
|
|
67
|
+
// Atomic SET ... EX so a crash between SET and EXPIRE can never leave a
|
|
68
|
+
// permanent (TTL-less) key behind.
|
|
69
|
+
await sendable.send("SET", [fullKey, val, "EX", String(Math.floor(ttlSeconds))]);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Fallback for clients without a raw `send`: best-effort two-step.
|
|
73
|
+
await this.redis.set(fullKey, val);
|
|
74
|
+
await this.redis.expire(fullKey, ttlSeconds);
|
|
75
|
+
}
|
|
66
76
|
}
|
|
67
77
|
else {
|
|
68
|
-
await this.redis.set(
|
|
78
|
+
await this.redis.set(fullKey, val);
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
/**
|
|
@@ -100,8 +110,13 @@ export class RedisCacheStore {
|
|
|
100
110
|
return true;
|
|
101
111
|
}
|
|
102
112
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
113
|
+
* Remove only this store's keys (those under its prefix) using a non-blocking
|
|
114
|
+
* SCAN + DEL. This no longer flushes the entire Redis database, so data owned
|
|
115
|
+
* by other stores sharing the same database (sessions, queues, ...) is left
|
|
116
|
+
* intact.
|
|
117
|
+
*
|
|
118
|
+
* If the prefix is empty (which would match every key) this throws rather than
|
|
119
|
+
* risk wiping unrelated data; configure a non-empty prefix to use flush.
|
|
105
120
|
*
|
|
106
121
|
* @example
|
|
107
122
|
* ```ts
|
|
@@ -109,8 +124,20 @@ export class RedisCacheStore {
|
|
|
109
124
|
* ```
|
|
110
125
|
*/
|
|
111
126
|
async flush() {
|
|
127
|
+
if (!this.prefix) {
|
|
128
|
+
throw new Error('[@tekir/cache] RedisCacheStore.flush() refused: an empty prefix would delete every key in the database. Configure a non-empty prefix.');
|
|
129
|
+
}
|
|
112
130
|
// Structural cast: callers may pass clients with varying `send` arg
|
|
113
131
|
// types. We only need it to accept a string command and an array.
|
|
114
|
-
|
|
132
|
+
const client = this.redis;
|
|
133
|
+
const pattern = `${this.prefix}*`;
|
|
134
|
+
let cursor = '0';
|
|
135
|
+
do {
|
|
136
|
+
const reply = (await client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100']));
|
|
137
|
+
const [next, batch] = reply;
|
|
138
|
+
cursor = next;
|
|
139
|
+
if (batch && batch.length)
|
|
140
|
+
await client.send('DEL', batch);
|
|
141
|
+
} while (cursor !== '0');
|
|
115
142
|
}
|
|
116
143
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekir/cache",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "In-memory, Redis, and database caching abstraction",
|
|
5
5
|
"author": "dev@tekir.io",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@tekir/core": "^0.1.
|
|
42
|
+
"@tekir/core": "^0.1.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@tekir/redis": "^0.1.0"
|
package/src/cache.ts
CHANGED
|
@@ -18,6 +18,9 @@ export class Cache {
|
|
|
18
18
|
private stores: Record<string, CacheStore>
|
|
19
19
|
private defaultStore: string
|
|
20
20
|
private defaultTtl: number
|
|
21
|
+
// In-flight factory promises keyed by `<store>:<key>` for single-flight
|
|
22
|
+
// stampede protection in getOrSet.
|
|
23
|
+
private inFlight = new Map<string, Promise<unknown>>()
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Create a new Cache instance.
|
|
@@ -96,6 +99,10 @@ export class Cache {
|
|
|
96
99
|
* @param factory - An async function that produces the value when not cached.
|
|
97
100
|
* @returns The cached or freshly-computed value.
|
|
98
101
|
*
|
|
102
|
+
* Concurrent misses for the same key share a single `factory()` execution
|
|
103
|
+
* (single-flight) so a popular key expiring does not trigger a stampede
|
|
104
|
+
* (thundering herd) against the backend.
|
|
105
|
+
*
|
|
99
106
|
* @example
|
|
100
107
|
* ```ts
|
|
101
108
|
* const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
|
|
@@ -104,9 +111,19 @@ export class Cache {
|
|
|
104
111
|
async getOrSet<T>(key: string, ttl: number, factory: () => Promise<T>): Promise<T> {
|
|
105
112
|
const cached = await this.get<T>(key)
|
|
106
113
|
if (cached !== null) return cached
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
|
|
115
|
+
const flightKey = `${this.defaultStore}:${key}`
|
|
116
|
+
const existing = this.inFlight.get(flightKey)
|
|
117
|
+
if (existing) return existing as Promise<T>
|
|
118
|
+
|
|
119
|
+
const promise = (async () => {
|
|
120
|
+
const value = await factory()
|
|
121
|
+
await this.set(key, value, ttl)
|
|
122
|
+
return value
|
|
123
|
+
})().finally(() => this.inFlight.delete(flightKey))
|
|
124
|
+
|
|
125
|
+
this.inFlight.set(flightKey, promise)
|
|
126
|
+
return promise
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
/**
|
|
@@ -122,7 +139,10 @@ export class Cache {
|
|
|
122
139
|
*/
|
|
123
140
|
async pull<T = unknown>(key: string): Promise<T | null> {
|
|
124
141
|
const value = await this.get<T>(key)
|
|
125
|
-
|
|
142
|
+
// Use has() rather than a null-check so that an explicitly cached `null`
|
|
143
|
+
// value is still evicted (get() alone cannot distinguish "absent" from
|
|
144
|
+
// "stored null").
|
|
145
|
+
if (value !== null || (await this.has(key))) await this.delete(key)
|
|
126
146
|
return value
|
|
127
147
|
}
|
|
128
148
|
}
|
package/src/http-cache.ts
CHANGED
|
@@ -60,8 +60,30 @@ export type HttpCacheOptions = {
|
|
|
60
60
|
* responses. Default: true.
|
|
61
61
|
*/
|
|
62
62
|
setCacheControl?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* How to handle requests that carry credentials (`Authorization` or `Cookie`).
|
|
65
|
+
*
|
|
66
|
+
* Caching a per-user response under a shared key leaks one user's response to
|
|
67
|
+
* another. To prevent that, the default behaviour is `'bypass'`: authenticated
|
|
68
|
+
* requests skip the cache entirely unless you opt in.
|
|
69
|
+
*
|
|
70
|
+
* - `'bypass'` (default): never read or write the cache for credentialed
|
|
71
|
+
* requests.
|
|
72
|
+
* - `'vary'`: include the credential headers in the cache key so each
|
|
73
|
+
* identity gets its own entry. Use this only when you understand the cache
|
|
74
|
+
* size implications.
|
|
75
|
+
* - `'allow'`: cache credentialed requests under the same key as anonymous
|
|
76
|
+
* ones. DANGEROUS: only safe when the response is identical for every user.
|
|
77
|
+
*
|
|
78
|
+
* Note: providing a custom `key` builder that already incorporates identity
|
|
79
|
+
* overrides this and is always honoured.
|
|
80
|
+
*/
|
|
81
|
+
authenticated?: "bypass" | "vary" | "allow"
|
|
63
82
|
}
|
|
64
83
|
|
|
84
|
+
/** Request headers that indicate a credentialed/per-user request. */
|
|
85
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie"]
|
|
86
|
+
|
|
65
87
|
export type HttpCacheCtx = {
|
|
66
88
|
request: { url: string; method: string; headers: Headers; raw?: Request }
|
|
67
89
|
params?: Record<string, string>
|
|
@@ -162,12 +184,26 @@ const entryToResponse = (e: CachedEntry, opts: HttpCacheOptions): Response => {
|
|
|
162
184
|
return new Response(e.body, { status: e.status, headers })
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
/** True if the request carries an Authorization or Cookie header. */
|
|
188
|
+
const hasCredentials = (req: HttpCacheCtx["request"]): boolean => {
|
|
189
|
+
for (const h of CREDENTIAL_HEADERS) {
|
|
190
|
+
if (req.headers?.get?.(h)) return true
|
|
191
|
+
}
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
165
195
|
export function cache(opts: HttpCacheOptions = {}) {
|
|
166
196
|
const ttl = opts.ttl ?? 60
|
|
167
197
|
const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()))
|
|
168
198
|
const vary = opts.vary ?? []
|
|
169
199
|
const prefix = opts.prefix ?? "http:"
|
|
170
|
-
const
|
|
200
|
+
const hasCustomKey = typeof opts.key === "function"
|
|
201
|
+
const authMode = opts.authenticated ?? "bypass"
|
|
202
|
+
// When varying by credentials, fold the credential headers into the key so
|
|
203
|
+
// each identity gets a private entry.
|
|
204
|
+
const effectiveVary =
|
|
205
|
+
authMode === "vary" ? [...vary, ...CREDENTIAL_HEADERS] : vary
|
|
206
|
+
const buildKey = opts.key ?? ((ctx: HttpCacheCtx) => defaultKey(ctx, effectiveVary))
|
|
171
207
|
const directStore = resolveStore(opts.store)
|
|
172
208
|
|
|
173
209
|
return async function cacheMiddleware(ctx: any, next: () => Promise<void>) {
|
|
@@ -188,6 +224,15 @@ export function cache(opts: HttpCacheOptions = {}) {
|
|
|
188
224
|
return
|
|
189
225
|
}
|
|
190
226
|
|
|
227
|
+
// Secure-by-default: a credentialed (Authorization/Cookie) request usually
|
|
228
|
+
// produces a per-user response. Caching it under a shared key would leak it
|
|
229
|
+
// to other users. Unless the caller opted into 'vary'/'allow' or supplied a
|
|
230
|
+
// custom identity-aware key, bypass the cache for such requests.
|
|
231
|
+
if (authMode === "bypass" && !hasCustomKey && hasCredentials(req)) {
|
|
232
|
+
await next()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
191
236
|
// Resolve store: option > module-level default (set by CacheProvider).
|
|
192
237
|
// No store anywhere → middleware acts as a transparent no-op so the
|
|
193
238
|
// route still works without a registered cache backend.
|
package/src/stores/database.ts
CHANGED
|
@@ -35,7 +35,13 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
35
35
|
try {
|
|
36
36
|
await this.db.exec(`CREATE TABLE IF NOT EXISTS "${this.table}" (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)`)
|
|
37
37
|
this._ready = true
|
|
38
|
-
} catch {
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Don't silently swallow: an unset _ready means every later get/set blows
|
|
40
|
+
// up with a confusing SQL error. Surface the real cause and rethrow so the
|
|
41
|
+
// misconfiguration is visible at the point of failure.
|
|
42
|
+
console.error(`[@tekir/cache] Failed to create cache table "${this.table}": ${(e as Error).message}`)
|
|
43
|
+
throw e
|
|
44
|
+
}
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/**
|
|
@@ -79,7 +85,15 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
79
85
|
* @returns `true` if the key exists and has not expired.
|
|
80
86
|
*/
|
|
81
87
|
async has(key: string): Promise<boolean> {
|
|
82
|
-
|
|
88
|
+
await this._ensureTable()
|
|
89
|
+
const row = await this.db.queryOne(`SELECT expires_at FROM "${this.table}" WHERE key = ?`, [key]) as { expires_at: number | null } | null
|
|
90
|
+
if (!row) return false
|
|
91
|
+
if (row.expires_at && Date.now() > row.expires_at) {
|
|
92
|
+
await this.db.run(`DELETE FROM "${this.table}" WHERE key = ?`, [key])
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
// Present even if the stored value is `null` (negative caching).
|
|
96
|
+
return true
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
/**
|
|
@@ -94,6 +108,17 @@ export class DatabaseCacheStore implements CacheStore {
|
|
|
94
108
|
return true
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Delete all expired entries. Entries are otherwise only removed when read,
|
|
113
|
+
* so call this periodically to stop never-read expired rows from accumulating.
|
|
114
|
+
*
|
|
115
|
+
* @returns A promise that resolves once expired rows have been removed.
|
|
116
|
+
*/
|
|
117
|
+
async prune(): Promise<void> {
|
|
118
|
+
await this._ensureTable()
|
|
119
|
+
await this.db.run(`DELETE FROM "${this.table}" WHERE expires_at IS NOT NULL AND expires_at < ?`, [Date.now()])
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
/**
|
|
98
123
|
* Remove all entries from the cache table.
|
|
99
124
|
*
|
package/src/stores/memory.ts
CHANGED
|
@@ -13,6 +13,26 @@ import type { CacheStore } from '../types'
|
|
|
13
13
|
*/
|
|
14
14
|
export class MemoryCacheStore implements CacheStore {
|
|
15
15
|
private data = new Map<string, { value: unknown; expiresAt: number | null }>()
|
|
16
|
+
private maxEntries: number
|
|
17
|
+
private writes = 0
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param options.maxEntries - Hard cap on stored entries. When exceeded, the
|
|
21
|
+
* oldest insertion-order entry is evicted (after pruning expired ones).
|
|
22
|
+
* Defaults to 10000 to bound memory growth from never-read keys. Set to 0
|
|
23
|
+
* to disable the cap.
|
|
24
|
+
*/
|
|
25
|
+
constructor(options: { maxEntries?: number } = {}) {
|
|
26
|
+
this.maxEntries = options.maxEntries ?? 10000
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remove every entry whose TTL has elapsed. */
|
|
30
|
+
prune(): void {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
for (const [k, entry] of this.data) {
|
|
33
|
+
if (entry.expiresAt && now > entry.expiresAt) this.data.delete(k)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
16
36
|
|
|
17
37
|
/**
|
|
18
38
|
* Retrieve a cached value by key. Returns `null` if the key does not exist
|
|
@@ -43,6 +63,16 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
43
63
|
value,
|
|
44
64
|
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
|
|
45
65
|
})
|
|
66
|
+
// Periodically sweep expired entries so keys that are never read again don't
|
|
67
|
+
// accumulate unbounded, then enforce the size cap.
|
|
68
|
+
if (this.maxEntries > 0) {
|
|
69
|
+
if (++this.writes % 256 === 0) this.prune()
|
|
70
|
+
while (this.data.size > this.maxEntries) {
|
|
71
|
+
const oldest = this.data.keys().next().value
|
|
72
|
+
if (oldest === undefined) break
|
|
73
|
+
this.data.delete(oldest)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
46
76
|
}
|
|
47
77
|
|
|
48
78
|
/**
|
|
@@ -52,7 +82,15 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
52
82
|
* @returns `true` if the key exists and has not expired.
|
|
53
83
|
*/
|
|
54
84
|
async has(key: string): Promise<boolean> {
|
|
55
|
-
|
|
85
|
+
// Check the map directly so an explicitly stored `null` value still counts
|
|
86
|
+
// as present (get() alone can't distinguish stored-null from absent).
|
|
87
|
+
const entry = this.data.get(key)
|
|
88
|
+
if (!entry) return false
|
|
89
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
90
|
+
this.data.delete(key)
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
return true
|
|
56
94
|
}
|
|
57
95
|
|
|
58
96
|
/**
|
package/src/stores/redis.ts
CHANGED
|
@@ -77,11 +77,20 @@ export class RedisCacheStore implements CacheStore {
|
|
|
77
77
|
*/
|
|
78
78
|
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
|
79
79
|
const val = JSON.stringify(value)
|
|
80
|
+
const fullKey = this.prefix + key
|
|
80
81
|
if (ttlSeconds) {
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const sendable = this.redis as unknown as Partial<RawSendable>
|
|
83
|
+
if (typeof sendable.send === "function") {
|
|
84
|
+
// Atomic SET ... EX so a crash between SET and EXPIRE can never leave a
|
|
85
|
+
// permanent (TTL-less) key behind.
|
|
86
|
+
await sendable.send("SET", [fullKey, val, "EX", String(Math.floor(ttlSeconds))])
|
|
87
|
+
} else {
|
|
88
|
+
// Fallback for clients without a raw `send`: best-effort two-step.
|
|
89
|
+
await this.redis.set(fullKey, val)
|
|
90
|
+
await this.redis.expire(fullKey, ttlSeconds)
|
|
91
|
+
}
|
|
83
92
|
} else {
|
|
84
|
-
await this.redis.set(
|
|
93
|
+
await this.redis.set(fullKey, val)
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -119,8 +128,13 @@ export class RedisCacheStore implements CacheStore {
|
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
131
|
+
* Remove only this store's keys (those under its prefix) using a non-blocking
|
|
132
|
+
* SCAN + DEL. This no longer flushes the entire Redis database, so data owned
|
|
133
|
+
* by other stores sharing the same database (sessions, queues, ...) is left
|
|
134
|
+
* intact.
|
|
135
|
+
*
|
|
136
|
+
* If the prefix is empty (which would match every key) this throws rather than
|
|
137
|
+
* risk wiping unrelated data; configure a non-empty prefix to use flush.
|
|
124
138
|
*
|
|
125
139
|
* @example
|
|
126
140
|
* ```ts
|
|
@@ -128,8 +142,21 @@ export class RedisCacheStore implements CacheStore {
|
|
|
128
142
|
* ```
|
|
129
143
|
*/
|
|
130
144
|
async flush(): Promise<void> {
|
|
145
|
+
if (!this.prefix) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'[@tekir/cache] RedisCacheStore.flush() refused: an empty prefix would delete every key in the database. Configure a non-empty prefix.'
|
|
148
|
+
)
|
|
149
|
+
}
|
|
131
150
|
// Structural cast: callers may pass clients with varying `send` arg
|
|
132
151
|
// types. We only need it to accept a string command and an array.
|
|
133
|
-
|
|
152
|
+
const client = this.redis as unknown as RawSendable
|
|
153
|
+
const pattern = `${this.prefix}*`
|
|
154
|
+
let cursor = '0'
|
|
155
|
+
do {
|
|
156
|
+
const reply = (await client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100'])) as [string, string[]]
|
|
157
|
+
const [next, batch] = reply
|
|
158
|
+
cursor = next
|
|
159
|
+
if (batch && batch.length) await client.send('DEL', batch)
|
|
160
|
+
} while (cursor !== '0')
|
|
134
161
|
}
|
|
135
162
|
}
|