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.
Files changed (64) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +39 -7
  9. package/docs/07-yapilandirma.md +51 -1
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +454 -0
  20. package/docs/en/07-configuration.md +736 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +34 -0
  40. package/src/config/index.js +68 -13
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +19 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/dev/devtools.js +6 -2
  54. package/src/server/dev/version-check.mjs +139 -0
  55. package/src/server/head-hints.js +1 -1
  56. package/src/server/html-cache.js +10 -4
  57. package/src/server/middleware/csrf.js +134 -0
  58. package/src/server/prewarm.js +6 -6
  59. package/src/server/render.js +199 -16
  60. package/src/server/router.js +14 -7
  61. package/src/server/status-page.js +1 -1
  62. package/src/version.mjs +9 -4
  63. package/src/views/components/loader.js +1 -1
  64. 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)