@alxia/cache 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
1
+ # @alxia/cache
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
5
+ they refresh, one route run for many concurrent misses, tags to empty it,
6
+ ETags and 304s. In memory, or in Redis with
7
+ [`@alxia/redis`](https://www.npmjs.com/package/@alxia/redis)'s
8
+ `redisCacheStore`.
9
+
10
+ ```sh
11
+ bun add @alxia/cache @alxia/core
12
+ bun add -d typescript
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { cache } from '@alxia/cache';
19
+ import { alxia } from '@alxia/core';
20
+ import { z } from 'zod'; // any Standard Schema validates a body; zod is one
21
+
22
+ const Product = z.object({ id: z.string(), name: z.string() });
23
+ const catalogue = new Map<string, z.infer<typeof Product>>();
24
+
25
+ const products = cache({ ttl: 60, staleWhileRevalidate: 300, statuses: [200, 404], tags: () => ['products'] });
26
+
27
+ const app = alxia()
28
+ .post('/products', { body: Product }, async ({ body, reply }) => {
29
+ catalogue.set(body.id, body);
30
+ await products.invalidateTag('products'); // the next GET runs the route
31
+ return reply(201, body);
32
+ })
33
+ .use(products) // the GETs after it are cached
34
+ .get('/products', ({ reply }) => reply(200, [...catalogue.values()]))
35
+ .get('/products/:id', ({ params, cache, reply }) => {
36
+ cache.tag(`product:${params.id}`); // a tag of its own
37
+ const product = catalogue.get(params.id);
38
+ return product ? reply(200, product) : reply(404, { error: 'not_found' });
39
+ });
40
+
41
+ app.listen({ port: 3000 });
42
+ ```
43
+
44
+ ## What it does
45
+
46
+ - **Fresh** (`ttl` seconds): answered from the store, `X-Cache: HIT`.
47
+ - **Stale** (`staleWhileRevalidate` seconds more): answered from the store at
48
+ once, `X-Cache: STALE`, while one request refreshes it behind.
49
+ - **Missing**: the route runs once, however many requests wait for it, and
50
+ its response is kept: `X-Cache: MISS`.
51
+ - **A weak `ETag`** from the body when the route set none: a client whose
52
+ copy is current gets a 304.
53
+ - **Never kept**: a status outside `statuses` (`200`), a response that says
54
+ `Cache-Control: private` or `no-store`, sets a cookie, or streams events —
55
+ and one whose route called `cache.skip()`. Nor is it handed to a
56
+ concurrent request: each runs the route itself.
57
+ - **A store that cannot answer** costs the cache, not the response: the
58
+ route runs, nothing is kept, and the outage's first error is logged.
59
+ - Only `GET` and `HEAD` — not `QUERY`, whose key would have to include its
60
+ body; only the routes declared after the plugin.
61
+
62
+ ## The key
63
+
64
+ The path and the query, by default. A response that differs by a header —
65
+ the language — lists it in `vary`, which keys by its value and says `Vary`:
66
+
67
+ ```ts
68
+ cache({ ttl: 60, vary: ['accept-language', 'cookie'] });
69
+ cache({ ttl: 60, key: (ctx) => (ctx.request.headers.has('authorization') ? undefined : ctx.url.pathname) });
70
+ ```
71
+
72
+ `key` returning `undefined` is not cached: a request with a session, say.
73
+ Whatever the key, `invalidate(path)` forgets every response kept for the
74
+ path and query as the request asked them:
75
+
76
+ ```ts
77
+ await products.invalidate('/products'); // under each `vary` value, or a key of your own
78
+ await products.invalidate('/products?page=2'); // another path: the query is part of it
79
+ ```
80
+
81
+ A `key` or `tags` that reads what an earlier plugin added names it as the
82
+ type argument; an app that does not give it before the cache cannot use it:
83
+
84
+ ```ts
85
+ const perTenant = cache<{ user: { tenantId: string } }>({
86
+ ttl: 60,
87
+ key: ({ user, url }) => `${user.tenantId}:${url.pathname}${url.search}`,
88
+ tags: ({ user }) => [`tenant:${user.tenantId}`],
89
+ });
90
+
91
+ const auth = alxia().derive(() => ({ user: { tenantId: 'acme' } })); // your session plugin
92
+
93
+ alxia().use(auth).use(perTenant); // compiles: auth derives user
94
+ alxia().use(perTenant); // a compile error: no `user` in this app's context
95
+ ```
96
+
97
+ ## Two stores
98
+
99
+ ```ts
100
+ import { MemoryCacheStore } from '@alxia/cache';
101
+ import { redisCacheStore } from '@alxia/redis';
102
+
103
+ cache({ ttl: 60, store: new MemoryCacheStore({ maxEntries: 5_000, maxBytes: 128 * 1024 * 1024 }) });
104
+ cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }) });
105
+ ```
106
+
107
+ The memory store keeps the most recently read responses of one process. The
108
+ Redis store, on `@nxgt/redis`, shares them — and their invalidation —
109
+ across every process. A store of your own implements `CacheStore`: `get`,
110
+ `set`, `delete`, `deleteTag` — and remembers each response's `tags`, which
111
+ `invalidate` relies on too.
112
+
113
+ ## Options
114
+
115
+ | option | default | |
116
+ | --- | --- | --- |
117
+ | `ttl` | required | seconds fresh |
118
+ | `staleWhileRevalidate` | 0 | seconds served stale while refreshed |
119
+ | `store` | `MemoryCacheStore` | |
120
+ | `key` | path and query | `(ctx) => string \| undefined`; `cache<{ user: User }>(…)` lets it read a `user` an earlier plugin adds |
121
+ | `vary` | none | request headers the response depends on |
122
+ | `statuses` | `[200]` | |
123
+ | `tags` | none | `(ctx) => string[]`, typed like `key` |
124
+ | `honorClientNoCache` | `false` | a client's `no-cache` skips the cache |
125
+ | `debugHeaders` | `true` | `X-Cache` and `Age` |
126
+
127
+ ## API
128
+
129
+ | export | |
130
+ | --- | --- |
131
+ | `cache<Requires>(options)` | the plugin, with `invalidate(path)`, `invalidateTag(tag)` and `store`; routes after it read `cache.tag()` and `cache.skip()` |
132
+ | `CacheOptions<Requires>` | its options: `ttl`, `staleWhileRevalidate`, `store`, `key`, `vary`, `statuses`, `tags`, `honorClientNoCache`, `debugHeaders` |
133
+ | `defaultKey(path, vary, headers)` | the default key: the path and query, then each varying header's value |
134
+ | `pathTag(path)` | the tag every kept response carries for its path, `alxia:path:<path>`: what `invalidate(path)` deletes |
135
+ | `MemoryCacheStore` | the in-process store: least recently used |
136
+ | `MemoryCacheOptions` | its options: `maxEntries`, `maxBytes` |
137
+ | `CacheStore`, `CachedResponse` | a store's contract |
138
+ | `Cache`, `CacheControls` | the plugin's handles, and what the routes behind it read |
139
+
140
+ ## Documentation
141
+
142
+ - [Guide](https://github.com/softistx/alxia/tree/develop/packages/cache/docs): a page per area — caching responses and its options, keys and `Vary`, invalidation by path and by tag, and stores, the memory one, Redis, or your own.
143
+ - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/cache/docs/troubleshooting.md): an error message, or a cache that does not hit, and what to do about it.
144
+ - [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/cache/docs/roadmap.md): what is coming, and what is not planned.
@@ -0,0 +1,74 @@
1
+ import { type BaseContext, type Empty } from '@alxia/core';
2
+ import { type CacheStore } from './store';
3
+ /**
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
6
+ * uses the cache must then give.
7
+ */
8
+ export interface CacheOptions<Requires extends object = Empty> {
9
+ /** Seconds a response is fresh. */
10
+ readonly ttl: number;
11
+ /**
12
+ * Seconds a response is served stale after, while one request refreshes
13
+ * it in the background: no client waits for a slow route. None by default.
14
+ */
15
+ readonly staleWhileRevalidate?: number;
16
+ /** Where responses are kept: this process's memory by default. */
17
+ readonly store?: CacheStore;
18
+ /**
19
+ * The key of a request: its path and query by default, and the headers
20
+ * in `vary`. `undefined` is not cached: a request with a session, say.
21
+ */
22
+ readonly key?: (ctx: BaseContext & Requires) => string | undefined;
23
+ /** Request headers the response varies by: `accept-language`. Each is part of the key, and of `Vary`. */
24
+ readonly vary?: readonly string[];
25
+ /** The statuses kept. `200` by default; a 404 may be worth keeping too. */
26
+ readonly statuses?: readonly number[];
27
+ /** Tags every response of these routes carries, for `invalidateTag`. */
28
+ readonly tags?: (ctx: BaseContext & Requires) => readonly string[];
29
+ /** Whether `Cache-Control: no-cache` from the client skips the cache. Off by default: a client cannot empty yours. */
30
+ readonly honorClientNoCache?: boolean;
31
+ /** Says `X-Cache: HIT`, `STALE` or `MISS`, and `Age`. On by default. */
32
+ readonly debugHeaders?: boolean;
33
+ }
34
+ /** What the routes behind the cache read. */
35
+ export interface CacheControls {
36
+ /** Tags the response being built, beyond the plugin's `tags`. Tags starting `alxia:` are the plugin's own. */
37
+ tag(...tags: string[]): void;
38
+ /** Keeps this response out of the cache. */
39
+ skip(): void;
40
+ }
41
+ /** A cache of responses, and the hands to empty it. */
42
+ export interface Cache {
43
+ /**
44
+ * Forgets every response kept for `path` — `/users/1?x=y`, as the request
45
+ * asked it — whatever its key: each `vary` value, a `key` of your own.
46
+ */
47
+ invalidate(path: string): Promise<void>;
48
+ /** Forgets every response tagged `tag`. */
49
+ invalidateTag(tag: string): Promise<void>;
50
+ readonly store: CacheStore;
51
+ }
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
56
+ * once and refreshed behind. A response that says `no-store` or `private`,
57
+ * sets a cookie, or has another status is never kept.
58
+ *
59
+ * Every kept response gets a weak `ETag` from its body when it has none, so
60
+ * a client whose copy is current gets a 304.
61
+ *
62
+ * ```ts
63
+ * const products = cache({ ttl: 60, staleWhileRevalidate: 300, tags: () => ['products'] });
64
+ * app.use(products).get('/products', ...);
65
+ * await products.invalidateTag('products');
66
+ * ```
67
+ *
68
+ * A `key` or `tags` that reads what an earlier plugin added names it, and
69
+ * the app must then give it: `cache<{ user: User }>({ tags: ({ user }) => [user.id], … })`.
70
+ */
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;
74
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,16 @@
1
+ /** What each route behind the cache says through `ctx.cache`, kept by request. */
2
+ /** What a route said through `ctx.cache`, for the response it is building. */
3
+ export interface Control {
4
+ readonly tags: string[];
5
+ skipped: boolean;
6
+ }
7
+ export declare function requestControls(): {
8
+ /** What the route said for `request`, once it has run. */
9
+ get: (request: Request) => Control | undefined;
10
+ /** A fresh control for `request`, and the hands a route is given to it. */
11
+ open(request: Request): {
12
+ tag: (...tags: string[]) => void;
13
+ skip: () => void;
14
+ };
15
+ };
16
+ //# sourceMappingURL=control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAElF,8EAA8E;AAC9E,MAAM,WAAW,OAAO;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,wBAAgB,eAAe;IAG7B,0DAA0D;mBAC3C,OAAO,KAAG,OAAO,GAAG,SAAS;IAC5C,2EAA2E;kBAC7D,OAAO;uBAIJ,MAAM,EAAE;;;EAS1B"}
@@ -0,0 +1,24 @@
1
+ import type { CachedResponse } from './store';
2
+ /** What a run of the route gives: a response it kept, or its own when it kept nothing. */
3
+ export type Loaded = {
4
+ kept: CachedResponse;
5
+ } | {
6
+ own: Response;
7
+ };
8
+ /**
9
+ * Runs the route once for every concurrent miss of a key. Only a response
10
+ * that is kept is shared: one that is not — private, skipped, a cookie,
11
+ * another status — answers the request that ran the route, and every other
12
+ * waiting request runs the route itself, through its own `next`.
13
+ */
14
+ export declare function singleFlight(): {
15
+ /** Whether a run of `key` is under way. */
16
+ has: (key: string) => boolean;
17
+ load(key: string, run: () => Promise<Loaded>, next: () => Promise<Response>): Promise<CachedResponse | Response>;
18
+ };
19
+ /**
20
+ * A stale response's refresh, run behind it: the route's own error is its to
21
+ * log, and a response it does not keep is read by no one.
22
+ */
23
+ export declare function refreshBehind(loading: Promise<CachedResponse | Response>): void;
24
+ //# sourceMappingURL=flight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight.d.ts","sourceRoot":"","sources":["../src/flight.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,0FAA0F;AAC1F,MAAM,MAAM,MAAM,GAAG;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GAAG;IAAE,GAAG,EAAE,QAAQ,CAAA;CAAE,CAAC;AAElE;;;;;GAKG;AACH,wBAAgB,YAAY;IAI1B,2CAA2C;eAChC,MAAM,KAAG,OAAO;cAErB,MAAM,OACN,MAAM,OAAO,CAAC,MAAM,CAAC,QACpB,MAAM,OAAO,CAAC,QAAQ,CAAC,GAC3B,OAAO,CAAC,cAAc,GAAG,QAAQ,CAAC;EAoBtC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,OAAO,CAAC,cAAc,GAAG,QAAQ,CAAC,GACzC,IAAI,CAMN"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A store that cannot answer is a miss, or keeps nothing: a cache is never
3
+ * worth a 500. Said to the log once per outage — again only after the store
4
+ * has answered since.
5
+ */
6
+ export declare function storeGuard(): <T>(work: () => Promise<T> | T, fallback: T) => Promise<T>;
7
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,UAAU,KAEX,CAAC,EAAE,MAAM,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,UAAU,CAAC,KAAG,OAAO,CAAC,CAAC,CAAC,CAWrE"}
@@ -0,0 +1,4 @@
1
+ export { type Cache, type CacheControls, type CacheOptions, cache, } from './cache';
2
+ export { defaultKey, pathTag } from './keys';
3
+ export { type CachedResponse, type CacheStore, type MemoryCacheOptions, MemoryCacheStore, } from './store';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,276 @@
1
+ // src/cache.ts
2
+ import { definePlugin } from "@alxia/core";
3
+
4
+ // src/control.ts
5
+ function requestControls() {
6
+ const controls = new WeakMap;
7
+ return {
8
+ get: (request) => controls.get(request),
9
+ open(request) {
10
+ const control = { tags: [], skipped: false };
11
+ controls.set(request, control);
12
+ return {
13
+ tag: (...tags) => {
14
+ control.tags.push(...tags);
15
+ },
16
+ skip: () => {
17
+ control.skipped = true;
18
+ }
19
+ };
20
+ }
21
+ };
22
+ }
23
+
24
+ // src/flight.ts
25
+ function singleFlight() {
26
+ const loading = new Map;
27
+ return {
28
+ has: (key) => loading.has(key),
29
+ load(key, run, next) {
30
+ const running = loading.get(key);
31
+ if (running !== undefined) {
32
+ return running.then((cached) => cached ?? next(), () => next());
33
+ }
34
+ const ran = run();
35
+ const shared = ran.then((loaded) => ("kept" in loaded) ? loaded.kept : undefined);
36
+ loading.set(key, shared);
37
+ shared.finally(() => loading.delete(key)).catch(() => {});
38
+ return ran.then((loaded) => ("kept" in loaded) ? loaded.kept : loaded.own);
39
+ }
40
+ };
41
+ }
42
+ function refreshBehind(loading) {
43
+ loading.then((loaded) => loaded instanceof Response ? loaded.body?.cancel() : undefined).catch((error) => console.error(error));
44
+ }
45
+
46
+ // src/guard.ts
47
+ function storeGuard() {
48
+ let failing = false;
49
+ return async (work, fallback) => {
50
+ try {
51
+ const value = await work();
52
+ failing = false;
53
+ return value;
54
+ } catch (error) {
55
+ if (!failing)
56
+ console.error(error);
57
+ failing = true;
58
+ return fallback;
59
+ }
60
+ };
61
+ }
62
+
63
+ // src/keep.ts
64
+ import { vary as addVary } from "@alxia/core";
65
+ var PRIVATE = /\b(no-store|private)\b/i;
66
+ function keepable(response, control, statuses) {
67
+ 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
+ }
69
+ async function toCached(response, keep) {
70
+ const body = new Uint8Array(await response.arrayBuffer());
71
+ const headers = new Headers(response.headers);
72
+ headers.delete("content-length");
73
+ headers.delete("date");
74
+ if (!headers.has("etag")) {
75
+ headers.set("etag", `W/"${Bun.hash(body).toString(36)}"`);
76
+ }
77
+ for (const name of keep.vary)
78
+ addVary(headers, name);
79
+ return {
80
+ status: response.status,
81
+ headers: [...headers],
82
+ body,
83
+ storedAt: Date.now(),
84
+ ttl: keep.ttl,
85
+ stale: keep.stale,
86
+ tags: keep.tags()
87
+ };
88
+ }
89
+
90
+ // src/keys.ts
91
+ var pathTag = (path) => `alxia:path:${path}`;
92
+ function defaultKey(path, vary, headers) {
93
+ if (vary.length === 0)
94
+ return path;
95
+ return `${path}|${vary.map((name) => `${name}=${headers.get(name) ?? ""}`).join("|")}`;
96
+ }
97
+
98
+ // src/lookup.ts
99
+ function bypasses(request, honorClientNoCache) {
100
+ if (request.method !== "GET" && request.method !== "HEAD")
101
+ return true;
102
+ return honorClientNoCache && /\bno-cache\b/i.test(request.headers.get("cache-control") ?? "");
103
+ }
104
+ function freshness(found, now = Date.now()) {
105
+ const age = now - found.storedAt;
106
+ if (age < found.ttl)
107
+ return "fresh";
108
+ if (age < found.ttl + found.stale)
109
+ return "stale";
110
+ return;
111
+ }
112
+
113
+ // src/respond.ts
114
+ function respond(request, cached, state) {
115
+ const headers = new Headers(cached.headers);
116
+ if (state !== undefined) {
117
+ headers.set("x-cache", state);
118
+ headers.set("age", String(Math.max(0, Math.floor((Date.now() - cached.storedAt) / 1000))));
119
+ }
120
+ const etag = headers.get("etag");
121
+ const match = request.headers.get("if-none-match");
122
+ if (etag !== null && match !== null) {
123
+ const weak = (tag) => tag.trim().replace(/^W\//, "");
124
+ if (match.split(",").some((tag) => tag.trim() === "*" || weak(tag) === weak(etag))) {
125
+ return new Response(null, { status: 304, headers });
126
+ }
127
+ }
128
+ return new Response(cached.status === 204 ? null : cached.body, {
129
+ status: cached.status,
130
+ headers
131
+ });
132
+ }
133
+
134
+ // src/store.ts
135
+ class MemoryCacheStore {
136
+ #entries = new Map;
137
+ #tags = new Map;
138
+ #maxEntries;
139
+ #maxBytes;
140
+ #bytes = 0;
141
+ constructor(options = {}) {
142
+ this.#maxEntries = options.maxEntries ?? 1000;
143
+ this.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
144
+ }
145
+ get(key) {
146
+ const entry = this.#entries.get(key);
147
+ if (entry === undefined)
148
+ return;
149
+ if (entry.expiresAt <= Date.now()) {
150
+ this.delete(key);
151
+ return;
152
+ }
153
+ this.#entries.delete(key);
154
+ this.#entries.set(key, entry);
155
+ return entry.value;
156
+ }
157
+ set(key, value, keepFor) {
158
+ this.delete(key);
159
+ if (value.body.byteLength > this.#maxBytes)
160
+ return;
161
+ this.#entries.set(key, { value, expiresAt: Date.now() + keepFor });
162
+ this.#bytes += value.body.byteLength;
163
+ for (const tag of value.tags) {
164
+ let keys = this.#tags.get(tag);
165
+ if (keys === undefined) {
166
+ keys = new Set;
167
+ this.#tags.set(tag, keys);
168
+ }
169
+ keys.add(key);
170
+ }
171
+ for (const oldest of this.#entries.keys()) {
172
+ if (this.#entries.size <= this.#maxEntries && this.#bytes <= this.#maxBytes)
173
+ break;
174
+ this.delete(oldest);
175
+ }
176
+ }
177
+ delete(key) {
178
+ const entry = this.#entries.get(key);
179
+ if (entry === undefined)
180
+ return;
181
+ this.#entries.delete(key);
182
+ this.#bytes -= entry.value.body.byteLength;
183
+ for (const tag of entry.value.tags) {
184
+ const keys = this.#tags.get(tag);
185
+ keys?.delete(key);
186
+ if (keys?.size === 0)
187
+ this.#tags.delete(tag);
188
+ }
189
+ }
190
+ deleteTag(tag) {
191
+ for (const key of [...this.#tags.get(tag) ?? []])
192
+ this.delete(key);
193
+ }
194
+ get size() {
195
+ return this.#entries.size;
196
+ }
197
+ }
198
+
199
+ // src/cache.ts
200
+ 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 controls = requestControls();
210
+ const attempt = storeGuard();
211
+ const flight = singleFlight();
212
+ const keepOnMiss = async (key, ctx, next) => {
213
+ const response = await next();
214
+ const control = controls.get(ctx.request);
215
+ if (!keepable(response, control, statuses))
216
+ return { own: response };
217
+ const cached = await toCached(response, {
218
+ ttl,
219
+ stale,
220
+ vary,
221
+ tags: () => [
222
+ ...options.tags?.(ctx) ?? [],
223
+ ...control?.tags ?? [],
224
+ pathTag(`${ctx.url.pathname}${ctx.url.search}`)
225
+ ]
226
+ });
227
+ await attempt(() => store.set(key, cached, ttl + stale), undefined);
228
+ return { kept: cached };
229
+ };
230
+ const load = (key, ctx, next) => flight.load(key, () => keepOnMiss(key, ctx, next), next);
231
+ 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) => {
236
+ const { request } = ctx;
237
+ const key = bypasses(request, honorNoCache) ? undefined : keyOf(ctx);
238
+ if (key === undefined)
239
+ return next();
240
+ const found = await attempt(() => store.get(key), undefined);
241
+ const worth = found === undefined ? undefined : freshness(found);
242
+ if (found !== undefined && worth === "fresh") {
243
+ return respond(request, found, label("HIT"));
244
+ }
245
+ if (found !== undefined && worth === "stale") {
246
+ if (!flight.has(key))
247
+ refreshBehind(load(key, ctx, next));
248
+ return respond(request, found, label("STALE"));
249
+ }
250
+ const loaded = await load(key, ctx, next);
251
+ if (loaded instanceof Response)
252
+ return loaded;
253
+ return respond(request, loaded, label("MISS"));
254
+ }));
255
+ return Object.assign(plugin, handlesOf(store));
256
+ }
257
+ function handlesOf(store) {
258
+ return {
259
+ store,
260
+ invalidate: async (path) => {
261
+ await store.deleteTag(pathTag(path));
262
+ },
263
+ invalidateTag: async (tag) => {
264
+ await store.deleteTag(tag);
265
+ }
266
+ };
267
+ }
268
+ export {
269
+ MemoryCacheStore,
270
+ cache,
271
+ defaultKey,
272
+ pathTag
273
+ };
274
+
275
+ //# debugId=01EFC24056EEAC7464756E2164756E21
276
+ //# sourceMappingURL=index.js.map