@tekir/cache 0.1.1 → 0.1.3
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 +89 -0
- package/dist/http-cache.js +175 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/provider.js +8 -2
- package/package.json +2 -2
- package/src/http-cache.ts +240 -0
- package/src/index.ts +2 -0
- package/src/provider.ts +9 -2
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
import type { CacheStore } from "./types";
|
|
27
|
+
export type HttpCacheOptions = {
|
|
28
|
+
/**
|
|
29
|
+
* Cache backend. Either a `Cache` manager (uses default store) or a raw
|
|
30
|
+
* `CacheStore`. If omitted you must register `@tekir/cache` in the app
|
|
31
|
+
* and the middleware will resolve `service('cache')` at request time.
|
|
32
|
+
*/
|
|
33
|
+
store?: Cache | CacheStore;
|
|
34
|
+
/** Time-to-live in seconds. Default: 60. */
|
|
35
|
+
ttl?: number;
|
|
36
|
+
/**
|
|
37
|
+
* HTTP methods that are cacheable. Default: ['GET', 'HEAD'].
|
|
38
|
+
* Mutating methods (POST/PUT/PATCH/DELETE) skip the cache.
|
|
39
|
+
*/
|
|
40
|
+
methods?: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Custom key builder. Defaults to `${method} ${url}`. Pass a function
|
|
43
|
+
* to include user identity, query params, etc.
|
|
44
|
+
*/
|
|
45
|
+
key?: (ctx: HttpCacheCtx) => string;
|
|
46
|
+
/**
|
|
47
|
+
* Optional list of request headers to include in the cache key,
|
|
48
|
+
* mirroring the HTTP `Vary` header. Default: [].
|
|
49
|
+
*/
|
|
50
|
+
vary?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Skip predicate. Return `true` to bypass caching for this request.
|
|
53
|
+
*/
|
|
54
|
+
skip?: (ctx: HttpCacheCtx) => boolean | Promise<boolean>;
|
|
55
|
+
/** Override the namespace prefix used in cache keys. Default: 'http:'. */
|
|
56
|
+
prefix?: string;
|
|
57
|
+
/**
|
|
58
|
+
* If true, sets `Cache-Control: public, max-age=<ttl>` on cached
|
|
59
|
+
* responses. Default: true.
|
|
60
|
+
*/
|
|
61
|
+
setCacheControl?: boolean;
|
|
62
|
+
};
|
|
63
|
+
export type HttpCacheCtx = {
|
|
64
|
+
request: {
|
|
65
|
+
url: string;
|
|
66
|
+
method: string;
|
|
67
|
+
headers: Headers;
|
|
68
|
+
raw?: Request;
|
|
69
|
+
};
|
|
70
|
+
params?: Record<string, string>;
|
|
71
|
+
query?: Record<string, string | string[]>;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Register the default backing store for the `cache()` middleware. Called
|
|
75
|
+
* by `CacheProvider` after it builds the Cache manager from config.
|
|
76
|
+
*
|
|
77
|
+
* Users can also call this directly if they don't use providers:
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* import { setDefaultCacheStore, Cache, MemoryCacheStore } from '@tekir/cache'
|
|
81
|
+
* setDefaultCacheStore(new Cache({ stores: { memory: new MemoryCacheStore() } }))
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export declare function setDefaultCacheStore(s: Cache | CacheStore | null): void;
|
|
85
|
+
/**
|
|
86
|
+
* Returns the currently registered default store, or null if none.
|
|
87
|
+
*/
|
|
88
|
+
export declare function getDefaultCacheStore(): Cache | CacheStore | null;
|
|
89
|
+
export declare function cache(opts?: HttpCacheOptions): (ctx: any, next: () => Promise<void>) => Promise<void>;
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
const SAFE_METHODS = ["GET", "HEAD"];
|
|
27
|
+
const isStore = (s) => !!s &&
|
|
28
|
+
typeof s.get === "function" &&
|
|
29
|
+
typeof s.set === "function";
|
|
30
|
+
/**
|
|
31
|
+
* Module-level default store. CacheProvider sets this at register time so
|
|
32
|
+
* `cache({ ttl: 60 })` works without an explicit `store` option once the
|
|
33
|
+
* provider is wired into the app. Stays null if the provider isn't used,
|
|
34
|
+
* in which case the middleware no-ops (passes through).
|
|
35
|
+
*/
|
|
36
|
+
let _defaultStore = null;
|
|
37
|
+
/**
|
|
38
|
+
* Register the default backing store for the `cache()` middleware. Called
|
|
39
|
+
* by `CacheProvider` after it builds the Cache manager from config.
|
|
40
|
+
*
|
|
41
|
+
* Users can also call this directly if they don't use providers:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { setDefaultCacheStore, Cache, MemoryCacheStore } from '@tekir/cache'
|
|
45
|
+
* setDefaultCacheStore(new Cache({ stores: { memory: new MemoryCacheStore() } }))
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function setDefaultCacheStore(s) {
|
|
49
|
+
_defaultStore = s;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Returns the currently registered default store, or null if none.
|
|
53
|
+
*/
|
|
54
|
+
export function getDefaultCacheStore() {
|
|
55
|
+
return _defaultStore;
|
|
56
|
+
}
|
|
57
|
+
const hash = (s) => {
|
|
58
|
+
// FNV-1a 32-bit. Good enough for ETags; not crypto.
|
|
59
|
+
let h = 0x811c9dc5;
|
|
60
|
+
for (let i = 0; i < s.length; i++) {
|
|
61
|
+
h ^= s.charCodeAt(i);
|
|
62
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
63
|
+
}
|
|
64
|
+
return h.toString(16).padStart(8, "0");
|
|
65
|
+
};
|
|
66
|
+
const defaultKey = (ctx, vary) => {
|
|
67
|
+
const parts = [ctx.request.method, ctx.request.url];
|
|
68
|
+
for (const h of vary) {
|
|
69
|
+
const v = ctx.request.headers.get(h);
|
|
70
|
+
if (v)
|
|
71
|
+
parts.push(`${h}=${v}`);
|
|
72
|
+
}
|
|
73
|
+
return parts.join("|");
|
|
74
|
+
};
|
|
75
|
+
const resolveStore = (s) => {
|
|
76
|
+
if (!s)
|
|
77
|
+
return null;
|
|
78
|
+
if (s instanceof Cache)
|
|
79
|
+
return s.store();
|
|
80
|
+
if (isStore(s))
|
|
81
|
+
return s;
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
const responseToEntry = async (resp) => {
|
|
85
|
+
const body = await resp.clone().text();
|
|
86
|
+
const headers = {};
|
|
87
|
+
resp.headers.forEach((v, k) => {
|
|
88
|
+
// Skip hop-by-hop and connection-specific headers
|
|
89
|
+
if (k === "connection" || k === "keep-alive" || k === "transfer-encoding")
|
|
90
|
+
return;
|
|
91
|
+
headers[k] = v;
|
|
92
|
+
});
|
|
93
|
+
const etag = `W/"${hash(body)}"`;
|
|
94
|
+
return { status: resp.status, headers, body, etag, storedAt: Date.now() };
|
|
95
|
+
};
|
|
96
|
+
const entryToResponse = (e, opts) => {
|
|
97
|
+
const headers = { ...e.headers, etag: e.etag };
|
|
98
|
+
if (opts.setCacheControl !== false && !headers["cache-control"]) {
|
|
99
|
+
headers["cache-control"] = `public, max-age=${opts.ttl ?? 60}`;
|
|
100
|
+
}
|
|
101
|
+
headers["x-tekir-cache"] = "HIT";
|
|
102
|
+
return new Response(e.body, { status: e.status, headers });
|
|
103
|
+
};
|
|
104
|
+
export function cache(opts = {}) {
|
|
105
|
+
const ttl = opts.ttl ?? 60;
|
|
106
|
+
const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()));
|
|
107
|
+
const vary = opts.vary ?? [];
|
|
108
|
+
const prefix = opts.prefix ?? "http:";
|
|
109
|
+
const buildKey = opts.key ?? ((ctx) => defaultKey(ctx, vary));
|
|
110
|
+
const directStore = resolveStore(opts.store);
|
|
111
|
+
return async function cacheMiddleware(ctx, next) {
|
|
112
|
+
const req = ctx.request;
|
|
113
|
+
if (!req || !methods.has(String(req.method ?? "GET").toUpperCase())) {
|
|
114
|
+
await next();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (opts.skip && (await opts.skip(ctx))) {
|
|
118
|
+
await next();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const cacheControl = req.headers?.get?.("cache-control") ?? "";
|
|
122
|
+
if (cacheControl.includes("no-store")) {
|
|
123
|
+
await next();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Resolve store: option > module-level default (set by CacheProvider).
|
|
127
|
+
// No store anywhere → middleware acts as a transparent no-op so the
|
|
128
|
+
// route still works without a registered cache backend.
|
|
129
|
+
let store = directStore;
|
|
130
|
+
if (!store)
|
|
131
|
+
store = resolveStore(_defaultStore);
|
|
132
|
+
if (!store) {
|
|
133
|
+
await next();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const key = prefix + buildKey(ctx);
|
|
137
|
+
const cached = await store.get(key);
|
|
138
|
+
// Conditional request: client sent If-None-Match
|
|
139
|
+
const ifNoneMatch = req.headers?.get?.("if-none-match") ?? "";
|
|
140
|
+
if (cached) {
|
|
141
|
+
if (ifNoneMatch && ifNoneMatch === cached.etag) {
|
|
142
|
+
ctx.$result = new Response(null, {
|
|
143
|
+
status: 304,
|
|
144
|
+
headers: { etag: cached.etag, "x-tekir-cache": "REVALIDATED" },
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
ctx.$result = entryToResponse(cached, opts);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Miss: run the handler chain, capture, store.
|
|
152
|
+
await next();
|
|
153
|
+
const result = ctx.$result;
|
|
154
|
+
if (!(result instanceof Response))
|
|
155
|
+
return;
|
|
156
|
+
if (result.status >= 500 || result.status === 204)
|
|
157
|
+
return; // don't cache errors / empty
|
|
158
|
+
if (cacheControl.includes("no-cache"))
|
|
159
|
+
return;
|
|
160
|
+
const respCacheControl = result.headers.get("cache-control") ?? "";
|
|
161
|
+
if (respCacheControl.includes("private") || respCacheControl.includes("no-store"))
|
|
162
|
+
return;
|
|
163
|
+
const entry = await responseToEntry(result);
|
|
164
|
+
await store.set(key, entry, ttl);
|
|
165
|
+
// Re-emit with x-tekir-cache: MISS so the client can see it
|
|
166
|
+
const out = {};
|
|
167
|
+
result.headers.forEach((v, k) => (out[k] = v));
|
|
168
|
+
out["etag"] = entry.etag;
|
|
169
|
+
if (opts.setCacheControl !== false && !out["cache-control"]) {
|
|
170
|
+
out["cache-control"] = `public, max-age=${ttl}`;
|
|
171
|
+
}
|
|
172
|
+
out["x-tekir-cache"] = "MISS";
|
|
173
|
+
ctx.$result = new Response(entry.body, { status: result.status, headers: out });
|
|
174
|
+
};
|
|
175
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,5 @@ export { RedisCacheStore } from './stores/redis';
|
|
|
4
4
|
export { DatabaseCacheStore } from './stores/database';
|
|
5
5
|
export { Cache, createCache } from './cache';
|
|
6
6
|
export { CacheProvider } from './provider';
|
|
7
|
+
export { cache, setDefaultCacheStore, getDefaultCacheStore } from './http-cache';
|
|
8
|
+
export type { HttpCacheOptions, HttpCacheCtx } from './http-cache';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { RedisCacheStore } from './stores/redis';
|
|
|
3
3
|
export { DatabaseCacheStore } from './stores/database';
|
|
4
4
|
export { Cache, createCache } from './cache';
|
|
5
5
|
export { CacheProvider } from './provider';
|
|
6
|
+
export { cache, setDefaultCacheStore, getDefaultCacheStore } from './http-cache';
|
package/dist/provider.js
CHANGED
|
@@ -77,10 +77,16 @@ export class CacheProvider {
|
|
|
77
77
|
if (Object.keys(stores).length === 0) {
|
|
78
78
|
stores.memory = new MemoryCacheStore();
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
const cacheInstance = new Cache({
|
|
81
81
|
stores,
|
|
82
82
|
ttl: config('cache.ttl', 60),
|
|
83
83
|
default: config('cache.default', Object.keys(stores)[0]),
|
|
84
|
-
})
|
|
84
|
+
});
|
|
85
|
+
app.instance('cache', cacheInstance);
|
|
86
|
+
// Wire the cache() HTTP middleware so route-level `cache({ ttl: 60 })`
|
|
87
|
+
// works without an explicit `store` option once this provider is
|
|
88
|
+
// registered.
|
|
89
|
+
const { setDefaultCacheStore } = await import('./http-cache');
|
|
90
|
+
setDefaultCacheStore(cacheInstance);
|
|
85
91
|
}
|
|
86
92
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekir/cache",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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.3"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@tekir/redis": "^0.1.0"
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
import type { CacheStore } from "./types"
|
|
27
|
+
|
|
28
|
+
export type HttpCacheOptions = {
|
|
29
|
+
/**
|
|
30
|
+
* Cache backend. Either a `Cache` manager (uses default store) or a raw
|
|
31
|
+
* `CacheStore`. If omitted you must register `@tekir/cache` in the app
|
|
32
|
+
* and the middleware will resolve `service('cache')` at request time.
|
|
33
|
+
*/
|
|
34
|
+
store?: Cache | CacheStore
|
|
35
|
+
/** Time-to-live in seconds. Default: 60. */
|
|
36
|
+
ttl?: number
|
|
37
|
+
/**
|
|
38
|
+
* HTTP methods that are cacheable. Default: ['GET', 'HEAD'].
|
|
39
|
+
* Mutating methods (POST/PUT/PATCH/DELETE) skip the cache.
|
|
40
|
+
*/
|
|
41
|
+
methods?: string[]
|
|
42
|
+
/**
|
|
43
|
+
* Custom key builder. Defaults to `${method} ${url}`. Pass a function
|
|
44
|
+
* to include user identity, query params, etc.
|
|
45
|
+
*/
|
|
46
|
+
key?: (ctx: HttpCacheCtx) => string
|
|
47
|
+
/**
|
|
48
|
+
* Optional list of request headers to include in the cache key,
|
|
49
|
+
* mirroring the HTTP `Vary` header. Default: [].
|
|
50
|
+
*/
|
|
51
|
+
vary?: string[]
|
|
52
|
+
/**
|
|
53
|
+
* Skip predicate. Return `true` to bypass caching for this request.
|
|
54
|
+
*/
|
|
55
|
+
skip?: (ctx: HttpCacheCtx) => boolean | Promise<boolean>
|
|
56
|
+
/** Override the namespace prefix used in cache keys. Default: 'http:'. */
|
|
57
|
+
prefix?: string
|
|
58
|
+
/**
|
|
59
|
+
* If true, sets `Cache-Control: public, max-age=<ttl>` on cached
|
|
60
|
+
* responses. Default: true.
|
|
61
|
+
*/
|
|
62
|
+
setCacheControl?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type HttpCacheCtx = {
|
|
66
|
+
request: { url: string; method: string; headers: Headers; raw?: Request }
|
|
67
|
+
params?: Record<string, string>
|
|
68
|
+
query?: Record<string, string | string[]>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type CachedEntry = {
|
|
72
|
+
status: number
|
|
73
|
+
headers: Record<string, string>
|
|
74
|
+
body: string
|
|
75
|
+
etag: string
|
|
76
|
+
storedAt: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SAFE_METHODS = ["GET", "HEAD"]
|
|
80
|
+
|
|
81
|
+
const isStore = (s: unknown): s is CacheStore =>
|
|
82
|
+
!!s &&
|
|
83
|
+
typeof (s as CacheStore).get === "function" &&
|
|
84
|
+
typeof (s as CacheStore).set === "function"
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Module-level default store. CacheProvider sets this at register time so
|
|
88
|
+
* `cache({ ttl: 60 })` works without an explicit `store` option once the
|
|
89
|
+
* provider is wired into the app. Stays null if the provider isn't used,
|
|
90
|
+
* in which case the middleware no-ops (passes through).
|
|
91
|
+
*/
|
|
92
|
+
let _defaultStore: Cache | CacheStore | null = null
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register the default backing store for the `cache()` middleware. Called
|
|
96
|
+
* by `CacheProvider` after it builds the Cache manager from config.
|
|
97
|
+
*
|
|
98
|
+
* Users can also call this directly if they don't use providers:
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { setDefaultCacheStore, Cache, MemoryCacheStore } from '@tekir/cache'
|
|
102
|
+
* setDefaultCacheStore(new Cache({ stores: { memory: new MemoryCacheStore() } }))
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function setDefaultCacheStore(s: Cache | CacheStore | null): void {
|
|
106
|
+
_defaultStore = s
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the currently registered default store, or null if none.
|
|
111
|
+
*/
|
|
112
|
+
export function getDefaultCacheStore(): Cache | CacheStore | null {
|
|
113
|
+
return _defaultStore
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hash = (s: string): string => {
|
|
117
|
+
// FNV-1a 32-bit. Good enough for ETags; not crypto.
|
|
118
|
+
let h = 0x811c9dc5
|
|
119
|
+
for (let i = 0; i < s.length; i++) {
|
|
120
|
+
h ^= s.charCodeAt(i)
|
|
121
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
|
122
|
+
}
|
|
123
|
+
return h.toString(16).padStart(8, "0")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const defaultKey = (ctx: HttpCacheCtx, vary: string[]): string => {
|
|
127
|
+
const parts = [ctx.request.method, ctx.request.url]
|
|
128
|
+
for (const h of vary) {
|
|
129
|
+
const v = ctx.request.headers.get(h)
|
|
130
|
+
if (v) parts.push(`${h}=${v}`)
|
|
131
|
+
}
|
|
132
|
+
return parts.join("|")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const resolveStore = (
|
|
136
|
+
s: HttpCacheOptions["store"] | null | undefined,
|
|
137
|
+
): CacheStore | null => {
|
|
138
|
+
if (!s) return null
|
|
139
|
+
if (s instanceof Cache) return s.store()
|
|
140
|
+
if (isStore(s)) return s
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const responseToEntry = async (resp: Response): Promise<CachedEntry> => {
|
|
145
|
+
const body = await resp.clone().text()
|
|
146
|
+
const headers: Record<string, string> = {}
|
|
147
|
+
resp.headers.forEach((v, k) => {
|
|
148
|
+
// Skip hop-by-hop and connection-specific headers
|
|
149
|
+
if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") return
|
|
150
|
+
headers[k] = v
|
|
151
|
+
})
|
|
152
|
+
const etag = `W/"${hash(body)}"`
|
|
153
|
+
return { status: resp.status, headers, body, etag, storedAt: Date.now() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const entryToResponse = (e: CachedEntry, opts: HttpCacheOptions): Response => {
|
|
157
|
+
const headers: Record<string, string> = { ...e.headers, etag: e.etag }
|
|
158
|
+
if (opts.setCacheControl !== false && !headers["cache-control"]) {
|
|
159
|
+
headers["cache-control"] = `public, max-age=${opts.ttl ?? 60}`
|
|
160
|
+
}
|
|
161
|
+
headers["x-tekir-cache"] = "HIT"
|
|
162
|
+
return new Response(e.body, { status: e.status, headers })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function cache(opts: HttpCacheOptions = {}) {
|
|
166
|
+
const ttl = opts.ttl ?? 60
|
|
167
|
+
const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()))
|
|
168
|
+
const vary = opts.vary ?? []
|
|
169
|
+
const prefix = opts.prefix ?? "http:"
|
|
170
|
+
const buildKey = opts.key ?? ((ctx: HttpCacheCtx) => defaultKey(ctx, vary))
|
|
171
|
+
const directStore = resolveStore(opts.store)
|
|
172
|
+
|
|
173
|
+
return async function cacheMiddleware(ctx: any, next: () => Promise<void>) {
|
|
174
|
+
const req = ctx.request
|
|
175
|
+
if (!req || !methods.has(String(req.method ?? "GET").toUpperCase())) {
|
|
176
|
+
await next()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (opts.skip && (await opts.skip(ctx))) {
|
|
181
|
+
await next()
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const cacheControl = req.headers?.get?.("cache-control") ?? ""
|
|
186
|
+
if (cacheControl.includes("no-store")) {
|
|
187
|
+
await next()
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Resolve store: option > module-level default (set by CacheProvider).
|
|
192
|
+
// No store anywhere → middleware acts as a transparent no-op so the
|
|
193
|
+
// route still works without a registered cache backend.
|
|
194
|
+
let store: CacheStore | null = directStore
|
|
195
|
+
if (!store) store = resolveStore(_defaultStore)
|
|
196
|
+
if (!store) {
|
|
197
|
+
await next()
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const key = prefix + buildKey(ctx)
|
|
202
|
+
const cached = await store.get<CachedEntry>(key)
|
|
203
|
+
|
|
204
|
+
// Conditional request: client sent If-None-Match
|
|
205
|
+
const ifNoneMatch = req.headers?.get?.("if-none-match") ?? ""
|
|
206
|
+
if (cached) {
|
|
207
|
+
if (ifNoneMatch && ifNoneMatch === cached.etag) {
|
|
208
|
+
ctx.$result = new Response(null, {
|
|
209
|
+
status: 304,
|
|
210
|
+
headers: { etag: cached.etag, "x-tekir-cache": "REVALIDATED" },
|
|
211
|
+
})
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
ctx.$result = entryToResponse(cached, opts)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Miss: run the handler chain, capture, store.
|
|
219
|
+
await next()
|
|
220
|
+
const result = ctx.$result
|
|
221
|
+
if (!(result instanceof Response)) return
|
|
222
|
+
if (result.status >= 500 || result.status === 204) return // don't cache errors / empty
|
|
223
|
+
if (cacheControl.includes("no-cache")) return
|
|
224
|
+
const respCacheControl = result.headers.get("cache-control") ?? ""
|
|
225
|
+
if (respCacheControl.includes("private") || respCacheControl.includes("no-store")) return
|
|
226
|
+
|
|
227
|
+
const entry = await responseToEntry(result)
|
|
228
|
+
await store.set(key, entry, ttl)
|
|
229
|
+
|
|
230
|
+
// Re-emit with x-tekir-cache: MISS so the client can see it
|
|
231
|
+
const out: Record<string, string> = {}
|
|
232
|
+
result.headers.forEach((v, k) => (out[k] = v))
|
|
233
|
+
out["etag"] = entry.etag
|
|
234
|
+
if (opts.setCacheControl !== false && !out["cache-control"]) {
|
|
235
|
+
out["cache-control"] = `public, max-age=${ttl}`
|
|
236
|
+
}
|
|
237
|
+
out["x-tekir-cache"] = "MISS"
|
|
238
|
+
ctx.$result = new Response(entry.body, { status: result.status, headers: out })
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,5 @@ export { RedisCacheStore } from './stores/redis'
|
|
|
4
4
|
export { DatabaseCacheStore } from './stores/database'
|
|
5
5
|
export { Cache, createCache } from './cache'
|
|
6
6
|
export { CacheProvider } from './provider'
|
|
7
|
+
export { cache, setDefaultCacheStore, getDefaultCacheStore } from './http-cache'
|
|
8
|
+
export type { HttpCacheOptions, HttpCacheCtx } from './http-cache'
|
package/src/provider.ts
CHANGED
|
@@ -84,10 +84,17 @@ export class CacheProvider {
|
|
|
84
84
|
stores.memory = new MemoryCacheStore()
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
const cacheInstance = new Cache({
|
|
88
88
|
stores,
|
|
89
89
|
ttl: config('cache.ttl', 60) as number,
|
|
90
90
|
default: config('cache.default', Object.keys(stores)[0]) as string,
|
|
91
|
-
})
|
|
91
|
+
})
|
|
92
|
+
app.instance('cache', cacheInstance)
|
|
93
|
+
|
|
94
|
+
// Wire the cache() HTTP middleware so route-level `cache({ ttl: 60 })`
|
|
95
|
+
// works without an explicit `store` option once this provider is
|
|
96
|
+
// registered.
|
|
97
|
+
const { setDefaultCacheStore } = await import('./http-cache')
|
|
98
|
+
setDefaultCacheStore(cacheInstance)
|
|
92
99
|
}
|
|
93
100
|
}
|