jskelet 0.2.3 → 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.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,1232 +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 |
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
- Dropping a single row is not the same as `invalidateHtmlCache()`: that one
872
- matches a path pattern and takes down **every** query variant of a path, while
873
- `dropHtmlCacheKey()` takes the exact key — `/list?page=2` goes and
874
- `/list?page=3` stays hot.
875
-
876
- ## The CDN tier: Cloudflare
877
-
878
- Everything above is the **origin** cache. With Cloudflare in front, the HTML
879
- your visitors get usually never reaches you: the copy at the edge is served
880
- until its TTL runs out. That is why `invalidateHtmlCache()` alone does not fix
881
- "I updated the page but the old one still shows" — the origin refreshes, the
882
- edge keeps waiting.
883
-
884
- JSkelet lets you drive both tiers from the same place.
885
-
886
- ### Setup
887
-
888
- The token is a secret, so it goes in the environment, not in a config file:
889
-
890
- ```bash
891
- JSKELET_CLOUDFLARE_KEY=... # API token
892
- JSKELET_CLOUDFLARE_ZONE_ID=... # zone identifier
893
- JSKELET_CLOUDFLARE_HOSTNAME=example.com # optional
894
- ```
895
-
896
- Which permissions the token needs depends on what you want to do: `Zone.Cache
897
- Purge` to purge, `Zone.Zone Settings` to change settings, `Zone.Analytics`
898
- (read) for the hit ratio and the edge breakdown. A purge-only token still opens
899
- the panel; the settings sections just report an error.
900
-
901
- The zone id and site name are not secrets, so they can also come from
902
- `jskelet.config.mjs`. The environment always wins:
903
-
904
- ```js
905
- cache: {
906
- cloudflare: {
907
- zoneId: "…",
908
- hostname: "example.com", // purging wants absolute URLs; this turns paths into them
909
- analyticsHours: 24,
910
- },
911
- }
912
- ```
913
-
914
- Without `hostname`, purge URLs are derived from the origin the panel was opened
915
- on. If you reach the panel over an internal address (`http://10.0.0.4:3000`),
916
- that address means nothing to Cloudflare — there, `hostname` is required.
917
-
918
- ### What you can do
919
-
920
- Whatever Cloudflare's cache surface offers is in the panel:
921
-
922
- | Action | Note |
923
- | --- | --- |
924
- | Purge everything | The whole zone. The bluntest tool; warming back up is expensive |
925
- | Purge by URL | Every page currently held in memory with one button, or `cf purge` per row |
926
- | Purge by prefix / host / tag | Available on all plans now; 100 keys per request |
927
- | Development mode | Bypasses the edge cache for three hours, then turns itself off |
928
- | Cache level, browser cache TTL, query string sorting, Always Online | Zone settings |
929
- | Tiered Cache, Regional Tiered Cache, Cache Reserve | Plan dependent; shows "unavailable" where the plan lacks it |
930
- | Clear Cache Reserve | Separate from purging: `purge_everything` drops the edges, the persistent copy in R2 stays |
931
-
932
- Long URL lists are split into batches of 100 keys and sent **sequentially**.
933
- Sending them in parallel means half the batch rejected on the Free plan, where
934
- purging is limited to five requests per minute.
935
-
936
- The same surface from code:
937
-
938
- ```js
939
- import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
940
-
941
- export async function onPostPublished(slug) {
942
- const paths = ["/", `/blog/${slug}`];
943
-
944
- invalidateHtmlCache(paths); // origin
945
- await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
946
- }
947
- ```
948
-
949
- Nothing in this module throws: with no token, on a Cloudflare 403 or when the
950
- network drops, the result is `{ ok: false, error }`. A CDN outage should not
951
- break your publishing flow.
952
-
953
- ### "How many edges hold this page?" — what can and cannot be asked
954
-
955
- There is no Cloudflare endpoint that lists the **inventory** of an object.
956
- Hundreds of cities run independent caches and none of them will answer "do you
957
- currently hold this URL". So the panel shows observation rather than inventory:
958
- enter a path and the GraphQL analytics tell you which colo (IST, FRA, AMS…)
959
- served it from cache and how often it went to the origin over the last N hours.
960
-
961
- ```js
962
- const report = await fetchPathEdges({ path: "/blog", hours: 24 });
963
- // → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
964
- ```
965
-
966
- Two limits to keep in mind while reading it: an edge that received no request
967
- does not appear at all, even if it holds a copy; and the dataset is sampled, so
968
- ratios are reliable while absolute counts are estimates.
969
-
970
- There is also no way to **warm** an edge you pick. An object enters an edge
971
- cache only through a real request routed there; you cannot tell Frankfurt from
972
- your server to go cache something. Three things do work in practice:
973
-
974
- - **Warm the origin** (`prewarm`): the edge that takes the first request finds
975
- a ready response, so that request is not the slow one.
976
- - **Tiered Cache**: edges do not go straight to the origin, they pull from an
977
- upper tier — the first request in one city counts as warming for the others.
978
- - **Cache Reserve**: a persistent copy in R2 for long-tail content, so requests
979
- do not reach the origin when an edge evicts.
980
-
981
- If your `hit` ratio is low, check whether the response is cacheable at all
982
- before anything else: `Cache-Control: private`, `Set-Cookie` and query string
983
- settings are the most common reasons an edge decides not to cache, and they
984
- show up as `dynamic` in this panel.
985
-
986
- ## Prewarm — warming up at startup
987
-
988
- The equivalent of Next's build-time prerender, except the output is not written
989
- to disk: since the cache lives in process memory, the warm-up also happens when
990
- the process comes up. The gain is the same — the first visitor does not wait
991
- for a cold render — but the data is not frozen; every entry ages with the
992
- route's `revalidate` and is refreshed in the background with
993
- stale-while-revalidate.
994
-
995
- The warm-up is done with **real HTTP requests**
996
- (`http://127.0.0.1:<port>`), so that the cache key, the compression and the
997
- middleware chain are exactly the same as with normal traffic.
998
-
999
- ### `hooks.prewarmPaths()`
1000
-
1001
- The application declares which paths get warmed; usually it is the very same
1002
- function that produces the sitemap.
1003
-
1004
- ```js
1005
- // jskelet.config.mjs
1006
- export default {
1007
- hooks: {
1008
- async prewarmPaths() {
1009
- const slugs = await getAllArticleSlugs();
1010
- return ["/", "/markets", ...slugs.map((slug) => `/news/${slug}`)];
1011
- },
1012
- },
1013
- };
1014
- ```
1015
-
1016
- Rules:
1017
-
1018
- - If it does not return an array a warning is printed and no warm-up happens.
1019
- - Only strings starting with `/` are taken.
1020
- - Ones starting with one of the `prewarmSkip` prefixes are skipped. The default
1021
- list: `/api/`, `/_fragment/`, `/__jskelet/`. Session-dependent pages should
1022
- not be warmed.
1023
- - Deduplication **preserves order**: when no `priority` is given, the order the
1024
- application provides is meaningful — put the most important pages first.
1025
- - If this hook is not defined the warm-up is never set up; not even the timer
1026
- is started.
1027
-
1028
- ### Round logic
1029
-
1030
- 1. The list is collected. If it is longer than `max` (400 by default) a slice is
1031
- selected: the paths matching `priority` are taken first **on every round**,
1032
- and the remaining slots are filled from the queue.
1033
- 2. `concurrency` workers send requests in parallel (4 in prod, 1 in dev). A
1034
- single worker in dev: so the scan does not compete for CPU with the render of
1035
- the page you currently have open in the browser.
1036
- 3. If `rps` is given, the round never goes above that rate — no matter the
1037
- parallelism. In dev, 4 requests per second apply by default: rendering runs
1038
- on a single event loop, so an unpaced round leaves page requests and the dev
1039
- panel's live channel waiting behind it.
1040
- 4. **A single serial retry round** is performed for the paths that hit a
1041
- **transient** failure (`concurrency: 1`). Permanent answers like `400`, `403`
1042
- or `404` are not retried: a deterministic error does not get better on the
1043
- second try and those calls spend quota for nothing. The summary shows them as
1044
- `N not retried (permanent)`.
1045
- 5. The wait before the retry is `retryDelayMs`, but when the rate limit is on and
1046
- something is holding it back, that wins: retrying 2 seconds into a 10 second
1047
- circuit breaker would just earn the same 429 up front.
1048
- 6. A summary is logged:
1049
- `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
1050
-
1051
- Then comes how much the pass actually touched the upstream:
1052
-
1053
- ```text
1054
- [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
1055
- ```
1056
-
1057
- This is the one line that tells you which way to turn the knob. If the ratio is
1058
- low the fix is not the rate limit but a longer `withDataCache` TTL — the brake
1059
- slows calls down, it does not reduce their number. The same counters are
1060
- available through `getDataCacheStats()` and on the dev report's **Data cache**
1061
- card.
1062
-
1063
- Request errors and the per-page render warnings (`was produced with missing
1064
- data`, `returned notFound() while upstream is failing`, `could not be
1065
- produced`) raised during the pass are not logged one by one. They are counted
1066
- while the pass runs and printed after the summary, most frequent kinds first:
1067
-
1068
- ```text
1069
- [prewarm] 137 problems were not logged individually:
1070
- 94× missing data, upstream is failing permanently (403 /api/v1/polls)
1071
- 37× missing data, upstream is failing permanently (400 /api/v1/posts)
1072
- 6× 500 Cannot read properties of undefined (reading 'title')
1073
- ```
1074
-
1075
- This way a momentary upstream failure cannot bury the "warmed …" line under
1076
- hundreds of stack traces. Errors from real traffic are logged immediately as
1077
- before; for the detail of a single path, look at the **Prewarming** tab in the
1078
- dev panel.
1079
-
1080
- ### Warm-up order: `priority`
1081
-
1082
- ```js
1083
- // jskelet.config.mjs
1084
- cache: () => ({
1085
- prewarm: {
1086
- priority: [
1087
- "/",
1088
- "/markets/:path*",
1089
- /-comments$/,
1090
- ],
1091
- },
1092
- }),
1093
- ```
1094
-
1095
- The pattern syntax (`/news/:slug`) and a plain `RegExp` can be used together;
1096
- the latter is for rules the pattern syntax does not cover, such as "everything
1097
- ending in `-comments`". Whatever is written first is warmed first; paths that
1098
- match nothing go to the queue and keep their relative order.
1099
-
1100
- ### Drip warm-up: `rotate` + `rps` + `intervalSeconds`
1101
-
1102
- On a site with 10,000 paths, warming everything in a single round is neither
1103
- possible (the HTML cache holds 500 entries) nor right (the API quota runs out).
1104
- The correct behaviour is to spread the list over time:
1105
-
1106
- ```js
1107
- prewarm: {
1108
- max: 300, // 300 pages per round
1109
- rps: 4, // at most 4 requests per second
1110
- intervalSeconds: 300, // a round every 5 minutes
1111
- rotate: true, // the queue continues where it left off
1112
- priority: ["/", "/markets/:path*"],
1113
- }
1114
- ```
1115
-
1116
- In this setup the priority pages are refreshed on every round, the rest of the
1117
- queue is walked end to end across rounds, and upstream never sees more than four
1118
- requests per second. Used together with the data cache, the warm-up barely
1119
- reaches the API after the second round: it reads from the data layer.
1120
-
1121
- With rotation on, the paths left outside the limit are not lost, they are left
1122
- for the next round; the log distinguishes this:
1123
- `… , 700 deferred to the next pass`. With `rotate: false` you get the classic
1124
- behaviour — every round warms the same first slice of the list and the rest is
1125
- never warmed (`… , 700 over the limit`).
1126
-
1127
- If a round takes longer than `intervalSeconds`, a new round is not started;
1128
- overlapping rounds would put twice the load on upstream.
1129
-
1130
- The requests go out with the headers `user-agent: jskelet-prewarm`
1131
- (`brand.prewarmUserAgent`) and `accept-encoding: br, gzip`; the second one so
1132
- that the compressed body enters the cache too.
1133
-
1134
- If `DEV_TOKEN` is set, the warm-up carries the token as a cookie; otherwise the
1135
- dev gate returns 404 for all pages and the cache never fills.
1136
-
1137
- The request list in the dev panel and the terminal filter out requests carrying
1138
- `prewarmUserAgent`: so that hundreds of warm-up requests do not flood the view.
1139
- Progress shows up in the badge next to the bubble.
1140
-
1141
- ### Timing
1142
-
1143
- - The warm-up starts at boot **with a delay**: so it does not compete with the
1144
- first real requests. The default delay is 500 ms in prod and 3000 ms in dev.
1145
- Longer in dev, because a file save restarts the process and the timer dies
1146
- with it; it only warms up once the server stays quiet for a while.
1147
- - If `PREWARM_INTERVAL_SECONDS` / `cache().prewarm.intervalSeconds` > 0 the
1148
- round is repeated periodically. Because entries age with `revalidate` and the
1149
- visitor does not wait thanks to stale-while-revalidate, this is **optional**;
1150
- it is for setups that also want to keep pages that are never visited warm.
1151
- - All timers are `unref()`ed: they do not delay process shutdown.
1152
- - No warm-up failure takes the process down.
1153
-
1154
- ### Settings
1155
-
1156
- Order of precedence: **environment variable → config → code default.** Env
1157
- comes first so that one-off experiments can be done without editing the config.
1158
-
1159
- | Setting | Env | `cache().prewarm` | Default |
1160
- | --- | --- | --- | --- |
1161
- | On/off | `PREWARM=0` disables it, `PREWARM=1` overrides the config and enables it | `enabled` | `true` |
1162
- | Maximum paths per round | `PREWARM_MAX` | `max` | `400` |
1163
- | Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
1164
- | Requests per second | `PREWARM_RPS` | `rps` | prod `0` (unlimited), dev 4 |
1165
- | Startup delay (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
1166
- | Retry round delay (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
1167
- | Period (seconds) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (off) |
1168
- | Queue rotation | — | `rotate` | `true` |
1169
- | Warm-up order | — | `priority` | `[]` |
1170
-
1171
- Numeric settings only accept **positive and finite** values; an invalid value
1172
- silently falls through to the next layer.
1173
-
1174
- ### Triggering by hand
1175
-
1176
- ```js
1177
- import { prewarm, prewarmProgress } from "jskelet";
1178
-
1179
- await prewarm({ origin: "http://127.0.0.1:3000" }); // paths from the hook
1180
- await prewarm({ origin, paths: ["/", "/markets"] }); // only these paths
1181
- await prewarm({ origin, quiet: true }); // without printing a summary
1182
- ```
1183
-
1184
- If `paths` is given the hook is never called. The return value is
1185
- `{ ok, failed, total, elapsed }`.
1186
-
1187
- `prewarmProgress` holds the live state and the dev panel reads it:
1188
-
1189
- ```js
1190
- {
1191
- active, done, total, ok, failed, startedAt, finishedAt,
1192
- entries: [{ path, status, ms, bytes, cache, error }],
1193
- }
1194
- ```
1195
-
1196
- The `cache` field inside `entries` is that path's `X-JSkelet-Cache` response;
1197
- from there you can see whether the warm-up round really returned `MISS` and
1198
- filled the cache.
1199
-
1200
- ## Diagnosis: common situations
1201
-
1202
- - **Every request returns `MISS`.** The route was not given a `revalidate`, or
1203
- the pattern inside `cache().html` gives 0 seconds. Or the page returns a code
1204
- other than `status: 200`.
1205
- - **The page returns `MISS` but upstream is healthy.** A transient upstream
1206
- failure may have been reported; look for the line `was produced with missing
1207
- data, not caching it` in the log.
1208
- - **Stale data all the time.** `revalidate` is too high; remember that the real
1209
- lag is at most `revalidate` + one refresh round.
1210
- - **The cache is bloating.** Because query parameters go into the key, campaign
1211
- parameters may be multiplying entries.
1212
- - **The warm-up never runs.** `hooks.prewarmPaths` is not defined, `PREWARM=0`
1213
- is set, or `cache().prewarm.enabled === false`.
1214
- - **The warm-up round pushes the API into 429.** No `rps` was given. Lowering
1215
- `concurrency` is not enough; the setting that protects the quota is the total
1216
- rate. The lasting fix is the data cache: after the second round the warm-up
1217
- does not reach upstream.
1218
- - **The warm-up list is longer than `max` and its tail never warms.** `rotate`
1219
- may be `false`; the `over the limit` phrase in the log shows this.
1220
- - **A whole section returns 404.** Upstream may be down. The page is now retried
1221
- once and, failing that, a 503 that does not enter the cache is returned
1222
- instead of a 404; look for the `returned notFound() while upstream is failing`
1223
- line in the log. If you still see 404s, the failure may come from a non-`fetch`
1224
- client (which needs `reportUpstreamFailure()`) or `cache().trackUpstream` is
1225
- off.
1226
-
1227
- ## What's next
1228
-
1229
- - The full reference of config fields and the env table:
1230
- [07-configuration.md](./07-configuration.md)
1231
- - Watching the cache from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
1232
- - 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)