jskelet 0.2.1 → 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 +54 -0
- package/docs/06-cache.md +195 -4
- package/docs/07-yapilandirma.md +70 -2
- package/docs/en/06-caching.md +202 -5
- package/docs/en/07-configuration.md +72 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +63 -0
- package/src/client/cache-panel/panel.css +448 -0
- package/src/client/cache-panel/panel.html +133 -0
- package/src/client/cache-panel/panel.js +339 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +67 -2
- package/src/config/index.js +98 -1
- package/src/index.js +5 -0
- package/src/server/cache-panel.js +505 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +94 -15
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
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,6 +135,12 @@ one is listed under a **Breaking** heading.
|
|
|
87
135
|
|
|
88
136
|
### Changed
|
|
89
137
|
|
|
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.
|
|
90
144
|
- Errors and warnings raised during a prewarm pass are no longer logged one per
|
|
91
145
|
page. Request errors and the per-page render warnings (`was produced with
|
|
92
146
|
missing data`, `returned notFound() while upstream is failing`, `could not be
|
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,12 +875,28 @@ 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.
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
|
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
|
+
|
|
709
900
|
Tur sırasında oluşan istek hataları ve sayfa başına basılan render uyarıları
|
|
710
901
|
(`was produced with missing data`, `returned notFound() while upstream is
|
|
711
902
|
failing`, `could not be produced`) tek tek loglanmaz; sayılır ve tur bitince
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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ı |
|
package/docs/en/06-caching.md
CHANGED
|
@@ -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,13 +901,29 @@ 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
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
|
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
|
+
|
|
730
927
|
Request errors and the per-page render warnings (`was produced with missing
|
|
731
928
|
data`, `returned notFound() while upstream is failing`, `could not be
|
|
732
929
|
produced`) raised during the pass are not logged one by one. They are counted
|