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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
package/docs/en/06-caching.md
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
If
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
cache
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
`
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
|
206
|
-
|
|
|
207
|
-
| `now
|
|
208
|
-
|
|
209
|
-
`
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
leadMs
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
`
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
256 MB
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
|
264
|
-
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
HTML
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
The
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
```js
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
{/
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
```
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
|
1008
|
-
|
|
|
1009
|
-
|
|
|
1010
|
-
|
|
|
1011
|
-
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
`
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
```js
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
to
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
is
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
the
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
`
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
The
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
`
|
|
1399
|
-
|
|
1400
|
-
`
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
- **
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
-
|
|
1453
|
-
|
|
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)
|