@alxia/cache 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -10
- package/dist/cache.d.ts +19 -11
- package/dist/cache.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +42 -22
- package/dist/index.js.map +4 -4
- package/dist/keep.d.ts +14 -0
- package/dist/keep.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide/caching.md +42 -15
- package/docs/guide/invalidation.md +3 -3
- package/docs/guide/keys-and-vary.md +27 -12
- package/docs/guide/stores.md +5 -5
- package/docs/roadmap.md +5 -2
- package/docs/troubleshooting.md +73 -18
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @alxia/cache
|
|
2
2
|
|
|
3
3
|
HTTP response caching for [alxia](https://www.npmjs.com/package/@alxia/core),
|
|
4
|
-
with no dependency: fresh responses served again, stale ones served while
|
|
4
|
+
as a middleware, with no dependency: fresh responses served again, stale ones served while
|
|
5
5
|
they refresh, one route run for many concurrent misses, tags to empty it,
|
|
6
6
|
ETags and 304s. In memory, or in Redis with
|
|
7
7
|
[`@alxia/redis`](https://www.npmjs.com/package/@alxia/redis)'s
|
|
@@ -16,7 +16,7 @@ bun add -d typescript
|
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
import { cache } from '@alxia/cache';
|
|
19
|
-
import { alxia } from '@alxia/core';
|
|
19
|
+
import { alxia, validate } from '@alxia/core';
|
|
20
20
|
import { z } from 'zod'; // any Standard Schema validates a body; zod is one
|
|
21
21
|
|
|
22
22
|
const Product = z.object({ id: z.string(), name: z.string() });
|
|
@@ -25,7 +25,7 @@ const catalogue = new Map<string, z.infer<typeof Product>>();
|
|
|
25
25
|
const products = cache({ ttl: 60, staleWhileRevalidate: 300, statuses: [200, 404], tags: () => ['products'] });
|
|
26
26
|
|
|
27
27
|
const app = alxia()
|
|
28
|
-
.post('/products', { body: Product }, async ({ body, reply }) => {
|
|
28
|
+
.post('/products', validate({ body: Product }), async ({ body, reply }) => {
|
|
29
29
|
catalogue.set(body.id, body);
|
|
30
30
|
await products.invalidateTag('products'); // the next GET runs the route
|
|
31
31
|
return reply(201, body);
|
|
@@ -54,10 +54,24 @@ app.listen({ port: 3000 });
|
|
|
54
54
|
`Cache-Control: private` or `no-store`, sets a cookie, or streams events —
|
|
55
55
|
and one whose route called `cache.skip()`. Nor is it handed to a
|
|
56
56
|
concurrent request: each runs the route itself.
|
|
57
|
+
- **Someone's own, unless it says otherwise** (RFC 9111 §3.5): the answer
|
|
58
|
+
to a request carrying `Authorization` is kept only when it says
|
|
59
|
+
`Cache-Control: public`, `s-maxage` or `must-revalidate`, or `vary` names
|
|
60
|
+
`authorization`; one carrying `Cookie` also when the cache has a `key` of
|
|
61
|
+
your own, which is your word that it tells users apart. A bearer API's
|
|
62
|
+
`/me` behind `cache()` runs for every caller:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
cache({ ttl: 60 }); // Authorization or Cookie: not kept
|
|
66
|
+
cache({ ttl: 60, vary: ['authorization'] }); // kept per token
|
|
67
|
+
cache({ ttl: 60, key: ({ url, user }) => `${user.id}:${url.pathname}` }); // kept per user, for a cookie session
|
|
68
|
+
```
|
|
57
69
|
- **A store that cannot answer** costs the cache, not the response: the
|
|
58
70
|
route runs, nothing is kept, and the outage's first error is logged.
|
|
59
71
|
- Only `GET` and `HEAD` — not `QUERY`, whose key would have to include its
|
|
60
|
-
body; only the routes declared after the
|
|
72
|
+
body; only the routes declared after the middleware. A request no route
|
|
73
|
+
matches passes through it, never looked up nor kept, even with `404` in
|
|
74
|
+
`statuses`: a 404 kept is a route's own.
|
|
61
75
|
|
|
62
76
|
## The key
|
|
63
77
|
|
|
@@ -78,7 +92,7 @@ await products.invalidate('/products'); // under each `vary` value, or
|
|
|
78
92
|
await products.invalidate('/products?page=2'); // another path: the query is part of it
|
|
79
93
|
```
|
|
80
94
|
|
|
81
|
-
A `key` or `tags` that reads what an earlier
|
|
95
|
+
A `key` or `tags` that reads what an earlier middleware added names it as the
|
|
82
96
|
type argument; an app that does not give it before the cache cannot use it:
|
|
83
97
|
|
|
84
98
|
```ts
|
|
@@ -88,9 +102,9 @@ const perTenant = cache<{ user: { tenantId: string } }>({
|
|
|
88
102
|
tags: ({ user }) => [`tenant:${user.tenantId}`],
|
|
89
103
|
});
|
|
90
104
|
|
|
91
|
-
const auth = alxia().derive(() => ({ user: { tenantId: 'acme' } })); // your session
|
|
105
|
+
const auth = alxia().derive(() => ({ user: { tenantId: 'acme' } })); // your session middleware
|
|
92
106
|
|
|
93
|
-
alxia().
|
|
107
|
+
alxia().plugin(auth).use(perTenant); // compiles: auth derives user
|
|
94
108
|
alxia().use(perTenant); // a compile error: no `user` in this app's context
|
|
95
109
|
```
|
|
96
110
|
|
|
@@ -117,7 +131,7 @@ across every process. A store of your own implements `CacheStore`: `get`,
|
|
|
117
131
|
| `ttl` | required | seconds fresh |
|
|
118
132
|
| `staleWhileRevalidate` | 0 | seconds served stale while refreshed |
|
|
119
133
|
| `store` | `MemoryCacheStore` | |
|
|
120
|
-
| `key` | path and query | `(ctx) => string \| undefined`; `cache<{ user: User }>(…)` lets it read a `user` an earlier
|
|
134
|
+
| `key` | path and query | `(ctx) => string \| undefined`; `cache<{ user: User }>(…)` lets it read a `user` an earlier middleware adds |
|
|
121
135
|
| `vary` | none | request headers the response depends on |
|
|
122
136
|
| `statuses` | `[200]` | |
|
|
123
137
|
| `tags` | none | `(ctx) => string[]`, typed like `key` |
|
|
@@ -128,14 +142,14 @@ across every process. A store of your own implements `CacheStore`: `get`,
|
|
|
128
142
|
|
|
129
143
|
| export | |
|
|
130
144
|
| --- | --- |
|
|
131
|
-
| `cache<Requires>(options)` | the
|
|
145
|
+
| `cache<Requires>(options)` | the middleware, with `invalidate(path)`, `invalidateTag(tag)` and `store`; routes after it read `cache.tag()` and `cache.skip()` |
|
|
132
146
|
| `CacheOptions<Requires>` | its options: `ttl`, `staleWhileRevalidate`, `store`, `key`, `vary`, `statuses`, `tags`, `honorClientNoCache`, `debugHeaders` |
|
|
133
147
|
| `defaultKey(path, vary, headers)` | the default key: the path and query, then each varying header's value |
|
|
134
148
|
| `pathTag(path)` | the tag every kept response carries for its path, `alxia:path:<path>`: what `invalidate(path)` deletes |
|
|
135
149
|
| `MemoryCacheStore` | the in-process store: least recently used |
|
|
136
150
|
| `MemoryCacheOptions` | its options: `maxEntries`, `maxBytes` |
|
|
137
151
|
| `CacheStore`, `CachedResponse` | a store's contract |
|
|
138
|
-
| `Cache`, `CacheControls` | the
|
|
152
|
+
| `Cache`, `CacheControls`, `CacheMiddleware<Requires>` | the middleware's handles, what the routes behind it read, and the type `cache()` returns |
|
|
139
153
|
|
|
140
154
|
## Documentation
|
|
141
155
|
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type BaseContext, type Empty } from '@alxia/core';
|
|
1
|
+
import { type BaseContext, type Empty, type Middleware, type MiddlewareMark, type Next } from '@alxia/core';
|
|
2
2
|
import { type CacheStore } from './store';
|
|
3
3
|
/**
|
|
4
4
|
* `Requires` is what `key` and `tags` read from the context beyond
|
|
5
|
-
* `BaseContext` — a `user` an earlier
|
|
5
|
+
* `BaseContext` — a `user` an earlier middleware adds — and what the app that
|
|
6
6
|
* uses the cache must then give.
|
|
7
7
|
*/
|
|
8
8
|
export interface CacheOptions<Requires extends object = Empty> {
|
|
@@ -33,7 +33,7 @@ export interface CacheOptions<Requires extends object = Empty> {
|
|
|
33
33
|
}
|
|
34
34
|
/** What the routes behind the cache read. */
|
|
35
35
|
export interface CacheControls {
|
|
36
|
-
/** Tags the response being built, beyond the
|
|
36
|
+
/** Tags the response being built, beyond the middleware's `tags`. Tags starting `alxia:` are the cache's own. */
|
|
37
37
|
tag(...tags: string[]): void;
|
|
38
38
|
/** Keeps this response out of the cache. */
|
|
39
39
|
skip(): void;
|
|
@@ -50,11 +50,21 @@ export interface Cache {
|
|
|
50
50
|
readonly store: CacheStore;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
53
|
+
* What `cache()` makes: a middleware that requires `Requires` of the app
|
|
54
|
+
* and gives `cache`, with the hands to empty it.
|
|
55
|
+
*/
|
|
56
|
+
export type CacheMiddleware<Requires extends object = Empty> = Middleware<Requires, Promise<Response | Next<{
|
|
57
|
+
cache: CacheControls;
|
|
58
|
+
}>>> & MiddlewareMark & Cache;
|
|
59
|
+
/**
|
|
60
|
+
* Responses kept and served again, as a middleware: a `GET` to a route
|
|
61
|
+
* declared after it is answered from the store while fresh, and from the
|
|
62
|
+
* route otherwise. Concurrent misses run the route once. Stale, it is served at
|
|
56
63
|
* once and refreshed behind. A response that says `no-store` or `private`,
|
|
57
|
-
* sets a cookie, or has another status is never kept
|
|
64
|
+
* sets a cookie, or has another status is never kept; nor is the answer to a
|
|
65
|
+
* request carrying `Authorization` or `Cookie`, unless it says `public`,
|
|
66
|
+
* `s-maxage` or `must-revalidate`, or the key tells senders apart: `vary`
|
|
67
|
+
* naming the header, or, for a cookie, a `key` of the app's own.
|
|
58
68
|
*
|
|
59
69
|
* Every kept response gets a weak `ETag` from its body when it has none, so
|
|
60
70
|
* a client whose copy is current gets a 304.
|
|
@@ -65,10 +75,8 @@ export interface Cache {
|
|
|
65
75
|
* await products.invalidateTag('products');
|
|
66
76
|
* ```
|
|
67
77
|
*
|
|
68
|
-
* A `key` or `tags` that reads what an earlier
|
|
78
|
+
* A `key` or `tags` that reads what an earlier middleware added names it, and
|
|
69
79
|
* the app must then give it: `cache<{ user: User }>({ tags: ({ user }) => [user.id], … })`.
|
|
70
80
|
*/
|
|
71
|
-
export declare function cache<Requires extends object = Empty>(options: CacheOptions<Requires>):
|
|
72
|
-
cache: CacheControls;
|
|
73
|
-
}, Empty, "", never> & import("@alxia/core").Requiring<Requires> & Cache;
|
|
81
|
+
export declare function cache<Requires extends object = Empty>(options: CacheOptions<Requires>): NoInfer<CacheMiddleware<Requires>>;
|
|
74
82
|
//# sourceMappingURL=cache.d.ts.map
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,MAAM,aAAa,CAAC;AAQrB,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,SAAS,CAAC;AAE5D;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK;IAC5D,mCAAmC;IACnC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,QAAQ,KAAK,MAAM,GAAG,SAAS,CAAC;IACnE,yGAAyG;IACzG,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,wEAAwE;IACxE,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,QAAQ,KAAK,SAAS,MAAM,EAAE,CAAC;IACnE,sHAAsH;IACtH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,wEAAwE;IACxE,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,6CAA6C;AAC7C,MAAM,WAAW,aAAa;IAC7B,iHAAiH;IACjH,GAAG,CAAC,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC7B,4CAA4C;IAC5C,IAAI,IAAI,IAAI,CAAC;CACb;AAED,uDAAuD;AACvD,MAAM,WAAW,KAAK;IACrB;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,2CAA2C;IAC3C,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,IAAI,UAAU,CACxE,QAAQ,EACR,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAAE,KAAK,EAAE,aAAa,CAAA;CAAE,CAAC,CAAC,CAClD,GACA,cAAc,GACd,KAAK,CAAC;AAEP;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,KAAK,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,EACpD,OAAO,EAAE,YAAY,CAAC,QAAQ,CAAC,GAC7B,OAAO,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAsEpC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type Cache, type CacheControls, type CacheOptions, cache, } from './cache';
|
|
1
|
+
export { type Cache, type CacheControls, type CacheMiddleware, type CacheOptions, cache, } from './cache';
|
|
2
2
|
export { defaultKey, pathTag } from './keys';
|
|
3
3
|
export { type CachedResponse, type CacheStore, type MemoryCacheOptions, MemoryCacheStore, } from './store';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EACV,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,GACL,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,EACN,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,gBAAgB,GAChB,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EACV,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,GACL,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,EACN,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,gBAAgB,GAChB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/cache.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
defineMiddleware
|
|
4
|
+
} from "@alxia/core";
|
|
3
5
|
|
|
4
6
|
// src/control.ts
|
|
5
7
|
function requestControls() {
|
|
@@ -66,6 +68,12 @@ var PRIVATE = /\b(no-store|private)\b/i;
|
|
|
66
68
|
function keepable(response, control, statuses) {
|
|
67
69
|
return !(control?.skipped || !statuses.has(response.status) || PRIVATE.test(response.headers.get("cache-control") ?? "") || response.headers.has("set-cookie") || response.headers.get("content-type")?.startsWith("text/event-stream"));
|
|
68
70
|
}
|
|
71
|
+
var SHARED = /\b(public|s-maxage|must-revalidate)\b/i;
|
|
72
|
+
function shareable(request, response, keyedBy) {
|
|
73
|
+
const { headers } = request;
|
|
74
|
+
const credentialed = headers.has("authorization") && !keyedBy.authorization || headers.has("cookie") && !keyedBy.cookie;
|
|
75
|
+
return !credentialed || SHARED.test(response.headers.get("cache-control") ?? "");
|
|
76
|
+
}
|
|
69
77
|
async function toCached(response, keep) {
|
|
70
78
|
const body = new Uint8Array(await response.arrayBuffer());
|
|
71
79
|
const headers = new Headers(response.headers);
|
|
@@ -198,22 +206,17 @@ class MemoryCacheStore {
|
|
|
198
206
|
|
|
199
207
|
// src/cache.ts
|
|
200
208
|
function cache(options) {
|
|
201
|
-
const store
|
|
202
|
-
const
|
|
203
|
-
const stale = (options.staleWhileRevalidate ?? 0) * 1000;
|
|
204
|
-
const vary = (options.vary ?? []).map((name) => name.toLowerCase());
|
|
205
|
-
const statuses = new Set(options.statuses ?? [200]);
|
|
206
|
-
const debug = options.debugHeaders ?? true;
|
|
207
|
-
const honorNoCache = options.honorClientNoCache ?? false;
|
|
208
|
-
const keyOf = options.key ?? ((ctx) => defaultKey(`${ctx.url.pathname}${ctx.url.search}`, vary, ctx.request.headers));
|
|
209
|
+
const { store, ttl, stale, vary, statuses, debug, honorNoCache, ...keys } = settingsOf(options);
|
|
210
|
+
const { keyOf, keyedBy } = keys;
|
|
209
211
|
const controls = requestControls();
|
|
210
212
|
const attempt = storeGuard();
|
|
211
213
|
const flight = singleFlight();
|
|
212
214
|
const keepOnMiss = async (key, ctx, next) => {
|
|
213
215
|
const response = await next();
|
|
214
216
|
const control = controls.get(ctx.request);
|
|
215
|
-
if (!keepable(response, control, statuses))
|
|
217
|
+
if (!keepable(response, control, statuses) || !shareable(ctx.request, response, keyedBy)) {
|
|
216
218
|
return { own: response };
|
|
219
|
+
}
|
|
217
220
|
const cached = await toCached(response, {
|
|
218
221
|
ttl,
|
|
219
222
|
stale,
|
|
@@ -229,30 +232,47 @@ function cache(options) {
|
|
|
229
232
|
};
|
|
230
233
|
const load = (key, ctx, next) => flight.load(key, () => keepOnMiss(key, ctx, next), next);
|
|
231
234
|
const label = (says) => debug ? says : undefined;
|
|
232
|
-
const
|
|
233
|
-
const cacheControls = controls.open(request);
|
|
234
|
-
return { cache: cacheControls };
|
|
235
|
-
}).wrap(async (ctx, next) => {
|
|
235
|
+
const middleware = defineMiddleware()(async (ctx, next) => {
|
|
236
236
|
const { request } = ctx;
|
|
237
|
-
const
|
|
237
|
+
const added = { cache: controls.open(request) };
|
|
238
|
+
const key = ctx.route === undefined || bypasses(request, honorNoCache) ? undefined : keyOf(ctx);
|
|
238
239
|
if (key === undefined)
|
|
239
|
-
return next();
|
|
240
|
+
return next(added);
|
|
240
241
|
const found = await attempt(() => store.get(key), undefined);
|
|
241
242
|
const worth = found === undefined ? undefined : freshness(found);
|
|
242
243
|
if (found !== undefined && worth === "fresh") {
|
|
243
244
|
return respond(request, found, label("HIT"));
|
|
244
245
|
}
|
|
245
246
|
if (found !== undefined && worth === "stale") {
|
|
246
|
-
if (!flight.has(key))
|
|
247
|
-
refreshBehind(load(key, ctx, next));
|
|
247
|
+
if (!flight.has(key)) {
|
|
248
|
+
refreshBehind(load(key, ctx, () => next.behind(added)));
|
|
249
|
+
}
|
|
248
250
|
return respond(request, found, label("STALE"));
|
|
249
251
|
}
|
|
250
|
-
const loaded = await load(key, ctx, next);
|
|
252
|
+
const loaded = await load(key, ctx, () => next(added));
|
|
251
253
|
if (loaded instanceof Response)
|
|
252
254
|
return loaded;
|
|
253
255
|
return respond(request, loaded, label("MISS"));
|
|
254
|
-
})
|
|
255
|
-
return Object.assign(
|
|
256
|
+
});
|
|
257
|
+
return Object.assign(middleware, handlesOf(store));
|
|
258
|
+
}
|
|
259
|
+
function settingsOf(options) {
|
|
260
|
+
const vary = (options.vary ?? []).map((name) => name.toLowerCase());
|
|
261
|
+
const keyedBy = {
|
|
262
|
+
authorization: vary.includes("authorization"),
|
|
263
|
+
cookie: options.key !== undefined || vary.includes("cookie")
|
|
264
|
+
};
|
|
265
|
+
return {
|
|
266
|
+
store: options.store ?? new MemoryCacheStore,
|
|
267
|
+
ttl: options.ttl * 1000,
|
|
268
|
+
stale: (options.staleWhileRevalidate ?? 0) * 1000,
|
|
269
|
+
vary,
|
|
270
|
+
statuses: new Set(options.statuses ?? [200]),
|
|
271
|
+
debug: options.debugHeaders ?? true,
|
|
272
|
+
honorNoCache: options.honorClientNoCache ?? false,
|
|
273
|
+
keyOf: options.key ?? ((ctx) => defaultKey(`${ctx.url.pathname}${ctx.url.search}`, vary, ctx.request.headers)),
|
|
274
|
+
keyedBy
|
|
275
|
+
};
|
|
256
276
|
}
|
|
257
277
|
function handlesOf(store) {
|
|
258
278
|
return {
|
|
@@ -272,5 +292,5 @@ export {
|
|
|
272
292
|
pathTag
|
|
273
293
|
};
|
|
274
294
|
|
|
275
|
-
//# debugId=
|
|
295
|
+
//# debugId=4C215944C289F9AC64756E2164756E21
|
|
276
296
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/cache.ts", "../src/control.ts", "../src/flight.ts", "../src/guard.ts", "../src/keep.ts", "../src/keys.ts", "../src/lookup.ts", "../src/respond.ts", "../src/store.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import {
|
|
5
|
+
"import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n} from '@alxia/core';\nimport { requestControls } from './control';\nimport { type Loaded, refreshBehind, singleFlight } from './flight';\nimport { storeGuard } from './guard';\nimport { type KeyedBy, keepable, shareable, toCached } from './keep';\nimport { defaultKey, pathTag } from './keys';\nimport { bypasses, freshness } from './lookup';\nimport { respond } from './respond';\nimport { type CacheStore, MemoryCacheStore } from './store';\n\n/**\n * `Requires` is what `key` and `tags` read from the context beyond\n * `BaseContext` — a `user` an earlier middleware adds — and what the app that\n * uses the cache must then give.\n */\nexport interface CacheOptions<Requires extends object = Empty> {\n\t/** Seconds a response is fresh. */\n\treadonly ttl: number;\n\t/**\n\t * Seconds a response is served stale after, while one request refreshes\n\t * it in the background: no client waits for a slow route. None by default.\n\t */\n\treadonly staleWhileRevalidate?: number;\n\t/** Where responses are kept: this process's memory by default. */\n\treadonly store?: CacheStore;\n\t/**\n\t * The key of a request: its path and query by default, and the headers\n\t * in `vary`. `undefined` is not cached: a request with a session, say.\n\t */\n\treadonly key?: (ctx: BaseContext & Requires) => string | undefined;\n\t/** Request headers the response varies by: `accept-language`. Each is part of the key, and of `Vary`. */\n\treadonly vary?: readonly string[];\n\t/** The statuses kept. `200` by default; a 404 may be worth keeping too. */\n\treadonly statuses?: readonly number[];\n\t/** Tags every response of these routes carries, for `invalidateTag`. */\n\treadonly tags?: (ctx: BaseContext & Requires) => readonly string[];\n\t/** Whether `Cache-Control: no-cache` from the client skips the cache. Off by default: a client cannot empty yours. */\n\treadonly honorClientNoCache?: boolean;\n\t/** Says `X-Cache: HIT`, `STALE` or `MISS`, and `Age`. On by default. */\n\treadonly debugHeaders?: boolean;\n}\n\n/** What the routes behind the cache read. */\nexport interface CacheControls {\n\t/** Tags the response being built, beyond the middleware's `tags`. Tags starting `alxia:` are the cache's own. */\n\ttag(...tags: string[]): void;\n\t/** Keeps this response out of the cache. */\n\tskip(): void;\n}\n\n/** A cache of responses, and the hands to empty it. */\nexport interface Cache {\n\t/**\n\t * Forgets every response kept for `path` — `/users/1?x=y`, as the request\n\t * asked it — whatever its key: each `vary` value, a `key` of your own.\n\t */\n\tinvalidate(path: string): Promise<void>;\n\t/** Forgets every response tagged `tag`. */\n\tinvalidateTag(tag: string): Promise<void>;\n\treadonly store: CacheStore;\n}\n\n/**\n * What `cache()` makes: a middleware that requires `Requires` of the app\n * and gives `cache`, with the hands to empty it.\n */\nexport type CacheMiddleware<Requires extends object = Empty> = Middleware<\n\tRequires,\n\tPromise<Response | Next<{ cache: CacheControls }>>\n> &\n\tMiddlewareMark &\n\tCache;\n\n/**\n * Responses kept and served again, as a middleware: a `GET` to a route\n * declared after it is answered from the store while fresh, and from the\n * route otherwise. Concurrent misses run the route once. Stale, it is served at\n * once and refreshed behind. A response that says `no-store` or `private`,\n * sets a cookie, or has another status is never kept; nor is the answer to a\n * request carrying `Authorization` or `Cookie`, unless it says `public`,\n * `s-maxage` or `must-revalidate`, or the key tells senders apart: `vary`\n * naming the header, or, for a cookie, a `key` of the app's own.\n *\n * Every kept response gets a weak `ETag` from its body when it has none, so\n * a client whose copy is current gets a 304.\n *\n * ```ts\n * const products = cache({ ttl: 60, staleWhileRevalidate: 300, tags: () => ['products'] });\n * app.use(products).get('/products', ...);\n * await products.invalidateTag('products');\n * ```\n *\n * A `key` or `tags` that reads what an earlier middleware added names it, and\n * the app must then give it: `cache<{ user: User }>({ tags: ({ user }) => [user.id], … })`.\n */\nexport function cache<Requires extends object = Empty>(\n\toptions: CacheOptions<Requires>,\n): NoInfer<CacheMiddleware<Requires>> {\n\tconst { store, ttl, stale, vary, statuses, debug, honorNoCache, ...keys } =\n\t\tsettingsOf(options);\n\tconst { keyOf, keyedBy } = keys;\n\tconst controls = requestControls();\n\tconst attempt = storeGuard();\n\tconst flight = singleFlight();\n\n\t/** The miss: the route runs, and what it answers is kept when it may be. */\n\tconst keepOnMiss = async (\n\t\tkey: string,\n\t\tctx: BaseContext & Requires,\n\t\tnext: () => Promise<Response>,\n\t): Promise<Loaded> => {\n\t\tconst response = await next();\n\t\tconst control = controls.get(ctx.request);\n\t\tif (\n\t\t\t!keepable(response, control, statuses) ||\n\t\t\t!shareable(ctx.request, response, keyedBy)\n\t\t) {\n\t\t\treturn { own: response };\n\t\t}\n\t\tconst cached = await toCached(response, {\n\t\t\tttl,\n\t\t\tstale,\n\t\t\tvary,\n\t\t\ttags: () => [\n\t\t\t\t...(options.tags?.(ctx) ?? []),\n\t\t\t\t...(control?.tags ?? []),\n\t\t\t\tpathTag(`${ctx.url.pathname}${ctx.url.search}`),\n\t\t\t],\n\t\t});\n\t\tawait attempt(() => store.set(key, cached, ttl + stale), undefined);\n\t\treturn { kept: cached };\n\t};\n\tconst load = (\n\t\tkey: string,\n\t\tctx: BaseContext & Requires,\n\t\tnext: () => Promise<Response>,\n\t) => flight.load(key, () => keepOnMiss(key, ctx, next), next);\n\n\tconst label = (says: 'HIT' | 'STALE' | 'MISS') => (debug ? says : undefined);\n\n\tconst middleware = defineMiddleware<Requires>()(async (ctx, next) => {\n\t\tconst { request } = ctx;\n\t\tconst added: { cache: CacheControls } = { cache: controls.open(request) };\n\t\t// A request no route matches is never kept: there is no route to answer it again.\n\t\tconst key =\n\t\t\tctx.route === undefined || bypasses(request, honorNoCache)\n\t\t\t\t? undefined\n\t\t\t\t: keyOf(ctx);\n\t\tif (key === undefined) return next(added);\n\t\tconst found = await attempt(() => store.get(key), undefined);\n\t\tconst worth = found === undefined ? undefined : freshness(found);\n\t\tif (found !== undefined && worth === 'fresh') {\n\t\t\treturn respond(request, found, label('HIT'));\n\t\t}\n\t\tif (found !== undefined && worth === 'stale') {\n\t\t\t// Served at once; the route runs behind it, unless a refresh already does.\n\t\t\tif (!flight.has(key)) {\n\t\t\t\trefreshBehind(load(key, ctx, () => next.behind(added)));\n\t\t\t}\n\t\t\treturn respond(request, found, label('STALE'));\n\t\t}\n\t\tconst loaded = await load(key, ctx, () => next(added));\n\t\tif (loaded instanceof Response) return loaded;\n\t\treturn respond(request, loaded, label('MISS'));\n\t});\n\n\treturn Object.assign(middleware, handlesOf(store));\n}\n\n/** The options with their defaults: milliseconds, lower-case header names, the key of a request and what it tells apart. */\nfunction settingsOf<Requires extends object>(options: CacheOptions<Requires>) {\n\tconst vary = (options.vary ?? []).map((name) => name.toLowerCase());\n\tconst keyedBy: KeyedBy = {\n\t\tauthorization: vary.includes('authorization'),\n\t\tcookie: options.key !== undefined || vary.includes('cookie'),\n\t};\n\treturn {\n\t\tstore: options.store ?? new MemoryCacheStore(),\n\t\tttl: options.ttl * 1000,\n\t\tstale: (options.staleWhileRevalidate ?? 0) * 1000,\n\t\tvary,\n\t\tstatuses: new Set(options.statuses ?? [200]),\n\t\tdebug: options.debugHeaders ?? true,\n\t\thonorNoCache: options.honorClientNoCache ?? false,\n\t\tkeyOf:\n\t\t\toptions.key ??\n\t\t\t((ctx: BaseContext & Requires) =>\n\t\t\t\tdefaultKey(\n\t\t\t\t\t`${ctx.url.pathname}${ctx.url.search}`,\n\t\t\t\t\tvary,\n\t\t\t\t\tctx.request.headers,\n\t\t\t\t)),\n\t\tkeyedBy,\n\t};\n}\n\n/** The hands to empty a cache: by the path a request asked, or by tag. */\nfunction handlesOf(store: CacheStore): Cache {\n\treturn {\n\t\tstore,\n\t\tinvalidate: async (path) => {\n\t\t\tawait store.deleteTag(pathTag(path));\n\t\t},\n\t\tinvalidateTag: async (tag) => {\n\t\t\tawait store.deleteTag(tag);\n\t\t},\n\t};\n}\n",
|
|
6
6
|
"/** What each route behind the cache says through `ctx.cache`, kept by request. */\n\n/** What a route said through `ctx.cache`, for the response it is building. */\nexport interface Control {\n\treadonly tags: string[];\n\tskipped: boolean;\n}\n\nexport function requestControls() {\n\tconst controls = new WeakMap<Request, Control>();\n\treturn {\n\t\t/** What the route said for `request`, once it has run. */\n\t\tget: (request: Request): Control | undefined => controls.get(request),\n\t\t/** A fresh control for `request`, and the hands a route is given to it. */\n\t\topen(request: Request) {\n\t\t\tconst control: Control = { tags: [], skipped: false };\n\t\t\tcontrols.set(request, control);\n\t\t\treturn {\n\t\t\t\ttag: (...tags: string[]) => {\n\t\t\t\t\tcontrol.tags.push(...tags);\n\t\t\t\t},\n\t\t\t\tskip: () => {\n\t\t\t\t\tcontrol.skipped = true;\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t};\n}\n",
|
|
7
7
|
"import type { CachedResponse } from './store';\n\n/** What a run of the route gives: a response it kept, or its own when it kept nothing. */\nexport type Loaded = { kept: CachedResponse } | { own: Response };\n\n/**\n * Runs the route once for every concurrent miss of a key. Only a response\n * that is kept is shared: one that is not — private, skipped, a cookie,\n * another status — answers the request that ran the route, and every other\n * waiting request runs the route itself, through its own `next`.\n */\nexport function singleFlight() {\n\t/** The run of each key being loaded: what it kept, or `undefined` when it kept nothing. */\n\tconst loading = new Map<string, Promise<CachedResponse | undefined>>();\n\treturn {\n\t\t/** Whether a run of `key` is under way. */\n\t\thas: (key: string): boolean => loading.has(key),\n\t\tload(\n\t\t\tkey: string,\n\t\t\trun: () => Promise<Loaded>,\n\t\t\tnext: () => Promise<Response>,\n\t\t): Promise<CachedResponse | Response> {\n\t\t\tconst running = loading.get(key);\n\t\t\tif (running !== undefined) {\n\t\t\t\treturn running.then(\n\t\t\t\t\t(cached): Promise<CachedResponse | Response> | CachedResponse =>\n\t\t\t\t\t\tcached ?? next(),\n\t\t\t\t\t(): Promise<CachedResponse | Response> => next(),\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst ran = run();\n\t\t\tconst shared = ran.then((loaded) =>\n\t\t\t\t'kept' in loaded ? loaded.kept : undefined,\n\t\t\t);\n\t\t\tloading.set(key, shared);\n\t\t\tshared.finally(() => loading.delete(key)).catch(() => {});\n\t\t\treturn ran.then((loaded) =>\n\t\t\t\t'kept' in loaded ? loaded.kept : loaded.own,\n\t\t\t);\n\t\t},\n\t};\n}\n\n/**\n * A stale response's refresh, run behind it: the route's own error is its to\n * log, and a response it does not keep is read by no one.\n */\nexport function refreshBehind(\n\tloading: Promise<CachedResponse | Response>,\n): void {\n\tloading\n\t\t.then((loaded) =>\n\t\t\tloaded instanceof Response ? loaded.body?.cancel() : undefined,\n\t\t)\n\t\t.catch((error) => console.error(error));\n}\n",
|
|
8
8
|
"/**\n * A store that cannot answer is a miss, or keeps nothing: a cache is never\n * worth a 500. Said to the log once per outage — again only after the store\n * has answered since.\n */\nexport function storeGuard() {\n\tlet failing = false;\n\treturn async <T>(work: () => Promise<T> | T, fallback: T): Promise<T> => {\n\t\ttry {\n\t\t\tconst value = await work();\n\t\t\tfailing = false;\n\t\t\treturn value;\n\t\t} catch (error) {\n\t\t\tif (!failing) console.error(error);\n\t\t\tfailing = true;\n\t\t\treturn fallback;\n\t\t}\n\t};\n}\n",
|
|
9
|
-
"/** What a response must be to be kept, and what is kept of it. */\nimport { vary as addVary } from '@alxia/core';\nimport type { Control } from './control';\nimport type { CachedResponse } from './store';\n\n/** Response headers that make a response someone's own. */\nconst PRIVATE = /\\b(no-store|private)\\b/i;\n\n/**\n * Whether a response may be kept: not skipped, of a kept status, nobody's\n * own — no `no-store`, `private` or cookie — and not an event stream.\n */\nexport function keepable(\n\tresponse: Response,\n\tcontrol: Control | undefined,\n\tstatuses: ReadonlySet<number>,\n): boolean {\n\treturn !(\n\t\tcontrol?.skipped ||\n\t\t!statuses.has(response.status) ||\n\t\tPRIVATE.test(response.headers.get('cache-control') ?? '') ||\n\t\tresponse.headers.has('set-cookie') ||\n\t\tresponse.headers.get('content-type')?.startsWith('text/event-stream')\n\t);\n}\n\n/**\n * The response as it is kept: its body read, `Content-Length` and `Date`\n * dropped, a weak `ETag` of its body when it has none, and `Vary` naming\n * each header in `vary`. `tags` is asked once the body is read.\n */\nexport async function toCached(\n\tresponse: Response,\n\tkeep: {\n\t\treadonly ttl: number;\n\t\treadonly stale: number;\n\t\treadonly vary: readonly string[];\n\t\treadonly tags: () => string[];\n\t},\n): Promise<CachedResponse> {\n\tconst body = new Uint8Array(await response.arrayBuffer());\n\tconst headers = new Headers(response.headers);\n\theaders.delete('content-length');\n\theaders.delete('date');\n\tif (!headers.has('etag')) {\n\t\theaders.set('etag', `W/\"${Bun.hash(body).toString(36)}\"`);\n\t}\n\tfor (const name of keep.vary) addVary(headers, name);\n\treturn {\n\t\tstatus: response.status,\n\t\theaders: [...headers],\n\t\tbody,\n\t\tstoredAt: Date.now(),\n\t\tttl: keep.ttl,\n\t\tstale: keep.stale,\n\t\ttags: keep.tags(),\n\t};\n}\n",
|
|
9
|
+
"/** What a response must be to be kept, and what is kept of it. */\nimport { vary as addVary } from '@alxia/core';\nimport type { Control } from './control';\nimport type { CachedResponse } from './store';\n\n/** Response headers that make a response someone's own. */\nconst PRIVATE = /\\b(no-store|private)\\b/i;\n\n/**\n * Whether a response may be kept: not skipped, of a kept status, nobody's\n * own — no `no-store`, `private` or cookie — and not an event stream.\n */\nexport function keepable(\n\tresponse: Response,\n\tcontrol: Control | undefined,\n\tstatuses: ReadonlySet<number>,\n): boolean {\n\treturn !(\n\t\tcontrol?.skipped ||\n\t\t!statuses.has(response.status) ||\n\t\tPRIVATE.test(response.headers.get('cache-control') ?? '') ||\n\t\tresponse.headers.has('set-cookie') ||\n\t\tresponse.headers.get('content-type')?.startsWith('text/event-stream')\n\t);\n}\n\n/**\n * Response directives that let a shared cache keep the answer to a request\n * carrying credentials (RFC 9111 §3.5).\n */\nconst SHARED = /\\b(public|s-maxage|must-revalidate)\\b/i;\n\n/** Which request credentials the cache's key tells apart: a response to a request carrying one is someone's own otherwise. */\nexport interface KeyedBy {\n\t/** `vary` names `authorization`: each token is a key of its own. */\n\treadonly authorization: boolean;\n\t/** The app gave a `key`, or `vary` names `cookie`. */\n\treadonly cookie: boolean;\n}\n\n/**\n * Whether the answer to `request` may be kept for others. A request carrying\n * `Authorization` or `Cookie` is answered for whoever sent it: its response\n * is kept only when it says it may be shared — `public`, `s-maxage` or\n * `must-revalidate` — or when the key tells those senders apart (`keyedBy`).\n */\nexport function shareable(\n\trequest: Request,\n\tresponse: Response,\n\tkeyedBy: KeyedBy,\n): boolean {\n\tconst { headers } = request;\n\tconst credentialed =\n\t\t(headers.has('authorization') && !keyedBy.authorization) ||\n\t\t(headers.has('cookie') && !keyedBy.cookie);\n\treturn (\n\t\t!credentialed || SHARED.test(response.headers.get('cache-control') ?? '')\n\t);\n}\n\n/**\n * The response as it is kept: its body read, `Content-Length` and `Date`\n * dropped, a weak `ETag` of its body when it has none, and `Vary` naming\n * each header in `vary`. `tags` is asked once the body is read.\n */\nexport async function toCached(\n\tresponse: Response,\n\tkeep: {\n\t\treadonly ttl: number;\n\t\treadonly stale: number;\n\t\treadonly vary: readonly string[];\n\t\treadonly tags: () => string[];\n\t},\n): Promise<CachedResponse> {\n\tconst body = new Uint8Array(await response.arrayBuffer());\n\tconst headers = new Headers(response.headers);\n\theaders.delete('content-length');\n\theaders.delete('date');\n\tif (!headers.has('etag')) {\n\t\theaders.set('etag', `W/\"${Bun.hash(body).toString(36)}\"`);\n\t}\n\tfor (const name of keep.vary) addVary(headers, name);\n\treturn {\n\t\tstatus: response.status,\n\t\theaders: [...headers],\n\t\tbody,\n\t\tstoredAt: Date.now(),\n\t\tttl: keep.ttl,\n\t\tstale: keep.stale,\n\t\ttags: keep.tags(),\n\t};\n}\n",
|
|
10
10
|
"/**\n * The tag every kept response carries for its path — `/users/1?x=y`, as the\n * request asked it — and what `invalidate(path)` forgets. Tags starting\n * `alxia:` are the plugin's own. Exported for a store, or a job, that\n * forgets a path without the `Cache` at hand: `store.deleteTag(pathTag(p))`.\n */\nexport const pathTag = (path: string): string => `alxia:path:${path}`;\n\n/** The default key: the path and query, then each varying header's value. */\nexport function defaultKey(\n\tpath: string,\n\tvary: readonly string[],\n\theaders: Headers,\n): string {\n\tif (vary.length === 0) return path;\n\treturn `${path}|${vary.map((name) => `${name}=${headers.get(name) ?? ''}`).join('|')}`;\n}\n",
|
|
11
11
|
"/** Which requests the cache answers, and what a kept response is worth now. */\nimport type { CachedResponse } from './store';\n\n/** Whether a request goes straight to the route: not a `GET` or `HEAD`, or a `no-cache` the cache honors. */\nexport function bypasses(\n\trequest: Request,\n\thonorClientNoCache: boolean,\n): boolean {\n\tif (request.method !== 'GET' && request.method !== 'HEAD') return true;\n\treturn (\n\t\thonorClientNoCache &&\n\t\t/\\bno-cache\\b/i.test(request.headers.get('cache-control') ?? '')\n\t);\n}\n\n/** A kept response is `fresh` within its ttl, `stale` within its stale window after, and worth nothing beyond. */\nexport function freshness(\n\tfound: CachedResponse,\n\tnow: number = Date.now(),\n): 'fresh' | 'stale' | undefined {\n\tconst age = now - found.storedAt;\n\tif (age < found.ttl) return 'fresh';\n\tif (age < found.ttl + found.stale) return 'stale';\n\treturn undefined;\n}\n",
|
|
12
12
|
"import type { CachedResponse } from './store';\n\n/** A kept response, or a 304 to a client that has it. */\nexport function respond(\n\trequest: Request,\n\tcached: CachedResponse,\n\tstate: string | undefined,\n): Response {\n\tconst headers = new Headers(cached.headers as [string, string][]);\n\tif (state !== undefined) {\n\t\theaders.set('x-cache', state);\n\t\theaders.set(\n\t\t\t'age',\n\t\t\tString(Math.max(0, Math.floor((Date.now() - cached.storedAt) / 1000))),\n\t\t);\n\t}\n\tconst etag = headers.get('etag');\n\tconst match = request.headers.get('if-none-match');\n\tif (etag !== null && match !== null) {\n\t\tconst weak = (tag: string) => tag.trim().replace(/^W\\//, '');\n\t\tif (\n\t\t\tmatch\n\t\t\t\t.split(',')\n\t\t\t\t.some((tag) => tag.trim() === '*' || weak(tag) === weak(etag))\n\t\t) {\n\t\t\treturn new Response(null, { status: 304, headers });\n\t\t}\n\t}\n\treturn new Response(\n\t\tcached.status === 204 ? null : (cached.body as Uint8Array<ArrayBuffer>),\n\t\t{\n\t\t\tstatus: cached.status,\n\t\t\theaders,\n\t\t},\n\t);\n}\n",
|
|
13
13
|
"/** A response as a store keeps it. */\nexport interface CachedResponse {\n\treadonly status: number;\n\treadonly headers: readonly (readonly [string, string])[];\n\treadonly body: Uint8Array;\n\t/** Milliseconds since the epoch. */\n\treadonly storedAt: number;\n\t/** Milliseconds it is fresh for, from `storedAt`. */\n\treadonly ttl: number;\n\t/** Milliseconds it may be served stale after, while it is refreshed. */\n\treadonly stale: number;\n\treadonly tags: readonly string[];\n}\n\n/**\n * Where responses are kept. The memory store keeps them in one process;\n * `@alxia/redis`'s `redisCacheStore` across every process sharing a Redis.\n */\nexport interface CacheStore {\n\tget(\n\t\tkey: string,\n\t): Promise<CachedResponse | undefined> | CachedResponse | undefined;\n\t/** Keeps `value` for `keepFor` milliseconds: its freshness and its staleness together. */\n\tset(\n\t\tkey: string,\n\t\tvalue: CachedResponse,\n\t\tkeepFor: number,\n\t): Promise<void> | void;\n\tdelete(key: string): Promise<void> | void;\n\t/** Forgets every response tagged `tag`. */\n\tdeleteTag(tag: string): Promise<void> | void;\n}\n\nexport interface MemoryCacheOptions {\n\t/** The most responses kept: the least recently read goes first. 1 000 by default. */\n\treadonly maxEntries?: number;\n\t/** The most bytes of bodies kept. 64 MiB by default. */\n\treadonly maxBytes?: number;\n}\n\n/** A least-recently-used store in one process's memory. */\nexport class MemoryCacheStore implements CacheStore {\n\treadonly #entries = new Map<\n\t\tstring,\n\t\t{ value: CachedResponse; expiresAt: number }\n\t>();\n\treadonly #tags = new Map<string, Set<string>>();\n\treadonly #maxEntries: number;\n\treadonly #maxBytes: number;\n\t#bytes = 0;\n\n\tconstructor(options: MemoryCacheOptions = {}) {\n\t\tthis.#maxEntries = options.maxEntries ?? 1_000;\n\t\tthis.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024;\n\t}\n\n\tget(key: string): CachedResponse | undefined {\n\t\tconst entry = this.#entries.get(key);\n\t\tif (entry === undefined) return undefined;\n\t\tif (entry.expiresAt <= Date.now()) {\n\t\t\tthis.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\t// Read again: the most recently used goes to the end.\n\t\tthis.#entries.delete(key);\n\t\tthis.#entries.set(key, entry);\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: CachedResponse, keepFor: number): void {\n\t\tthis.delete(key);\n\t\tif (value.body.byteLength > this.#maxBytes) return;\n\t\tthis.#entries.set(key, { value, expiresAt: Date.now() + keepFor });\n\t\tthis.#bytes += value.body.byteLength;\n\t\tfor (const tag of value.tags) {\n\t\t\tlet keys = this.#tags.get(tag);\n\t\t\tif (keys === undefined) {\n\t\t\t\tkeys = new Set();\n\t\t\t\tthis.#tags.set(tag, keys);\n\t\t\t}\n\t\t\tkeys.add(key);\n\t\t}\n\t\tfor (const oldest of this.#entries.keys()) {\n\t\t\tif (\n\t\t\t\tthis.#entries.size <= this.#maxEntries &&\n\t\t\t\tthis.#bytes <= this.#maxBytes\n\t\t\t)\n\t\t\t\tbreak;\n\t\t\tthis.delete(oldest);\n\t\t}\n\t}\n\n\tdelete(key: string): void {\n\t\tconst entry = this.#entries.get(key);\n\t\tif (entry === undefined) return;\n\t\tthis.#entries.delete(key);\n\t\tthis.#bytes -= entry.value.body.byteLength;\n\t\tfor (const tag of entry.value.tags) {\n\t\t\tconst keys = this.#tags.get(tag);\n\t\t\tkeys?.delete(key);\n\t\t\tif (keys?.size === 0) this.#tags.delete(tag);\n\t\t}\n\t}\n\n\tdeleteTag(tag: string): void {\n\t\tfor (const key of [...(this.#tags.get(tag) ?? [])]) this.delete(key);\n\t}\n\n\t/** How many responses are kept. */\n\tget size(): number {\n\t\treturn this.#entries.size;\n\t}\n}\n"
|
|
14
14
|
],
|
|
15
|
-
"mappings": ";AAAA;;;ACQO,SAAS,eAAe,GAAG;AAAA,EACjC,MAAM,WAAW,IAAI;AAAA,EACrB,OAAO;AAAA,IAEN,KAAK,CAAC,YAA0C,SAAS,IAAI,OAAO;AAAA,IAEpE,IAAI,CAAC,SAAkB;AAAA,MACtB,MAAM,UAAmB,EAAE,MAAM,CAAC,GAAG,SAAS,MAAM;AAAA,MACpD,SAAS,IAAI,SAAS,OAAO;AAAA,MAC7B,OAAO;AAAA,QACN,KAAK,IAAI,SAAmB;AAAA,UAC3B,QAAQ,KAAK,KAAK,GAAG,IAAI;AAAA;AAAA,QAE1B,MAAM,MAAM;AAAA,UACX,QAAQ,UAAU;AAAA;AAAA,MAEpB;AAAA;AAAA,EAEF;AAAA;;;ACfM,SAAS,YAAY,GAAG;AAAA,EAE9B,MAAM,UAAU,IAAI;AAAA,EACpB,OAAO;AAAA,IAEN,KAAK,CAAC,QAAyB,QAAQ,IAAI,GAAG;AAAA,IAC9C,IAAI,CACH,KACA,KACA,MACqC;AAAA,MACrC,MAAM,UAAU,QAAQ,IAAI,GAAG;AAAA,MAC/B,IAAI,YAAY,WAAW;AAAA,QAC1B,OAAO,QAAQ,KACd,CAAC,WACA,UAAU,KAAK,GAChB,MAA0C,KAAK,CAChD;AAAA,MACD;AAAA,MACA,MAAM,MAAM,IAAI;AAAA,MAChB,MAAM,SAAS,IAAI,KAAK,CAAC,YACxB,UAAU,UAAS,OAAO,OAAO,SAClC;AAAA,MACA,QAAQ,IAAI,KAAK,MAAM;AAAA,MACvB,OAAO,QAAQ,MAAM,QAAQ,OAAO,GAAG,CAAC,EAAE,MAAM,MAAM,EAAE;AAAA,MACxD,OAAO,IAAI,KAAK,CAAC,YAChB,UAAU,UAAS,OAAO,OAAO,OAAO,GACzC;AAAA;AAAA,EAEF;AAAA;AAOM,SAAS,aAAa,CAC5B,SACO;AAAA,EACP,QACE,KAAK,CAAC,WACN,kBAAkB,WAAW,OAAO,MAAM,OAAO,IAAI,SACtD,EACC,MAAM,CAAC,UAAU,QAAQ,MAAM,KAAK,CAAC;AAAA;;;ACjDjC,SAAS,UAAU,GAAG;AAAA,EAC5B,IAAI,UAAU;AAAA,EACd,OAAO,OAAU,MAA4B,aAA4B;AAAA,IACxE,IAAI;AAAA,MACH,MAAM,QAAQ,MAAM,KAAK;AAAA,MACzB,UAAU;AAAA,MACV,OAAO;AAAA,MACN,OAAO,OAAO;AAAA,MACf,IAAI,CAAC;AAAA,QAAS,QAAQ,MAAM,KAAK;AAAA,MACjC,UAAU;AAAA,MACV,OAAO;AAAA;AAAA;AAAA;;;ACdV,iBAAS;AAKT,IAAM,UAAU;AAMT,SAAS,QAAQ,CACvB,UACA,SACA,UACU;AAAA,EACV,OAAO,EACN,SAAS,WACT,CAAC,SAAS,IAAI,SAAS,MAAM,KAC7B,QAAQ,KAAK,SAAS,QAAQ,IAAI,eAAe,KAAK,EAAE,KACxD,SAAS,QAAQ,IAAI,YAAY,KACjC,SAAS,QAAQ,IAAI,cAAc,GAAG,WAAW,mBAAmB;AAAA;
|
|
16
|
-
"debugId": "
|
|
15
|
+
"mappings": "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",
|
|
16
|
+
"debugId": "4C215944C289F9AC64756E2164756E21",
|
|
17
17
|
"names": []
|
|
18
18
|
}
|
package/dist/keep.d.ts
CHANGED
|
@@ -5,6 +5,20 @@ import type { CachedResponse } from './store';
|
|
|
5
5
|
* own — no `no-store`, `private` or cookie — and not an event stream.
|
|
6
6
|
*/
|
|
7
7
|
export declare function keepable(response: Response, control: Control | undefined, statuses: ReadonlySet<number>): boolean;
|
|
8
|
+
/** Which request credentials the cache's key tells apart: a response to a request carrying one is someone's own otherwise. */
|
|
9
|
+
export interface KeyedBy {
|
|
10
|
+
/** `vary` names `authorization`: each token is a key of its own. */
|
|
11
|
+
readonly authorization: boolean;
|
|
12
|
+
/** The app gave a `key`, or `vary` names `cookie`. */
|
|
13
|
+
readonly cookie: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether the answer to `request` may be kept for others. A request carrying
|
|
17
|
+
* `Authorization` or `Cookie` is answered for whoever sent it: its response
|
|
18
|
+
* is kept only when it says it may be shared — `public`, `s-maxage` or
|
|
19
|
+
* `must-revalidate` — or when the key tells those senders apart (`keyedBy`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function shareable(request: Request, response: Response, keyedBy: KeyedBy): boolean;
|
|
8
22
|
/**
|
|
9
23
|
* The response as it is kept: its body read, `Content-Length` and `Date`
|
|
10
24
|
* dropped, a weak `ETag` of its body when it has none, and `Vary` naming
|
package/dist/keep.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"keep.d.ts","sourceRoot":"","sources":["../src/keep.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAK9C;;;GAGG;AACH,wBAAgB,QAAQ,CACvB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,GAAG,SAAS,EAC5B,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,GAC3B,OAAO,CAQT;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE;IACL,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,MAAM,EAAE,CAAC;CAC9B,GACC,OAAO,CAAC,cAAc,CAAC,CAkBzB"}
|
|
1
|
+
{"version":3,"file":"keep.d.ts","sourceRoot":"","sources":["../src/keep.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAK9C;;;GAGG;AACH,wBAAgB,QAAQ,CACvB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,GAAG,SAAS,EAC5B,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,GAC3B,OAAO,CAQT;AAQD,8HAA8H;AAC9H,MAAM,WAAW,OAAO;IACvB,oEAAoE;IACpE,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,sDAAsD;IACtD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,GACd,OAAO,CAQT;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE;IACL,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,MAAM,EAAE,CAAC;CAC9B,GACC,OAAO,CAAC,cAAc,CAAC,CAkBzB"}
|
package/docs/README.md
CHANGED
|
@@ -9,7 +9,7 @@ a realistic example for each.
|
|
|
9
9
|
| Page | Read it when |
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| [Caching responses](guide/caching.md) | adding the cache, choosing `ttl` and `staleWhileRevalidate`, knowing which responses are kept, reading `X-Cache` and `ETag`, or tagging and skipping from a route |
|
|
12
|
-
| [Keys and Vary](guide/keys-and-vary.md) | caching a response that differs by language or another header, writing a `key` of your own, keying or tagging by what an earlier
|
|
12
|
+
| [Keys and Vary](guide/keys-and-vary.md) | caching a response that differs by language or another header, writing a `key` of your own, keying or tagging by what an earlier middleware added, or keeping personal responses out |
|
|
13
13
|
| [Invalidation](guide/invalidation.md) | emptying the cache after a write, by path or by tag, across several caches or several processes |
|
|
14
14
|
| [Stores](guide/stores.md) | sizing the memory store, sharing responses in Redis, or writing and testing a store of your own |
|
|
15
15
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or the cache does not hit when you expected it to |
|
package/docs/guide/caching.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Caching responses
|
|
2
2
|
|
|
3
|
-
This page covers
|
|
3
|
+
This page covers `cache()`, a middleware: which requests it answers, which
|
|
4
4
|
responses it keeps, each option, the headers it sends, and what a route
|
|
5
5
|
behind it reads.
|
|
6
6
|
|
|
@@ -23,7 +23,7 @@ curl -i localhost:3000/products # x-cache: HIT, age: 0 — the route did not
|
|
|
23
23
|
|
|
24
24
|
## Which requests
|
|
25
25
|
|
|
26
|
-
The
|
|
26
|
+
The cache is a middleware: it applies to the routes declared **after**
|
|
27
27
|
`use(cache(…))`, in the same app or group, and to no other. Within those:
|
|
28
28
|
|
|
29
29
|
- only `GET` and `HEAD` are looked up; every other method runs the route as
|
|
@@ -35,7 +35,12 @@ The plugin is a route hook: it applies to the routes declared **after**
|
|
|
35
35
|
- a request whose [`key`](keys-and-vary.md#a-key-of-your-own) is
|
|
36
36
|
`undefined` is not looked up, nor kept;
|
|
37
37
|
- with `honorClientNoCache: true`, a request that says
|
|
38
|
-
`Cache-Control: no-cache` runs the route, and its response is not kept
|
|
38
|
+
`Cache-Control: no-cache` runs the route, and its response is not kept;
|
|
39
|
+
- given to `app.use`, it runs on a request no route matches too, and lets it
|
|
40
|
+
through: a missing path is never looked up nor kept, even with `404` in
|
|
41
|
+
[`statuses`](#which-responses-are-kept) — the store holds only what a
|
|
42
|
+
route answered, one entry per path a route serves, never one per path a
|
|
43
|
+
client made up.
|
|
39
44
|
|
|
40
45
|
## The three answers
|
|
41
46
|
|
|
@@ -94,6 +99,8 @@ A response is kept only when all of these hold:
|
|
|
94
99
|
| its status is in `statuses` (`[200]` by default) | a 500 or a 404 is not served again unless you say so |
|
|
95
100
|
| its `Cache-Control` has neither `private` nor `no-store` | the route said it belongs to one client |
|
|
96
101
|
| it sets no cookie | a `Set-Cookie` belongs to one client |
|
|
102
|
+
| the request carries no `Authorization`, or the response says `public`, `s-maxage` or `must-revalidate`, or `vary` names `authorization` | an authorized request's answer belongs to its sender (RFC 9111 §3.5) |
|
|
103
|
+
| the request carries no `Cookie`, or the response says `public`, `s-maxage` or `must-revalidate`, or `vary` names `cookie`, or the cache has a `key` of yours | the same, for a cookie session; a `key` of your own is your word that it tells users apart |
|
|
97
104
|
| it is not `text/event-stream` | a stream has no end to keep |
|
|
98
105
|
| the route did not call `cache.skip()` | the route said so |
|
|
99
106
|
|
|
@@ -118,14 +125,14 @@ const app = alxia()
|
|
|
118
125
|
); // concurrent requests: one run each, each with its own answer
|
|
119
126
|
```
|
|
120
127
|
|
|
121
|
-
A route that is always personal still belongs before the
|
|
128
|
+
A route that is always personal still belongs before the cache: it saves
|
|
122
129
|
the store lookup, and the wait on another request's run.
|
|
123
130
|
|
|
124
131
|
## Options
|
|
125
132
|
|
|
126
133
|
```ts
|
|
127
|
-
cache<Requires extends object = Empty>(options: CacheOptions<Requires>):
|
|
128
|
-
//
|
|
134
|
+
cache<Requires extends object = Empty>(options: CacheOptions<Requires>): CacheMiddleware<Requires>
|
|
135
|
+
// a middleware, given to `app.use`, which checks `Requires`; and the hands to empty it
|
|
129
136
|
```
|
|
130
137
|
|
|
131
138
|
`Requires` is what `key` and `tags` read beyond `BaseContext`, empty by
|
|
@@ -216,7 +223,7 @@ test('a client whose copy is current gets a 304', async () => {
|
|
|
216
223
|
A browser sends `If-None-Match` on its own once it has the response with
|
|
217
224
|
an `ETag`; curl does not unless you pass the header.
|
|
218
225
|
|
|
219
|
-
The
|
|
226
|
+
The cache sets no `Cache-Control` of its own: it caches on the server.
|
|
220
227
|
For a browser or a CDN to keep the response as well, the route says so —
|
|
221
228
|
`public` is not `private`, so the response is still kept here:
|
|
222
229
|
|
|
@@ -230,17 +237,37 @@ app.get('/products', ({ reply }) =>
|
|
|
230
237
|
|
|
231
238
|
A kept response keeps the route's status and headers, without
|
|
232
239
|
`Content-Length` and `Date`, with its `ETag`, and with each `vary` header
|
|
233
|
-
appended to `Vary` once. Headers that
|
|
234
|
-
|
|
235
|
-
again to every answer, from the
|
|
240
|
+
appended to `Vary` once. Headers that a middleware declared **before** the
|
|
241
|
+
cache adds on the way out (CORS, secure headers, compression, a logger's
|
|
242
|
+
request id) are not kept: they are added again to every answer, from the
|
|
243
|
+
cache or not. That is the order to use. A middleware declared **after** the
|
|
244
|
+
cache runs on a miss only, and what it adds is kept and replayed.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { alxia, defineMiddleware } from '@alxia/core';
|
|
248
|
+
import { cache } from '@alxia/cache';
|
|
249
|
+
import { cors } from '@alxia/cors';
|
|
250
|
+
|
|
251
|
+
const stamp = defineMiddleware(async (_ctx, next) => {
|
|
252
|
+
const response = await next();
|
|
253
|
+
response.headers.set('x-rendered-by', 'origin');
|
|
254
|
+
return response;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const app = alxia()
|
|
258
|
+
.use(cors()) // every answer, a hit included
|
|
259
|
+
.use(cache({ ttl: 60 }))
|
|
260
|
+
.use(stamp) // a miss only; its header is kept
|
|
261
|
+
.get('/products', ({ reply }) => reply(200, []));
|
|
262
|
+
```
|
|
236
263
|
|
|
237
264
|
## What a route reads
|
|
238
265
|
|
|
239
|
-
Every route after the
|
|
266
|
+
Every route after the cache reads `ctx.cache`:
|
|
240
267
|
|
|
241
268
|
```ts
|
|
242
269
|
interface CacheControls {
|
|
243
|
-
/** Tags the response being built, beyond the
|
|
270
|
+
/** Tags the response being built, beyond the cache's `tags`. */
|
|
244
271
|
tag(...tags: string[]): void;
|
|
245
272
|
/** Keeps this response out of the cache. */
|
|
246
273
|
skip(): void;
|
|
@@ -264,13 +291,13 @@ const app = alxia()
|
|
|
264
291
|
});
|
|
265
292
|
```
|
|
266
293
|
|
|
267
|
-
A route declared before the
|
|
294
|
+
A route declared before the cache has no `ctx.cache`; reading it is a
|
|
268
295
|
compile error ([Troubleshooting](../troubleshooting.md#property-cache-does-not-exist-on-type-context)).
|
|
269
296
|
|
|
270
297
|
## The value `cache()` returns
|
|
271
298
|
|
|
272
|
-
`cache()` returns the
|
|
273
|
-
of its store on it
|
|
299
|
+
`cache()` returns the middleware — to give to `app.use` — with the handles
|
|
300
|
+
of its store on it. Its type is `CacheMiddleware<Requires>`:
|
|
274
301
|
|
|
275
302
|
```ts
|
|
276
303
|
interface Cache {
|
|
@@ -33,8 +33,8 @@ invalidateTag(tag: string): Promise<void>
|
|
|
33
33
|
Forgets every response that carries `tag`, whatever its key. A response
|
|
34
34
|
carries:
|
|
35
35
|
|
|
36
|
-
- the tags of the
|
|
37
|
-
`cache<{ user: User }>(…)` lets it read what an earlier
|
|
36
|
+
- the tags of the cache's `tags(ctx)`, computed for each response kept —
|
|
37
|
+
`cache<{ user: User }>(…)` lets it read what an earlier middleware added
|
|
38
38
|
([Reading the app's context](keys-and-vary.md#reading-the-apps-context));
|
|
39
39
|
- the tags its route added with `ctx.cache.tag(…)`.
|
|
40
40
|
|
|
@@ -96,7 +96,7 @@ import { pathTag } from '@alxia/cache';
|
|
|
96
96
|
pathTag('/api/products?page=2'); // 'alxia:path:/api/products?page=2'
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
Tags starting `alxia:` are the
|
|
99
|
+
Tags starting `alxia:` are the cache's: do not give one of yours that
|
|
100
100
|
prefix. A store of your own must remember each response's `tags`, or
|
|
101
101
|
`invalidate` reaches nothing ([Writing a store](stores.md#writing-a-store)).
|
|
102
102
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Keys and Vary
|
|
2
2
|
|
|
3
3
|
This page covers what makes two requests "the same" to the cache: the
|
|
4
|
-
default key, `vary`, a `key` of your own, reading what an earlier
|
|
4
|
+
default key, `vary`, a `key` of your own, reading what an earlier middleware
|
|
5
5
|
added, and keeping personal responses out.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
@@ -85,7 +85,7 @@ key?: (ctx: BaseContext & Requires) => string | undefined;
|
|
|
85
85
|
|
|
86
86
|
`key` replaces the default key whole. It is synchronous and reads the
|
|
87
87
|
`BaseContext` — `request`, `url`, `ip`, `server`, `route`, `pathParams` —
|
|
88
|
-
and `Requires`, empty by default. To key by what an earlier
|
|
88
|
+
and `Requires`, empty by default. To key by what an earlier middleware added,
|
|
89
89
|
see [Reading the app's context](#reading-the-apps-context).
|
|
90
90
|
|
|
91
91
|
Keyed by the language the route actually answers in, two visitors who both
|
|
@@ -143,10 +143,10 @@ not by key.
|
|
|
143
143
|
|
|
144
144
|
## Reading the app's context
|
|
145
145
|
|
|
146
|
-
To key or tag by what an earlier
|
|
146
|
+
To key or tag by what an earlier middleware added, such as a signed-in `user`
|
|
147
147
|
and its tenant, name it as `cache`'s type argument. `key` and `tags` then
|
|
148
|
-
read it, and the cache
|
|
149
|
-
|
|
148
|
+
read it, and the app that uses the cache must give it first: an app that
|
|
149
|
+
does not give `user` before it cannot use it.
|
|
150
150
|
|
|
151
151
|
```ts
|
|
152
152
|
const perTenant = cache<{ user: { tenantId: string } }>({
|
|
@@ -156,18 +156,18 @@ const perTenant = cache<{ user: { tenantId: string } }>({
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
const auth = alxia().derive(({ request }) => ({
|
|
159
|
-
user: { tenantId: request.headers.get('x-tenant') ?? 'public' }, // your session
|
|
159
|
+
user: { tenantId: request.headers.get('x-tenant') ?? 'public' }, // your session middleware
|
|
160
160
|
}));
|
|
161
161
|
|
|
162
162
|
const app = alxia()
|
|
163
|
-
.
|
|
163
|
+
.plugin(auth)
|
|
164
164
|
.use(perTenant)
|
|
165
165
|
.get('/dashboard', ({ user, reply }) => reply(200, { tenant: user.tenantId }));
|
|
166
166
|
|
|
167
167
|
await perTenant.invalidateTag('tenant:acme'); // one tenant's pages, every path
|
|
168
168
|
|
|
169
169
|
alxia().use(perTenant);
|
|
170
|
-
// error:
|
|
170
|
+
// error: Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: { tenantId: string; }; }'
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
The rule of [a key of your own](#a-key-of-your-own) still holds: the route
|
|
@@ -176,10 +176,25 @@ personal.
|
|
|
176
176
|
|
|
177
177
|
## Personal responses
|
|
178
178
|
|
|
179
|
-
The default key does not read who is asking
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
179
|
+
The default key does not read who is asking, so the cache reads the
|
|
180
|
+
request instead (RFC 9111 §3.5): the answer to a request carrying
|
|
181
|
+
`Authorization` or `Cookie` is never kept, unless
|
|
182
|
+
|
|
183
|
+
- the response says it may be shared: `Cache-Control: public`, `s-maxage`
|
|
184
|
+
or `must-revalidate`;
|
|
185
|
+
- `vary` names that header, so each value is a key of its own;
|
|
186
|
+
- for `Cookie` only, the cache has a `key` of yours: your word that the key
|
|
187
|
+
tells users apart, as `perTenant` above does.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
cache({ ttl: 60 }); // /me with a bearer token: runs for every caller, no X-Cache
|
|
191
|
+
cache({ ttl: 60, vary: ['authorization'] }); // kept per token
|
|
192
|
+
cache<{ user: { id: string } }>({ ttl: 60, key: ({ user, url }) => `${user.id}:${url.pathname}` }); // a cookie session, kept per user
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A `key` of yours that does not read the user, behind a cookie session,
|
|
196
|
+
serves the first visitor's answer to every later one; so does a credential
|
|
197
|
+
the cache does not know — an `X-Api-Key` header, a token in the query.
|
|
183
198
|
|
|
184
199
|
A personal response that says so is never kept, nor shared with a
|
|
185
200
|
concurrent request: it answers `Cache-Control: private` (or `no-store`),
|
package/docs/guide/stores.md
CHANGED
|
@@ -15,9 +15,9 @@ const app = alxia()
|
|
|
15
15
|
.get('/products', ({ reply }) => reply(200, []));
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
The
|
|
18
|
+
The cache does not know which store it was given: every store answers the
|
|
19
19
|
same `CacheStore` contract, and freshness — `ttl`, `staleWhileRevalidate` —
|
|
20
|
-
is decided by the
|
|
20
|
+
is decided by the cache, not the store.
|
|
21
21
|
|
|
22
22
|
## In memory: `MemoryCacheStore`
|
|
23
23
|
|
|
@@ -128,7 +128,7 @@ interface CachedResponse {
|
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
What the
|
|
131
|
+
What the cache relies on:
|
|
132
132
|
|
|
133
133
|
| Method | Must |
|
|
134
134
|
| --- | --- |
|
|
@@ -137,7 +137,7 @@ What the plugin relies on:
|
|
|
137
137
|
| `delete` | forget `key`; a key that is not there is not an error |
|
|
138
138
|
| `deleteTag` | forget every key whose response carries `tag` |
|
|
139
139
|
|
|
140
|
-
`get` need not check freshness: the
|
|
140
|
+
`get` need not check freshness: the cache reads `storedAt`, `ttl` and
|
|
141
141
|
`stale` itself. Expiring at `keepFor` only bounds what the store holds.
|
|
142
142
|
|
|
143
143
|
A store over any key-value service — here a plain `Map`, standing in for
|
|
@@ -191,7 +191,7 @@ is `deleteTag` of it. A store that drops `tags` leaves `invalidate` and
|
|
|
191
191
|
|
|
192
192
|
### Testing a store
|
|
193
193
|
|
|
194
|
-
The
|
|
194
|
+
The cache's own behaviour is the best test of a store: run an app on it.
|
|
195
195
|
|
|
196
196
|
```ts
|
|
197
197
|
import { expect, test } from 'bun:test';
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,10 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **A middleware, not a plugin.** `app.use(cache({ ttl }))` is the form;
|
|
11
|
+
`app.plugin(cache(…))` keeps working, deprecated. Stale responses are
|
|
12
|
+
served at once with the refresh run behind them, and `CacheMiddleware<Requires>`
|
|
13
|
+
names what `cache()` returns.
|
|
11
14
|
|
|
12
15
|
## Next
|
|
13
16
|
|
|
@@ -33,7 +36,7 @@ Nothing scheduled yet.
|
|
|
33
36
|
|
|
34
37
|
### 0.1.0
|
|
35
38
|
|
|
36
|
-
- **Response caching as a plugin.** `
|
|
39
|
+
- **Response caching as a plugin.** `plugin(cache({ ttl }))` answers the `GET`
|
|
37
40
|
and `HEAD` requests of every route declared after it from a store while
|
|
38
41
|
they are fresh, and from the route otherwise, saying `X-Cache: HIT` or
|
|
39
42
|
`MISS` and `Age`.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -36,6 +36,7 @@ goes wrong prints nothing at all, and is under [Traps](#traps), by symptom.
|
|
|
36
36
|
- [Old data after a write](#old-data-after-a-write)
|
|
37
37
|
- [A hard reload shows new data, a plain reload the old](#a-hard-reload-shows-new-data-a-plain-reload-the-old)
|
|
38
38
|
- [Responses are kept for hours](#responses-are-kept-for-hours)
|
|
39
|
+
- [A header is missing from a cached answer, or repeated in it](#a-header-is-missing-from-a-cached-answer-or-repeated-in-it)
|
|
39
40
|
|
|
40
41
|
## Types
|
|
41
42
|
|
|
@@ -66,10 +67,10 @@ declared before `use(cache(…))`.
|
|
|
66
67
|
error TS2339: Property 'cache' does not exist on type 'Context<Empty, "/x", Empty>'.
|
|
67
68
|
```
|
|
68
69
|
|
|
69
|
-
**Why:** the
|
|
70
|
+
**Why:** the cache is a middleware: it applies to, and adds `cache` to, the
|
|
70
71
|
routes declared after it. The route before it is not cached either.
|
|
71
72
|
|
|
72
|
-
**Fix:** declare the route after the
|
|
73
|
+
**Fix:** declare the route after the cache:
|
|
73
74
|
|
|
74
75
|
```ts
|
|
75
76
|
alxia()
|
|
@@ -93,14 +94,14 @@ error TS2339: Property 'users' does not exist on type 'CacheControls'.
|
|
|
93
94
|
At run time, without a typecheck, the route answers a 500 with
|
|
94
95
|
`TypeError: undefined is not an object (evaluating 'cache.users.remember')`.
|
|
95
96
|
|
|
96
|
-
**Why:** `ctx.cache` is this
|
|
97
|
+
**Why:** `ctx.cache` is this middleware's controls, `{ tag, skip }`, and
|
|
97
98
|
nothing else.
|
|
98
99
|
|
|
99
|
-
**Fix:** read the other plugin's name for it:
|
|
100
|
+
**Fix:** read the other plugin's name for it (`redis()` is still a plugin, given to `app.plugin`):
|
|
100
101
|
|
|
101
102
|
```ts
|
|
102
103
|
alxia()
|
|
103
|
-
.
|
|
104
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
104
105
|
.use(cache({ ttl: 60 }))
|
|
105
106
|
.get('/users/:id', async ({ caches, cache, params, reply }) => {
|
|
106
107
|
cache.tag(`user:${params.id}`);
|
|
@@ -111,7 +112,7 @@ alxia()
|
|
|
111
112
|
### `Property 'user' does not exist on type 'BaseContext & Empty'`
|
|
112
113
|
|
|
113
114
|
**When:** a `key` or `tags` function reads something an earlier `derive`,
|
|
114
|
-
`decorate` or
|
|
115
|
+
`decorate` or middleware added to the context, and `cache` is not told about it.
|
|
115
116
|
|
|
116
117
|
```text
|
|
117
118
|
error TS2339: Property 'user' does not exist on type 'BaseContext & Empty'.
|
|
@@ -136,13 +137,13 @@ const auth = alxia().derive(({ request }) => ({
|
|
|
136
137
|
user: { tenantId: request.headers.get('x-tenant') ?? 'public' },
|
|
137
138
|
}));
|
|
138
139
|
|
|
139
|
-
alxia().
|
|
140
|
+
alxia().plugin(auth).use(perTenant); // auth derives user
|
|
140
141
|
```
|
|
141
142
|
|
|
142
143
|
On an app that does not give `user`, `use(perTenant)` is a compile error:
|
|
143
|
-
|
|
144
|
-
or
|
|
145
|
-
|
|
144
|
+
`Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: { tenantId: string; }; }'`,
|
|
145
|
+
or `Types of property 'user' are incompatible` when its `user` is not
|
|
146
|
+
`{ tenantId: string }`.
|
|
146
147
|
See [Reading the app's context](guide/keys-and-vary.md#reading-the-apps-context).
|
|
147
148
|
|
|
148
149
|
To tag by what only the route knows — the product it loaded — tag from the
|
|
@@ -233,7 +234,7 @@ error TS2322: Type '(_key: string) => CachedResponse | null' is not assignable t
|
|
|
233
234
|
Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'.
|
|
234
235
|
```
|
|
235
236
|
|
|
236
|
-
**Why:** the
|
|
237
|
+
**Why:** the cache treats `undefined` as a miss; it would read `null` as a
|
|
237
238
|
response.
|
|
238
239
|
|
|
239
240
|
**Fix:** turn the client's `null` into `undefined`:
|
|
@@ -325,8 +326,9 @@ fails is what stale-while-revalidate is for. Fix the route; keep
|
|
|
325
326
|
| Cause | Fix |
|
|
326
327
|
| --- | --- |
|
|
327
328
|
| the route is declared before `use(cache(…))` | declare it after |
|
|
328
|
-
| the response sets a cookie — a session
|
|
329
|
+
| the response sets a cookie — a session middleware that touches every response, say | move the routes that set it before the cache, or stop it setting a cookie on public pages |
|
|
329
330
|
| the response says `Cache-Control: private` or `no-store` | intended: it is personal |
|
|
331
|
+
| the request carries `Authorization` or `Cookie` | see [below](#no-x-cache-header-on-a-request-with-authorization-or-cookie) |
|
|
330
332
|
| its status is not in `statuses` (`[200]`) | `statuses: [200, 404]` |
|
|
331
333
|
| it is `text/event-stream` | intended: a stream is never kept |
|
|
332
334
|
| the route called `cache.skip()` | intended |
|
|
@@ -336,6 +338,34 @@ fails is what stale-while-revalidate is for. Fix the route; keep
|
|
|
336
338
|
| `honorClientNoCache: true`, and the request said `Cache-Control: no-cache` | see [below](#a-hard-reload-shows-new-data-a-plain-reload-the-old) |
|
|
337
339
|
| `debugHeaders: false` | the cache works; it just does not say so |
|
|
338
340
|
|
|
341
|
+
### No `X-Cache` header on a request with `Authorization` or `Cookie`
|
|
342
|
+
|
|
343
|
+
**When:** the same route hits from curl with no credential, and a request
|
|
344
|
+
with a bearer token or a browser's cookies gets no `X-Cache` and runs the
|
|
345
|
+
route every time.
|
|
346
|
+
|
|
347
|
+
**Why:** the answer to a request carrying `Authorization` or `Cookie` is
|
|
348
|
+
taken for that sender's own (RFC 9111 §3.5), and never kept, unless the
|
|
349
|
+
response says it may be shared or the key tells senders apart. Without it,
|
|
350
|
+
the first caller's `/me` would be served to every later one.
|
|
351
|
+
|
|
352
|
+
**Fix:** when the response really is the same for everyone, say so; when it
|
|
353
|
+
is per user, key by the user:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
// The same for every caller: kept, and served to all.
|
|
357
|
+
app.get('/catalogue', ({ reply }) => reply(200, items, { headers: { 'cache-control': 'public, max-age=60' } }));
|
|
358
|
+
|
|
359
|
+
// Per token: each Authorization value is a key of its own.
|
|
360
|
+
cache({ ttl: 60, vary: ['authorization'] });
|
|
361
|
+
|
|
362
|
+
// Per user, for a cookie session: a key of your own lifts the Cookie rule.
|
|
363
|
+
cache<{ user: { id: string } }>({ ttl: 60, key: ({ user, url }) => `${user.id}:${url.pathname}${url.search}` });
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
A `key` does not lift the `Authorization` rule: name it in `vary`, or answer
|
|
367
|
+
`public`, `s-maxage` or `must-revalidate`.
|
|
368
|
+
|
|
339
369
|
### `X-Cache: MISS` on every request
|
|
340
370
|
|
|
341
371
|
**When:** the response is computed, kept, and the next request misses
|
|
@@ -413,13 +443,18 @@ app.use(cache({ ttl: 60, vary: ['accept-encoding'] }))
|
|
|
413
443
|
|
|
414
444
|
### One visitor sees another visitor's page
|
|
415
445
|
|
|
416
|
-
**When:** a route that answers by who is asking
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
a
|
|
446
|
+
**When:** a route that answers by who is asking is behind the cache, its
|
|
447
|
+
response says nothing about it, and the cache cannot tell: a `key` of your
|
|
448
|
+
own that does not read the user, behind a cookie session; a credential the
|
|
449
|
+
cache does not know — an `X-Api-Key` header, a token in the query; or a
|
|
450
|
+
response that says `public`. curl, with no credential, shows nothing wrong;
|
|
451
|
+
a signed-in client does.
|
|
420
452
|
|
|
421
|
-
**Why:** the default key is the path and query only.
|
|
422
|
-
|
|
453
|
+
**Why:** the default key is the path and query only. A request carrying
|
|
454
|
+
`Authorization` or `Cookie` is not kept by default
|
|
455
|
+
([Personal responses](guide/keys-and-vary.md#personal-responses)), but a
|
|
456
|
+
`key` of yours lifts the `Cookie` rule, and any other credential is not
|
|
457
|
+
seen. The first visitor's response is kept, and served to everyone after.
|
|
423
458
|
|
|
424
459
|
**Fix:** say the response is personal — it is then never kept, nor handed
|
|
425
460
|
to a concurrent request:
|
|
@@ -489,3 +524,23 @@ not to refresh it.
|
|
|
489
524
|
```ts
|
|
490
525
|
cache({ ttl: 60, staleWhileRevalidate: 300 }); // one minute fresh, five more stale
|
|
491
526
|
```
|
|
527
|
+
|
|
528
|
+
### A header is missing from a cached answer, or repeated in it
|
|
529
|
+
|
|
530
|
+
**When:** a header another middleware sets (CORS, a request id, secure
|
|
531
|
+
headers) is absent on a hit, or a hit replays a value from a long-gone
|
|
532
|
+
request.
|
|
533
|
+
|
|
534
|
+
**Why:** a middleware declared **after** the cache runs on a miss only, and
|
|
535
|
+
what it adds to the response is kept and replayed. One declared **before**
|
|
536
|
+
it runs on every answer, hit or not, on the way out, and its headers are
|
|
537
|
+
not kept.
|
|
538
|
+
|
|
539
|
+
**Fix:** declare what must be on every answer before the cache:
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
const app = alxia()
|
|
543
|
+
.use(cors()) // every answer, a hit included
|
|
544
|
+
.use(cache({ ttl: 60 }))
|
|
545
|
+
.get('/products', ({ reply }) => reply(200, []));
|
|
546
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/cache",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "HTTP response caching for alxia: TTL, stale-while-revalidate, one load for concurrent misses, tags, ETags — in memory, or in Redis with @alxia/redis",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,12 +41,11 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/
|
|
45
|
-
"@alxia/core": "^0.2.0",
|
|
44
|
+
"@alxia/core": "^0.4.0",
|
|
46
45
|
"@types/bun": "^1.4.2"
|
|
47
46
|
},
|
|
48
47
|
"peerDependencies": {
|
|
49
|
-
"@alxia/core": "^0.
|
|
48
|
+
"@alxia/core": "^0.4.0",
|
|
50
49
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
51
50
|
}
|
|
52
51
|
}
|