@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 +21 -0
- package/README.md +143 -2
- package/dist/cache.d.ts +74 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/control.d.ts +16 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/flight.d.ts +24 -0
- package/dist/flight.d.ts.map +1 -0
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +276 -0
- package/dist/index.js.map +18 -0
- package/dist/keep.d.ts +19 -0
- package/dist/keep.d.ts.map +1 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/lookup.d.ts +7 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/respond.d.ts +4 -0
- package/dist/respond.d.ts.map +1 -0
- package/dist/store.d.ts +43 -0
- package/dist/store.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/caching.md +326 -0
- package/docs/guide/invalidation.md +192 -0
- package/docs/guide/keys-and-vary.md +219 -0
- package/docs/guide/stores.md +273 -0
- package/docs/roadmap.md +67 -0
- package/docs/troubleshooting.md +491 -0
- package/package.json +51 -5
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
|
-
#
|
|
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
|
|
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.
|
package/dist/cache.d.ts
ADDED
|
@@ -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"}
|
package/dist/flight.d.ts
ADDED
|
@@ -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"}
|
package/dist/guard.d.ts
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|