jskelet 0.1.2 → 0.1.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.
@@ -4,8 +4,9 @@ This document explains JSkelet's ISR substitute in full detail: the HTML TTL
4
4
  cache and its stale-while-revalidate behaviour, where `revalidate` comes from,
5
5
  how the cache key is built, the values of the `X-JSkelet-Cache` header, why the
6
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
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
9
10
  measurement rationale behind the decisions is in
10
11
  [02-architecture.md](./02-architecture.md), and the full reference of config
11
12
  fields is in [07-configuration.md](./07-configuration.md).
@@ -18,6 +19,7 @@ route(controller, { revalidate })
18
19
  └─ withUpstreamTracking(...) ← missing data detection
19
20
  └─ withRequestCache(...) ← per-request memoization
20
21
  └─ produce() → controller + renderPage
22
+ └─ withDataCache(...) ← upstream data cache
21
23
  ```
22
24
 
23
25
  The order matters: the **per-request cache must be innermost** so that two
@@ -25,6 +27,22 @@ calls in the same render collapse into a single upstream request; **upstream
25
27
  tracking must be inside the HTML cache** so that output produced with missing
26
28
  data is not written to the cache.
27
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
+
28
46
  ## Public versus per-visitor
29
47
 
30
48
  Everything in this document applies to HTML that **can go to everyone
@@ -109,8 +127,8 @@ The practical consequence: a page that does not depend on the query string
109
127
  produces a separate entry for every combination when it is called with
110
128
  different campaign parameters (`?utm_source=…`). Stripping such parameters at
111
129
  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.
130
+ `revalidate`), is a reasonable precaution; by default the store holds at most
131
+ 500 entries and evicts the oldest with LRU.
114
132
 
115
133
  ## Stale-while-revalidate
116
134
 
@@ -141,8 +159,8 @@ data in the HTML can be at most `revalidate + one refresh round` behind. That
141
159
  price is acceptable, because live fields such as prices are updated on the
142
160
  client over WebSocket.
143
161
 
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.
162
+ The store is an LRU: an accessed entry is moved to the end, and once the limit
163
+ (`cache().maxEntries`, 500 by default) is exceeded the oldest is evicted.
146
164
 
147
165
  ## What gets written to the cache
148
166
 
@@ -223,15 +241,118 @@ Details:
223
241
  - `withRequestCache(run)` is exported; it can be used to set up the same scope
224
242
  outside `route()` (for example in an Express handler you wrote yourself).
225
243
 
244
+ ## Cross-request data cache: `withDataCache`
245
+
246
+ `cache()` only lives for the duration of **a single request**. What it takes to
247
+ protect the long tail from the API quota is a data layer that lives across
248
+ requests, has a TTL and refreshes itself:
249
+
250
+ ```js
251
+ // lib/api/articles.js
252
+ import { withDataCache, reportUpstreamFailure } from "jskelet";
253
+
254
+ export async function getArticle(slug) {
255
+ return withDataCache(`news:${slug}`, 600, async () => {
256
+ const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
257
+
258
+ if (!response.ok) {
259
+ reportUpstreamFailure({ status: response.status, path: `/articles/${slug}` });
260
+ return null;
261
+ }
262
+
263
+ return response.json();
264
+ });
265
+ }
266
+ ```
267
+
268
+ The wrapper form of the same pattern — the key is derived from the arguments:
269
+
270
+ ```js
271
+ import { dataCache } from "jskelet";
272
+
273
+ export const getArticle = dataCache(
274
+ async (slug) => apiGet(`/articles/${slug}`),
275
+ { key: "news", revalidate: 600 },
276
+ );
277
+ ```
278
+
279
+ Behaviour:
280
+
281
+ | State | Result |
282
+ | --- | --- |
283
+ | Fresh entry | Returns immediately, the `producer` does not run |
284
+ | TTL expired, still inside the stale window | The stale value returns **immediately**, the refresh runs in the background |
285
+ | No entry | The `producer` is awaited |
286
+ | The `producer` failed, a stale entry exists | The stale value returns, warning: `[data-cache] producer failed, serving stale value: …` |
287
+ | The `producer` failed, there is no entry | The error goes to the caller |
288
+
289
+ Details:
290
+
291
+ - **Concurrent calls for the same key collapse into one upstream request.** This
292
+ is the behaviour that saves the most quota during warm-up rounds: if 50 pages
293
+ want the same index data, the API is called once.
294
+ - **`null` and `undefined` are not stored.** An application's HTTP client
295
+ usually returns `null` on failure; storing that would freeze a transient 429
296
+ into "no data" for the whole TTL. Pass `{ storeEmpty: true }` if you want the
297
+ empty answer stored deliberately.
298
+ - **The stale window is longer than the HTML one**: `staleFactor` defaults to 10,
299
+ so an entry stays as an emergency fallback for 11 times its TTL. Stale data is
300
+ better than an incomplete page. It can be turned off per key with
301
+ `{ staleFactor: 0 }`.
302
+ - The key belongs entirely to the application: distinctions such as language,
303
+ version or page number go into the key (`news:en:v2:${slug}`).
304
+ - When the TTL is `0` the cache is disabled and the `producer` runs on every
305
+ call — enough to switch a setting off temporarily.
306
+
307
+ The management surface:
308
+
309
+ | Function | What it does |
310
+ | --- | --- |
311
+ | `withDataCache(key, ttlSeconds, producer, options?)` | The main entry point |
312
+ | `dataCache(fn, { key, revalidate, … })` | The function wrapper |
313
+ | `clearDataCache(prefix?)` | Drops the entries matching the prefix (or all of them), returns how many were removed |
314
+ | `getDataCacheSize()` | The number of entries |
315
+ | `getDataCacheEntries()` | A dump: `{ key, stale, expiresIn }`. The value itself is not returned. |
316
+
317
+ `clearDataCache("news:")` is the counterpart of a "this content was updated"
318
+ webhook: it drops one section's data so the next HTML refresh picks up the new
319
+ content.
320
+
226
321
  ## Degraded render: `reportUpstreamFailure`
227
322
 
228
323
  If upstream went down during the render, the output contains missing data.
229
324
  Rather than serving such HTML for the whole TTL, the right behaviour is to
230
325
  **never write it** to the cache: the next request tries again.
231
326
 
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.
327
+ This information arrives through two paths.
328
+
329
+ ### Automatic tracking (the default)
330
+
331
+ At startup `createApp()` wraps `globalThis.fetch` and reports **transient**
332
+ failures (`429`, `5xx`, network errors) from calls made during a render on its
333
+ own. No application code is needed; if your API client talks over `fetch`, the
334
+ rate limit protection is already in place.
335
+
336
+ The details:
337
+
338
+ - Only calls inside a render scope count. A `fetch` from a script, a cron job or
339
+ anywhere outside a request is left untouched.
340
+ - Requests to our own server (`localhost`, `127.0.0.1`) are skipped: the warm-up
341
+ round and the health check are not upstream.
342
+ - Deterministic answers such as `404`/`403` are **not** reported automatically.
343
+ In most APIs a `404` means "no such record"; treating it as missing data would
344
+ produce a false warning on every not-found page.
345
+ - To turn it off: `cache().trackUpstream: false`. An application that wraps
346
+ `fetch` itself (metrics, retries, a circuit breaker) may prefer that.
347
+
348
+ ### Manual reporting
349
+
350
+ For a client that does not use `fetch` (a database driver, gRPC, a vendor SDK),
351
+ or for a layer that wants to flag permanent failures too, the contract is
352
+ unchanged. The dependency direction is deliberately inverted: the framework does
353
+ not know about the data layer, the data layer notifies the framework. If nobody
354
+ ever calls it, the cost is an empty array. If the same failure arrives through
355
+ both paths it is de-duplicated.
235
356
 
236
357
  ```js
237
358
  // lib/api/client.js
@@ -267,6 +388,58 @@ do not get better by retrying. Turning the cache off because of them would mean
267
388
  rendering the page from scratch on every visit — the content comes back just as
268
389
  incomplete, and the visitor only pays the render time.
269
390
 
391
+ Output produced with missing data is **not offered to shared caches** either: a
392
+ `degraded` response gets `private, no-store` instead of `public, s-maxage=…`.
393
+ Taking back the "do not store" decision at the CDN would repeat the same mistake
394
+ one layer up. The diagnostic header (`X-JSkelet-Cache: MISS`) is still written.
395
+
396
+ ### When `notFound()` coincides with a transient failure
397
+
398
+ A controller that calls `notFound()` because no data arrived can turn the whole
399
+ site into 404s when upstream is rate limited — and because those 404s enter the
400
+ cache, a temporary quota problem becomes a "this page does not exist" answer for
401
+ the whole TTL. For a search engine that is a permanent loss.
402
+
403
+ The framework separates the two cases: if a **transient** upstream failure
404
+ happened during the render, `notFound()` is not served as a 404. In order:
405
+
406
+ 1. The page is **retried** after a short delay (once by default, after 300 ms).
407
+ The retry runs in its own upstream and per-request cache scope, so neither
408
+ the first round's failure nor its memoized empty answers affect it.
409
+ 2. If the second round can produce the page, the visitor sees the **real
410
+ content** and the output is cached normally. Warm-up logs show this is
411
+ common: the same path returns 200 seconds later.
412
+ 3. If the retries are exhausted the response is a `503` — not cached, carrying
413
+ `Retry-After`, and the next request can still produce the real content.
414
+
415
+ | During the render | Result of `notFound()` |
416
+ | --- | --- |
417
+ | A transient failure exists (`429`, `5xx`, network error) | Retry → the page if it succeeds; otherwise `503`, `Retry-After: 30`, `no-store` |
418
+ | The retry got a clean answer saying "not there" | A normal `404` |
419
+ | A permanent failure (`404`, `403`…) or no failure | A normal `404`, no retry |
420
+
421
+ The log lines:
422
+
423
+ ```
424
+ [render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
425
+ [render] /news/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
426
+ ```
427
+
428
+ So **an existing page never turns into a 404**: either the real content arrives,
429
+ or an uncached 503 does. Nothing is frozen as "missing".
430
+
431
+ The cost of a retry is a second round of requests on upstream, which is why the
432
+ default is a single attempt. The setting is `cache().transientRetry`:
433
+
434
+ ```js
435
+ cache: {
436
+ transientRetry: { attempts: 2, delayMs: 500 },
437
+ }
438
+ ```
439
+
440
+ `transientRetry: false` (or `attempts: 0`) disables the retry and falls straight
441
+ through to the 503.
442
+
270
443
  ## Managing the cache
271
444
 
272
445
  `jskelet` exports these functions:
@@ -345,26 +518,78 @@ Rules:
345
518
  - Ones starting with one of the `prewarmSkip` prefixes are skipped. The default
346
519
  list: `/api/`, `/_fragment/`, `/__jskelet/`. Session-dependent pages should
347
520
  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.
521
+ - Deduplication **preserves order**: when no `priority` is given, the order the
522
+ application provides is meaningful — put the most important pages first.
351
523
  - If this hook is not defined the warm-up is never set up; not even the timer
352
524
  is started.
353
525
 
354
526
  ### Round logic
355
527
 
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:
528
+ 1. The list is collected. If it is longer than `max` (400 by default) a slice is
529
+ selected: the paths matching `priority` are taken first **on every round**,
530
+ and the remaining slots are filled from the queue.
531
+ 2. `concurrency` workers send requests in parallel (4 in prod, 2 in dev). Less
532
+ parallelism in dev: so the scan does not compete for CPU with the render of
533
+ the page you currently have open in the browser.
534
+ 3. If `rps` is given, the round never goes above that rate — no matter the
535
+ parallelism.
536
+ 4. **A single serial retry round** is performed for the failed paths after
537
+ waiting `retryDelayMs` (`concurrency: 1`). The wait is deliberate: rate limit
538
+ windows are on the order of seconds, so retrying immediately just earns the
539
+ same 429.
540
+ 5. A summary is logged:
366
541
  `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
367
542
 
543
+ ### Warm-up order: `priority`
544
+
545
+ ```js
546
+ // jskelet.config.mjs
547
+ cache: () => ({
548
+ prewarm: {
549
+ priority: [
550
+ "/",
551
+ "/markets/:path*",
552
+ /-comments$/,
553
+ ],
554
+ },
555
+ }),
556
+ ```
557
+
558
+ The pattern syntax (`/news/:slug`) and a plain `RegExp` can be used together;
559
+ the latter is for rules the pattern syntax does not cover, such as "everything
560
+ ending in `-comments`". Whatever is written first is warmed first; paths that
561
+ match nothing go to the queue and keep their relative order.
562
+
563
+ ### Drip warm-up: `rotate` + `rps` + `intervalSeconds`
564
+
565
+ On a site with 10,000 paths, warming everything in a single round is neither
566
+ possible (the HTML cache holds 500 entries) nor right (the API quota runs out).
567
+ The correct behaviour is to spread the list over time:
568
+
569
+ ```js
570
+ prewarm: {
571
+ max: 300, // 300 pages per round
572
+ rps: 4, // at most 4 requests per second
573
+ intervalSeconds: 300, // a round every 5 minutes
574
+ rotate: true, // the queue continues where it left off
575
+ priority: ["/", "/markets/:path*"],
576
+ }
577
+ ```
578
+
579
+ In this setup the priority pages are refreshed on every round, the rest of the
580
+ queue is walked end to end across rounds, and upstream never sees more than four
581
+ requests per second. Used together with the data cache, the warm-up barely
582
+ reaches the API after the second round: it reads from the data layer.
583
+
584
+ With rotation on, the paths left outside the limit are not lost, they are left
585
+ for the next round; the log distinguishes this:
586
+ `… , 700 deferred to the next pass`. With `rotate: false` you get the classic
587
+ behaviour — every round warms the same first slice of the list and the rest is
588
+ never warmed (`… , 700 over the limit`).
589
+
590
+ If a round takes longer than `intervalSeconds`, a new round is not started;
591
+ overlapping rounds would put twice the load on upstream.
592
+
368
593
  The requests go out with the headers `user-agent: jskelet-prewarm`
369
594
  (`brand.prewarmUserAgent`) and `accept-encoding: br, gzip`; the second one so
370
595
  that the compressed body enters the cache too.
@@ -397,10 +622,14 @@ comes first so that one-off experiments can be done without editing the config.
397
622
  | Setting | Env | `cache().prewarm` | Default |
398
623
  | --- | --- | --- | --- |
399
624
  | On/off | `PREWARM=0` disables it, `PREWARM=1` overrides the config and enables it | `enabled` | `true` |
400
- | Maximum paths | `PREWARM_MAX` | `max` | `400` |
625
+ | Maximum paths per round | `PREWARM_MAX` | `max` | `400` |
401
626
  | Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 2 |
627
+ | Requests per second | `PREWARM_RPS` | `rps` | `0` (unlimited) |
402
628
  | Startup delay (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
629
+ | Retry round delay (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
403
630
  | Period (seconds) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (off) |
631
+ | Queue rotation | — | `rotate` | `true` |
632
+ | Warm-up order | — | `priority` | `[]` |
404
633
 
405
634
  Numeric settings only accept **positive and finite** values; an invalid value
406
635
  silently falls through to the next layer.
@@ -445,6 +674,18 @@ filled the cache.
445
674
  parameters may be multiplying entries.
446
675
  - **The warm-up never runs.** `hooks.prewarmPaths` is not defined, `PREWARM=0`
447
676
  is set, or `cache().prewarm.enabled === false`.
677
+ - **The warm-up round pushes the API into 429.** No `rps` was given. Lowering
678
+ `concurrency` is not enough; the setting that protects the quota is the total
679
+ rate. The lasting fix is the data cache: after the second round the warm-up
680
+ does not reach upstream.
681
+ - **The warm-up list is longer than `max` and its tail never warms.** `rotate`
682
+ may be `false`; the `over the limit` phrase in the log shows this.
683
+ - **A whole section returns 404.** Upstream may be down. The page is now retried
684
+ once and, failing that, a 503 that does not enter the cache is returned
685
+ instead of a 404; look for the `returned notFound() while upstream is failing`
686
+ line in the log. If you still see 404s, the failure may come from a non-`fetch`
687
+ client (which needs `reportUpstreamFailure()`) or `cache().trackUpstream` is
688
+ off.
448
689
 
449
690
  ## What's next
450
691
 
@@ -119,7 +119,17 @@ export default {
119
119
  async cache() {
120
120
  return {
121
121
  html: { "/": 60, "/news/:slug": 300 },
122
- prewarm: { enabled: true, max: 400, concurrency: 4, intervalSeconds: 0 },
122
+ maxEntries: 500,
123
+ data: { maxEntries: 10000, staleFactor: 10 },
124
+ prewarm: {
125
+ enabled: true,
126
+ max: 400,
127
+ concurrency: 4,
128
+ rps: 0,
129
+ intervalSeconds: 0,
130
+ rotate: true,
131
+ priority: ["/", "/news/:slug"],
132
+ },
123
133
  };
124
134
  },
125
135
 
@@ -561,8 +571,10 @@ Details: [03-routing.md](./03-routing.md).
561
571
 
562
572
  ## `cache()`
563
573
 
564
- **Type:** `() => { html?: Record<string, number>, prewarm?: object }` —
565
- **Default:** `{ html: {}, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
574
+ **Type:**
575
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, transientRetry?: object | false, prewarm?: object }` —
576
+ **Default:**
577
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
566
578
 
567
579
  ### `cache().html`
568
580
 
@@ -582,18 +594,76 @@ html: {
582
594
  }
583
595
  ```
584
596
 
597
+ ### `cache().maxEntries`
598
+
599
+ **Type:** `number` — **Default:** `500`
600
+
601
+ The entry limit of the HTML cache. Because an entry costs a hundred kilobytes,
602
+ raising this number burns through memory quickly; trying to solve a site with
603
+ tens of thousands of paths from here is the wrong layer — the right place is
604
+ `cache().data`.
605
+
606
+ ### `cache().data`
607
+
608
+ The upstream data cache (`withDataCache`). Details:
609
+ [06-caching.md](./06-caching.md).
610
+
611
+ | Field | Type | Default | Meaning |
612
+ | --- | --- | --- | --- |
613
+ | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. |
614
+ | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
615
+
616
+ ### `cache().trackUpstream`
617
+
618
+ **Type:** `boolean` — **Default:** `true`
619
+
620
+ When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
621
+ `5xx`, network) during a render are reported automatically; calling
622
+ `reportUpstreamFailure()` is not required. An application that wraps `fetch`
623
+ itself can turn this off.
624
+
625
+ ### `cache().transientRetry`
626
+
627
+ **Type:** `{ attempts?: number, delayMs?: number } | false` —
628
+ **Default:** `{ attempts: 1, delayMs: 300 }`
629
+
630
+ How many extra times a page is tried when `notFound()` was called because of a
631
+ transient upstream failure. The point is that an existing page never turns into
632
+ a 404; if the retries are exhausted the response is an uncached 503. `false` or
633
+ `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
634
+
585
635
  ### `cache().prewarm`
586
636
 
587
637
  | Field | Type | Default | Meaning |
588
638
  | --- | --- | --- | --- |
589
639
  | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
590
- | `max` | `number` | `400` | At most how many paths are prewarmed |
640
+ | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
591
641
  | `concurrency` | `number` | prod 4, dev 2 | Number of parallel workers |
642
+ | `rps` | `number` | `0` | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. |
592
643
  | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
644
+ | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
593
645
  | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
646
+ | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
647
+ | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
648
+
649
+ `priority` accepts two forms: the pattern syntax used everywhere in the config,
650
+ and a plain `RegExp`. Whatever is written first is warmed first.
651
+
652
+ ```js
653
+ prewarm: {
654
+ max: 500,
655
+ rps: 4,
656
+ intervalSeconds: 300,
657
+ priority: [
658
+ "/", // the home page
659
+ "/markets/:path*", // the whole markets section
660
+ /-comments$/, // a rule the pattern syntax does not cover
661
+ ],
662
+ }
663
+ ```
594
664
 
595
- Each one can be overridden by an environment variable of the same name; env
596
- takes precedence. Details: [06-caching.md](./06-caching.md).
665
+ Each numeric field can be overridden by an environment variable of the same
666
+ name; env takes precedence. Details: [06-caching.md](./06-caching.md).
597
667
 
598
668
  ## `hooks`
599
669
 
@@ -689,7 +759,9 @@ and no warning is printed.
689
759
  | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
690
760
  | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
691
761
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 2 | Number of parallel workers |
762
+ | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
692
763
  | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
764
+ | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
693
765
  | `PREWARM_INTERVAL_SECONDS` | `startPrewarm` | `0` | If greater than 0, a periodic pass |
694
766
  | `JSKELET_VERBOSE` | `jskelet dev` | — | If `1`, all of the changed files are listed on restart |
695
767
  | `JSKELET_COLOR` | `jskelet/log` | — | If `1`, colour is forced. Because child processes write to a pipe, colour detection turns off; `jskelet dev` sets this itself. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,6 +55,69 @@ export const DEFAULT_PREWARM = {
55
55
  concurrency: 4,
56
56
  /** İki tur arasında beklenen süre: upstream'e ani yük binmesin. */
57
57
  delayMs: 0,
58
+ /**
59
+ * Saniyedeki en fazla ısıtma isteği. 0 → sınırsız (yalnızca `concurrency`
60
+ * frenler). Upstream'i kota sınırının altında tutmanın en doğrudan yolu bu:
61
+ * paralellik ne kadar yükselse de tur bu hızın üstüne çıkmaz.
62
+ */
63
+ rps: 0,
64
+ /**
65
+ * Tekrar turundan önce beklenen süre. Rate limit pencereleri saniye
66
+ * mertebesinde; hemen tekrar denemek aynı 429'u almak demek.
67
+ */
68
+ retryDelayMs: 2000,
69
+ /**
70
+ * Liste `max`'tan uzunsa periyodik turlar kaldığı yerden devam eder.
71
+ * Böylece 10.000 yolluk bir site tek turda değil, turlar boyunca ısınır.
72
+ * `priority` eşleşen yollar her turda ısıtıldığı için rotasyon yalnızca
73
+ * kuyruğu dolaşır.
74
+ */
75
+ rotate: true,
76
+ /**
77
+ * Isıtma sırasını belirleyen desenler. String (`/haber/:slug`) ya da
78
+ * `RegExp` kabul eder; önce yazılan önce ısınır.
79
+ * @type {(string | RegExp)[]}
80
+ */
81
+ priority: [],
82
+ };
83
+
84
+ /**
85
+ * HTML önbelleğinin girdi sınırı. 500 girdi ortalama bir sayfa boyutunda
86
+ * yaklaşık 100-200 MB tutar; uzun kuyruklu siteler bunu yükseltmek yerine
87
+ * veri önbelleğine yaslanmalı (bkz. `DEFAULT_DATA_CACHE`).
88
+ */
89
+ export const DEFAULT_HTML_CACHE_MAX_ENTRIES = 500;
90
+
91
+ /**
92
+ * `notFound()` geçici bir upstream hatasına denk geldiğinde sayfanın kaç kez
93
+ * daha denenmesi gerektiği.
94
+ *
95
+ * Varsayılan tek deneme: maliyeti upstream'e binen ikinci bir istek turu, ama
96
+ * alternatifi var olan bir sayfayı 404 olarak servis etmek — arama motoru için
97
+ * geçici bir rate limit'in kalıcı kayba dönüşmesi. `attempts: 0` tekrarı
98
+ * kapatır ve doğrudan önbelleğe girmeyen 503'e düşer.
99
+ */
100
+ export const DEFAULT_TRANSIENT_RETRY = {
101
+ attempts: 1,
102
+ delayMs: 300,
103
+ };
104
+
105
+ /**
106
+ * Upstream veri önbelleği.
107
+ *
108
+ * HTML önbelleğinden bilinçli olarak çok daha büyük: JSON, aynı sayfanın
109
+ * HTML'ine göre onlarca kat küçük. Uzun kuyruğu (on binlerce haber/etiket)
110
+ * HTML olarak tutmak imkânsız, verisini tutmak ise ucuz — ve API kotasını
111
+ * koruyan katman burası.
112
+ */
113
+ export const DEFAULT_DATA_CACHE = {
114
+ maxEntries: 10000,
115
+ /**
116
+ * TTL dolduktan sonra girdinin kaç TTL boyunca daha kullanılabileceği.
117
+ * HTML'deki 1 katsayısından yüksek: bayat veri, eksik sayfadan iyidir ve
118
+ * upstream düştüğünde tek elde kalan şey budur.
119
+ */
120
+ staleFactor: 10,
58
121
  };
59
122
 
60
123
  /** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */