jskelet 0.2.2 → 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
 
@@ -738,6 +751,13 @@ Bağlantı kurulmamışken de güvenle çağrılır. Dönen nesne
738
751
  kesicinin açık olduğunu, `errors` toplam komut hatasını gösterir. Aynı özet dev
739
752
  panelinin raporunda da var ([09-dev-araclari.md](./09-dev-araclari.md)).
740
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
+
741
761
  Ayarların tam listesi: [07-yapilandirma.md](./07-yapilandirma.md).
742
762
 
743
763
  ## Yönetim paneli
@@ -795,9 +815,12 @@ Panel açıldığında sunucu logu şifreyi basar:
795
815
 
796
816
  | Bölüm | Gösterdiği |
797
817
  | --- | --- |
798
- | Üst satır | Ortam, pid, uptime, RSS |
818
+ | Üst satır | Sürüm, ortam, pid, uptime, RSS |
799
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 |
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 |
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 |
801
824
 
802
825
  Liste **anahtar bazında filtrelenir** ve filtre sunucuda uygulanır: veri
803
826
  önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır
@@ -814,7 +837,10 @@ değil.
814
837
  | Clear HTML cache | `clearHtmlCache()` |
815
838
  | Clear data cache (önek opsiyonel) | `clearDataCache(prefix)` |
816
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` |
817
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 |
818
844
 
819
845
  Hepsi paylaşımlı kademeye de yayılır: tek kopyanın önbelleğini boşaltmak,
820
846
  kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
@@ -824,6 +850,117 @@ desenine bakar ve bir yolun **bütün** query varyantlarını düşürür,
824
850
  `dropHtmlCacheKey()` ise tam anahtarı alır — `/liste?sayfa=2` düşerken
825
851
  `/liste?sayfa=3` sıcak kalır.
826
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
+
827
964
  ## Prewarm — açılışta ısıtma
828
965
 
829
966
  Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek
@@ -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, upstream?: object, 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 }, upstream: { rate: 0 }, 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`
@@ -722,6 +756,30 @@ yetkisi demek ve her deploy eski erişimi kendiliğinden iptal etmeli. Yasaklı
722
756
  yetkisiz her cevap `404`'tür. Kullanım ve ekran ayrıntıları:
723
757
  [06-cache.md](./06-cache.md).
724
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
+
725
783
  ```js
726
784
  panel: {
727
785
  enabled: process.env.CACHE_PANEL === "1",
@@ -852,6 +910,9 @@ basılmaz.
852
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) |
853
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) |
854
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 |
855
916
  | `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar |
856
917
  | `PREWARM_MAX` | `prewarm` | `400` | En fazla kaç yol ısıtılır |
857
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
@@ -117,18 +117,31 @@ The cache also only kicks in for `GET` requests.
117
117
  ## The cache key
118
118
 
119
119
  ```
120
- `${req.path}?${new URLSearchParams(query).toString()}`
120
+ `${path}?${the allowed query parameters, sorted}`
121
121
  ```
122
122
 
123
- So the path **and all query parameters** are part of the key. `/list?page=2`
124
- and `/list?page=3` are separate entries.
123
+ For a request without a query the key is just the path. **A request that carries
124
+ a query parameter is dynamic by default**: it never enters the cache and is sent
125
+ with `private, no-store`. Caching every variant of a path mints an unbounded
126
+ number of keys (`?utm_source=…` and friends), and in a 500-entry store LRU then
127
+ evicts the real pages in favour of campaign variants.
125
128
 
126
- The practical consequence: a page that does not depend on the query string
127
- produces a separate entry for every combination when it is called with
128
- different campaign parameters (`?utm_source=…`). Stripping such parameters at
129
- the reverse proxy layer, or turning off the cache (by not supplying
130
- `revalidate`), is a reasonable precaution; by default the store holds at most
131
- 500 entries and evicts the oldest with LRU.
129
+ Which parameter actually changes the output is declared by the application, in
130
+ `jskelet.config.mjs` → `cache().query`:
131
+
132
+ ```js
133
+ cache: () => ({
134
+ html: { "/list": 60 },
135
+ query: { "/list": ["page"] },
136
+ }),
137
+ ```
138
+
139
+ Now `/list?page=2` and `/list?page=3` are separate entries, while
140
+ `/list?page=2&utm_source=x` shares the `?page=2` copy: a parameter outside the
141
+ list never reaches the key. A pattern mapped to `true` puts every parameter in
142
+ the key (careful: nothing but `maxEntries` then bounds the entry count), and one
143
+ mapped to `[]` ignores the query entirely. Details:
144
+ [07-configuration.md](./07-configuration.md).
132
145
 
133
146
  ## Stale-while-revalidate
134
147
 
@@ -756,6 +769,13 @@ you the circuit breaker is open and `errors` is the total command failure count.
756
769
  The same summary is in the dev panel report
757
770
  ([09-dev-tools.md](./09-dev-tools.md)).
758
771
 
772
+ Two more diagnostic surfaces:
773
+
774
+ | Call | What it tells you |
775
+ | --- | --- |
776
+ | `getRedisDetails()` | **Where** the connection points: address, TLS, database, `namespace`, which kinds are shared, whether the purge channel is subscribed. The password is never returned — a connection URL may carry one. |
777
+ | `inspectRedis()` | What is actually in the shared tier: keys per kind, `DBSIZE` and `used_memory`. It runs a `SCAN`, so **never call it on the request path**; in the admin panel it sits behind its own button. |
778
+
759
779
  The full list of settings: [07-configuration.md](./07-configuration.md).
760
780
 
761
781
  ## The admin panel
@@ -817,9 +837,12 @@ When the panel is on, the server log prints the password:
817
837
 
818
838
  | Area | Contents |
819
839
  | --- | --- |
820
- | Top bar | Environment, pid, uptime, RSS |
840
+ | Top bar | Version, environment, pid, uptime, RSS |
821
841
  | 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 |
842
+ | Shared tier | **Where** the connection points (address, TLS, database), the key prefix and `namespace`, the `buildId`, which kinds are shared, the state of compressed bodies and the purge broadcast, the command timeout and the error count. When it is off, a Redis recommendation with an install snippet takes its place. |
843
+ | Cloudflare | Zone, plan, cache related zone settings, how long development mode has left, Tiered Cache / Cache Reserve state and the cache hit ratio. When no zone is connected, a setup snippet takes its place. |
844
+ | Host | The machine's memory usage and how full the disk holding the project is |
845
+ | Entry list | HTML: path (opens in a new tab), fresh/stale, size, status code, remaining TTL, dependency count, precompressed bodies. Data: key (click to copy), fresh/stale, remaining TTL |
823
846
 
824
847
  The list is **filtered by key** and the filter runs on the server: a data cache
825
848
  can hold tens of thousands of keys. At most 500 rows come back per request and
@@ -836,7 +859,10 @@ export content.
836
859
  | Clear HTML cache | `clearHtmlCache()` |
837
860
  | Clear data cache (optional prefix) | `clearDataCache(prefix)` |
838
861
  | Drop shared keys | Scans and unlinks the `html` or `data` namespace in Redis |
862
+ | Count keys in Redis | `inspectRedis()` — keys per kind, `DBSIZE` and `used_memory` |
839
863
  | Prewarm | `prewarm()` — the pass runs in the background, progress shows in the card |
864
+ | Cloudflare purge (everything / URLs held here / prefix / host / tag) | `purgeCloudflare()` |
865
+ | Change a Cloudflare setting or feature | Zone settings and Tiered Cache / Cache Reserve |
840
866
 
841
867
  Each one propagates to the shared tier as well: clearing a single replica's
842
868
  cache is what produces the "I cleared it and it is still old" question in a
@@ -847,6 +873,116 @@ matches a path pattern and takes down **every** query variant of a path, while
847
873
  `dropHtmlCacheKey()` takes the exact key — `/list?page=2` goes and
848
874
  `/list?page=3` stays hot.
849
875
 
876
+ ## The CDN tier: Cloudflare
877
+
878
+ Everything above is the **origin** cache. With Cloudflare in front, the HTML
879
+ your visitors get usually never reaches you: the copy at the edge is served
880
+ until its TTL runs out. That is why `invalidateHtmlCache()` alone does not fix
881
+ "I updated the page but the old one still shows" — the origin refreshes, the
882
+ edge keeps waiting.
883
+
884
+ JSkelet lets you drive both tiers from the same place.
885
+
886
+ ### Setup
887
+
888
+ The token is a secret, so it goes in the environment, not in a config file:
889
+
890
+ ```bash
891
+ JSKELET_CLOUDFLARE_KEY=... # API token
892
+ JSKELET_CLOUDFLARE_ZONE_ID=... # zone identifier
893
+ JSKELET_CLOUDFLARE_HOSTNAME=example.com # optional
894
+ ```
895
+
896
+ Which permissions the token needs depends on what you want to do: `Zone.Cache
897
+ Purge` to purge, `Zone.Zone Settings` to change settings, `Zone.Analytics`
898
+ (read) for the hit ratio and the edge breakdown. A purge-only token still opens
899
+ the panel; the settings sections just report an error.
900
+
901
+ The zone id and site name are not secrets, so they can also come from
902
+ `jskelet.config.mjs`. The environment always wins:
903
+
904
+ ```js
905
+ cache: {
906
+ cloudflare: {
907
+ zoneId: "…",
908
+ hostname: "example.com", // purging wants absolute URLs; this turns paths into them
909
+ analyticsHours: 24,
910
+ },
911
+ }
912
+ ```
913
+
914
+ Without `hostname`, purge URLs are derived from the origin the panel was opened
915
+ on. If you reach the panel over an internal address (`http://10.0.0.4:3000`),
916
+ that address means nothing to Cloudflare — there, `hostname` is required.
917
+
918
+ ### What you can do
919
+
920
+ Whatever Cloudflare's cache surface offers is in the panel:
921
+
922
+ | Action | Note |
923
+ | --- | --- |
924
+ | Purge everything | The whole zone. The bluntest tool; warming back up is expensive |
925
+ | Purge by URL | Every page currently held in memory with one button, or `cf purge` per row |
926
+ | Purge by prefix / host / tag | Available on all plans now; 100 keys per request |
927
+ | Development mode | Bypasses the edge cache for three hours, then turns itself off |
928
+ | Cache level, browser cache TTL, query string sorting, Always Online | Zone settings |
929
+ | Tiered Cache, Regional Tiered Cache, Cache Reserve | Plan dependent; shows "unavailable" where the plan lacks it |
930
+ | Clear Cache Reserve | Separate from purging: `purge_everything` drops the edges, the persistent copy in R2 stays |
931
+
932
+ Long URL lists are split into batches of 100 keys and sent **sequentially**.
933
+ Sending them in parallel means half the batch rejected on the Free plan, where
934
+ purging is limited to five requests per minute.
935
+
936
+ The same surface from code:
937
+
938
+ ```js
939
+ import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
940
+
941
+ export async function onPostPublished(slug) {
942
+ const paths = ["/", `/blog/${slug}`];
943
+
944
+ invalidateHtmlCache(paths); // origin
945
+ await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
946
+ }
947
+ ```
948
+
949
+ Nothing in this module throws: with no token, on a Cloudflare 403 or when the
950
+ network drops, the result is `{ ok: false, error }`. A CDN outage should not
951
+ break your publishing flow.
952
+
953
+ ### "How many edges hold this page?" — what can and cannot be asked
954
+
955
+ There is no Cloudflare endpoint that lists the **inventory** of an object.
956
+ Hundreds of cities run independent caches and none of them will answer "do you
957
+ currently hold this URL". So the panel shows observation rather than inventory:
958
+ enter a path and the GraphQL analytics tell you which colo (IST, FRA, AMS…)
959
+ served it from cache and how often it went to the origin over the last N hours.
960
+
961
+ ```js
962
+ const report = await fetchPathEdges({ path: "/blog", hours: 24 });
963
+ // → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
964
+ ```
965
+
966
+ Two limits to keep in mind while reading it: an edge that received no request
967
+ does not appear at all, even if it holds a copy; and the dataset is sampled, so
968
+ ratios are reliable while absolute counts are estimates.
969
+
970
+ There is also no way to **warm** an edge you pick. An object enters an edge
971
+ cache only through a real request routed there; you cannot tell Frankfurt from
972
+ your server to go cache something. Three things do work in practice:
973
+
974
+ - **Warm the origin** (`prewarm`): the edge that takes the first request finds
975
+ a ready response, so that request is not the slow one.
976
+ - **Tiered Cache**: edges do not go straight to the origin, they pull from an
977
+ upper tier — the first request in one city counts as warming for the others.
978
+ - **Cache Reserve**: a persistent copy in R2 for long-tail content, so requests
979
+ do not reach the origin when an edge evicts.
980
+
981
+ If your `hit` ratio is low, check whether the response is cacheable at all
982
+ before anything else: `Cache-Control: private`, `Set-Cookie` and query string
983
+ settings are the most common reasons an edge decides not to cache, and they
984
+ show up as `dynamic` in this panel.
985
+
850
986
  ## Prewarm — warming up at startup
851
987
 
852
988
  The equivalent of Next's build-time prerender, except the output is not written
@@ -119,6 +119,7 @@ export default {
119
119
  async cache() {
120
120
  return {
121
121
  html: { "/": 60, "/news/:slug": 300 },
122
+ query: { "/search": ["q", "page"] },
122
123
  maxEntries: 500,
123
124
  data: { maxEntries: 10000, staleFactor: 10 },
124
125
  prewarm: {
@@ -572,9 +573,9 @@ Details: [03-routing.md](./03-routing.md).
572
573
  ## `cache()`
573
574
 
574
575
  **Type:**
575
- `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
576
+ `() => { 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 }` —
576
577
  **Default:**
577
- `{ 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 } }`
578
+ `{ 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 } }`
578
579
 
579
580
  ### `cache().html`
580
581
 
@@ -594,6 +595,39 @@ html: {
594
595
  }
595
596
  ```
596
597
 
598
+ ### `cache().query`
599
+
600
+ A pattern → list of query parameters allowed into the cache key.
601
+
602
+ **By default a request that carries a query parameter is dynamic**: even when
603
+ `cache().html` covers that path, the response never enters the HTML cache and
604
+ is sent with `private, no-store`. The reason is simple — caching every variant
605
+ of a path mints an unbounded number of keys (`?utm_source=…` and friends), and
606
+ once the `maxEntries` limit is reached those keys evict the real pages. Only the
607
+ application knows which parameter actually changes the output.
608
+
609
+ ```js
610
+ query: {
611
+ "/search": ["q", "page"], // only these two belong to the key
612
+ "/products": ["category"],
613
+ "/report/:id": true, // every parameter belongs to the key
614
+ "/campaign": [], // the query is ignored entirely
615
+ }
616
+ ```
617
+
618
+ - **Allowlist** (`string[]`): the listed parameters become part of the key and
619
+ each distinct value gets its own entry. Parameters outside the list are
620
+ **ignored** — the page is still cached and every campaign variant shares one
621
+ copy.
622
+ - **`true`**: every parameter belongs to the key. Nothing but `maxEntries`
623
+ bounds the number of entries, so use it only where the value set is closed.
624
+ - **`[]`**: the query is not considered at all; every variant is served the HTML
625
+ of the query-less version.
626
+
627
+ Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
628
+ share one entry. `route(fn, { private: true })` is unaffected by this section; a
629
+ private route is never cached under any condition.
630
+
597
631
  ### `cache().maxEntries`
598
632
 
599
633
  **Type:** `number` — **Default:** `500`
@@ -740,6 +774,32 @@ leaking one means handing out the right to flush the cache, and a deploy should
740
774
  revoke old access on its own. Banned and unauthorised requests all get a `404`.
741
775
  Usage and screens: [06-caching.md](./06-caching.md).
742
776
 
777
+ ### `cache().cloudflare`
778
+
779
+ The CDN tier. JSkelet's cache is the origin cache; the copy your visitors get
780
+ sits at the edge. With this section connected, the panel can purge the edge,
781
+ read and change cache related zone settings and show the cache hit ratio.
782
+
783
+ | Field | Type | Default | Meaning |
784
+ | --- | --- | --- | --- |
785
+ | `enabled` | `boolean` | `true` | Set `false` to keep the surface off even when a token is present in the environment |
786
+ | `zoneId` | `string \| null` | `null` | Zone identifier (`JSKELET_CLOUDFLARE_ZONE_ID` overrides it) |
787
+ | `apiToken` | `string \| null` | `null` | The token; **prefer the environment**, putting it here puts a secret in the repo |
788
+ | `hostname` | `string \| null` | `null` | Purging wants absolute URLs; paths are resolved against this name. Falls back to the origin the panel was opened on |
789
+ | `analyticsHours` | `number` | `24` | Analytics window, at most `72` |
790
+
791
+ Passing the token only through `JSKELET_CLOUDFLARE_KEY` keeps the config file
792
+ clean. Permissions follow what you intend to do: `Zone.Cache Purge` to purge,
793
+ `Zone.Zone Settings` for settings, `Zone.Analytics` (read) for the hit ratio.
794
+ The token is never returned in a panel response — only the fact that it came
795
+ from the environment.
796
+
797
+ With no zone connected the panel shows a setup snippet rather than a warning,
798
+ and if Cloudflare returns an error that section reports it while the rest of the
799
+ panel keeps working. What can actually be asked — in particular why "how many
800
+ edges hold this page" has no exact answer — is in
801
+ [06-caching.md](./06-caching.md).
802
+
743
803
  ```js
744
804
  panel: {
745
805
  enabled: process.env.CACHE_PANEL === "1",
@@ -871,6 +931,9 @@ and no warning is printed.
871
931
  | `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
872
932
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
873
933
  | `JSKELET_CACHE_PANEL` | `createApp` | — | When set, turns the cache panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
934
+ | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
935
+ | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
936
+ | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
874
937
  | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
875
938
  | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
876
939
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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",
@@ -5,11 +5,16 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
7
7
  <meta name="referrer" content="no-referrer" />
8
- <title>Cache</title>
8
+ <title>Cache · JSkelet</title>
9
+ <link rel="icon" href="logo.png" />
9
10
  <link rel="stylesheet" href="panel.css" />
10
11
  </head>
11
12
  <body class="login">
12
13
  <form id="form" autocomplete="off">
14
+ <div class="brand">
15
+ <img src="logo.png" alt="" width="30" height="30" />
16
+ <span class="wordmark">JSkelet</span>
17
+ </div>
13
18
  <h1>Cache panel</h1>
14
19
  <p>
15
20
  This run's password is printed in the server log
@@ -27,6 +32,9 @@
27
32
  />
28
33
  <button class="primary" type="submit">Unlock</button>
29
34
  <p class="error" id="error"></p>
35
+ <p class="hint m0">
36
+ Three failed attempts block this address for 24 hours.
37
+ </p>
30
38
  </form>
31
39
 
32
40
  <script type="module">