06 — Önbellek ve prewarm
Bu sayfada
- Genel resim
- Public ve kişiye özel ayrımı
- revalidate — TTL nereden gelir
- Cache anahtarı
- Host / locale: cache().vary
- Stale-while-revalidate
- Ne önbelleğe yazılır
- Yanıt başlıkları
- Sıkıştırılmış gövdenin saklanması
- İstek içi memoizasyon: cache()
- İstekler arası veri önbelleği: withDataCache
- Degraded render: reportUpstreamFailure
- Otomatik izleme (varsayılan)
- Elle bildirim
- Loader sözleşmesi: boş liste ≠ hata
- Geçici ve kalıcı hata ayrımı
- notFound() geçici hataya denk gelirse
- Upstream hız freni: cache().upstream
- Üç mekanizma, üç farklı sınır
- Devre kesici
- Durumu görmek
- Freni açmadan önce
- Önbelleği yönetmek
- Hedefli invalidation
- Otomatik bağımlılık: clearDataCache HTML'i de tazeler
- Paylaşımlı önbellek: Redis
- Ne kazanırsınız
- Anahtar düzeni
- Bilmeniz gereken takaslar
- Durumu görmek
- Yönetim paneli
- Erişim ve güvenlik
- Panelde ne var
- Panelden yapılabilenler
- CDN kademesi: Cloudflare
- Kurulum
- Ne yapılabilir
- "Bu sayfa kaç edge'de cache'li?" — sorulabilen ve sorulamayan
- Prewarm — açılışta veya ziyarette ısıtma
- Mod: onVisit — ziyaret edilen sayfanın linkleri
- Mod: klasik — hooks.prewarmPaths()
- Tur mantığı (klasik)
- Isıtma sırası: priority
- Damla damla ısıtma: rotate + rps + intervalSeconds
- Zamanlama
- Ayarlar
- Elle tetikleme
- Teşhis: sık görülen durumlar
- Sırada ne var
Bu belge JSkelet'in ISR ikamesini bütün ayrıntılarıyla anlatır: HTML TTL önbelleği ve stale-while-revalidate davranışı, revalidate'in nereden geldiği, cache anahtarının nasıl kurulduğu, X-JSkelet-Cache başlığının değerleri, sıkıştırılmış gövdenin neden önbellekte durduğu, istek içi memoizasyon (withRequestCache / cache()), veri önbelleği (withDataCache), upstream hatalarının önbelleği nasıl etkilediği (otomatik izleme ve reportUpstreamFailure) ve sunucu açılışındaki ısıtma turu. Kararların arkasındaki ölçüm gerekçeleri Mimari'de, config alanlarının tam referansı Yapılandırma'de.
Genel resim
route(controller, { revalidate })
└─ withHtmlCache(key, ttl, producer) ← TTL + stale-while-revalidate
└─ withUpstreamTracking(...) ← eksik veri tespiti
└─ withRequestCache(...) ← istek içi memoizasyon
└─ produce() → controller + renderPage
└─ withDataCache(...) ← upstream veri önbelleği
Sıra önemli: istek içi cache en içte olmalı ki aynı render'daki iki çağrı tek upstream isteğine düşsün; upstream takibi HTML cache'in içinde olmalı ki eksik veriyle üretilen çıktı önbelleğe yazılmasın.
İki önbelleğin iş bölümü:
| HTML önbelleği | Veri önbelleği | |
|---|---|---|
| Ne tutar | Sayfanın tamamı (+ sıkıştırılmış gövdesi) | Upstream'den gelen JSON |
| Girdi boyutu | ~100-200 kB | ~1-20 kB |
| Girdi sınırı | 500 (cache().maxEntries) | 10.000 (cache().data.maxEntries) |
| Kime yarar | Trafiği olan sayfalar: render bile edilmez | Uzun kuyruk: render edilir ama API'ye gidilmez |
Bu ayrım pratikte şuna karşılık gelir: on binlerce yollu bir sitede sayfaların tamamını HTML olarak sıcak tutmak mümkün değil — 500 girdiyi aşan ısıtma kendi ısıttığını siler. Uzun kuyruk için hedef "HTML hazır olsun" değil, "sayfayı üretecek veri API'ye gitmeden bulunsun" olmalı. O zaman hiç ısıtılmamış bir sayfa da ilk ziyaretçide milisaniyeler içinde üretilir ve kota harcamaz.
Public ve kişiye özel ayrımı
Bu belgedeki her şey herkese aynı gidebilen HTML için geçerli. Cache anahtarında kimlik yok (yalnızca yol + query + isteğe bağlı vary); yani önbellekteki bir sayfa onu ilk isteyen kişinin değil, o yolun (ve vary parçalarının) cevabıdır.
Kullanıcıya bağlı bir sayfa bu yüzden ayrı bir yoldan geçer:
app.get("/panel", route(async ({ req }) => { … }, { private: true }));
private: true üç şeyi birden yapar: önbellek devre dışı kalır, config'in cache.html deseni bu kararı ezemez ve yanıt private, no-store, Vary: Cookie ile, ETag'siz gider. Ayrıntılar ve oturum/CSRF tarafı 12-panel-ve-oturum.md'de.
Bayrağı unutursanız framework sessiz kalmaz: controller Cookie, Authorization ya da req.session/req.user okuduğu anda render işaretlenir ve önbelleğe yazılmaz. Dev'de istek bir hatayla düşer, üretimde no-store ile servis edilip loglanır. Koruma bir mazeret değil son savunma — doğru yer private: true.
revalidate — TTL nereden gelir
Bir route'un TTL'i iki kaynaktan gelebilir ve config kazanır:
route(controller, { revalidate: 60 })— route'un kendi süresi.jskelet.config.mjs→cache().htmliçindeki eşleşen desen. Varsa route'unkini ezer.
Tek istisna private: true: desen eşleşse bile yok sayılır. Kilit tek yönlü, çünkü ters yönde bir hata sessiz veri sızıntısı anlamına geliyor.
// jskelet.config.mjs
export default {
async cache() {
return {
html: {
"/": 60,
"/haber/:slug": 300,
"/etiket/:slug": 120,
},
};
},
};
Config ile ezme, tek bir dosyadan tüm sitenin tazelik profilini ayarlamayı mümkün kılar; route dosyalarını dolaşmak gerekmez.
Çözüm sonucu yol başına hatırlanır, böylece her istekte desen taraması yapılmaz. Hiç cache().html kuralı yoksa doğrudan route'un değeri kullanılır.
revalidate verilmemişse ya da 0 ise sayfa hiç önbelleklenmez: her istek render edilir ve yanıt Cache-Control: private, no-store ile, ETag'siz gider. X-JSkelet-Cache başlığı da yazılmaz — önbellek yolu hiç çalışmadı, MISS demek yanıltıcı olurdu.
Dinamik bir sayfaya no-store yazılması bilinçli. Hiç direktif taşımayan bir yanıtı HTTP "sezgisel olarak önbelleklenebilir" sayıyor; araya giren bir proxy ya da tarayıcının geri tuşu, tek bir ziyaretçi için üretilmiş HTML'i saklayabiliyordu.
Önbellek ayrıca yalnızca GET istekleri için devreye girer.
Cache anahtarı
`${varyPrefix}${yol}?${izin verilen query parametreleri, sıralı}`
varyPrefix varsayılan olarak boştur. Query'siz bir istek için anahtar ${varyPrefix}${yol}? biçimindedir. Query parametresi taşıyan istek varsayılan olarak dinamiktir: önbelleğe hiç girmez ve private, no-store ile gider. Bir yolun bütün varyantlarını cache'lemek ?utm_source=… gibi sonsuz sayıda anahtar üretiyor ve 500 girdilik store'da LRU, gerçek sayfaları kampanya varyantları için dışarı atıyor.
Hangi parametrenin çıktıyı gerçekten değiştirdiğini uygulama bildirir — jskelet.config.mjs → cache().query:
cache: () => ({
html: { "/liste": 60 },
query: { "/liste": ["sayfa"] },
}),
Artık /liste?sayfa=2 ile /liste?sayfa=3 ayrı girdiler, /liste?sayfa=2&utm_source=x ise ?sayfa=2 kopyasını paylaşır: listede olmayan parametre anahtara girmez. Bir desen true ile eşlenirse bütün parametreler anahtara girer (dikkat: girdi sayısını sınırlayan tek şey maxEntries olur), [] ile eşlenirse query tamamen yok sayılır. Ayrıntı: Yapılandırma.
Host / locale: cache().vary
CDN zaten tam URL ile ayırır; asıl risk origin L1 ve Redis HTML anahtarıdır. Host'tan locale üreten sitelerde (tr.example.com / en.example.com) vary olmadan ilk locale'in HTML'i diğer host'a servis edilir — Express 5'te istek nesnesine locale yazmak kırılgan bir kaçış yoludur.
cache: () => ({
html: { "/": 300, "/instruments/:slug": 300 },
vary: {
// true → public Host (x-forwarded-host || host), lowercase, portsuz
host: true,
// veya özel:
// headers: ["x-locale"],
// fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
},
}),
Örnek anahtarlar: h=tr.investvio.com|/instruments/aapl?, h=tr.example.com&l=tr|/…?.
| Alan | Tip | Anlamı | |
|---|---|---|---|
host | boolean | Public Host'u h=… olarak anahtara ekler | |
headers | string[] | Verilen istek başlıklarını (ad=değer) ekler | |
fn | `(req) => string \ | null` | Dönüş değeri bir segment olarak eklenir (tam kontrol) |
Prewarm: varsayılan ısıtma http://127.0.0.1:<port> üzerinden gider. vary.host açıksa bu yalnızca loopback anahtarını ısıtır; locale sitelerinde çoklu origin gerekir:
prewarm: {
origins: ["http://localhost", "http://tr.localhost"],
},
Port yazılmazsa dinleme portu eklenir. onVisit modunda ısıtma, vary açıkken ziyaretçinin Host başlığını kullanır.
Stale-while-revalidate
Girdi yapısı:
expiresAt = now + ttl
staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
produceMs = son başarılı üretimin süresi (ms)
Okuma davranışı:
| Durum | Yanıt | Arka plan |
|---|---|---|
now < expiresAt - leadMs | Önbellekteki HTML, HIT | — |
expiresAt - leadMs ≤ now < expiresAt | Önbellekteki HTML, HIT | Erken tazeleme başlar |
expiresAt ≤ now < staleUntil | Önbellekteki HTML anında, STALE | Tazeleme başlatılır |
now ≥ staleUntil | Girdi silinir, taze render, MISS (uçuştaki tazeleme varken silinmez) | — |
leadMs sayfanın load süresini hesaba katar:
leadMs = min(max(produceMs * 2, 250ms), ttl / 2)
Böylece yavaş bir sayfa TTL dolduğu anda hâlâ soğuk render'a düşmez: taze HTML çoğu zaman expiresAt gelmeden yazılmış olur. Trafik yoksa bir sweeper aynı pencerede girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır; startPrewarm ( PREWARM=0 değilse) kuyruğu HTTP ile boşaltır — klasik prewarmPaths olmasa da.
Stale penceresinde tazelemenin hatası isteği etkilemez: eski HTML pencere boyunca geçerli kalır ve hata yalnızca loglanır ([html-cache] background refresh failed: …).
Aynı anahtar için eşzamanlı tazelemeler tek bir çalışmada birleştirilir (inflight haritası): yüz eşzamanlı istek tek render'a düşer.
Kazanç: ilk ısıtmadan sonra hiçbir istek render'ı beklemez. Bedel: HTML'deki veri en fazla revalidate + bir tazeleme turu kadar geride olabilir. Bu bedel kabul edilebilir, çünkü fiyat gibi canlı alanlar istemcide WebSocket'ten güncelleniyor.
Store LRU'dur: erişilen girdi sona taşınır, sınır (cache().maxEntries, varsayılan 500) aşılınca en eski düşürülür.
Ne önbelleğe yazılır
Yalnızca şu iki koşulun ikisini birlikte sağlayan çıktı saklanır:
status === 200degraded !== true— render sırasında geçici bir upstream hatası bildirilmemiş.
Yani 404 sayfaları, redirect'ler ve eksik veriyle üretilmiş HTML önbelleğe girmez.
Yanıt başlıkları
route() her yanıta X-JSkelet-Cache yazar (başlık adı brand.cacheHeader ile değiştirilebilir):
| Değer | Anlamı |
|---|---|
HIT | Önbellekten, taze |
STALE | Önbellekten, süresi geçmiş; arkada tazeleniyor |
MISS | Bu istekte render edildi (ya da önbellek kapalı) |
Önbelleklenebilir yanıtlarda ayrıca:
Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
max-age=0 tarayıcıda saklamayı kapatır, s-maxage ara katmanlara (CDN, ters proxy) süreyi bildirir. Böylece CDN önünde durduğunda aynı tazelik modeli iki katmanda birlikte çalışır.
Sıkıştırılmış gövdenin saklanması
Önbelleğe alınan her girdi bir encoded haritası taşır ve HTML ile aynı ömrü paylaşır. Bir sayfa ilk kez brotli ya da gzip ile istendiğinde çıktı hesaplanıp haritaya konur; sonraki isteklerde aynı buffer gönderilir. Aynı sayfa her istekte yeniden brotli'lenmez.
Bu yolda Content-Encoding, Vary ve Content-Length doğrudan route() tarafından yazılır; sıkıştırma middleware'i Content-Encoding gördüğü için devreye girmez.
HEAD istekleri sıkıştırılmaz (gövde yok). İstemci ne brotli ne gzip kabul ediyorsa düz HTML gönderilir.
İstek içi memoizasyon: cache()
React'in cache() fonksiyonunun karşılığı: aynı istek içinde aynı argümanlarla yapılan çağrılar tek kez çalışır.
// lib/api/articles.js
import { cache } from "jskelet";
export const getArticle = cache(async (slug) => {
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
return response.json();
});
Artık aynı render'da hem controller hem hooks.layoutContext() aynı yazıyı isterse tek upstream isteği yapılır.
Ayrıntılar:
- Bağlam
AsyncLocalStorageile taşınır veroute()içindewithRequestCache()tarafından kurulur. - Bağlam yoksa memoizasyon devre dışı kalır ve fonksiyon doğrudan çağrılır. Bir script'ten ya da başka bir sürecin içinden çağırmak güvenlidir.
- Anahtar
JSON.stringify(args); argümansız çağrılar""anahtarını paylaşır. Serileştirilemeyen argümanlar (fonksiyon,Symbol, döngüsel nesne) ile kullanmayın. - Saklanan şey fonksiyonun dönüş değeridir, yani
asyncfonksiyonlarda Promise'in kendisi. Aynı Promise paylaşıldığı için eşzamanlı çağrılar da birleşir. withRequestCache(run)dışa açıktır;route()dışında (ör. kendi yazdığınız bir Express handler'ında) aynı kapsamı kurmak için kullanılabilir.
İstekler arası veri önbelleği: withDataCache
cache() yalnızca tek bir istek boyunca yaşar. Uzun kuyruğu API kotasından korumak için gereken şey istekler arasında yaşayan, TTL'li ve kendi kendini tazeleyen bir veri katmanı:
// lib/api/articles.js
import { withDataCache, reportUpstreamFailure } from "jskelet";
export async function getArticle(slug) {
return withDataCache(`haber:${slug}`, 600, async () => {
const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
if (!response.ok) {
reportUpstreamFailure({ status: response.status, path: `/articles/${slug}` });
return null;
}
return response.json();
});
}
Aynı kalıbın sarmalayıcı biçimi — anahtar argümanlardan üretilir:
import { dataCache } from "jskelet";
export const getArticle = dataCache(
async (slug) => apiGet(`/articles/${slug}`),
{ key: "haber", revalidate: 600 },
);
Davranış:
| Durum | Sonuç |
|---|---|
| Taze girdi | Anında döner, producer çalışmaz |
| TTL geçmiş, bayat pencere sürüyor | Bayat değer anında döner, tazeleme arkada yürür |
| Girdi yok | producer beklenir |
producer hata verdi, bayat girdi var | Bayat değer döner, uyarı: [data-cache] producer failed, serving stale value: … |
producer hata verdi, girdi yok | Hata çağırana gider |
Ayrıntılar:
- Aynı anahtarı eşzamanlı isteyen çağrılar tek upstream isteğine düşer. Isıtma turlarında kotayı en çok kurtaran davranış bu: 50 sayfa aynı endeks verisini istiyorsa API bir kez çağrılır.
nullveundefinedsaklanmaz. Uygulamaların HTTP istemcisi hatada genelliklenulldöner; bunu saklamak geçici bir 429'u TTL boyunca "veri yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen{ storeEmpty: true }verir.- Bayat pencere HTML'dekinden uzun:
staleFactorvarsayılanı 10, yani girdi TTL'inin 11 katı boyunca acil durum yedeği olarak kalır. Bayat veri, eksik sayfadan iyidir. Anahtar başına{ staleFactor: 0 }ile kapatılabilir. - Anahtar tamamen uygulamanın: dil, sürüm, sayfa numarası gibi ayrımlar anahtara yazılır (
haber:tr:v2:${slug}). - TTL
0verildiğinde önbellek devre dışı kalır veproducerher çağrıda çalışır — bir ayarı geçici olarak kapatmak için yeterli.
Yönetim yüzeyi:
| Fonksiyon | Ne yapar |
|---|---|
withDataCache(key, ttlSeconds, producer, options?) | Ana giriş noktası |
dataCache(fn, { key, revalidate, … }) | Fonksiyon sarmalayıcısı |
clearDataCache(prefix?) | Önek eşleşen girdileri (ya da tümünü) düşürür, silinen sayısını döner |
getDataCacheSize() | Girdi sayısı |
getDataCacheEntries() | Döküm: { key, stale, expiresIn }. Değerin kendisi dönmez. |
clearDataCache("haber:"), "bu içerik güncellendi" webhook'unun karşılığıdır: tek bir bölümün verisini düşürür ve o veriyi okumuş HTML sayfalarını da bayatlatır, yani güncelleme TTL beklenmeden görünür. Ayrıntısı aşağıda, "Otomatik bağımlılık" bölümünde.
Degraded render: reportUpstreamFailure
Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir. Böyle bir HTML'i tüm TTL boyunca servis etmek yerine önbelleğe hiç yazmamak doğru davranış: sonraki istek yeniden dener.
Bu bilgi iki yoldan gelir.
Otomatik izleme (varsayılan)
createApp() açılışta globalThis.fetchi sarar ve render sırasında yapılan çağrılardaki geçici hataları (429, 5xx, ağ hatası) kendiliğinden bildirir. Uygulama tarafında hiçbir satır gerekmez; fetch ile konuşan bir API istemcisi varsa rate limit koruması hazırdır.
Ayrıntılar:
- Yalnızca bir render bağlamı içindeki çağrılar sayılır. Script'ten, cron'dan ya da istek dışı bir yerden yapılan
fetchdokunulmaz kalır. - Kendi sunucumuza yapılan istekler (
localhost,127.0.0.1) atlanır: ısıtma turu ve sağlık kontrolü upstream değildir. 404/403gibi deterministik cevaplar otomatik olarak bildirilmez. Çoğu API'de404"böyle bir kayıt yok" demektir; onu eksik veri saymak her yok sayfasında yanlış uyarı üretirdi.- Kapatmak için
cache().trackUpstream: false.fetchi kendisi saran bir uygulama (ölçüm, retry, circuit breaker) bunu tercih edebilir.
Elle bildirim
fetch kullanmayan bir istemci (veritabanı sürücüsü, gRPC, satıcı SDK'sı) ya da kalıcı hataları da işaretlemek isteyen bir katman için sözleşme aynı kaldı. Bağımlılık yönü bilinçli olarak tersine çevrilmiştir: framework veri katmanını tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet boş bir dizidir. Aynı hata her iki yoldan bildirilirse tekilleştirilir.
// lib/api/client.js
import { reportUpstreamFailure } from "jskelet";
export async function apiGet(path) {
try {
const response = await fetch(`${process.env.API_ORIGIN}${path}`);
if (!response.ok) {
reportUpstreamFailure({ status: response.status, path });
return null;
}
return response.json();
} catch (error) {
// Yanıt hiç gelmedi: status 0 ağ hatası anlamına gelir.
reportUpstreamFailure({ status: 0, path });
return null;
}
}
Loader sözleşmesi: boş liste ≠ hata
catch → [] (veya null) ile yutulan bir upstream hatası, yanlış mapping ile aynı görünür: boş UI. Rate limit ve geçici hatalar logda doğru yönde işaretlense bile ziyaretçi “veri yok” sanır. Widget loader’ları sessiz []’ye gömülmek yerine sonucu ayırsın:
/**
* @returns {Promise<{ items: object[], error: Error | null }>}
*/
export async function loadTickerItems() {
try {
const items = await apiGet("/ticker");
if (!items) {
return { items: [], error: new Error("Upstream returned no data") };
}
return { items, error: null };
} catch (error) {
return {
items: [],
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
Uygulama tarafında ortak bir LoadErrorState bileşeni (veya eşdeğeri) bu error alanını göstersin; her widget kendi boş hâline düşmesin:
// views/components/load-error-state.js
import { esc } from "jskelet/html";
/**
* @param {{ message?: string, title?: string }} props
* @returns {string}
*/
export function LoadErrorState({ message, title = "Veri yüklenemedi" }) {
return `<div role="alert" data-load-error class="…">
<p>${esc(title)}</p>
${message ? `<p>${esc(message)}</p>` : ""}
</div>`;
}
{#if error}
<LoadErrorState :message="error.message" />
{#else if items.length}
{#each items as item}
…
{/each}
{#else}
<p>Kayıt yok</p>
{/if}
Framework markaya özel UI taşımaz; LoadErrorState uygulama bileşenidir. Önemli olan sözleşme: { items, error } (veya eşdeğeri) ve hata ile “gerçekten boş”un şablonda ayrı kolları.
Geçici ve kalıcı hata ayrımı
| Durum | Sayılır | Sonuç |
|---|---|---|
0 (ağ hatası), 408, 425, 429, >= 500 | Geçici | Sayfa önbelleğe yazılmaz, uyarı: [render] <path> was produced with missing data, not caching it (…) |
Diğerleri (400, 403, 404, …) | Kalıcı | Yalnızca uyarı: [render] <path> was produced with missing data, upstream is failing permanently (…). Önbellek engellenmez. |
Kalıcı hataların önbelleği engellememesi bilinçli: deterministik cevaplar tekrar denemekle düzelmez. Onlar yüzünden önbelleği kapatmak sayfayı her ziyarette baştan render etmek olur — içerik yine aynı eksik hâliyle döner, ziyaretçi sadece render süresini öder.
Eksik veriyle üretilen çıktı paylaşılan önbelleklere de sunulmaz: degraded bir yanıt public, s-maxage=… değil private, no-store alır. Süreç içi önbelleğe yazmama kararını CDN'de geri almak, aynı hatayı bir katman yukarıda tekrarlamak olurdu. Teşhis başlığı (X-JSkelet-Cache: MISS) yine yazılır.
notFound() geçici hataya denk gelirse
Veri gelmediği için notFound() çağıran bir controller, upstream rate limit'e girdiğinde tüm siteyi 404'e çevirebilir — ve bu 404'ler önbelleğe girdiği için geçici bir kota sorunu TTL boyunca "bu sayfa yok" cevabına dönüşür. Arama motoru için bu kalıcı bir kayıp.
Framework bu durumu ayırır: render sırasında geçici bir upstream hatası varsa notFound() 404 olarak servis edilmez. Sırayla:
- Sayfa kısa bir beklemeden sonra yeniden denenir (varsayılan bir kez, 300 ms sonra). Deneme kendi upstream ve istek içi cache bağlamında koşar; ilk turun hatası da memoize edilmiş boş cevabı da ikinci turu etkilemez.
- İkinci tur sayfayı üretebilirse ziyaretçi gerçek içeriği görür ve çıktı normal şekilde önbelleğe girer. Isıtma günlükleri bunun sık olduğunu gösteriyor: aynı yol saniyeler sonra 200 dönüyor.
- Denemeler tükendiyse yanıt
503olur — önbelleğe girmez,Retry-Aftertaşır, sonraki istek yine gerçek içeriği üretebilir.
| Render sırasında | notFound() sonucu |
|---|---|
Geçici hata var (429, 5xx, ağ hatası) | Tekrar dene → başarılıysa sayfa; hâlâ olmuyorsa 503, Retry-After: 30, no-store |
| Tekrar denemede upstream sağlam cevap verip "yok" dedi | Normal 404 |
Kalıcı hata var (404, 403…) ya da hata yok | Normal 404, tekrar denenmez |
Log satırları:
[render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
[render] /haber/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
Yani var olan bir sayfa hiçbir koşulda 404'e dönüşmez: ya gerçek içerik gelir, ya önbelleğe girmeyen bir 503. Hiçbir şey "yok" olarak dondurulmaz.
Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turudur; bu yüzden varsayılan tek deneme. Ayar cache().transientRetry:
cache: {
transientRetry: { attempts: 2, delayMs: 500 },
}
transientRetry: false (ya da attempts: 0) tekrarı kapatır ve doğrudan 503'e düşer.
Upstream hız freni: cache().upstream
Buraya kadarki her şey 429 geldikten sonra ne olacağını anlatıyor. Bu bölüm 429'u en baştan almamakla ilgili.
Fren trackUpstreamFetch() sarmalayıcısının içinde, yani gerçek fetch çağrısının geçtiği yerde duruyor. Isıtma turundaki prewarm.rps bu işi yapamaz: o, kendi sunucumuza atılan sayfa isteklerini sayıyor, ama bir sayfa render'ı bir API çağrısı da yapabilir yirmi tane de. Kotayı bağlayan şey sayfa sayısı değil, çağrı sayısı — ve fren buraya konduğunda ısıtma da gerçek trafik de aynı bütçeden harcar.
Varsayılan kapalı: rate verilmedikçe hiçbir istek beklemez ve maliyet bir daldan ibarettir.
// jskelet.config.mjs
cache: () => ({
upstream: {
rate: 10, // saniyedeki tavan (host başına)
burst: 20, // kısa patlama toleransı
concurrency: 8, // aynı anda uçan çağrı
hosts: {
// Kotası farklı olan uçlar ayrı ayarlanır.
"api.example.com": { rate: 3, concurrency: 2 },
},
},
}),
Üç mekanizma, üç farklı sınır
| Mekanizma | Neyi sınırlar | Ayar |
|---|---|---|
| Token bucket | Ortalama hız (çağrı/saniye) | rate, burst |
| Eşzamanlılık | Anlık baskı (aynı anda uçan çağrı) | concurrency |
| AIMD | Doğru hızın ne olduğu | minRate, increaseStep, increaseIntervalMs, decreaseIntervalMs |
Üçüncüsü asıl fikir. Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır: kotanın gerçek sınırını kimse config'e doğru yazamaz, üstelik gün içinde değişir. Bu yüzden rate bir tavan olarak alınır ve gerçek hız upstream'in cevabına göre oynar:
- 429 ya da 503 → hız yarıya iner (çarpımsal azalma). Yanıt
Retry-Aftertaşıyorsa kova o süre boyunca tamamen durur — upstream sana ne kadar bekleyeceğini zaten söylüyor. - Temiz geçen her pencere → hız
increaseStepkadar yukarı çıkar (toplamsal artış),ratetavanına kadar.
Azalmanın çarpımsal, artışın toplamsal olması bilinçli. Tersi olsaydı her pencerede yeniden 429 yenirdi.
Devre kesici
Art arda breakerFailures (varsayılan 5) tane 429 alan bir host breakerCooldownMs süresince tamamen baypas edilir: çağrı hiç yapılmaz, doğrudan geçici hata olarak bildirilir.
Sert görünüyor ama asimetri bunu gerektiriyor: 429 geçici sayıldığı için o çağrıyla üretilen HTML önbelleğe yazılmaz. Yani rate limit'e girmiş bir turda kota harcanır ve karşılığında hiçbir şey saklanmaz. Üstüne bir sonraki tur aynı sayfayı yine soğuk bulup yine dener. Kesici bu boşa yanmayı kesiyor.
[upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
Yalnızca 429 ve 503 sayılır. 400/404 bir kota sorunu değil, 500 de öyle: onlar için yavaşlamak arızayı düzeltmez, sadece siteyi yavaşlatır.
Durumu görmek
getUpstreamLimiterStatus() host başına o anki hızı, uçuştaki çağrıyı ve sayaçları döner; dev panelinin Server sekmesi de aynı bilgiyi basıyor. 429 fırtınasında "şu an saniyede kaça indi" bilgisi olmadan ayar yapmak körlemesine olur.
import { getUpstreamLimiterStatus } from "jskelet";
// [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
// active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
Freni açmadan önce
Hız freni son çare. Aynı upstream yanıtını yüzlerce sayfa çekiyorsa asıl çözüm withDataCache TTL'ini tur aralığından uzun tutmak: 400 sayfalık bir tur, ortak bir uç için tek çağrı yapar. Fren o çağrıları yavaşlatır, sayısını azaltmaz.
Önbelleği yönetmek
jskelet şu fonksiyonları dışa açar:
| Fonksiyon | Ne yapar |
|---|---|
withHtmlCache(key, ttlSeconds, producer) | Önbelleği doğrudan kullanmak için. ttlSeconds 0 ise producer her zaman çalışır. |
invalidateHtmlCache(target, options?) | Eşleşen sayfaları bayatlatır (ya da { hard: true } ile düşürür), etkilenen sayı döner. |
clearHtmlCache() | Store'u tamamen boşaltır. |
getHtmlCacheSize() | Girdi sayısı. |
getHtmlCacheEntries() | Döküm: { key, bytes, status, stale, expiresIn, encodings, deps }. HTML gövdesi dönmez, yalnızca boyutu. |
Hedefli invalidation
TTL'i beklemekle tüm önbelleği boşaltmak arasındaki boşluğu invalidateHtmlCache() doldurur:
import { invalidateHtmlCache } from "jskelet";
invalidateHtmlCache("/haber/abc"); // o yol ve altı
invalidateHtmlCache("/haber/:slug"); // desen sözdizimi
invalidateHtmlCache([/-yorumlar$/, "/"]); // RegExp ve liste
Varsayılan davranış bayatlatmaktır, silmek değil: girdi süresi geçmiş sayılır ve normal stale-while-revalidate yoluna düşer. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i döver. Bayatlatmada ziyaretçi eski HTML'i beklemeden alır, tazeleme arkada ve anahtar başına tek seferde koşar. Eski HTML'in gerçekten geçersiz olduğu durumlar için { hard: true }.
Anahtar yol?query olduğundan eşleştirme yol kısmına yapılır: bir yolun bütün query varyantları (?utm_source=… dahil) tek çağrıyla düşer. Düz string'te önek segment sınırında kesilir — /haber kuralı /haberleri etkilemez.
Uçuşta olan bir render de hedeflenir: purge'den önce başlamış bir tur, sonucu artık eski veriyi taşıdığı için önbelleğe yazılmaz ve bir sonraki istek yeni bir tur başlatır.
Otomatik bağımlılık: clearDataCache HTML'i de tazeler
Hangi sayfanın hangi içerikten etkilendiğini bildirmek gerekmez. Render sırasında okunan her withDataCache anahtarı kaydedilir; clearDataCache() bir anahtarı düşürdüğünde onu fiilen okumuş bütün HTML girdileri bayatlar.
// "bu haber güncellendi" webhook'u
clearDataCache(`haber:${slug}`);
Bu tek satır haber detayını, o haberi listeleyen ana sayfayı ve etiket sayfasını birlikte tazeler — çünkü üçü de o anahtarı okumuştu. Elle tag'lemede en sık yapılan hata (detayı işaretleyip listeyi unutmak) burada yapısal olarak mümkün değil: bildirim değil, gözlem var.
Ayrıntılar:
- Bağımlılık her tazelemede yeniden toplanır; sayfanın okuduğu anahtarlar zamanla değişebilir.
- Render sürerken gelen bir purge de yakalanır: o turun çıktısı "doğduğu anda bayat" olacağı için önbelleğe yazılmaz.
- Sayfa başına bağımlılık sayısı
getHtmlCacheEntries()dökümündedepsalanında görünür. Bir invalidation beklediğiniz sayfayı tazelemiyorsa önce buraya bakın: sayfa o veriyiwithDataCacheüzerinden okumuyor olabilir. withDataCachekullanmayan bir uygulamada kaydedilecek bir şey yoktur;cache().trackDependencies: falseile izleme tamamen kapatılabilir.- Bayatlatılan yollar ısıtma kuyruğunun başına alınır.
prewarmkuruluysa sayfa, ziyaretçi gelmesini beklemeden tazelenir ve tur özeti bunu ayırt eder:[prewarm] warmed 12/12 pages, 3 invalidated (0.4s).
Bir yönetim ucu yazmak için:
import { clearHtmlCache, getHtmlCacheEntries } from "jskelet";
export default function register(app) {
app.post("/_admin/cache/temizle", (req, res) => {
if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
res.status(404).end();
return;
}
clearHtmlCache();
res.json({ ok: true });
});
app.get("/_admin/cache", (req, res) => {
res.json(getHtmlCacheEntries());
});
}
Dev sunucusu ayrıca manifest her değiştiğinde önbelleği kendiliğinden temizler: saklanan HTML eski hash'li varlık URL'lerini taşıyor olur ve temizlenmezse sayfa silinmiş dosyayı istemeye devam eder (Dev araçları).
Önbellek süreç belleğinde yaşadığı için birden fazla süreç/kopya çalıştırıyorsanız her birinin kendi önbelleği olur; clearHtmlCache() yalnızca çağrıldığı süreci etkiler. Birden fazla instance çalıştıran bir kurulumda bunu aşmanın yolu bir sonraki bölümde.
Paylaşımlı önbellek: Redis
Varsayılan önbellek tek prosese ait. Bu, tek instance çalışan bir sitede en hızlı ve en basit kurulum — ama üç kopya çalıştırdığınızda iki sorun çıkar:
- Her kopya kendi başına ısınır. Yeni bir instance açıldığında ya da bir deploy sonrası konteyner yenilendiğinde önbellek boştur; aynı sayfa üç kez render edilir, aynı veri üç kez çekilir.
- Invalidation tek kopyaya ulaşır.
invalidateHtmlCache()çağıran webhook yalnızca isteği alan instance'ı tazeler; diğerleri TTL'i bekler. Ziyaretçi hangi kopyaya düştüğüne göre eski ya da yeni içeriği görür.
cache().redis bu iki sorunu çözer. Redis birincil store olmaz: bellek içi önbellek (L1) aynen kalır ve her istek onu okur; Redis ikinci kademedir (L2).
// jskelet.config.mjs
export default {
cache() {
return {
html: { "/haber/:slug": 300 },
redis: {
enabled: true,
url: process.env.REDIS_URL,
namespace: "haber-sitesi",
},
};
},
};
ioredis opsiyonel bir peer bağımlılıktır, uygulamanın kendisine kurulur:
npm install ioredis
Kurulmadıysa ya da Redis'e bağlanılamıyorsa uyarı basılır ve site bellek içi önbellekle çalışmaya devam eder. Redis çalışırken düşerse aynı şey olur: bir devre kesici art arda beş hatadan sonra katmanı beş saniye baypas eder, böylece her istek ağ zaman aşımı beklemez.
Ne kazanırsınız
- Soğuk instance sıcak önbellek bulur. L1'de olmayan bir yol için render çalışmadan önce Redis okunur; başka bir kopya o sayfayı ürettiyse render hiç çalışmaz.
- Veri önbelleği kotayı bir kez harcar.
withDataCacheaynı mantıkla çalışır ve kazanç burada daha büyük: JSON küçük, bir kopyanın çektiği veri hepsine yeter. - Invalidation her kopyaya gider.
invalidateHtmlCache(),clearHtmlCache()veclearDataCache()bir pub/sub kanalına mesaj bırakır; her instance kendi L1'ine aynı işlemi uygular. Deseni yayınlar, eşleşen anahtarları değil — hangi yolun nerede sıcak olduğu kopyaya bağlı.
Anahtar düzeni
_jskelet:{namespace}:{buildId}:html:{vary|}{yol}?{query}
_jskelet:{namespace}:{buildId}:data:{anahtar}
_jskelet:{namespace}:events
buildId her build'de değişir (jskelet build bunu .jskelet/build.json dosyasına yazar) ve zorunlu bir parçadır: saklanan HTML hash'li varlık yollarını gömüyor, yani bir deploy'dan sonra eski HTML geçersizdir. Kimlik önekte durduğu için yeni sürüm kendiliğinden yeni bir isim alanına yazar, eski anahtarlar TTL ile ölür — elle temizlik ya da FLUSHDB gerekmez. Build çalıştırılmadıysa kimlik dev olur.
namespace aynı Redis'i paylaşan birden fazla uygulamayı ayırır. Olay kanalı bilinçli olarak buildId taşımaz: deploy sırasında eski ve yeni sürüm yan yana koşuyor ve bir purge ikisine de ulaşmalı.
Bilmeniz gereken takaslar
- Kişiye özel çıktı paylaşılmaz.
storable: falseişaretlenen bir render (cookie/Authorizationokuyan sayfa) Redis'e hiç yazılmaz. Tek prosesteyken bile geçerli olan bu kural paylaşımlı kademede daha da kritik: sızması, bir kullanıcının HTML'ini tüm kümeye servis etmek olur.degradedrender ve 200 dışındaki durum kodları da paylaşılmaz. - Sıkıştırılmış gövdeler varsayılan olarak yerel kalır.
storeEncoded: trueile açılabilir, ama girdi başına boyutu iki-üç katına çıkarır; brotli'yi yeniden üretmek çoğu zaman Redis'ten indirmekten ucuzdur. - Yumuşak invalidation Redis kopyasını siler. Bayatlatmanın Redis karşılığı her anahtar için oku-değiştir-yaz turu demek ve bir webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç görmemiş bir kopyanın bir kez render etmesi; L1'i sıcak olan kopyalar eski HTML'i bayat pencerede servis etmeye devam eder.
- Yalnızca taze girdi kabul edilir. Bayat bir kopyayı L1'e almak tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tur yine Redis'i okur ve render hiç çalışmaz.
- Tutarlılık nihai. Bir purge ile o purge'ün her kopyaya ulaşması arasında kısa bir pencere var. Bu pencerede bir kopya eski HTML'i servis edebilir; süresi TTL ile sınırlı.
- Dev'de kapalı tutun. Dev sunucusu manifest her değiştiğinde önbelleği boşaltıyor; paylaşımlı bir store bunu anlamsızlaştırır.
enabledyalnızca açıkçatrueverildiğinde açılır.
Durumu görmek
import { getRedisStatus } from "jskelet";
app.get("/api/healthcheck", (req, res) => {
res.json({ ok: true, cache: getRedisStatus() });
});
Bağlantı kurulmamışken de güvenle çağrılır. Dönen nesne { enabled, connected, keyPrefix, buildId, errors, bypassed }; bypassed devre kesicinin açık olduğunu, errors toplam komut hatasını gösterir. Aynı özet dev panelinin raporunda da var (Dev araçları).
İki teşhis yüzeyi daha var:
| Çağrı | Ne der |
|---|---|
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. |
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ı. |
Ayarların tam listesi: Yapılandırma.
Yönetim paneli
Yukarıdaki getHtmlCacheEntries() / getRedisStatus() uçlarını elle yazmak yerine framework hazır bir panel taşıyor. Dev overlay'den ayrı bir şey: overlay yalnızca NODE_ENV=development iken var, panel ise ortama bakmaz — "bu sayfa neden bayat", "webhook purge'ü geçti mi", "Redis gerçekten bağlı mı" soruları üretimde soruluyor.
Panel üst düzey admin() ile açılır (cache() içinde değil) ve kökü /_jskelet/admin'dır. Altında Overview, Cache, Routes, Views, Logs ve System sayfaları vardır; Cache sayfası eski tek sayfalık panelle aynı işlemleri taşır.
// jskelet.config.mjs
export default {
admin() {
return {
enabled: process.env.JSKELET_ADMIN === "1",
allowIps: ["10.0.0.0/8"], // boş = IP kısıtı yok
blockBots: true,
};
},
cache() {
return {
html: { "/haber/:slug": 300 },
};
},
};
enabled verilmedikçe hiçbir şey mount edilmez: yol yoktur, modül yüklenmez, üretim sürecinde hiçbir maliyeti olmaz. Ortam değişkeni (JSKELET_ADMIN=1) config'i ezer; panel genelde bir arıza sırasında tek seferlik açılıyor ve o an config dosyasını değiştirip yeniden dağıtmak istenmiyor.
Panel açıldığında sunucu logu şifreyi basar:
┌─ ADMIN ──────────────────────────────────────┐
│ http://localhost:3000/_jskelet/admin │
│ │
│ password 3f9c… │
│ │
│ Valid until this process restarts. │
└──────────────────────────────────────────────┘
Erişim ve güvenlik
- Şifre her süreç başlangıcında yeniden üretilir (32 haneli, onaltılık) ve yalnızca logda görünür. Kalıcı bir sır tutulmuyor: sızması önbelleği boşaltma yetkisi demek ve bir deploy eski erişimi kendiliğinden iptal etmeli.
- Şifre query string ile kabul edilmez. Erişim logları, tarayıcı geçmişi ve
Refererbaşlığı sırrı taşımasın; giriş yalnızca form üzerinden. allowIpsverilirse (exact IP veya CIDR) listede olmayan her istek login dahil404alır.blockBots(varsayılantrue) bilinen crawler UA'larını (Googlebot, Bingbot, Ahrefs, …)404ile reddeder.- Üç başarısız denemede IP 24 saat yasaklanır (
banAttempts,banHours). Yanlış şifre kadar oturumsuz yazma isteği de sayılır; başarılı giriş sayacı sıfırlar. - Yasaklı ve yetkisiz her cevap
404. 401/403 panelin var olduğunu doğrular, 404 hiç yokmuş gibi davranır. Panelin dışındaki site etkilenmez. - Hiçbir yeri indekslenmez: her cevapta
X-Robots-Tag: noindex, nofollow, noarchive, nosnippet,Cache-Control: no-storeveReferrer-Policy: no-referrer. Yol ayrıca ısıtma listesinden ve gezinme ipuçlarından muaftır. - Aksiyonlar
X-JSkelet-Adminbaşlığı ister — çapraz siteden gönderilemeyen bir başlık, yani panelin kendi CSRF freni. - Oturumlar ve yasak sayaçları süreç belleğinde durur; şifresi zaten her restart'ta değişen bir panel için diske yazmak yanlış takas olurdu.
Panelde ne var
| Bölüm | Gösterdiği |
|---|---|
| Overview | HTML/data/Redis/prewarm kartları ve upstream freni özeti |
| Cache | Paylaşımlı kademe, Cloudflare, aksiyonlar, girdi listesi (eski panel) |
| Routes | Express'e kayıtlı path/method'lar, route modül dosyaları, son istek özeti |
| Views | views/ altındaki şablon envanteri |
| Logs | Canlı SSE kuyruğu; method/status/cache/kind/path ve metin filtresi |
| System | Makine RAM / disk |
Liste anahtar bazında filtrelenir ve filtre sunucuda uygulanır: veri önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır döner; başlıktaki sayaç kaç eşleşmenin kesildiğini söyler. HTML gövdesi ve veri değerleri hiç dönmez — panelin işi durumu göstermek, içeriği dışa vermek değil.
Panelden yapılabilenler
| İşlem | Karşılığı |
|---|---|
Invalidate (hedef + hard) | invalidateHtmlCache(target, { hard }) |
Tek satırı drop | dropHtmlCacheKey(key) / dropDataCacheKey(key) |
| Clear HTML cache | clearHtmlCache() |
| Clear data cache (önek opsiyonel) | clearDataCache(prefix) |
| Drop shared keys | Redis'teki html ya da data isim alanını tarar ve düşürür |
| Count keys in Redis | inspectRedis() — tür başına anahtar sayısı, DBSIZE ve used_memory |
| Prewarm | prewarm() — tur arkada koşar, ilerleme kartta görünür |
| Cloudflare purge (her şey / bellekteki URL'ler / prefix / host / tag) | purgeCloudflare() |
| Cloudflare ayarı ya da özelliği değiştirmek | Zone ayarları ve Tiered Cache / Cache Reserve |
Hepsi paylaşımlı kademeye de yayılır: tek kopyanın önbelleğini boşaltmak, kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
Panel iki dilde: header'daki seçici Türkçe ile İngilizce arasında geçiş yapar. İlk açılışta tarayıcının diline bakılır, seçim localStorage'da tutulur ve giriş sayfasına da uygulanır. Dil değişimi hiçbir isteğe yol açmaz. Sunucu tarafı arayüz dilini hiç bilmez: /action cevabı metin değil bir kod döner ({ ok, code, params }) ve cümleyi panel kurar — framework'ün log'u ve API'si tek dilde kalır.
Listedeki tek satırı silmek invalidateHtmlCache()ten farklıdır: o yol desenine bakar ve bir yolun bütün query varyantlarını düşürür, dropHtmlCacheKey() ise tam anahtarı alır — /liste?sayfa=2 düşerken /liste?sayfa=3 sıcak kalır.
CDN kademesi: Cloudflare
Buraya kadar anlatılan her şey origin önbelleği. Önünde Cloudflare varsa ziyaretçinin gördüğü HTML çoğu zaman hiç size ulaşmıyor: edge'deki kopya TTL'ini doldurana kadar servis edilir. Bu yüzden invalidateHtmlCache() tek başına "sayfayı güncelledim ama eski hâli görünüyor" sorununu çözmez — origin tazelenir, edge beklemeye devam eder.
JSkelet iki kademeyi aynı yerden yönetilebilir kılar.
Kurulum
Token bir sır; config dosyasına değil ortama yazılır:
JSKELET_CLOUDFLARE_KEY=... # API token
JSKELET_CLOUDFLARE_ZONE_ID=... # zone kimliği
JSKELET_CLOUDFLARE_HOSTNAME=example.com # opsiyonel
Token'a gereken izinler, yapmak istediğinize göre: purge için Zone.Cache Purge, ayarları değiştirmek için Zone.Zone Settings, isabet oranı ve edge kırılımı için Zone.Analytics (salt okunur). Yalnızca purge izni verilen bir token'la panel açılır, ayar bölümleri hata yazar.
Zone kimliği ve site adı sır olmadığı için jskelet.config.mjs içinden de verilebilir; env her zaman önceliklidir:
cache: {
cloudflare: {
zoneId: "…",
hostname: "example.com", // purge tam URL ister; yol → URL çevrimi için
analyticsHours: 24,
},
}
hostname verilmezse purge URL'leri panelin açıldığı origin'den türetilir. Paneli iç bir adresten (http://10.0.0.4:3000) açıyorsanız bu adresin Cloudflare'de karşılığı yok; o kurulumda hostname zorunlu.
Ne yapılabilir
Cloudflare'in cache yüzeyinde ne varsa panelde de var:
| İşlem | Not |
|---|---|
| Purge everything | Zone'un tamamı. En kaba araç; ısınma maliyeti yüksek |
| Purge by URL | Panelin o an bellekte tuttuğu sayfalar tek düğmeyle; ya da satır başına cf purge |
| Purge by prefix / host / tag | Artık her planda çalışıyor; istek başına 100 anahtar |
| Development mode | Üç saat boyunca edge önbelleğini baypas eder, sonra kendiliğinden kapanır |
| Cache level, browser cache TTL, query string sıralaması, Always Online | Zone ayarları |
| Tiered Cache, Regional Tiered Cache, Cache Reserve | Plana bağlı; kapalı planda "unavailable" görünür |
| Clear Cache Reserve | Purge'den ayrı: purge_everything edge'i düşürür, R2'deki kalıcı kopya kalır |
Uzun URL listeleri istek başına 100 anahtarlık partilere bölünür ve sırayla gönderilir. Paralel göndermek Free planda dakikada beş isteklik hız freni yüzünden yarısı reddedilen bir tur demek.
Kod tarafında aynı yüzey:
import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
export async function onPostPublished(slug) {
const paths = ["/", `/blog/${slug}`];
invalidateHtmlCache(paths); // origin
await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
}
Bu modüldeki hiçbir fonksiyon fırlatmaz: token yoksa, Cloudflare 403 dönerse ya da ağ düşerse sonuç { ok: false, error } olur. Bir CDN arızası içerik yayınlama akışını kesmemeli.
"Bu sayfa kaç edge'de cache'li?" — sorulabilen ve sorulamayan
Cloudflare API'sinde bir objenin envanterini veren uç yok. Yüzlerce şehirde birbirinden bağımsız önbellekler var ve hiçbiri "şu an bende bu URL'in kopyası var mı" sorusuna cevap vermiyor. Panel bu yüzden envanter değil gözlem gösterir: bir yol girip sorguladığınızda GraphQL analitiğinden son N saatte hangi kolonun (IST, FRA, AMS…) o yolu kaç kez cache'ten, kaç kez origin'den servis ettiği gelir.
const report = await fetchPathEdges({ path: "/blog", hours: 24 });
// → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
Okurken iki sınırı akılda tutun: hiç istek almamış bir edge listede görünmez, kopyası olsa da; ve veri kümesi örneklemeli, yani oranlar güvenilir ama mutlak sayılar yaklaşıktır.
Seçtiğiniz bir edge'i ısıtmanın da yolu yok. Bir obje ancak o koloya yönlenen gerçek bir istekle o edge'in önbelleğine giriyor; sunucudan "Frankfurt'a bunu cache'let" diyemezsiniz. Pratikte yapılabilen üç şey var:
- Origin'i ısıtmak (
prewarm): ilk isteği alan edge cevabı hazır bulur, o istek yavaşlamaz. - Tiered Cache: edge'ler origin'e doğrudan gitmez, aradaki üst katmandan besleniyor; bir şehirdeki ilk istek diğer şehirler için de ısıtma sayılır.
- Cache Reserve: uzun kuyruklu içerik için R2'de kalıcı kopya; edge düşünce istek origin'e kadar inmiyor.
hit oranınız düşükse önce cevabın cache'lenebilir olup olmadığına bakın: Cache-Control: private, Set-Cookie ve query string ayarları edge'in cache'lememe kararının en sık sebepleri, ve bu panelde dynamic olarak görünür.
Prewarm — açılışta veya ziyarette ısıtma
Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca (klasik mod) ya da trafik geldikçe (onVisit) yapılır. Kazanç aynı — tıklanan / komşu sayfa soğuk render'ı beklemez — fakat veri dondurulmaz; her girdi route'un revalidate süresiyle yaşlanır ve stale-while-revalidate ile arkada tazelenir.
Isıtma gerçek HTTP istekleriyle yapılır (http://127.0.0.1:<port> ya da cache().prewarm.origins), çünkü cache anahtarı, sıkıştırma ve middleware zinciri normal trafikle bire bir aynı olsun. vary.host açıksa varsayılan loopback yalnızca o host'un anahtarını ısıtır — locale sitelerinde origins: ["http://localhost", "http://tr.localhost"] gibi çoklu origin gerekir.
İki mod karşılıklı dışlayıcıdır. cache().prewarm.onVisit açıksa klasik alanlar (max, priority, rotate, intervalSeconds, …) ve hooks.prewarmPaths birlikte verilemez — config yüklenirken hata fırlar. Tersi de geçerli: klasik liste ısıtması kullanıyorsanız onVisit yazmayın.
Mod: onVisit — ziyaret edilen sayfanın linkleri
Bir kullanıcı herkese açık, önbelleklenebilir bir sayfayı (public HTML, 200) aldığında framework yanıt HTML'indeki aynı-origin <a href> yollarını (üstten alta, perPage kadar) kuyruğa alır ve arka planda ısıtır. Bir sonraki tıklama veya aynı sayfaya gelen başka ziyaretçi çoğu zaman HIT görür.
// jskelet.config.mjs
export default {
async cache() {
return {
html: { "/": 60, "/haber/:slug": 300 },
prewarm: {
onVisit: {
perPage: 20, // sayfa başına en fazla link
concurrency: 2, // opsiyonel
rps: 4, // opsiyonel; 0 = sınırsız
},
},
};
},
};
onVisit: true de yeterlidir (varsayılan perPage: 20).
Kurallar:
- Yalnızca
route()ile giden public + cache'lenebilir 200 HTML tetikler;private, degraded veyano-storeyanıtlar link çıkarmaz. - Isıtma isteğinin kendi UA'sı (
brand.prewarmUserAgent) tetiklemez — sonsuz crawl olmaz. - Zaten taze olan yollar kuyruğa girmez.
nofollow,target="_blank",data-no-prefetch,prewarmSkipvenavigation.excludeSpeculation Rules ile aynı muafiyetleri paylaşır.- Query string ısıtılmaz (varsayılan cache politikası query'yi dinamik sayar).
- Açılışta otomatik tur yoktur; ilk ziyaretçi o sayfa için hâlâ MISS ödeyebilir. Kritik yolları deploy öncesi sıcak tutmak istiyorsanız klasik modu veya readiness + seed tercih edin.
PREWARM=0onVisit'i de kapatır.PREWARM_MAX/PREWARM_INTERVAL_SECONDS/PREWARM_DELAY_MS/PREWARM_RETRY_DELAY_MSonVisit ile birlikte kullanılamaz (hata).
Mod: klasik — hooks.prewarmPaths()
Hangi yolların ısıtılacağını uygulama bildirir; genelde sitemap üreten fonksiyonun aynısıdır.
// jskelet.config.mjs
export default {
hooks: {
async prewarmPaths() {
const slugs = await getAllArticleSlugs();
return ["/", "/piyasalar", ...slugs.map((slug) => `/haber/${slug}`)];
},
},
};
Kurallar:
- Dizi döndürmezse uyarı basılır ve ısıtma yapılmaz.
- Yalnızca
/ile başlayan string'ler alınır. prewarmSkipöneklerinden biriyle başlayanlar atlanır. Varsayılan liste:/api/,/_fragment/,/__jskelet/. Oturuma bağlı sayfalar ısıtılmamalı.- Tekilleştirme sırayı korur:
priorityverilmediğinde uygulamanın verdiği sıra anlamlıdır — en önemli sayfaları başa koyun. - Bu hook tanımlı değilse klasik ısıtma hiç kurulmaz; zamanlayıcı bile açılmaz. (
onVisitmodunda hook yasaktır, yukarıya bakın.)
Tur mantığı (klasik)
- Liste toplanır.
max'tan (varsayılan 400) uzunsa bir dilim seçilir:priorityeşleşenler her turda başa alınır, kalan yerler kuyruktan doldurulur. concurrencyişçi paralel olarak istek atar (prod'da 4, dev'de 1). Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU için yarışmasın.rpsverilmişse tur bu hızın üstüne çıkmaz — paralellik ne olursa olsun. Dev'de varsayılan olarak saniyede 4 istek uygulanır: render tek bir olay döngüsünde çalıştığı için aralıksız bir tur, sayfa isteklerini ve dev panelinin canlı kanalını arkasında bekletiyor.- Geçici hata alan yollar için tek seri tekrar turu yapılır (
concurrency: 1).400/403/404gibi kalıcı cevaplar tekrar turuna girmez: deterministik bir hata tekrar denemekle düzelmez ve o çağrılar kotadan karşılıksız yer. ÖzetteN not retried (permanent)olarak görünür. - Bekleme süresi
retryDelayMs, ama hız freni açıkken freni bekleten süre varsa o kazanır: 10 saniye kapalı kalacak bir devre kesiciden 2 saniye sonra tekrar denemek, aynı 429'u peşin peşin almak olurdu. - Özet loglanır:
[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)
Ardından turun upstream'e ne kadar dokunduğu basılır:
[prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
Bu satır ayarın hangi yönde değişmesi gerektiğini söyleyen tek satır. Oran düşükse çözüm hız freni değil, withDataCache TTL'ini tur aralığından uzun tutmak: fren çağrıları yavaşlatır, sayısını azaltmaz. Aynı sayaçlara getDataCacheStats() ile ya da dev raporundaki Data cache kartından da bakılabilir.
Tur sırasında oluşan istek hataları ve sayfa başına basılan render uyarıları (was produced with missing data, returned notFound() while upstream is failing, could not be produced) tek tek loglanmaz; sayılır ve tur bitince özetin ardından, en sık görülen türler başta olacak şekilde basılır:
[prewarm] 137 problems were not logged individually:
94× missing data, upstream is failing permanently (403 /api/v1/polls)
37× missing data, upstream is failing permanently (400 /api/v1/posts)
6× 500 Cannot read properties of undefined (reading 'title')
Böylece bir anlık upstream arızası "warmed …" satırını yüzlerce yığın izinin altına gömmüyor. Gerçek trafiğin hataları eskisi gibi anında loglanır, tek bir yolun ayrıntısı için dev panelindeki Prewarming sekmesine bakılır.
Isıtma sırası: priority
// jskelet.config.mjs
cache: () => ({
prewarm: {
priority: [
"/",
"/piyasalar/:path*",
/-yorumlar$/,
],
},
}),
Desen sözdizimi (/haber/:slug) ve doğrudan RegExp birlikte kullanılabilir; ikincisi "sonu -yorumlar ile bitenler" gibi desen sözdiziminin karşılamadığı kurallar için. Önce yazılan önce ısınır, hiçbirine uymayan yollar kuyruğa gider ve kendi aralarındaki sırayı korur.
Damla damla ısıtma: rotate + rps + intervalSeconds
10.000 yolluk bir sitede tek turda her şeyi ısıtmak ne mümkün (HTML önbelleği 500 girdi tutar) ne de doğru (API kotası dolar). Doğru davranış listeyi zamana yaymak:
prewarm: {
max: 300, // her turda 300 sayfa
rps: 4, // saniyede en fazla 4 istek
intervalSeconds: 300, // 5 dakikada bir tur
rotate: true, // kuyruk kaldığı yerden devam eder
priority: ["/", "/piyasalar/:path*"],
}
Bu kurulumda öncelikli sayfalar her turda tazelenir, geri kalan kuyruk turlar boyunca baştan sona dolaşılır ve upstream saniyede dört isteğin üstünü hiç görmez. Veri önbelleği ile birlikte kullanıldığında ikinci turdan sonra ısıtma API'ye neredeyse hiç gitmez: veri katmanından okur.
Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki tura kalıyor; log bunu ayırt eder: … , 700 deferred to the next pass. rotate: false ile klasik davranışa dönülür — her tur listenin aynı ilk dilimini ısıtır ve gerisi hiç ısınmaz (… , 700 over the limit).
Bir tur intervalSeconds'tan uzun sürerse yeni tur başlatılmaz; üst üste binen turlar upstream'e iki kat yük bindirirdi.
İstekler user-agent: jskelet-prewarm (brand.prewarmUserAgent) ve accept-encoding: br, gzip başlıklarıyla gider; ikincisi sıkıştırılmış gövdenin de önbelleğe girmesi için.
DEV_TOKEN ayarlıysa ısıtma token'ı çerez olarak taşır; yoksa dev gate tüm sayfalara 404 döner ve önbellek hiç dolmaz.
Dev panelindeki istek listesi ve terminal, prewarmUserAgent taşıyan istekleri filtreler: yüzlerce ısıtma isteği görünümü doldurmasın. İlerleme baloncuğun yanındaki rozette görünür.
Zamanlama
- Isıtma açılışta gecikmeyle başlar: ilk gerçek isteklerle yarışmasın. Varsayılan gecikme prod'da 500 ms, dev'de 3000 ms. Dev'de daha uzun, çünkü dosya kaydı süreci yeniden başlattığı için zamanlayıcı da ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
PREWARM_INTERVAL_SECONDS/cache().prewarm.intervalSeconds> 0 ise tur periyodik tekrarlanır. Girdilerrevalidateile yaşlandığı ve stale-while-revalidate sayesinde ziyaretçi beklemediği için bu opsiyoneldir; hiç ziyaret edilmeyen sayfaları da sıcak tutmak isteyen kurulumlar için.- Tüm zamanlayıcılar
unref()edilmiştir: süreç kapanışını geciktirmezler. - Hiçbir ısıtma hatası süreci düşürmez.
Ayarlar
Öncelik sırası: ortam değişkeni → config → kod varsayılanı. Env önde, çünkü tek seferlik deneyler config'i düzenlemeden yapılabilsin.
| Ayar | Env | cache().prewarm | Varsayılan |
|---|---|---|---|
| Açık/kapalı | PREWARM=0 kapatır, PREWARM=1 config'i ezip açar | enabled | true |
| Tur başına en fazla yol | PREWARM_MAX | max | 400 |
| Paralellik | PREWARM_CONCURRENCY | concurrency | prod 4, dev 1 |
| Saniyedeki istek | PREWARM_RPS | rps | prod 0 (sınırsız), dev 4 |
| Başlangıç gecikmesi (ms) | PREWARM_DELAY_MS | delayMs | prod 500, dev 3000 |
| Tekrar turu gecikmesi (ms) | PREWARM_RETRY_DELAY_MS | retryDelayMs | 2000 |
| Periyot (saniye) | PREWARM_INTERVAL_SECONDS | intervalSeconds | 0 (kapalı) |
| Kuyruk rotasyonu | — | rotate | true |
| Isıtma sırası | — | priority | [] |
Sayısal ayarlar yalnızca pozitif ve sonlu değer kabul eder; geçersiz bir değer sessizce bir sonraki katmana düşer.
Elle tetikleme
import { prewarm, prewarmProgress } from "jskelet";
await prewarm({ origin: "http://127.0.0.1:3000" }); // hook'tan yollar
await prewarm({ origin, paths: ["/", "/piyasalar"] }); // yalnızca bu yollar
await prewarm({ origin, quiet: true }); // özet basmadan
paths verilirse hook hiç çağrılmaz. Dönüş değeri { ok, failed, total, elapsed }.
prewarmProgress canlı durumu tutar ve dev paneli bunu okur:
{
active, done, total, ok, failed, startedAt, finishedAt,
entries: [{ path, status, ms, bytes, cache, error }],
}
entries içindeki cache alanı o yolun X-JSkelet-Cache yanıtıdır; ısıtma turunun gerçekten MISS → önbellek doldurup doldurmadığını buradan görürsünüz.
Teşhis: sık görülen durumlar
- Her istek
MISSdönüyor. Route'arevalidateverilmemiş ya dacache().htmliçindeki desen 0 saniye veriyor. Veya sayfastatus: 200dışında bir kod dönüyor. - Sayfa
MISSdönüyor ama upstream sağlam. Geçici bir upstream hatası bildirilmiş olabilir; logdawas produced with missing data, not caching itsatırını arayın. - Sürekli eski veri.
revalidateçok yüksek; unutmayın ki gerçek gecikme en fazlarevalidate+ bir tazeleme turudur. - Önbellek şişiyor. Query parametreleri anahtara girdiği için kampanya parametreleri girdi çoğaltıyor olabilir.
- Yanlış dil / host HTML'i geliyor. Host'tan locale üreten bir sitede
cache().vary.host: trueyoksa ilk locale'in HTML'i diğer host'a servis edilir. Prewarm yalnızca127.0.0.1ile ısınıyorsaprewarm.originsile locale host'larını ekleyin. - Isıtma hiç çalışmıyor. Klasik modda
hooks.prewarmPathstanımlı değil,PREWARM=0ayarlı ya dacache().prewarm.enabled === false.onVisitmodundalistensonrası logdaonVisit modesatırını ve public cache'li bir sayfa gezildiğini doğrulayın. - Config
onVisit+max/prewarmPathsile düşüyor. İki mod karşılıklı dışlayıcı; yalnızca birini kullanın. - Isıtma turu API'yi 429'a sokuyor.
rpsverilmemiş.concurrencydüşürmek yeterli değil; kotayı koruyan ayar toplam hız. Kalıcı çözüm veri önbelleği: ikinci turdan sonra ısıtma upstream'e gitmez. - Isıtma listesi
max'tan uzun ve sonu hiç ısınmıyor.rotate: falseolabilir; logdakiover the limitifadesi bunu gösterir. - Bir bölümün tamamı 404 dönüyor. Upstream düşmüş olabilir. Artık bu durumda sayfa bir kez daha denenir, olmazsa 404 değil önbelleğe girmeyen 503 döner; logda
returned notFound() while upstream is failingsatırını arayın. Hâlâ 404 görüyorsanız hatafetchdışı bir istemciden geliyor olabilir (reportUpstreamFailure()gerekir) ya dacache().trackUpstreamkapatılmış.
Sırada ne var
- Config alanlarının tam referansı ve env tablosu: Yapılandırma
- Önbelleği dev panelinden izlemek: Dev araçları
- CDN/ters proxy ile birlikte kullanım: Dağıtım