jskelet 0.2.4 → 0.2.5
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/AGENTS.md +132 -132
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +287 -287
- package/docs/03-routing.md +480 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1209 -1209
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +305 -305
- package/docs/en/03-routing.md +497 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1239 -1239
- package/docs/en/07-configuration.md +986 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +359 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/cache-panel/i18n.js +670 -670
- package/src/client/cache-panel/login.html +74 -74
- package/src/client/cache-panel/panel.css +756 -756
- package/src/client/cache-panel/panel.html +308 -308
- package/src/client/cache-panel/panel.js +915 -915
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-panel.js +759 -759
- package/src/server/cloudflare.js +607 -595
- package/src/server/create-app.js +291 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
package/docs/en/06-caching.md
CHANGED
|
@@ -1,1239 +1,1239 @@
|
|
|
1
|
-
# 06 — Caching and prewarm
|
|
2
|
-
|
|
3
|
-
This document explains JSkelet's ISR substitute in full detail: the HTML TTL
|
|
4
|
-
cache and its stale-while-revalidate behaviour, where `revalidate` comes from,
|
|
5
|
-
how the cache key is built, the values of the `X-JSkelet-Cache` header, why the
|
|
6
|
-
compressed body is kept in the cache, per-request memoization
|
|
7
|
-
(`withRequestCache` / `cache()`), the data cache (`withDataCache`), how upstream
|
|
8
|
-
failures affect the cache (automatic tracking and `reportUpstreamFailure`) and the prewarm round at
|
|
9
|
-
server startup. The
|
|
10
|
-
measurement rationale behind the decisions is in
|
|
11
|
-
[02-architecture.md](./02-architecture.md), and the full reference of config
|
|
12
|
-
fields is in [07-configuration.md](./07-configuration.md).
|
|
13
|
-
|
|
14
|
-
## The big picture
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
route(controller, { revalidate })
|
|
18
|
-
└─ withHtmlCache(key, ttl, producer) ← TTL + stale-while-revalidate
|
|
19
|
-
└─ withUpstreamTracking(...) ← missing data detection
|
|
20
|
-
└─ withRequestCache(...) ← per-request memoization
|
|
21
|
-
└─ produce() → controller + renderPage
|
|
22
|
-
└─ withDataCache(...) ← upstream data cache
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
The order matters: the **per-request cache must be innermost** so that two
|
|
26
|
-
calls in the same render collapse into a single upstream request; **upstream
|
|
27
|
-
tracking must be inside the HTML cache** so that output produced with missing
|
|
28
|
-
data is not written to the cache.
|
|
29
|
-
|
|
30
|
-
How the two caches divide the work:
|
|
31
|
-
|
|
32
|
-
| | HTML cache | Data cache |
|
|
33
|
-
| --- | --- | --- |
|
|
34
|
-
| What it holds | The whole page (+ its compressed body) | The JSON that came from upstream |
|
|
35
|
-
| Entry size | ~100-200 kB | ~1-20 kB |
|
|
36
|
-
| Entry limit | 500 (`cache().maxEntries`) | 10,000 (`cache().data.maxEntries`) |
|
|
37
|
-
| Who benefits | Pages with traffic: not even rendered | The long tail: rendered, but without going to the API |
|
|
38
|
-
|
|
39
|
-
In practice this distinction means: on a site with tens of thousands of paths it
|
|
40
|
-
is impossible to keep every page hot as HTML — a warm-up that goes past 500
|
|
41
|
-
entries deletes what it just warmed. For the long tail the goal is not "have the
|
|
42
|
-
HTML ready" but **"have the data that produces the page available without going
|
|
43
|
-
to the API"**. Then a page that was never warmed is also produced within
|
|
44
|
-
milliseconds on the first visit, and spends no quota.
|
|
45
|
-
|
|
46
|
-
## Public versus per-visitor
|
|
47
|
-
|
|
48
|
-
Everything in this document applies to HTML that **can go to everyone
|
|
49
|
-
unchanged**. There is no identity in the cache key (only path + query), so a
|
|
50
|
-
page in the cache is the answer for that path, not the answer for whoever asked
|
|
51
|
-
for it first.
|
|
52
|
-
|
|
53
|
-
A page that depends on the user therefore takes a separate path:
|
|
54
|
-
|
|
55
|
-
```js
|
|
56
|
-
app.get("/dashboard", route(async ({ req }) => { … }, { private: true }));
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
`private: true` does three things at once: the cache is disabled, a
|
|
60
|
-
`cache.html` pattern **cannot** override that decision, and the response is
|
|
61
|
-
sent with `private, no-store` and `Vary: Cookie`, without an ETag. The details
|
|
62
|
-
and the session/CSRF side are in
|
|
63
|
-
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
64
|
-
|
|
65
|
-
If you forget the flag, the framework does not stay quiet: as soon as the
|
|
66
|
-
controller reads `Cookie`, `Authorization` or `req.session`/`req.user`, the
|
|
67
|
-
render is marked and **not written** to the cache. In development the request
|
|
68
|
-
fails with an explanation, in production it is served with `no-store` and
|
|
69
|
-
logged. The guard is a last line of defence, not an excuse — the right place is
|
|
70
|
-
`private: true`.
|
|
71
|
-
|
|
72
|
-
## `revalidate` — where the TTL comes from
|
|
73
|
-
|
|
74
|
-
A route's TTL can come from two sources, and **the config wins**:
|
|
75
|
-
|
|
76
|
-
1. `route(controller, { revalidate: 60 })` — the route's own duration.
|
|
77
|
-
2. The matching pattern inside `jskelet.config.mjs` → `cache().html`. If it
|
|
78
|
-
exists it overrides the route's value.
|
|
79
|
-
|
|
80
|
-
The one exception is `private: true`: a matching pattern is ignored. The lock is
|
|
81
|
-
one-way, because a mistake in the other direction means a silent data leak.
|
|
82
|
-
|
|
83
|
-
```js
|
|
84
|
-
// jskelet.config.mjs
|
|
85
|
-
export default {
|
|
86
|
-
async cache() {
|
|
87
|
-
return {
|
|
88
|
-
html: {
|
|
89
|
-
"/": 60,
|
|
90
|
-
"/news/:slug": 300,
|
|
91
|
-
"/tag/:slug": 120,
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Overriding from the config makes it possible to tune the freshness profile of
|
|
99
|
-
the whole site from a single file; you do not have to walk through the route
|
|
100
|
-
files.
|
|
101
|
-
|
|
102
|
-
The resolution result is **remembered per path**, so a pattern scan is not done
|
|
103
|
-
on every request. If there is no `cache().html` rule at all, the route's own
|
|
104
|
-
value is used directly.
|
|
105
|
-
|
|
106
|
-
If `revalidate` is not given, or is 0, the page is **not cached at all**: every
|
|
107
|
-
request is rendered and the response is sent with
|
|
108
|
-
`Cache-Control: private, no-store` and no ETag. No `X-JSkelet-Cache` header is
|
|
109
|
-
written either — the cache path never ran, so `MISS` would be misleading.
|
|
110
|
-
|
|
111
|
-
Sending `no-store` on a dynamic page is deliberate. HTTP treats a response with
|
|
112
|
-
no directives as "heuristically cacheable", so an intermediate proxy or the
|
|
113
|
-
browser's back button could store HTML produced for a single visitor.
|
|
114
|
-
|
|
115
|
-
The cache also only kicks in for `GET` requests.
|
|
116
|
-
|
|
117
|
-
## The cache key
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
`${path}?${the allowed query parameters, sorted}`
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
For a request without a query the key is just the path. **A request that carries
|
|
124
|
-
a query parameter is dynamic by default**: it never enters the cache and is sent
|
|
125
|
-
with `private, no-store`. Caching every variant of a path mints an unbounded
|
|
126
|
-
number of keys (`?utm_source=…` and friends), and in a 500-entry store LRU then
|
|
127
|
-
evicts the real pages in favour of campaign variants.
|
|
128
|
-
|
|
129
|
-
Which parameter actually changes the output is declared by the application, in
|
|
130
|
-
`jskelet.config.mjs` → `cache().query`:
|
|
131
|
-
|
|
132
|
-
```js
|
|
133
|
-
cache: () => ({
|
|
134
|
-
html: { "/list": 60 },
|
|
135
|
-
query: { "/list": ["page"] },
|
|
136
|
-
}),
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Now `/list?page=2` and `/list?page=3` are separate entries, while
|
|
140
|
-
`/list?page=2&utm_source=x` shares the `?page=2` copy: a parameter outside the
|
|
141
|
-
list never reaches the key. A pattern mapped to `true` puts every parameter in
|
|
142
|
-
the key (careful: nothing but `maxEntries` then bounds the entry count), and one
|
|
143
|
-
mapped to `[]` ignores the query entirely. Details:
|
|
144
|
-
[07-configuration.md](./07-configuration.md).
|
|
145
|
-
|
|
146
|
-
## Stale-while-revalidate
|
|
147
|
-
|
|
148
|
-
The entry structure:
|
|
149
|
-
|
|
150
|
-
```
|
|
151
|
-
expiresAt = now + ttl
|
|
152
|
-
staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Read behaviour:
|
|
156
|
-
|
|
157
|
-
| State | Response | Background |
|
|
158
|
-
| --- | --- | --- |
|
|
159
|
-
| `now < expiresAt` | The cached HTML, `HIT` | — |
|
|
160
|
-
| `expiresAt ≤ now < staleUntil` | The cached HTML **immediately**, `STALE` | A refresh is started |
|
|
161
|
-
| `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` | — |
|
|
162
|
-
|
|
163
|
-
A failure of the refresh inside the stale window does not affect the request:
|
|
164
|
-
the old HTML stays valid for the whole window and the error is only logged
|
|
165
|
-
(`[html-cache] background refresh failed: …`).
|
|
166
|
-
|
|
167
|
-
Concurrent refreshes for the same key are collapsed into a single run (the
|
|
168
|
-
`inflight` map): a hundred concurrent requests fall to one render.
|
|
169
|
-
|
|
170
|
-
The gain: after the first warm-up no request waits for a render. The price: the
|
|
171
|
-
data in the HTML can be at most `revalidate + one refresh round` behind. That
|
|
172
|
-
price is acceptable, because live fields such as prices are updated on the
|
|
173
|
-
client over WebSocket.
|
|
174
|
-
|
|
175
|
-
The store is an LRU: an accessed entry is moved to the end, and once the limit
|
|
176
|
-
(`cache().maxEntries`, 500 by default) is exceeded the oldest is evicted.
|
|
177
|
-
|
|
178
|
-
## What gets written to the cache
|
|
179
|
-
|
|
180
|
-
Only output that satisfies **both** of these two conditions is stored:
|
|
181
|
-
|
|
182
|
-
1. `status === 200`
|
|
183
|
-
2. `degraded !== true` — no transient upstream failure was reported during the
|
|
184
|
-
render.
|
|
185
|
-
|
|
186
|
-
So 404 pages, redirects and HTML produced with missing data do not enter the
|
|
187
|
-
cache.
|
|
188
|
-
|
|
189
|
-
## Response headers
|
|
190
|
-
|
|
191
|
-
`route()` writes `X-JSkelet-Cache` on every response (the header name can be
|
|
192
|
-
changed with `brand.cacheHeader`):
|
|
193
|
-
|
|
194
|
-
| Value | Meaning |
|
|
195
|
-
| --- | --- |
|
|
196
|
-
| `HIT` | From the cache, fresh |
|
|
197
|
-
| `STALE` | From the cache, expired; being refreshed in the background |
|
|
198
|
-
| `MISS` | Rendered on this request (or the cache is off) |
|
|
199
|
-
|
|
200
|
-
On cacheable responses, additionally:
|
|
201
|
-
|
|
202
|
-
```
|
|
203
|
-
Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
`max-age=0` turns off storage in the browser, `s-maxage` announces the duration
|
|
207
|
-
to intermediate layers (CDN, reverse proxy). This way, when a CDN sits in
|
|
208
|
-
front, the same freshness model works across both layers together.
|
|
209
|
-
|
|
210
|
-
## Storing the compressed body
|
|
211
|
-
|
|
212
|
-
Every cached entry carries an `encoded` map and shares the same lifetime as the
|
|
213
|
-
HTML. The first time a page is requested with brotli or gzip the output is
|
|
214
|
-
computed and put in the map; on subsequent requests the same buffer is sent.
|
|
215
|
-
The same page is not re-brotli'd on every request.
|
|
216
|
-
|
|
217
|
-
On this path `Content-Encoding`, `Vary` and `Content-Length` are written
|
|
218
|
-
directly by `route()`; the compression middleware does not kick in because it
|
|
219
|
-
sees `Content-Encoding`.
|
|
220
|
-
|
|
221
|
-
`HEAD` requests are not compressed (there is no body). If the client accepts
|
|
222
|
-
neither brotli nor gzip, plain HTML is sent.
|
|
223
|
-
|
|
224
|
-
## Per-request memoization: `cache()`
|
|
225
|
-
|
|
226
|
-
The equivalent of React's `cache()` function: calls made with the same
|
|
227
|
-
arguments within the same request run only once.
|
|
228
|
-
|
|
229
|
-
```js
|
|
230
|
-
// lib/api/articles.js
|
|
231
|
-
import { cache } from "jskelet";
|
|
232
|
-
|
|
233
|
-
export const getArticle = cache(async (slug) => {
|
|
234
|
-
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
|
|
235
|
-
return response.json();
|
|
236
|
-
});
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
Now if both the controller and `hooks.layoutContext()` ask for the same article
|
|
240
|
-
in the same render, a single upstream request is made.
|
|
241
|
-
|
|
242
|
-
Details:
|
|
243
|
-
|
|
244
|
-
- The context is carried with `AsyncLocalStorage` and is set up by
|
|
245
|
-
`withRequestCache()` inside `route()`.
|
|
246
|
-
- **Without a context, memoization is disabled** and the function is called
|
|
247
|
-
directly. Calling it from a script or from inside another process is safe.
|
|
248
|
-
- The key is `JSON.stringify(args)`; argument-less calls share the `""` key. Do
|
|
249
|
-
not use it with arguments that cannot be serialised (functions, `Symbol`,
|
|
250
|
-
circular objects).
|
|
251
|
-
- What is stored is the function's **return value**, that is, the Promise
|
|
252
|
-
itself for `async` functions. Because the same Promise is shared, concurrent
|
|
253
|
-
calls collapse too.
|
|
254
|
-
- `withRequestCache(run)` is exported; it can be used to set up the same scope
|
|
255
|
-
outside `route()` (for example in an Express handler you wrote yourself).
|
|
256
|
-
|
|
257
|
-
## Cross-request data cache: `withDataCache`
|
|
258
|
-
|
|
259
|
-
`cache()` only lives for the duration of **a single request**. What it takes to
|
|
260
|
-
protect the long tail from the API quota is a data layer that lives across
|
|
261
|
-
requests, has a TTL and refreshes itself:
|
|
262
|
-
|
|
263
|
-
```js
|
|
264
|
-
// lib/api/articles.js
|
|
265
|
-
import { withDataCache, reportUpstreamFailure } from "jskelet";
|
|
266
|
-
|
|
267
|
-
export async function getArticle(slug) {
|
|
268
|
-
return withDataCache(`news:${slug}`, 600, async () => {
|
|
269
|
-
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
|
|
270
|
-
|
|
271
|
-
if (!response.ok) {
|
|
272
|
-
reportUpstreamFailure({ status: response.status, path: `/articles/${slug}` });
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return response.json();
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
The wrapper form of the same pattern — the key is derived from the arguments:
|
|
282
|
-
|
|
283
|
-
```js
|
|
284
|
-
import { dataCache } from "jskelet";
|
|
285
|
-
|
|
286
|
-
export const getArticle = dataCache(
|
|
287
|
-
async (slug) => apiGet(`/articles/${slug}`),
|
|
288
|
-
{ key: "news", revalidate: 600 },
|
|
289
|
-
);
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
Behaviour:
|
|
293
|
-
|
|
294
|
-
| State | Result |
|
|
295
|
-
| --- | --- |
|
|
296
|
-
| Fresh entry | Returns immediately, the `producer` does not run |
|
|
297
|
-
| TTL expired, still inside the stale window | The stale value returns **immediately**, the refresh runs in the background |
|
|
298
|
-
| No entry | The `producer` is awaited |
|
|
299
|
-
| The `producer` failed, a stale entry exists | The stale value returns, warning: `[data-cache] producer failed, serving stale value: …` |
|
|
300
|
-
| The `producer` failed, there is no entry | The error goes to the caller |
|
|
301
|
-
|
|
302
|
-
Details:
|
|
303
|
-
|
|
304
|
-
- **Concurrent calls for the same key collapse into one upstream request.** This
|
|
305
|
-
is the behaviour that saves the most quota during warm-up rounds: if 50 pages
|
|
306
|
-
want the same index data, the API is called once.
|
|
307
|
-
- **`null` and `undefined` are not stored.** An application's HTTP client
|
|
308
|
-
usually returns `null` on failure; storing that would freeze a transient 429
|
|
309
|
-
into "no data" for the whole TTL. Pass `{ storeEmpty: true }` if you want the
|
|
310
|
-
empty answer stored deliberately.
|
|
311
|
-
- **The stale window is longer than the HTML one**: `staleFactor` defaults to 10,
|
|
312
|
-
so an entry stays as an emergency fallback for 11 times its TTL. Stale data is
|
|
313
|
-
better than an incomplete page. It can be turned off per key with
|
|
314
|
-
`{ staleFactor: 0 }`.
|
|
315
|
-
- The key belongs entirely to the application: distinctions such as language,
|
|
316
|
-
version or page number go into the key (`news:en:v2:${slug}`).
|
|
317
|
-
- When the TTL is `0` the cache is disabled and the `producer` runs on every
|
|
318
|
-
call — enough to switch a setting off temporarily.
|
|
319
|
-
|
|
320
|
-
The management surface:
|
|
321
|
-
|
|
322
|
-
| Function | What it does |
|
|
323
|
-
| --- | --- |
|
|
324
|
-
| `withDataCache(key, ttlSeconds, producer, options?)` | The main entry point |
|
|
325
|
-
| `dataCache(fn, { key, revalidate, … })` | The function wrapper |
|
|
326
|
-
| `clearDataCache(prefix?)` | Drops the entries matching the prefix (or all of them), returns how many were removed |
|
|
327
|
-
| `getDataCacheSize()` | The number of entries |
|
|
328
|
-
| `getDataCacheEntries()` | A dump: `{ key, stale, expiresIn }`. The value itself is not returned. |
|
|
329
|
-
|
|
330
|
-
`clearDataCache("news:")` is the counterpart of a "this content was updated"
|
|
331
|
-
webhook: it drops one section's data **and stales the HTML pages that read it**,
|
|
332
|
-
so the update shows up without waiting for a TTL. See "Automatic dependencies"
|
|
333
|
-
below.
|
|
334
|
-
|
|
335
|
-
## Degraded render: `reportUpstreamFailure`
|
|
336
|
-
|
|
337
|
-
If upstream went down during the render, the output contains missing data.
|
|
338
|
-
Rather than serving such HTML for the whole TTL, the right behaviour is to
|
|
339
|
-
**never write it** to the cache: the next request tries again.
|
|
340
|
-
|
|
341
|
-
This information arrives through two paths.
|
|
342
|
-
|
|
343
|
-
### Automatic tracking (the default)
|
|
344
|
-
|
|
345
|
-
At startup `createApp()` wraps `globalThis.fetch` and reports **transient**
|
|
346
|
-
failures (`429`, `5xx`, network errors) from calls made during a render on its
|
|
347
|
-
own. No application code is needed; if your API client talks over `fetch`, the
|
|
348
|
-
rate limit protection is already in place.
|
|
349
|
-
|
|
350
|
-
The details:
|
|
351
|
-
|
|
352
|
-
- Only calls inside a render scope count. A `fetch` from a script, a cron job or
|
|
353
|
-
anywhere outside a request is left untouched.
|
|
354
|
-
- Requests to our own server (`localhost`, `127.0.0.1`) are skipped: the warm-up
|
|
355
|
-
round and the health check are not upstream.
|
|
356
|
-
- Deterministic answers such as `404`/`403` are **not** reported automatically.
|
|
357
|
-
In most APIs a `404` means "no such record"; treating it as missing data would
|
|
358
|
-
produce a false warning on every not-found page.
|
|
359
|
-
- To turn it off: `cache().trackUpstream: false`. An application that wraps
|
|
360
|
-
`fetch` itself (metrics, retries, a circuit breaker) may prefer that.
|
|
361
|
-
|
|
362
|
-
### Manual reporting
|
|
363
|
-
|
|
364
|
-
For a client that does not use `fetch` (a database driver, gRPC, a vendor SDK),
|
|
365
|
-
or for a layer that wants to flag permanent failures too, the contract is
|
|
366
|
-
unchanged. The dependency direction is deliberately inverted: the framework does
|
|
367
|
-
not know about the data layer, the data layer notifies the framework. If nobody
|
|
368
|
-
ever calls it, the cost is an empty array. If the same failure arrives through
|
|
369
|
-
both paths it is de-duplicated.
|
|
370
|
-
|
|
371
|
-
```js
|
|
372
|
-
// lib/api/client.js
|
|
373
|
-
import { reportUpstreamFailure } from "jskelet";
|
|
374
|
-
|
|
375
|
-
export async function apiGet(path) {
|
|
376
|
-
try {
|
|
377
|
-
const response = await fetch(`${process.env.API_ORIGIN}${path}`);
|
|
378
|
-
|
|
379
|
-
if (!response.ok) {
|
|
380
|
-
reportUpstreamFailure({ status: response.status, path });
|
|
381
|
-
return null;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return response.json();
|
|
385
|
-
} catch (error) {
|
|
386
|
-
// No response at all: status 0 means a network error.
|
|
387
|
-
reportUpstreamFailure({ status: 0, path });
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
### Distinguishing transient and permanent failures
|
|
394
|
-
|
|
395
|
-
| State | Counts as | Result |
|
|
396
|
-
| --- | --- | --- |
|
|
397
|
-
| `0` (network error), `408`, `425`, `429`, `>= 500` | **Transient** | The page is not written to the cache, warning: `[render] <path> was produced with missing data, not caching it (…)` |
|
|
398
|
-
| Others (`400`, `403`, `404`, …) | **Permanent** | Only a warning: `[render] <path> was produced with missing data, upstream is failing permanently (…)`. The cache is not blocked. |
|
|
399
|
-
|
|
400
|
-
Permanent failures not blocking the cache is deliberate: deterministic answers
|
|
401
|
-
do not get better by retrying. Turning the cache off because of them would mean
|
|
402
|
-
rendering the page from scratch on every visit — the content comes back just as
|
|
403
|
-
incomplete, and the visitor only pays the render time.
|
|
404
|
-
|
|
405
|
-
Output produced with missing data is **not offered to shared caches** either: a
|
|
406
|
-
`degraded` response gets `private, no-store` instead of `public, s-maxage=…`.
|
|
407
|
-
Taking back the "do not store" decision at the CDN would repeat the same mistake
|
|
408
|
-
one layer up. The diagnostic header (`X-JSkelet-Cache: MISS`) is still written.
|
|
409
|
-
|
|
410
|
-
### When `notFound()` coincides with a transient failure
|
|
411
|
-
|
|
412
|
-
A controller that calls `notFound()` because no data arrived can turn the whole
|
|
413
|
-
site into 404s when upstream is rate limited — and because those 404s enter the
|
|
414
|
-
cache, a temporary quota problem becomes a "this page does not exist" answer for
|
|
415
|
-
the whole TTL. For a search engine that is a permanent loss.
|
|
416
|
-
|
|
417
|
-
The framework separates the two cases: if a **transient** upstream failure
|
|
418
|
-
happened during the render, `notFound()` is not served as a 404. In order:
|
|
419
|
-
|
|
420
|
-
1. The page is **retried** after a short delay (once by default, after 300 ms).
|
|
421
|
-
The retry runs in its own upstream and per-request cache scope, so neither
|
|
422
|
-
the first round's failure nor its memoized empty answers affect it.
|
|
423
|
-
2. If the second round can produce the page, the visitor sees the **real
|
|
424
|
-
content** and the output is cached normally. Warm-up logs show this is
|
|
425
|
-
common: the same path returns 200 seconds later.
|
|
426
|
-
3. If the retries are exhausted the response is a `503` — not cached, carrying
|
|
427
|
-
`Retry-After`, and the next request can still produce the real content.
|
|
428
|
-
|
|
429
|
-
| During the render | Result of `notFound()` |
|
|
430
|
-
| --- | --- |
|
|
431
|
-
| A transient failure exists (`429`, `5xx`, network error) | Retry → the page if it succeeds; otherwise `503`, `Retry-After: 30`, `no-store` |
|
|
432
|
-
| The retry got a clean answer saying "not there" | A normal `404` |
|
|
433
|
-
| A permanent failure (`404`, `403`…) or no failure | A normal `404`, no retry |
|
|
434
|
-
|
|
435
|
-
The log lines:
|
|
436
|
-
|
|
437
|
-
```
|
|
438
|
-
[render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
|
|
439
|
-
[render] /news/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
So **an existing page never turns into a 404**: either the real content arrives,
|
|
443
|
-
or an uncached 503 does. Nothing is frozen as "missing".
|
|
444
|
-
|
|
445
|
-
The cost of a retry is a second round of requests on upstream, which is why the
|
|
446
|
-
default is a single attempt. The setting is `cache().transientRetry`:
|
|
447
|
-
|
|
448
|
-
```js
|
|
449
|
-
cache: {
|
|
450
|
-
transientRetry: { attempts: 2, delayMs: 500 },
|
|
451
|
-
}
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
`transientRetry: false` (or `attempts: 0`) disables the retry and falls straight
|
|
455
|
-
through to the 503.
|
|
456
|
-
|
|
457
|
-
## Upstream rate limit: `cache().upstream`
|
|
458
|
-
|
|
459
|
-
Everything above describes what happens **after** a 429 arrives. This section is
|
|
460
|
-
about not getting one in the first place.
|
|
461
|
-
|
|
462
|
-
The brake sits inside the `trackUpstreamFetch()` wrapper, that is, where the
|
|
463
|
-
real `fetch` call goes out. The prewarm pass's `prewarm.rps` cannot do this job:
|
|
464
|
-
it counts **page** requests to our own server, but one page render may make one
|
|
465
|
-
API call or twenty. What binds the quota is the number of calls, not the number
|
|
466
|
-
of pages — and with the brake here, prewarming and real traffic spend the same
|
|
467
|
-
budget.
|
|
468
|
-
|
|
469
|
-
Off by default: unless `rate` is given, no request ever waits and the cost is a
|
|
470
|
-
single branch.
|
|
471
|
-
|
|
472
|
-
```js
|
|
473
|
-
// jskelet.config.mjs
|
|
474
|
-
cache: () => ({
|
|
475
|
-
upstream: {
|
|
476
|
-
rate: 10, // ceiling in calls per second, per host
|
|
477
|
-
burst: 20, // tolerance for short bursts
|
|
478
|
-
concurrency: 8, // calls in flight at once
|
|
479
|
-
hosts: {
|
|
480
|
-
// Endpoints with a different quota get their own settings.
|
|
481
|
-
"api.example.com": { rate: 3, concurrency: 2 },
|
|
482
|
-
},
|
|
483
|
-
},
|
|
484
|
-
}),
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
### Three mechanisms, three different limits
|
|
488
|
-
|
|
489
|
-
| Mechanism | What it bounds | Settings |
|
|
490
|
-
| --- | --- | --- |
|
|
491
|
-
| Token bucket | Average rate (calls per second) | `rate`, `burst` |
|
|
492
|
-
| Concurrency | Instantaneous pressure (calls in flight) | `concurrency` |
|
|
493
|
-
| AIMD | What the right rate actually is | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
|
|
494
|
-
|
|
495
|
-
The third one is the real idea. A fixed rate is always either too slow or too
|
|
496
|
-
fast: nobody can write the true quota limit into a config file, and it changes
|
|
497
|
-
during the day anyway. So `rate` is treated as a **ceiling** and the actual rate
|
|
498
|
-
moves with what the upstream says:
|
|
499
|
-
|
|
500
|
-
- **429 or 503** → the rate is halved (multiplicative decrease). If the response
|
|
501
|
-
carries `Retry-After`, the bucket stops entirely for that long — the upstream
|
|
502
|
-
is already telling you how long to wait.
|
|
503
|
-
- **Every clean window** → the rate climbs by `increaseStep` (additive
|
|
504
|
-
increase), up to the `rate` ceiling.
|
|
505
|
-
|
|
506
|
-
Decreasing multiplicatively and increasing additively is deliberate. The other
|
|
507
|
-
way round would earn a fresh 429 every window.
|
|
508
|
-
|
|
509
|
-
### Circuit breaker
|
|
510
|
-
|
|
511
|
-
A host that returns `breakerFailures` (default 5) rate limits in a row is
|
|
512
|
-
bypassed entirely for `breakerCooldownMs`: the call is not made at all and is
|
|
513
|
-
reported straight away as a transient failure.
|
|
514
|
-
|
|
515
|
-
It looks harsh, but the asymmetry demands it: because a 429 counts as transient,
|
|
516
|
-
the HTML produced by that call is **not stored**. So a pass that hit the rate
|
|
517
|
-
limit spends quota and stores nothing in return — and the next pass finds the
|
|
518
|
-
same page cold and tries again. The breaker stops that burn.
|
|
519
|
-
|
|
520
|
-
```
|
|
521
|
-
[upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
Only 429 and 503 count. A `400`/`404` is not a quota problem and neither is a
|
|
525
|
-
`500`: slowing down does not fix them, it only makes the site slower.
|
|
526
|
-
|
|
527
|
-
### Seeing the state
|
|
528
|
-
|
|
529
|
-
`getUpstreamLimiterStatus()` returns the current rate, calls in flight and
|
|
530
|
-
counters per host; the dev panel's **Server** tab prints the same thing. During
|
|
531
|
-
a 429 storm, tuning without knowing "what rate is it down to right now" is
|
|
532
|
-
guesswork.
|
|
533
|
-
|
|
534
|
-
```js
|
|
535
|
-
import { getUpstreamLimiterStatus } from "jskelet";
|
|
536
|
-
|
|
537
|
-
// [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
|
|
538
|
-
// active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
### Before turning it on
|
|
542
|
-
|
|
543
|
-
The rate limit is a last resort. If hundreds of pages fetch the same upstream
|
|
544
|
-
response, the real fix is keeping the
|
|
545
|
-
[`withDataCache`](#cross-request-data-cache-withdatacache) TTL longer than the
|
|
546
|
-
pass interval: a 400-page pass then makes one call for a shared endpoint. The
|
|
547
|
-
brake slows those calls down, it does not reduce their number.
|
|
548
|
-
|
|
549
|
-
## Managing the cache
|
|
550
|
-
|
|
551
|
-
`jskelet` exports these functions:
|
|
552
|
-
|
|
553
|
-
| Function | What it does |
|
|
554
|
-
| --- | --- |
|
|
555
|
-
| `withHtmlCache(key, ttlSeconds, producer)` | For using the cache directly. If `ttlSeconds` is 0 the producer always runs. |
|
|
556
|
-
| `invalidateHtmlCache(target, options?)` | Stales the matching pages (or drops them with `{ hard: true }`) and returns how many were affected. |
|
|
557
|
-
| `clearHtmlCache()` | Empties the store completely. |
|
|
558
|
-
| `getHtmlCacheSize()` | The number of entries. |
|
|
559
|
-
| `getHtmlCacheEntries()` | A dump: `{ key, bytes, status, stale, expiresIn, encodings, deps }`. The HTML body is not returned, only its size. |
|
|
560
|
-
|
|
561
|
-
### Targeted invalidation
|
|
562
|
-
|
|
563
|
-
`invalidateHtmlCache()` fills the gap between waiting for the TTL and flushing
|
|
564
|
-
the whole cache:
|
|
565
|
-
|
|
566
|
-
```js
|
|
567
|
-
import { invalidateHtmlCache } from "jskelet";
|
|
568
|
-
|
|
569
|
-
invalidateHtmlCache("/news/abc"); // that path and everything under it
|
|
570
|
-
invalidateHtmlCache("/news/:slug"); // the pattern syntax
|
|
571
|
-
invalidateHtmlCache([/-comments$/, "/"]); // regexps and lists
|
|
572
|
-
```
|
|
573
|
-
|
|
574
|
-
The default is to **stale** the entry, not to delete it: it is treated as
|
|
575
|
-
expired and falls through the normal stale-while-revalidate path. When a webhook
|
|
576
|
-
takes down five hundred pages at once, a hard delete starts five hundred cold
|
|
577
|
-
renders at exactly the moment the content changed, and hammers the upstream.
|
|
578
|
-
Staling instead hands the visitor the old HTML without a wait, and the refresh
|
|
579
|
-
runs in the background, once per key. Use `{ hard: true }` when the old HTML is
|
|
580
|
-
genuinely invalid.
|
|
581
|
-
|
|
582
|
-
Since the key is `path?query`, matching is done against the **path**: every
|
|
583
|
-
query variant of a path (including `?utm_source=…`) is covered by one call. For
|
|
584
|
-
a plain string the prefix stops at a segment boundary — a `/news` rule does not
|
|
585
|
-
touch `/newsletter`.
|
|
586
|
-
|
|
587
|
-
An in-flight render is targeted too: a pass that started before the purge is
|
|
588
|
-
carrying data that is now out of date, so it is **not** stored and the next
|
|
589
|
-
request starts a fresh pass.
|
|
590
|
-
|
|
591
|
-
### Automatic dependencies: `clearDataCache` refreshes the HTML too
|
|
592
|
-
|
|
593
|
-
You do not have to declare which page is affected by which content. Every
|
|
594
|
-
`withDataCache` key read during a render is recorded, and when `clearDataCache()`
|
|
595
|
-
drops a key, every HTML entry that **actually read it** is staled.
|
|
596
|
-
|
|
597
|
-
```js
|
|
598
|
-
// the "this article changed" webhook
|
|
599
|
-
clearDataCache(`news:${slug}`);
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
That single line refreshes the article page, the home page that lists it and the
|
|
603
|
-
tag page together — because all three read that key. The most common mistake in
|
|
604
|
-
manual tagging (marking the detail page and forgetting the listing) is
|
|
605
|
-
structurally impossible here: nothing is declared, everything is observed.
|
|
606
|
-
|
|
607
|
-
Details:
|
|
608
|
-
|
|
609
|
-
- Dependencies are collected **on every refresh**, since the keys a page reads
|
|
610
|
-
can change over time.
|
|
611
|
-
- A purge that lands while a render is in flight is caught as well: that pass
|
|
612
|
-
would be stale the moment it was born, so it is not stored.
|
|
613
|
-
- The dependency count per page shows up as `deps` in the `getHtmlCacheEntries()`
|
|
614
|
-
dump. If an invalidation is not refreshing the page you expected, look there
|
|
615
|
-
first: the page may not be reading that data through `withDataCache`.
|
|
616
|
-
- An application that does not use `withDataCache` has nothing to record;
|
|
617
|
-
tracking can be turned off entirely with `cache().trackDependencies: false`.
|
|
618
|
-
- Staled paths go to the **front** of the prewarm queue. If `prewarm` is set up
|
|
619
|
-
the page is refreshed without waiting for a visitor, and the pass summary says
|
|
620
|
-
so: `[prewarm] warmed 12/12 pages, 3 invalidated (0.4s)`.
|
|
621
|
-
|
|
622
|
-
To write an admin endpoint:
|
|
623
|
-
|
|
624
|
-
```js
|
|
625
|
-
import { clearHtmlCache, getHtmlCacheEntries } from "jskelet";
|
|
626
|
-
|
|
627
|
-
export default function register(app) {
|
|
628
|
-
app.post("/_admin/cache/clear", (req, res) => {
|
|
629
|
-
if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
|
|
630
|
-
res.status(404).end();
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
clearHtmlCache();
|
|
634
|
-
res.json({ ok: true });
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
app.get("/_admin/cache", (req, res) => {
|
|
638
|
-
res.json(getHtmlCacheEntries());
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
```
|
|
642
|
-
|
|
643
|
-
The dev server also clears the cache by itself whenever the manifest changes:
|
|
644
|
-
the stored HTML would be carrying asset URLs with old hashes, and if it were
|
|
645
|
-
not cleared the page would keep requesting a deleted file
|
|
646
|
-
([09-dev-tools.md](./09-dev-tools.md)).
|
|
647
|
-
|
|
648
|
-
Because the cache lives in process memory, if you run more than one
|
|
649
|
-
process/replica each one has its own cache; `clearHtmlCache()` only affects the
|
|
650
|
-
process it is called in. The next section covers how to get past this when you
|
|
651
|
-
run several instances.
|
|
652
|
-
|
|
653
|
-
## A shared cache: Redis
|
|
654
|
-
|
|
655
|
-
The default cache belongs to a single process. That is the fastest and simplest
|
|
656
|
-
setup for a site running one instance — but two problems appear once you run
|
|
657
|
-
three replicas:
|
|
658
|
-
|
|
659
|
-
1. **Every replica warms up on its own.** When a new instance comes up, or a
|
|
660
|
-
container is replaced after a deploy, its cache is empty: the same page is
|
|
661
|
-
rendered three times and the same data is fetched three times.
|
|
662
|
-
2. **Invalidation reaches one replica.** The webhook that calls
|
|
663
|
-
`invalidateHtmlCache()` only refreshes the instance that received the
|
|
664
|
-
request; the others wait for the TTL. A visitor sees the old or the new
|
|
665
|
-
content depending on which replica they land on.
|
|
666
|
-
|
|
667
|
-
`cache().redis` solves both. Redis is **not the primary store**: the in-process
|
|
668
|
-
cache (L1) stays exactly as it is and every request reads it; Redis is a second
|
|
669
|
-
tier (L2).
|
|
670
|
-
|
|
671
|
-
```js
|
|
672
|
-
// jskelet.config.mjs
|
|
673
|
-
export default {
|
|
674
|
-
cache() {
|
|
675
|
-
return {
|
|
676
|
-
html: { "/news/:slug": 300 },
|
|
677
|
-
redis: {
|
|
678
|
-
enabled: true,
|
|
679
|
-
url: process.env.REDIS_URL,
|
|
680
|
-
namespace: "news-site",
|
|
681
|
-
},
|
|
682
|
-
};
|
|
683
|
-
},
|
|
684
|
-
};
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
`ioredis` is an optional peer dependency, installed in the application itself:
|
|
688
|
-
|
|
689
|
-
```bash
|
|
690
|
-
npm install ioredis
|
|
691
|
-
```
|
|
692
|
-
|
|
693
|
-
If it is not installed, or Redis cannot be reached, a warning is printed and the
|
|
694
|
-
site **keeps running on the in-process cache**. The same happens if Redis goes
|
|
695
|
-
down while running: a circuit breaker bypasses the tier for five seconds after
|
|
696
|
-
five consecutive failures, so requests do not each wait for a network timeout.
|
|
697
|
-
|
|
698
|
-
### What you get
|
|
699
|
-
|
|
700
|
-
- **A cold instance finds a warm cache.** For a path that is not in L1, Redis is
|
|
701
|
-
read before the render runs; if another replica already produced that page, the
|
|
702
|
-
render never happens.
|
|
703
|
-
- **The data cache spends the quota once.** `withDataCache` works the same way,
|
|
704
|
-
and the gain is bigger here: JSON is small, and what one replica fetched is
|
|
705
|
-
enough for all of them.
|
|
706
|
-
- **Invalidation reaches every replica.** `invalidateHtmlCache()`,
|
|
707
|
-
`clearHtmlCache()` and `clearDataCache()` leave a message on a pub/sub
|
|
708
|
-
channel and each instance applies the same operation to its own L1. The
|
|
709
|
-
pattern is published, not the matched keys — which path is hot where depends
|
|
710
|
-
on the replica.
|
|
711
|
-
|
|
712
|
-
### Key layout
|
|
713
|
-
|
|
714
|
-
```
|
|
715
|
-
_jskelet:{namespace}:{buildId}:html:{path}?{query}
|
|
716
|
-
_jskelet:{namespace}:{buildId}:data:{key}
|
|
717
|
-
_jskelet:{namespace}:events
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
`buildId` changes with every build (`jskelet build` writes it to
|
|
721
|
-
`.jskelet/build.json`) and it is a **required** part: the stored HTML embeds
|
|
722
|
-
hashed asset paths, so after a deploy the old HTML is invalid. Because the id
|
|
723
|
-
sits in the prefix, a new version automatically writes into a new namespace and
|
|
724
|
-
the old keys die with their TTL — no manual cleanup and no `FLUSHDB`. When the
|
|
725
|
-
build has not been run the id is `dev`.
|
|
726
|
-
|
|
727
|
-
`namespace` separates several applications sharing one Redis. The event channel
|
|
728
|
-
deliberately does **not** carry `buildId`: during a deploy the old and the new
|
|
729
|
-
version run side by side and a purge has to reach both.
|
|
730
|
-
|
|
731
|
-
### Trade-offs worth knowing
|
|
732
|
-
|
|
733
|
-
- **Personalised output is never shared.** A render marked `storable: false` (a
|
|
734
|
-
page that read a cookie or `Authorization`) is never written to Redis. The
|
|
735
|
-
rule already holds in a single process, but it matters far more in a shared
|
|
736
|
-
tier: a leak would mean serving one user's HTML to the whole cluster.
|
|
737
|
-
`degraded` renders and non-200 status codes are not shared either.
|
|
738
|
-
- **Compressed bodies stay local by default.** `storeEncoded: true` turns this
|
|
739
|
-
on, but it doubles or triples the size per entry; recomputing brotli is
|
|
740
|
-
usually cheaper than downloading it from Redis.
|
|
741
|
-
- **A soft invalidation deletes the Redis copy.** Staling in Redis would mean a
|
|
742
|
-
read-modify-write round per key, and a webhook drops thousands of keys at
|
|
743
|
-
once. The cost of deleting is one render on a replica that never saw that
|
|
744
|
-
path; replicas whose L1 is hot keep serving the old HTML through the stale
|
|
745
|
-
window.
|
|
746
|
-
- **Only fresh entries are accepted.** Promoting a stale copy into L1 would
|
|
747
|
-
postpone the refresh forever: the entry stays stale, every pass reads Redis
|
|
748
|
-
again and the render never runs.
|
|
749
|
-
- **Consistency is eventual.** There is a short window between a purge and that
|
|
750
|
-
purge reaching every replica. During it a replica may serve the old HTML; the
|
|
751
|
-
window is bounded by the TTL.
|
|
752
|
-
- **Keep it off in dev.** The dev server clears the cache whenever the manifest
|
|
753
|
-
changes, which makes a shared store pointless. `enabled` only turns on when
|
|
754
|
-
`true` is passed explicitly.
|
|
755
|
-
|
|
756
|
-
### Seeing the status
|
|
757
|
-
|
|
758
|
-
```js
|
|
759
|
-
import { getRedisStatus } from "jskelet";
|
|
760
|
-
|
|
761
|
-
app.get("/api/healthcheck", (req, res) => {
|
|
762
|
-
res.json({ ok: true, cache: getRedisStatus() });
|
|
763
|
-
});
|
|
764
|
-
```
|
|
765
|
-
|
|
766
|
-
Safe to call even with no connection. The returned object is
|
|
767
|
-
`{ enabled, connected, keyPrefix, buildId, errors, bypassed }`; `bypassed` tells
|
|
768
|
-
you the circuit breaker is open and `errors` is the total command failure count.
|
|
769
|
-
The same summary is in the dev panel report
|
|
770
|
-
([09-dev-tools.md](./09-dev-tools.md)).
|
|
771
|
-
|
|
772
|
-
Two more diagnostic surfaces:
|
|
773
|
-
|
|
774
|
-
| Call | What it tells you |
|
|
775
|
-
| --- | --- |
|
|
776
|
-
| `getRedisDetails()` | **Where** the connection points: address, TLS, database, `namespace`, which kinds are shared, whether the purge channel is subscribed. The password is never returned — a connection URL may carry one. |
|
|
777
|
-
| `inspectRedis()` | What is actually in the shared tier: keys per kind, `DBSIZE` and `used_memory`. It runs a `SCAN`, so **never call it on the request path**; in the admin panel it sits behind its own button. |
|
|
778
|
-
|
|
779
|
-
The full list of settings: [07-configuration.md](./07-configuration.md).
|
|
780
|
-
|
|
781
|
-
## The admin panel
|
|
782
|
-
|
|
783
|
-
Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
|
|
784
|
-
endpoints above, the framework ships a panel. It is deliberately separate from
|
|
785
|
-
the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
|
|
786
|
-
panel does not look at the environment — "why is this page stale", "did the
|
|
787
|
-
webhook purge land", "is Redis actually connected" are production questions.
|
|
788
|
-
|
|
789
|
-
```js
|
|
790
|
-
// jskelet.config.mjs
|
|
791
|
-
export default {
|
|
792
|
-
cache() {
|
|
793
|
-
return {
|
|
794
|
-
html: { "/news/:slug": 300 },
|
|
795
|
-
panel: { enabled: process.env.CACHE_PANEL === "1" },
|
|
796
|
-
};
|
|
797
|
-
},
|
|
798
|
-
};
|
|
799
|
-
```
|
|
800
|
-
|
|
801
|
-
Without `enabled` **nothing is mounted**: the path does not exist, the module is
|
|
802
|
-
never loaded and it costs the production process nothing. The environment
|
|
803
|
-
variable (`JSKELET_CACHE_PANEL=1`) overrides the config, because the panel is
|
|
804
|
-
usually opened once during an incident and editing the config file and
|
|
805
|
-
redeploying is the last thing you want at that moment.
|
|
806
|
-
|
|
807
|
-
When the panel is on, the server log prints the password:
|
|
808
|
-
|
|
809
|
-
```
|
|
810
|
-
[cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
### Access and hardening
|
|
814
|
-
|
|
815
|
-
- **The password is regenerated on every process start** (32 hex characters) and
|
|
816
|
-
only ever appears in the log. There is no persistent secret to leak: leaking
|
|
817
|
-
one means handing out the right to flush the cache, and a deploy should revoke
|
|
818
|
-
old access on its own.
|
|
819
|
-
- **The password is not accepted in the query string,** so access logs, browser
|
|
820
|
-
history and the `Referer` header never carry it. Sign-in goes through the form.
|
|
821
|
-
- **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
|
|
822
|
-
Requests without a session count just like a wrong password; a successful
|
|
823
|
-
sign-in resets the counter.
|
|
824
|
-
- **Banned and unauthorised requests get a `404`.** A 401 or 403 confirms the
|
|
825
|
-
panel exists; a 404 behaves as if it never did. The rest of the site is
|
|
826
|
-
untouched.
|
|
827
|
-
- **Nothing is indexable:** every response carries `X-Robots-Tag: noindex,
|
|
828
|
-
nofollow, noarchive, nosnippet`, `Cache-Control: no-store` and
|
|
829
|
-
`Referrer-Policy: no-referrer`. The path is also exempt from prewarming and
|
|
830
|
-
from navigation speculation.
|
|
831
|
-
- Actions require an `X-JSkelet-Cache-Panel` header — a header a cross-site form
|
|
832
|
-
cannot send, which is the panel's own CSRF brake.
|
|
833
|
-
- Sessions and ban counters live in process memory; persisting them to disk
|
|
834
|
-
would be the wrong trade for a panel whose password changes on every restart.
|
|
835
|
-
|
|
836
|
-
### What the panel shows
|
|
837
|
-
|
|
838
|
-
| Area | Contents |
|
|
839
|
-
| --- | --- |
|
|
840
|
-
| Top bar | Version, environment, pid, uptime, RSS and the language picker (Turkish / English) |
|
|
841
|
-
| Cards | HTML entry count and limit, HTML bytes in memory, stale entry count, data entry count, Redis state (`connected` / `bypassed` / `off`), prewarm progress |
|
|
842
|
-
| Shared tier | **Where** the connection points (address, TLS, database), the key prefix and `namespace`, the `buildId`, which kinds are shared, the state of compressed bodies and the purge broadcast, the command timeout and the error count. When it is off, a Redis recommendation with an install snippet takes its place. |
|
|
843
|
-
| Cloudflare | Zone, plan, cache related zone settings, how long development mode has left, Tiered Cache / Cache Reserve state and the cache hit ratio. When no zone is connected, a setup snippet takes its place. |
|
|
844
|
-
| Host | The machine's memory usage and how full the disk holding the project is |
|
|
845
|
-
| Entry list | HTML: path (opens in a new tab), fresh/stale, size, status code, remaining TTL, dependency count, precompressed bodies. Data: key (click to copy), fresh/stale, remaining TTL |
|
|
846
|
-
|
|
847
|
-
The list is **filtered by key** and the filter runs on the server: a data cache
|
|
848
|
-
can hold tens of thousands of keys. At most 500 rows come back per request and
|
|
849
|
-
the counter in the heading says how many matches were cut. HTML bodies and
|
|
850
|
-
cached values are **never returned** — the panel's job is to show state, not to
|
|
851
|
-
export content.
|
|
852
|
-
|
|
853
|
-
### What you can do from it
|
|
854
|
-
|
|
855
|
-
| Action | Equivalent call |
|
|
856
|
-
| --- | --- |
|
|
857
|
-
| Invalidate (target + `hard`) | `invalidateHtmlCache(target, { hard })` |
|
|
858
|
-
| `drop` a single row | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
|
|
859
|
-
| Clear HTML cache | `clearHtmlCache()` |
|
|
860
|
-
| Clear data cache (optional prefix) | `clearDataCache(prefix)` |
|
|
861
|
-
| Drop shared keys | Scans and unlinks the `html` or `data` namespace in Redis |
|
|
862
|
-
| Count keys in Redis | `inspectRedis()` — keys per kind, `DBSIZE` and `used_memory` |
|
|
863
|
-
| Prewarm | `prewarm()` — the pass runs in the background, progress shows in the card |
|
|
864
|
-
| Cloudflare purge (everything / URLs held here / prefix / host / tag) | `purgeCloudflare()` |
|
|
865
|
-
| Change a Cloudflare setting or feature | Zone settings and Tiered Cache / Cache Reserve |
|
|
866
|
-
|
|
867
|
-
Each one propagates to the shared tier as well: clearing a single replica's
|
|
868
|
-
cache is what produces the "I cleared it and it is still old" question in a
|
|
869
|
-
clustered setup.
|
|
870
|
-
|
|
871
|
-
The panel speaks two languages: the picker in the header switches between
|
|
872
|
-
Turkish and English. The first visit follows the browser, the choice is kept in
|
|
873
|
-
`localStorage` and applies to the login page too. Switching costs no request.
|
|
874
|
-
The server never knows the interface language: an `/action` response returns a
|
|
875
|
-
code rather than a sentence (`{ ok, code, params }`) and the panel builds the
|
|
876
|
-
text — so the framework's log and API stay in one language.
|
|
877
|
-
|
|
878
|
-
Dropping a single row is not the same as `invalidateHtmlCache()`: that one
|
|
879
|
-
matches a path pattern and takes down **every** query variant of a path, while
|
|
880
|
-
`dropHtmlCacheKey()` takes the exact key — `/list?page=2` goes and
|
|
881
|
-
`/list?page=3` stays hot.
|
|
882
|
-
|
|
883
|
-
## The CDN tier: Cloudflare
|
|
884
|
-
|
|
885
|
-
Everything above is the **origin** cache. With Cloudflare in front, the HTML
|
|
886
|
-
your visitors get usually never reaches you: the copy at the edge is served
|
|
887
|
-
until its TTL runs out. That is why `invalidateHtmlCache()` alone does not fix
|
|
888
|
-
"I updated the page but the old one still shows" — the origin refreshes, the
|
|
889
|
-
edge keeps waiting.
|
|
890
|
-
|
|
891
|
-
JSkelet lets you drive both tiers from the same place.
|
|
892
|
-
|
|
893
|
-
### Setup
|
|
894
|
-
|
|
895
|
-
The token is a secret, so it goes in the environment, not in a config file:
|
|
896
|
-
|
|
897
|
-
```bash
|
|
898
|
-
JSKELET_CLOUDFLARE_KEY=... # API token
|
|
899
|
-
JSKELET_CLOUDFLARE_ZONE_ID=... # zone identifier
|
|
900
|
-
JSKELET_CLOUDFLARE_HOSTNAME=example.com # optional
|
|
901
|
-
```
|
|
902
|
-
|
|
903
|
-
Which permissions the token needs depends on what you want to do: `Zone.Cache
|
|
904
|
-
Purge` to purge, `Zone.Zone Settings` to change settings, `Zone.Analytics`
|
|
905
|
-
(read) for the hit ratio and the edge breakdown. A purge-only token still opens
|
|
906
|
-
the panel; the settings sections just report an error.
|
|
907
|
-
|
|
908
|
-
The zone id and site name are not secrets, so they can also come from
|
|
909
|
-
`jskelet.config.mjs`. The environment always wins:
|
|
910
|
-
|
|
911
|
-
```js
|
|
912
|
-
cache: {
|
|
913
|
-
cloudflare: {
|
|
914
|
-
zoneId: "…",
|
|
915
|
-
hostname: "example.com", // purging wants absolute URLs; this turns paths into them
|
|
916
|
-
analyticsHours: 24,
|
|
917
|
-
},
|
|
918
|
-
}
|
|
919
|
-
```
|
|
920
|
-
|
|
921
|
-
Without `hostname`, purge URLs are derived from the origin the panel was opened
|
|
922
|
-
on. If you reach the panel over an internal address (`http://10.0.0.4:3000`),
|
|
923
|
-
that address means nothing to Cloudflare — there, `hostname` is required.
|
|
924
|
-
|
|
925
|
-
### What you can do
|
|
926
|
-
|
|
927
|
-
Whatever Cloudflare's cache surface offers is in the panel:
|
|
928
|
-
|
|
929
|
-
| Action | Note |
|
|
930
|
-
| --- | --- |
|
|
931
|
-
| Purge everything | The whole zone. The bluntest tool; warming back up is expensive |
|
|
932
|
-
| Purge by URL | Every page currently held in memory with one button, or `cf purge` per row |
|
|
933
|
-
| Purge by prefix / host / tag | Available on all plans now; 100 keys per request |
|
|
934
|
-
| Development mode | Bypasses the edge cache for three hours, then turns itself off |
|
|
935
|
-
| Cache level, browser cache TTL, query string sorting, Always Online | Zone settings |
|
|
936
|
-
| Tiered Cache, Regional Tiered Cache, Cache Reserve | Plan dependent; shows "unavailable" where the plan lacks it |
|
|
937
|
-
| Clear Cache Reserve | Separate from purging: `purge_everything` drops the edges, the persistent copy in R2 stays |
|
|
938
|
-
|
|
939
|
-
Long URL lists are split into batches of 100 keys and sent **sequentially**.
|
|
940
|
-
Sending them in parallel means half the batch rejected on the Free plan, where
|
|
941
|
-
purging is limited to five requests per minute.
|
|
942
|
-
|
|
943
|
-
The same surface from code:
|
|
944
|
-
|
|
945
|
-
```js
|
|
946
|
-
import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
|
|
947
|
-
|
|
948
|
-
export async function onPostPublished(slug) {
|
|
949
|
-
const paths = ["/", `/blog/${slug}`];
|
|
950
|
-
|
|
951
|
-
invalidateHtmlCache(paths); // origin
|
|
952
|
-
await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
|
|
953
|
-
}
|
|
954
|
-
```
|
|
955
|
-
|
|
956
|
-
Nothing in this module throws: with no token, on a Cloudflare 403 or when the
|
|
957
|
-
network drops, the result is `{ ok: false, error }`. A CDN outage should not
|
|
958
|
-
break your publishing flow.
|
|
959
|
-
|
|
960
|
-
### "How many edges hold this page?" — what can and cannot be asked
|
|
961
|
-
|
|
962
|
-
There is no Cloudflare endpoint that lists the **inventory** of an object.
|
|
963
|
-
Hundreds of cities run independent caches and none of them will answer "do you
|
|
964
|
-
currently hold this URL". So the panel shows observation rather than inventory:
|
|
965
|
-
enter a path and the GraphQL analytics tell you which colo (IST, FRA, AMS…)
|
|
966
|
-
served it from cache and how often it went to the origin over the last N hours.
|
|
967
|
-
|
|
968
|
-
```js
|
|
969
|
-
const report = await fetchPathEdges({ path: "/blog", hours: 24 });
|
|
970
|
-
// → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
|
|
971
|
-
```
|
|
972
|
-
|
|
973
|
-
Two limits to keep in mind while reading it: an edge that received no request
|
|
974
|
-
does not appear at all, even if it holds a copy; and the dataset is sampled, so
|
|
975
|
-
ratios are reliable while absolute counts are estimates.
|
|
976
|
-
|
|
977
|
-
There is also no way to **warm** an edge you pick. An object enters an edge
|
|
978
|
-
cache only through a real request routed there; you cannot tell Frankfurt from
|
|
979
|
-
your server to go cache something. Three things do work in practice:
|
|
980
|
-
|
|
981
|
-
- **Warm the origin** (`prewarm`): the edge that takes the first request finds
|
|
982
|
-
a ready response, so that request is not the slow one.
|
|
983
|
-
- **Tiered Cache**: edges do not go straight to the origin, they pull from an
|
|
984
|
-
upper tier — the first request in one city counts as warming for the others.
|
|
985
|
-
- **Cache Reserve**: a persistent copy in R2 for long-tail content, so requests
|
|
986
|
-
do not reach the origin when an edge evicts.
|
|
987
|
-
|
|
988
|
-
If your `hit` ratio is low, check whether the response is cacheable at all
|
|
989
|
-
before anything else: `Cache-Control: private`, `Set-Cookie` and query string
|
|
990
|
-
settings are the most common reasons an edge decides not to cache, and they
|
|
991
|
-
show up as `dynamic` in this panel.
|
|
992
|
-
|
|
993
|
-
## Prewarm — warming up at startup
|
|
994
|
-
|
|
995
|
-
The equivalent of Next's build-time prerender, except the output is not written
|
|
996
|
-
to disk: since the cache lives in process memory, the warm-up also happens when
|
|
997
|
-
the process comes up. The gain is the same — the first visitor does not wait
|
|
998
|
-
for a cold render — but the data is not frozen; every entry ages with the
|
|
999
|
-
route's `revalidate` and is refreshed in the background with
|
|
1000
|
-
stale-while-revalidate.
|
|
1001
|
-
|
|
1002
|
-
The warm-up is done with **real HTTP requests**
|
|
1003
|
-
(`http://127.0.0.1:<port>`), so that the cache key, the compression and the
|
|
1004
|
-
middleware chain are exactly the same as with normal traffic.
|
|
1005
|
-
|
|
1006
|
-
### `hooks.prewarmPaths()`
|
|
1007
|
-
|
|
1008
|
-
The application declares which paths get warmed; usually it is the very same
|
|
1009
|
-
function that produces the sitemap.
|
|
1010
|
-
|
|
1011
|
-
```js
|
|
1012
|
-
// jskelet.config.mjs
|
|
1013
|
-
export default {
|
|
1014
|
-
hooks: {
|
|
1015
|
-
async prewarmPaths() {
|
|
1016
|
-
const slugs = await getAllArticleSlugs();
|
|
1017
|
-
return ["/", "/markets", ...slugs.map((slug) => `/news/${slug}`)];
|
|
1018
|
-
},
|
|
1019
|
-
},
|
|
1020
|
-
};
|
|
1021
|
-
```
|
|
1022
|
-
|
|
1023
|
-
Rules:
|
|
1024
|
-
|
|
1025
|
-
- If it does not return an array a warning is printed and no warm-up happens.
|
|
1026
|
-
- Only strings starting with `/` are taken.
|
|
1027
|
-
- Ones starting with one of the `prewarmSkip` prefixes are skipped. The default
|
|
1028
|
-
list: `/api/`, `/_fragment/`, `/__jskelet/`. Session-dependent pages should
|
|
1029
|
-
not be warmed.
|
|
1030
|
-
- Deduplication **preserves order**: when no `priority` is given, the order the
|
|
1031
|
-
application provides is meaningful — put the most important pages first.
|
|
1032
|
-
- If this hook is not defined the warm-up is never set up; not even the timer
|
|
1033
|
-
is started.
|
|
1034
|
-
|
|
1035
|
-
### Round logic
|
|
1036
|
-
|
|
1037
|
-
1. The list is collected. If it is longer than `max` (400 by default) a slice is
|
|
1038
|
-
selected: the paths matching `priority` are taken first **on every round**,
|
|
1039
|
-
and the remaining slots are filled from the queue.
|
|
1040
|
-
2. `concurrency` workers send requests in parallel (4 in prod, 1 in dev). A
|
|
1041
|
-
single worker in dev: so the scan does not compete for CPU with the render of
|
|
1042
|
-
the page you currently have open in the browser.
|
|
1043
|
-
3. If `rps` is given, the round never goes above that rate — no matter the
|
|
1044
|
-
parallelism. In dev, 4 requests per second apply by default: rendering runs
|
|
1045
|
-
on a single event loop, so an unpaced round leaves page requests and the dev
|
|
1046
|
-
panel's live channel waiting behind it.
|
|
1047
|
-
4. **A single serial retry round** is performed for the paths that hit a
|
|
1048
|
-
**transient** failure (`concurrency: 1`). Permanent answers like `400`, `403`
|
|
1049
|
-
or `404` are not retried: a deterministic error does not get better on the
|
|
1050
|
-
second try and those calls spend quota for nothing. The summary shows them as
|
|
1051
|
-
`N not retried (permanent)`.
|
|
1052
|
-
5. The wait before the retry is `retryDelayMs`, but when the rate limit is on and
|
|
1053
|
-
something is holding it back, that wins: retrying 2 seconds into a 10 second
|
|
1054
|
-
circuit breaker would just earn the same 429 up front.
|
|
1055
|
-
6. A summary is logged:
|
|
1056
|
-
`[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
|
|
1057
|
-
|
|
1058
|
-
Then comes how much the pass actually touched the upstream:
|
|
1059
|
-
|
|
1060
|
-
```text
|
|
1061
|
-
[prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
|
|
1062
|
-
```
|
|
1063
|
-
|
|
1064
|
-
This is the one line that tells you which way to turn the knob. If the ratio is
|
|
1065
|
-
low the fix is not the rate limit but a longer `withDataCache` TTL — the brake
|
|
1066
|
-
slows calls down, it does not reduce their number. The same counters are
|
|
1067
|
-
available through `getDataCacheStats()` and on the dev report's **Data cache**
|
|
1068
|
-
card.
|
|
1069
|
-
|
|
1070
|
-
Request errors and the per-page render warnings (`was produced with missing
|
|
1071
|
-
data`, `returned notFound() while upstream is failing`, `could not be
|
|
1072
|
-
produced`) raised during the pass are not logged one by one. They are counted
|
|
1073
|
-
while the pass runs and printed after the summary, most frequent kinds first:
|
|
1074
|
-
|
|
1075
|
-
```text
|
|
1076
|
-
[prewarm] 137 problems were not logged individually:
|
|
1077
|
-
94× missing data, upstream is failing permanently (403 /api/v1/polls)
|
|
1078
|
-
37× missing data, upstream is failing permanently (400 /api/v1/posts)
|
|
1079
|
-
6× 500 Cannot read properties of undefined (reading 'title')
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
This way a momentary upstream failure cannot bury the "warmed …" line under
|
|
1083
|
-
hundreds of stack traces. Errors from real traffic are logged immediately as
|
|
1084
|
-
before; for the detail of a single path, look at the **Prewarming** tab in the
|
|
1085
|
-
dev panel.
|
|
1086
|
-
|
|
1087
|
-
### Warm-up order: `priority`
|
|
1088
|
-
|
|
1089
|
-
```js
|
|
1090
|
-
// jskelet.config.mjs
|
|
1091
|
-
cache: () => ({
|
|
1092
|
-
prewarm: {
|
|
1093
|
-
priority: [
|
|
1094
|
-
"/",
|
|
1095
|
-
"/markets/:path*",
|
|
1096
|
-
/-comments$/,
|
|
1097
|
-
],
|
|
1098
|
-
},
|
|
1099
|
-
}),
|
|
1100
|
-
```
|
|
1101
|
-
|
|
1102
|
-
The pattern syntax (`/news/:slug`) and a plain `RegExp` can be used together;
|
|
1103
|
-
the latter is for rules the pattern syntax does not cover, such as "everything
|
|
1104
|
-
ending in `-comments`". Whatever is written first is warmed first; paths that
|
|
1105
|
-
match nothing go to the queue and keep their relative order.
|
|
1106
|
-
|
|
1107
|
-
### Drip warm-up: `rotate` + `rps` + `intervalSeconds`
|
|
1108
|
-
|
|
1109
|
-
On a site with 10,000 paths, warming everything in a single round is neither
|
|
1110
|
-
possible (the HTML cache holds 500 entries) nor right (the API quota runs out).
|
|
1111
|
-
The correct behaviour is to spread the list over time:
|
|
1112
|
-
|
|
1113
|
-
```js
|
|
1114
|
-
prewarm: {
|
|
1115
|
-
max: 300, // 300 pages per round
|
|
1116
|
-
rps: 4, // at most 4 requests per second
|
|
1117
|
-
intervalSeconds: 300, // a round every 5 minutes
|
|
1118
|
-
rotate: true, // the queue continues where it left off
|
|
1119
|
-
priority: ["/", "/markets/:path*"],
|
|
1120
|
-
}
|
|
1121
|
-
```
|
|
1122
|
-
|
|
1123
|
-
In this setup the priority pages are refreshed on every round, the rest of the
|
|
1124
|
-
queue is walked end to end across rounds, and upstream never sees more than four
|
|
1125
|
-
requests per second. Used together with the data cache, the warm-up barely
|
|
1126
|
-
reaches the API after the second round: it reads from the data layer.
|
|
1127
|
-
|
|
1128
|
-
With rotation on, the paths left outside the limit are not lost, they are left
|
|
1129
|
-
for the next round; the log distinguishes this:
|
|
1130
|
-
`… , 700 deferred to the next pass`. With `rotate: false` you get the classic
|
|
1131
|
-
behaviour — every round warms the same first slice of the list and the rest is
|
|
1132
|
-
never warmed (`… , 700 over the limit`).
|
|
1133
|
-
|
|
1134
|
-
If a round takes longer than `intervalSeconds`, a new round is not started;
|
|
1135
|
-
overlapping rounds would put twice the load on upstream.
|
|
1136
|
-
|
|
1137
|
-
The requests go out with the headers `user-agent: jskelet-prewarm`
|
|
1138
|
-
(`brand.prewarmUserAgent`) and `accept-encoding: br, gzip`; the second one so
|
|
1139
|
-
that the compressed body enters the cache too.
|
|
1140
|
-
|
|
1141
|
-
If `DEV_TOKEN` is set, the warm-up carries the token as a cookie; otherwise the
|
|
1142
|
-
dev gate returns 404 for all pages and the cache never fills.
|
|
1143
|
-
|
|
1144
|
-
The request list in the dev panel and the terminal filter out requests carrying
|
|
1145
|
-
`prewarmUserAgent`: so that hundreds of warm-up requests do not flood the view.
|
|
1146
|
-
Progress shows up in the badge next to the bubble.
|
|
1147
|
-
|
|
1148
|
-
### Timing
|
|
1149
|
-
|
|
1150
|
-
- The warm-up starts at boot **with a delay**: so it does not compete with the
|
|
1151
|
-
first real requests. The default delay is 500 ms in prod and 3000 ms in dev.
|
|
1152
|
-
Longer in dev, because a file save restarts the process and the timer dies
|
|
1153
|
-
with it; it only warms up once the server stays quiet for a while.
|
|
1154
|
-
- If `PREWARM_INTERVAL_SECONDS` / `cache().prewarm.intervalSeconds` > 0 the
|
|
1155
|
-
round is repeated periodically. Because entries age with `revalidate` and the
|
|
1156
|
-
visitor does not wait thanks to stale-while-revalidate, this is **optional**;
|
|
1157
|
-
it is for setups that also want to keep pages that are never visited warm.
|
|
1158
|
-
- All timers are `unref()`ed: they do not delay process shutdown.
|
|
1159
|
-
- No warm-up failure takes the process down.
|
|
1160
|
-
|
|
1161
|
-
### Settings
|
|
1162
|
-
|
|
1163
|
-
Order of precedence: **environment variable → config → code default.** Env
|
|
1164
|
-
comes first so that one-off experiments can be done without editing the config.
|
|
1165
|
-
|
|
1166
|
-
| Setting | Env | `cache().prewarm` | Default |
|
|
1167
|
-
| --- | --- | --- | --- |
|
|
1168
|
-
| On/off | `PREWARM=0` disables it, `PREWARM=1` overrides the config and enables it | `enabled` | `true` |
|
|
1169
|
-
| Maximum paths per round | `PREWARM_MAX` | `max` | `400` |
|
|
1170
|
-
| Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
|
|
1171
|
-
| Requests per second | `PREWARM_RPS` | `rps` | prod `0` (unlimited), dev 4 |
|
|
1172
|
-
| Startup delay (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
|
|
1173
|
-
| Retry round delay (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
|
|
1174
|
-
| Period (seconds) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (off) |
|
|
1175
|
-
| Queue rotation | — | `rotate` | `true` |
|
|
1176
|
-
| Warm-up order | — | `priority` | `[]` |
|
|
1177
|
-
|
|
1178
|
-
Numeric settings only accept **positive and finite** values; an invalid value
|
|
1179
|
-
silently falls through to the next layer.
|
|
1180
|
-
|
|
1181
|
-
### Triggering by hand
|
|
1182
|
-
|
|
1183
|
-
```js
|
|
1184
|
-
import { prewarm, prewarmProgress } from "jskelet";
|
|
1185
|
-
|
|
1186
|
-
await prewarm({ origin: "http://127.0.0.1:3000" }); // paths from the hook
|
|
1187
|
-
await prewarm({ origin, paths: ["/", "/markets"] }); // only these paths
|
|
1188
|
-
await prewarm({ origin, quiet: true }); // without printing a summary
|
|
1189
|
-
```
|
|
1190
|
-
|
|
1191
|
-
If `paths` is given the hook is never called. The return value is
|
|
1192
|
-
`{ ok, failed, total, elapsed }`.
|
|
1193
|
-
|
|
1194
|
-
`prewarmProgress` holds the live state and the dev panel reads it:
|
|
1195
|
-
|
|
1196
|
-
```js
|
|
1197
|
-
{
|
|
1198
|
-
active, done, total, ok, failed, startedAt, finishedAt,
|
|
1199
|
-
entries: [{ path, status, ms, bytes, cache, error }],
|
|
1200
|
-
}
|
|
1201
|
-
```
|
|
1202
|
-
|
|
1203
|
-
The `cache` field inside `entries` is that path's `X-JSkelet-Cache` response;
|
|
1204
|
-
from there you can see whether the warm-up round really returned `MISS` and
|
|
1205
|
-
filled the cache.
|
|
1206
|
-
|
|
1207
|
-
## Diagnosis: common situations
|
|
1208
|
-
|
|
1209
|
-
- **Every request returns `MISS`.** The route was not given a `revalidate`, or
|
|
1210
|
-
the pattern inside `cache().html` gives 0 seconds. Or the page returns a code
|
|
1211
|
-
other than `status: 200`.
|
|
1212
|
-
- **The page returns `MISS` but upstream is healthy.** A transient upstream
|
|
1213
|
-
failure may have been reported; look for the line `was produced with missing
|
|
1214
|
-
data, not caching it` in the log.
|
|
1215
|
-
- **Stale data all the time.** `revalidate` is too high; remember that the real
|
|
1216
|
-
lag is at most `revalidate` + one refresh round.
|
|
1217
|
-
- **The cache is bloating.** Because query parameters go into the key, campaign
|
|
1218
|
-
parameters may be multiplying entries.
|
|
1219
|
-
- **The warm-up never runs.** `hooks.prewarmPaths` is not defined, `PREWARM=0`
|
|
1220
|
-
is set, or `cache().prewarm.enabled === false`.
|
|
1221
|
-
- **The warm-up round pushes the API into 429.** No `rps` was given. Lowering
|
|
1222
|
-
`concurrency` is not enough; the setting that protects the quota is the total
|
|
1223
|
-
rate. The lasting fix is the data cache: after the second round the warm-up
|
|
1224
|
-
does not reach upstream.
|
|
1225
|
-
- **The warm-up list is longer than `max` and its tail never warms.** `rotate`
|
|
1226
|
-
may be `false`; the `over the limit` phrase in the log shows this.
|
|
1227
|
-
- **A whole section returns 404.** Upstream may be down. The page is now retried
|
|
1228
|
-
once and, failing that, a 503 that does not enter the cache is returned
|
|
1229
|
-
instead of a 404; look for the `returned notFound() while upstream is failing`
|
|
1230
|
-
line in the log. If you still see 404s, the failure may come from a non-`fetch`
|
|
1231
|
-
client (which needs `reportUpstreamFailure()`) or `cache().trackUpstream` is
|
|
1232
|
-
off.
|
|
1233
|
-
|
|
1234
|
-
## What's next
|
|
1235
|
-
|
|
1236
|
-
- The full reference of config fields and the env table:
|
|
1237
|
-
[07-configuration.md](./07-configuration.md)
|
|
1238
|
-
- Watching the cache from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
|
|
1239
|
-
- Using it together with a CDN/reverse proxy: [10-deployment.md](./10-deployment.md)
|
|
1
|
+
# 06 — Caching and prewarm
|
|
2
|
+
|
|
3
|
+
This document explains JSkelet's ISR substitute in full detail: the HTML TTL
|
|
4
|
+
cache and its stale-while-revalidate behaviour, where `revalidate` comes from,
|
|
5
|
+
how the cache key is built, the values of the `X-JSkelet-Cache` header, why the
|
|
6
|
+
compressed body is kept in the cache, per-request memoization
|
|
7
|
+
(`withRequestCache` / `cache()`), the data cache (`withDataCache`), how upstream
|
|
8
|
+
failures affect the cache (automatic tracking and `reportUpstreamFailure`) and the prewarm round at
|
|
9
|
+
server startup. The
|
|
10
|
+
measurement rationale behind the decisions is in
|
|
11
|
+
[02-architecture.md](./02-architecture.md), and the full reference of config
|
|
12
|
+
fields is in [07-configuration.md](./07-configuration.md).
|
|
13
|
+
|
|
14
|
+
## The big picture
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
route(controller, { revalidate })
|
|
18
|
+
└─ withHtmlCache(key, ttl, producer) ← TTL + stale-while-revalidate
|
|
19
|
+
└─ withUpstreamTracking(...) ← missing data detection
|
|
20
|
+
└─ withRequestCache(...) ← per-request memoization
|
|
21
|
+
└─ produce() → controller + renderPage
|
|
22
|
+
└─ withDataCache(...) ← upstream data cache
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The order matters: the **per-request cache must be innermost** so that two
|
|
26
|
+
calls in the same render collapse into a single upstream request; **upstream
|
|
27
|
+
tracking must be inside the HTML cache** so that output produced with missing
|
|
28
|
+
data is not written to the cache.
|
|
29
|
+
|
|
30
|
+
How the two caches divide the work:
|
|
31
|
+
|
|
32
|
+
| | HTML cache | Data cache |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| What it holds | The whole page (+ its compressed body) | The JSON that came from upstream |
|
|
35
|
+
| Entry size | ~100-200 kB | ~1-20 kB |
|
|
36
|
+
| Entry limit | 500 (`cache().maxEntries`) | 10,000 (`cache().data.maxEntries`) |
|
|
37
|
+
| Who benefits | Pages with traffic: not even rendered | The long tail: rendered, but without going to the API |
|
|
38
|
+
|
|
39
|
+
In practice this distinction means: on a site with tens of thousands of paths it
|
|
40
|
+
is impossible to keep every page hot as HTML — a warm-up that goes past 500
|
|
41
|
+
entries deletes what it just warmed. For the long tail the goal is not "have the
|
|
42
|
+
HTML ready" but **"have the data that produces the page available without going
|
|
43
|
+
to the API"**. Then a page that was never warmed is also produced within
|
|
44
|
+
milliseconds on the first visit, and spends no quota.
|
|
45
|
+
|
|
46
|
+
## Public versus per-visitor
|
|
47
|
+
|
|
48
|
+
Everything in this document applies to HTML that **can go to everyone
|
|
49
|
+
unchanged**. There is no identity in the cache key (only path + query), so a
|
|
50
|
+
page in the cache is the answer for that path, not the answer for whoever asked
|
|
51
|
+
for it first.
|
|
52
|
+
|
|
53
|
+
A page that depends on the user therefore takes a separate path:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
app.get("/dashboard", route(async ({ req }) => { … }, { private: true }));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`private: true` does three things at once: the cache is disabled, a
|
|
60
|
+
`cache.html` pattern **cannot** override that decision, and the response is
|
|
61
|
+
sent with `private, no-store` and `Vary: Cookie`, without an ETag. The details
|
|
62
|
+
and the session/CSRF side are in
|
|
63
|
+
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
64
|
+
|
|
65
|
+
If you forget the flag, the framework does not stay quiet: as soon as the
|
|
66
|
+
controller reads `Cookie`, `Authorization` or `req.session`/`req.user`, the
|
|
67
|
+
render is marked and **not written** to the cache. In development the request
|
|
68
|
+
fails with an explanation, in production it is served with `no-store` and
|
|
69
|
+
logged. The guard is a last line of defence, not an excuse — the right place is
|
|
70
|
+
`private: true`.
|
|
71
|
+
|
|
72
|
+
## `revalidate` — where the TTL comes from
|
|
73
|
+
|
|
74
|
+
A route's TTL can come from two sources, and **the config wins**:
|
|
75
|
+
|
|
76
|
+
1. `route(controller, { revalidate: 60 })` — the route's own duration.
|
|
77
|
+
2. The matching pattern inside `jskelet.config.mjs` → `cache().html`. If it
|
|
78
|
+
exists it overrides the route's value.
|
|
79
|
+
|
|
80
|
+
The one exception is `private: true`: a matching pattern is ignored. The lock is
|
|
81
|
+
one-way, because a mistake in the other direction means a silent data leak.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// jskelet.config.mjs
|
|
85
|
+
export default {
|
|
86
|
+
async cache() {
|
|
87
|
+
return {
|
|
88
|
+
html: {
|
|
89
|
+
"/": 60,
|
|
90
|
+
"/news/:slug": 300,
|
|
91
|
+
"/tag/:slug": 120,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Overriding from the config makes it possible to tune the freshness profile of
|
|
99
|
+
the whole site from a single file; you do not have to walk through the route
|
|
100
|
+
files.
|
|
101
|
+
|
|
102
|
+
The resolution result is **remembered per path**, so a pattern scan is not done
|
|
103
|
+
on every request. If there is no `cache().html` rule at all, the route's own
|
|
104
|
+
value is used directly.
|
|
105
|
+
|
|
106
|
+
If `revalidate` is not given, or is 0, the page is **not cached at all**: every
|
|
107
|
+
request is rendered and the response is sent with
|
|
108
|
+
`Cache-Control: private, no-store` and no ETag. No `X-JSkelet-Cache` header is
|
|
109
|
+
written either — the cache path never ran, so `MISS` would be misleading.
|
|
110
|
+
|
|
111
|
+
Sending `no-store` on a dynamic page is deliberate. HTTP treats a response with
|
|
112
|
+
no directives as "heuristically cacheable", so an intermediate proxy or the
|
|
113
|
+
browser's back button could store HTML produced for a single visitor.
|
|
114
|
+
|
|
115
|
+
The cache also only kicks in for `GET` requests.
|
|
116
|
+
|
|
117
|
+
## The cache key
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
`${path}?${the allowed query parameters, sorted}`
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For a request without a query the key is just the path. **A request that carries
|
|
124
|
+
a query parameter is dynamic by default**: it never enters the cache and is sent
|
|
125
|
+
with `private, no-store`. Caching every variant of a path mints an unbounded
|
|
126
|
+
number of keys (`?utm_source=…` and friends), and in a 500-entry store LRU then
|
|
127
|
+
evicts the real pages in favour of campaign variants.
|
|
128
|
+
|
|
129
|
+
Which parameter actually changes the output is declared by the application, in
|
|
130
|
+
`jskelet.config.mjs` → `cache().query`:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
cache: () => ({
|
|
134
|
+
html: { "/list": 60 },
|
|
135
|
+
query: { "/list": ["page"] },
|
|
136
|
+
}),
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Now `/list?page=2` and `/list?page=3` are separate entries, while
|
|
140
|
+
`/list?page=2&utm_source=x` shares the `?page=2` copy: a parameter outside the
|
|
141
|
+
list never reaches the key. A pattern mapped to `true` puts every parameter in
|
|
142
|
+
the key (careful: nothing but `maxEntries` then bounds the entry count), and one
|
|
143
|
+
mapped to `[]` ignores the query entirely. Details:
|
|
144
|
+
[07-configuration.md](./07-configuration.md).
|
|
145
|
+
|
|
146
|
+
## Stale-while-revalidate
|
|
147
|
+
|
|
148
|
+
The entry structure:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
expiresAt = now + ttl
|
|
152
|
+
staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Read behaviour:
|
|
156
|
+
|
|
157
|
+
| State | Response | Background |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| `now < expiresAt` | The cached HTML, `HIT` | — |
|
|
160
|
+
| `expiresAt ≤ now < staleUntil` | The cached HTML **immediately**, `STALE` | A refresh is started |
|
|
161
|
+
| `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` | — |
|
|
162
|
+
|
|
163
|
+
A failure of the refresh inside the stale window does not affect the request:
|
|
164
|
+
the old HTML stays valid for the whole window and the error is only logged
|
|
165
|
+
(`[html-cache] background refresh failed: …`).
|
|
166
|
+
|
|
167
|
+
Concurrent refreshes for the same key are collapsed into a single run (the
|
|
168
|
+
`inflight` map): a hundred concurrent requests fall to one render.
|
|
169
|
+
|
|
170
|
+
The gain: after the first warm-up no request waits for a render. The price: the
|
|
171
|
+
data in the HTML can be at most `revalidate + one refresh round` behind. That
|
|
172
|
+
price is acceptable, because live fields such as prices are updated on the
|
|
173
|
+
client over WebSocket.
|
|
174
|
+
|
|
175
|
+
The store is an LRU: an accessed entry is moved to the end, and once the limit
|
|
176
|
+
(`cache().maxEntries`, 500 by default) is exceeded the oldest is evicted.
|
|
177
|
+
|
|
178
|
+
## What gets written to the cache
|
|
179
|
+
|
|
180
|
+
Only output that satisfies **both** of these two conditions is stored:
|
|
181
|
+
|
|
182
|
+
1. `status === 200`
|
|
183
|
+
2. `degraded !== true` — no transient upstream failure was reported during the
|
|
184
|
+
render.
|
|
185
|
+
|
|
186
|
+
So 404 pages, redirects and HTML produced with missing data do not enter the
|
|
187
|
+
cache.
|
|
188
|
+
|
|
189
|
+
## Response headers
|
|
190
|
+
|
|
191
|
+
`route()` writes `X-JSkelet-Cache` on every response (the header name can be
|
|
192
|
+
changed with `brand.cacheHeader`):
|
|
193
|
+
|
|
194
|
+
| Value | Meaning |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `HIT` | From the cache, fresh |
|
|
197
|
+
| `STALE` | From the cache, expired; being refreshed in the background |
|
|
198
|
+
| `MISS` | Rendered on this request (or the cache is off) |
|
|
199
|
+
|
|
200
|
+
On cacheable responses, additionally:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`max-age=0` turns off storage in the browser, `s-maxage` announces the duration
|
|
207
|
+
to intermediate layers (CDN, reverse proxy). This way, when a CDN sits in
|
|
208
|
+
front, the same freshness model works across both layers together.
|
|
209
|
+
|
|
210
|
+
## Storing the compressed body
|
|
211
|
+
|
|
212
|
+
Every cached entry carries an `encoded` map and shares the same lifetime as the
|
|
213
|
+
HTML. The first time a page is requested with brotli or gzip the output is
|
|
214
|
+
computed and put in the map; on subsequent requests the same buffer is sent.
|
|
215
|
+
The same page is not re-brotli'd on every request.
|
|
216
|
+
|
|
217
|
+
On this path `Content-Encoding`, `Vary` and `Content-Length` are written
|
|
218
|
+
directly by `route()`; the compression middleware does not kick in because it
|
|
219
|
+
sees `Content-Encoding`.
|
|
220
|
+
|
|
221
|
+
`HEAD` requests are not compressed (there is no body). If the client accepts
|
|
222
|
+
neither brotli nor gzip, plain HTML is sent.
|
|
223
|
+
|
|
224
|
+
## Per-request memoization: `cache()`
|
|
225
|
+
|
|
226
|
+
The equivalent of React's `cache()` function: calls made with the same
|
|
227
|
+
arguments within the same request run only once.
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
// lib/api/articles.js
|
|
231
|
+
import { cache } from "jskelet";
|
|
232
|
+
|
|
233
|
+
export const getArticle = cache(async (slug) => {
|
|
234
|
+
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
|
|
235
|
+
return response.json();
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Now if both the controller and `hooks.layoutContext()` ask for the same article
|
|
240
|
+
in the same render, a single upstream request is made.
|
|
241
|
+
|
|
242
|
+
Details:
|
|
243
|
+
|
|
244
|
+
- The context is carried with `AsyncLocalStorage` and is set up by
|
|
245
|
+
`withRequestCache()` inside `route()`.
|
|
246
|
+
- **Without a context, memoization is disabled** and the function is called
|
|
247
|
+
directly. Calling it from a script or from inside another process is safe.
|
|
248
|
+
- The key is `JSON.stringify(args)`; argument-less calls share the `""` key. Do
|
|
249
|
+
not use it with arguments that cannot be serialised (functions, `Symbol`,
|
|
250
|
+
circular objects).
|
|
251
|
+
- What is stored is the function's **return value**, that is, the Promise
|
|
252
|
+
itself for `async` functions. Because the same Promise is shared, concurrent
|
|
253
|
+
calls collapse too.
|
|
254
|
+
- `withRequestCache(run)` is exported; it can be used to set up the same scope
|
|
255
|
+
outside `route()` (for example in an Express handler you wrote yourself).
|
|
256
|
+
|
|
257
|
+
## Cross-request data cache: `withDataCache`
|
|
258
|
+
|
|
259
|
+
`cache()` only lives for the duration of **a single request**. What it takes to
|
|
260
|
+
protect the long tail from the API quota is a data layer that lives across
|
|
261
|
+
requests, has a TTL and refreshes itself:
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
// lib/api/articles.js
|
|
265
|
+
import { withDataCache, reportUpstreamFailure } from "jskelet";
|
|
266
|
+
|
|
267
|
+
export async function getArticle(slug) {
|
|
268
|
+
return withDataCache(`news:${slug}`, 600, async () => {
|
|
269
|
+
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
|
|
270
|
+
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
reportUpstreamFailure({ status: response.status, path: `/articles/${slug}` });
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return response.json();
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The wrapper form of the same pattern — the key is derived from the arguments:
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
import { dataCache } from "jskelet";
|
|
285
|
+
|
|
286
|
+
export const getArticle = dataCache(
|
|
287
|
+
async (slug) => apiGet(`/articles/${slug}`),
|
|
288
|
+
{ key: "news", revalidate: 600 },
|
|
289
|
+
);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Behaviour:
|
|
293
|
+
|
|
294
|
+
| State | Result |
|
|
295
|
+
| --- | --- |
|
|
296
|
+
| Fresh entry | Returns immediately, the `producer` does not run |
|
|
297
|
+
| TTL expired, still inside the stale window | The stale value returns **immediately**, the refresh runs in the background |
|
|
298
|
+
| No entry | The `producer` is awaited |
|
|
299
|
+
| The `producer` failed, a stale entry exists | The stale value returns, warning: `[data-cache] producer failed, serving stale value: …` |
|
|
300
|
+
| The `producer` failed, there is no entry | The error goes to the caller |
|
|
301
|
+
|
|
302
|
+
Details:
|
|
303
|
+
|
|
304
|
+
- **Concurrent calls for the same key collapse into one upstream request.** This
|
|
305
|
+
is the behaviour that saves the most quota during warm-up rounds: if 50 pages
|
|
306
|
+
want the same index data, the API is called once.
|
|
307
|
+
- **`null` and `undefined` are not stored.** An application's HTTP client
|
|
308
|
+
usually returns `null` on failure; storing that would freeze a transient 429
|
|
309
|
+
into "no data" for the whole TTL. Pass `{ storeEmpty: true }` if you want the
|
|
310
|
+
empty answer stored deliberately.
|
|
311
|
+
- **The stale window is longer than the HTML one**: `staleFactor` defaults to 10,
|
|
312
|
+
so an entry stays as an emergency fallback for 11 times its TTL. Stale data is
|
|
313
|
+
better than an incomplete page. It can be turned off per key with
|
|
314
|
+
`{ staleFactor: 0 }`.
|
|
315
|
+
- The key belongs entirely to the application: distinctions such as language,
|
|
316
|
+
version or page number go into the key (`news:en:v2:${slug}`).
|
|
317
|
+
- When the TTL is `0` the cache is disabled and the `producer` runs on every
|
|
318
|
+
call — enough to switch a setting off temporarily.
|
|
319
|
+
|
|
320
|
+
The management surface:
|
|
321
|
+
|
|
322
|
+
| Function | What it does |
|
|
323
|
+
| --- | --- |
|
|
324
|
+
| `withDataCache(key, ttlSeconds, producer, options?)` | The main entry point |
|
|
325
|
+
| `dataCache(fn, { key, revalidate, … })` | The function wrapper |
|
|
326
|
+
| `clearDataCache(prefix?)` | Drops the entries matching the prefix (or all of them), returns how many were removed |
|
|
327
|
+
| `getDataCacheSize()` | The number of entries |
|
|
328
|
+
| `getDataCacheEntries()` | A dump: `{ key, stale, expiresIn }`. The value itself is not returned. |
|
|
329
|
+
|
|
330
|
+
`clearDataCache("news:")` is the counterpart of a "this content was updated"
|
|
331
|
+
webhook: it drops one section's data **and stales the HTML pages that read it**,
|
|
332
|
+
so the update shows up without waiting for a TTL. See "Automatic dependencies"
|
|
333
|
+
below.
|
|
334
|
+
|
|
335
|
+
## Degraded render: `reportUpstreamFailure`
|
|
336
|
+
|
|
337
|
+
If upstream went down during the render, the output contains missing data.
|
|
338
|
+
Rather than serving such HTML for the whole TTL, the right behaviour is to
|
|
339
|
+
**never write it** to the cache: the next request tries again.
|
|
340
|
+
|
|
341
|
+
This information arrives through two paths.
|
|
342
|
+
|
|
343
|
+
### Automatic tracking (the default)
|
|
344
|
+
|
|
345
|
+
At startup `createApp()` wraps `globalThis.fetch` and reports **transient**
|
|
346
|
+
failures (`429`, `5xx`, network errors) from calls made during a render on its
|
|
347
|
+
own. No application code is needed; if your API client talks over `fetch`, the
|
|
348
|
+
rate limit protection is already in place.
|
|
349
|
+
|
|
350
|
+
The details:
|
|
351
|
+
|
|
352
|
+
- Only calls inside a render scope count. A `fetch` from a script, a cron job or
|
|
353
|
+
anywhere outside a request is left untouched.
|
|
354
|
+
- Requests to our own server (`localhost`, `127.0.0.1`) are skipped: the warm-up
|
|
355
|
+
round and the health check are not upstream.
|
|
356
|
+
- Deterministic answers such as `404`/`403` are **not** reported automatically.
|
|
357
|
+
In most APIs a `404` means "no such record"; treating it as missing data would
|
|
358
|
+
produce a false warning on every not-found page.
|
|
359
|
+
- To turn it off: `cache().trackUpstream: false`. An application that wraps
|
|
360
|
+
`fetch` itself (metrics, retries, a circuit breaker) may prefer that.
|
|
361
|
+
|
|
362
|
+
### Manual reporting
|
|
363
|
+
|
|
364
|
+
For a client that does not use `fetch` (a database driver, gRPC, a vendor SDK),
|
|
365
|
+
or for a layer that wants to flag permanent failures too, the contract is
|
|
366
|
+
unchanged. The dependency direction is deliberately inverted: the framework does
|
|
367
|
+
not know about the data layer, the data layer notifies the framework. If nobody
|
|
368
|
+
ever calls it, the cost is an empty array. If the same failure arrives through
|
|
369
|
+
both paths it is de-duplicated.
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
// lib/api/client.js
|
|
373
|
+
import { reportUpstreamFailure } from "jskelet";
|
|
374
|
+
|
|
375
|
+
export async function apiGet(path) {
|
|
376
|
+
try {
|
|
377
|
+
const response = await fetch(`${process.env.API_ORIGIN}${path}`);
|
|
378
|
+
|
|
379
|
+
if (!response.ok) {
|
|
380
|
+
reportUpstreamFailure({ status: response.status, path });
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return response.json();
|
|
385
|
+
} catch (error) {
|
|
386
|
+
// No response at all: status 0 means a network error.
|
|
387
|
+
reportUpstreamFailure({ status: 0, path });
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Distinguishing transient and permanent failures
|
|
394
|
+
|
|
395
|
+
| State | Counts as | Result |
|
|
396
|
+
| --- | --- | --- |
|
|
397
|
+
| `0` (network error), `408`, `425`, `429`, `>= 500` | **Transient** | The page is not written to the cache, warning: `[render] <path> was produced with missing data, not caching it (…)` |
|
|
398
|
+
| Others (`400`, `403`, `404`, …) | **Permanent** | Only a warning: `[render] <path> was produced with missing data, upstream is failing permanently (…)`. The cache is not blocked. |
|
|
399
|
+
|
|
400
|
+
Permanent failures not blocking the cache is deliberate: deterministic answers
|
|
401
|
+
do not get better by retrying. Turning the cache off because of them would mean
|
|
402
|
+
rendering the page from scratch on every visit — the content comes back just as
|
|
403
|
+
incomplete, and the visitor only pays the render time.
|
|
404
|
+
|
|
405
|
+
Output produced with missing data is **not offered to shared caches** either: a
|
|
406
|
+
`degraded` response gets `private, no-store` instead of `public, s-maxage=…`.
|
|
407
|
+
Taking back the "do not store" decision at the CDN would repeat the same mistake
|
|
408
|
+
one layer up. The diagnostic header (`X-JSkelet-Cache: MISS`) is still written.
|
|
409
|
+
|
|
410
|
+
### When `notFound()` coincides with a transient failure
|
|
411
|
+
|
|
412
|
+
A controller that calls `notFound()` because no data arrived can turn the whole
|
|
413
|
+
site into 404s when upstream is rate limited — and because those 404s enter the
|
|
414
|
+
cache, a temporary quota problem becomes a "this page does not exist" answer for
|
|
415
|
+
the whole TTL. For a search engine that is a permanent loss.
|
|
416
|
+
|
|
417
|
+
The framework separates the two cases: if a **transient** upstream failure
|
|
418
|
+
happened during the render, `notFound()` is not served as a 404. In order:
|
|
419
|
+
|
|
420
|
+
1. The page is **retried** after a short delay (once by default, after 300 ms).
|
|
421
|
+
The retry runs in its own upstream and per-request cache scope, so neither
|
|
422
|
+
the first round's failure nor its memoized empty answers affect it.
|
|
423
|
+
2. If the second round can produce the page, the visitor sees the **real
|
|
424
|
+
content** and the output is cached normally. Warm-up logs show this is
|
|
425
|
+
common: the same path returns 200 seconds later.
|
|
426
|
+
3. If the retries are exhausted the response is a `503` — not cached, carrying
|
|
427
|
+
`Retry-After`, and the next request can still produce the real content.
|
|
428
|
+
|
|
429
|
+
| During the render | Result of `notFound()` |
|
|
430
|
+
| --- | --- |
|
|
431
|
+
| A transient failure exists (`429`, `5xx`, network error) | Retry → the page if it succeeds; otherwise `503`, `Retry-After: 30`, `no-store` |
|
|
432
|
+
| The retry got a clean answer saying "not there" | A normal `404` |
|
|
433
|
+
| A permanent failure (`404`, `403`…) or no failure | A normal `404`, no retry |
|
|
434
|
+
|
|
435
|
+
The log lines:
|
|
436
|
+
|
|
437
|
+
```
|
|
438
|
+
[render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
|
|
439
|
+
[render] /news/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
So **an existing page never turns into a 404**: either the real content arrives,
|
|
443
|
+
or an uncached 503 does. Nothing is frozen as "missing".
|
|
444
|
+
|
|
445
|
+
The cost of a retry is a second round of requests on upstream, which is why the
|
|
446
|
+
default is a single attempt. The setting is `cache().transientRetry`:
|
|
447
|
+
|
|
448
|
+
```js
|
|
449
|
+
cache: {
|
|
450
|
+
transientRetry: { attempts: 2, delayMs: 500 },
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`transientRetry: false` (or `attempts: 0`) disables the retry and falls straight
|
|
455
|
+
through to the 503.
|
|
456
|
+
|
|
457
|
+
## Upstream rate limit: `cache().upstream`
|
|
458
|
+
|
|
459
|
+
Everything above describes what happens **after** a 429 arrives. This section is
|
|
460
|
+
about not getting one in the first place.
|
|
461
|
+
|
|
462
|
+
The brake sits inside the `trackUpstreamFetch()` wrapper, that is, where the
|
|
463
|
+
real `fetch` call goes out. The prewarm pass's `prewarm.rps` cannot do this job:
|
|
464
|
+
it counts **page** requests to our own server, but one page render may make one
|
|
465
|
+
API call or twenty. What binds the quota is the number of calls, not the number
|
|
466
|
+
of pages — and with the brake here, prewarming and real traffic spend the same
|
|
467
|
+
budget.
|
|
468
|
+
|
|
469
|
+
Off by default: unless `rate` is given, no request ever waits and the cost is a
|
|
470
|
+
single branch.
|
|
471
|
+
|
|
472
|
+
```js
|
|
473
|
+
// jskelet.config.mjs
|
|
474
|
+
cache: () => ({
|
|
475
|
+
upstream: {
|
|
476
|
+
rate: 10, // ceiling in calls per second, per host
|
|
477
|
+
burst: 20, // tolerance for short bursts
|
|
478
|
+
concurrency: 8, // calls in flight at once
|
|
479
|
+
hosts: {
|
|
480
|
+
// Endpoints with a different quota get their own settings.
|
|
481
|
+
"api.example.com": { rate: 3, concurrency: 2 },
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
}),
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### Three mechanisms, three different limits
|
|
488
|
+
|
|
489
|
+
| Mechanism | What it bounds | Settings |
|
|
490
|
+
| --- | --- | --- |
|
|
491
|
+
| Token bucket | Average rate (calls per second) | `rate`, `burst` |
|
|
492
|
+
| Concurrency | Instantaneous pressure (calls in flight) | `concurrency` |
|
|
493
|
+
| AIMD | What the right rate actually is | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
|
|
494
|
+
|
|
495
|
+
The third one is the real idea. A fixed rate is always either too slow or too
|
|
496
|
+
fast: nobody can write the true quota limit into a config file, and it changes
|
|
497
|
+
during the day anyway. So `rate` is treated as a **ceiling** and the actual rate
|
|
498
|
+
moves with what the upstream says:
|
|
499
|
+
|
|
500
|
+
- **429 or 503** → the rate is halved (multiplicative decrease). If the response
|
|
501
|
+
carries `Retry-After`, the bucket stops entirely for that long — the upstream
|
|
502
|
+
is already telling you how long to wait.
|
|
503
|
+
- **Every clean window** → the rate climbs by `increaseStep` (additive
|
|
504
|
+
increase), up to the `rate` ceiling.
|
|
505
|
+
|
|
506
|
+
Decreasing multiplicatively and increasing additively is deliberate. The other
|
|
507
|
+
way round would earn a fresh 429 every window.
|
|
508
|
+
|
|
509
|
+
### Circuit breaker
|
|
510
|
+
|
|
511
|
+
A host that returns `breakerFailures` (default 5) rate limits in a row is
|
|
512
|
+
bypassed entirely for `breakerCooldownMs`: the call is not made at all and is
|
|
513
|
+
reported straight away as a transient failure.
|
|
514
|
+
|
|
515
|
+
It looks harsh, but the asymmetry demands it: because a 429 counts as transient,
|
|
516
|
+
the HTML produced by that call is **not stored**. So a pass that hit the rate
|
|
517
|
+
limit spends quota and stores nothing in return — and the next pass finds the
|
|
518
|
+
same page cold and tries again. The breaker stops that burn.
|
|
519
|
+
|
|
520
|
+
```
|
|
521
|
+
[upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Only 429 and 503 count. A `400`/`404` is not a quota problem and neither is a
|
|
525
|
+
`500`: slowing down does not fix them, it only makes the site slower.
|
|
526
|
+
|
|
527
|
+
### Seeing the state
|
|
528
|
+
|
|
529
|
+
`getUpstreamLimiterStatus()` returns the current rate, calls in flight and
|
|
530
|
+
counters per host; the dev panel's **Server** tab prints the same thing. During
|
|
531
|
+
a 429 storm, tuning without knowing "what rate is it down to right now" is
|
|
532
|
+
guesswork.
|
|
533
|
+
|
|
534
|
+
```js
|
|
535
|
+
import { getUpstreamLimiterStatus } from "jskelet";
|
|
536
|
+
|
|
537
|
+
// [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
|
|
538
|
+
// active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Before turning it on
|
|
542
|
+
|
|
543
|
+
The rate limit is a last resort. If hundreds of pages fetch the same upstream
|
|
544
|
+
response, the real fix is keeping the
|
|
545
|
+
[`withDataCache`](#cross-request-data-cache-withdatacache) TTL longer than the
|
|
546
|
+
pass interval: a 400-page pass then makes one call for a shared endpoint. The
|
|
547
|
+
brake slows those calls down, it does not reduce their number.
|
|
548
|
+
|
|
549
|
+
## Managing the cache
|
|
550
|
+
|
|
551
|
+
`jskelet` exports these functions:
|
|
552
|
+
|
|
553
|
+
| Function | What it does |
|
|
554
|
+
| --- | --- |
|
|
555
|
+
| `withHtmlCache(key, ttlSeconds, producer)` | For using the cache directly. If `ttlSeconds` is 0 the producer always runs. |
|
|
556
|
+
| `invalidateHtmlCache(target, options?)` | Stales the matching pages (or drops them with `{ hard: true }`) and returns how many were affected. |
|
|
557
|
+
| `clearHtmlCache()` | Empties the store completely. |
|
|
558
|
+
| `getHtmlCacheSize()` | The number of entries. |
|
|
559
|
+
| `getHtmlCacheEntries()` | A dump: `{ key, bytes, status, stale, expiresIn, encodings, deps }`. The HTML body is not returned, only its size. |
|
|
560
|
+
|
|
561
|
+
### Targeted invalidation
|
|
562
|
+
|
|
563
|
+
`invalidateHtmlCache()` fills the gap between waiting for the TTL and flushing
|
|
564
|
+
the whole cache:
|
|
565
|
+
|
|
566
|
+
```js
|
|
567
|
+
import { invalidateHtmlCache } from "jskelet";
|
|
568
|
+
|
|
569
|
+
invalidateHtmlCache("/news/abc"); // that path and everything under it
|
|
570
|
+
invalidateHtmlCache("/news/:slug"); // the pattern syntax
|
|
571
|
+
invalidateHtmlCache([/-comments$/, "/"]); // regexps and lists
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
The default is to **stale** the entry, not to delete it: it is treated as
|
|
575
|
+
expired and falls through the normal stale-while-revalidate path. When a webhook
|
|
576
|
+
takes down five hundred pages at once, a hard delete starts five hundred cold
|
|
577
|
+
renders at exactly the moment the content changed, and hammers the upstream.
|
|
578
|
+
Staling instead hands the visitor the old HTML without a wait, and the refresh
|
|
579
|
+
runs in the background, once per key. Use `{ hard: true }` when the old HTML is
|
|
580
|
+
genuinely invalid.
|
|
581
|
+
|
|
582
|
+
Since the key is `path?query`, matching is done against the **path**: every
|
|
583
|
+
query variant of a path (including `?utm_source=…`) is covered by one call. For
|
|
584
|
+
a plain string the prefix stops at a segment boundary — a `/news` rule does not
|
|
585
|
+
touch `/newsletter`.
|
|
586
|
+
|
|
587
|
+
An in-flight render is targeted too: a pass that started before the purge is
|
|
588
|
+
carrying data that is now out of date, so it is **not** stored and the next
|
|
589
|
+
request starts a fresh pass.
|
|
590
|
+
|
|
591
|
+
### Automatic dependencies: `clearDataCache` refreshes the HTML too
|
|
592
|
+
|
|
593
|
+
You do not have to declare which page is affected by which content. Every
|
|
594
|
+
`withDataCache` key read during a render is recorded, and when `clearDataCache()`
|
|
595
|
+
drops a key, every HTML entry that **actually read it** is staled.
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
// the "this article changed" webhook
|
|
599
|
+
clearDataCache(`news:${slug}`);
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
That single line refreshes the article page, the home page that lists it and the
|
|
603
|
+
tag page together — because all three read that key. The most common mistake in
|
|
604
|
+
manual tagging (marking the detail page and forgetting the listing) is
|
|
605
|
+
structurally impossible here: nothing is declared, everything is observed.
|
|
606
|
+
|
|
607
|
+
Details:
|
|
608
|
+
|
|
609
|
+
- Dependencies are collected **on every refresh**, since the keys a page reads
|
|
610
|
+
can change over time.
|
|
611
|
+
- A purge that lands while a render is in flight is caught as well: that pass
|
|
612
|
+
would be stale the moment it was born, so it is not stored.
|
|
613
|
+
- The dependency count per page shows up as `deps` in the `getHtmlCacheEntries()`
|
|
614
|
+
dump. If an invalidation is not refreshing the page you expected, look there
|
|
615
|
+
first: the page may not be reading that data through `withDataCache`.
|
|
616
|
+
- An application that does not use `withDataCache` has nothing to record;
|
|
617
|
+
tracking can be turned off entirely with `cache().trackDependencies: false`.
|
|
618
|
+
- Staled paths go to the **front** of the prewarm queue. If `prewarm` is set up
|
|
619
|
+
the page is refreshed without waiting for a visitor, and the pass summary says
|
|
620
|
+
so: `[prewarm] warmed 12/12 pages, 3 invalidated (0.4s)`.
|
|
621
|
+
|
|
622
|
+
To write an admin endpoint:
|
|
623
|
+
|
|
624
|
+
```js
|
|
625
|
+
import { clearHtmlCache, getHtmlCacheEntries } from "jskelet";
|
|
626
|
+
|
|
627
|
+
export default function register(app) {
|
|
628
|
+
app.post("/_admin/cache/clear", (req, res) => {
|
|
629
|
+
if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
|
|
630
|
+
res.status(404).end();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
clearHtmlCache();
|
|
634
|
+
res.json({ ok: true });
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
app.get("/_admin/cache", (req, res) => {
|
|
638
|
+
res.json(getHtmlCacheEntries());
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The dev server also clears the cache by itself whenever the manifest changes:
|
|
644
|
+
the stored HTML would be carrying asset URLs with old hashes, and if it were
|
|
645
|
+
not cleared the page would keep requesting a deleted file
|
|
646
|
+
([09-dev-tools.md](./09-dev-tools.md)).
|
|
647
|
+
|
|
648
|
+
Because the cache lives in process memory, if you run more than one
|
|
649
|
+
process/replica each one has its own cache; `clearHtmlCache()` only affects the
|
|
650
|
+
process it is called in. The next section covers how to get past this when you
|
|
651
|
+
run several instances.
|
|
652
|
+
|
|
653
|
+
## A shared cache: Redis
|
|
654
|
+
|
|
655
|
+
The default cache belongs to a single process. That is the fastest and simplest
|
|
656
|
+
setup for a site running one instance — but two problems appear once you run
|
|
657
|
+
three replicas:
|
|
658
|
+
|
|
659
|
+
1. **Every replica warms up on its own.** When a new instance comes up, or a
|
|
660
|
+
container is replaced after a deploy, its cache is empty: the same page is
|
|
661
|
+
rendered three times and the same data is fetched three times.
|
|
662
|
+
2. **Invalidation reaches one replica.** The webhook that calls
|
|
663
|
+
`invalidateHtmlCache()` only refreshes the instance that received the
|
|
664
|
+
request; the others wait for the TTL. A visitor sees the old or the new
|
|
665
|
+
content depending on which replica they land on.
|
|
666
|
+
|
|
667
|
+
`cache().redis` solves both. Redis is **not the primary store**: the in-process
|
|
668
|
+
cache (L1) stays exactly as it is and every request reads it; Redis is a second
|
|
669
|
+
tier (L2).
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
// jskelet.config.mjs
|
|
673
|
+
export default {
|
|
674
|
+
cache() {
|
|
675
|
+
return {
|
|
676
|
+
html: { "/news/:slug": 300 },
|
|
677
|
+
redis: {
|
|
678
|
+
enabled: true,
|
|
679
|
+
url: process.env.REDIS_URL,
|
|
680
|
+
namespace: "news-site",
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
`ioredis` is an optional peer dependency, installed in the application itself:
|
|
688
|
+
|
|
689
|
+
```bash
|
|
690
|
+
npm install ioredis
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
If it is not installed, or Redis cannot be reached, a warning is printed and the
|
|
694
|
+
site **keeps running on the in-process cache**. The same happens if Redis goes
|
|
695
|
+
down while running: a circuit breaker bypasses the tier for five seconds after
|
|
696
|
+
five consecutive failures, so requests do not each wait for a network timeout.
|
|
697
|
+
|
|
698
|
+
### What you get
|
|
699
|
+
|
|
700
|
+
- **A cold instance finds a warm cache.** For a path that is not in L1, Redis is
|
|
701
|
+
read before the render runs; if another replica already produced that page, the
|
|
702
|
+
render never happens.
|
|
703
|
+
- **The data cache spends the quota once.** `withDataCache` works the same way,
|
|
704
|
+
and the gain is bigger here: JSON is small, and what one replica fetched is
|
|
705
|
+
enough for all of them.
|
|
706
|
+
- **Invalidation reaches every replica.** `invalidateHtmlCache()`,
|
|
707
|
+
`clearHtmlCache()` and `clearDataCache()` leave a message on a pub/sub
|
|
708
|
+
channel and each instance applies the same operation to its own L1. The
|
|
709
|
+
pattern is published, not the matched keys — which path is hot where depends
|
|
710
|
+
on the replica.
|
|
711
|
+
|
|
712
|
+
### Key layout
|
|
713
|
+
|
|
714
|
+
```
|
|
715
|
+
_jskelet:{namespace}:{buildId}:html:{path}?{query}
|
|
716
|
+
_jskelet:{namespace}:{buildId}:data:{key}
|
|
717
|
+
_jskelet:{namespace}:events
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
`buildId` changes with every build (`jskelet build` writes it to
|
|
721
|
+
`.jskelet/build.json`) and it is a **required** part: the stored HTML embeds
|
|
722
|
+
hashed asset paths, so after a deploy the old HTML is invalid. Because the id
|
|
723
|
+
sits in the prefix, a new version automatically writes into a new namespace and
|
|
724
|
+
the old keys die with their TTL — no manual cleanup and no `FLUSHDB`. When the
|
|
725
|
+
build has not been run the id is `dev`.
|
|
726
|
+
|
|
727
|
+
`namespace` separates several applications sharing one Redis. The event channel
|
|
728
|
+
deliberately does **not** carry `buildId`: during a deploy the old and the new
|
|
729
|
+
version run side by side and a purge has to reach both.
|
|
730
|
+
|
|
731
|
+
### Trade-offs worth knowing
|
|
732
|
+
|
|
733
|
+
- **Personalised output is never shared.** A render marked `storable: false` (a
|
|
734
|
+
page that read a cookie or `Authorization`) is never written to Redis. The
|
|
735
|
+
rule already holds in a single process, but it matters far more in a shared
|
|
736
|
+
tier: a leak would mean serving one user's HTML to the whole cluster.
|
|
737
|
+
`degraded` renders and non-200 status codes are not shared either.
|
|
738
|
+
- **Compressed bodies stay local by default.** `storeEncoded: true` turns this
|
|
739
|
+
on, but it doubles or triples the size per entry; recomputing brotli is
|
|
740
|
+
usually cheaper than downloading it from Redis.
|
|
741
|
+
- **A soft invalidation deletes the Redis copy.** Staling in Redis would mean a
|
|
742
|
+
read-modify-write round per key, and a webhook drops thousands of keys at
|
|
743
|
+
once. The cost of deleting is one render on a replica that never saw that
|
|
744
|
+
path; replicas whose L1 is hot keep serving the old HTML through the stale
|
|
745
|
+
window.
|
|
746
|
+
- **Only fresh entries are accepted.** Promoting a stale copy into L1 would
|
|
747
|
+
postpone the refresh forever: the entry stays stale, every pass reads Redis
|
|
748
|
+
again and the render never runs.
|
|
749
|
+
- **Consistency is eventual.** There is a short window between a purge and that
|
|
750
|
+
purge reaching every replica. During it a replica may serve the old HTML; the
|
|
751
|
+
window is bounded by the TTL.
|
|
752
|
+
- **Keep it off in dev.** The dev server clears the cache whenever the manifest
|
|
753
|
+
changes, which makes a shared store pointless. `enabled` only turns on when
|
|
754
|
+
`true` is passed explicitly.
|
|
755
|
+
|
|
756
|
+
### Seeing the status
|
|
757
|
+
|
|
758
|
+
```js
|
|
759
|
+
import { getRedisStatus } from "jskelet";
|
|
760
|
+
|
|
761
|
+
app.get("/api/healthcheck", (req, res) => {
|
|
762
|
+
res.json({ ok: true, cache: getRedisStatus() });
|
|
763
|
+
});
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Safe to call even with no connection. The returned object is
|
|
767
|
+
`{ enabled, connected, keyPrefix, buildId, errors, bypassed }`; `bypassed` tells
|
|
768
|
+
you the circuit breaker is open and `errors` is the total command failure count.
|
|
769
|
+
The same summary is in the dev panel report
|
|
770
|
+
([09-dev-tools.md](./09-dev-tools.md)).
|
|
771
|
+
|
|
772
|
+
Two more diagnostic surfaces:
|
|
773
|
+
|
|
774
|
+
| Call | What it tells you |
|
|
775
|
+
| --- | --- |
|
|
776
|
+
| `getRedisDetails()` | **Where** the connection points: address, TLS, database, `namespace`, which kinds are shared, whether the purge channel is subscribed. The password is never returned — a connection URL may carry one. |
|
|
777
|
+
| `inspectRedis()` | What is actually in the shared tier: keys per kind, `DBSIZE` and `used_memory`. It runs a `SCAN`, so **never call it on the request path**; in the admin panel it sits behind its own button. |
|
|
778
|
+
|
|
779
|
+
The full list of settings: [07-configuration.md](./07-configuration.md).
|
|
780
|
+
|
|
781
|
+
## The admin panel
|
|
782
|
+
|
|
783
|
+
Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
|
|
784
|
+
endpoints above, the framework ships a panel. It is deliberately separate from
|
|
785
|
+
the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
|
|
786
|
+
panel does not look at the environment — "why is this page stale", "did the
|
|
787
|
+
webhook purge land", "is Redis actually connected" are production questions.
|
|
788
|
+
|
|
789
|
+
```js
|
|
790
|
+
// jskelet.config.mjs
|
|
791
|
+
export default {
|
|
792
|
+
cache() {
|
|
793
|
+
return {
|
|
794
|
+
html: { "/news/:slug": 300 },
|
|
795
|
+
panel: { enabled: process.env.CACHE_PANEL === "1" },
|
|
796
|
+
};
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
Without `enabled` **nothing is mounted**: the path does not exist, the module is
|
|
802
|
+
never loaded and it costs the production process nothing. The environment
|
|
803
|
+
variable (`JSKELET_CACHE_PANEL=1`) overrides the config, because the panel is
|
|
804
|
+
usually opened once during an incident and editing the config file and
|
|
805
|
+
redeploying is the last thing you want at that moment.
|
|
806
|
+
|
|
807
|
+
When the panel is on, the server log prints the password:
|
|
808
|
+
|
|
809
|
+
```
|
|
810
|
+
[cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Access and hardening
|
|
814
|
+
|
|
815
|
+
- **The password is regenerated on every process start** (32 hex characters) and
|
|
816
|
+
only ever appears in the log. There is no persistent secret to leak: leaking
|
|
817
|
+
one means handing out the right to flush the cache, and a deploy should revoke
|
|
818
|
+
old access on its own.
|
|
819
|
+
- **The password is not accepted in the query string,** so access logs, browser
|
|
820
|
+
history and the `Referer` header never carry it. Sign-in goes through the form.
|
|
821
|
+
- **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
|
|
822
|
+
Requests without a session count just like a wrong password; a successful
|
|
823
|
+
sign-in resets the counter.
|
|
824
|
+
- **Banned and unauthorised requests get a `404`.** A 401 or 403 confirms the
|
|
825
|
+
panel exists; a 404 behaves as if it never did. The rest of the site is
|
|
826
|
+
untouched.
|
|
827
|
+
- **Nothing is indexable:** every response carries `X-Robots-Tag: noindex,
|
|
828
|
+
nofollow, noarchive, nosnippet`, `Cache-Control: no-store` and
|
|
829
|
+
`Referrer-Policy: no-referrer`. The path is also exempt from prewarming and
|
|
830
|
+
from navigation speculation.
|
|
831
|
+
- Actions require an `X-JSkelet-Cache-Panel` header — a header a cross-site form
|
|
832
|
+
cannot send, which is the panel's own CSRF brake.
|
|
833
|
+
- Sessions and ban counters live in process memory; persisting them to disk
|
|
834
|
+
would be the wrong trade for a panel whose password changes on every restart.
|
|
835
|
+
|
|
836
|
+
### What the panel shows
|
|
837
|
+
|
|
838
|
+
| Area | Contents |
|
|
839
|
+
| --- | --- |
|
|
840
|
+
| Top bar | Version, environment, pid, uptime, RSS and the language picker (Turkish / English) |
|
|
841
|
+
| Cards | HTML entry count and limit, HTML bytes in memory, stale entry count, data entry count, Redis state (`connected` / `bypassed` / `off`), prewarm progress |
|
|
842
|
+
| Shared tier | **Where** the connection points (address, TLS, database), the key prefix and `namespace`, the `buildId`, which kinds are shared, the state of compressed bodies and the purge broadcast, the command timeout and the error count. When it is off, a Redis recommendation with an install snippet takes its place. |
|
|
843
|
+
| Cloudflare | Zone, plan, cache related zone settings, how long development mode has left, Tiered Cache / Cache Reserve state and the cache hit ratio. When no zone is connected, a setup snippet takes its place. |
|
|
844
|
+
| Host | The machine's memory usage and how full the disk holding the project is |
|
|
845
|
+
| Entry list | HTML: path (opens in a new tab), fresh/stale, size, status code, remaining TTL, dependency count, precompressed bodies. Data: key (click to copy), fresh/stale, remaining TTL |
|
|
846
|
+
|
|
847
|
+
The list is **filtered by key** and the filter runs on the server: a data cache
|
|
848
|
+
can hold tens of thousands of keys. At most 500 rows come back per request and
|
|
849
|
+
the counter in the heading says how many matches were cut. HTML bodies and
|
|
850
|
+
cached values are **never returned** — the panel's job is to show state, not to
|
|
851
|
+
export content.
|
|
852
|
+
|
|
853
|
+
### What you can do from it
|
|
854
|
+
|
|
855
|
+
| Action | Equivalent call |
|
|
856
|
+
| --- | --- |
|
|
857
|
+
| Invalidate (target + `hard`) | `invalidateHtmlCache(target, { hard })` |
|
|
858
|
+
| `drop` a single row | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
|
|
859
|
+
| Clear HTML cache | `clearHtmlCache()` |
|
|
860
|
+
| Clear data cache (optional prefix) | `clearDataCache(prefix)` |
|
|
861
|
+
| Drop shared keys | Scans and unlinks the `html` or `data` namespace in Redis |
|
|
862
|
+
| Count keys in Redis | `inspectRedis()` — keys per kind, `DBSIZE` and `used_memory` |
|
|
863
|
+
| Prewarm | `prewarm()` — the pass runs in the background, progress shows in the card |
|
|
864
|
+
| Cloudflare purge (everything / URLs held here / prefix / host / tag) | `purgeCloudflare()` |
|
|
865
|
+
| Change a Cloudflare setting or feature | Zone settings and Tiered Cache / Cache Reserve |
|
|
866
|
+
|
|
867
|
+
Each one propagates to the shared tier as well: clearing a single replica's
|
|
868
|
+
cache is what produces the "I cleared it and it is still old" question in a
|
|
869
|
+
clustered setup.
|
|
870
|
+
|
|
871
|
+
The panel speaks two languages: the picker in the header switches between
|
|
872
|
+
Turkish and English. The first visit follows the browser, the choice is kept in
|
|
873
|
+
`localStorage` and applies to the login page too. Switching costs no request.
|
|
874
|
+
The server never knows the interface language: an `/action` response returns a
|
|
875
|
+
code rather than a sentence (`{ ok, code, params }`) and the panel builds the
|
|
876
|
+
text — so the framework's log and API stay in one language.
|
|
877
|
+
|
|
878
|
+
Dropping a single row is not the same as `invalidateHtmlCache()`: that one
|
|
879
|
+
matches a path pattern and takes down **every** query variant of a path, while
|
|
880
|
+
`dropHtmlCacheKey()` takes the exact key — `/list?page=2` goes and
|
|
881
|
+
`/list?page=3` stays hot.
|
|
882
|
+
|
|
883
|
+
## The CDN tier: Cloudflare
|
|
884
|
+
|
|
885
|
+
Everything above is the **origin** cache. With Cloudflare in front, the HTML
|
|
886
|
+
your visitors get usually never reaches you: the copy at the edge is served
|
|
887
|
+
until its TTL runs out. That is why `invalidateHtmlCache()` alone does not fix
|
|
888
|
+
"I updated the page but the old one still shows" — the origin refreshes, the
|
|
889
|
+
edge keeps waiting.
|
|
890
|
+
|
|
891
|
+
JSkelet lets you drive both tiers from the same place.
|
|
892
|
+
|
|
893
|
+
### Setup
|
|
894
|
+
|
|
895
|
+
The token is a secret, so it goes in the environment, not in a config file:
|
|
896
|
+
|
|
897
|
+
```bash
|
|
898
|
+
JSKELET_CLOUDFLARE_KEY=... # API token
|
|
899
|
+
JSKELET_CLOUDFLARE_ZONE_ID=... # zone identifier
|
|
900
|
+
JSKELET_CLOUDFLARE_HOSTNAME=example.com # optional
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Which permissions the token needs depends on what you want to do: `Zone.Cache
|
|
904
|
+
Purge` to purge, `Zone.Zone Settings` to change settings, `Zone.Analytics`
|
|
905
|
+
(read) for the hit ratio and the edge breakdown. A purge-only token still opens
|
|
906
|
+
the panel; the settings sections just report an error.
|
|
907
|
+
|
|
908
|
+
The zone id and site name are not secrets, so they can also come from
|
|
909
|
+
`jskelet.config.mjs`. The environment always wins:
|
|
910
|
+
|
|
911
|
+
```js
|
|
912
|
+
cache: {
|
|
913
|
+
cloudflare: {
|
|
914
|
+
zoneId: "…",
|
|
915
|
+
hostname: "example.com", // purging wants absolute URLs; this turns paths into them
|
|
916
|
+
analyticsHours: 24,
|
|
917
|
+
},
|
|
918
|
+
}
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
Without `hostname`, purge URLs are derived from the origin the panel was opened
|
|
922
|
+
on. If you reach the panel over an internal address (`http://10.0.0.4:3000`),
|
|
923
|
+
that address means nothing to Cloudflare — there, `hostname` is required.
|
|
924
|
+
|
|
925
|
+
### What you can do
|
|
926
|
+
|
|
927
|
+
Whatever Cloudflare's cache surface offers is in the panel:
|
|
928
|
+
|
|
929
|
+
| Action | Note |
|
|
930
|
+
| --- | --- |
|
|
931
|
+
| Purge everything | The whole zone. The bluntest tool; warming back up is expensive |
|
|
932
|
+
| Purge by URL | Every page currently held in memory with one button, or `cf purge` per row |
|
|
933
|
+
| Purge by prefix / host / tag | Available on all plans now; 100 keys per request |
|
|
934
|
+
| Development mode | Bypasses the edge cache for three hours, then turns itself off |
|
|
935
|
+
| Cache level, browser cache TTL, query string sorting, Always Online | Zone settings |
|
|
936
|
+
| Tiered Cache, Regional Tiered Cache, Cache Reserve | Plan dependent; shows "unavailable" where the plan lacks it |
|
|
937
|
+
| Clear Cache Reserve | Separate from purging: `purge_everything` drops the edges, the persistent copy in R2 stays |
|
|
938
|
+
|
|
939
|
+
Long URL lists are split into batches of 100 keys and sent **sequentially**.
|
|
940
|
+
Sending them in parallel means half the batch rejected on the Free plan, where
|
|
941
|
+
purging is limited to five requests per minute.
|
|
942
|
+
|
|
943
|
+
The same surface from code:
|
|
944
|
+
|
|
945
|
+
```js
|
|
946
|
+
import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
|
|
947
|
+
|
|
948
|
+
export async function onPostPublished(slug) {
|
|
949
|
+
const paths = ["/", `/blog/${slug}`];
|
|
950
|
+
|
|
951
|
+
invalidateHtmlCache(paths); // origin
|
|
952
|
+
await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
|
|
953
|
+
}
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
Nothing in this module throws: with no token, on a Cloudflare 403 or when the
|
|
957
|
+
network drops, the result is `{ ok: false, error }`. A CDN outage should not
|
|
958
|
+
break your publishing flow.
|
|
959
|
+
|
|
960
|
+
### "How many edges hold this page?" — what can and cannot be asked
|
|
961
|
+
|
|
962
|
+
There is no Cloudflare endpoint that lists the **inventory** of an object.
|
|
963
|
+
Hundreds of cities run independent caches and none of them will answer "do you
|
|
964
|
+
currently hold this URL". So the panel shows observation rather than inventory:
|
|
965
|
+
enter a path and the GraphQL analytics tell you which colo (IST, FRA, AMS…)
|
|
966
|
+
served it from cache and how often it went to the origin over the last N hours.
|
|
967
|
+
|
|
968
|
+
```js
|
|
969
|
+
const report = await fetchPathEdges({ path: "/blog", hours: 24 });
|
|
970
|
+
// → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
Two limits to keep in mind while reading it: an edge that received no request
|
|
974
|
+
does not appear at all, even if it holds a copy; and the dataset is sampled, so
|
|
975
|
+
ratios are reliable while absolute counts are estimates.
|
|
976
|
+
|
|
977
|
+
There is also no way to **warm** an edge you pick. An object enters an edge
|
|
978
|
+
cache only through a real request routed there; you cannot tell Frankfurt from
|
|
979
|
+
your server to go cache something. Three things do work in practice:
|
|
980
|
+
|
|
981
|
+
- **Warm the origin** (`prewarm`): the edge that takes the first request finds
|
|
982
|
+
a ready response, so that request is not the slow one.
|
|
983
|
+
- **Tiered Cache**: edges do not go straight to the origin, they pull from an
|
|
984
|
+
upper tier — the first request in one city counts as warming for the others.
|
|
985
|
+
- **Cache Reserve**: a persistent copy in R2 for long-tail content, so requests
|
|
986
|
+
do not reach the origin when an edge evicts.
|
|
987
|
+
|
|
988
|
+
If your `hit` ratio is low, check whether the response is cacheable at all
|
|
989
|
+
before anything else: `Cache-Control: private`, `Set-Cookie` and query string
|
|
990
|
+
settings are the most common reasons an edge decides not to cache, and they
|
|
991
|
+
show up as `dynamic` in this panel.
|
|
992
|
+
|
|
993
|
+
## Prewarm — warming up at startup
|
|
994
|
+
|
|
995
|
+
The equivalent of Next's build-time prerender, except the output is not written
|
|
996
|
+
to disk: since the cache lives in process memory, the warm-up also happens when
|
|
997
|
+
the process comes up. The gain is the same — the first visitor does not wait
|
|
998
|
+
for a cold render — but the data is not frozen; every entry ages with the
|
|
999
|
+
route's `revalidate` and is refreshed in the background with
|
|
1000
|
+
stale-while-revalidate.
|
|
1001
|
+
|
|
1002
|
+
The warm-up is done with **real HTTP requests**
|
|
1003
|
+
(`http://127.0.0.1:<port>`), so that the cache key, the compression and the
|
|
1004
|
+
middleware chain are exactly the same as with normal traffic.
|
|
1005
|
+
|
|
1006
|
+
### `hooks.prewarmPaths()`
|
|
1007
|
+
|
|
1008
|
+
The application declares which paths get warmed; usually it is the very same
|
|
1009
|
+
function that produces the sitemap.
|
|
1010
|
+
|
|
1011
|
+
```js
|
|
1012
|
+
// jskelet.config.mjs
|
|
1013
|
+
export default {
|
|
1014
|
+
hooks: {
|
|
1015
|
+
async prewarmPaths() {
|
|
1016
|
+
const slugs = await getAllArticleSlugs();
|
|
1017
|
+
return ["/", "/markets", ...slugs.map((slug) => `/news/${slug}`)];
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
};
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
Rules:
|
|
1024
|
+
|
|
1025
|
+
- If it does not return an array a warning is printed and no warm-up happens.
|
|
1026
|
+
- Only strings starting with `/` are taken.
|
|
1027
|
+
- Ones starting with one of the `prewarmSkip` prefixes are skipped. The default
|
|
1028
|
+
list: `/api/`, `/_fragment/`, `/__jskelet/`. Session-dependent pages should
|
|
1029
|
+
not be warmed.
|
|
1030
|
+
- Deduplication **preserves order**: when no `priority` is given, the order the
|
|
1031
|
+
application provides is meaningful — put the most important pages first.
|
|
1032
|
+
- If this hook is not defined the warm-up is never set up; not even the timer
|
|
1033
|
+
is started.
|
|
1034
|
+
|
|
1035
|
+
### Round logic
|
|
1036
|
+
|
|
1037
|
+
1. The list is collected. If it is longer than `max` (400 by default) a slice is
|
|
1038
|
+
selected: the paths matching `priority` are taken first **on every round**,
|
|
1039
|
+
and the remaining slots are filled from the queue.
|
|
1040
|
+
2. `concurrency` workers send requests in parallel (4 in prod, 1 in dev). A
|
|
1041
|
+
single worker in dev: so the scan does not compete for CPU with the render of
|
|
1042
|
+
the page you currently have open in the browser.
|
|
1043
|
+
3. If `rps` is given, the round never goes above that rate — no matter the
|
|
1044
|
+
parallelism. In dev, 4 requests per second apply by default: rendering runs
|
|
1045
|
+
on a single event loop, so an unpaced round leaves page requests and the dev
|
|
1046
|
+
panel's live channel waiting behind it.
|
|
1047
|
+
4. **A single serial retry round** is performed for the paths that hit a
|
|
1048
|
+
**transient** failure (`concurrency: 1`). Permanent answers like `400`, `403`
|
|
1049
|
+
or `404` are not retried: a deterministic error does not get better on the
|
|
1050
|
+
second try and those calls spend quota for nothing. The summary shows them as
|
|
1051
|
+
`N not retried (permanent)`.
|
|
1052
|
+
5. The wait before the retry is `retryDelayMs`, but when the rate limit is on and
|
|
1053
|
+
something is holding it back, that wins: retrying 2 seconds into a 10 second
|
|
1054
|
+
circuit breaker would just earn the same 429 up front.
|
|
1055
|
+
6. A summary is logged:
|
|
1056
|
+
`[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
|
|
1057
|
+
|
|
1058
|
+
Then comes how much the pass actually touched the upstream:
|
|
1059
|
+
|
|
1060
|
+
```text
|
|
1061
|
+
[prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
This is the one line that tells you which way to turn the knob. If the ratio is
|
|
1065
|
+
low the fix is not the rate limit but a longer `withDataCache` TTL — the brake
|
|
1066
|
+
slows calls down, it does not reduce their number. The same counters are
|
|
1067
|
+
available through `getDataCacheStats()` and on the dev report's **Data cache**
|
|
1068
|
+
card.
|
|
1069
|
+
|
|
1070
|
+
Request errors and the per-page render warnings (`was produced with missing
|
|
1071
|
+
data`, `returned notFound() while upstream is failing`, `could not be
|
|
1072
|
+
produced`) raised during the pass are not logged one by one. They are counted
|
|
1073
|
+
while the pass runs and printed after the summary, most frequent kinds first:
|
|
1074
|
+
|
|
1075
|
+
```text
|
|
1076
|
+
[prewarm] 137 problems were not logged individually:
|
|
1077
|
+
94× missing data, upstream is failing permanently (403 /api/v1/polls)
|
|
1078
|
+
37× missing data, upstream is failing permanently (400 /api/v1/posts)
|
|
1079
|
+
6× 500 Cannot read properties of undefined (reading 'title')
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
This way a momentary upstream failure cannot bury the "warmed …" line under
|
|
1083
|
+
hundreds of stack traces. Errors from real traffic are logged immediately as
|
|
1084
|
+
before; for the detail of a single path, look at the **Prewarming** tab in the
|
|
1085
|
+
dev panel.
|
|
1086
|
+
|
|
1087
|
+
### Warm-up order: `priority`
|
|
1088
|
+
|
|
1089
|
+
```js
|
|
1090
|
+
// jskelet.config.mjs
|
|
1091
|
+
cache: () => ({
|
|
1092
|
+
prewarm: {
|
|
1093
|
+
priority: [
|
|
1094
|
+
"/",
|
|
1095
|
+
"/markets/:path*",
|
|
1096
|
+
/-comments$/,
|
|
1097
|
+
],
|
|
1098
|
+
},
|
|
1099
|
+
}),
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
The pattern syntax (`/news/:slug`) and a plain `RegExp` can be used together;
|
|
1103
|
+
the latter is for rules the pattern syntax does not cover, such as "everything
|
|
1104
|
+
ending in `-comments`". Whatever is written first is warmed first; paths that
|
|
1105
|
+
match nothing go to the queue and keep their relative order.
|
|
1106
|
+
|
|
1107
|
+
### Drip warm-up: `rotate` + `rps` + `intervalSeconds`
|
|
1108
|
+
|
|
1109
|
+
On a site with 10,000 paths, warming everything in a single round is neither
|
|
1110
|
+
possible (the HTML cache holds 500 entries) nor right (the API quota runs out).
|
|
1111
|
+
The correct behaviour is to spread the list over time:
|
|
1112
|
+
|
|
1113
|
+
```js
|
|
1114
|
+
prewarm: {
|
|
1115
|
+
max: 300, // 300 pages per round
|
|
1116
|
+
rps: 4, // at most 4 requests per second
|
|
1117
|
+
intervalSeconds: 300, // a round every 5 minutes
|
|
1118
|
+
rotate: true, // the queue continues where it left off
|
|
1119
|
+
priority: ["/", "/markets/:path*"],
|
|
1120
|
+
}
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
In this setup the priority pages are refreshed on every round, the rest of the
|
|
1124
|
+
queue is walked end to end across rounds, and upstream never sees more than four
|
|
1125
|
+
requests per second. Used together with the data cache, the warm-up barely
|
|
1126
|
+
reaches the API after the second round: it reads from the data layer.
|
|
1127
|
+
|
|
1128
|
+
With rotation on, the paths left outside the limit are not lost, they are left
|
|
1129
|
+
for the next round; the log distinguishes this:
|
|
1130
|
+
`… , 700 deferred to the next pass`. With `rotate: false` you get the classic
|
|
1131
|
+
behaviour — every round warms the same first slice of the list and the rest is
|
|
1132
|
+
never warmed (`… , 700 over the limit`).
|
|
1133
|
+
|
|
1134
|
+
If a round takes longer than `intervalSeconds`, a new round is not started;
|
|
1135
|
+
overlapping rounds would put twice the load on upstream.
|
|
1136
|
+
|
|
1137
|
+
The requests go out with the headers `user-agent: jskelet-prewarm`
|
|
1138
|
+
(`brand.prewarmUserAgent`) and `accept-encoding: br, gzip`; the second one so
|
|
1139
|
+
that the compressed body enters the cache too.
|
|
1140
|
+
|
|
1141
|
+
If `DEV_TOKEN` is set, the warm-up carries the token as a cookie; otherwise the
|
|
1142
|
+
dev gate returns 404 for all pages and the cache never fills.
|
|
1143
|
+
|
|
1144
|
+
The request list in the dev panel and the terminal filter out requests carrying
|
|
1145
|
+
`prewarmUserAgent`: so that hundreds of warm-up requests do not flood the view.
|
|
1146
|
+
Progress shows up in the badge next to the bubble.
|
|
1147
|
+
|
|
1148
|
+
### Timing
|
|
1149
|
+
|
|
1150
|
+
- The warm-up starts at boot **with a delay**: so it does not compete with the
|
|
1151
|
+
first real requests. The default delay is 500 ms in prod and 3000 ms in dev.
|
|
1152
|
+
Longer in dev, because a file save restarts the process and the timer dies
|
|
1153
|
+
with it; it only warms up once the server stays quiet for a while.
|
|
1154
|
+
- If `PREWARM_INTERVAL_SECONDS` / `cache().prewarm.intervalSeconds` > 0 the
|
|
1155
|
+
round is repeated periodically. Because entries age with `revalidate` and the
|
|
1156
|
+
visitor does not wait thanks to stale-while-revalidate, this is **optional**;
|
|
1157
|
+
it is for setups that also want to keep pages that are never visited warm.
|
|
1158
|
+
- All timers are `unref()`ed: they do not delay process shutdown.
|
|
1159
|
+
- No warm-up failure takes the process down.
|
|
1160
|
+
|
|
1161
|
+
### Settings
|
|
1162
|
+
|
|
1163
|
+
Order of precedence: **environment variable → config → code default.** Env
|
|
1164
|
+
comes first so that one-off experiments can be done without editing the config.
|
|
1165
|
+
|
|
1166
|
+
| Setting | Env | `cache().prewarm` | Default |
|
|
1167
|
+
| --- | --- | --- | --- |
|
|
1168
|
+
| On/off | `PREWARM=0` disables it, `PREWARM=1` overrides the config and enables it | `enabled` | `true` |
|
|
1169
|
+
| Maximum paths per round | `PREWARM_MAX` | `max` | `400` |
|
|
1170
|
+
| Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
|
|
1171
|
+
| Requests per second | `PREWARM_RPS` | `rps` | prod `0` (unlimited), dev 4 |
|
|
1172
|
+
| Startup delay (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
|
|
1173
|
+
| Retry round delay (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
|
|
1174
|
+
| Period (seconds) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (off) |
|
|
1175
|
+
| Queue rotation | — | `rotate` | `true` |
|
|
1176
|
+
| Warm-up order | — | `priority` | `[]` |
|
|
1177
|
+
|
|
1178
|
+
Numeric settings only accept **positive and finite** values; an invalid value
|
|
1179
|
+
silently falls through to the next layer.
|
|
1180
|
+
|
|
1181
|
+
### Triggering by hand
|
|
1182
|
+
|
|
1183
|
+
```js
|
|
1184
|
+
import { prewarm, prewarmProgress } from "jskelet";
|
|
1185
|
+
|
|
1186
|
+
await prewarm({ origin: "http://127.0.0.1:3000" }); // paths from the hook
|
|
1187
|
+
await prewarm({ origin, paths: ["/", "/markets"] }); // only these paths
|
|
1188
|
+
await prewarm({ origin, quiet: true }); // without printing a summary
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
If `paths` is given the hook is never called. The return value is
|
|
1192
|
+
`{ ok, failed, total, elapsed }`.
|
|
1193
|
+
|
|
1194
|
+
`prewarmProgress` holds the live state and the dev panel reads it:
|
|
1195
|
+
|
|
1196
|
+
```js
|
|
1197
|
+
{
|
|
1198
|
+
active, done, total, ok, failed, startedAt, finishedAt,
|
|
1199
|
+
entries: [{ path, status, ms, bytes, cache, error }],
|
|
1200
|
+
}
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
The `cache` field inside `entries` is that path's `X-JSkelet-Cache` response;
|
|
1204
|
+
from there you can see whether the warm-up round really returned `MISS` and
|
|
1205
|
+
filled the cache.
|
|
1206
|
+
|
|
1207
|
+
## Diagnosis: common situations
|
|
1208
|
+
|
|
1209
|
+
- **Every request returns `MISS`.** The route was not given a `revalidate`, or
|
|
1210
|
+
the pattern inside `cache().html` gives 0 seconds. Or the page returns a code
|
|
1211
|
+
other than `status: 200`.
|
|
1212
|
+
- **The page returns `MISS` but upstream is healthy.** A transient upstream
|
|
1213
|
+
failure may have been reported; look for the line `was produced with missing
|
|
1214
|
+
data, not caching it` in the log.
|
|
1215
|
+
- **Stale data all the time.** `revalidate` is too high; remember that the real
|
|
1216
|
+
lag is at most `revalidate` + one refresh round.
|
|
1217
|
+
- **The cache is bloating.** Because query parameters go into the key, campaign
|
|
1218
|
+
parameters may be multiplying entries.
|
|
1219
|
+
- **The warm-up never runs.** `hooks.prewarmPaths` is not defined, `PREWARM=0`
|
|
1220
|
+
is set, or `cache().prewarm.enabled === false`.
|
|
1221
|
+
- **The warm-up round pushes the API into 429.** No `rps` was given. Lowering
|
|
1222
|
+
`concurrency` is not enough; the setting that protects the quota is the total
|
|
1223
|
+
rate. The lasting fix is the data cache: after the second round the warm-up
|
|
1224
|
+
does not reach upstream.
|
|
1225
|
+
- **The warm-up list is longer than `max` and its tail never warms.** `rotate`
|
|
1226
|
+
may be `false`; the `over the limit` phrase in the log shows this.
|
|
1227
|
+
- **A whole section returns 404.** Upstream may be down. The page is now retried
|
|
1228
|
+
once and, failing that, a 503 that does not enter the cache is returned
|
|
1229
|
+
instead of a 404; look for the `returned notFound() while upstream is failing`
|
|
1230
|
+
line in the log. If you still see 404s, the failure may come from a non-`fetch`
|
|
1231
|
+
client (which needs `reportUpstreamFailure()`) or `cache().trackUpstream` is
|
|
1232
|
+
off.
|
|
1233
|
+
|
|
1234
|
+
## What's next
|
|
1235
|
+
|
|
1236
|
+
- The full reference of config fields and the env table:
|
|
1237
|
+
[07-configuration.md](./07-configuration.md)
|
|
1238
|
+
- Watching the cache from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
|
|
1239
|
+
- Using it together with a CDN/reverse proxy: [10-deployment.md](./10-deployment.md)
|