jskelet 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -10,6 +10,54 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - A cache admin panel at `/_jskelet/cache`, turned on with
14
+ `cache().panel: { enabled: true }` or `JSKELET_CACHE_PANEL=1`. It lists what
15
+ the in-process tier holds (key, size, status, remaining TTL, dependency count,
16
+ precompressed bodies for HTML; key and TTL for data), reports whether the
17
+ Redis tier is connected or bypassed, and runs the operations you would
18
+ otherwise hand-write an admin route for: targeted invalidation with an
19
+ optional hard mode, dropping a single entry, clearing either cache, unlinking
20
+ the shared keys and triggering a prewarm pass. Unlike the dev overlay it does
21
+ not look at `NODE_ENV`, because "why is this page stale" is a production
22
+ question — but nothing is mounted until it is explicitly enabled, so the path
23
+ does not exist by default. Access is a 32-character password regenerated on
24
+ every process start and printed once to the server log; there is no persistent
25
+ secret to leak and a deploy revokes old access on its own. The password is
26
+ never accepted in a query string, three failed attempts ban the IP for 24
27
+ hours, and every banned or unauthorised response is a `404` rather than a 401
28
+ that would confirm the panel exists. The panel is excluded from indexing,
29
+ prewarming and navigation speculation.
30
+ - `dropHtmlCacheKey()` and `dropDataCacheKey()` drop one exact cache key.
31
+ `invalidateHtmlCache()` matches a path pattern and takes down every query
32
+ variant of a path, which is the right default for a webhook but wrong when you
33
+ want `/list?page=2` gone and `/list?page=3` left hot.
34
+
35
+ - An adaptive per-host rate limit for upstream calls, `cache().upstream`. It sits
36
+ in the `fetch` wrapper rather than in the prewarm pass, because what spends the
37
+ quota is the API call, not the page: one render may make one call or twenty, so
38
+ `prewarm.rps` could never bound the real thing. A token bucket caps the average
39
+ rate, a concurrency limit caps the calls in flight, and `rate` is treated as a
40
+ ceiling that the limiter pulls down on its own — a 429 or 503 halves the rate,
41
+ `Retry-After` stops the bucket for exactly as long as the upstream asked, and
42
+ clean windows climb back one step at a time. A host that returns
43
+ `breakerFailures` rate limits in a row is bypassed for `breakerCooldownMs`,
44
+ which stops the worst waste: because a 429 counts as transient, the HTML
45
+ produced by a throttled call is never stored, so a pass in that state spends
46
+ quota and keeps nothing. Only 429 and 503 penalise the rate; a 400 or 500 is
47
+ not a quota problem. Off by default — set `rate` to turn it on.
48
+ - `getUpstreamLimiterStatus()` reports the current rate, calls in flight, 429
49
+ count and breaker state per host. The dev panel's Server tab shows the same.
50
+ - `getDataCacheStats()` counts how the data cache was used: fresh hits, stale
51
+ hits, misses, coalesced concurrent reads, values promoted from the shared tier
52
+ and — the only number that reaches the quota — real producer runs. A prewarm
53
+ pass now prints its own share of that (`12 upstream calls for 430 data reads
54
+ (97% from the data cache)`), which is what tells you whether the fix is a
55
+ longer TTL or a rate limit. The dev report has a Data cache card for it.
56
+
57
+ - The dev overlay header now shows the installed JSkelet version next to the
58
+ title, labelled `latest` when it matches npm and `outdated` with the newer
59
+ version when it does not, so you can tell at a glance which version the
60
+ project runs without opening the Server tab.
13
61
  - An optional Redis tier behind both caches, turned on with
14
62
  `cache().redis: { enabled: true, url }` and `npm install ioredis`. The
15
63
  in-process cache stays primary and every request still reads it; Redis only
@@ -87,12 +135,20 @@ one is listed under a **Breaking** heading.
87
135
 
88
136
  ### Changed
89
137
 
90
- - Request errors raised during a prewarm pass are no longer logged one by one.
91
- They are counted while the pass runs and printed as a single summary line
92
- afterwards, grouped by status and message with the most frequent kinds first,
93
- so a flaky upstream can no longer bury the "warmed N/M pages" line under
94
- hundreds of stack traces. Errors from real traffic are logged as before, and
95
- the dev tools panel still shows the per-path detail.
138
+ - The prewarm retry pass no longer retries permanent failures. A `400`, `403` or
139
+ `404` does not get better on the second try, so those paths are dropped from
140
+ the retry round and counted as `N not retried (permanent)` in the summary. The
141
+ wait before the round now also honours the upstream rate limit: if a
142
+ `Retry-After` or an open circuit breaker is holding calls back, the pass waits
143
+ that out instead of retrying into the same 429.
144
+ - Errors and warnings raised during a prewarm pass are no longer logged one per
145
+ page. Request errors and the per-page render warnings (`was produced with
146
+ missing data`, `returned notFound() while upstream is failing`, `could not be
147
+ produced`) are counted while the pass runs and printed as a single summary
148
+ block afterwards, grouped by message with the most frequent kinds first, so a
149
+ failing upstream can no longer bury the "warmed N/M pages" line under hundreds
150
+ of near-identical lines. Real traffic logs as before, and the dev tools panel
151
+ still shows the per-path detail.
96
152
  - The marketing example's changelog page is now a timeline: releases are laid out
97
153
  along a rail with a sticky version column, each change group gets its own card
98
154
  with a coloured rule and item count, and a row of version chips at the top
package/docs/06-cache.md CHANGED
@@ -431,6 +431,97 @@ cache: {
431
431
  `transientRetry: false` (ya da `attempts: 0`) tekrarı kapatır ve doğrudan 503'e
432
432
  düşer.
433
433
 
434
+ ## Upstream hız freni: `cache().upstream`
435
+
436
+ Buraya kadarki her şey 429 **geldikten sonra** ne olacağını anlatıyor. Bu bölüm
437
+ 429'u en baştan almamakla ilgili.
438
+
439
+ Fren `trackUpstreamFetch()` sarmalayıcısının içinde, yani gerçek `fetch`
440
+ çağrısının geçtiği yerde duruyor. Isıtma turundaki `prewarm.rps` bu işi
441
+ yapamaz: o, kendi sunucumuza atılan **sayfa** isteklerini sayıyor, ama bir
442
+ sayfa render'ı bir API çağrısı da yapabilir yirmi tane de. Kotayı bağlayan şey
443
+ sayfa sayısı değil, çağrı sayısı — ve fren buraya konduğunda ısıtma da gerçek
444
+ trafik de aynı bütçeden harcar.
445
+
446
+ Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez ve maliyet bir
447
+ daldan ibarettir.
448
+
449
+ ```js
450
+ // jskelet.config.mjs
451
+ cache: () => ({
452
+ upstream: {
453
+ rate: 10, // saniyedeki tavan (host başına)
454
+ burst: 20, // kısa patlama toleransı
455
+ concurrency: 8, // aynı anda uçan çağrı
456
+ hosts: {
457
+ // Kotası farklı olan uçlar ayrı ayarlanır.
458
+ "api.example.com": { rate: 3, concurrency: 2 },
459
+ },
460
+ },
461
+ }),
462
+ ```
463
+
464
+ ### Üç mekanizma, üç farklı sınır
465
+
466
+ | Mekanizma | Neyi sınırlar | Ayar |
467
+ | --- | --- | --- |
468
+ | Token bucket | Ortalama hız (çağrı/saniye) | `rate`, `burst` |
469
+ | Eşzamanlılık | Anlık baskı (aynı anda uçan çağrı) | `concurrency` |
470
+ | AIMD | Doğru hızın ne olduğu | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
471
+
472
+ Üçüncüsü asıl fikir. Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır:
473
+ kotanın gerçek sınırını kimse config'e doğru yazamaz, üstelik gün içinde
474
+ değişir. Bu yüzden `rate` bir **tavan** olarak alınır ve gerçek hız upstream'in
475
+ cevabına göre oynar:
476
+
477
+ - **429 ya da 503** → hız yarıya iner (çarpımsal azalma). Yanıt `Retry-After`
478
+ taşıyorsa kova o süre boyunca tamamen durur — upstream sana ne kadar
479
+ bekleyeceğini zaten söylüyor.
480
+ - **Temiz geçen her pencere** → hız `increaseStep` kadar yukarı çıkar
481
+ (toplamsal artış), `rate` tavanına kadar.
482
+
483
+ Azalmanın çarpımsal, artışın toplamsal olması bilinçli. Tersi olsaydı her
484
+ pencerede yeniden 429 yenirdi.
485
+
486
+ ### Devre kesici
487
+
488
+ Art arda `breakerFailures` (varsayılan 5) tane 429 alan bir host
489
+ `breakerCooldownMs` süresince tamamen baypas edilir: çağrı hiç yapılmaz,
490
+ doğrudan geçici hata olarak bildirilir.
491
+
492
+ Sert görünüyor ama asimetri bunu gerektiriyor: 429 geçici sayıldığı için o
493
+ çağrıyla üretilen HTML **önbelleğe yazılmaz**. Yani rate limit'e girmiş bir
494
+ turda kota harcanır ve karşılığında hiçbir şey saklanmaz. Üstüne bir sonraki
495
+ tur aynı sayfayı yine soğuk bulup yine dener. Kesici bu boşa yanmayı kesiyor.
496
+
497
+ ```
498
+ [upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
499
+ ```
500
+
501
+ Yalnızca 429 ve 503 sayılır. `400`/`404` bir kota sorunu değil, `500` de öyle:
502
+ onlar için yavaşlamak arızayı düzeltmez, sadece siteyi yavaşlatır.
503
+
504
+ ### Durumu görmek
505
+
506
+ `getUpstreamLimiterStatus()` host başına o anki hızı, uçuştaki çağrıyı ve
507
+ sayaçları döner; dev panelinin **Server** sekmesi de aynı bilgiyi basıyor.
508
+ 429 fırtınasında "şu an saniyede kaça indi" bilgisi olmadan ayar yapmak
509
+ körlemesine olur.
510
+
511
+ ```js
512
+ import { getUpstreamLimiterStatus } from "jskelet";
513
+
514
+ // [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
515
+ // active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
516
+ ```
517
+
518
+ ### Freni açmadan önce
519
+
520
+ Hız freni son çare. Aynı upstream yanıtını yüzlerce sayfa çekiyorsa asıl çözüm
521
+ [`withDataCache`](#istekler-arası-veri-önbelleği-withdatacache) TTL'ini tur
522
+ aralığından uzun tutmak: 400 sayfalık bir tur, ortak bir uç için tek çağrı
523
+ yapar. Fren o çağrıları yavaşlatır, sayısını azaltmaz.
524
+
434
525
  ## Önbelleği yönetmek
435
526
 
436
527
  `jskelet` şu fonksiyonları dışa açar:
@@ -649,6 +740,90 @@ panelinin raporunda da var ([09-dev-araclari.md](./09-dev-araclari.md)).
649
740
 
650
741
  Ayarların tam listesi: [07-yapilandirma.md](./07-yapilandirma.md).
651
742
 
743
+ ## Yönetim paneli
744
+
745
+ Yukarıdaki `getHtmlCacheEntries()` / `getRedisStatus()` uçlarını elle yazmak
746
+ yerine framework hazır bir panel taşıyor. Dev overlay'den ayrı bir şey: overlay
747
+ yalnızca `NODE_ENV=development` iken var, panel ise ortama bakmaz — "bu sayfa
748
+ neden bayat", "webhook purge'ü geçti mi", "Redis gerçekten bağlı mı" soruları
749
+ üretimde soruluyor.
750
+
751
+ ```js
752
+ // jskelet.config.mjs
753
+ export default {
754
+ cache() {
755
+ return {
756
+ html: { "/haber/:slug": 300 },
757
+ panel: { enabled: process.env.CACHE_PANEL === "1" },
758
+ };
759
+ },
760
+ };
761
+ ```
762
+
763
+ `enabled` verilmedikçe **hiçbir şey mount edilmez**: yol yoktur, modül
764
+ yüklenmez, üretim sürecinde hiçbir maliyeti olmaz. Ortam değişkeni
765
+ (`JSKELET_CACHE_PANEL=1`) config'i ezer; panel genelde bir arıza sırasında tek
766
+ seferlik açılıyor ve o an config dosyasını değiştirip yeniden dağıtmak
767
+ istenmiyor.
768
+
769
+ Panel açıldığında sunucu logu şifreyi basar:
770
+
771
+ ```
772
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
773
+ ```
774
+
775
+ ### Erişim ve güvenlik
776
+
777
+ - **Şifre her süreç başlangıcında yeniden üretilir** (32 haneli, onaltılık) ve
778
+ yalnızca logda görünür. Kalıcı bir sır tutulmuyor: sızması önbelleği boşaltma
779
+ yetkisi demek ve bir deploy eski erişimi kendiliğinden iptal etmeli.
780
+ - **Şifre query string ile kabul edilmez.** Erişim logları, tarayıcı geçmişi ve
781
+ `Referer` başlığı sırrı taşımasın; giriş yalnızca form üzerinden.
782
+ - **Üç başarısız denemede IP 24 saat yasaklanır** (`banAttempts`, `banHours`).
783
+ Yanlış şifre kadar oturumsuz istek de sayılır; başarılı giriş sayacı sıfırlar.
784
+ - **Yasaklı ve yetkisiz her cevap `404`.** 401/403 panelin var olduğunu
785
+ doğrular, 404 hiç yokmuş gibi davranır. Panelin dışındaki site etkilenmez.
786
+ - **Hiçbir yeri indekslenmez:** her cevapta `X-Robots-Tag: noindex, nofollow,
787
+ noarchive, nosnippet`, `Cache-Control: no-store` ve `Referrer-Policy:
788
+ no-referrer`. Yol ayrıca ısıtma listesinden ve gezinme ipuçlarından muaftır.
789
+ - Aksiyonlar `X-JSkelet-Cache-Panel` başlığı ister — çapraz siteden
790
+ gönderilemeyen bir başlık, yani panelin kendi CSRF freni.
791
+ - Oturumlar ve yasak sayaçları süreç belleğinde durur; şifresi zaten her
792
+ restart'ta değişen bir panel için diske yazmak yanlış takas olurdu.
793
+
794
+ ### Panelde ne var
795
+
796
+ | Bölüm | Gösterdiği |
797
+ | --- | --- |
798
+ | Üst satır | Ortam, pid, uptime, RSS |
799
+ | Kartlar | HTML girdi sayısı ve sınırı, bellekteki HTML boyutu, bayat girdi sayısı, veri girdisi sayısı, Redis durumu (`connected` / `bypassed` / `off`), ısıtma turunun ilerlemesi |
800
+ | Girdi listesi | HTML: anahtar, taze/bayat, boyut, durum kodu, kalan TTL, bağımlılık sayısı, hazır sıkıştırılmış gövdeler. Veri: anahtar, taze/bayat, kalan TTL |
801
+
802
+ Liste **anahtar bazında filtrelenir** ve filtre sunucuda uygulanır: veri
803
+ önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır
804
+ döner; başlıktaki sayaç kaç eşleşmenin kesildiğini söyler. HTML gövdesi ve veri
805
+ değerleri **hiç dönmez** — panelin işi durumu göstermek, içeriği dışa vermek
806
+ değil.
807
+
808
+ ### Panelden yapılabilenler
809
+
810
+ | İşlem | Karşılığı |
811
+ | --- | --- |
812
+ | Invalidate (hedef + `hard`) | `invalidateHtmlCache(target, { hard })` |
813
+ | Tek satırı `drop` | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
814
+ | Clear HTML cache | `clearHtmlCache()` |
815
+ | Clear data cache (önek opsiyonel) | `clearDataCache(prefix)` |
816
+ | Drop shared keys | Redis'teki `html` ya da `data` isim alanını tarar ve düşürür |
817
+ | Prewarm | `prewarm()` — tur arkada koşar, ilerleme kartta görünür |
818
+
819
+ Hepsi paylaşımlı kademeye de yayılır: tek kopyanın önbelleğini boşaltmak,
820
+ kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
821
+
822
+ Listedeki tek satırı silmek `invalidateHtmlCache()`ten farklıdır: o yol
823
+ desenine bakar ve bir yolun **bütün** query varyantlarını düşürür,
824
+ `dropHtmlCacheKey()` ise tam anahtarı alır — `/liste?sayfa=2` düşerken
825
+ `/liste?sayfa=3` sıcak kalır.
826
+
652
827
  ## Prewarm — açılışta ısıtma
653
828
 
654
829
  Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek
@@ -700,19 +875,37 @@ Kurallar:
700
875
  Dev'de varsayılan olarak saniyede 4 istek uygulanır: render tek bir olay
701
876
  döngüsünde çalıştığı için aralıksız bir tur, sayfa isteklerini ve dev
702
877
  panelinin canlı kanalını arkasında bekletiyor.
703
- 4. Başarısız yollar için, `retryDelayMs` bekledikten sonra **tek seri tekrar
704
- turu** yapılır (`concurrency: 1`). Bekleme bilinçli: rate limit pencereleri
705
- saniye mertebesinde, hemen tekrar denemek aynı 429'u almak demek.
706
- 5. Özet loglanır:
878
+ 4. **Geçici** hata alan yollar için **tek seri tekrar turu** yapılır
879
+ (`concurrency: 1`). `400`/`403`/`404` gibi kalıcı cevaplar tekrar turuna
880
+ girmez: deterministik bir hata tekrar denemekle düzelmez ve o çağrılar
881
+ kotadan karşılıksız yer. Özette `N not retried (permanent)` olarak görünür.
882
+ 5. Bekleme süresi `retryDelayMs`, ama hız freni açıkken **freni bekleten süre**
883
+ varsa o kazanır: 10 saniye kapalı kalacak bir devre kesiciden 2 saniye sonra
884
+ tekrar denemek, aynı 429'u peşin peşin almak olurdu.
885
+ 6. Özet loglanır:
707
886
  `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
708
887
 
709
- Tur sırasında oluşan istek hataları tek tek loglanmaz; sayılır ve tur bitince
710
- özetin ardından tek bir satırda, en sık görülen türler başta olacak şekilde
711
- basılır:
888
+ Ardından turun upstream'e ne kadar dokunduğu basılır:
889
+
890
+ ```text
891
+ [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
892
+ ```
893
+
894
+ Bu satır ayarın hangi yönde değişmesi gerektiğini söyleyen tek satır. Oran
895
+ düşükse çözüm hız freni değil, `withDataCache` TTL'ini tur aralığından uzun
896
+ tutmak: fren çağrıları yavaşlatır, sayısını azaltmaz. Aynı sayaçlara
897
+ `getDataCacheStats()` ile ya da dev raporundaki **Data cache** kartından da
898
+ bakılabilir.
899
+
900
+ Tur sırasında oluşan istek hataları ve sayfa başına basılan render uyarıları
901
+ (`was produced with missing data`, `returned notFound() while upstream is
902
+ failing`, `could not be produced`) tek tek loglanmaz; sayılır ve tur bitince
903
+ özetin ardından, en sık görülen türler başta olacak şekilde basılır:
712
904
 
713
905
  ```text
714
- [prewarm] 37 request errors were not logged individually:
715
- 31× 502 upstream fetch failed: /api/quotes
906
+ [prewarm] 137 problems were not logged individually:
907
+ 94× missing data, upstream is failing permanently (403 /api/v1/polls)
908
+ 37× missing data, upstream is failing permanently (400 /api/v1/posts)
716
909
  6× 500 Cannot read properties of undefined (reading 'title')
717
910
  ```
718
911
 
@@ -560,9 +560,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
560
560
  ## `cache()`
561
561
 
562
562
  **Tip:**
563
- `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, redis?: object, prewarm?: object }` —
563
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
564
564
  **Varsayılan:**
565
- `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
565
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
566
566
 
567
567
  ### `cache().html`
568
568
 
@@ -627,6 +627,39 @@ denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi; denemeler tükenirse
627
627
  önbelleğe girmeyen bir 503 olur. `false` ya da `attempts: 0` tekrarı kapatır.
628
628
  Ayrıntı: [06-cache.md](./06-cache.md).
629
629
 
630
+ ### `cache().upstream`
631
+
632
+ Upstream API'ye giden `fetch` çağrılarının host başına hız freni. Varsayılan
633
+ **kapalı**: `rate` verilmedikçe hiçbir istek beklemez. `rate` bir tavandır;
634
+ gerçek hız 429 cevaplarına göre kendini aşağı çeker ve temiz geçen pencerelerde
635
+ kademe kademe geri çıkar.
636
+
637
+ | Alan | Tip | Varsayılan | Anlamı |
638
+ | --- | --- | --- | --- |
639
+ | `rate` | `number` | `0` | Saniyedeki en fazla çağrı. `0` → fren kapalı |
640
+ | `burst` | `number` | `0` | Kova boyu; `0` → bir saniyelik bütçe kadar patlama |
641
+ | `concurrency` | `number` | `8` | Aynı anda uçabilecek çağrı |
642
+ | `minRate` | `number` | `0.5` | Azalmanın dibi; hız buranın altına inmez |
643
+ | `increaseStep` | `number` | `1` | Toplamsal artışın adımı (çağrı/saniye) |
644
+ | `increaseIntervalMs` | `number` | `5000` | Artış periyodu |
645
+ | `decreaseIntervalMs` | `number` | `1000` | İki azalma arasındaki en kısa süre |
646
+ | `breakerFailures` | `number` | `5` | Art arda kaç 429'dan sonra host baypas edilir |
647
+ | `breakerCooldownMs` | `number` | `10000` | Baypasın süresi |
648
+ | `hosts` | `Record<string, object>` | `{}` | Host bazlı override; aynı alanlar geçerli |
649
+
650
+ Yalnızca `429` ve `503` hızı cezalandırır: `400`/`404`/`500` bir kota sorunu
651
+ değil. Durumu `getUpstreamLimiterStatus()` ile ya da dev panelinin **Server**
652
+ sekmesinden okuyabilirsin. Ayrıntı ve freni açmadan önce bakılacak yer:
653
+ [06-cache.md](./06-cache.md).
654
+
655
+ ```js
656
+ upstream: {
657
+ rate: 10,
658
+ concurrency: 4,
659
+ hosts: { "api.example.com": { rate: 3 } },
660
+ }
661
+ ```
662
+
630
663
  ### `cache().redis`
631
664
 
632
665
  Opsiyonel Redis ikinci kademesi (L2). Bellek içi önbellek birincil kalır; Redis
@@ -661,6 +694,40 @@ redis: {
661
694
  }
662
695
  ```
663
696
 
697
+ ### `cache().panel`
698
+
699
+ Önbellek yönetim paneli. Bellek içi kademenin ve Redis kademesinin durumunu
700
+ gösterir; hedefli invalidation, tek girdi silme ve ısıtma tetikler.
701
+
702
+ Ortama bakmaz: `enabled` verilmedikçe **hiç mount edilmez** ve yol da yoktur.
703
+ Açıkken production'da da çalışır — asıl sorular ("bu sayfa neden bayat",
704
+ "webhook purge'ü geçti mi") orada soruluyor.
705
+
706
+ | Alan | Tip | Varsayılan | Anlamı |
707
+ | --- | --- | --- | --- |
708
+ | `enabled` | `boolean` | `false` | Yalnızca açıkça `true` verildiğinde açılır (`JSKELET_CACHE_PANEL` ezer) |
709
+ | `basePath` | `string` | `"/_jskelet/cache"` | Panelin kökü |
710
+ | `banAttempts` | `number` | `3` | Kaç başarısız denemeden sonra IP yasaklanır |
711
+ | `banHours` | `number` | `24` | Yasağın süresi |
712
+ | `sessionHours` | `number` | `12` | Oturum çerezinin ömrü |
713
+
714
+ Şifre **her süreç başlangıcında** üretilir ve yalnızca sunucu logunda görünür:
715
+
716
+ ```
717
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
718
+ ```
719
+
720
+ Kalıcı bir sır (config alanı ya da env) tutulmaz; sızması önbelleği boşaltma
721
+ yetkisi demek ve her deploy eski erişimi kendiliğinden iptal etmeli. Yasaklı ve
722
+ yetkisiz her cevap `404`'tür. Kullanım ve ekran ayrıntıları:
723
+ [06-cache.md](./06-cache.md).
724
+
725
+ ```js
726
+ panel: {
727
+ enabled: process.env.CACHE_PANEL === "1",
728
+ }
729
+ ```
730
+
664
731
  ### `cache().prewarm`
665
732
 
666
733
  | Alan | Tip | Varsayılan | Anlamı |
@@ -784,6 +851,7 @@ basılmaz.
784
851
  | `HOST` | `startServer` | `::` | Bağlanılacak arayüz. Varsayılan çift yığın dinler (IPv6 + IPv4); IPv6 yoksa `0.0.0.0`'a düşer |
785
852
  | `JSKELET_SECRET` | `jskelet/cookies` | — | İmzalı cookie sırrı. `security.cookieSecret` verilmediğinde buradan okunur; ikisi de yoksa imzalı cookie API'si hata verir. [12](./12-panel-ve-oturum.md) |
786
853
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | Ayarlıysa token taşımayan her isteğe 404 döner. Isıtma token'ı çerez olarak taşır. [09](./09-dev-araclari.md) |
854
+ | `JSKELET_CACHE_PANEL` | `createApp` | — | Ayarlıysa önbellek panelini açar; `0` config'te açık olan paneli kapatır. Env config'i ezer, çünkü panel genelde bir arıza sırasında tek seferlik açılır. [06](./06-cache.md) |
787
855
  | `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar |
788
856
  | `PREWARM_MAX` | `prewarm` | `400` | En fazla kaç yol ısıtılır |
789
857
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Paralel işçi sayısı |
@@ -441,6 +441,98 @@ cache: {
441
441
  `transientRetry: false` (or `attempts: 0`) disables the retry and falls straight
442
442
  through to the 503.
443
443
 
444
+ ## Upstream rate limit: `cache().upstream`
445
+
446
+ Everything above describes what happens **after** a 429 arrives. This section is
447
+ about not getting one in the first place.
448
+
449
+ The brake sits inside the `trackUpstreamFetch()` wrapper, that is, where the
450
+ real `fetch` call goes out. The prewarm pass's `prewarm.rps` cannot do this job:
451
+ it counts **page** requests to our own server, but one page render may make one
452
+ API call or twenty. What binds the quota is the number of calls, not the number
453
+ of pages — and with the brake here, prewarming and real traffic spend the same
454
+ budget.
455
+
456
+ Off by default: unless `rate` is given, no request ever waits and the cost is a
457
+ single branch.
458
+
459
+ ```js
460
+ // jskelet.config.mjs
461
+ cache: () => ({
462
+ upstream: {
463
+ rate: 10, // ceiling in calls per second, per host
464
+ burst: 20, // tolerance for short bursts
465
+ concurrency: 8, // calls in flight at once
466
+ hosts: {
467
+ // Endpoints with a different quota get their own settings.
468
+ "api.example.com": { rate: 3, concurrency: 2 },
469
+ },
470
+ },
471
+ }),
472
+ ```
473
+
474
+ ### Three mechanisms, three different limits
475
+
476
+ | Mechanism | What it bounds | Settings |
477
+ | --- | --- | --- |
478
+ | Token bucket | Average rate (calls per second) | `rate`, `burst` |
479
+ | Concurrency | Instantaneous pressure (calls in flight) | `concurrency` |
480
+ | AIMD | What the right rate actually is | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
481
+
482
+ The third one is the real idea. A fixed rate is always either too slow or too
483
+ fast: nobody can write the true quota limit into a config file, and it changes
484
+ during the day anyway. So `rate` is treated as a **ceiling** and the actual rate
485
+ moves with what the upstream says:
486
+
487
+ - **429 or 503** → the rate is halved (multiplicative decrease). If the response
488
+ carries `Retry-After`, the bucket stops entirely for that long — the upstream
489
+ is already telling you how long to wait.
490
+ - **Every clean window** → the rate climbs by `increaseStep` (additive
491
+ increase), up to the `rate` ceiling.
492
+
493
+ Decreasing multiplicatively and increasing additively is deliberate. The other
494
+ way round would earn a fresh 429 every window.
495
+
496
+ ### Circuit breaker
497
+
498
+ A host that returns `breakerFailures` (default 5) rate limits in a row is
499
+ bypassed entirely for `breakerCooldownMs`: the call is not made at all and is
500
+ reported straight away as a transient failure.
501
+
502
+ It looks harsh, but the asymmetry demands it: because a 429 counts as transient,
503
+ the HTML produced by that call is **not stored**. So a pass that hit the rate
504
+ limit spends quota and stores nothing in return — and the next pass finds the
505
+ same page cold and tries again. The breaker stops that burn.
506
+
507
+ ```
508
+ [upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
509
+ ```
510
+
511
+ Only 429 and 503 count. A `400`/`404` is not a quota problem and neither is a
512
+ `500`: slowing down does not fix them, it only makes the site slower.
513
+
514
+ ### Seeing the state
515
+
516
+ `getUpstreamLimiterStatus()` returns the current rate, calls in flight and
517
+ counters per host; the dev panel's **Server** tab prints the same thing. During
518
+ a 429 storm, tuning without knowing "what rate is it down to right now" is
519
+ guesswork.
520
+
521
+ ```js
522
+ import { getUpstreamLimiterStatus } from "jskelet";
523
+
524
+ // [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
525
+ // active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
526
+ ```
527
+
528
+ ### Before turning it on
529
+
530
+ The rate limit is a last resort. If hundreds of pages fetch the same upstream
531
+ response, the real fix is keeping the
532
+ [`withDataCache`](#cross-request-data-cache-withdatacache) TTL longer than the
533
+ pass interval: a 400-page pass then makes one call for a shared endpoint. The
534
+ brake slows those calls down, it does not reduce their number.
535
+
444
536
  ## Managing the cache
445
537
 
446
538
  `jskelet` exports these functions:
@@ -666,6 +758,95 @@ The same summary is in the dev panel report
666
758
 
667
759
  The full list of settings: [07-configuration.md](./07-configuration.md).
668
760
 
761
+ ## The admin panel
762
+
763
+ Instead of hand-writing the `getHtmlCacheEntries()` / `getRedisStatus()`
764
+ endpoints above, the framework ships a panel. It is deliberately separate from
765
+ the dev overlay: the overlay only exists while `NODE_ENV=development`, while the
766
+ panel does not look at the environment — "why is this page stale", "did the
767
+ webhook purge land", "is Redis actually connected" are production questions.
768
+
769
+ ```js
770
+ // jskelet.config.mjs
771
+ export default {
772
+ cache() {
773
+ return {
774
+ html: { "/news/:slug": 300 },
775
+ panel: { enabled: process.env.CACHE_PANEL === "1" },
776
+ };
777
+ },
778
+ };
779
+ ```
780
+
781
+ Without `enabled` **nothing is mounted**: the path does not exist, the module is
782
+ never loaded and it costs the production process nothing. The environment
783
+ variable (`JSKELET_CACHE_PANEL=1`) overrides the config, because the panel is
784
+ usually opened once during an incident and editing the config file and
785
+ redeploying is the last thing you want at that moment.
786
+
787
+ When the panel is on, the server log prints the password:
788
+
789
+ ```
790
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
791
+ ```
792
+
793
+ ### Access and hardening
794
+
795
+ - **The password is regenerated on every process start** (32 hex characters) and
796
+ only ever appears in the log. There is no persistent secret to leak: leaking
797
+ one means handing out the right to flush the cache, and a deploy should revoke
798
+ old access on its own.
799
+ - **The password is not accepted in the query string,** so access logs, browser
800
+ history and the `Referer` header never carry it. Sign-in goes through the form.
801
+ - **Three failed attempts ban the IP for 24 hours** (`banAttempts`, `banHours`).
802
+ Requests without a session count just like a wrong password; a successful
803
+ sign-in resets the counter.
804
+ - **Banned and unauthorised requests get a `404`.** A 401 or 403 confirms the
805
+ panel exists; a 404 behaves as if it never did. The rest of the site is
806
+ untouched.
807
+ - **Nothing is indexable:** every response carries `X-Robots-Tag: noindex,
808
+ nofollow, noarchive, nosnippet`, `Cache-Control: no-store` and
809
+ `Referrer-Policy: no-referrer`. The path is also exempt from prewarming and
810
+ from navigation speculation.
811
+ - Actions require an `X-JSkelet-Cache-Panel` header — a header a cross-site form
812
+ cannot send, which is the panel's own CSRF brake.
813
+ - Sessions and ban counters live in process memory; persisting them to disk
814
+ would be the wrong trade for a panel whose password changes on every restart.
815
+
816
+ ### What the panel shows
817
+
818
+ | Area | Contents |
819
+ | --- | --- |
820
+ | Top bar | Environment, pid, uptime, RSS |
821
+ | Cards | HTML entry count and limit, HTML bytes in memory, stale entry count, data entry count, Redis state (`connected` / `bypassed` / `off`), prewarm progress |
822
+ | Entry list | HTML: key, fresh/stale, size, status code, remaining TTL, dependency count, precompressed bodies. Data: key, fresh/stale, remaining TTL |
823
+
824
+ The list is **filtered by key** and the filter runs on the server: a data cache
825
+ can hold tens of thousands of keys. At most 500 rows come back per request and
826
+ the counter in the heading says how many matches were cut. HTML bodies and
827
+ cached values are **never returned** — the panel's job is to show state, not to
828
+ export content.
829
+
830
+ ### What you can do from it
831
+
832
+ | Action | Equivalent call |
833
+ | --- | --- |
834
+ | Invalidate (target + `hard`) | `invalidateHtmlCache(target, { hard })` |
835
+ | `drop` a single row | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
836
+ | Clear HTML cache | `clearHtmlCache()` |
837
+ | Clear data cache (optional prefix) | `clearDataCache(prefix)` |
838
+ | Drop shared keys | Scans and unlinks the `html` or `data` namespace in Redis |
839
+ | Prewarm | `prewarm()` — the pass runs in the background, progress shows in the card |
840
+
841
+ Each one propagates to the shared tier as well: clearing a single replica's
842
+ cache is what produces the "I cleared it and it is still old" question in a
843
+ clustered setup.
844
+
845
+ Dropping a single row is not the same as `invalidateHtmlCache()`: that one
846
+ matches a path pattern and takes down **every** query variant of a path, while
847
+ `dropHtmlCacheKey()` takes the exact key — `/list?page=2` goes and
848
+ `/list?page=3` stays hot.
849
+
669
850
  ## Prewarm — warming up at startup
670
851
 
671
852
  The equivalent of Next's build-time prerender, except the output is not written
@@ -720,20 +901,38 @@ Rules:
720
901
  parallelism. In dev, 4 requests per second apply by default: rendering runs
721
902
  on a single event loop, so an unpaced round leaves page requests and the dev
722
903
  panel's live channel waiting behind it.
723
- 4. **A single serial retry round** is performed for the failed paths after
724
- waiting `retryDelayMs` (`concurrency: 1`). The wait is deliberate: rate limit
725
- windows are on the order of seconds, so retrying immediately just earns the
726
- same 429.
727
- 5. A summary is logged:
904
+ 4. **A single serial retry round** is performed for the paths that hit a
905
+ **transient** failure (`concurrency: 1`). Permanent answers like `400`, `403`
906
+ or `404` are not retried: a deterministic error does not get better on the
907
+ second try and those calls spend quota for nothing. The summary shows them as
908
+ `N not retried (permanent)`.
909
+ 5. The wait before the retry is `retryDelayMs`, but when the rate limit is on and
910
+ something is holding it back, that wins: retrying 2 seconds into a 10 second
911
+ circuit breaker would just earn the same 429 up front.
912
+ 6. A summary is logged:
728
913
  `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
729
914
 
730
- Request errors raised during the pass are not logged one by one. They are
731
- counted while the pass runs and printed after the summary as a single line,
732
- grouped by status and message with the most frequent kinds first:
915
+ Then comes how much the pass actually touched the upstream:
916
+
917
+ ```text
918
+ [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
919
+ ```
920
+
921
+ This is the one line that tells you which way to turn the knob. If the ratio is
922
+ low the fix is not the rate limit but a longer `withDataCache` TTL — the brake
923
+ slows calls down, it does not reduce their number. The same counters are
924
+ available through `getDataCacheStats()` and on the dev report's **Data cache**
925
+ card.
926
+
927
+ Request errors and the per-page render warnings (`was produced with missing
928
+ data`, `returned notFound() while upstream is failing`, `could not be
929
+ produced`) raised during the pass are not logged one by one. They are counted
930
+ while the pass runs and printed after the summary, most frequent kinds first:
733
931
 
734
932
  ```text
735
- [prewarm] 37 request errors were not logged individually:
736
- 31× 502 upstream fetch failed: /api/quotes
933
+ [prewarm] 137 problems were not logged individually:
934
+ 94× missing data, upstream is failing permanently (403 /api/v1/polls)
935
+ 37× missing data, upstream is failing permanently (400 /api/v1/posts)
737
936
  6× 500 Cannot read properties of undefined (reading 'title')
738
937
  ```
739
938