jskelet 0.6.3 → 0.6.4

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