@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 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 plugin.
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 plugin added names it as the
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 plugin
105
+ const auth = alxia().derive(() => ({ user: { tenantId: 'acme' } })); // your session middleware
92
106
 
93
- alxia().use(auth).use(perTenant); // compiles: auth derives user
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 plugin adds |
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 plugin, with `invalidate(path)`, `invalidateTag(tag)` and `store`; routes after it read `cache.tag()` and `cache.skip()` |
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 plugin's handles, and what the routes behind it read |
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 plugin adds — and what the app that
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 plugin's `tags`. Tags starting `alxia:` are the plugin's own. */
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
- * Responses kept and served again, as a plugin: a `GET` to a route declared
54
- * after it is answered from the store while fresh, and from the route
55
- * otherwise. Concurrent misses run the route once. Stale, it is served at
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 plugin added names it, and
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>): import("@alxia/core").Alxia<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
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAQzE,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,8GAA8G;IAC9G,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;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,KAAK,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,EACpD,OAAO,EAAE,YAAY,CAAC,QAAQ,CAAC;;yEA8E/B"}
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
@@ -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 { definePlugin } from "@alxia/core";
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 = options.store ?? new MemoryCacheStore;
202
- const ttl = options.ttl * 1000;
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 plugin = definePlugin()((app) => app.derive(({ request }) => {
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 key = bypasses(request, honorNoCache) ? undefined : keyOf(ctx);
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(plugin, handlesOf(store));
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=01EFC24056EEAC7464756E2164756E21
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 { type BaseContext, definePlugin, type Empty } from '@alxia/core';\nimport { requestControls } from './control';\nimport { type Loaded, refreshBehind, singleFlight } from './flight';\nimport { storeGuard } from './guard';\nimport { keepable, 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 plugin 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 plugin's `tags`. Tags starting `alxia:` are the plugin'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 * Responses kept and served again, as a plugin: a `GET` to a route declared\n * after it is answered from the store while fresh, and from the route\n * 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.\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 plugin 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) {\n\tconst store = options.store ?? new MemoryCacheStore();\n\tconst ttl = options.ttl * 1000;\n\tconst stale = (options.staleWhileRevalidate ?? 0) * 1000;\n\tconst vary = (options.vary ?? []).map((name) => name.toLowerCase());\n\tconst statuses = new Set(options.statuses ?? [200]);\n\tconst debug = options.debugHeaders ?? true;\n\tconst honorNoCache = options.honorClientNoCache ?? false;\n\tconst keyOf =\n\t\toptions.key ??\n\t\t((ctx: BaseContext & Requires) =>\n\t\t\tdefaultKey(\n\t\t\t\t`${ctx.url.pathname}${ctx.url.search}`,\n\t\t\t\tvary,\n\t\t\t\tctx.request.headers,\n\t\t\t));\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 (!keepable(response, control, statuses)) return { own: response };\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 plugin = definePlugin<Requires>()((app) =>\n\t\tapp\n\t\t\t.derive(({ request }) => {\n\t\t\t\tconst cacheControls: CacheControls = controls.open(request);\n\t\t\t\treturn { cache: cacheControls };\n\t\t\t})\n\t\t\t.wrap(async (ctx, next) => {\n\t\t\t\tconst { request } = ctx;\n\t\t\t\tconst key = bypasses(request, honorNoCache) ? undefined : keyOf(ctx);\n\t\t\t\tif (key === undefined) return next();\n\n\t\t\t\tconst found = await attempt(() => store.get(key), undefined);\n\t\t\t\tconst worth = found === undefined ? undefined : freshness(found);\n\t\t\t\tif (found !== undefined && worth === 'fresh') {\n\t\t\t\t\treturn respond(request, found, label('HIT'));\n\t\t\t\t}\n\t\t\t\tif (found !== undefined && worth === 'stale') {\n\t\t\t\t\tif (!flight.has(key)) refreshBehind(load(key, ctx, next));\n\t\t\t\t\treturn respond(request, found, label('STALE'));\n\t\t\t\t}\n\t\t\t\tconst loaded = await load(key, ctx, next);\n\t\t\t\tif (loaded instanceof Response) return loaded;\n\t\t\t\treturn respond(request, loaded, label('MISS'));\n\t\t\t}),\n\t);\n\n\treturn Object.assign(plugin, handlesOf(store));\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",
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;AAStE,eAAsB,QAAQ,CAC7B,UACA,MAM0B;AAAA,EAC1B,MAAM,OAAO,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;AAAA,EACxD,MAAM,UAAU,IAAI,QAAQ,SAAS,OAAO;AAAA,EAC5C,QAAQ,OAAO,gBAAgB;AAAA,EAC/B,QAAQ,OAAO,MAAM;AAAA,EACrB,IAAI,CAAC,QAAQ,IAAI,MAAM,GAAG;AAAA,IACzB,QAAQ,IAAI,QAAQ,MAAM,IAAI,KAAK,IAAI,EAAE,SAAS,EAAE,IAAI;AAAA,EACzD;AAAA,EACA,WAAW,QAAQ,KAAK;AAAA,IAAM,QAAQ,SAAS,IAAI;AAAA,EACnD,OAAO;AAAA,IACN,QAAQ,SAAS;AAAA,IACjB,SAAS,CAAC,GAAG,OAAO;AAAA,IACpB;AAAA,IACA,UAAU,KAAK,IAAI;AAAA,IACnB,KAAK,KAAK;AAAA,IACV,OAAO,KAAK;AAAA,IACZ,MAAM,KAAK,KAAK;AAAA,EACjB;AAAA;;;AClDM,IAAM,UAAU,CAAC,SAAyB,cAAc;AAGxD,SAAS,UAAU,CACzB,MACA,MACA,SACS;AAAA,EACT,IAAI,KAAK,WAAW;AAAA,IAAG,OAAO;AAAA,EAC9B,OAAO,GAAG,QAAQ,KAAK,IAAI,CAAC,SAAS,GAAG,QAAQ,QAAQ,IAAI,IAAI,KAAK,IAAI,EAAE,KAAK,GAAG;AAAA;;;ACX7E,SAAS,QAAQ,CACvB,SACA,oBACU;AAAA,EACV,IAAI,QAAQ,WAAW,SAAS,QAAQ,WAAW;AAAA,IAAQ,OAAO;AAAA,EAClE,OACC,sBACA,gBAAgB,KAAK,QAAQ,QAAQ,IAAI,eAAe,KAAK,EAAE;AAAA;AAK1D,SAAS,SAAS,CACxB,OACA,MAAc,KAAK,IAAI,GACS;AAAA,EAChC,MAAM,MAAM,MAAM,MAAM;AAAA,EACxB,IAAI,MAAM,MAAM;AAAA,IAAK,OAAO;AAAA,EAC5B,IAAI,MAAM,MAAM,MAAM,MAAM;AAAA,IAAO,OAAO;AAAA,EAC1C;AAAA;;;ACpBM,SAAS,OAAO,CACtB,SACA,QACA,OACW;AAAA,EACX,MAAM,UAAU,IAAI,QAAQ,OAAO,OAA6B;AAAA,EAChE,IAAI,UAAU,WAAW;AAAA,IACxB,QAAQ,IAAI,WAAW,KAAK;AAAA,IAC5B,QAAQ,IACP,OACA,OAAO,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,YAAY,IAAI,CAAC,CAAC,CACtE;AAAA,EACD;AAAA,EACA,MAAM,OAAO,QAAQ,IAAI,MAAM;AAAA,EAC/B,MAAM,QAAQ,QAAQ,QAAQ,IAAI,eAAe;AAAA,EACjD,IAAI,SAAS,QAAQ,UAAU,MAAM;AAAA,IACpC,MAAM,OAAO,CAAC,QAAgB,IAAI,KAAK,EAAE,QAAQ,QAAQ,EAAE;AAAA,IAC3D,IACC,MACE,MAAM,GAAG,EACT,KAAK,CAAC,QAAQ,IAAI,KAAK,MAAM,OAAO,KAAK,GAAG,MAAM,KAAK,IAAI,CAAC,GAC7D;AAAA,MACD,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,QAAQ,CAAC;AAAA,IACnD;AAAA,EACD;AAAA,EACA,OAAO,IAAI,SACV,OAAO,WAAW,MAAM,OAAQ,OAAO,MACvC;AAAA,IACC,QAAQ,OAAO;AAAA,IACf;AAAA,EACD,CACD;AAAA;;;ACOM,MAAM,iBAAuC;AAAA,EAC1C,WAAW,IAAI;AAAA,EAIf,QAAQ,IAAI;AAAA,EACZ;AAAA,EACA;AAAA,EACT,SAAS;AAAA,EAET,WAAW,CAAC,UAA8B,CAAC,GAAG;AAAA,IAC7C,KAAK,cAAc,QAAQ,cAAc;AAAA,IACzC,KAAK,YAAY,QAAQ,YAAY,KAAK,OAAO;AAAA;AAAA,EAGlD,GAAG,CAAC,KAAyC;AAAA,IAC5C,MAAM,QAAQ,KAAK,SAAS,IAAI,GAAG;AAAA,IACnC,IAAI,UAAU;AAAA,MAAW;AAAA,IACzB,IAAI,MAAM,aAAa,KAAK,IAAI,GAAG;AAAA,MAClC,KAAK,OAAO,GAAG;AAAA,MACf;AAAA,IACD;AAAA,IAEA,KAAK,SAAS,OAAO,GAAG;AAAA,IACxB,KAAK,SAAS,IAAI,KAAK,KAAK;AAAA,IAC5B,OAAO,MAAM;AAAA;AAAA,EAGd,GAAG,CAAC,KAAa,OAAuB,SAAuB;AAAA,IAC9D,KAAK,OAAO,GAAG;AAAA,IACf,IAAI,MAAM,KAAK,aAAa,KAAK;AAAA,MAAW;AAAA,IAC5C,KAAK,SAAS,IAAI,KAAK,EAAE,OAAO,WAAW,KAAK,IAAI,IAAI,QAAQ,CAAC;AAAA,IACjE,KAAK,UAAU,MAAM,KAAK;AAAA,IAC1B,WAAW,OAAO,MAAM,MAAM;AAAA,MAC7B,IAAI,OAAO,KAAK,MAAM,IAAI,GAAG;AAAA,MAC7B,IAAI,SAAS,WAAW;AAAA,QACvB,OAAO,IAAI;AAAA,QACX,KAAK,MAAM,IAAI,KAAK,IAAI;AAAA,MACzB;AAAA,MACA,KAAK,IAAI,GAAG;AAAA,IACb;AAAA,IACA,WAAW,UAAU,KAAK,SAAS,KAAK,GAAG;AAAA,MAC1C,IACC,KAAK,SAAS,QAAQ,KAAK,eAC3B,KAAK,UAAU,KAAK;AAAA,QAEpB;AAAA,MACD,KAAK,OAAO,MAAM;AAAA,IACnB;AAAA;AAAA,EAGD,MAAM,CAAC,KAAmB;AAAA,IACzB,MAAM,QAAQ,KAAK,SAAS,IAAI,GAAG;AAAA,IACnC,IAAI,UAAU;AAAA,MAAW;AAAA,IACzB,KAAK,SAAS,OAAO,GAAG;AAAA,IACxB,KAAK,UAAU,MAAM,MAAM,KAAK;AAAA,IAChC,WAAW,OAAO,MAAM,MAAM,MAAM;AAAA,MACnC,MAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAAA,MAC/B,MAAM,OAAO,GAAG;AAAA,MAChB,IAAI,MAAM,SAAS;AAAA,QAAG,KAAK,MAAM,OAAO,GAAG;AAAA,IAC5C;AAAA;AAAA,EAGD,SAAS,CAAC,KAAmB;AAAA,IAC5B,WAAW,OAAO,CAAC,GAAI,KAAK,MAAM,IAAI,GAAG,KAAK,CAAC,CAAE;AAAA,MAAG,KAAK,OAAO,GAAG;AAAA;AAAA,MAIhE,IAAI,GAAW;AAAA,IAClB,OAAO,KAAK,SAAS;AAAA;AAEvB;;;AR/BO,SAAS,KAAsC,CACrD,SACC;AAAA,EACD,MAAM,QAAQ,QAAQ,SAAS,IAAI;AAAA,EACnC,MAAM,MAAM,QAAQ,MAAM;AAAA,EAC1B,MAAM,SAAS,QAAQ,wBAAwB,KAAK;AAAA,EACpD,MAAM,QAAQ,QAAQ,QAAQ,CAAC,GAAG,IAAI,CAAC,SAAS,KAAK,YAAY,CAAC;AAAA,EAClE,MAAM,WAAW,IAAI,IAAI,QAAQ,YAAY,CAAC,GAAG,CAAC;AAAA,EAClD,MAAM,QAAQ,QAAQ,gBAAgB;AAAA,EACtC,MAAM,eAAe,QAAQ,sBAAsB;AAAA,EACnD,MAAM,QACL,QAAQ,QACP,CAAC,QACD,WACC,GAAG,IAAI,IAAI,WAAW,IAAI,IAAI,UAC9B,MACA,IAAI,QAAQ,OACb;AAAA,EACF,MAAM,WAAW,gBAAgB;AAAA,EACjC,MAAM,UAAU,WAAW;AAAA,EAC3B,MAAM,SAAS,aAAa;AAAA,EAG5B,MAAM,aAAa,OAClB,KACA,KACA,SACqB;AAAA,IACrB,MAAM,WAAW,MAAM,KAAK;AAAA,IAC5B,MAAM,UAAU,SAAS,IAAI,IAAI,OAAO;AAAA,IACxC,IAAI,CAAC,SAAS,UAAU,SAAS,QAAQ;AAAA,MAAG,OAAO,EAAE,KAAK,SAAS;AAAA,IACnE,MAAM,SAAS,MAAM,SAAS,UAAU;AAAA,MACvC;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM,MAAM;AAAA,QACX,GAAI,QAAQ,OAAO,GAAG,KAAK,CAAC;AAAA,QAC5B,GAAI,SAAS,QAAQ,CAAC;AAAA,QACtB,QAAQ,GAAG,IAAI,IAAI,WAAW,IAAI,IAAI,QAAQ;AAAA,MAC/C;AAAA,IACD,CAAC;AAAA,IACD,MAAM,QAAQ,MAAM,MAAM,IAAI,KAAK,QAAQ,MAAM,KAAK,GAAG,SAAS;AAAA,IAClE,OAAO,EAAE,MAAM,OAAO;AAAA;AAAA,EAEvB,MAAM,OAAO,CACZ,KACA,KACA,SACI,OAAO,KAAK,KAAK,MAAM,WAAW,KAAK,KAAK,IAAI,GAAG,IAAI;AAAA,EAE5D,MAAM,QAAQ,CAAC,SAAoC,QAAQ,OAAO;AAAA,EAElE,MAAM,SAAS,aAAuB,EAAE,CAAC,QACxC,IACE,OAAO,GAAG,cAAc;AAAA,IACxB,MAAM,gBAA+B,SAAS,KAAK,OAAO;AAAA,IAC1D,OAAO,EAAE,OAAO,cAAc;AAAA,GAC9B,EACA,KAAK,OAAO,KAAK,SAAS;AAAA,IAC1B,QAAQ,YAAY;AAAA,IACpB,MAAM,MAAM,SAAS,SAAS,YAAY,IAAI,YAAY,MAAM,GAAG;AAAA,IACnE,IAAI,QAAQ;AAAA,MAAW,OAAO,KAAK;AAAA,IAEnC,MAAM,QAAQ,MAAM,QAAQ,MAAM,MAAM,IAAI,GAAG,GAAG,SAAS;AAAA,IAC3D,MAAM,QAAQ,UAAU,YAAY,YAAY,UAAU,KAAK;AAAA,IAC/D,IAAI,UAAU,aAAa,UAAU,SAAS;AAAA,MAC7C,OAAO,QAAQ,SAAS,OAAO,MAAM,KAAK,CAAC;AAAA,IAC5C;AAAA,IACA,IAAI,UAAU,aAAa,UAAU,SAAS;AAAA,MAC7C,IAAI,CAAC,OAAO,IAAI,GAAG;AAAA,QAAG,cAAc,KAAK,KAAK,KAAK,IAAI,CAAC;AAAA,MACxD,OAAO,QAAQ,SAAS,OAAO,MAAM,OAAO,CAAC;AAAA,IAC9C;AAAA,IACA,MAAM,SAAS,MAAM,KAAK,KAAK,KAAK,IAAI;AAAA,IACxC,IAAI,kBAAkB;AAAA,MAAU,OAAO;AAAA,IACvC,OAAO,QAAQ,SAAS,QAAQ,MAAM,MAAM,CAAC;AAAA,GAC7C,CACH;AAAA,EAEA,OAAO,OAAO,OAAO,QAAQ,UAAU,KAAK,CAAC;AAAA;AAI9C,SAAS,SAAS,CAAC,OAA0B;AAAA,EAC5C,OAAO;AAAA,IACN;AAAA,IACA,YAAY,OAAO,SAAS;AAAA,MAC3B,MAAM,MAAM,UAAU,QAAQ,IAAI,CAAC;AAAA;AAAA,IAEpC,eAAe,OAAO,QAAQ;AAAA,MAC7B,MAAM,MAAM,UAAU,GAAG;AAAA;AAAA,EAE3B;AAAA;",
16
- "debugId": "01EFC24056EEAC7464756E2164756E21",
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
@@ -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 plugin added, or keeping personal responses out |
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 |
@@ -1,6 +1,6 @@
1
1
  # Caching responses
2
2
 
3
- This page covers the `cache()` plugin: which requests it answers, which
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 plugin is a route hook: it applies to the routes declared **after**
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 plugin: it saves
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>): Alxia<…> & Requiring<Requires> & Cache
128
- // an app, given to `use`, which checks `Requires`; and the hands to empty it
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 plugin sets no `Cache-Control` of its own: it caches on the server.
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 global hooks add after the route
234
- (`onResponse`, a CORS or compression plugin) are not kept: they are added
235
- again to every answer, from the cache or not.
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 plugin reads `ctx.cache`:
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 plugin's `tags`. */
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 plugin has no `ctx.cache`; reading it is a
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 plugin — an app to give to `use` — with the handles
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 plugin's `tags(ctx)`, computed for each response kept —
37
- `cache<{ user: User }>(…)` lets it read what an earlier plugin added
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 plugin's: do not give one of yours that
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 plugin
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 plugin added,
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 plugin added, such as a signed-in `user`
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 is a [`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
149
- plugin: an app that does not give `user` before it cannot use it.
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 plugin
159
+ user: { tenantId: request.headers.get('x-tenant') ?? 'public' }, // your session middleware
160
160
  }));
161
161
 
162
162
  const app = alxia()
163
- .use(auth)
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: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
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. A route that answers by the
180
- `Cookie` or `Authorization` header, behind a cache with the default key,
181
- and says nothing about it, serves the first visitor's answer to every later
182
- one. curl, sending no cookie, never shows it; a signed-in browser does.
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`),
@@ -15,9 +15,9 @@ const app = alxia()
15
15
  .get('/products', ({ reply }) => reply(200, []));
16
16
  ```
17
17
 
18
- The plugin does not know which store it was given: every store answers 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 plugin, not the store.
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 plugin relies on:
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 plugin reads `storedAt`, `ttl` and
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 plugin's own behaviour is the best test of a store: run an app on it.
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
- Nothing scheduled yet.
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.** `use(cache({ ttl }))` answers the `GET`
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`.
@@ -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 plugin is a route hook: it applies to, and adds `cache` to, 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 plugin:
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 plugin's controls, `{ tag, skip }`, and
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
- .use(redis(connection.client, { caches: { users } }))
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 plugin added to the context, and `cache` is not told about it.
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().use(auth).use(perTenant); // auth derives user
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
- [`the plugin reads "user", which this app's context does not give`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first),
144
- or [`… gives with another type`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-gives-with-another-type)
145
- when its `user` is not `{ tenantId: string }`.
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 plugin treats `undefined` as a miss; it would read `null` as a
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 plugin that touches every response, say | move the routes that set it before the cache, or stop it setting a cookie on public pages |
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 — a `Cookie`, an
417
- `Authorization` header — is behind the cache with the default key, and its
418
- response says nothing about it. curl, with no cookie, shows nothing wrong;
419
- a signed-in browser does.
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. The first visitor's
422
- response is kept, and served to everyone after.
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.1.1",
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/client": "^0.2.0",
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.2.0",
48
+ "@alxia/core": "^0.4.0",
50
49
  "typescript": "^6.0.3 || ^7.0.0"
51
50
  }
52
51
  }