jskelet 0.2.1 → 0.2.3

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.
@@ -123,6 +123,10 @@ app.get(
123
123
  | `revalidate` | `number` (saniye) | HTML önbellek TTL'i. Verilmezse ya da 0 ise bu route önbelleklenmez. `jskelet.config.mjs` → `cache().html` içindeki eşleşen bir kural bu değeri **ezer**. |
124
124
  | `private` | `boolean` | Sayfa ziyaretçiye bağlı. Önbellek devre dışı kalır, `cache().html` deseni bunu **ezemez**, yanıt `private, no-store` ve `Vary: Cookie` ile ETag'siz gider. |
125
125
 
126
+ `revalidate` verilse bile **query parametresi taşıyan istek varsayılan olarak
127
+ dinamiktir**; o yol için `cache().query` altında bir izin listesi tanımlamak
128
+ gerekir ([06-cache.md](./06-cache.md)).
129
+
126
130
  Oturuma bağlı her sayfa `private: true` almalı; önbellek anahtarında kimlik
127
131
  olmadığı için bayrak olmadan bir kullanıcının HTML'i bir başkasına servis
128
132
  edilir. Framework bu hatayı çalışma zamanında da yakalıyor (controller cookie
package/docs/06-cache.md CHANGED
@@ -110,17 +110,30 @@ saklayabiliyordu.
110
110
  ## Cache anahtarı
111
111
 
112
112
  ```
113
- `${req.path}?${new URLSearchParams(query).toString()}`
113
+ `${yol}?${izin verilen query parametreleri, sıralı}`
114
114
  ```
115
115
 
116
- Yani yol **ve tüm query parametreleri** anahtarın parçasıdır. `/liste?sayfa=2`
117
- ile `/liste?sayfa=3` ayrı girdilerdir.
116
+ Query'siz bir istek için anahtar yalnızca yoldur. **Query parametresi taşıyan
117
+ istek varsayılan olarak dinamiktir**: önbelleğe hiç girmez ve `private,
118
+ no-store` ile gider. Bir yolun bütün varyantlarını cache'lemek `?utm_source=…`
119
+ gibi sonsuz sayıda anahtar üretiyor ve 500 girdilik store'da LRU, gerçek
120
+ sayfaları kampanya varyantları için dışarı atıyor.
118
121
 
119
- Bunun pratik sonucu: query string'e bağlı olmayan bir sayfa, farklı kampanya
120
- parametreleriyle (`?utm_source=…`) çağrıldığında her kombinasyon için ayrı bir
121
- girdi üretir. Bu tür parametreleri ters proxy katmanında temizlemek ya da
122
- önbelleği kapatmak (`revalidate` vermemek) makul bir önlemdir; store varsayılan
123
- olarak en fazla 500 girdi tutar ve LRU ile en eskiyi düşürür.
122
+ Hangi parametrenin çıktıyı gerçekten değiştirdiğini uygulama bildirir —
123
+ `jskelet.config.mjs` → `cache().query`:
124
+
125
+ ```js
126
+ cache: () => ({
127
+ html: { "/liste": 60 },
128
+ query: { "/liste": ["sayfa"] },
129
+ }),
130
+ ```
131
+
132
+ Artık `/liste?sayfa=2` ile `/liste?sayfa=3` ayrı girdiler, `/liste?sayfa=2&utm_source=x`
133
+ ise `?sayfa=2` kopyasını paylaşır: listede olmayan parametre anahtara girmez.
134
+ Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (dikkat: girdi
135
+ sayısını sınırlayan tek şey `maxEntries` olur), `[]` ile eşlenirse query tamamen
136
+ yok sayılır. Ayrıntı: [07-yapilandirma.md](./07-yapilandirma.md).
124
137
 
125
138
  ## Stale-while-revalidate
126
139
 
@@ -431,6 +444,97 @@ cache: {
431
444
  `transientRetry: false` (ya da `attempts: 0`) tekrarı kapatır ve doğrudan 503'e
432
445
  düşer.
433
446
 
447
+ ## Upstream hız freni: `cache().upstream`
448
+
449
+ Buraya kadarki her şey 429 **geldikten sonra** ne olacağını anlatıyor. Bu bölüm
450
+ 429'u en baştan almamakla ilgili.
451
+
452
+ Fren `trackUpstreamFetch()` sarmalayıcısının içinde, yani gerçek `fetch`
453
+ çağrısının geçtiği yerde duruyor. Isıtma turundaki `prewarm.rps` bu işi
454
+ yapamaz: o, kendi sunucumuza atılan **sayfa** isteklerini sayıyor, ama bir
455
+ sayfa render'ı bir API çağrısı da yapabilir yirmi tane de. Kotayı bağlayan şey
456
+ sayfa sayısı değil, çağrı sayısı — ve fren buraya konduğunda ısıtma da gerçek
457
+ trafik de aynı bütçeden harcar.
458
+
459
+ Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez ve maliyet bir
460
+ daldan ibarettir.
461
+
462
+ ```js
463
+ // jskelet.config.mjs
464
+ cache: () => ({
465
+ upstream: {
466
+ rate: 10, // saniyedeki tavan (host başına)
467
+ burst: 20, // kısa patlama toleransı
468
+ concurrency: 8, // aynı anda uçan çağrı
469
+ hosts: {
470
+ // Kotası farklı olan uçlar ayrı ayarlanır.
471
+ "api.example.com": { rate: 3, concurrency: 2 },
472
+ },
473
+ },
474
+ }),
475
+ ```
476
+
477
+ ### Üç mekanizma, üç farklı sınır
478
+
479
+ | Mekanizma | Neyi sınırlar | Ayar |
480
+ | --- | --- | --- |
481
+ | Token bucket | Ortalama hız (çağrı/saniye) | `rate`, `burst` |
482
+ | Eşzamanlılık | Anlık baskı (aynı anda uçan çağrı) | `concurrency` |
483
+ | AIMD | Doğru hızın ne olduğu | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
484
+
485
+ Üçüncüsü asıl fikir. Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır:
486
+ kotanın gerçek sınırını kimse config'e doğru yazamaz, üstelik gün içinde
487
+ değişir. Bu yüzden `rate` bir **tavan** olarak alınır ve gerçek hız upstream'in
488
+ cevabına göre oynar:
489
+
490
+ - **429 ya da 503** → hız yarıya iner (çarpımsal azalma). Yanıt `Retry-After`
491
+ taşıyorsa kova o süre boyunca tamamen durur — upstream sana ne kadar
492
+ bekleyeceğini zaten söylüyor.
493
+ - **Temiz geçen her pencere** → hız `increaseStep` kadar yukarı çıkar
494
+ (toplamsal artış), `rate` tavanına kadar.
495
+
496
+ Azalmanın çarpımsal, artışın toplamsal olması bilinçli. Tersi olsaydı her
497
+ pencerede yeniden 429 yenirdi.
498
+
499
+ ### Devre kesici
500
+
501
+ Art arda `breakerFailures` (varsayılan 5) tane 429 alan bir host
502
+ `breakerCooldownMs` süresince tamamen baypas edilir: çağrı hiç yapılmaz,
503
+ doğrudan geçici hata olarak bildirilir.
504
+
505
+ Sert görünüyor ama asimetri bunu gerektiriyor: 429 geçici sayıldığı için o
506
+ çağrıyla üretilen HTML **önbelleğe yazılmaz**. Yani rate limit'e girmiş bir
507
+ turda kota harcanır ve karşılığında hiçbir şey saklanmaz. Üstüne bir sonraki
508
+ tur aynı sayfayı yine soğuk bulup yine dener. Kesici bu boşa yanmayı kesiyor.
509
+
510
+ ```
511
+ [upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
512
+ ```
513
+
514
+ Yalnızca 429 ve 503 sayılır. `400`/`404` bir kota sorunu değil, `500` de öyle:
515
+ onlar için yavaşlamak arızayı düzeltmez, sadece siteyi yavaşlatır.
516
+
517
+ ### Durumu görmek
518
+
519
+ `getUpstreamLimiterStatus()` host başına o anki hızı, uçuştaki çağrıyı ve
520
+ sayaçları döner; dev panelinin **Server** sekmesi de aynı bilgiyi basıyor.
521
+ 429 fırtınasında "şu an saniyede kaça indi" bilgisi olmadan ayar yapmak
522
+ körlemesine olur.
523
+
524
+ ```js
525
+ import { getUpstreamLimiterStatus } from "jskelet";
526
+
527
+ // [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
528
+ // active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
529
+ ```
530
+
531
+ ### Freni açmadan önce
532
+
533
+ Hız freni son çare. Aynı upstream yanıtını yüzlerce sayfa çekiyorsa asıl çözüm
534
+ [`withDataCache`](#istekler-arası-veri-önbelleği-withdatacache) TTL'ini tur
535
+ aralığından uzun tutmak: 400 sayfalık bir tur, ortak bir uç için tek çağrı
536
+ yapar. Fren o çağrıları yavaşlatır, sayısını azaltmaz.
537
+
434
538
  ## Önbelleği yönetmek
435
539
 
436
540
  `jskelet` şu fonksiyonları dışa açar:
@@ -647,8 +751,216 @@ Bağlantı kurulmamışken de güvenle çağrılır. Dönen nesne
647
751
  kesicinin açık olduğunu, `errors` toplam komut hatasını gösterir. Aynı özet dev
648
752
  panelinin raporunda da var ([09-dev-araclari.md](./09-dev-araclari.md)).
649
753
 
754
+ İki teşhis yüzeyi daha var:
755
+
756
+ | Çağrı | Ne der |
757
+ | --- | --- |
758
+ | `getRedisDetails()` | Bağlantının **nereye** kurulduğu: adres, TLS, veritabanı, `namespace`, hangi türlerin paylaşıldığı, purge yayınına abone olunup olunmadığı. Şifre asla dönmez — bağlantı URL'i sır taşıyor olabilir. |
759
+ | `inspectRedis()` | Paylaşımlı kademede gerçekten ne durduğu: tür başına anahtar sayısı, `DBSIZE` ve `used_memory`. Bir `SCAN` turu olduğu için **istek yolunda çağrılmaz**; yönetim panelinde de ayrı bir düğmeye bağlı. |
760
+
650
761
  Ayarların tam listesi: [07-yapilandirma.md](./07-yapilandirma.md).
651
762
 
763
+ ## Yönetim paneli
764
+
765
+ Yukarıdaki `getHtmlCacheEntries()` / `getRedisStatus()` uçlarını elle yazmak
766
+ yerine framework hazır bir panel taşıyor. Dev overlay'den ayrı bir şey: overlay
767
+ yalnızca `NODE_ENV=development` iken var, panel ise ortama bakmaz — "bu sayfa
768
+ neden bayat", "webhook purge'ü geçti mi", "Redis gerçekten bağlı mı" soruları
769
+ üretimde soruluyor.
770
+
771
+ ```js
772
+ // jskelet.config.mjs
773
+ export default {
774
+ cache() {
775
+ return {
776
+ html: { "/haber/:slug": 300 },
777
+ panel: { enabled: process.env.CACHE_PANEL === "1" },
778
+ };
779
+ },
780
+ };
781
+ ```
782
+
783
+ `enabled` verilmedikçe **hiçbir şey mount edilmez**: yol yoktur, modül
784
+ yüklenmez, üretim sürecinde hiçbir maliyeti olmaz. Ortam değişkeni
785
+ (`JSKELET_CACHE_PANEL=1`) config'i ezer; panel genelde bir arıza sırasında tek
786
+ seferlik açılıyor ve o an config dosyasını değiştirip yeniden dağıtmak
787
+ istenmiyor.
788
+
789
+ Panel açıldığında sunucu logu şifreyi basar:
790
+
791
+ ```
792
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
793
+ ```
794
+
795
+ ### Erişim ve güvenlik
796
+
797
+ - **Şifre her süreç başlangıcında yeniden üretilir** (32 haneli, onaltılık) ve
798
+ yalnızca logda görünür. Kalıcı bir sır tutulmuyor: sızması önbelleği boşaltma
799
+ yetkisi demek ve bir deploy eski erişimi kendiliğinden iptal etmeli.
800
+ - **Şifre query string ile kabul edilmez.** Erişim logları, tarayıcı geçmişi ve
801
+ `Referer` başlığı sırrı taşımasın; giriş yalnızca form üzerinden.
802
+ - **Üç başarısız denemede IP 24 saat yasaklanır** (`banAttempts`, `banHours`).
803
+ Yanlış şifre kadar oturumsuz istek de sayılır; başarılı giriş sayacı sıfırlar.
804
+ - **Yasaklı ve yetkisiz her cevap `404`.** 401/403 panelin var olduğunu
805
+ doğrular, 404 hiç yokmuş gibi davranır. Panelin dışındaki site etkilenmez.
806
+ - **Hiçbir yeri indekslenmez:** her cevapta `X-Robots-Tag: noindex, nofollow,
807
+ noarchive, nosnippet`, `Cache-Control: no-store` ve `Referrer-Policy:
808
+ no-referrer`. Yol ayrıca ısıtma listesinden ve gezinme ipuçlarından muaftır.
809
+ - Aksiyonlar `X-JSkelet-Cache-Panel` başlığı ister — çapraz siteden
810
+ gönderilemeyen bir başlık, yani panelin kendi CSRF freni.
811
+ - Oturumlar ve yasak sayaçları süreç belleğinde durur; şifresi zaten her
812
+ restart'ta değişen bir panel için diske yazmak yanlış takas olurdu.
813
+
814
+ ### Panelde ne var
815
+
816
+ | Bölüm | Gösterdiği |
817
+ | --- | --- |
818
+ | Üst satır | Sürüm, ortam, pid, uptime, RSS |
819
+ | 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 |
820
+ | Paylaşımlı kademe | Bağlantının **nereye** kurulduğu (adres, TLS, veritabanı), anahtar öneki ve `namespace`, `buildId`, hangi türlerin paylaşıldığı, sıkıştırılmış gövde ve purge yayını durumu, komut zaman aşımı ve hata sayısı. Kapalıysa yerine Redis önerisi ve kurulum parçacığı çıkar. |
821
+ | Cloudflare | Zone, plan, cache ile ilgili zone ayarları, development mode'un kalan süresi, Tiered Cache / Cache Reserve durumu ve cache isabet oranı. Bağlı değilse kurulum parçacığı çıkar. |
822
+ | Host | Makinenin RAM kullanımı ve projenin bulunduğu diskin doluluğu |
823
+ | Girdi listesi | HTML: yol (yeni sekmede açılır), taze/bayat, boyut, durum kodu, kalan TTL, bağımlılık sayısı, hazır sıkıştırılmış gövdeler. Veri: anahtar (tıklayınca panoya kopyalanır), taze/bayat, kalan TTL |
824
+
825
+ Liste **anahtar bazında filtrelenir** ve filtre sunucuda uygulanır: veri
826
+ önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır
827
+ döner; başlıktaki sayaç kaç eşleşmenin kesildiğini söyler. HTML gövdesi ve veri
828
+ değerleri **hiç dönmez** — panelin işi durumu göstermek, içeriği dışa vermek
829
+ değil.
830
+
831
+ ### Panelden yapılabilenler
832
+
833
+ | İşlem | Karşılığı |
834
+ | --- | --- |
835
+ | Invalidate (hedef + `hard`) | `invalidateHtmlCache(target, { hard })` |
836
+ | Tek satırı `drop` | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
837
+ | Clear HTML cache | `clearHtmlCache()` |
838
+ | Clear data cache (önek opsiyonel) | `clearDataCache(prefix)` |
839
+ | Drop shared keys | Redis'teki `html` ya da `data` isim alanını tarar ve düşürür |
840
+ | Count keys in Redis | `inspectRedis()` — tür başına anahtar sayısı, `DBSIZE` ve `used_memory` |
841
+ | Prewarm | `prewarm()` — tur arkada koşar, ilerleme kartta görünür |
842
+ | Cloudflare purge (her şey / bellekteki URL'ler / prefix / host / tag) | `purgeCloudflare()` |
843
+ | Cloudflare ayarı ya da özelliği değiştirmek | Zone ayarları ve Tiered Cache / Cache Reserve |
844
+
845
+ Hepsi paylaşımlı kademeye de yayılır: tek kopyanın önbelleğini boşaltmak,
846
+ kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
847
+
848
+ Listedeki tek satırı silmek `invalidateHtmlCache()`ten farklıdır: o yol
849
+ desenine bakar ve bir yolun **bütün** query varyantlarını düşürür,
850
+ `dropHtmlCacheKey()` ise tam anahtarı alır — `/liste?sayfa=2` düşerken
851
+ `/liste?sayfa=3` sıcak kalır.
852
+
853
+ ## CDN kademesi: Cloudflare
854
+
855
+ Buraya kadar anlatılan her şey **origin** önbelleği. Önünde Cloudflare varsa
856
+ ziyaretçinin gördüğü HTML çoğu zaman hiç size ulaşmıyor: edge'deki kopya
857
+ TTL'ini doldurana kadar servis edilir. Bu yüzden `invalidateHtmlCache()`
858
+ tek başına "sayfayı güncelledim ama eski hâli görünüyor" sorununu çözmez —
859
+ origin tazelenir, edge beklemeye devam eder.
860
+
861
+ JSkelet iki kademeyi aynı yerden yönetilebilir kılar.
862
+
863
+ ### Kurulum
864
+
865
+ Token bir sır; config dosyasına değil ortama yazılır:
866
+
867
+ ```bash
868
+ JSKELET_CLOUDFLARE_KEY=... # API token
869
+ JSKELET_CLOUDFLARE_ZONE_ID=... # zone kimliği
870
+ JSKELET_CLOUDFLARE_HOSTNAME=example.com # opsiyonel
871
+ ```
872
+
873
+ Token'a gereken izinler, yapmak istediğinize göre: purge için `Zone.Cache
874
+ Purge`, ayarları değiştirmek için `Zone.Zone Settings`, isabet oranı ve edge
875
+ kırılımı için `Zone.Analytics` (salt okunur). Yalnızca purge izni verilen bir
876
+ token'la panel açılır, ayar bölümleri hata yazar.
877
+
878
+ Zone kimliği ve site adı sır olmadığı için `jskelet.config.mjs` içinden de
879
+ verilebilir; env her zaman önceliklidir:
880
+
881
+ ```js
882
+ cache: {
883
+ cloudflare: {
884
+ zoneId: "…",
885
+ hostname: "example.com", // purge tam URL ister; yol → URL çevrimi için
886
+ analyticsHours: 24,
887
+ },
888
+ }
889
+ ```
890
+
891
+ `hostname` verilmezse purge URL'leri panelin açıldığı origin'den türetilir.
892
+ Paneli iç bir adresten (`http://10.0.0.4:3000`) açıyorsanız bu adresin
893
+ Cloudflare'de karşılığı yok; o kurulumda `hostname` zorunlu.
894
+
895
+ ### Ne yapılabilir
896
+
897
+ Cloudflare'in cache yüzeyinde ne varsa panelde de var:
898
+
899
+ | İşlem | Not |
900
+ | --- | --- |
901
+ | Purge everything | Zone'un tamamı. En kaba araç; ısınma maliyeti yüksek |
902
+ | Purge by URL | Panelin o an bellekte tuttuğu sayfalar tek düğmeyle; ya da satır başına `cf purge` |
903
+ | Purge by prefix / host / tag | Artık her planda çalışıyor; istek başına 100 anahtar |
904
+ | Development mode | Üç saat boyunca edge önbelleğini baypas eder, sonra kendiliğinden kapanır |
905
+ | Cache level, browser cache TTL, query string sıralaması, Always Online | Zone ayarları |
906
+ | Tiered Cache, Regional Tiered Cache, Cache Reserve | Plana bağlı; kapalı planda "unavailable" görünür |
907
+ | Clear Cache Reserve | Purge'den ayrı: `purge_everything` edge'i düşürür, R2'deki kalıcı kopya kalır |
908
+
909
+ Uzun URL listeleri istek başına 100 anahtarlık partilere bölünür ve **sırayla**
910
+ gönderilir. Paralel göndermek Free planda dakikada beş isteklik hız freni
911
+ yüzünden yarısı reddedilen bir tur demek.
912
+
913
+ Kod tarafında aynı yüzey:
914
+
915
+ ```js
916
+ import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
917
+
918
+ export async function onPostPublished(slug) {
919
+ const paths = ["/", `/blog/${slug}`];
920
+
921
+ invalidateHtmlCache(paths); // origin
922
+ await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
923
+ }
924
+ ```
925
+
926
+ Bu modüldeki hiçbir fonksiyon fırlatmaz: token yoksa, Cloudflare 403 dönerse
927
+ ya da ağ düşerse sonuç `{ ok: false, error }` olur. Bir CDN arızası içerik
928
+ yayınlama akışını kesmemeli.
929
+
930
+ ### "Bu sayfa kaç edge'de cache'li?" — sorulabilen ve sorulamayan
931
+
932
+ Cloudflare API'sinde bir objenin **envanterini** veren uç yok. Yüzlerce şehirde
933
+ birbirinden bağımsız önbellekler var ve hiçbiri "şu an bende bu URL'in kopyası
934
+ var mı" sorusuna cevap vermiyor. Panel bu yüzden envanter değil **gözlem**
935
+ gösterir: bir yol girip sorguladığınızda GraphQL analitiğinden son N saatte
936
+ hangi kolonun (IST, FRA, AMS…) o yolu kaç kez cache'ten, kaç kez origin'den
937
+ servis ettiği gelir.
938
+
939
+ ```js
940
+ const report = await fetchPathEdges({ path: "/blog", hours: 24 });
941
+ // → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
942
+ ```
943
+
944
+ Okurken iki sınırı akılda tutun: hiç istek almamış bir edge listede görünmez,
945
+ kopyası olsa da; ve veri kümesi örneklemeli, yani oranlar güvenilir ama mutlak
946
+ sayılar yaklaşıktır.
947
+
948
+ Seçtiğiniz bir edge'i **ısıtmanın** da yolu yok. Bir obje ancak o koloya
949
+ yönlenen gerçek bir istekle o edge'in önbelleğine giriyor; sunucudan
950
+ "Frankfurt'a bunu cache'let" diyemezsiniz. Pratikte yapılabilen üç şey var:
951
+
952
+ - **Origin'i ısıtmak** (`prewarm`): ilk isteği alan edge cevabı hazır bulur,
953
+ o istek yavaşlamaz.
954
+ - **Tiered Cache**: edge'ler origin'e doğrudan gitmez, aradaki üst katmandan
955
+ besleniyor; bir şehirdeki ilk istek diğer şehirler için de ısıtma sayılır.
956
+ - **Cache Reserve**: uzun kuyruklu içerik için R2'de kalıcı kopya; edge
957
+ düşünce istek origin'e kadar inmiyor.
958
+
959
+ `hit` oranınız düşükse önce cevabın cache'lenebilir olup olmadığına bakın:
960
+ `Cache-Control: private`, `Set-Cookie` ve query string ayarları edge'in
961
+ cache'lememe kararının en sık sebepleri, ve bu panelde `dynamic` olarak
962
+ görünür.
963
+
652
964
  ## Prewarm — açılışta ısıtma
653
965
 
654
966
  Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek
@@ -700,12 +1012,28 @@ Kurallar:
700
1012
  Dev'de varsayılan olarak saniyede 4 istek uygulanır: render tek bir olay
701
1013
  döngüsünde çalıştığı için aralıksız bir tur, sayfa isteklerini ve dev
702
1014
  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:
1015
+ 4. **Geçici** hata alan yollar için **tek seri tekrar turu** yapılır
1016
+ (`concurrency: 1`). `400`/`403`/`404` gibi kalıcı cevaplar tekrar turuna
1017
+ girmez: deterministik bir hata tekrar denemekle düzelmez ve o çağrılar
1018
+ kotadan karşılıksız yer. Özette `N not retried (permanent)` olarak görünür.
1019
+ 5. Bekleme süresi `retryDelayMs`, ama hız freni açıkken **freni bekleten süre**
1020
+ varsa o kazanır: 10 saniye kapalı kalacak bir devre kesiciden 2 saniye sonra
1021
+ tekrar denemek, aynı 429'u peşin peşin almak olurdu.
1022
+ 6. Özet loglanır:
707
1023
  `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
708
1024
 
1025
+ Ardından turun upstream'e ne kadar dokunduğu basılır:
1026
+
1027
+ ```text
1028
+ [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
1029
+ ```
1030
+
1031
+ Bu satır ayarın hangi yönde değişmesi gerektiğini söyleyen tek satır. Oran
1032
+ düşükse çözüm hız freni değil, `withDataCache` TTL'ini tur aralığından uzun
1033
+ tutmak: fren çağrıları yavaşlatır, sayısını azaltmaz. Aynı sayaçlara
1034
+ `getDataCacheStats()` ile ya da dev raporundaki **Data cache** kartından da
1035
+ bakılabilir.
1036
+
709
1037
  Tur sırasında oluşan istek hataları ve sayfa başına basılan render uyarıları
710
1038
  (`was produced with missing data`, `returned notFound() while upstream is
711
1039
  failing`, `could not be produced`) tek tek loglanmaz; sayılır ve tur bitince
@@ -115,6 +115,7 @@ export default {
115
115
  async cache() {
116
116
  return {
117
117
  html: { "/": 60, "/haber/:slug": 300 },
118
+ query: { "/arama": ["q", "page"] },
118
119
  maxEntries: 500,
119
120
  data: { maxEntries: 10000, staleFactor: 10 },
120
121
  prewarm: {
@@ -560,9 +561,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
560
561
  ## `cache()`
561
562
 
562
563
  **Tip:**
563
- `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, redis?: object, prewarm?: object }` —
564
+ `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
564
565
  **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 } }`
566
+ `{ html: {}, query: {}, 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
567
 
567
568
  ### `cache().html`
568
569
 
@@ -582,6 +583,39 @@ Tek istisna `route(fn, { private: true })`: bu route'ta desen eşleşse bile yok
582
583
  sayılır. Kilidin tek yönlü olması bilinçli — ters yönde bir hata, bir
583
584
  kullanıcının HTML'inin bir başkasına servis edilmesi anlamına geliyor.
584
585
 
586
+ ### `cache().query`
587
+
588
+ Desen → cache anahtarına girmesine izin verilen query parametreleri.
589
+
590
+ **Varsayılan olarak query parametresi taşıyan istek dinamiktir**: `cache().html`
591
+ o yolu kapsıyor olsa bile HTML önbelleğine hiç girmez, `private, no-store` ile
592
+ gider. Sebebi basit — bir yolun bütün varyantlarını cache'lemek `?utm_source=…`
593
+ gibi sonsuz sayıda anahtar üretiyor ve `maxEntries` sınırına dayandığında
594
+ LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı gerçekten
595
+ değiştirdiğini yalnızca uygulama bilir.
596
+
597
+ ```js
598
+ query: {
599
+ "/arama": ["q", "page"], // yalnızca bu ikisi anahtara girer
600
+ "/urunler": ["kategori"],
601
+ "/rapor/:id": true, // bütün parametreler anahtara girer
602
+ "/kampanya": [], // query tamamen yok sayılır
603
+ }
604
+ ```
605
+
606
+ - **İzin listesi** (`string[]`): listedeki parametreler anahtara girer, her
607
+ farklı değer kendi girdisini alır. Listede olmayan parametreler **yok
608
+ sayılır** — sayfa yine cache'lenir ve bütün kampanya varyantları tek kopyayı
609
+ paylaşır.
610
+ - **`true`**: bütün parametreler anahtara girer. Anahtar sayısını sınırlayan
611
+ tek şey `maxEntries` olur; yalnızca değer kümesi kapalı olan yollarda kullan.
612
+ - **`[]`**: query hiç dikkate alınmaz, bütün varyantlar query'siz sürümün
613
+ HTML'ini alır.
614
+
615
+ Parametreler anahtara **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı girdiyi
616
+ paylaşır. `route(fn, { private: true })` bu bölümden etkilenmez; private route
617
+ hiçbir koşulda cache'lenmez.
618
+
585
619
  ### `cache().maxEntries`
586
620
 
587
621
  **Tip:** `number` — **Varsayılan:** `500`
@@ -627,6 +661,39 @@ denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi; denemeler tükenirse
627
661
  önbelleğe girmeyen bir 503 olur. `false` ya da `attempts: 0` tekrarı kapatır.
628
662
  Ayrıntı: [06-cache.md](./06-cache.md).
629
663
 
664
+ ### `cache().upstream`
665
+
666
+ Upstream API'ye giden `fetch` çağrılarının host başına hız freni. Varsayılan
667
+ **kapalı**: `rate` verilmedikçe hiçbir istek beklemez. `rate` bir tavandır;
668
+ gerçek hız 429 cevaplarına göre kendini aşağı çeker ve temiz geçen pencerelerde
669
+ kademe kademe geri çıkar.
670
+
671
+ | Alan | Tip | Varsayılan | Anlamı |
672
+ | --- | --- | --- | --- |
673
+ | `rate` | `number` | `0` | Saniyedeki en fazla çağrı. `0` → fren kapalı |
674
+ | `burst` | `number` | `0` | Kova boyu; `0` → bir saniyelik bütçe kadar patlama |
675
+ | `concurrency` | `number` | `8` | Aynı anda uçabilecek çağrı |
676
+ | `minRate` | `number` | `0.5` | Azalmanın dibi; hız buranın altına inmez |
677
+ | `increaseStep` | `number` | `1` | Toplamsal artışın adımı (çağrı/saniye) |
678
+ | `increaseIntervalMs` | `number` | `5000` | Artış periyodu |
679
+ | `decreaseIntervalMs` | `number` | `1000` | İki azalma arasındaki en kısa süre |
680
+ | `breakerFailures` | `number` | `5` | Art arda kaç 429'dan sonra host baypas edilir |
681
+ | `breakerCooldownMs` | `number` | `10000` | Baypasın süresi |
682
+ | `hosts` | `Record<string, object>` | `{}` | Host bazlı override; aynı alanlar geçerli |
683
+
684
+ Yalnızca `429` ve `503` hızı cezalandırır: `400`/`404`/`500` bir kota sorunu
685
+ değil. Durumu `getUpstreamLimiterStatus()` ile ya da dev panelinin **Server**
686
+ sekmesinden okuyabilirsin. Ayrıntı ve freni açmadan önce bakılacak yer:
687
+ [06-cache.md](./06-cache.md).
688
+
689
+ ```js
690
+ upstream: {
691
+ rate: 10,
692
+ concurrency: 4,
693
+ hosts: { "api.example.com": { rate: 3 } },
694
+ }
695
+ ```
696
+
630
697
  ### `cache().redis`
631
698
 
632
699
  Opsiyonel Redis ikinci kademesi (L2). Bellek içi önbellek birincil kalır; Redis
@@ -661,6 +728,64 @@ redis: {
661
728
  }
662
729
  ```
663
730
 
731
+ ### `cache().panel`
732
+
733
+ Önbellek yönetim paneli. Bellek içi kademenin ve Redis kademesinin durumunu
734
+ gösterir; hedefli invalidation, tek girdi silme ve ısıtma tetikler.
735
+
736
+ Ortama bakmaz: `enabled` verilmedikçe **hiç mount edilmez** ve yol da yoktur.
737
+ Açıkken production'da da çalışır — asıl sorular ("bu sayfa neden bayat",
738
+ "webhook purge'ü geçti mi") orada soruluyor.
739
+
740
+ | Alan | Tip | Varsayılan | Anlamı |
741
+ | --- | --- | --- | --- |
742
+ | `enabled` | `boolean` | `false` | Yalnızca açıkça `true` verildiğinde açılır (`JSKELET_CACHE_PANEL` ezer) |
743
+ | `basePath` | `string` | `"/_jskelet/cache"` | Panelin kökü |
744
+ | `banAttempts` | `number` | `3` | Kaç başarısız denemeden sonra IP yasaklanır |
745
+ | `banHours` | `number` | `24` | Yasağın süresi |
746
+ | `sessionHours` | `number` | `12` | Oturum çerezinin ömrü |
747
+
748
+ Şifre **her süreç başlangıcında** üretilir ve yalnızca sunucu logunda görünür:
749
+
750
+ ```
751
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
752
+ ```
753
+
754
+ Kalıcı bir sır (config alanı ya da env) tutulmaz; sızması önbelleği boşaltma
755
+ yetkisi demek ve her deploy eski erişimi kendiliğinden iptal etmeli. Yasaklı ve
756
+ yetkisiz her cevap `404`'tür. Kullanım ve ekran ayrıntıları:
757
+ [06-cache.md](./06-cache.md).
758
+
759
+ ### `cache().cloudflare`
760
+
761
+ CDN kademesi. JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü kopya
762
+ edge'de duruyor. Bu bölüm bağlıysa panelden edge purge'ü, cache ile ilgili zone
763
+ ayarları ve cache isabet oranı yönetilebilir.
764
+
765
+ | Alan | Tip | Varsayılan | Anlamı |
766
+ | --- | --- | --- | --- |
767
+ | `enabled` | `boolean` | `true` | `false` verilirse env'de token olsa bile yüzey kapalı kalır |
768
+ | `zoneId` | `string \| null` | `null` | Zone kimliği (`JSKELET_CLOUDFLARE_ZONE_ID` ezer) |
769
+ | `apiToken` | `string \| null` | `null` | Token; **env tercih edilir**, config'e yazmak sırrı repoya sokar |
770
+ | `hostname` | `string \| null` | `null` | Purge tam URL ister; yol → URL çevrimi bu ad üzerinden yapılır. Verilmezse panelin açıldığı origin kullanılır |
771
+ | `analyticsHours` | `number` | `24` | Analitik penceresi, en çok `72` |
772
+
773
+ Token yalnızca `JSKELET_CLOUDFLARE_KEY` ile verildiğinde config dosyası temiz
774
+ kalır; izinler yapılacak işe göre: purge için `Zone.Cache Purge`, ayarlar için
775
+ `Zone.Zone Settings`, isabet oranı için `Zone.Analytics` (salt okunur). Token
776
+ hiçbir panel cevabında dönmez, yalnızca "env'den geldi" bilgisi görünür.
777
+
778
+ Zone bağlı değilse panel bir uyarı değil kurulum önerisi gösterir; Cloudflare
779
+ hata dönerse ilgili bölüm hatayı yazar ve panelin kalanı çalışmaya devam eder.
780
+ Neyin sorulabildiği — özellikle "bu sayfa kaç edge'de cache'li" sorusunun neden
781
+ tam cevabı olmadığı — [06-cache.md](./06-cache.md) içinde.
782
+
783
+ ```js
784
+ panel: {
785
+ enabled: process.env.CACHE_PANEL === "1",
786
+ }
787
+ ```
788
+
664
789
  ### `cache().prewarm`
665
790
 
666
791
  | Alan | Tip | Varsayılan | Anlamı |
@@ -784,6 +909,10 @@ basılmaz.
784
909
  | `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
910
  | `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
911
  | `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) |
912
+ | `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) |
913
+ | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache yüzeyi | — | API token. Verilene kadar CDN purge'ü ve edge analitiği kapalıdır; config'teki `apiToken`'ı ezer. Token hiçbir cevapta dönmez. [06](./06-cache.md) |
914
+ | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache yüzeyi | — | Zone kimliği. Token'la birlikte verilmedikçe hiçbir Cloudflare ucu çağrılmaz |
915
+ | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache yüzeyi | — | Purge URL'lerinin kökü. Panel iç bir adresten açılıyorsa gerekir |
787
916
  | `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar |
788
917
  | `PREWARM_MAX` | `prewarm` | `400` | En fazla kaç yol ısıtılır |
789
918
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Paralel işçi sayısı |
@@ -126,6 +126,10 @@ app.get(
126
126
  | `revalidate` | `number` (seconds) | The HTML cache TTL. If it is not given, or is 0, this route is not cached. A matching rule in `jskelet.config.mjs` → `cache().html` **overrides** this value. |
127
127
  | `private` | `boolean` | The page depends on the visitor. The cache is disabled, a `cache().html` pattern **cannot** override that, and the response is sent with `private, no-store` and `Vary: Cookie`, without an ETag. |
128
128
 
129
+ Even with `revalidate` given, **a request that carries a query parameter is
130
+ dynamic by default**; that path needs an allowlist under `cache().query`
131
+ ([06-caching.md](./06-caching.md)).
132
+
129
133
  Every session-dependent page needs `private: true`; because identity is not part
130
134
  of the cache key, without the flag one user's HTML is served to another. The
131
135
  framework also catches this at runtime (a render that reads cookies is never