jskelet 0.1.1 → 0.1.2
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 +5 -0
- package/CHANGELOG.md +63 -0
- package/README.md +21 -7
- package/bin/jskelet.mjs +6 -6
- package/docs/03-routing.md +48 -9
- package/docs/04-render-ve-sablonlar.md +2 -2
- package/docs/05-islands.md +59 -6
- package/docs/06-cache.md +39 -7
- package/docs/07-yapilandirma.md +51 -1
- package/docs/08-build.md +4 -4
- package/docs/09-dev-araclari.md +5 -0
- package/docs/12-panel-ve-oturum.md +384 -0
- package/docs/README.md +25 -2
- package/docs/en/01-getting-started.md +292 -0
- package/docs/en/02-architecture.md +305 -0
- package/docs/en/03-routing.md +493 -0
- package/docs/en/04-rendering.md +504 -0
- package/docs/en/05-islands.md +492 -0
- package/docs/en/06-caching.md +454 -0
- package/docs/en/07-configuration.md +736 -0
- package/docs/en/08-build.md +383 -0
- package/docs/en/09-dev-tools.md +314 -0
- package/docs/en/10-deployment.md +332 -0
- package/docs/en/11-migration.md +360 -0
- package/docs/en/12-dashboards-and-sessions.md +392 -0
- package/docs/en/README.md +112 -0
- package/package.json +4 -2
- package/src/build/build.mjs +1 -1
- package/src/build/tasks/client.mjs +2 -2
- package/src/build/tasks/fonts.mjs +3 -3
- package/src/build/tasks/icons.mjs +1 -1
- package/src/build/tasks/images.mjs +2 -2
- package/src/client/devtools/overlay.js +196 -164
- package/src/client/devtools/report.js +96 -96
- package/src/client/form.js +192 -0
- package/src/client/index.js +10 -1
- package/src/client/registry.js +78 -4
- package/src/client/swap.js +188 -0
- package/src/config/defaults.js +34 -0
- package/src/config/index.js +68 -13
- package/src/config/pattern.js +1 -1
- package/src/dev-server.mjs +1 -1
- package/src/http/control-flow.js +16 -1
- package/src/http/cookies.js +257 -0
- package/src/http/request-context.js +162 -0
- package/src/index.js +19 -2
- package/src/init.mjs +32 -31
- package/src/log.mjs +8 -2
- package/src/logo.png +0 -0
- package/src/runtime/alias-hooks.mjs +1 -1
- package/src/server/assets.js +1 -1
- package/src/server/create-app.js +12 -4
- package/src/server/dev/devtools.js +6 -2
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +10 -4
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +6 -6
- package/src/server/render.js +199 -16
- package/src/server/router.js +14 -7
- package/src/server/status-page.js +1 -1
- package/src/version.mjs +9 -4
- package/src/views/components/loader.js +1 -1
- package/src/views/helpers/tags.js +53 -1
|
@@ -0,0 +1,454 @@
|
|
|
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()`), how upstream failures affect the cache
|
|
8
|
+
(`reportUpstreamFailure`) and the prewarm round at server startup. The
|
|
9
|
+
measurement rationale behind the decisions is in
|
|
10
|
+
[02-architecture.md](./02-architecture.md), and the full reference of config
|
|
11
|
+
fields is in [07-configuration.md](./07-configuration.md).
|
|
12
|
+
|
|
13
|
+
## The big picture
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
route(controller, { revalidate })
|
|
17
|
+
└─ withHtmlCache(key, ttl, producer) ← TTL + stale-while-revalidate
|
|
18
|
+
└─ withUpstreamTracking(...) ← missing data detection
|
|
19
|
+
└─ withRequestCache(...) ← per-request memoization
|
|
20
|
+
└─ produce() → controller + renderPage
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The order matters: the **per-request cache must be innermost** so that two
|
|
24
|
+
calls in the same render collapse into a single upstream request; **upstream
|
|
25
|
+
tracking must be inside the HTML cache** so that output produced with missing
|
|
26
|
+
data is not written to the cache.
|
|
27
|
+
|
|
28
|
+
## Public versus per-visitor
|
|
29
|
+
|
|
30
|
+
Everything in this document applies to HTML that **can go to everyone
|
|
31
|
+
unchanged**. There is no identity in the cache key (only path + query), so a
|
|
32
|
+
page in the cache is the answer for that path, not the answer for whoever asked
|
|
33
|
+
for it first.
|
|
34
|
+
|
|
35
|
+
A page that depends on the user therefore takes a separate path:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
app.get("/dashboard", route(async ({ req }) => { … }, { private: true }));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`private: true` does three things at once: the cache is disabled, a
|
|
42
|
+
`cache.html` pattern **cannot** override that decision, and the response is
|
|
43
|
+
sent with `private, no-store` and `Vary: Cookie`, without an ETag. The details
|
|
44
|
+
and the session/CSRF side are in
|
|
45
|
+
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
46
|
+
|
|
47
|
+
If you forget the flag, the framework does not stay quiet: as soon as the
|
|
48
|
+
controller reads `Cookie`, `Authorization` or `req.session`/`req.user`, the
|
|
49
|
+
render is marked and **not written** to the cache. In development the request
|
|
50
|
+
fails with an explanation, in production it is served with `no-store` and
|
|
51
|
+
logged. The guard is a last line of defence, not an excuse — the right place is
|
|
52
|
+
`private: true`.
|
|
53
|
+
|
|
54
|
+
## `revalidate` — where the TTL comes from
|
|
55
|
+
|
|
56
|
+
A route's TTL can come from two sources, and **the config wins**:
|
|
57
|
+
|
|
58
|
+
1. `route(controller, { revalidate: 60 })` — the route's own duration.
|
|
59
|
+
2. The matching pattern inside `jskelet.config.mjs` → `cache().html`. If it
|
|
60
|
+
exists it overrides the route's value.
|
|
61
|
+
|
|
62
|
+
The one exception is `private: true`: a matching pattern is ignored. The lock is
|
|
63
|
+
one-way, because a mistake in the other direction means a silent data leak.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// jskelet.config.mjs
|
|
67
|
+
export default {
|
|
68
|
+
async cache() {
|
|
69
|
+
return {
|
|
70
|
+
html: {
|
|
71
|
+
"/": 60,
|
|
72
|
+
"/news/:slug": 300,
|
|
73
|
+
"/tag/:slug": 120,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Overriding from the config makes it possible to tune the freshness profile of
|
|
81
|
+
the whole site from a single file; you do not have to walk through the route
|
|
82
|
+
files.
|
|
83
|
+
|
|
84
|
+
The resolution result is **remembered per path**, so a pattern scan is not done
|
|
85
|
+
on every request. If there is no `cache().html` rule at all, the route's own
|
|
86
|
+
value is used directly.
|
|
87
|
+
|
|
88
|
+
If `revalidate` is not given, or is 0, the page is **not cached at all**: every
|
|
89
|
+
request is rendered and the response is sent with
|
|
90
|
+
`Cache-Control: private, no-store` and no ETag. No `X-JSkelet-Cache` header is
|
|
91
|
+
written either — the cache path never ran, so `MISS` would be misleading.
|
|
92
|
+
|
|
93
|
+
Sending `no-store` on a dynamic page is deliberate. HTTP treats a response with
|
|
94
|
+
no directives as "heuristically cacheable", so an intermediate proxy or the
|
|
95
|
+
browser's back button could store HTML produced for a single visitor.
|
|
96
|
+
|
|
97
|
+
The cache also only kicks in for `GET` requests.
|
|
98
|
+
|
|
99
|
+
## The cache key
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
`${req.path}?${new URLSearchParams(query).toString()}`
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
So the path **and all query parameters** are part of the key. `/list?page=2`
|
|
106
|
+
and `/list?page=3` are separate entries.
|
|
107
|
+
|
|
108
|
+
The practical consequence: a page that does not depend on the query string
|
|
109
|
+
produces a separate entry for every combination when it is called with
|
|
110
|
+
different campaign parameters (`?utm_source=…`). Stripping such parameters at
|
|
111
|
+
the reverse proxy layer, or turning off the cache (by not supplying
|
|
112
|
+
`revalidate`), is a reasonable precaution; the store holds at most 500 entries
|
|
113
|
+
and evicts the oldest with LRU.
|
|
114
|
+
|
|
115
|
+
## Stale-while-revalidate
|
|
116
|
+
|
|
117
|
+
The entry structure:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
expiresAt = now + ttl
|
|
121
|
+
staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Read behaviour:
|
|
125
|
+
|
|
126
|
+
| State | Response | Background |
|
|
127
|
+
| --- | --- | --- |
|
|
128
|
+
| `now < expiresAt` | The cached HTML, `HIT` | — |
|
|
129
|
+
| `expiresAt ≤ now < staleUntil` | The cached HTML **immediately**, `STALE` | A refresh is started |
|
|
130
|
+
| `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` | — |
|
|
131
|
+
|
|
132
|
+
A failure of the refresh inside the stale window does not affect the request:
|
|
133
|
+
the old HTML stays valid for the whole window and the error is only logged
|
|
134
|
+
(`[html-cache] background refresh failed: …`).
|
|
135
|
+
|
|
136
|
+
Concurrent refreshes for the same key are collapsed into a single run (the
|
|
137
|
+
`inflight` map): a hundred concurrent requests fall to one render.
|
|
138
|
+
|
|
139
|
+
The gain: after the first warm-up no request waits for a render. The price: the
|
|
140
|
+
data in the HTML can be at most `revalidate + one refresh round` behind. That
|
|
141
|
+
price is acceptable, because live fields such as prices are updated on the
|
|
142
|
+
client over WebSocket.
|
|
143
|
+
|
|
144
|
+
The store is an LRU: an accessed entry is moved to the end, and once
|
|
145
|
+
`MAX_ENTRIES = 500` is exceeded the oldest is evicted.
|
|
146
|
+
|
|
147
|
+
## What gets written to the cache
|
|
148
|
+
|
|
149
|
+
Only output that satisfies **both** of these two conditions is stored:
|
|
150
|
+
|
|
151
|
+
1. `status === 200`
|
|
152
|
+
2. `degraded !== true` — no transient upstream failure was reported during the
|
|
153
|
+
render.
|
|
154
|
+
|
|
155
|
+
So 404 pages, redirects and HTML produced with missing data do not enter the
|
|
156
|
+
cache.
|
|
157
|
+
|
|
158
|
+
## Response headers
|
|
159
|
+
|
|
160
|
+
`route()` writes `X-JSkelet-Cache` on every response (the header name can be
|
|
161
|
+
changed with `brand.cacheHeader`):
|
|
162
|
+
|
|
163
|
+
| Value | Meaning |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `HIT` | From the cache, fresh |
|
|
166
|
+
| `STALE` | From the cache, expired; being refreshed in the background |
|
|
167
|
+
| `MISS` | Rendered on this request (or the cache is off) |
|
|
168
|
+
|
|
169
|
+
On cacheable responses, additionally:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`max-age=0` turns off storage in the browser, `s-maxage` announces the duration
|
|
176
|
+
to intermediate layers (CDN, reverse proxy). This way, when a CDN sits in
|
|
177
|
+
front, the same freshness model works across both layers together.
|
|
178
|
+
|
|
179
|
+
## Storing the compressed body
|
|
180
|
+
|
|
181
|
+
Every cached entry carries an `encoded` map and shares the same lifetime as the
|
|
182
|
+
HTML. The first time a page is requested with brotli or gzip the output is
|
|
183
|
+
computed and put in the map; on subsequent requests the same buffer is sent.
|
|
184
|
+
The same page is not re-brotli'd on every request.
|
|
185
|
+
|
|
186
|
+
On this path `Content-Encoding`, `Vary` and `Content-Length` are written
|
|
187
|
+
directly by `route()`; the compression middleware does not kick in because it
|
|
188
|
+
sees `Content-Encoding`.
|
|
189
|
+
|
|
190
|
+
`HEAD` requests are not compressed (there is no body). If the client accepts
|
|
191
|
+
neither brotli nor gzip, plain HTML is sent.
|
|
192
|
+
|
|
193
|
+
## Per-request memoization: `cache()`
|
|
194
|
+
|
|
195
|
+
The equivalent of React's `cache()` function: calls made with the same
|
|
196
|
+
arguments within the same request run only once.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
// lib/api/articles.js
|
|
200
|
+
import { cache } from "jskelet";
|
|
201
|
+
|
|
202
|
+
export const getArticle = cache(async (slug) => {
|
|
203
|
+
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
|
|
204
|
+
return response.json();
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Now if both the controller and `hooks.layoutContext()` ask for the same article
|
|
209
|
+
in the same render, a single upstream request is made.
|
|
210
|
+
|
|
211
|
+
Details:
|
|
212
|
+
|
|
213
|
+
- The context is carried with `AsyncLocalStorage` and is set up by
|
|
214
|
+
`withRequestCache()` inside `route()`.
|
|
215
|
+
- **Without a context, memoization is disabled** and the function is called
|
|
216
|
+
directly. Calling it from a script or from inside another process is safe.
|
|
217
|
+
- The key is `JSON.stringify(args)`; argument-less calls share the `""` key. Do
|
|
218
|
+
not use it with arguments that cannot be serialised (functions, `Symbol`,
|
|
219
|
+
circular objects).
|
|
220
|
+
- What is stored is the function's **return value**, that is, the Promise
|
|
221
|
+
itself for `async` functions. Because the same Promise is shared, concurrent
|
|
222
|
+
calls collapse too.
|
|
223
|
+
- `withRequestCache(run)` is exported; it can be used to set up the same scope
|
|
224
|
+
outside `route()` (for example in an Express handler you wrote yourself).
|
|
225
|
+
|
|
226
|
+
## Degraded render: `reportUpstreamFailure`
|
|
227
|
+
|
|
228
|
+
If upstream went down during the render, the output contains missing data.
|
|
229
|
+
Rather than serving such HTML for the whole TTL, the right behaviour is to
|
|
230
|
+
**never write it** to the cache: the next request tries again.
|
|
231
|
+
|
|
232
|
+
The dependency direction is deliberately inverted: the framework does not know
|
|
233
|
+
about the data layer, the data layer notifies the framework. If nobody ever
|
|
234
|
+
calls it, the cost is an empty array.
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
// lib/api/client.js
|
|
238
|
+
import { reportUpstreamFailure } from "jskelet";
|
|
239
|
+
|
|
240
|
+
export async function apiGet(path) {
|
|
241
|
+
try {
|
|
242
|
+
const response = await fetch(`${process.env.API_ORIGIN}${path}`);
|
|
243
|
+
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
reportUpstreamFailure({ status: response.status, path });
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return response.json();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// No response at all: status 0 means a network error.
|
|
252
|
+
reportUpstreamFailure({ status: 0, path });
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Distinguishing transient and permanent failures
|
|
259
|
+
|
|
260
|
+
| State | Counts as | Result |
|
|
261
|
+
| --- | --- | --- |
|
|
262
|
+
| `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 (…)` |
|
|
263
|
+
| Others (`400`, `403`, `404`, …) | **Permanent** | Only a warning: `[render] <path> was produced with missing data, upstream is failing permanently (…)`. The cache is not blocked. |
|
|
264
|
+
|
|
265
|
+
Permanent failures not blocking the cache is deliberate: deterministic answers
|
|
266
|
+
do not get better by retrying. Turning the cache off because of them would mean
|
|
267
|
+
rendering the page from scratch on every visit — the content comes back just as
|
|
268
|
+
incomplete, and the visitor only pays the render time.
|
|
269
|
+
|
|
270
|
+
## Managing the cache
|
|
271
|
+
|
|
272
|
+
`jskelet` exports these functions:
|
|
273
|
+
|
|
274
|
+
| Function | What it does |
|
|
275
|
+
| --- | --- |
|
|
276
|
+
| `withHtmlCache(key, ttlSeconds, producer)` | For using the cache directly. If `ttlSeconds` is 0 the producer always runs. |
|
|
277
|
+
| `clearHtmlCache()` | Empties the store completely. |
|
|
278
|
+
| `getHtmlCacheSize()` | The number of entries. |
|
|
279
|
+
| `getHtmlCacheEntries()` | A dump: `{ key, bytes, status, stale, expiresIn, encodings }`. The HTML body is not returned, only its size. |
|
|
280
|
+
|
|
281
|
+
To write an admin endpoint:
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
import { clearHtmlCache, getHtmlCacheEntries } from "jskelet";
|
|
285
|
+
|
|
286
|
+
export default function register(app) {
|
|
287
|
+
app.post("/_admin/cache/clear", (req, res) => {
|
|
288
|
+
if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
|
|
289
|
+
res.status(404).end();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
clearHtmlCache();
|
|
293
|
+
res.json({ ok: true });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
app.get("/_admin/cache", (req, res) => {
|
|
297
|
+
res.json(getHtmlCacheEntries());
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The dev server also clears the cache by itself whenever the manifest changes:
|
|
303
|
+
the stored HTML would be carrying asset URLs with old hashes, and if it were
|
|
304
|
+
not cleared the page would keep requesting a deleted file
|
|
305
|
+
([09-dev-tools.md](./09-dev-tools.md)).
|
|
306
|
+
|
|
307
|
+
Because the cache lives in process memory, if you run more than one
|
|
308
|
+
process/replica each one has its own cache; `clearHtmlCache()` only affects the
|
|
309
|
+
process it is called in.
|
|
310
|
+
|
|
311
|
+
## Prewarm — warming up at startup
|
|
312
|
+
|
|
313
|
+
The equivalent of Next's build-time prerender, except the output is not written
|
|
314
|
+
to disk: since the cache lives in process memory, the warm-up also happens when
|
|
315
|
+
the process comes up. The gain is the same — the first visitor does not wait
|
|
316
|
+
for a cold render — but the data is not frozen; every entry ages with the
|
|
317
|
+
route's `revalidate` and is refreshed in the background with
|
|
318
|
+
stale-while-revalidate.
|
|
319
|
+
|
|
320
|
+
The warm-up is done with **real HTTP requests**
|
|
321
|
+
(`http://127.0.0.1:<port>`), so that the cache key, the compression and the
|
|
322
|
+
middleware chain are exactly the same as with normal traffic.
|
|
323
|
+
|
|
324
|
+
### `hooks.prewarmPaths()`
|
|
325
|
+
|
|
326
|
+
The application declares which paths get warmed; usually it is the very same
|
|
327
|
+
function that produces the sitemap.
|
|
328
|
+
|
|
329
|
+
```js
|
|
330
|
+
// jskelet.config.mjs
|
|
331
|
+
export default {
|
|
332
|
+
hooks: {
|
|
333
|
+
async prewarmPaths() {
|
|
334
|
+
const slugs = await getAllArticleSlugs();
|
|
335
|
+
return ["/", "/markets", ...slugs.map((slug) => `/news/${slug}`)];
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Rules:
|
|
342
|
+
|
|
343
|
+
- If it does not return an array a warning is printed and no warm-up happens.
|
|
344
|
+
- Only strings starting with `/` are taken.
|
|
345
|
+
- Ones starting with one of the `prewarmSkip` prefixes are skipped. The default
|
|
346
|
+
list: `/api/`, `/_fragment/`, `/__jskelet/`. Session-dependent pages should
|
|
347
|
+
not be warmed.
|
|
348
|
+
- Deduplication **preserves order**: since the list is trimmed with
|
|
349
|
+
`PREWARM_MAX`, the priority order the application gives is meaningful — put
|
|
350
|
+
the most important pages first.
|
|
351
|
+
- If this hook is not defined the warm-up is never set up; not even the timer
|
|
352
|
+
is started.
|
|
353
|
+
|
|
354
|
+
### Round logic
|
|
355
|
+
|
|
356
|
+
1. The list is collected and trimmed with `PREWARM_MAX` (400 by default).
|
|
357
|
+
2. `PREWARM_CONCURRENCY` workers send requests in parallel (4 in prod, 2 in
|
|
358
|
+
dev). Less parallelism in dev: so the scan does not compete for CPU with the
|
|
359
|
+
render of the page you currently have open in the browser.
|
|
360
|
+
3. **A single serial retry round** is performed for the failed paths
|
|
361
|
+
(`concurrency: 1`). The errors are mostly upstream rate limiting (429): the
|
|
362
|
+
first round strains the API while fetching hundreds of pages at once. The
|
|
363
|
+
retry round gets those pages into the cache; otherwise the visitor pays for
|
|
364
|
+
the cold render.
|
|
365
|
+
4. A summary is logged:
|
|
366
|
+
`[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
|
|
367
|
+
|
|
368
|
+
The requests go out with the headers `user-agent: jskelet-prewarm`
|
|
369
|
+
(`brand.prewarmUserAgent`) and `accept-encoding: br, gzip`; the second one so
|
|
370
|
+
that the compressed body enters the cache too.
|
|
371
|
+
|
|
372
|
+
If `DEV_TOKEN` is set, the warm-up carries the token as a cookie; otherwise the
|
|
373
|
+
dev gate returns 404 for all pages and the cache never fills.
|
|
374
|
+
|
|
375
|
+
The request list in the dev panel and the terminal filter out requests carrying
|
|
376
|
+
`prewarmUserAgent`: so that hundreds of warm-up requests do not flood the view.
|
|
377
|
+
Progress shows up in the badge next to the bubble.
|
|
378
|
+
|
|
379
|
+
### Timing
|
|
380
|
+
|
|
381
|
+
- The warm-up starts at boot **with a delay**: so it does not compete with the
|
|
382
|
+
first real requests. The default delay is 500 ms in prod and 3000 ms in dev.
|
|
383
|
+
Longer in dev, because a file save restarts the process and the timer dies
|
|
384
|
+
with it; it only warms up once the server stays quiet for a while.
|
|
385
|
+
- If `PREWARM_INTERVAL_SECONDS` / `cache().prewarm.intervalSeconds` > 0 the
|
|
386
|
+
round is repeated periodically. Because entries age with `revalidate` and the
|
|
387
|
+
visitor does not wait thanks to stale-while-revalidate, this is **optional**;
|
|
388
|
+
it is for setups that also want to keep pages that are never visited warm.
|
|
389
|
+
- All timers are `unref()`ed: they do not delay process shutdown.
|
|
390
|
+
- No warm-up failure takes the process down.
|
|
391
|
+
|
|
392
|
+
### Settings
|
|
393
|
+
|
|
394
|
+
Order of precedence: **environment variable → config → code default.** Env
|
|
395
|
+
comes first so that one-off experiments can be done without editing the config.
|
|
396
|
+
|
|
397
|
+
| Setting | Env | `cache().prewarm` | Default |
|
|
398
|
+
| --- | --- | --- | --- |
|
|
399
|
+
| On/off | `PREWARM=0` disables it, `PREWARM=1` overrides the config and enables it | `enabled` | `true` |
|
|
400
|
+
| Maximum paths | `PREWARM_MAX` | `max` | `400` |
|
|
401
|
+
| Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 2 |
|
|
402
|
+
| Startup delay (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
|
|
403
|
+
| Period (seconds) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (off) |
|
|
404
|
+
|
|
405
|
+
Numeric settings only accept **positive and finite** values; an invalid value
|
|
406
|
+
silently falls through to the next layer.
|
|
407
|
+
|
|
408
|
+
### Triggering by hand
|
|
409
|
+
|
|
410
|
+
```js
|
|
411
|
+
import { prewarm, prewarmProgress } from "jskelet";
|
|
412
|
+
|
|
413
|
+
await prewarm({ origin: "http://127.0.0.1:3000" }); // paths from the hook
|
|
414
|
+
await prewarm({ origin, paths: ["/", "/markets"] }); // only these paths
|
|
415
|
+
await prewarm({ origin, quiet: true }); // without printing a summary
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
If `paths` is given the hook is never called. The return value is
|
|
419
|
+
`{ ok, failed, total, elapsed }`.
|
|
420
|
+
|
|
421
|
+
`prewarmProgress` holds the live state and the dev panel reads it:
|
|
422
|
+
|
|
423
|
+
```js
|
|
424
|
+
{
|
|
425
|
+
active, done, total, ok, failed, startedAt, finishedAt,
|
|
426
|
+
entries: [{ path, status, ms, bytes, cache, error }],
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The `cache` field inside `entries` is that path's `X-JSkelet-Cache` response;
|
|
431
|
+
from there you can see whether the warm-up round really returned `MISS` and
|
|
432
|
+
filled the cache.
|
|
433
|
+
|
|
434
|
+
## Diagnosis: common situations
|
|
435
|
+
|
|
436
|
+
- **Every request returns `MISS`.** The route was not given a `revalidate`, or
|
|
437
|
+
the pattern inside `cache().html` gives 0 seconds. Or the page returns a code
|
|
438
|
+
other than `status: 200`.
|
|
439
|
+
- **The page returns `MISS` but upstream is healthy.** A transient upstream
|
|
440
|
+
failure may have been reported; look for the line `was produced with missing
|
|
441
|
+
data, not caching it` in the log.
|
|
442
|
+
- **Stale data all the time.** `revalidate` is too high; remember that the real
|
|
443
|
+
lag is at most `revalidate` + one refresh round.
|
|
444
|
+
- **The cache is bloating.** Because query parameters go into the key, campaign
|
|
445
|
+
parameters may be multiplying entries.
|
|
446
|
+
- **The warm-up never runs.** `hooks.prewarmPaths` is not defined, `PREWARM=0`
|
|
447
|
+
is set, or `cache().prewarm.enabled === false`.
|
|
448
|
+
|
|
449
|
+
## What's next
|
|
450
|
+
|
|
451
|
+
- The full reference of config fields and the env table:
|
|
452
|
+
[07-configuration.md](./07-configuration.md)
|
|
453
|
+
- Watching the cache from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
|
|
454
|
+
- Using it together with a CDN/reverse proxy: [10-deployment.md](./10-deployment.md)
|