@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/docs/roadmap.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@alxia/cache` gives an app, and what is coming. This page is a
|
|
4
|
+
direction, not a commitment: the version something shipped in is the only
|
|
5
|
+
number on it. Every release, with each change it made, is in
|
|
6
|
+
[`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/cache/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
## Now
|
|
9
|
+
|
|
10
|
+
Nothing scheduled yet.
|
|
11
|
+
|
|
12
|
+
## Next
|
|
13
|
+
|
|
14
|
+
Nothing scheduled yet.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
Nothing scheduled yet.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A runtime dependency.** `@alxia/cache` installs nothing beside itself
|
|
23
|
+
and its `@alxia/core` and `typescript` peers: it is built on Bun's and the
|
|
24
|
+
web platform's own APIs, so adding it to an app adds no package to audit
|
|
25
|
+
or update.
|
|
26
|
+
- **A Redis store in this package.** This package defines the store's
|
|
27
|
+
contract and ships the memory store; sharing responses across processes
|
|
28
|
+
is `@alxia/redis`'s `redisCacheStore`, which answers the same contract, so
|
|
29
|
+
an app that runs one process installs no Redis client. The plugin never
|
|
30
|
+
knows which store it was given.
|
|
31
|
+
|
|
32
|
+
## Shipped
|
|
33
|
+
|
|
34
|
+
### 0.1.0
|
|
35
|
+
|
|
36
|
+
- **Response caching as a plugin.** `use(cache({ ttl }))` answers the `GET`
|
|
37
|
+
and `HEAD` requests of every route declared after it from a store while
|
|
38
|
+
they are fresh, and from the route otherwise, saying `X-Cache: HIT` or
|
|
39
|
+
`MISS` and `Age`.
|
|
40
|
+
- **Stale while revalidate.** With `staleWhileRevalidate`, an expired
|
|
41
|
+
response is still served at once, as `X-Cache: STALE`, while one request
|
|
42
|
+
refreshes it behind: no client waits for a slow route.
|
|
43
|
+
- **One run for many misses.** Concurrent requests for a missing response
|
|
44
|
+
wait for one run of the route, not one each, when its response is kept.
|
|
45
|
+
- **Only what may be shared.** A response is kept only with a status in
|
|
46
|
+
`statuses` (`200` by default), and never when it says
|
|
47
|
+
`Cache-Control: private` or `no-store`, sets a cookie, streams events, or
|
|
48
|
+
its route calls `cache.skip()`.
|
|
49
|
+
- **ETags and 304s.** Every kept response gets a weak `ETag` from its body
|
|
50
|
+
when the route set none, and a client whose copy is current gets a 304.
|
|
51
|
+
- **Keys your way.** The path and query by default; `vary` adds request
|
|
52
|
+
headers to the key and to `Vary`; `key` replaces it, and `undefined`
|
|
53
|
+
leaves a request uncached. `defaultKey` builds the default one.
|
|
54
|
+
`cache<{ user: User }>(…)` types `key` and `tags` with what an earlier
|
|
55
|
+
plugin adds, and an app that does not give it cannot use the cache.
|
|
56
|
+
- **Invalidation.** `invalidate(path)` forgets every response kept for a
|
|
57
|
+
path, whatever its key, and
|
|
58
|
+
`invalidateTag(tag)` every response tagged by the plugin's `tags` or by
|
|
59
|
+
its route's `cache.tag()`.
|
|
60
|
+
- **A store that cannot answer costs the cache, not the response.** A
|
|
61
|
+
lookup that fails is a miss and a keep that fails keeps nothing, logged
|
|
62
|
+
once per outage; an invalidation rejects, so the code that changed the
|
|
63
|
+
data knows.
|
|
64
|
+
- **A store contract.** `CacheStore` is four methods; `MemoryCacheStore`
|
|
65
|
+
keeps the least recently read responses of one process, within
|
|
66
|
+
`maxEntries` and `maxBytes`, and `@alxia/redis`'s `redisCacheStore` shares
|
|
67
|
+
them across processes.
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: a TypeScript error, or an
|
|
4
|
+
exception in the log. `@alxia/cache` throws nothing of its own; most of what
|
|
5
|
+
goes wrong prints nothing at all, and is under [Traps](#traps), by symptom.
|
|
6
|
+
|
|
7
|
+
**Types**
|
|
8
|
+
|
|
9
|
+
- [`Argument of type '{}' is not assignable to parameter of type 'CacheOptions<Empty>'`](#argument-of-type--is-not-assignable-to-parameter-of-type-cacheoptionsempty)
|
|
10
|
+
- [`Property 'cache' does not exist on type 'Context<…>'`](#property-cache-does-not-exist-on-type-context)
|
|
11
|
+
- [`Property '…' does not exist on type 'CacheControls'`](#property--does-not-exist-on-type-cachecontrols)
|
|
12
|
+
- [`Property 'user' does not exist on type 'BaseContext & Empty'`](#property-user-does-not-exist-on-type-basecontext--empty)
|
|
13
|
+
- [`Type '(…) => Promise<string>' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`](#type---promisestring-is-not-assignable-to-type-ctx-basecontext--empty--string--undefined)
|
|
14
|
+
- [`Type '(…) => string | null' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`](#type---string--null-is-not-assignable-to-type-ctx-basecontext--empty--string--undefined)
|
|
15
|
+
- [`Type '() => string' is not assignable to type '(ctx: BaseContext & Empty) => readonly string[]'`](#type---string-is-not-assignable-to-type-ctx-basecontext--empty--readonly-string)
|
|
16
|
+
- [`Type 'string' is not assignable to type 'readonly string[]'`](#type-string-is-not-assignable-to-type-readonly-string)
|
|
17
|
+
|
|
18
|
+
**Writing a store**
|
|
19
|
+
|
|
20
|
+
- [`Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'`](#type-null-is-not-assignable-to-type-cachedresponse--promisecachedresponse--undefined--undefined)
|
|
21
|
+
- [`Property 'deleteTag' is missing in type '{ … }' but required in type 'CacheStore'`](#property-deletetag-is-missing-in-type----but-required-in-type-cachestore)
|
|
22
|
+
- [`Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'`](#type-string-is-not-assignable-to-type-uint8arrayarraybufferlike)
|
|
23
|
+
|
|
24
|
+
**Runtime**
|
|
25
|
+
|
|
26
|
+
- [`500 {"error":"internal"}` from a route that invalidates, with the store's error in the log](#500-errorinternal-from-a-route-that-invalidates-with-the-stores-error-in-the-log)
|
|
27
|
+
- [The route's error is logged, yet the client got a `200` with `X-Cache: STALE`](#the-routes-error-is-logged-yet-the-client-got-a-200-with-x-cache-stale)
|
|
28
|
+
|
|
29
|
+
**Traps**
|
|
30
|
+
|
|
31
|
+
- [No `X-Cache` header, and the route runs every time](#no-x-cache-header-and-the-route-runs-every-time)
|
|
32
|
+
- [`X-Cache: MISS` on every request](#x-cache-miss-on-every-request)
|
|
33
|
+
- [Hits with curl, misses in a browser](#hits-with-curl-misses-in-a-browser)
|
|
34
|
+
- [A client that sent no `Accept-Encoding` gets gzip bytes](#a-client-that-sent-no-accept-encoding-gets-gzip-bytes)
|
|
35
|
+
- [One visitor sees another visitor's page](#one-visitor-sees-another-visitors-page)
|
|
36
|
+
- [Old data after a write](#old-data-after-a-write)
|
|
37
|
+
- [A hard reload shows new data, a plain reload the old](#a-hard-reload-shows-new-data-a-plain-reload-the-old)
|
|
38
|
+
- [Responses are kept for hours](#responses-are-kept-for-hours)
|
|
39
|
+
|
|
40
|
+
## Types
|
|
41
|
+
|
|
42
|
+
### `Argument of type '{}' is not assignable to parameter of type 'CacheOptions<Empty>'`
|
|
43
|
+
|
|
44
|
+
**When:** calling `cache()` without a `ttl`.
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
error TS2345: Argument of type '{}' is not assignable to parameter of type 'CacheOptions<Empty>'.
|
|
48
|
+
Property 'ttl' is missing in type '{}' but required in type 'CacheOptions<Empty>'.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Why:** `ttl` has no default: how long a response may be served again is
|
|
52
|
+
a decision only the app can make.
|
|
53
|
+
|
|
54
|
+
**Fix:** give it, in seconds:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
cache({ ttl: 60 });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `Property 'cache' does not exist on type 'Context<…>'`
|
|
61
|
+
|
|
62
|
+
**When:** a route reads `ctx.cache` — to call `tag` or `skip` — and is
|
|
63
|
+
declared before `use(cache(…))`.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
error TS2339: Property 'cache' does not exist on type 'Context<Empty, "/x", Empty>'.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Why:** the plugin is a route hook: it applies to, and adds `cache` to, the
|
|
70
|
+
routes declared after it. The route before it is not cached either.
|
|
71
|
+
|
|
72
|
+
**Fix:** declare the route after the plugin:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
alxia()
|
|
76
|
+
.use(cache({ ttl: 60 }))
|
|
77
|
+
.get('/products/:id', ({ params, cache, reply }) => {
|
|
78
|
+
cache.tag(`product:${params.id}`);
|
|
79
|
+
return reply(200, { id: params.id });
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `Property '…' does not exist on type 'CacheControls'`
|
|
84
|
+
|
|
85
|
+
**When:** a route reads from `ctx.cache` something other than `tag` or
|
|
86
|
+
`skip` — most often `@alxia/redis`'s typed caches, which `redis()` puts in
|
|
87
|
+
the context as `caches`.
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
error TS2339: Property 'users' does not exist on type 'CacheControls'.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
At run time, without a typecheck, the route answers a 500 with
|
|
94
|
+
`TypeError: undefined is not an object (evaluating 'cache.users.remember')`.
|
|
95
|
+
|
|
96
|
+
**Why:** `ctx.cache` is this plugin's controls, `{ tag, skip }`, and
|
|
97
|
+
nothing else.
|
|
98
|
+
|
|
99
|
+
**Fix:** read the other plugin's name for it:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
alxia()
|
|
103
|
+
.use(redis(connection.client, { caches: { users } }))
|
|
104
|
+
.use(cache({ ttl: 60 }))
|
|
105
|
+
.get('/users/:id', async ({ caches, cache, params, reply }) => {
|
|
106
|
+
cache.tag(`user:${params.id}`);
|
|
107
|
+
return reply.ok(await caches.users.remember(params.id, () => loadUser(params.id)));
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `Property 'user' does not exist on type 'BaseContext & Empty'`
|
|
112
|
+
|
|
113
|
+
**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
|
+
|
|
116
|
+
```text
|
|
117
|
+
error TS2339: Property 'user' does not exist on type 'BaseContext & Empty'.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Why:** `key` and `tags` are typed with `BaseContext` — `request`, `url`,
|
|
121
|
+
`ip`, `server`, `route`, `pathParams` — plus what you name as `cache`'s type
|
|
122
|
+
argument, and nothing else. `cache` is built before it is used, so it
|
|
123
|
+
cannot see the app it will be used on.
|
|
124
|
+
|
|
125
|
+
**Fix:** name what `key` and `tags` read. The app that uses the cache must
|
|
126
|
+
then give it, before the cache:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const perTenant = cache<{ user: { tenantId: string } }>({
|
|
130
|
+
ttl: 60,
|
|
131
|
+
key: ({ user, url }) => `${user.tenantId}:${url.pathname}${url.search}`,
|
|
132
|
+
tags: ({ user }) => [`tenant:${user.tenantId}`],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const auth = alxia().derive(({ request }) => ({
|
|
136
|
+
user: { tenantId: request.headers.get('x-tenant') ?? 'public' },
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
alxia().use(auth).use(perTenant); // auth derives user
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
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 }`.
|
|
146
|
+
See [Reading the app's context](guide/keys-and-vary.md#reading-the-apps-context).
|
|
147
|
+
|
|
148
|
+
To tag by what only the route knows — the product it loaded — tag from the
|
|
149
|
+
route with `ctx.cache.tag(…)` instead
|
|
150
|
+
([Invalidation](guide/invalidation.md#by-tag-invalidatetag)).
|
|
151
|
+
|
|
152
|
+
### `Type '(…) => Promise<string>' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`
|
|
153
|
+
|
|
154
|
+
**When:** `key` is an `async` function.
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
error TS2322: Type '({ url }: BaseContext & Empty) => Promise<string>' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'.
|
|
158
|
+
Type 'Promise<string>' is not assignable to type 'string'.
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Why:** the key is computed on every request before the store is asked;
|
|
162
|
+
it is synchronous.
|
|
163
|
+
|
|
164
|
+
**Fix:** compute the key from the request alone. A route whose answer
|
|
165
|
+
needs a lookup — a session, a tenant from a database — is personal:
|
|
166
|
+
declare it before the cache, or answer `Cache-Control: private`
|
|
167
|
+
([Personal responses](guide/keys-and-vary.md#personal-responses)).
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
cache({ ttl: 60, key: ({ url }) => `${url.pathname}${url.search}` });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `Type '(…) => string | null' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`
|
|
174
|
+
|
|
175
|
+
**When:** `key` returns `request.headers.get(…)`, or anything else that may
|
|
176
|
+
be `null`.
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
error TS2322: Type '({ request }: BaseContext & Empty) => string | null' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'.
|
|
180
|
+
Type 'string | null' is not assignable to type 'string | undefined'.
|
|
181
|
+
Type 'null' is not assignable to type 'string | undefined'.
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Why:** `undefined` is the key's "do not cache"; `null` is not accepted for
|
|
185
|
+
it.
|
|
186
|
+
|
|
187
|
+
**Fix:**
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
cache({ ttl: 60, key: ({ request }) => request.headers.get('x-tenant') ?? undefined });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `Type '() => string' is not assignable to type '(ctx: BaseContext & Empty) => readonly string[]'`
|
|
194
|
+
|
|
195
|
+
**When:** `tags` returns one tag instead of a list.
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
error TS2322: Type '() => string' is not assignable to type '(ctx: BaseContext & Empty) => readonly string[]'.
|
|
199
|
+
Type 'string' is not assignable to type 'readonly string[]'.
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Fix:**
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
cache({ ttl: 60, tags: () => ['products'] });
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### `Type 'string' is not assignable to type 'readonly string[]'`
|
|
209
|
+
|
|
210
|
+
**When:** `vary` or `statuses` is given one value instead of a list — or
|
|
211
|
+
the line above, for `tags`.
|
|
212
|
+
|
|
213
|
+
```text
|
|
214
|
+
error TS2322: Type 'string' is not assignable to type 'readonly string[]'.
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Fix:**
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
cache({ ttl: 60, vary: ['accept-language'], statuses: [200] });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Writing a store
|
|
224
|
+
|
|
225
|
+
### `Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'`
|
|
226
|
+
|
|
227
|
+
**When:** a store's `get` returns `null` for a key it does not hold — as
|
|
228
|
+
most key-value clients do.
|
|
229
|
+
|
|
230
|
+
```text
|
|
231
|
+
error TS2322: Type '(_key: string) => CachedResponse | null' is not assignable to type '(key: string) => CachedResponse | Promise<CachedResponse | undefined> | undefined'.
|
|
232
|
+
Type 'CachedResponse | null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'.
|
|
233
|
+
Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'.
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Why:** the plugin treats `undefined` as a miss; it would read `null` as a
|
|
237
|
+
response.
|
|
238
|
+
|
|
239
|
+
**Fix:** turn the client's `null` into `undefined`:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
async get(key) {
|
|
243
|
+
const raw = await client.get(key); // string | null
|
|
244
|
+
return raw === null ? undefined : decode(raw);
|
|
245
|
+
},
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### `Property 'deleteTag' is missing in type '{ … }' but required in type 'CacheStore'`
|
|
249
|
+
|
|
250
|
+
**When:** a store implements `get`, `set` and `delete` only.
|
|
251
|
+
|
|
252
|
+
```text
|
|
253
|
+
error TS2741: Property 'deleteTag' is missing in type '{ get: () => undefined; set(): void; delete(): void; }' but required in type 'CacheStore'.
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Why:** `invalidateTag` calls the store's `deleteTag`; every store must
|
|
257
|
+
answer it.
|
|
258
|
+
|
|
259
|
+
**Fix:** remember which keys each tag names in `set`, and delete them in
|
|
260
|
+
`deleteTag` — [Writing a store](guide/stores.md#writing-a-store) has a
|
|
261
|
+
complete one.
|
|
262
|
+
|
|
263
|
+
### `Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'`
|
|
264
|
+
|
|
265
|
+
**When:** a store's `get` returns the body as it read it from a text
|
|
266
|
+
service — a string — instead of bytes.
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
error TS2322: Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'.
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Why:** `CachedResponse.body` is the response's bytes, so a binary body
|
|
273
|
+
survives the round trip.
|
|
274
|
+
|
|
275
|
+
**Fix:** store it as base64, and decode it on the way back:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const text = Buffer.from(value.body).toString('base64'); // in set
|
|
279
|
+
const body = new Uint8Array(Buffer.from(text, 'base64')); // in get
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Runtime
|
|
283
|
+
|
|
284
|
+
### `500 {"error":"internal"}` from a route that invalidates, with the store's error in the log
|
|
285
|
+
|
|
286
|
+
**When:** a write calls `invalidate(path)` or `invalidateTag(tag)` while
|
|
287
|
+
the store cannot answer — a Redis that is down, a store of your own with a
|
|
288
|
+
bug — and does not catch it.
|
|
289
|
+
|
|
290
|
+
**Why:** a lookup or a keep that fails is only logged — the cached route
|
|
291
|
+
still answers, `X-Cache: MISS` — but an invalidation rejects, so the code
|
|
292
|
+
that changed the data learns that the old responses may still be served.
|
|
293
|
+
|
|
294
|
+
**Fix:** bring the store back. Where the write must succeed regardless,
|
|
295
|
+
catch the invalidation, and keep `ttl` short —
|
|
296
|
+
[When the store cannot answer](guide/stores.md#when-the-store-cannot-answer):
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
await products.invalidateTag('products').catch((error) => console.error(error));
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### The route's error is logged, yet the client got a `200` with `X-Cache: STALE`
|
|
303
|
+
|
|
304
|
+
**When:** `staleWhileRevalidate` is set, a response is stale, and the
|
|
305
|
+
route throws while refreshing it.
|
|
306
|
+
|
|
307
|
+
**Why:** the stale copy was already sent; the refresh runs behind it, and
|
|
308
|
+
its error has no request left to answer, so it is logged with
|
|
309
|
+
`console.error`. Each later stale request tries again, until
|
|
310
|
+
`ttl + staleWhileRevalidate` has passed; then the next request waits for
|
|
311
|
+
the route, and gets its 500.
|
|
312
|
+
|
|
313
|
+
**Fix:** none is needed — serving the last good response while the route
|
|
314
|
+
fails is what stale-while-revalidate is for. Fix the route; keep
|
|
315
|
+
`staleWhileRevalidate` as long as you are willing to serve old data.
|
|
316
|
+
|
|
317
|
+
## Traps
|
|
318
|
+
|
|
319
|
+
### No `X-Cache` header, and the route runs every time
|
|
320
|
+
|
|
321
|
+
**When:** the request is never looked up, or the response is never kept.
|
|
322
|
+
|
|
323
|
+
**Why:** one of these, from the most common:
|
|
324
|
+
|
|
325
|
+
| Cause | Fix |
|
|
326
|
+
| --- | --- |
|
|
327
|
+
| 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 says `Cache-Control: private` or `no-store` | intended: it is personal |
|
|
330
|
+
| its status is not in `statuses` (`[200]`) | `statuses: [200, 404]` |
|
|
331
|
+
| it is `text/event-stream` | intended: a stream is never kept |
|
|
332
|
+
| the route called `cache.skip()` | intended |
|
|
333
|
+
| it arrived while a concurrent request for the same key was answered with something not kept, or failed | intended: it ran the route itself, and the next request is a miss |
|
|
334
|
+
| `key` returned `undefined` | intended: see [Personal responses](guide/keys-and-vary.md#personal-responses) |
|
|
335
|
+
| the method is not `GET` or `HEAD` | intended |
|
|
336
|
+
| `honorClientNoCache: true`, and the request said `Cache-Control: no-cache` | see [below](#a-hard-reload-shows-new-data-a-plain-reload-the-old) |
|
|
337
|
+
| `debugHeaders: false` | the cache works; it just does not say so |
|
|
338
|
+
|
|
339
|
+
### `X-Cache: MISS` on every request
|
|
340
|
+
|
|
341
|
+
**When:** the response is computed, kept, and the next request misses
|
|
342
|
+
anyway.
|
|
343
|
+
|
|
344
|
+
**Why:** the store did not keep it, or the next request has another key:
|
|
345
|
+
|
|
346
|
+
- **The body is larger than the memory store's `maxBytes`** (64 MiB by
|
|
347
|
+
default): a body that would not fit is never kept.
|
|
348
|
+
- **The key changes every time**: a query parameter that is new on each
|
|
349
|
+
request (`?t=1712345678`), or a `vary` header whose value differs — see
|
|
350
|
+
[the next entry](#hits-with-curl-misses-in-a-browser).
|
|
351
|
+
- **A store of your own reads `keepFor` as seconds**: it is
|
|
352
|
+
**milliseconds**. A Redis `EX` given `keepFor` keeps a response 1 000 times
|
|
353
|
+
too long; `PX` is right, or `Math.ceil(keepFor / 1000)` for `EX`.
|
|
354
|
+
- **Each process has its own memory store**, and a load balancer spreads
|
|
355
|
+
requests across them: each process misses once.
|
|
356
|
+
- **The store cannot answer**: its first error of the outage is in the log,
|
|
357
|
+
and every request runs the route
|
|
358
|
+
([When the store cannot answer](guide/stores.md#when-the-store-cannot-answer)).
|
|
359
|
+
|
|
360
|
+
**Fix:** for a large body, raise the limit:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
cache({ ttl: 60, store: new MemoryCacheStore({ maxBytes: 256 * 1024 * 1024 }) });
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
For a changing query, key by what the route reads:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
cache({ ttl: 60, key: ({ url }) => `${url.pathname}?page=${url.searchParams.get('page') ?? '1'}` });
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Hits with curl, misses in a browser
|
|
373
|
+
|
|
374
|
+
**When:** `vary` names `accept-language`, `cookie`, `user-agent` or another
|
|
375
|
+
header a browser fills in.
|
|
376
|
+
|
|
377
|
+
**Why:** the key holds the header's **exact** value. curl sends no
|
|
378
|
+
`Accept-Language` and no `Cookie`, so every curl request shares one key and
|
|
379
|
+
hits. A browser sends its whole preference list —
|
|
380
|
+
`fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7` — and every cookie of the site, so
|
|
381
|
+
visitors rarely share a key.
|
|
382
|
+
|
|
383
|
+
**Fix:** key by what the route actually answers with, and keep `vary` for
|
|
384
|
+
the `Vary` header —
|
|
385
|
+
[A key of your own](guide/keys-and-vary.md#a-key-of-your-own):
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
cache({
|
|
389
|
+
ttl: 60,
|
|
390
|
+
vary: ['accept-language'],
|
|
391
|
+
key: ({ url, request }) =>
|
|
392
|
+
`${url.pathname}${url.search}|${language(request.headers.get('accept-language'))}`,
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### A client that sent no `Accept-Encoding` gets gzip bytes
|
|
397
|
+
|
|
398
|
+
**When:** the cache is in front of `app.static(…, { precompressed })`, or of
|
|
399
|
+
any route whose answer depends on a request header, and that header is not
|
|
400
|
+
in `vary`. The first client asked with `Accept-Encoding: gzip`; the next
|
|
401
|
+
one, without it, gets `Content-Encoding: gzip` with `X-Cache: HIT`.
|
|
402
|
+
|
|
403
|
+
**Why:** the default key is built from the path, the query and `vary`
|
|
404
|
+
alone, and a `key` of your own from what it reads. The response's own
|
|
405
|
+
`Vary` is passed on to the client but not read by the cache.
|
|
406
|
+
|
|
407
|
+
**Fix:** name the header in `vary`, or read it in your `key`:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
app.use(cache({ ttl: 60, vary: ['accept-encoding'] }))
|
|
411
|
+
.static('/assets', './public', { precompressed: ['br', 'gzip'] });
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### One visitor sees another visitor's page
|
|
415
|
+
|
|
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.
|
|
420
|
+
|
|
421
|
+
**Why:** the default key is the path and query only. The first visitor's
|
|
422
|
+
response is kept, and served to everyone after.
|
|
423
|
+
|
|
424
|
+
**Fix:** say the response is personal — it is then never kept, nor handed
|
|
425
|
+
to a concurrent request:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
app.get('/me', ({ reply }) =>
|
|
429
|
+
reply(200, { name: 'Grace' }, { headers: { 'cache-control': 'private' } }),
|
|
430
|
+
);
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Or declare the route before the cache, which also saves the lookup
|
|
434
|
+
([Personal responses](guide/keys-and-vary.md#personal-responses)).
|
|
435
|
+
|
|
436
|
+
Then empty what was already kept: `await products.invalidateTag(…)`, or
|
|
437
|
+
restart a process that uses the memory store.
|
|
438
|
+
|
|
439
|
+
### Old data after a write
|
|
440
|
+
|
|
441
|
+
**When:** a write calls `invalidate(path)` or `invalidateTag(tag)`, and a
|
|
442
|
+
read still answers the old response.
|
|
443
|
+
|
|
444
|
+
**Why:** the invalidation did not reach the key, or the store:
|
|
445
|
+
|
|
446
|
+
- `invalidate(path)` forgets the responses of that **exact** path and
|
|
447
|
+
query, prefix included: `/products` is not `/products?page=2`, nor
|
|
448
|
+
`/api/products`.
|
|
449
|
+
- A store of your own does not remember each response's `tags` in `set`:
|
|
450
|
+
neither `invalidate` nor `invalidateTag` reaches anything.
|
|
451
|
+
- Two `cache()` without a `store` have **two memory stores**; invalidating
|
|
452
|
+
through one does not touch the other.
|
|
453
|
+
- Several processes with the **memory store** each keep their own copy;
|
|
454
|
+
the write's process is the only one emptied.
|
|
455
|
+
|
|
456
|
+
**Fix:** invalidate by tag — it reaches every query — on a store every
|
|
457
|
+
cache and process shares:
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
const store = redisCacheStore(connection.client, { name: 'shop' });
|
|
461
|
+
const products = cache({ ttl: 60, store, tags: () => ['products'] });
|
|
462
|
+
|
|
463
|
+
await products.invalidateTag('products');
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
[Invalidation](guide/invalidation.md) covers each case.
|
|
467
|
+
|
|
468
|
+
### A hard reload shows new data, a plain reload the old
|
|
469
|
+
|
|
470
|
+
**When:** `honorClientNoCache: true`.
|
|
471
|
+
|
|
472
|
+
**Why:** a browser sends `Cache-Control: no-cache` on a hard reload
|
|
473
|
+
(Shift-reload) and not on a plain one. With `honorClientNoCache`, that
|
|
474
|
+
request runs the route — but its fresh response does **not** replace the
|
|
475
|
+
one kept, so the next plain request is served the old one.
|
|
476
|
+
|
|
477
|
+
**Fix:** leave `honorClientNoCache` off (the default) and invalidate on
|
|
478
|
+
write; or keep it, knowing it is a way for one client to bypass the cache,
|
|
479
|
+
not to refresh it.
|
|
480
|
+
|
|
481
|
+
### Responses are kept for hours
|
|
482
|
+
|
|
483
|
+
**When:** `ttl` or `staleWhileRevalidate` was given in milliseconds.
|
|
484
|
+
|
|
485
|
+
**Why:** both are **seconds**: `ttl: 60_000` is nearly 17 hours.
|
|
486
|
+
|
|
487
|
+
**Fix:**
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
cache({ ttl: 60, staleWhileRevalidate: 300 }); // one minute fresh, five more stale
|
|
491
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@alxia/cache",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/cache"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/client": "^0.1.0",
|
|
45
|
+
"@alxia/core": "^0.1.0",
|
|
46
|
+
"@types/bun": "^1.4.2"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@alxia/core": "^0.1.0",
|
|
50
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|