@tekir/cache 0.1.9 → 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/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: { url: string; method: string; headers: Headers; raw?: 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
- body: string
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
- const parts = [ctx.request.method, ctx.request.url]
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.headers.get(h)
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 body = await resp.clone().text()
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(body)}"`
175
- return { status: resp.status, headers, body, etag, storedAt: Date.now() }
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
- return new Response(e.body, { status: e.status, headers })
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.headers?.get?.(h)) return true
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.headers?.get?.("cache-control") ?? ""
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.headers?.get?.("if-none-match") ?? ""
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
- const result = ctx.$result
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) return // don't cache errors / empty
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
- ctx.$result = new Response(entry.body, { status: result.status, headers: out })
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
  }
@@ -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
- await this.db.exec(`CREATE TABLE IF NOT EXISTS "${this.table}" (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)`)
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 "${this.table}" WHERE key = ?`, [key]) as CacheDbRow | null
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 "${this.table}" WHERE key = ?`, [key])
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
- await this.db.run(
76
- `INSERT OR REPLACE INTO "${this.table}" (key, value, expires_at) VALUES (?, ?, ?)`,
77
- [key, val, expiresAt]
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 "${this.table}" WHERE key = ?`, [key]) as { expires_at: number | null } | null
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 "${this.table}" WHERE key = ?`, [key])
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 "${this.table}" WHERE key = ?`, [key])
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 "${this.table}" WHERE expires_at IS NOT NULL AND expires_at < ?`, [Date.now()])
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 "${this.table}"`)
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
- }
@@ -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
- }