jskelet 0.2.4 → 0.2.5

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.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +8 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1209
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1239
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -670
  40. package/src/client/cache-panel/login.html +74 -74
  41. package/src/client/cache-panel/panel.css +756 -756
  42. package/src/client/cache-panel/panel.html +308 -308
  43. package/src/client/cache-panel/panel.js +915 -915
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -759
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
package/docs/06-cache.md CHANGED
@@ -1,1209 +1,1209 @@
1
- # 06 — Önbellek ve prewarm
2
-
3
- Bu belge JSkelet'in ISR ikamesini bütün ayrıntılarıyla anlatır: HTML TTL
4
- önbelleği ve stale-while-revalidate davranışı, `revalidate`'in nereden geldiği,
5
- cache anahtarının nasıl kurulduğu, `X-JSkelet-Cache` başlığının değerleri,
6
- sıkıştırılmış gövdenin neden önbellekte durduğu, istek içi memoizasyon
7
- (`withRequestCache` / `cache()`), veri önbelleği (`withDataCache`), upstream
8
- hatalarının önbelleği nasıl etkilediği (otomatik izleme ve
9
- `reportUpstreamFailure`) ve sunucu açılışındaki ısıtma turu.
10
- Kararların arkasındaki ölçüm gerekçeleri [02-mimari.md](./02-mimari.md)'de,
11
- config alanlarının tam referansı [07-yapilandirma.md](./07-yapilandirma.md)'de.
12
-
13
- ## Genel resim
14
-
15
- ```
16
- route(controller, { revalidate })
17
- └─ withHtmlCache(key, ttl, producer) ← TTL + stale-while-revalidate
18
- └─ withUpstreamTracking(...) ← eksik veri tespiti
19
- └─ withRequestCache(...) ← istek içi memoizasyon
20
- └─ produce() → controller + renderPage
21
- └─ withDataCache(...) ← upstream veri önbelleği
22
- ```
23
-
24
- Sıra önemli: **istek içi cache en içte** olmalı ki aynı render'daki iki çağrı
25
- tek upstream isteğine düşsün; **upstream takibi HTML cache'in içinde** olmalı ki
26
- eksik veriyle üretilen çıktı önbelleğe yazılmasın.
27
-
28
- İki önbelleğin iş bölümü:
29
-
30
- | | HTML önbelleği | Veri önbelleği |
31
- | --- | --- | --- |
32
- | Ne tutar | Sayfanın tamamı (+ sıkıştırılmış gövdesi) | Upstream'den gelen JSON |
33
- | Girdi boyutu | ~100-200 kB | ~1-20 kB |
34
- | Girdi sınırı | 500 (`cache().maxEntries`) | 10.000 (`cache().data.maxEntries`) |
35
- | Kime yarar | Trafiği olan sayfalar: render bile edilmez | Uzun kuyruk: render edilir ama API'ye gidilmez |
36
-
37
- Bu ayrım pratikte şuna karşılık gelir: on binlerce yollu bir sitede sayfaların
38
- tamamını HTML olarak sıcak tutmak mümkün değil — 500 girdiyi aşan ısıtma kendi
39
- ısıttığını siler. Uzun kuyruk için hedef "HTML hazır olsun" değil, **"sayfayı
40
- üretecek veri API'ye gitmeden bulunsun"** olmalı. O zaman hiç ısıtılmamış bir
41
- sayfa da ilk ziyaretçide milisaniyeler içinde üretilir ve kota harcamaz.
42
-
43
- ## Public ve kişiye özel ayrımı
44
-
45
- Bu belgedeki her şey **herkese aynı gidebilen** HTML için geçerli. Cache
46
- anahtarında kimlik yok (yalnızca yol + query), yani önbellekteki bir sayfa onu
47
- ilk isteyen kişinin değil, o yolun cevabıdır.
48
-
49
- Kullanıcıya bağlı bir sayfa bu yüzden ayrı bir yoldan geçer:
50
-
51
- ```js
52
- app.get("/panel", route(async ({ req }) => { … }, { private: true }));
53
- ```
54
-
55
- `private: true` üç şeyi birden yapar: önbellek devre dışı kalır, config'in
56
- `cache.html` deseni bu kararı **ezemez** ve yanıt `private, no-store`,
57
- `Vary: Cookie` ile, ETag'siz gider. Ayrıntılar ve oturum/CSRF tarafı
58
- [12-panel-ve-oturum.md](./12-panel-ve-oturum.md)'de.
59
-
60
- Bayrağı unutursanız framework sessiz kalmaz: controller `Cookie`,
61
- `Authorization` ya da `req.session`/`req.user` okuduğu anda render işaretlenir
62
- ve önbelleğe **yazılmaz**. Dev'de istek bir hatayla düşer, üretimde `no-store`
63
- ile servis edilip loglanır. Koruma bir mazeret değil son savunma — doğru yer
64
- `private: true`.
65
-
66
- ## `revalidate` — TTL nereden gelir
67
-
68
- Bir route'un TTL'i iki kaynaktan gelebilir ve **config kazanır**:
69
-
70
- 1. `route(controller, { revalidate: 60 })` — route'un kendi süresi.
71
- 2. `jskelet.config.mjs` → `cache().html` içindeki eşleşen desen. Varsa
72
- route'unkini ezer.
73
-
74
- Tek istisna `private: true`: desen eşleşse bile yok sayılır. Kilit tek yönlü,
75
- çünkü ters yönde bir hata sessiz veri sızıntısı anlamına geliyor.
76
-
77
- ```js
78
- // jskelet.config.mjs
79
- export default {
80
- async cache() {
81
- return {
82
- html: {
83
- "/": 60,
84
- "/haber/:slug": 300,
85
- "/etiket/:slug": 120,
86
- },
87
- };
88
- },
89
- };
90
- ```
91
-
92
- Config ile ezme, tek bir dosyadan tüm sitenin tazelik profilini ayarlamayı
93
- mümkün kılar; route dosyalarını dolaşmak gerekmez.
94
-
95
- Çözüm sonucu **yol başına hatırlanır**, böylece her istekte desen taraması
96
- yapılmaz. Hiç `cache().html` kuralı yoksa doğrudan route'un değeri kullanılır.
97
-
98
- `revalidate` verilmemişse ya da 0 ise sayfa **hiç önbelleklenmez**: her istek
99
- render edilir ve yanıt `Cache-Control: private, no-store` ile, ETag'siz gider.
100
- `X-JSkelet-Cache` başlığı da yazılmaz — önbellek yolu hiç çalışmadı, `MISS`
101
- demek yanıltıcı olurdu.
102
-
103
- Dinamik bir sayfaya `no-store` yazılması bilinçli. Hiç direktif taşımayan bir
104
- yanıtı HTTP "sezgisel olarak önbelleklenebilir" sayıyor; araya giren bir proxy
105
- ya da tarayıcının geri tuşu, tek bir ziyaretçi için üretilmiş HTML'i
106
- saklayabiliyordu.
107
-
108
- Önbellek ayrıca yalnızca `GET` istekleri için devreye girer.
109
-
110
- ## Cache anahtarı
111
-
112
- ```
113
- `${yol}?${izin verilen query parametreleri, sıralı}`
114
- ```
115
-
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.
121
-
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).
137
-
138
- ## Stale-while-revalidate
139
-
140
- Girdi yapısı:
141
-
142
- ```
143
- expiresAt = now + ttl
144
- staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
145
- ```
146
-
147
- Okuma davranışı:
148
-
149
- | Durum | Yanıt | Arka plan |
150
- | --- | --- | --- |
151
- | `now < expiresAt` | Önbellekteki HTML, `HIT` | — |
152
- | `expiresAt ≤ now < staleUntil` | Önbellekteki HTML **anında**, `STALE` | Tazeleme başlatılır |
153
- | `now ≥ staleUntil` | Girdi silinir, taze render, `MISS` | — |
154
-
155
- Stale penceresinde tazelemenin hatası isteği etkilemez: eski HTML pencere
156
- boyunca geçerli kalır ve hata yalnızca loglanır
157
- (`[html-cache] background refresh failed: …`).
158
-
159
- Aynı anahtar için eşzamanlı tazelemeler tek bir çalışmada birleştirilir
160
- (`inflight` haritası): yüz eşzamanlı istek tek render'a düşer.
161
-
162
- Kazanç: ilk ısıtmadan sonra hiçbir istek render'ı beklemez. Bedel: HTML'deki
163
- veri en fazla `revalidate + bir tazeleme turu` kadar geride olabilir. Bu bedel
164
- kabul edilebilir, çünkü fiyat gibi canlı alanlar istemcide WebSocket'ten
165
- güncelleniyor.
166
-
167
- Store LRU'dur: erişilen girdi sona taşınır, sınır (`cache().maxEntries`,
168
- varsayılan 500) aşılınca en eski düşürülür.
169
-
170
- ## Ne önbelleğe yazılır
171
-
172
- Yalnızca şu iki koşulun **ikisini** birlikte sağlayan çıktı saklanır:
173
-
174
- 1. `status === 200`
175
- 2. `degraded !== true` — render sırasında geçici bir upstream hatası
176
- bildirilmemiş.
177
-
178
- Yani 404 sayfaları, redirect'ler ve eksik veriyle üretilmiş HTML önbelleğe
179
- girmez.
180
-
181
- ## Yanıt başlıkları
182
-
183
- `route()` her yanıta `X-JSkelet-Cache` yazar (başlık adı `brand.cacheHeader`
184
- ile değiştirilebilir):
185
-
186
- | Değer | Anlamı |
187
- | --- | --- |
188
- | `HIT` | Önbellekten, taze |
189
- | `STALE` | Önbellekten, süresi geçmiş; arkada tazeleniyor |
190
- | `MISS` | Bu istekte render edildi (ya da önbellek kapalı) |
191
-
192
- Önbelleklenebilir yanıtlarda ayrıca:
193
-
194
- ```
195
- Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
196
- ```
197
-
198
- `max-age=0` tarayıcıda saklamayı kapatır, `s-maxage` ara katmanlara (CDN, ters
199
- proxy) süreyi bildirir. Böylece CDN önünde durduğunda aynı tazelik modeli iki
200
- katmanda birlikte çalışır.
201
-
202
- ## Sıkıştırılmış gövdenin saklanması
203
-
204
- Önbelleğe alınan her girdi bir `encoded` haritası taşır ve HTML ile aynı ömrü
205
- paylaşır. Bir sayfa ilk kez brotli ya da gzip ile istendiğinde çıktı hesaplanıp
206
- haritaya konur; sonraki isteklerde aynı buffer gönderilir. Aynı sayfa her
207
- istekte yeniden brotli'lenmez.
208
-
209
- Bu yolda `Content-Encoding`, `Vary` ve `Content-Length` doğrudan `route()`
210
- tarafından yazılır; sıkıştırma middleware'i `Content-Encoding` gördüğü için
211
- devreye girmez.
212
-
213
- `HEAD` istekleri sıkıştırılmaz (gövde yok). İstemci ne brotli ne gzip kabul
214
- ediyorsa düz HTML gönderilir.
215
-
216
- ## İstek içi memoizasyon: `cache()`
217
-
218
- React'in `cache()` fonksiyonunun karşılığı: aynı istek içinde aynı argümanlarla
219
- yapılan çağrılar tek kez çalışır.
220
-
221
- ```js
222
- // lib/api/articles.js
223
- import { cache } from "jskelet";
224
-
225
- export const getArticle = cache(async (slug) => {
226
- const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
227
- return response.json();
228
- });
229
- ```
230
-
231
- Artık aynı render'da hem controller hem `hooks.layoutContext()` aynı yazıyı
232
- isterse tek upstream isteği yapılır.
233
-
234
- Ayrıntılar:
235
-
236
- - Bağlam `AsyncLocalStorage` ile taşınır ve `route()` içinde
237
- `withRequestCache()` tarafından kurulur.
238
- - **Bağlam yoksa memoizasyon devre dışı kalır** ve fonksiyon doğrudan çağrılır.
239
- Bir script'ten ya da başka bir sürecin içinden çağırmak güvenlidir.
240
- - Anahtar `JSON.stringify(args)`; argümansız çağrılar `""` anahtarını
241
- paylaşır. Serileştirilemeyen argümanlar (fonksiyon, `Symbol`, döngüsel nesne)
242
- ile kullanmayın.
243
- - Saklanan şey fonksiyonun **dönüş değeridir**, yani `async` fonksiyonlarda
244
- Promise'in kendisi. Aynı Promise paylaşıldığı için eşzamanlı çağrılar da
245
- birleşir.
246
- - `withRequestCache(run)` dışa açıktır; `route()` dışında (ör. kendi yazdığınız
247
- bir Express handler'ında) aynı kapsamı kurmak için kullanılabilir.
248
-
249
- ## İstekler arası veri önbelleği: `withDataCache`
250
-
251
- `cache()` yalnızca **tek bir istek** boyunca yaşar. Uzun kuyruğu API kotasından
252
- korumak için gereken şey istekler arasında yaşayan, TTL'li ve kendi kendini
253
- tazeleyen bir veri katmanı:
254
-
255
- ```js
256
- // lib/api/articles.js
257
- import { withDataCache, reportUpstreamFailure } from "jskelet";
258
-
259
- export async function getArticle(slug) {
260
- return withDataCache(`haber:${slug}`, 600, async () => {
261
- const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
262
-
263
- if (!response.ok) {
264
- reportUpstreamFailure({ status: response.status, path: `/articles/${slug}` });
265
- return null;
266
- }
267
-
268
- return response.json();
269
- });
270
- }
271
- ```
272
-
273
- Aynı kalıbın sarmalayıcı biçimi — anahtar argümanlardan üretilir:
274
-
275
- ```js
276
- import { dataCache } from "jskelet";
277
-
278
- export const getArticle = dataCache(
279
- async (slug) => apiGet(`/articles/${slug}`),
280
- { key: "haber", revalidate: 600 },
281
- );
282
- ```
283
-
284
- Davranış:
285
-
286
- | Durum | Sonuç |
287
- | --- | --- |
288
- | Taze girdi | Anında döner, `producer` çalışmaz |
289
- | TTL geçmiş, bayat pencere sürüyor | Bayat değer **anında** döner, tazeleme arkada yürür |
290
- | Girdi yok | `producer` beklenir |
291
- | `producer` hata verdi, bayat girdi var | Bayat değer döner, uyarı: `[data-cache] producer failed, serving stale value: …` |
292
- | `producer` hata verdi, girdi yok | Hata çağırana gider |
293
-
294
- Ayrıntılar:
295
-
296
- - **Aynı anahtarı eşzamanlı isteyen çağrılar tek upstream isteğine düşer.**
297
- Isıtma turlarında kotayı en çok kurtaran davranış bu: 50 sayfa aynı endeks
298
- verisini istiyorsa API bir kez çağrılır.
299
- - **`null` ve `undefined` saklanmaz.** Uygulamaların HTTP istemcisi hatada
300
- genellikle `null` döner; bunu saklamak geçici bir 429'u TTL boyunca "veri yok"
301
- hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
302
- `{ storeEmpty: true }` verir.
303
- - **Bayat pencere HTML'dekinden uzun**: `staleFactor` varsayılanı 10, yani girdi
304
- TTL'inin 11 katı boyunca acil durum yedeği olarak kalır. Bayat veri, eksik
305
- sayfadan iyidir. Anahtar başına `{ staleFactor: 0 }` ile kapatılabilir.
306
- - Anahtar tamamen uygulamanın: dil, sürüm, sayfa numarası gibi ayrımlar anahtara
307
- yazılır (`haber:tr:v2:${slug}`).
308
- - TTL `0` verildiğinde önbellek devre dışı kalır ve `producer` her çağrıda
309
- çalışır — bir ayarı geçici olarak kapatmak için yeterli.
310
-
311
- Yönetim yüzeyi:
312
-
313
- | Fonksiyon | Ne yapar |
314
- | --- | --- |
315
- | `withDataCache(key, ttlSeconds, producer, options?)` | Ana giriş noktası |
316
- | `dataCache(fn, { key, revalidate, … })` | Fonksiyon sarmalayıcısı |
317
- | `clearDataCache(prefix?)` | Önek eşleşen girdileri (ya da tümünü) düşürür, silinen sayısını döner |
318
- | `getDataCacheSize()` | Girdi sayısı |
319
- | `getDataCacheEntries()` | Döküm: `{ key, stale, expiresIn }`. Değerin kendisi dönmez. |
320
-
321
- `clearDataCache("haber:")`, "bu içerik güncellendi" webhook'unun karşılığıdır:
322
- tek bir bölümün verisini düşürür ve **o veriyi okumuş HTML sayfalarını da**
323
- bayatlatır, yani güncelleme TTL beklenmeden görünür. Ayrıntısı aşağıda,
324
- "Otomatik bağımlılık" bölümünde.
325
-
326
- ## Degraded render: `reportUpstreamFailure`
327
-
328
- Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir. Böyle bir
329
- HTML'i tüm TTL boyunca servis etmek yerine önbelleğe **hiç yazmamak** doğru
330
- davranış: sonraki istek yeniden dener.
331
-
332
- Bu bilgi iki yoldan gelir.
333
-
334
- ### Otomatik izleme (varsayılan)
335
-
336
- `createApp()` açılışta `globalThis.fetch`i sarar ve render sırasında yapılan
337
- çağrılardaki **geçici** hataları (`429`, `5xx`, ağ hatası) kendiliğinden
338
- bildirir. Uygulama tarafında hiçbir satır gerekmez; `fetch` ile konuşan bir API
339
- istemcisi varsa rate limit koruması hazırdır.
340
-
341
- Ayrıntılar:
342
-
343
- - Yalnızca bir render bağlamı içindeki çağrılar sayılır. Script'ten, cron'dan
344
- ya da istek dışı bir yerden yapılan `fetch` dokunulmaz kalır.
345
- - Kendi sunucumuza yapılan istekler (`localhost`, `127.0.0.1`) atlanır: ısıtma
346
- turu ve sağlık kontrolü upstream değildir.
347
- - `404`/`403` gibi deterministik cevaplar **otomatik olarak bildirilmez**. Çoğu
348
- API'de `404` "böyle bir kayıt yok" demektir; onu eksik veri saymak her yok
349
- sayfasında yanlış uyarı üretirdi.
350
- - Kapatmak için `cache().trackUpstream: false`. `fetch`i kendisi saran bir
351
- uygulama (ölçüm, retry, circuit breaker) bunu tercih edebilir.
352
-
353
- ### Elle bildirim
354
-
355
- `fetch` kullanmayan bir istemci (veritabanı sürücüsü, gRPC, satıcı SDK'sı) ya da
356
- kalıcı hataları da işaretlemek isteyen bir katman için sözleşme aynı kaldı.
357
- Bağımlılık yönü bilinçli olarak tersine çevrilmiştir: framework veri katmanını
358
- tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet boş
359
- bir dizidir. Aynı hata her iki yoldan bildirilirse tekilleştirilir.
360
-
361
- ```js
362
- // lib/api/client.js
363
- import { reportUpstreamFailure } from "jskelet";
364
-
365
- export async function apiGet(path) {
366
- try {
367
- const response = await fetch(`${process.env.API_ORIGIN}${path}`);
368
-
369
- if (!response.ok) {
370
- reportUpstreamFailure({ status: response.status, path });
371
- return null;
372
- }
373
-
374
- return response.json();
375
- } catch (error) {
376
- // Yanıt hiç gelmedi: status 0 ağ hatası anlamına gelir.
377
- reportUpstreamFailure({ status: 0, path });
378
- return null;
379
- }
380
- }
381
- ```
382
-
383
- ### Geçici ve kalıcı hata ayrımı
384
-
385
- | Durum | Sayılır | Sonuç |
386
- | --- | --- | --- |
387
- | `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 (…)` |
388
- | Diğerleri (`400`, `403`, `404`, …) | **Kalıcı** | Yalnızca uyarı: `[render] <path> was produced with missing data, upstream is failing permanently (…)`. Önbellek engellenmez. |
389
-
390
- Kalıcı hataların önbelleği engellememesi bilinçli: deterministik cevaplar tekrar
391
- denemekle düzelmez. Onlar yüzünden önbelleği kapatmak sayfayı her ziyarette
392
- baştan render etmek olur — içerik yine aynı eksik hâliyle döner, ziyaretçi
393
- sadece render süresini öder.
394
-
395
- Eksik veriyle üretilen çıktı **paylaşılan önbelleklere de sunulmaz**: `degraded`
396
- bir yanıt `public, s-maxage=…` değil `private, no-store` alır. Süreç içi
397
- önbelleğe yazmama kararını CDN'de geri almak, aynı hatayı bir katman yukarıda
398
- tekrarlamak olurdu. Teşhis başlığı (`X-JSkelet-Cache: MISS`) yine yazılır.
399
-
400
- ### `notFound()` geçici hataya denk gelirse
401
-
402
- Veri gelmediği için `notFound()` çağıran bir controller, upstream rate limit'e
403
- girdiğinde tüm siteyi 404'e çevirebilir — ve bu 404'ler önbelleğe girdiği için
404
- geçici bir kota sorunu TTL boyunca "bu sayfa yok" cevabına dönüşür. Arama motoru
405
- için bu kalıcı bir kayıp.
406
-
407
- Framework bu durumu ayırır: render sırasında **geçici** bir upstream hatası
408
- varsa `notFound()` 404 olarak servis edilmez. Sırayla:
409
-
410
- 1. Sayfa kısa bir beklemeden sonra **yeniden denenir** (varsayılan bir kez,
411
- 300 ms sonra). Deneme kendi upstream ve istek içi cache bağlamında koşar;
412
- ilk turun hatası da memoize edilmiş boş cevabı da ikinci turu etkilemez.
413
- 2. İkinci tur sayfayı üretebilirse ziyaretçi **gerçek içeriği** görür ve çıktı
414
- normal şekilde önbelleğe girer. Isıtma günlükleri bunun sık olduğunu
415
- gösteriyor: aynı yol saniyeler sonra 200 dönüyor.
416
- 3. Denemeler tükendiyse yanıt `503` olur — önbelleğe girmez, `Retry-After`
417
- taşır, sonraki istek yine gerçek içeriği üretebilir.
418
-
419
- | Render sırasında | `notFound()` sonucu |
420
- | --- | --- |
421
- | Geçici hata var (`429`, `5xx`, ağ hatası) | Tekrar dene → başarılıysa sayfa; hâlâ olmuyorsa `503`, `Retry-After: 30`, `no-store` |
422
- | Tekrar denemede upstream sağlam cevap verip "yok" dedi | Normal `404` |
423
- | Kalıcı hata var (`404`, `403`…) ya da hata yok | Normal `404`, tekrar denenmez |
424
-
425
- Log satırları:
426
-
427
- ```
428
- [render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
429
- [render] /haber/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
430
- ```
431
-
432
- Yani **var olan bir sayfa hiçbir koşulda 404'e dönüşmez**: ya gerçek içerik
433
- gelir, ya önbelleğe girmeyen bir 503. Hiçbir şey "yok" olarak dondurulmaz.
434
-
435
- Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turudur; bu yüzden
436
- varsayılan tek deneme. Ayar `cache().transientRetry`:
437
-
438
- ```js
439
- cache: {
440
- transientRetry: { attempts: 2, delayMs: 500 },
441
- }
442
- ```
443
-
444
- `transientRetry: false` (ya da `attempts: 0`) tekrarı kapatır ve doğrudan 503'e
445
- düşer.
446
-
447
- ## Upstream hız freni: `cache().upstream`
448
-
449
- Buraya kadarki her şey 429 **geldikten sonra** ne olacağını anlatıyor. Bu bölüm
450
- 429'u en baştan almamakla ilgili.
451
-
452
- Fren `trackUpstreamFetch()` sarmalayıcısının içinde, yani gerçek `fetch`
453
- çağrısının geçtiği yerde duruyor. Isıtma turundaki `prewarm.rps` bu işi
454
- yapamaz: o, kendi sunucumuza atılan **sayfa** isteklerini sayıyor, ama bir
455
- sayfa render'ı bir API çağrısı da yapabilir yirmi tane de. Kotayı bağlayan şey
456
- sayfa sayısı değil, çağrı sayısı — ve fren buraya konduğunda ısıtma da gerçek
457
- trafik de aynı bütçeden harcar.
458
-
459
- Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez ve maliyet bir
460
- daldan ibarettir.
461
-
462
- ```js
463
- // jskelet.config.mjs
464
- cache: () => ({
465
- upstream: {
466
- rate: 10, // saniyedeki tavan (host başına)
467
- burst: 20, // kısa patlama toleransı
468
- concurrency: 8, // aynı anda uçan çağrı
469
- hosts: {
470
- // Kotası farklı olan uçlar ayrı ayarlanır.
471
- "api.example.com": { rate: 3, concurrency: 2 },
472
- },
473
- },
474
- }),
475
- ```
476
-
477
- ### Üç mekanizma, üç farklı sınır
478
-
479
- | Mekanizma | Neyi sınırlar | Ayar |
480
- | --- | --- | --- |
481
- | Token bucket | Ortalama hız (çağrı/saniye) | `rate`, `burst` |
482
- | Eşzamanlılık | Anlık baskı (aynı anda uçan çağrı) | `concurrency` |
483
- | AIMD | Doğru hızın ne olduğu | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
484
-
485
- Üçüncüsü asıl fikir. Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır:
486
- kotanın gerçek sınırını kimse config'e doğru yazamaz, üstelik gün içinde
487
- değişir. Bu yüzden `rate` bir **tavan** olarak alınır ve gerçek hız upstream'in
488
- cevabına göre oynar:
489
-
490
- - **429 ya da 503** → hız yarıya iner (çarpımsal azalma). Yanıt `Retry-After`
491
- taşıyorsa kova o süre boyunca tamamen durur — upstream sana ne kadar
492
- bekleyeceğini zaten söylüyor.
493
- - **Temiz geçen her pencere** → hız `increaseStep` kadar yukarı çıkar
494
- (toplamsal artış), `rate` tavanına kadar.
495
-
496
- Azalmanın çarpımsal, artışın toplamsal olması bilinçli. Tersi olsaydı her
497
- pencerede yeniden 429 yenirdi.
498
-
499
- ### Devre kesici
500
-
501
- Art arda `breakerFailures` (varsayılan 5) tane 429 alan bir host
502
- `breakerCooldownMs` süresince tamamen baypas edilir: çağrı hiç yapılmaz,
503
- doğrudan geçici hata olarak bildirilir.
504
-
505
- Sert görünüyor ama asimetri bunu gerektiriyor: 429 geçici sayıldığı için o
506
- çağrıyla üretilen HTML **önbelleğe yazılmaz**. Yani rate limit'e girmiş bir
507
- turda kota harcanır ve karşılığında hiçbir şey saklanmaz. Üstüne bir sonraki
508
- tur aynı sayfayı yine soğuk bulup yine dener. Kesici bu boşa yanmayı kesiyor.
509
-
510
- ```
511
- [upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
512
- ```
513
-
514
- Yalnızca 429 ve 503 sayılır. `400`/`404` bir kota sorunu değil, `500` de öyle:
515
- onlar için yavaşlamak arızayı düzeltmez, sadece siteyi yavaşlatır.
516
-
517
- ### Durumu görmek
518
-
519
- `getUpstreamLimiterStatus()` host başına o anki hızı, uçuştaki çağrıyı ve
520
- sayaçları döner; dev panelinin **Server** sekmesi de aynı bilgiyi basıyor.
521
- 429 fırtınasında "şu an saniyede kaça indi" bilgisi olmadan ayar yapmak
522
- körlemesine olur.
523
-
524
- ```js
525
- import { getUpstreamLimiterStatus } from "jskelet";
526
-
527
- // [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
528
- // active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
529
- ```
530
-
531
- ### Freni açmadan önce
532
-
533
- Hız freni son çare. Aynı upstream yanıtını yüzlerce sayfa çekiyorsa asıl çözüm
534
- [`withDataCache`](#istekler-arası-veri-önbelleği-withdatacache) TTL'ini tur
535
- aralığından uzun tutmak: 400 sayfalık bir tur, ortak bir uç için tek çağrı
536
- yapar. Fren o çağrıları yavaşlatır, sayısını azaltmaz.
537
-
538
- ## Önbelleği yönetmek
539
-
540
- `jskelet` şu fonksiyonları dışa açar:
541
-
542
- | Fonksiyon | Ne yapar |
543
- | --- | --- |
544
- | `withHtmlCache(key, ttlSeconds, producer)` | Önbelleği doğrudan kullanmak için. `ttlSeconds` 0 ise producer her zaman çalışır. |
545
- | `invalidateHtmlCache(target, options?)` | Eşleşen sayfaları bayatlatır (ya da `{ hard: true }` ile düşürür), etkilenen sayı döner. |
546
- | `clearHtmlCache()` | Store'u tamamen boşaltır. |
547
- | `getHtmlCacheSize()` | Girdi sayısı. |
548
- | `getHtmlCacheEntries()` | Döküm: `{ key, bytes, status, stale, expiresIn, encodings, deps }`. HTML gövdesi dönmez, yalnızca boyutu. |
549
-
550
- ### Hedefli invalidation
551
-
552
- TTL'i beklemekle tüm önbelleği boşaltmak arasındaki boşluğu `invalidateHtmlCache()`
553
- doldurur:
554
-
555
- ```js
556
- import { invalidateHtmlCache } from "jskelet";
557
-
558
- invalidateHtmlCache("/haber/abc"); // o yol ve altı
559
- invalidateHtmlCache("/haber/:slug"); // desen sözdizimi
560
- invalidateHtmlCache([/-yorumlar$/, "/"]); // RegExp ve liste
561
- ```
562
-
563
- Varsayılan davranış **bayatlatmaktır**, silmek değil: girdi süresi geçmiş
564
- sayılır ve normal stale-while-revalidate yoluna düşer. Bir webhook beş yüz
565
- sayfayı birden düşürdüğünde sert silme, tam da içeriğin güncellendiği anda beş
566
- yüz soğuk render başlatır ve upstream'i döver. Bayatlatmada ziyaretçi eski
567
- HTML'i beklemeden alır, tazeleme arkada ve anahtar başına tek seferde koşar.
568
- Eski HTML'in gerçekten geçersiz olduğu durumlar için `{ hard: true }`.
569
-
570
- Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir yolun
571
- bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer. Düz
572
- string'te önek segment sınırında kesilir — `/haber` kuralı `/haberler`i
573
- etkilemez.
574
-
575
- Uçuşta olan bir render de hedeflenir: purge'den önce başlamış bir tur, sonucu
576
- artık eski veriyi taşıdığı için önbelleğe **yazılmaz** ve bir sonraki istek yeni
577
- bir tur başlatır.
578
-
579
- ### Otomatik bağımlılık: `clearDataCache` HTML'i de tazeler
580
-
581
- Hangi sayfanın hangi içerikten etkilendiğini bildirmek gerekmez. Render
582
- sırasında okunan her `withDataCache` anahtarı kaydedilir; `clearDataCache()` bir
583
- anahtarı düşürdüğünde onu **fiilen okumuş** bütün HTML girdileri bayatlar.
584
-
585
- ```js
586
- // "bu haber güncellendi" webhook'u
587
- clearDataCache(`haber:${slug}`);
588
- ```
589
-
590
- Bu tek satır haber detayını, o haberi listeleyen ana sayfayı ve etiket
591
- sayfasını birlikte tazeler — çünkü üçü de o anahtarı okumuştu. Elle tag'lemede
592
- en sık yapılan hata (detayı işaretleyip listeyi unutmak) burada yapısal olarak
593
- mümkün değil: bildirim değil, gözlem var.
594
-
595
- Ayrıntılar:
596
-
597
- - Bağımlılık **her tazelemede yeniden** toplanır; sayfanın okuduğu anahtarlar
598
- zamanla değişebilir.
599
- - Render sürerken gelen bir purge de yakalanır: o turun çıktısı "doğduğu anda
600
- bayat" olacağı için önbelleğe yazılmaz.
601
- - Sayfa başına bağımlılık sayısı `getHtmlCacheEntries()` dökümünde `deps`
602
- alanında görünür. Bir invalidation beklediğiniz sayfayı tazelemiyorsa önce
603
- buraya bakın: sayfa o veriyi `withDataCache` üzerinden okumuyor olabilir.
604
- - `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yoktur;
605
- `cache().trackDependencies: false` ile izleme tamamen kapatılabilir.
606
- - Bayatlatılan yollar ısıtma kuyruğunun **başına** alınır. `prewarm` kuruluysa
607
- sayfa, ziyaretçi gelmesini beklemeden tazelenir ve tur özeti bunu ayırt eder:
608
- `[prewarm] warmed 12/12 pages, 3 invalidated (0.4s)`.
609
-
610
- Bir yönetim ucu yazmak için:
611
-
612
- ```js
613
- import { clearHtmlCache, getHtmlCacheEntries } from "jskelet";
614
-
615
- export default function register(app) {
616
- app.post("/_admin/cache/temizle", (req, res) => {
617
- if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
618
- res.status(404).end();
619
- return;
620
- }
621
- clearHtmlCache();
622
- res.json({ ok: true });
623
- });
624
-
625
- app.get("/_admin/cache", (req, res) => {
626
- res.json(getHtmlCacheEntries());
627
- });
628
- }
629
- ```
630
-
631
- Dev sunucusu ayrıca manifest her değiştiğinde önbelleği kendiliğinden temizler:
632
- saklanan HTML eski hash'li varlık URL'lerini taşıyor olur ve temizlenmezse sayfa
633
- silinmiş dosyayı istemeye devam eder ([09-dev-araclari.md](./09-dev-araclari.md)).
634
-
635
- Önbellek süreç belleğinde yaşadığı için birden fazla süreç/kopya çalıştırıyorsanız
636
- her birinin kendi önbelleği olur; `clearHtmlCache()` yalnızca çağrıldığı süreci
637
- etkiler. Birden fazla instance çalıştıran bir kurulumda bunu aşmanın yolu bir
638
- sonraki bölümde.
639
-
640
- ## Paylaşımlı önbellek: Redis
641
-
642
- Varsayılan önbellek tek prosese ait. Bu, tek instance çalışan bir sitede en hızlı
643
- ve en basit kurulum — ama üç kopya çalıştırdığınızda iki sorun çıkar:
644
-
645
- 1. **Her kopya kendi başına ısınır.** Yeni bir instance açıldığında ya da bir
646
- deploy sonrası konteyner yenilendiğinde önbellek boştur; aynı sayfa üç kez
647
- render edilir, aynı veri üç kez çekilir.
648
- 2. **Invalidation tek kopyaya ulaşır.** `invalidateHtmlCache()` çağıran webhook
649
- yalnızca isteği alan instance'ı tazeler; diğerleri TTL'i bekler. Ziyaretçi
650
- hangi kopyaya düştüğüne göre eski ya da yeni içeriği görür.
651
-
652
- `cache().redis` bu iki sorunu çözer. Redis **birincil store olmaz**: bellek içi
653
- önbellek (L1) aynen kalır ve her istek onu okur; Redis ikinci kademedir (L2).
654
-
655
- ```js
656
- // jskelet.config.mjs
657
- export default {
658
- cache() {
659
- return {
660
- html: { "/haber/:slug": 300 },
661
- redis: {
662
- enabled: true,
663
- url: process.env.REDIS_URL,
664
- namespace: "haber-sitesi",
665
- },
666
- };
667
- },
668
- };
669
- ```
670
-
671
- `ioredis` opsiyonel bir peer bağımlılıktır, uygulamanın kendisine kurulur:
672
-
673
- ```bash
674
- npm install ioredis
675
- ```
676
-
677
- Kurulmadıysa ya da Redis'e bağlanılamıyorsa uyarı basılır ve site **bellek içi
678
- önbellekle çalışmaya devam eder**. Redis çalışırken düşerse aynı şey olur: bir
679
- devre kesici art arda beş hatadan sonra katmanı beş saniye baypas eder, böylece
680
- her istek ağ zaman aşımı beklemez.
681
-
682
- ### Ne kazanırsınız
683
-
684
- - **Soğuk instance sıcak önbellek bulur.** L1'de olmayan bir yol için render
685
- çalışmadan önce Redis okunur; başka bir kopya o sayfayı ürettiyse render hiç
686
- çalışmaz.
687
- - **Veri önbelleği kotayı bir kez harcar.** `withDataCache` aynı mantıkla
688
- çalışır ve kazanç burada daha büyük: JSON küçük, bir kopyanın çektiği veri
689
- hepsine yeter.
690
- - **Invalidation her kopyaya gider.** `invalidateHtmlCache()`,
691
- `clearHtmlCache()` ve `clearDataCache()` bir pub/sub kanalına mesaj bırakır;
692
- her instance kendi L1'ine aynı işlemi uygular. Deseni yayınlar, eşleşen
693
- anahtarları değil — hangi yolun nerede sıcak olduğu kopyaya bağlı.
694
-
695
- ### Anahtar düzeni
696
-
697
- ```
698
- _jskelet:{namespace}:{buildId}:html:{yol}?{query}
699
- _jskelet:{namespace}:{buildId}:data:{anahtar}
700
- _jskelet:{namespace}:events
701
- ```
702
-
703
- `buildId` her build'de değişir (`jskelet build` bunu `.jskelet/build.json`
704
- dosyasına yazar) ve **zorunlu bir parçadır**: saklanan HTML hash'li varlık
705
- yollarını gömüyor, yani bir deploy'dan sonra eski HTML geçersizdir. Kimlik
706
- önekte durduğu için yeni sürüm kendiliğinden yeni bir isim alanına yazar, eski
707
- anahtarlar TTL ile ölür — elle temizlik ya da `FLUSHDB` gerekmez. Build
708
- çalıştırılmadıysa kimlik `dev` olur.
709
-
710
- `namespace` aynı Redis'i paylaşan birden fazla uygulamayı ayırır. Olay kanalı
711
- bilinçli olarak `buildId` **taşımaz**: deploy sırasında eski ve yeni sürüm yan
712
- yana koşuyor ve bir purge ikisine de ulaşmalı.
713
-
714
- ### Bilmeniz gereken takaslar
715
-
716
- - **Kişiye özel çıktı paylaşılmaz.** `storable: false` işaretlenen bir render
717
- (cookie/`Authorization` okuyan sayfa) Redis'e hiç yazılmaz. Tek prosesteyken
718
- bile geçerli olan bu kural paylaşımlı kademede daha da kritik: sızması, bir
719
- kullanıcının HTML'ini tüm kümeye servis etmek olur. `degraded` render ve 200
720
- dışındaki durum kodları da paylaşılmaz.
721
- - **Sıkıştırılmış gövdeler varsayılan olarak yerel kalır.** `storeEncoded: true`
722
- ile açılabilir, ama girdi başına boyutu iki-üç katına çıkarır; brotli'yi
723
- yeniden üretmek çoğu zaman Redis'ten indirmekten ucuzdur.
724
- - **Yumuşak invalidation Redis kopyasını siler.** Bayatlatmanın Redis karşılığı
725
- her anahtar için oku-değiştir-yaz turu demek ve bir webhook binlerce anahtarı
726
- birden düşürüyor. Silmenin bedeli, o yolu hiç görmemiş bir kopyanın bir kez
727
- render etmesi; L1'i sıcak olan kopyalar eski HTML'i bayat pencerede servis
728
- etmeye devam eder.
729
- - **Yalnızca taze girdi kabul edilir.** Bayat bir kopyayı L1'e almak tazelemeyi
730
- sonsuza kadar ertelerdi: girdi bayat kalır, her tur yine Redis'i okur ve
731
- render hiç çalışmaz.
732
- - **Tutarlılık nihai.** Bir purge ile o purge'ün her kopyaya ulaşması arasında
733
- kısa bir pencere var. Bu pencerede bir kopya eski HTML'i servis edebilir;
734
- süresi TTL ile sınırlı.
735
- - **Dev'de kapalı tutun.** Dev sunucusu manifest her değiştiğinde önbelleği
736
- boşaltıyor; paylaşımlı bir store bunu anlamsızlaştırır. `enabled` yalnızca
737
- açıkça `true` verildiğinde açılır.
738
-
739
- ### Durumu görmek
740
-
741
- ```js
742
- import { getRedisStatus } from "jskelet";
743
-
744
- app.get("/api/healthcheck", (req, res) => {
745
- res.json({ ok: true, cache: getRedisStatus() });
746
- });
747
- ```
748
-
749
- Bağlantı kurulmamışken de güvenle çağrılır. Dönen nesne
750
- `{ enabled, connected, keyPrefix, buildId, errors, bypassed }`; `bypassed` devre
751
- kesicinin açık olduğunu, `errors` toplam komut hatasını gösterir. Aynı özet dev
752
- panelinin raporunda da var ([09-dev-araclari.md](./09-dev-araclari.md)).
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
-
761
- Ayarların tam listesi: [07-yapilandirma.md](./07-yapilandirma.md).
762
-
763
- ## Yönetim paneli
764
-
765
- Yukarıdaki `getHtmlCacheEntries()` / `getRedisStatus()` uçlarını elle yazmak
766
- yerine framework hazır bir panel taşıyor. Dev overlay'den ayrı bir şey: overlay
767
- yalnızca `NODE_ENV=development` iken var, panel ise ortama bakmaz — "bu sayfa
768
- neden bayat", "webhook purge'ü geçti mi", "Redis gerçekten bağlı mı" soruları
769
- üretimde soruluyor.
770
-
771
- ```js
772
- // jskelet.config.mjs
773
- export default {
774
- cache() {
775
- return {
776
- html: { "/haber/:slug": 300 },
777
- panel: { enabled: process.env.CACHE_PANEL === "1" },
778
- };
779
- },
780
- };
781
- ```
782
-
783
- `enabled` verilmedikçe **hiçbir şey mount edilmez**: yol yoktur, modül
784
- yüklenmez, üretim sürecinde hiçbir maliyeti olmaz. Ortam değişkeni
785
- (`JSKELET_CACHE_PANEL=1`) config'i ezer; panel genelde bir arıza sırasında tek
786
- seferlik açılıyor ve o an config dosyasını değiştirip yeniden dağıtmak
787
- istenmiyor.
788
-
789
- Panel açıldığında sunucu logu şifreyi basar:
790
-
791
- ```
792
- [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
793
- ```
794
-
795
- ### Erişim ve güvenlik
796
-
797
- - **Şifre her süreç başlangıcında yeniden üretilir** (32 haneli, onaltılık) ve
798
- yalnızca logda görünür. Kalıcı bir sır tutulmuyor: sızması önbelleği boşaltma
799
- yetkisi demek ve bir deploy eski erişimi kendiliğinden iptal etmeli.
800
- - **Şifre query string ile kabul edilmez.** Erişim logları, tarayıcı geçmişi ve
801
- `Referer` başlığı sırrı taşımasın; giriş yalnızca form üzerinden.
802
- - **Üç başarısız denemede IP 24 saat yasaklanır** (`banAttempts`, `banHours`).
803
- Yanlış şifre kadar oturumsuz istek de sayılır; başarılı giriş sayacı sıfırlar.
804
- - **Yasaklı ve yetkisiz her cevap `404`.** 401/403 panelin var olduğunu
805
- doğrular, 404 hiç yokmuş gibi davranır. Panelin dışındaki site etkilenmez.
806
- - **Hiçbir yeri indekslenmez:** her cevapta `X-Robots-Tag: noindex, nofollow,
807
- noarchive, nosnippet`, `Cache-Control: no-store` ve `Referrer-Policy:
808
- no-referrer`. Yol ayrıca ısıtma listesinden ve gezinme ipuçlarından muaftır.
809
- - Aksiyonlar `X-JSkelet-Cache-Panel` başlığı ister — çapraz siteden
810
- gönderilemeyen bir başlık, yani panelin kendi CSRF freni.
811
- - Oturumlar ve yasak sayaçları süreç belleğinde durur; şifresi zaten her
812
- restart'ta değişen bir panel için diske yazmak yanlış takas olurdu.
813
-
814
- ### Panelde ne var
815
-
816
- | Bölüm | Gösterdiği |
817
- | --- | --- |
818
- | Üst satır | Sürüm, ortam, pid, uptime, RSS ve dil seçimi (Türkçe / İngilizce) |
819
- | Kartlar | HTML girdi sayısı ve sınırı, bellekteki HTML boyutu, bayat girdi sayısı, veri girdisi sayısı, Redis durumu (`connected` / `bypassed` / `off`), ısıtma turunun ilerlemesi |
820
- | Paylaşımlı kademe | Bağlantının **nereye** kurulduğu (adres, TLS, veritabanı), anahtar öneki ve `namespace`, `buildId`, hangi türlerin paylaşıldığı, sıkıştırılmış gövde ve purge yayını durumu, komut zaman aşımı ve hata sayısı. Kapalıysa yerine Redis önerisi ve kurulum parçacığı çıkar. |
821
- | Cloudflare | Zone, plan, cache ile ilgili zone ayarları, development mode'un kalan süresi, Tiered Cache / Cache Reserve durumu ve cache isabet oranı. Bağlı değilse kurulum parçacığı çıkar. |
822
- | Host | Makinenin RAM kullanımı ve projenin bulunduğu diskin doluluğu |
823
- | Girdi listesi | HTML: yol (yeni sekmede açılır), taze/bayat, boyut, durum kodu, kalan TTL, bağımlılık sayısı, hazır sıkıştırılmış gövdeler. Veri: anahtar (tıklayınca panoya kopyalanır), taze/bayat, kalan TTL |
824
-
825
- Liste **anahtar bazında filtrelenir** ve filtre sunucuda uygulanır: veri
826
- önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır
827
- döner; başlıktaki sayaç kaç eşleşmenin kesildiğini söyler. HTML gövdesi ve veri
828
- değerleri **hiç dönmez** — panelin işi durumu göstermek, içeriği dışa vermek
829
- değil.
830
-
831
- ### Panelden yapılabilenler
832
-
833
- | İşlem | Karşılığı |
834
- | --- | --- |
835
- | Invalidate (hedef + `hard`) | `invalidateHtmlCache(target, { hard })` |
836
- | Tek satırı `drop` | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
837
- | Clear HTML cache | `clearHtmlCache()` |
838
- | Clear data cache (önek opsiyonel) | `clearDataCache(prefix)` |
839
- | Drop shared keys | Redis'teki `html` ya da `data` isim alanını tarar ve düşürür |
840
- | Count keys in Redis | `inspectRedis()` — tür başına anahtar sayısı, `DBSIZE` ve `used_memory` |
841
- | Prewarm | `prewarm()` — tur arkada koşar, ilerleme kartta görünür |
842
- | Cloudflare purge (her şey / bellekteki URL'ler / prefix / host / tag) | `purgeCloudflare()` |
843
- | Cloudflare ayarı ya da özelliği değiştirmek | Zone ayarları ve Tiered Cache / Cache Reserve |
844
-
845
- Hepsi paylaşımlı kademeye de yayılır: tek kopyanın önbelleğini boşaltmak,
846
- kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
847
-
848
- Panel iki dilde: header'daki seçici Türkçe ile İngilizce arasında geçiş yapar.
849
- İlk açılışta tarayıcının diline bakılır, seçim `localStorage`'da tutulur ve
850
- giriş sayfasına da uygulanır. Dil değişimi hiçbir isteğe yol açmaz. Sunucu
851
- tarafı arayüz dilini hiç bilmez: `/action` cevabı metin değil bir kod döner
852
- (`{ ok, code, params }`) ve cümleyi panel kurar — framework'ün log'u ve API'si
853
- tek dilde kalır.
854
-
855
- Listedeki tek satırı silmek `invalidateHtmlCache()`ten farklıdır: o yol
856
- desenine bakar ve bir yolun **bütün** query varyantlarını düşürür,
857
- `dropHtmlCacheKey()` ise tam anahtarı alır — `/liste?sayfa=2` düşerken
858
- `/liste?sayfa=3` sıcak kalır.
859
-
860
- ## CDN kademesi: Cloudflare
861
-
862
- Buraya kadar anlatılan her şey **origin** önbelleği. Önünde Cloudflare varsa
863
- ziyaretçinin gördüğü HTML çoğu zaman hiç size ulaşmıyor: edge'deki kopya
864
- TTL'ini doldurana kadar servis edilir. Bu yüzden `invalidateHtmlCache()`
865
- tek başına "sayfayı güncelledim ama eski hâli görünüyor" sorununu çözmez —
866
- origin tazelenir, edge beklemeye devam eder.
867
-
868
- JSkelet iki kademeyi aynı yerden yönetilebilir kılar.
869
-
870
- ### Kurulum
871
-
872
- Token bir sır; config dosyasına değil ortama yazılır:
873
-
874
- ```bash
875
- JSKELET_CLOUDFLARE_KEY=... # API token
876
- JSKELET_CLOUDFLARE_ZONE_ID=... # zone kimliği
877
- JSKELET_CLOUDFLARE_HOSTNAME=example.com # opsiyonel
878
- ```
879
-
880
- Token'a gereken izinler, yapmak istediğinize göre: purge için `Zone.Cache
881
- Purge`, ayarları değiştirmek için `Zone.Zone Settings`, isabet oranı ve edge
882
- kırılımı için `Zone.Analytics` (salt okunur). Yalnızca purge izni verilen bir
883
- token'la panel açılır, ayar bölümleri hata yazar.
884
-
885
- Zone kimliği ve site adı sır olmadığı için `jskelet.config.mjs` içinden de
886
- verilebilir; env her zaman önceliklidir:
887
-
888
- ```js
889
- cache: {
890
- cloudflare: {
891
- zoneId: "…",
892
- hostname: "example.com", // purge tam URL ister; yol → URL çevrimi için
893
- analyticsHours: 24,
894
- },
895
- }
896
- ```
897
-
898
- `hostname` verilmezse purge URL'leri panelin açıldığı origin'den türetilir.
899
- Paneli iç bir adresten (`http://10.0.0.4:3000`) açıyorsanız bu adresin
900
- Cloudflare'de karşılığı yok; o kurulumda `hostname` zorunlu.
901
-
902
- ### Ne yapılabilir
903
-
904
- Cloudflare'in cache yüzeyinde ne varsa panelde de var:
905
-
906
- | İşlem | Not |
907
- | --- | --- |
908
- | Purge everything | Zone'un tamamı. En kaba araç; ısınma maliyeti yüksek |
909
- | Purge by URL | Panelin o an bellekte tuttuğu sayfalar tek düğmeyle; ya da satır başına `cf purge` |
910
- | Purge by prefix / host / tag | Artık her planda çalışıyor; istek başına 100 anahtar |
911
- | Development mode | Üç saat boyunca edge önbelleğini baypas eder, sonra kendiliğinden kapanır |
912
- | Cache level, browser cache TTL, query string sıralaması, Always Online | Zone ayarları |
913
- | Tiered Cache, Regional Tiered Cache, Cache Reserve | Plana bağlı; kapalı planda "unavailable" görünür |
914
- | Clear Cache Reserve | Purge'den ayrı: `purge_everything` edge'i düşürür, R2'deki kalıcı kopya kalır |
915
-
916
- Uzun URL listeleri istek başına 100 anahtarlık partilere bölünür ve **sırayla**
917
- gönderilir. Paralel göndermek Free planda dakikada beş isteklik hız freni
918
- yüzünden yarısı reddedilen bir tur demek.
919
-
920
- Kod tarafında aynı yüzey:
921
-
922
- ```js
923
- import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
924
-
925
- export async function onPostPublished(slug) {
926
- const paths = ["/", `/blog/${slug}`];
927
-
928
- invalidateHtmlCache(paths); // origin
929
- await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
930
- }
931
- ```
932
-
933
- Bu modüldeki hiçbir fonksiyon fırlatmaz: token yoksa, Cloudflare 403 dönerse
934
- ya da ağ düşerse sonuç `{ ok: false, error }` olur. Bir CDN arızası içerik
935
- yayınlama akışını kesmemeli.
936
-
937
- ### "Bu sayfa kaç edge'de cache'li?" — sorulabilen ve sorulamayan
938
-
939
- Cloudflare API'sinde bir objenin **envanterini** veren uç yok. Yüzlerce şehirde
940
- birbirinden bağımsız önbellekler var ve hiçbiri "şu an bende bu URL'in kopyası
941
- var mı" sorusuna cevap vermiyor. Panel bu yüzden envanter değil **gözlem**
942
- gösterir: bir yol girip sorguladığınızda GraphQL analitiğinden son N saatte
943
- hangi kolonun (IST, FRA, AMS…) o yolu kaç kez cache'ten, kaç kez origin'den
944
- servis ettiği gelir.
945
-
946
- ```js
947
- const report = await fetchPathEdges({ path: "/blog", hours: 24 });
948
- // → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
949
- ```
950
-
951
- Okurken iki sınırı akılda tutun: hiç istek almamış bir edge listede görünmez,
952
- kopyası olsa da; ve veri kümesi örneklemeli, yani oranlar güvenilir ama mutlak
953
- sayılar yaklaşıktır.
954
-
955
- Seçtiğiniz bir edge'i **ısıtmanın** da yolu yok. Bir obje ancak o koloya
956
- yönlenen gerçek bir istekle o edge'in önbelleğine giriyor; sunucudan
957
- "Frankfurt'a bunu cache'let" diyemezsiniz. Pratikte yapılabilen üç şey var:
958
-
959
- - **Origin'i ısıtmak** (`prewarm`): ilk isteği alan edge cevabı hazır bulur,
960
- o istek yavaşlamaz.
961
- - **Tiered Cache**: edge'ler origin'e doğrudan gitmez, aradaki üst katmandan
962
- besleniyor; bir şehirdeki ilk istek diğer şehirler için de ısıtma sayılır.
963
- - **Cache Reserve**: uzun kuyruklu içerik için R2'de kalıcı kopya; edge
964
- düşünce istek origin'e kadar inmiyor.
965
-
966
- `hit` oranınız düşükse önce cevabın cache'lenebilir olup olmadığına bakın:
967
- `Cache-Control: private`, `Set-Cookie` ve query string ayarları edge'in
968
- cache'lememe kararının en sık sebepleri, ve bu panelde `dynamic` olarak
969
- görünür.
970
-
971
- ## Prewarm — açılışta ısıtma
972
-
973
- Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek
974
- süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca yapılır. Kazanç
975
- aynı — ilk ziyaretçi soğuk render'ı beklemez — fakat veri dondurulmaz; her girdi
976
- route'un `revalidate` süresiyle yaşlanır ve stale-while-revalidate ile arkada
977
- tazelenir.
978
-
979
- Isıtma **gerçek HTTP istekleriyle** yapılır (`http://127.0.0.1:<port>`), çünkü
980
- cache anahtarı, sıkıştırma ve middleware zinciri normal trafikle bire bir aynı
981
- olsun.
982
-
983
- ### `hooks.prewarmPaths()`
984
-
985
- Hangi yolların ısıtılacağını uygulama bildirir; genelde sitemap üreten
986
- fonksiyonun aynısıdır.
987
-
988
- ```js
989
- // jskelet.config.mjs
990
- export default {
991
- hooks: {
992
- async prewarmPaths() {
993
- const slugs = await getAllArticleSlugs();
994
- return ["/", "/piyasalar", ...slugs.map((slug) => `/haber/${slug}`)];
995
- },
996
- },
997
- };
998
- ```
999
-
1000
- Kurallar:
1001
-
1002
- - Dizi döndürmezse uyarı basılır ve ısıtma yapılmaz.
1003
- - Yalnızca `/` ile başlayan string'ler alınır.
1004
- - `prewarmSkip` öneklerinden biriyle başlayanlar atlanır. Varsayılan liste:
1005
- `/api/`, `/_fragment/`, `/__jskelet/`. Oturuma bağlı sayfalar ısıtılmamalı.
1006
- - Tekilleştirme **sırayı korur**: `priority` verilmediğinde uygulamanın verdiği
1007
- sıra anlamlıdır — en önemli sayfaları başa koyun.
1008
- - Bu hook tanımlı değilse ısıtma hiç kurulmaz; zamanlayıcı bile açılmaz.
1009
-
1010
- ### Tur mantığı
1011
-
1012
- 1. Liste toplanır. `max`'tan (varsayılan 400) uzunsa bir dilim seçilir:
1013
- `priority` eşleşenler **her turda** başa alınır, kalan yerler kuyruktan
1014
- doldurulur.
1015
- 2. `concurrency` işçi paralel olarak istek atar (prod'da 4, dev'de 1). Dev'de
1016
- tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU için
1017
- yarışmasın.
1018
- 3. `rps` verilmişse tur bu hızın üstüne çıkmaz — paralellik ne olursa olsun.
1019
- Dev'de varsayılan olarak saniyede 4 istek uygulanır: render tek bir olay
1020
- döngüsünde çalıştığı için aralıksız bir tur, sayfa isteklerini ve dev
1021
- panelinin canlı kanalını arkasında bekletiyor.
1022
- 4. **Geçici** hata alan yollar için **tek seri tekrar turu** yapılır
1023
- (`concurrency: 1`). `400`/`403`/`404` gibi kalıcı cevaplar tekrar turuna
1024
- girmez: deterministik bir hata tekrar denemekle düzelmez ve o çağrılar
1025
- kotadan karşılıksız yer. Özette `N not retried (permanent)` olarak görünür.
1026
- 5. Bekleme süresi `retryDelayMs`, ama hız freni açıkken **freni bekleten süre**
1027
- varsa o kazanır: 10 saniye kapalı kalacak bir devre kesiciden 2 saniye sonra
1028
- tekrar denemek, aynı 429'u peşin peşin almak olurdu.
1029
- 6. Özet loglanır:
1030
- `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
1031
-
1032
- Ardından turun upstream'e ne kadar dokunduğu basılır:
1033
-
1034
- ```text
1035
- [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
1036
- ```
1037
-
1038
- Bu satır ayarın hangi yönde değişmesi gerektiğini söyleyen tek satır. Oran
1039
- düşükse çözüm hız freni değil, `withDataCache` TTL'ini tur aralığından uzun
1040
- tutmak: fren çağrıları yavaşlatır, sayısını azaltmaz. Aynı sayaçlara
1041
- `getDataCacheStats()` ile ya da dev raporundaki **Data cache** kartından da
1042
- bakılabilir.
1043
-
1044
- Tur sırasında oluşan istek hataları ve sayfa başına basılan render uyarıları
1045
- (`was produced with missing data`, `returned notFound() while upstream is
1046
- failing`, `could not be produced`) tek tek loglanmaz; sayılır ve tur bitince
1047
- özetin ardından, en sık görülen türler başta olacak şekilde basılır:
1048
-
1049
- ```text
1050
- [prewarm] 137 problems were not logged individually:
1051
- 94× missing data, upstream is failing permanently (403 /api/v1/polls)
1052
- 37× missing data, upstream is failing permanently (400 /api/v1/posts)
1053
- 6× 500 Cannot read properties of undefined (reading 'title')
1054
- ```
1055
-
1056
- Böylece bir anlık upstream arızası "warmed …" satırını yüzlerce yığın izinin
1057
- altına gömmüyor. Gerçek trafiğin hataları eskisi gibi anında loglanır, tek bir
1058
- yolun ayrıntısı için dev panelindeki **Prewarming** sekmesine bakılır.
1059
-
1060
- ### Isıtma sırası: `priority`
1061
-
1062
- ```js
1063
- // jskelet.config.mjs
1064
- cache: () => ({
1065
- prewarm: {
1066
- priority: [
1067
- "/",
1068
- "/piyasalar/:path*",
1069
- /-yorumlar$/,
1070
- ],
1071
- },
1072
- }),
1073
- ```
1074
-
1075
- Desen sözdizimi (`/haber/:slug`) ve doğrudan `RegExp` birlikte kullanılabilir;
1076
- ikincisi "sonu `-yorumlar` ile bitenler" gibi desen sözdiziminin karşılamadığı
1077
- kurallar için. Önce yazılan önce ısınır, hiçbirine uymayan yollar kuyruğa gider
1078
- ve kendi aralarındaki sırayı korur.
1079
-
1080
- ### Damla damla ısıtma: `rotate` + `rps` + `intervalSeconds`
1081
-
1082
- 10.000 yolluk bir sitede tek turda her şeyi ısıtmak ne mümkün (HTML önbelleği
1083
- 500 girdi tutar) ne de doğru (API kotası dolar). Doğru davranış listeyi zamana
1084
- yaymak:
1085
-
1086
- ```js
1087
- prewarm: {
1088
- max: 300, // her turda 300 sayfa
1089
- rps: 4, // saniyede en fazla 4 istek
1090
- intervalSeconds: 300, // 5 dakikada bir tur
1091
- rotate: true, // kuyruk kaldığı yerden devam eder
1092
- priority: ["/", "/piyasalar/:path*"],
1093
- }
1094
- ```
1095
-
1096
- Bu kurulumda öncelikli sayfalar her turda tazelenir, geri kalan kuyruk turlar
1097
- boyunca baştan sona dolaşılır ve upstream saniyede dört isteğin üstünü hiç
1098
- görmez. Veri önbelleği ile birlikte kullanıldığında ikinci turdan sonra ısıtma
1099
- API'ye neredeyse hiç gitmez: veri katmanından okur.
1100
-
1101
- Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki tura
1102
- kalıyor; log bunu ayırt eder:
1103
- `… , 700 deferred to the next pass`. `rotate: false` ile klasik davranışa
1104
- dönülür — her tur listenin aynı ilk dilimini ısıtır ve gerisi hiç ısınmaz
1105
- (`… , 700 over the limit`).
1106
-
1107
- Bir tur `intervalSeconds`'tan uzun sürerse yeni tur başlatılmaz; üst üste binen
1108
- turlar upstream'e iki kat yük bindirirdi.
1109
-
1110
- İstekler `user-agent: jskelet-prewarm` (`brand.prewarmUserAgent`) ve
1111
- `accept-encoding: br, gzip` başlıklarıyla gider; ikincisi sıkıştırılmış gövdenin
1112
- de önbelleğe girmesi için.
1113
-
1114
- `DEV_TOKEN` ayarlıysa ısıtma token'ı çerez olarak taşır; yoksa dev gate tüm
1115
- sayfalara 404 döner ve önbellek hiç dolmaz.
1116
-
1117
- Dev panelindeki istek listesi ve terminal, `prewarmUserAgent` taşıyan istekleri
1118
- filtreler: yüzlerce ısıtma isteği görünümü doldurmasın. İlerleme baloncuğun
1119
- yanındaki rozette görünür.
1120
-
1121
- ### Zamanlama
1122
-
1123
- - Isıtma açılışta **gecikmeyle** başlar: ilk gerçek isteklerle yarışmasın.
1124
- Varsayılan gecikme prod'da 500 ms, dev'de 3000 ms. Dev'de daha uzun, çünkü
1125
- dosya kaydı süreci yeniden başlattığı için zamanlayıcı da ölür; yalnızca
1126
- sunucu bir süre sakin kalınca ısınır.
1127
- - `PREWARM_INTERVAL_SECONDS` / `cache().prewarm.intervalSeconds` > 0 ise tur
1128
- periyodik tekrarlanır. Girdiler `revalidate` ile yaşlandığı ve
1129
- stale-while-revalidate sayesinde ziyaretçi beklemediği için bu **opsiyoneldir**;
1130
- hiç ziyaret edilmeyen sayfaları da sıcak tutmak isteyen kurulumlar için.
1131
- - Tüm zamanlayıcılar `unref()` edilmiştir: süreç kapanışını geciktirmezler.
1132
- - Hiçbir ısıtma hatası süreci düşürmez.
1133
-
1134
- ### Ayarlar
1135
-
1136
- Öncelik sırası: **ortam değişkeni → config → kod varsayılanı.** Env önde, çünkü
1137
- tek seferlik deneyler config'i düzenlemeden yapılabilsin.
1138
-
1139
- | Ayar | Env | `cache().prewarm` | Varsayılan |
1140
- | --- | --- | --- | --- |
1141
- | Açık/kapalı | `PREWARM=0` kapatır, `PREWARM=1` config'i ezip açar | `enabled` | `true` |
1142
- | Tur başına en fazla yol | `PREWARM_MAX` | `max` | `400` |
1143
- | Paralellik | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
1144
- | Saniyedeki istek | `PREWARM_RPS` | `rps` | prod `0` (sınırsız), dev 4 |
1145
- | Başlangıç gecikmesi (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
1146
- | Tekrar turu gecikmesi (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
1147
- | Periyot (saniye) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (kapalı) |
1148
- | Kuyruk rotasyonu | — | `rotate` | `true` |
1149
- | Isıtma sırası | — | `priority` | `[]` |
1150
-
1151
- Sayısal ayarlar yalnızca **pozitif ve sonlu** değer kabul eder; geçersiz bir
1152
- değer sessizce bir sonraki katmana düşer.
1153
-
1154
- ### Elle tetikleme
1155
-
1156
- ```js
1157
- import { prewarm, prewarmProgress } from "jskelet";
1158
-
1159
- await prewarm({ origin: "http://127.0.0.1:3000" }); // hook'tan yollar
1160
- await prewarm({ origin, paths: ["/", "/piyasalar"] }); // yalnızca bu yollar
1161
- await prewarm({ origin, quiet: true }); // özet basmadan
1162
- ```
1163
-
1164
- `paths` verilirse hook hiç çağrılmaz. Dönüş değeri
1165
- `{ ok, failed, total, elapsed }`.
1166
-
1167
- `prewarmProgress` canlı durumu tutar ve dev paneli bunu okur:
1168
-
1169
- ```js
1170
- {
1171
- active, done, total, ok, failed, startedAt, finishedAt,
1172
- entries: [{ path, status, ms, bytes, cache, error }],
1173
- }
1174
- ```
1175
-
1176
- `entries` içindeki `cache` alanı o yolun `X-JSkelet-Cache` yanıtıdır; ısıtma
1177
- turunun gerçekten `MISS` → önbellek doldurup doldurmadığını buradan görürsünüz.
1178
-
1179
- ## Teşhis: sık görülen durumlar
1180
-
1181
- - **Her istek `MISS` dönüyor.** Route'a `revalidate` verilmemiş ya da
1182
- `cache().html` içindeki desen 0 saniye veriyor. Veya sayfa `status: 200`
1183
- dışında bir kod dönüyor.
1184
- - **Sayfa `MISS` dönüyor ama upstream sağlam.** Geçici bir upstream hatası
1185
- bildirilmiş olabilir; logda `was produced with missing data, not caching it`
1186
- satırını arayın.
1187
- - **Sürekli eski veri.** `revalidate` çok yüksek; unutmayın ki gerçek gecikme en
1188
- fazla `revalidate` + bir tazeleme turudur.
1189
- - **Önbellek şişiyor.** Query parametreleri anahtara girdiği için kampanya
1190
- parametreleri girdi çoğaltıyor olabilir.
1191
- - **Isıtma hiç çalışmıyor.** `hooks.prewarmPaths` tanımlı değil, `PREWARM=0`
1192
- ayarlı ya da `cache().prewarm.enabled === false`.
1193
- - **Isıtma turu API'yi 429'a sokuyor.** `rps` verilmemiş. `concurrency`
1194
- düşürmek yeterli değil; kotayı koruyan ayar toplam hız. Kalıcı çözüm veri
1195
- önbelleği: ikinci turdan sonra ısıtma upstream'e gitmez.
1196
- - **Isıtma listesi `max`'tan uzun ve sonu hiç ısınmıyor.** `rotate: false`
1197
- olabilir; logdaki `over the limit` ifadesi bunu gösterir.
1198
- - **Bir bölümün tamamı 404 dönüyor.** Upstream düşmüş olabilir. Artık bu durumda
1199
- sayfa bir kez daha denenir, olmazsa 404 değil önbelleğe girmeyen 503 döner;
1200
- logda `returned notFound() while upstream is failing` satırını arayın. Hâlâ
1201
- 404 görüyorsanız hata `fetch` dışı bir istemciden geliyor olabilir
1202
- (`reportUpstreamFailure()` gerekir) ya da `cache().trackUpstream` kapatılmış.
1203
-
1204
- ## Sırada ne var
1205
-
1206
- - Config alanlarının tam referansı ve env tablosu:
1207
- [07-yapilandirma.md](./07-yapilandirma.md)
1208
- - Önbelleği dev panelinden izlemek: [09-dev-araclari.md](./09-dev-araclari.md)
1209
- - CDN/ters proxy ile birlikte kullanım: [10-dagitim.md](./10-dagitim.md)
1
+ # 06 — Önbellek ve prewarm
2
+
3
+ Bu belge JSkelet'in ISR ikamesini bütün ayrıntılarıyla anlatır: HTML TTL
4
+ önbelleği ve stale-while-revalidate davranışı, `revalidate`'in nereden geldiği,
5
+ cache anahtarının nasıl kurulduğu, `X-JSkelet-Cache` başlığının değerleri,
6
+ sıkıştırılmış gövdenin neden önbellekte durduğu, istek içi memoizasyon
7
+ (`withRequestCache` / `cache()`), veri önbelleği (`withDataCache`), upstream
8
+ hatalarının önbelleği nasıl etkilediği (otomatik izleme ve
9
+ `reportUpstreamFailure`) ve sunucu açılışındaki ısıtma turu.
10
+ Kararların arkasındaki ölçüm gerekçeleri [02-mimari.md](./02-mimari.md)'de,
11
+ config alanlarının tam referansı [07-yapilandirma.md](./07-yapilandirma.md)'de.
12
+
13
+ ## Genel resim
14
+
15
+ ```
16
+ route(controller, { revalidate })
17
+ └─ withHtmlCache(key, ttl, producer) ← TTL + stale-while-revalidate
18
+ └─ withUpstreamTracking(...) ← eksik veri tespiti
19
+ └─ withRequestCache(...) ← istek içi memoizasyon
20
+ └─ produce() → controller + renderPage
21
+ └─ withDataCache(...) ← upstream veri önbelleği
22
+ ```
23
+
24
+ Sıra önemli: **istek içi cache en içte** olmalı ki aynı render'daki iki çağrı
25
+ tek upstream isteğine düşsün; **upstream takibi HTML cache'in içinde** olmalı ki
26
+ eksik veriyle üretilen çıktı önbelleğe yazılmasın.
27
+
28
+ İki önbelleğin iş bölümü:
29
+
30
+ | | HTML önbelleği | Veri önbelleği |
31
+ | --- | --- | --- |
32
+ | Ne tutar | Sayfanın tamamı (+ sıkıştırılmış gövdesi) | Upstream'den gelen JSON |
33
+ | Girdi boyutu | ~100-200 kB | ~1-20 kB |
34
+ | Girdi sınırı | 500 (`cache().maxEntries`) | 10.000 (`cache().data.maxEntries`) |
35
+ | Kime yarar | Trafiği olan sayfalar: render bile edilmez | Uzun kuyruk: render edilir ama API'ye gidilmez |
36
+
37
+ Bu ayrım pratikte şuna karşılık gelir: on binlerce yollu bir sitede sayfaların
38
+ tamamını HTML olarak sıcak tutmak mümkün değil — 500 girdiyi aşan ısıtma kendi
39
+ ısıttığını siler. Uzun kuyruk için hedef "HTML hazır olsun" değil, **"sayfayı
40
+ üretecek veri API'ye gitmeden bulunsun"** olmalı. O zaman hiç ısıtılmamış bir
41
+ sayfa da ilk ziyaretçide milisaniyeler içinde üretilir ve kota harcamaz.
42
+
43
+ ## Public ve kişiye özel ayrımı
44
+
45
+ Bu belgedeki her şey **herkese aynı gidebilen** HTML için geçerli. Cache
46
+ anahtarında kimlik yok (yalnızca yol + query), yani önbellekteki bir sayfa onu
47
+ ilk isteyen kişinin değil, o yolun cevabıdır.
48
+
49
+ Kullanıcıya bağlı bir sayfa bu yüzden ayrı bir yoldan geçer:
50
+
51
+ ```js
52
+ app.get("/panel", route(async ({ req }) => { … }, { private: true }));
53
+ ```
54
+
55
+ `private: true` üç şeyi birden yapar: önbellek devre dışı kalır, config'in
56
+ `cache.html` deseni bu kararı **ezemez** ve yanıt `private, no-store`,
57
+ `Vary: Cookie` ile, ETag'siz gider. Ayrıntılar ve oturum/CSRF tarafı
58
+ [12-panel-ve-oturum.md](./12-panel-ve-oturum.md)'de.
59
+
60
+ Bayrağı unutursanız framework sessiz kalmaz: controller `Cookie`,
61
+ `Authorization` ya da `req.session`/`req.user` okuduğu anda render işaretlenir
62
+ ve önbelleğe **yazılmaz**. Dev'de istek bir hatayla düşer, üretimde `no-store`
63
+ ile servis edilip loglanır. Koruma bir mazeret değil son savunma — doğru yer
64
+ `private: true`.
65
+
66
+ ## `revalidate` — TTL nereden gelir
67
+
68
+ Bir route'un TTL'i iki kaynaktan gelebilir ve **config kazanır**:
69
+
70
+ 1. `route(controller, { revalidate: 60 })` — route'un kendi süresi.
71
+ 2. `jskelet.config.mjs` → `cache().html` içindeki eşleşen desen. Varsa
72
+ route'unkini ezer.
73
+
74
+ Tek istisna `private: true`: desen eşleşse bile yok sayılır. Kilit tek yönlü,
75
+ çünkü ters yönde bir hata sessiz veri sızıntısı anlamına geliyor.
76
+
77
+ ```js
78
+ // jskelet.config.mjs
79
+ export default {
80
+ async cache() {
81
+ return {
82
+ html: {
83
+ "/": 60,
84
+ "/haber/:slug": 300,
85
+ "/etiket/:slug": 120,
86
+ },
87
+ };
88
+ },
89
+ };
90
+ ```
91
+
92
+ Config ile ezme, tek bir dosyadan tüm sitenin tazelik profilini ayarlamayı
93
+ mümkün kılar; route dosyalarını dolaşmak gerekmez.
94
+
95
+ Çözüm sonucu **yol başına hatırlanır**, böylece her istekte desen taraması
96
+ yapılmaz. Hiç `cache().html` kuralı yoksa doğrudan route'un değeri kullanılır.
97
+
98
+ `revalidate` verilmemişse ya da 0 ise sayfa **hiç önbelleklenmez**: her istek
99
+ render edilir ve yanıt `Cache-Control: private, no-store` ile, ETag'siz gider.
100
+ `X-JSkelet-Cache` başlığı da yazılmaz — önbellek yolu hiç çalışmadı, `MISS`
101
+ demek yanıltıcı olurdu.
102
+
103
+ Dinamik bir sayfaya `no-store` yazılması bilinçli. Hiç direktif taşımayan bir
104
+ yanıtı HTTP "sezgisel olarak önbelleklenebilir" sayıyor; araya giren bir proxy
105
+ ya da tarayıcının geri tuşu, tek bir ziyaretçi için üretilmiş HTML'i
106
+ saklayabiliyordu.
107
+
108
+ Önbellek ayrıca yalnızca `GET` istekleri için devreye girer.
109
+
110
+ ## Cache anahtarı
111
+
112
+ ```
113
+ `${yol}?${izin verilen query parametreleri, sıralı}`
114
+ ```
115
+
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.
121
+
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).
137
+
138
+ ## Stale-while-revalidate
139
+
140
+ Girdi yapısı:
141
+
142
+ ```
143
+ expiresAt = now + ttl
144
+ staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
145
+ ```
146
+
147
+ Okuma davranışı:
148
+
149
+ | Durum | Yanıt | Arka plan |
150
+ | --- | --- | --- |
151
+ | `now < expiresAt` | Önbellekteki HTML, `HIT` | — |
152
+ | `expiresAt ≤ now < staleUntil` | Önbellekteki HTML **anında**, `STALE` | Tazeleme başlatılır |
153
+ | `now ≥ staleUntil` | Girdi silinir, taze render, `MISS` | — |
154
+
155
+ Stale penceresinde tazelemenin hatası isteği etkilemez: eski HTML pencere
156
+ boyunca geçerli kalır ve hata yalnızca loglanır
157
+ (`[html-cache] background refresh failed: …`).
158
+
159
+ Aynı anahtar için eşzamanlı tazelemeler tek bir çalışmada birleştirilir
160
+ (`inflight` haritası): yüz eşzamanlı istek tek render'a düşer.
161
+
162
+ Kazanç: ilk ısıtmadan sonra hiçbir istek render'ı beklemez. Bedel: HTML'deki
163
+ veri en fazla `revalidate + bir tazeleme turu` kadar geride olabilir. Bu bedel
164
+ kabul edilebilir, çünkü fiyat gibi canlı alanlar istemcide WebSocket'ten
165
+ güncelleniyor.
166
+
167
+ Store LRU'dur: erişilen girdi sona taşınır, sınır (`cache().maxEntries`,
168
+ varsayılan 500) aşılınca en eski düşürülür.
169
+
170
+ ## Ne önbelleğe yazılır
171
+
172
+ Yalnızca şu iki koşulun **ikisini** birlikte sağlayan çıktı saklanır:
173
+
174
+ 1. `status === 200`
175
+ 2. `degraded !== true` — render sırasında geçici bir upstream hatası
176
+ bildirilmemiş.
177
+
178
+ Yani 404 sayfaları, redirect'ler ve eksik veriyle üretilmiş HTML önbelleğe
179
+ girmez.
180
+
181
+ ## Yanıt başlıkları
182
+
183
+ `route()` her yanıta `X-JSkelet-Cache` yazar (başlık adı `brand.cacheHeader`
184
+ ile değiştirilebilir):
185
+
186
+ | Değer | Anlamı |
187
+ | --- | --- |
188
+ | `HIT` | Önbellekten, taze |
189
+ | `STALE` | Önbellekten, süresi geçmiş; arkada tazeleniyor |
190
+ | `MISS` | Bu istekte render edildi (ya da önbellek kapalı) |
191
+
192
+ Önbelleklenebilir yanıtlarda ayrıca:
193
+
194
+ ```
195
+ Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
196
+ ```
197
+
198
+ `max-age=0` tarayıcıda saklamayı kapatır, `s-maxage` ara katmanlara (CDN, ters
199
+ proxy) süreyi bildirir. Böylece CDN önünde durduğunda aynı tazelik modeli iki
200
+ katmanda birlikte çalışır.
201
+
202
+ ## Sıkıştırılmış gövdenin saklanması
203
+
204
+ Önbelleğe alınan her girdi bir `encoded` haritası taşır ve HTML ile aynı ömrü
205
+ paylaşır. Bir sayfa ilk kez brotli ya da gzip ile istendiğinde çıktı hesaplanıp
206
+ haritaya konur; sonraki isteklerde aynı buffer gönderilir. Aynı sayfa her
207
+ istekte yeniden brotli'lenmez.
208
+
209
+ Bu yolda `Content-Encoding`, `Vary` ve `Content-Length` doğrudan `route()`
210
+ tarafından yazılır; sıkıştırma middleware'i `Content-Encoding` gördüğü için
211
+ devreye girmez.
212
+
213
+ `HEAD` istekleri sıkıştırılmaz (gövde yok). İstemci ne brotli ne gzip kabul
214
+ ediyorsa düz HTML gönderilir.
215
+
216
+ ## İstek içi memoizasyon: `cache()`
217
+
218
+ React'in `cache()` fonksiyonunun karşılığı: aynı istek içinde aynı argümanlarla
219
+ yapılan çağrılar tek kez çalışır.
220
+
221
+ ```js
222
+ // lib/api/articles.js
223
+ import { cache } from "jskelet";
224
+
225
+ export const getArticle = cache(async (slug) => {
226
+ const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
227
+ return response.json();
228
+ });
229
+ ```
230
+
231
+ Artık aynı render'da hem controller hem `hooks.layoutContext()` aynı yazıyı
232
+ isterse tek upstream isteği yapılır.
233
+
234
+ Ayrıntılar:
235
+
236
+ - Bağlam `AsyncLocalStorage` ile taşınır ve `route()` içinde
237
+ `withRequestCache()` tarafından kurulur.
238
+ - **Bağlam yoksa memoizasyon devre dışı kalır** ve fonksiyon doğrudan çağrılır.
239
+ Bir script'ten ya da başka bir sürecin içinden çağırmak güvenlidir.
240
+ - Anahtar `JSON.stringify(args)`; argümansız çağrılar `""` anahtarını
241
+ paylaşır. Serileştirilemeyen argümanlar (fonksiyon, `Symbol`, döngüsel nesne)
242
+ ile kullanmayın.
243
+ - Saklanan şey fonksiyonun **dönüş değeridir**, yani `async` fonksiyonlarda
244
+ Promise'in kendisi. Aynı Promise paylaşıldığı için eşzamanlı çağrılar da
245
+ birleşir.
246
+ - `withRequestCache(run)` dışa açıktır; `route()` dışında (ör. kendi yazdığınız
247
+ bir Express handler'ında) aynı kapsamı kurmak için kullanılabilir.
248
+
249
+ ## İstekler arası veri önbelleği: `withDataCache`
250
+
251
+ `cache()` yalnızca **tek bir istek** boyunca yaşar. Uzun kuyruğu API kotasından
252
+ korumak için gereken şey istekler arasında yaşayan, TTL'li ve kendi kendini
253
+ tazeleyen bir veri katmanı:
254
+
255
+ ```js
256
+ // lib/api/articles.js
257
+ import { withDataCache, reportUpstreamFailure } from "jskelet";
258
+
259
+ export async function getArticle(slug) {
260
+ return withDataCache(`haber:${slug}`, 600, async () => {
261
+ const response = await fetch(`${process.env.API_ORIGIN}/articles/${slug}`);
262
+
263
+ if (!response.ok) {
264
+ reportUpstreamFailure({ status: response.status, path: `/articles/${slug}` });
265
+ return null;
266
+ }
267
+
268
+ return response.json();
269
+ });
270
+ }
271
+ ```
272
+
273
+ Aynı kalıbın sarmalayıcı biçimi — anahtar argümanlardan üretilir:
274
+
275
+ ```js
276
+ import { dataCache } from "jskelet";
277
+
278
+ export const getArticle = dataCache(
279
+ async (slug) => apiGet(`/articles/${slug}`),
280
+ { key: "haber", revalidate: 600 },
281
+ );
282
+ ```
283
+
284
+ Davranış:
285
+
286
+ | Durum | Sonuç |
287
+ | --- | --- |
288
+ | Taze girdi | Anında döner, `producer` çalışmaz |
289
+ | TTL geçmiş, bayat pencere sürüyor | Bayat değer **anında** döner, tazeleme arkada yürür |
290
+ | Girdi yok | `producer` beklenir |
291
+ | `producer` hata verdi, bayat girdi var | Bayat değer döner, uyarı: `[data-cache] producer failed, serving stale value: …` |
292
+ | `producer` hata verdi, girdi yok | Hata çağırana gider |
293
+
294
+ Ayrıntılar:
295
+
296
+ - **Aynı anahtarı eşzamanlı isteyen çağrılar tek upstream isteğine düşer.**
297
+ Isıtma turlarında kotayı en çok kurtaran davranış bu: 50 sayfa aynı endeks
298
+ verisini istiyorsa API bir kez çağrılır.
299
+ - **`null` ve `undefined` saklanmaz.** Uygulamaların HTTP istemcisi hatada
300
+ genellikle `null` döner; bunu saklamak geçici bir 429'u TTL boyunca "veri yok"
301
+ hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
302
+ `{ storeEmpty: true }` verir.
303
+ - **Bayat pencere HTML'dekinden uzun**: `staleFactor` varsayılanı 10, yani girdi
304
+ TTL'inin 11 katı boyunca acil durum yedeği olarak kalır. Bayat veri, eksik
305
+ sayfadan iyidir. Anahtar başına `{ staleFactor: 0 }` ile kapatılabilir.
306
+ - Anahtar tamamen uygulamanın: dil, sürüm, sayfa numarası gibi ayrımlar anahtara
307
+ yazılır (`haber:tr:v2:${slug}`).
308
+ - TTL `0` verildiğinde önbellek devre dışı kalır ve `producer` her çağrıda
309
+ çalışır — bir ayarı geçici olarak kapatmak için yeterli.
310
+
311
+ Yönetim yüzeyi:
312
+
313
+ | Fonksiyon | Ne yapar |
314
+ | --- | --- |
315
+ | `withDataCache(key, ttlSeconds, producer, options?)` | Ana giriş noktası |
316
+ | `dataCache(fn, { key, revalidate, … })` | Fonksiyon sarmalayıcısı |
317
+ | `clearDataCache(prefix?)` | Önek eşleşen girdileri (ya da tümünü) düşürür, silinen sayısını döner |
318
+ | `getDataCacheSize()` | Girdi sayısı |
319
+ | `getDataCacheEntries()` | Döküm: `{ key, stale, expiresIn }`. Değerin kendisi dönmez. |
320
+
321
+ `clearDataCache("haber:")`, "bu içerik güncellendi" webhook'unun karşılığıdır:
322
+ tek bir bölümün verisini düşürür ve **o veriyi okumuş HTML sayfalarını da**
323
+ bayatlatır, yani güncelleme TTL beklenmeden görünür. Ayrıntısı aşağıda,
324
+ "Otomatik bağımlılık" bölümünde.
325
+
326
+ ## Degraded render: `reportUpstreamFailure`
327
+
328
+ Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir. Böyle bir
329
+ HTML'i tüm TTL boyunca servis etmek yerine önbelleğe **hiç yazmamak** doğru
330
+ davranış: sonraki istek yeniden dener.
331
+
332
+ Bu bilgi iki yoldan gelir.
333
+
334
+ ### Otomatik izleme (varsayılan)
335
+
336
+ `createApp()` açılışta `globalThis.fetch`i sarar ve render sırasında yapılan
337
+ çağrılardaki **geçici** hataları (`429`, `5xx`, ağ hatası) kendiliğinden
338
+ bildirir. Uygulama tarafında hiçbir satır gerekmez; `fetch` ile konuşan bir API
339
+ istemcisi varsa rate limit koruması hazırdır.
340
+
341
+ Ayrıntılar:
342
+
343
+ - Yalnızca bir render bağlamı içindeki çağrılar sayılır. Script'ten, cron'dan
344
+ ya da istek dışı bir yerden yapılan `fetch` dokunulmaz kalır.
345
+ - Kendi sunucumuza yapılan istekler (`localhost`, `127.0.0.1`) atlanır: ısıtma
346
+ turu ve sağlık kontrolü upstream değildir.
347
+ - `404`/`403` gibi deterministik cevaplar **otomatik olarak bildirilmez**. Çoğu
348
+ API'de `404` "böyle bir kayıt yok" demektir; onu eksik veri saymak her yok
349
+ sayfasında yanlış uyarı üretirdi.
350
+ - Kapatmak için `cache().trackUpstream: false`. `fetch`i kendisi saran bir
351
+ uygulama (ölçüm, retry, circuit breaker) bunu tercih edebilir.
352
+
353
+ ### Elle bildirim
354
+
355
+ `fetch` kullanmayan bir istemci (veritabanı sürücüsü, gRPC, satıcı SDK'sı) ya da
356
+ kalıcı hataları da işaretlemek isteyen bir katman için sözleşme aynı kaldı.
357
+ Bağımlılık yönü bilinçli olarak tersine çevrilmiştir: framework veri katmanını
358
+ tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet boş
359
+ bir dizidir. Aynı hata her iki yoldan bildirilirse tekilleştirilir.
360
+
361
+ ```js
362
+ // lib/api/client.js
363
+ import { reportUpstreamFailure } from "jskelet";
364
+
365
+ export async function apiGet(path) {
366
+ try {
367
+ const response = await fetch(`${process.env.API_ORIGIN}${path}`);
368
+
369
+ if (!response.ok) {
370
+ reportUpstreamFailure({ status: response.status, path });
371
+ return null;
372
+ }
373
+
374
+ return response.json();
375
+ } catch (error) {
376
+ // Yanıt hiç gelmedi: status 0 ağ hatası anlamına gelir.
377
+ reportUpstreamFailure({ status: 0, path });
378
+ return null;
379
+ }
380
+ }
381
+ ```
382
+
383
+ ### Geçici ve kalıcı hata ayrımı
384
+
385
+ | Durum | Sayılır | Sonuç |
386
+ | --- | --- | --- |
387
+ | `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 (…)` |
388
+ | Diğerleri (`400`, `403`, `404`, …) | **Kalıcı** | Yalnızca uyarı: `[render] <path> was produced with missing data, upstream is failing permanently (…)`. Önbellek engellenmez. |
389
+
390
+ Kalıcı hataların önbelleği engellememesi bilinçli: deterministik cevaplar tekrar
391
+ denemekle düzelmez. Onlar yüzünden önbelleği kapatmak sayfayı her ziyarette
392
+ baştan render etmek olur — içerik yine aynı eksik hâliyle döner, ziyaretçi
393
+ sadece render süresini öder.
394
+
395
+ Eksik veriyle üretilen çıktı **paylaşılan önbelleklere de sunulmaz**: `degraded`
396
+ bir yanıt `public, s-maxage=…` değil `private, no-store` alır. Süreç içi
397
+ önbelleğe yazmama kararını CDN'de geri almak, aynı hatayı bir katman yukarıda
398
+ tekrarlamak olurdu. Teşhis başlığı (`X-JSkelet-Cache: MISS`) yine yazılır.
399
+
400
+ ### `notFound()` geçici hataya denk gelirse
401
+
402
+ Veri gelmediği için `notFound()` çağıran bir controller, upstream rate limit'e
403
+ girdiğinde tüm siteyi 404'e çevirebilir — ve bu 404'ler önbelleğe girdiği için
404
+ geçici bir kota sorunu TTL boyunca "bu sayfa yok" cevabına dönüşür. Arama motoru
405
+ için bu kalıcı bir kayıp.
406
+
407
+ Framework bu durumu ayırır: render sırasında **geçici** bir upstream hatası
408
+ varsa `notFound()` 404 olarak servis edilmez. Sırayla:
409
+
410
+ 1. Sayfa kısa bir beklemeden sonra **yeniden denenir** (varsayılan bir kez,
411
+ 300 ms sonra). Deneme kendi upstream ve istek içi cache bağlamında koşar;
412
+ ilk turun hatası da memoize edilmiş boş cevabı da ikinci turu etkilemez.
413
+ 2. İkinci tur sayfayı üretebilirse ziyaretçi **gerçek içeriği** görür ve çıktı
414
+ normal şekilde önbelleğe girer. Isıtma günlükleri bunun sık olduğunu
415
+ gösteriyor: aynı yol saniyeler sonra 200 dönüyor.
416
+ 3. Denemeler tükendiyse yanıt `503` olur — önbelleğe girmez, `Retry-After`
417
+ taşır, sonraki istek yine gerçek içeriği üretebilir.
418
+
419
+ | Render sırasında | `notFound()` sonucu |
420
+ | --- | --- |
421
+ | Geçici hata var (`429`, `5xx`, ağ hatası) | Tekrar dene → başarılıysa sayfa; hâlâ olmuyorsa `503`, `Retry-After: 30`, `no-store` |
422
+ | Tekrar denemede upstream sağlam cevap verip "yok" dedi | Normal `404` |
423
+ | Kalıcı hata var (`404`, `403`…) ya da hata yok | Normal `404`, tekrar denenmez |
424
+
425
+ Log satırları:
426
+
427
+ ```
428
+ [render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
429
+ [render] /haber/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
430
+ ```
431
+
432
+ Yani **var olan bir sayfa hiçbir koşulda 404'e dönüşmez**: ya gerçek içerik
433
+ gelir, ya önbelleğe girmeyen bir 503. Hiçbir şey "yok" olarak dondurulmaz.
434
+
435
+ Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turudur; bu yüzden
436
+ varsayılan tek deneme. Ayar `cache().transientRetry`:
437
+
438
+ ```js
439
+ cache: {
440
+ transientRetry: { attempts: 2, delayMs: 500 },
441
+ }
442
+ ```
443
+
444
+ `transientRetry: false` (ya da `attempts: 0`) tekrarı kapatır ve doğrudan 503'e
445
+ düşer.
446
+
447
+ ## Upstream hız freni: `cache().upstream`
448
+
449
+ Buraya kadarki her şey 429 **geldikten sonra** ne olacağını anlatıyor. Bu bölüm
450
+ 429'u en baştan almamakla ilgili.
451
+
452
+ Fren `trackUpstreamFetch()` sarmalayıcısının içinde, yani gerçek `fetch`
453
+ çağrısının geçtiği yerde duruyor. Isıtma turundaki `prewarm.rps` bu işi
454
+ yapamaz: o, kendi sunucumuza atılan **sayfa** isteklerini sayıyor, ama bir
455
+ sayfa render'ı bir API çağrısı da yapabilir yirmi tane de. Kotayı bağlayan şey
456
+ sayfa sayısı değil, çağrı sayısı — ve fren buraya konduğunda ısıtma da gerçek
457
+ trafik de aynı bütçeden harcar.
458
+
459
+ Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez ve maliyet bir
460
+ daldan ibarettir.
461
+
462
+ ```js
463
+ // jskelet.config.mjs
464
+ cache: () => ({
465
+ upstream: {
466
+ rate: 10, // saniyedeki tavan (host başına)
467
+ burst: 20, // kısa patlama toleransı
468
+ concurrency: 8, // aynı anda uçan çağrı
469
+ hosts: {
470
+ // Kotası farklı olan uçlar ayrı ayarlanır.
471
+ "api.example.com": { rate: 3, concurrency: 2 },
472
+ },
473
+ },
474
+ }),
475
+ ```
476
+
477
+ ### Üç mekanizma, üç farklı sınır
478
+
479
+ | Mekanizma | Neyi sınırlar | Ayar |
480
+ | --- | --- | --- |
481
+ | Token bucket | Ortalama hız (çağrı/saniye) | `rate`, `burst` |
482
+ | Eşzamanlılık | Anlık baskı (aynı anda uçan çağrı) | `concurrency` |
483
+ | AIMD | Doğru hızın ne olduğu | `minRate`, `increaseStep`, `increaseIntervalMs`, `decreaseIntervalMs` |
484
+
485
+ Üçüncüsü asıl fikir. Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır:
486
+ kotanın gerçek sınırını kimse config'e doğru yazamaz, üstelik gün içinde
487
+ değişir. Bu yüzden `rate` bir **tavan** olarak alınır ve gerçek hız upstream'in
488
+ cevabına göre oynar:
489
+
490
+ - **429 ya da 503** → hız yarıya iner (çarpımsal azalma). Yanıt `Retry-After`
491
+ taşıyorsa kova o süre boyunca tamamen durur — upstream sana ne kadar
492
+ bekleyeceğini zaten söylüyor.
493
+ - **Temiz geçen her pencere** → hız `increaseStep` kadar yukarı çıkar
494
+ (toplamsal artış), `rate` tavanına kadar.
495
+
496
+ Azalmanın çarpımsal, artışın toplamsal olması bilinçli. Tersi olsaydı her
497
+ pencerede yeniden 429 yenirdi.
498
+
499
+ ### Devre kesici
500
+
501
+ Art arda `breakerFailures` (varsayılan 5) tane 429 alan bir host
502
+ `breakerCooldownMs` süresince tamamen baypas edilir: çağrı hiç yapılmaz,
503
+ doğrudan geçici hata olarak bildirilir.
504
+
505
+ Sert görünüyor ama asimetri bunu gerektiriyor: 429 geçici sayıldığı için o
506
+ çağrıyla üretilen HTML **önbelleğe yazılmaz**. Yani rate limit'e girmiş bir
507
+ turda kota harcanır ve karşılığında hiçbir şey saklanmaz. Üstüne bir sonraki
508
+ tur aynı sayfayı yine soğuk bulup yine dener. Kesici bu boşa yanmayı kesiyor.
509
+
510
+ ```
511
+ [upstream] api.example.com: 5 consecutive rate limits — bypassing for 10000ms (rate is now 1.2/s)
512
+ ```
513
+
514
+ Yalnızca 429 ve 503 sayılır. `400`/`404` bir kota sorunu değil, `500` de öyle:
515
+ onlar için yavaşlamak arızayı düzeltmez, sadece siteyi yavaşlatır.
516
+
517
+ ### Durumu görmek
518
+
519
+ `getUpstreamLimiterStatus()` host başına o anki hızı, uçuştaki çağrıyı ve
520
+ sayaçları döner; dev panelinin **Server** sekmesi de aynı bilgiyi basıyor.
521
+ 429 fırtınasında "şu an saniyede kaça indi" bilgisi olmadan ayar yapmak
522
+ körlemesine olur.
523
+
524
+ ```js
525
+ import { getUpstreamLimiterStatus } from "jskelet";
526
+
527
+ // [{ host: "api.example.com", rate: 2.5, maxRate: 10, concurrency: 8,
528
+ // active: 3, throttled: 12, rejected: 40, bypassed: false, blockedMs: 0 }]
529
+ ```
530
+
531
+ ### Freni açmadan önce
532
+
533
+ Hız freni son çare. Aynı upstream yanıtını yüzlerce sayfa çekiyorsa asıl çözüm
534
+ [`withDataCache`](#istekler-arası-veri-önbelleği-withdatacache) TTL'ini tur
535
+ aralığından uzun tutmak: 400 sayfalık bir tur, ortak bir uç için tek çağrı
536
+ yapar. Fren o çağrıları yavaşlatır, sayısını azaltmaz.
537
+
538
+ ## Önbelleği yönetmek
539
+
540
+ `jskelet` şu fonksiyonları dışa açar:
541
+
542
+ | Fonksiyon | Ne yapar |
543
+ | --- | --- |
544
+ | `withHtmlCache(key, ttlSeconds, producer)` | Önbelleği doğrudan kullanmak için. `ttlSeconds` 0 ise producer her zaman çalışır. |
545
+ | `invalidateHtmlCache(target, options?)` | Eşleşen sayfaları bayatlatır (ya da `{ hard: true }` ile düşürür), etkilenen sayı döner. |
546
+ | `clearHtmlCache()` | Store'u tamamen boşaltır. |
547
+ | `getHtmlCacheSize()` | Girdi sayısı. |
548
+ | `getHtmlCacheEntries()` | Döküm: `{ key, bytes, status, stale, expiresIn, encodings, deps }`. HTML gövdesi dönmez, yalnızca boyutu. |
549
+
550
+ ### Hedefli invalidation
551
+
552
+ TTL'i beklemekle tüm önbelleği boşaltmak arasındaki boşluğu `invalidateHtmlCache()`
553
+ doldurur:
554
+
555
+ ```js
556
+ import { invalidateHtmlCache } from "jskelet";
557
+
558
+ invalidateHtmlCache("/haber/abc"); // o yol ve altı
559
+ invalidateHtmlCache("/haber/:slug"); // desen sözdizimi
560
+ invalidateHtmlCache([/-yorumlar$/, "/"]); // RegExp ve liste
561
+ ```
562
+
563
+ Varsayılan davranış **bayatlatmaktır**, silmek değil: girdi süresi geçmiş
564
+ sayılır ve normal stale-while-revalidate yoluna düşer. Bir webhook beş yüz
565
+ sayfayı birden düşürdüğünde sert silme, tam da içeriğin güncellendiği anda beş
566
+ yüz soğuk render başlatır ve upstream'i döver. Bayatlatmada ziyaretçi eski
567
+ HTML'i beklemeden alır, tazeleme arkada ve anahtar başına tek seferde koşar.
568
+ Eski HTML'in gerçekten geçersiz olduğu durumlar için `{ hard: true }`.
569
+
570
+ Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir yolun
571
+ bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer. Düz
572
+ string'te önek segment sınırında kesilir — `/haber` kuralı `/haberler`i
573
+ etkilemez.
574
+
575
+ Uçuşta olan bir render de hedeflenir: purge'den önce başlamış bir tur, sonucu
576
+ artık eski veriyi taşıdığı için önbelleğe **yazılmaz** ve bir sonraki istek yeni
577
+ bir tur başlatır.
578
+
579
+ ### Otomatik bağımlılık: `clearDataCache` HTML'i de tazeler
580
+
581
+ Hangi sayfanın hangi içerikten etkilendiğini bildirmek gerekmez. Render
582
+ sırasında okunan her `withDataCache` anahtarı kaydedilir; `clearDataCache()` bir
583
+ anahtarı düşürdüğünde onu **fiilen okumuş** bütün HTML girdileri bayatlar.
584
+
585
+ ```js
586
+ // "bu haber güncellendi" webhook'u
587
+ clearDataCache(`haber:${slug}`);
588
+ ```
589
+
590
+ Bu tek satır haber detayını, o haberi listeleyen ana sayfayı ve etiket
591
+ sayfasını birlikte tazeler — çünkü üçü de o anahtarı okumuştu. Elle tag'lemede
592
+ en sık yapılan hata (detayı işaretleyip listeyi unutmak) burada yapısal olarak
593
+ mümkün değil: bildirim değil, gözlem var.
594
+
595
+ Ayrıntılar:
596
+
597
+ - Bağımlılık **her tazelemede yeniden** toplanır; sayfanın okuduğu anahtarlar
598
+ zamanla değişebilir.
599
+ - Render sürerken gelen bir purge de yakalanır: o turun çıktısı "doğduğu anda
600
+ bayat" olacağı için önbelleğe yazılmaz.
601
+ - Sayfa başına bağımlılık sayısı `getHtmlCacheEntries()` dökümünde `deps`
602
+ alanında görünür. Bir invalidation beklediğiniz sayfayı tazelemiyorsa önce
603
+ buraya bakın: sayfa o veriyi `withDataCache` üzerinden okumuyor olabilir.
604
+ - `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yoktur;
605
+ `cache().trackDependencies: false` ile izleme tamamen kapatılabilir.
606
+ - Bayatlatılan yollar ısıtma kuyruğunun **başına** alınır. `prewarm` kuruluysa
607
+ sayfa, ziyaretçi gelmesini beklemeden tazelenir ve tur özeti bunu ayırt eder:
608
+ `[prewarm] warmed 12/12 pages, 3 invalidated (0.4s)`.
609
+
610
+ Bir yönetim ucu yazmak için:
611
+
612
+ ```js
613
+ import { clearHtmlCache, getHtmlCacheEntries } from "jskelet";
614
+
615
+ export default function register(app) {
616
+ app.post("/_admin/cache/temizle", (req, res) => {
617
+ if (req.headers["x-admin-token"] !== process.env.ADMIN_TOKEN) {
618
+ res.status(404).end();
619
+ return;
620
+ }
621
+ clearHtmlCache();
622
+ res.json({ ok: true });
623
+ });
624
+
625
+ app.get("/_admin/cache", (req, res) => {
626
+ res.json(getHtmlCacheEntries());
627
+ });
628
+ }
629
+ ```
630
+
631
+ Dev sunucusu ayrıca manifest her değiştiğinde önbelleği kendiliğinden temizler:
632
+ saklanan HTML eski hash'li varlık URL'lerini taşıyor olur ve temizlenmezse sayfa
633
+ silinmiş dosyayı istemeye devam eder ([09-dev-araclari.md](./09-dev-araclari.md)).
634
+
635
+ Önbellek süreç belleğinde yaşadığı için birden fazla süreç/kopya çalıştırıyorsanız
636
+ her birinin kendi önbelleği olur; `clearHtmlCache()` yalnızca çağrıldığı süreci
637
+ etkiler. Birden fazla instance çalıştıran bir kurulumda bunu aşmanın yolu bir
638
+ sonraki bölümde.
639
+
640
+ ## Paylaşımlı önbellek: Redis
641
+
642
+ Varsayılan önbellek tek prosese ait. Bu, tek instance çalışan bir sitede en hızlı
643
+ ve en basit kurulum — ama üç kopya çalıştırdığınızda iki sorun çıkar:
644
+
645
+ 1. **Her kopya kendi başına ısınır.** Yeni bir instance açıldığında ya da bir
646
+ deploy sonrası konteyner yenilendiğinde önbellek boştur; aynı sayfa üç kez
647
+ render edilir, aynı veri üç kez çekilir.
648
+ 2. **Invalidation tek kopyaya ulaşır.** `invalidateHtmlCache()` çağıran webhook
649
+ yalnızca isteği alan instance'ı tazeler; diğerleri TTL'i bekler. Ziyaretçi
650
+ hangi kopyaya düştüğüne göre eski ya da yeni içeriği görür.
651
+
652
+ `cache().redis` bu iki sorunu çözer. Redis **birincil store olmaz**: bellek içi
653
+ önbellek (L1) aynen kalır ve her istek onu okur; Redis ikinci kademedir (L2).
654
+
655
+ ```js
656
+ // jskelet.config.mjs
657
+ export default {
658
+ cache() {
659
+ return {
660
+ html: { "/haber/:slug": 300 },
661
+ redis: {
662
+ enabled: true,
663
+ url: process.env.REDIS_URL,
664
+ namespace: "haber-sitesi",
665
+ },
666
+ };
667
+ },
668
+ };
669
+ ```
670
+
671
+ `ioredis` opsiyonel bir peer bağımlılıktır, uygulamanın kendisine kurulur:
672
+
673
+ ```bash
674
+ npm install ioredis
675
+ ```
676
+
677
+ Kurulmadıysa ya da Redis'e bağlanılamıyorsa uyarı basılır ve site **bellek içi
678
+ önbellekle çalışmaya devam eder**. Redis çalışırken düşerse aynı şey olur: bir
679
+ devre kesici art arda beş hatadan sonra katmanı beş saniye baypas eder, böylece
680
+ her istek ağ zaman aşımı beklemez.
681
+
682
+ ### Ne kazanırsınız
683
+
684
+ - **Soğuk instance sıcak önbellek bulur.** L1'de olmayan bir yol için render
685
+ çalışmadan önce Redis okunur; başka bir kopya o sayfayı ürettiyse render hiç
686
+ çalışmaz.
687
+ - **Veri önbelleği kotayı bir kez harcar.** `withDataCache` aynı mantıkla
688
+ çalışır ve kazanç burada daha büyük: JSON küçük, bir kopyanın çektiği veri
689
+ hepsine yeter.
690
+ - **Invalidation her kopyaya gider.** `invalidateHtmlCache()`,
691
+ `clearHtmlCache()` ve `clearDataCache()` bir pub/sub kanalına mesaj bırakır;
692
+ her instance kendi L1'ine aynı işlemi uygular. Deseni yayınlar, eşleşen
693
+ anahtarları değil — hangi yolun nerede sıcak olduğu kopyaya bağlı.
694
+
695
+ ### Anahtar düzeni
696
+
697
+ ```
698
+ _jskelet:{namespace}:{buildId}:html:{yol}?{query}
699
+ _jskelet:{namespace}:{buildId}:data:{anahtar}
700
+ _jskelet:{namespace}:events
701
+ ```
702
+
703
+ `buildId` her build'de değişir (`jskelet build` bunu `.jskelet/build.json`
704
+ dosyasına yazar) ve **zorunlu bir parçadır**: saklanan HTML hash'li varlık
705
+ yollarını gömüyor, yani bir deploy'dan sonra eski HTML geçersizdir. Kimlik
706
+ önekte durduğu için yeni sürüm kendiliğinden yeni bir isim alanına yazar, eski
707
+ anahtarlar TTL ile ölür — elle temizlik ya da `FLUSHDB` gerekmez. Build
708
+ çalıştırılmadıysa kimlik `dev` olur.
709
+
710
+ `namespace` aynı Redis'i paylaşan birden fazla uygulamayı ayırır. Olay kanalı
711
+ bilinçli olarak `buildId` **taşımaz**: deploy sırasında eski ve yeni sürüm yan
712
+ yana koşuyor ve bir purge ikisine de ulaşmalı.
713
+
714
+ ### Bilmeniz gereken takaslar
715
+
716
+ - **Kişiye özel çıktı paylaşılmaz.** `storable: false` işaretlenen bir render
717
+ (cookie/`Authorization` okuyan sayfa) Redis'e hiç yazılmaz. Tek prosesteyken
718
+ bile geçerli olan bu kural paylaşımlı kademede daha da kritik: sızması, bir
719
+ kullanıcının HTML'ini tüm kümeye servis etmek olur. `degraded` render ve 200
720
+ dışındaki durum kodları da paylaşılmaz.
721
+ - **Sıkıştırılmış gövdeler varsayılan olarak yerel kalır.** `storeEncoded: true`
722
+ ile açılabilir, ama girdi başına boyutu iki-üç katına çıkarır; brotli'yi
723
+ yeniden üretmek çoğu zaman Redis'ten indirmekten ucuzdur.
724
+ - **Yumuşak invalidation Redis kopyasını siler.** Bayatlatmanın Redis karşılığı
725
+ her anahtar için oku-değiştir-yaz turu demek ve bir webhook binlerce anahtarı
726
+ birden düşürüyor. Silmenin bedeli, o yolu hiç görmemiş bir kopyanın bir kez
727
+ render etmesi; L1'i sıcak olan kopyalar eski HTML'i bayat pencerede servis
728
+ etmeye devam eder.
729
+ - **Yalnızca taze girdi kabul edilir.** Bayat bir kopyayı L1'e almak tazelemeyi
730
+ sonsuza kadar ertelerdi: girdi bayat kalır, her tur yine Redis'i okur ve
731
+ render hiç çalışmaz.
732
+ - **Tutarlılık nihai.** Bir purge ile o purge'ün her kopyaya ulaşması arasında
733
+ kısa bir pencere var. Bu pencerede bir kopya eski HTML'i servis edebilir;
734
+ süresi TTL ile sınırlı.
735
+ - **Dev'de kapalı tutun.** Dev sunucusu manifest her değiştiğinde önbelleği
736
+ boşaltıyor; paylaşımlı bir store bunu anlamsızlaştırır. `enabled` yalnızca
737
+ açıkça `true` verildiğinde açılır.
738
+
739
+ ### Durumu görmek
740
+
741
+ ```js
742
+ import { getRedisStatus } from "jskelet";
743
+
744
+ app.get("/api/healthcheck", (req, res) => {
745
+ res.json({ ok: true, cache: getRedisStatus() });
746
+ });
747
+ ```
748
+
749
+ Bağlantı kurulmamışken de güvenle çağrılır. Dönen nesne
750
+ `{ enabled, connected, keyPrefix, buildId, errors, bypassed }`; `bypassed` devre
751
+ kesicinin açık olduğunu, `errors` toplam komut hatasını gösterir. Aynı özet dev
752
+ panelinin raporunda da var ([09-dev-araclari.md](./09-dev-araclari.md)).
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
+
761
+ Ayarların tam listesi: [07-yapilandirma.md](./07-yapilandirma.md).
762
+
763
+ ## Yönetim paneli
764
+
765
+ Yukarıdaki `getHtmlCacheEntries()` / `getRedisStatus()` uçlarını elle yazmak
766
+ yerine framework hazır bir panel taşıyor. Dev overlay'den ayrı bir şey: overlay
767
+ yalnızca `NODE_ENV=development` iken var, panel ise ortama bakmaz — "bu sayfa
768
+ neden bayat", "webhook purge'ü geçti mi", "Redis gerçekten bağlı mı" soruları
769
+ üretimde soruluyor.
770
+
771
+ ```js
772
+ // jskelet.config.mjs
773
+ export default {
774
+ cache() {
775
+ return {
776
+ html: { "/haber/:slug": 300 },
777
+ panel: { enabled: process.env.CACHE_PANEL === "1" },
778
+ };
779
+ },
780
+ };
781
+ ```
782
+
783
+ `enabled` verilmedikçe **hiçbir şey mount edilmez**: yol yoktur, modül
784
+ yüklenmez, üretim sürecinde hiçbir maliyeti olmaz. Ortam değişkeni
785
+ (`JSKELET_CACHE_PANEL=1`) config'i ezer; panel genelde bir arıza sırasında tek
786
+ seferlik açılıyor ve o an config dosyasını değiştirip yeniden dağıtmak
787
+ istenmiyor.
788
+
789
+ Panel açıldığında sunucu logu şifreyi basar:
790
+
791
+ ```
792
+ [cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
793
+ ```
794
+
795
+ ### Erişim ve güvenlik
796
+
797
+ - **Şifre her süreç başlangıcında yeniden üretilir** (32 haneli, onaltılık) ve
798
+ yalnızca logda görünür. Kalıcı bir sır tutulmuyor: sızması önbelleği boşaltma
799
+ yetkisi demek ve bir deploy eski erişimi kendiliğinden iptal etmeli.
800
+ - **Şifre query string ile kabul edilmez.** Erişim logları, tarayıcı geçmişi ve
801
+ `Referer` başlığı sırrı taşımasın; giriş yalnızca form üzerinden.
802
+ - **Üç başarısız denemede IP 24 saat yasaklanır** (`banAttempts`, `banHours`).
803
+ Yanlış şifre kadar oturumsuz istek de sayılır; başarılı giriş sayacı sıfırlar.
804
+ - **Yasaklı ve yetkisiz her cevap `404`.** 401/403 panelin var olduğunu
805
+ doğrular, 404 hiç yokmuş gibi davranır. Panelin dışındaki site etkilenmez.
806
+ - **Hiçbir yeri indekslenmez:** her cevapta `X-Robots-Tag: noindex, nofollow,
807
+ noarchive, nosnippet`, `Cache-Control: no-store` ve `Referrer-Policy:
808
+ no-referrer`. Yol ayrıca ısıtma listesinden ve gezinme ipuçlarından muaftır.
809
+ - Aksiyonlar `X-JSkelet-Cache-Panel` başlığı ister — çapraz siteden
810
+ gönderilemeyen bir başlık, yani panelin kendi CSRF freni.
811
+ - Oturumlar ve yasak sayaçları süreç belleğinde durur; şifresi zaten her
812
+ restart'ta değişen bir panel için diske yazmak yanlış takas olurdu.
813
+
814
+ ### Panelde ne var
815
+
816
+ | Bölüm | Gösterdiği |
817
+ | --- | --- |
818
+ | Üst satır | Sürüm, ortam, pid, uptime, RSS ve dil seçimi (Türkçe / İngilizce) |
819
+ | Kartlar | HTML girdi sayısı ve sınırı, bellekteki HTML boyutu, bayat girdi sayısı, veri girdisi sayısı, Redis durumu (`connected` / `bypassed` / `off`), ısıtma turunun ilerlemesi |
820
+ | Paylaşımlı kademe | Bağlantının **nereye** kurulduğu (adres, TLS, veritabanı), anahtar öneki ve `namespace`, `buildId`, hangi türlerin paylaşıldığı, sıkıştırılmış gövde ve purge yayını durumu, komut zaman aşımı ve hata sayısı. Kapalıysa yerine Redis önerisi ve kurulum parçacığı çıkar. |
821
+ | Cloudflare | Zone, plan, cache ile ilgili zone ayarları, development mode'un kalan süresi, Tiered Cache / Cache Reserve durumu ve cache isabet oranı. Bağlı değilse kurulum parçacığı çıkar. |
822
+ | Host | Makinenin RAM kullanımı ve projenin bulunduğu diskin doluluğu |
823
+ | Girdi listesi | HTML: yol (yeni sekmede açılır), taze/bayat, boyut, durum kodu, kalan TTL, bağımlılık sayısı, hazır sıkıştırılmış gövdeler. Veri: anahtar (tıklayınca panoya kopyalanır), taze/bayat, kalan TTL |
824
+
825
+ Liste **anahtar bazında filtrelenir** ve filtre sunucuda uygulanır: veri
826
+ önbelleğinde on binlerce anahtar olabiliyor. Her istekte en fazla 500 satır
827
+ döner; başlıktaki sayaç kaç eşleşmenin kesildiğini söyler. HTML gövdesi ve veri
828
+ değerleri **hiç dönmez** — panelin işi durumu göstermek, içeriği dışa vermek
829
+ değil.
830
+
831
+ ### Panelden yapılabilenler
832
+
833
+ | İşlem | Karşılığı |
834
+ | --- | --- |
835
+ | Invalidate (hedef + `hard`) | `invalidateHtmlCache(target, { hard })` |
836
+ | Tek satırı `drop` | `dropHtmlCacheKey(key)` / `dropDataCacheKey(key)` |
837
+ | Clear HTML cache | `clearHtmlCache()` |
838
+ | Clear data cache (önek opsiyonel) | `clearDataCache(prefix)` |
839
+ | Drop shared keys | Redis'teki `html` ya da `data` isim alanını tarar ve düşürür |
840
+ | Count keys in Redis | `inspectRedis()` — tür başına anahtar sayısı, `DBSIZE` ve `used_memory` |
841
+ | Prewarm | `prewarm()` — tur arkada koşar, ilerleme kartta görünür |
842
+ | Cloudflare purge (her şey / bellekteki URL'ler / prefix / host / tag) | `purgeCloudflare()` |
843
+ | Cloudflare ayarı ya da özelliği değiştirmek | Zone ayarları ve Tiered Cache / Cache Reserve |
844
+
845
+ Hepsi paylaşımlı kademeye de yayılır: tek kopyanın önbelleğini boşaltmak,
846
+ kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
847
+
848
+ Panel iki dilde: header'daki seçici Türkçe ile İngilizce arasında geçiş yapar.
849
+ İlk açılışta tarayıcının diline bakılır, seçim `localStorage`'da tutulur ve
850
+ giriş sayfasına da uygulanır. Dil değişimi hiçbir isteğe yol açmaz. Sunucu
851
+ tarafı arayüz dilini hiç bilmez: `/action` cevabı metin değil bir kod döner
852
+ (`{ ok, code, params }`) ve cümleyi panel kurar — framework'ün log'u ve API'si
853
+ tek dilde kalır.
854
+
855
+ Listedeki tek satırı silmek `invalidateHtmlCache()`ten farklıdır: o yol
856
+ desenine bakar ve bir yolun **bütün** query varyantlarını düşürür,
857
+ `dropHtmlCacheKey()` ise tam anahtarı alır — `/liste?sayfa=2` düşerken
858
+ `/liste?sayfa=3` sıcak kalır.
859
+
860
+ ## CDN kademesi: Cloudflare
861
+
862
+ Buraya kadar anlatılan her şey **origin** önbelleği. Önünde Cloudflare varsa
863
+ ziyaretçinin gördüğü HTML çoğu zaman hiç size ulaşmıyor: edge'deki kopya
864
+ TTL'ini doldurana kadar servis edilir. Bu yüzden `invalidateHtmlCache()`
865
+ tek başına "sayfayı güncelledim ama eski hâli görünüyor" sorununu çözmez —
866
+ origin tazelenir, edge beklemeye devam eder.
867
+
868
+ JSkelet iki kademeyi aynı yerden yönetilebilir kılar.
869
+
870
+ ### Kurulum
871
+
872
+ Token bir sır; config dosyasına değil ortama yazılır:
873
+
874
+ ```bash
875
+ JSKELET_CLOUDFLARE_KEY=... # API token
876
+ JSKELET_CLOUDFLARE_ZONE_ID=... # zone kimliği
877
+ JSKELET_CLOUDFLARE_HOSTNAME=example.com # opsiyonel
878
+ ```
879
+
880
+ Token'a gereken izinler, yapmak istediğinize göre: purge için `Zone.Cache
881
+ Purge`, ayarları değiştirmek için `Zone.Zone Settings`, isabet oranı ve edge
882
+ kırılımı için `Zone.Analytics` (salt okunur). Yalnızca purge izni verilen bir
883
+ token'la panel açılır, ayar bölümleri hata yazar.
884
+
885
+ Zone kimliği ve site adı sır olmadığı için `jskelet.config.mjs` içinden de
886
+ verilebilir; env her zaman önceliklidir:
887
+
888
+ ```js
889
+ cache: {
890
+ cloudflare: {
891
+ zoneId: "…",
892
+ hostname: "example.com", // purge tam URL ister; yol → URL çevrimi için
893
+ analyticsHours: 24,
894
+ },
895
+ }
896
+ ```
897
+
898
+ `hostname` verilmezse purge URL'leri panelin açıldığı origin'den türetilir.
899
+ Paneli iç bir adresten (`http://10.0.0.4:3000`) açıyorsanız bu adresin
900
+ Cloudflare'de karşılığı yok; o kurulumda `hostname` zorunlu.
901
+
902
+ ### Ne yapılabilir
903
+
904
+ Cloudflare'in cache yüzeyinde ne varsa panelde de var:
905
+
906
+ | İşlem | Not |
907
+ | --- | --- |
908
+ | Purge everything | Zone'un tamamı. En kaba araç; ısınma maliyeti yüksek |
909
+ | Purge by URL | Panelin o an bellekte tuttuğu sayfalar tek düğmeyle; ya da satır başına `cf purge` |
910
+ | Purge by prefix / host / tag | Artık her planda çalışıyor; istek başına 100 anahtar |
911
+ | Development mode | Üç saat boyunca edge önbelleğini baypas eder, sonra kendiliğinden kapanır |
912
+ | Cache level, browser cache TTL, query string sıralaması, Always Online | Zone ayarları |
913
+ | Tiered Cache, Regional Tiered Cache, Cache Reserve | Plana bağlı; kapalı planda "unavailable" görünür |
914
+ | Clear Cache Reserve | Purge'den ayrı: `purge_everything` edge'i düşürür, R2'deki kalıcı kopya kalır |
915
+
916
+ Uzun URL listeleri istek başına 100 anahtarlık partilere bölünür ve **sırayla**
917
+ gönderilir. Paralel göndermek Free planda dakikada beş isteklik hız freni
918
+ yüzünden yarısı reddedilen bir tur demek.
919
+
920
+ Kod tarafında aynı yüzey:
921
+
922
+ ```js
923
+ import { invalidateHtmlCache, purgeCloudflare, toCloudflareUrls } from "jskelet";
924
+
925
+ export async function onPostPublished(slug) {
926
+ const paths = ["/", `/blog/${slug}`];
927
+
928
+ invalidateHtmlCache(paths); // origin
929
+ await purgeCloudflare({ files: toCloudflareUrls(paths) }); // edge
930
+ }
931
+ ```
932
+
933
+ Bu modüldeki hiçbir fonksiyon fırlatmaz: token yoksa, Cloudflare 403 dönerse
934
+ ya da ağ düşerse sonuç `{ ok: false, error }` olur. Bir CDN arızası içerik
935
+ yayınlama akışını kesmemeli.
936
+
937
+ ### "Bu sayfa kaç edge'de cache'li?" — sorulabilen ve sorulamayan
938
+
939
+ Cloudflare API'sinde bir objenin **envanterini** veren uç yok. Yüzlerce şehirde
940
+ birbirinden bağımsız önbellekler var ve hiçbiri "şu an bende bu URL'in kopyası
941
+ var mı" sorusuna cevap vermiyor. Panel bu yüzden envanter değil **gözlem**
942
+ gösterir: bir yol girip sorguladığınızda GraphQL analitiğinden son N saatte
943
+ hangi kolonun (IST, FRA, AMS…) o yolu kaç kez cache'ten, kaç kez origin'den
944
+ servis ettiği gelir.
945
+
946
+ ```js
947
+ const report = await fetchPathEdges({ path: "/blog", hours: 24 });
948
+ // → { colos: [{ colo: "IST", hits: 7, misses: 2 }, …], hits, misses }
949
+ ```
950
+
951
+ Okurken iki sınırı akılda tutun: hiç istek almamış bir edge listede görünmez,
952
+ kopyası olsa da; ve veri kümesi örneklemeli, yani oranlar güvenilir ama mutlak
953
+ sayılar yaklaşıktır.
954
+
955
+ Seçtiğiniz bir edge'i **ısıtmanın** da yolu yok. Bir obje ancak o koloya
956
+ yönlenen gerçek bir istekle o edge'in önbelleğine giriyor; sunucudan
957
+ "Frankfurt'a bunu cache'let" diyemezsiniz. Pratikte yapılabilen üç şey var:
958
+
959
+ - **Origin'i ısıtmak** (`prewarm`): ilk isteği alan edge cevabı hazır bulur,
960
+ o istek yavaşlamaz.
961
+ - **Tiered Cache**: edge'ler origin'e doğrudan gitmez, aradaki üst katmandan
962
+ besleniyor; bir şehirdeki ilk istek diğer şehirler için de ısıtma sayılır.
963
+ - **Cache Reserve**: uzun kuyruklu içerik için R2'de kalıcı kopya; edge
964
+ düşünce istek origin'e kadar inmiyor.
965
+
966
+ `hit` oranınız düşükse önce cevabın cache'lenebilir olup olmadığına bakın:
967
+ `Cache-Control: private`, `Set-Cookie` ve query string ayarları edge'in
968
+ cache'lememe kararının en sık sebepleri, ve bu panelde `dynamic` olarak
969
+ görünür.
970
+
971
+ ## Prewarm — açılışta ısıtma
972
+
973
+ Next'teki build-time prerender'ın karşılığı, ama çıktı diske yazılmaz: önbellek
974
+ süreç belleğinde yaşadığı için ısıtma da süreç ayağa kalkınca yapılır. Kazanç
975
+ aynı — ilk ziyaretçi soğuk render'ı beklemez — fakat veri dondurulmaz; her girdi
976
+ route'un `revalidate` süresiyle yaşlanır ve stale-while-revalidate ile arkada
977
+ tazelenir.
978
+
979
+ Isıtma **gerçek HTTP istekleriyle** yapılır (`http://127.0.0.1:<port>`), çünkü
980
+ cache anahtarı, sıkıştırma ve middleware zinciri normal trafikle bire bir aynı
981
+ olsun.
982
+
983
+ ### `hooks.prewarmPaths()`
984
+
985
+ Hangi yolların ısıtılacağını uygulama bildirir; genelde sitemap üreten
986
+ fonksiyonun aynısıdır.
987
+
988
+ ```js
989
+ // jskelet.config.mjs
990
+ export default {
991
+ hooks: {
992
+ async prewarmPaths() {
993
+ const slugs = await getAllArticleSlugs();
994
+ return ["/", "/piyasalar", ...slugs.map((slug) => `/haber/${slug}`)];
995
+ },
996
+ },
997
+ };
998
+ ```
999
+
1000
+ Kurallar:
1001
+
1002
+ - Dizi döndürmezse uyarı basılır ve ısıtma yapılmaz.
1003
+ - Yalnızca `/` ile başlayan string'ler alınır.
1004
+ - `prewarmSkip` öneklerinden biriyle başlayanlar atlanır. Varsayılan liste:
1005
+ `/api/`, `/_fragment/`, `/__jskelet/`. Oturuma bağlı sayfalar ısıtılmamalı.
1006
+ - Tekilleştirme **sırayı korur**: `priority` verilmediğinde uygulamanın verdiği
1007
+ sıra anlamlıdır — en önemli sayfaları başa koyun.
1008
+ - Bu hook tanımlı değilse ısıtma hiç kurulmaz; zamanlayıcı bile açılmaz.
1009
+
1010
+ ### Tur mantığı
1011
+
1012
+ 1. Liste toplanır. `max`'tan (varsayılan 400) uzunsa bir dilim seçilir:
1013
+ `priority` eşleşenler **her turda** başa alınır, kalan yerler kuyruktan
1014
+ doldurulur.
1015
+ 2. `concurrency` işçi paralel olarak istek atar (prod'da 4, dev'de 1). Dev'de
1016
+ tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU için
1017
+ yarışmasın.
1018
+ 3. `rps` verilmişse tur bu hızın üstüne çıkmaz — paralellik ne olursa olsun.
1019
+ Dev'de varsayılan olarak saniyede 4 istek uygulanır: render tek bir olay
1020
+ döngüsünde çalıştığı için aralıksız bir tur, sayfa isteklerini ve dev
1021
+ panelinin canlı kanalını arkasında bekletiyor.
1022
+ 4. **Geçici** hata alan yollar için **tek seri tekrar turu** yapılır
1023
+ (`concurrency: 1`). `400`/`403`/`404` gibi kalıcı cevaplar tekrar turuna
1024
+ girmez: deterministik bir hata tekrar denemekle düzelmez ve o çağrılar
1025
+ kotadan karşılıksız yer. Özette `N not retried (permanent)` olarak görünür.
1026
+ 5. Bekleme süresi `retryDelayMs`, ama hız freni açıkken **freni bekleten süre**
1027
+ varsa o kazanır: 10 saniye kapalı kalacak bir devre kesiciden 2 saniye sonra
1028
+ tekrar denemek, aynı 429'u peşin peşin almak olurdu.
1029
+ 6. Özet loglanır:
1030
+ `[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
1031
+
1032
+ Ardından turun upstream'e ne kadar dokunduğu basılır:
1033
+
1034
+ ```text
1035
+ [prewarm] 12 upstream calls for 430 data reads (97% from the data cache, 38 coalesced)
1036
+ ```
1037
+
1038
+ Bu satır ayarın hangi yönde değişmesi gerektiğini söyleyen tek satır. Oran
1039
+ düşükse çözüm hız freni değil, `withDataCache` TTL'ini tur aralığından uzun
1040
+ tutmak: fren çağrıları yavaşlatır, sayısını azaltmaz. Aynı sayaçlara
1041
+ `getDataCacheStats()` ile ya da dev raporundaki **Data cache** kartından da
1042
+ bakılabilir.
1043
+
1044
+ Tur sırasında oluşan istek hataları ve sayfa başına basılan render uyarıları
1045
+ (`was produced with missing data`, `returned notFound() while upstream is
1046
+ failing`, `could not be produced`) tek tek loglanmaz; sayılır ve tur bitince
1047
+ özetin ardından, en sık görülen türler başta olacak şekilde basılır:
1048
+
1049
+ ```text
1050
+ [prewarm] 137 problems were not logged individually:
1051
+ 94× missing data, upstream is failing permanently (403 /api/v1/polls)
1052
+ 37× missing data, upstream is failing permanently (400 /api/v1/posts)
1053
+ 6× 500 Cannot read properties of undefined (reading 'title')
1054
+ ```
1055
+
1056
+ Böylece bir anlık upstream arızası "warmed …" satırını yüzlerce yığın izinin
1057
+ altına gömmüyor. Gerçek trafiğin hataları eskisi gibi anında loglanır, tek bir
1058
+ yolun ayrıntısı için dev panelindeki **Prewarming** sekmesine bakılır.
1059
+
1060
+ ### Isıtma sırası: `priority`
1061
+
1062
+ ```js
1063
+ // jskelet.config.mjs
1064
+ cache: () => ({
1065
+ prewarm: {
1066
+ priority: [
1067
+ "/",
1068
+ "/piyasalar/:path*",
1069
+ /-yorumlar$/,
1070
+ ],
1071
+ },
1072
+ }),
1073
+ ```
1074
+
1075
+ Desen sözdizimi (`/haber/:slug`) ve doğrudan `RegExp` birlikte kullanılabilir;
1076
+ ikincisi "sonu `-yorumlar` ile bitenler" gibi desen sözdiziminin karşılamadığı
1077
+ kurallar için. Önce yazılan önce ısınır, hiçbirine uymayan yollar kuyruğa gider
1078
+ ve kendi aralarındaki sırayı korur.
1079
+
1080
+ ### Damla damla ısıtma: `rotate` + `rps` + `intervalSeconds`
1081
+
1082
+ 10.000 yolluk bir sitede tek turda her şeyi ısıtmak ne mümkün (HTML önbelleği
1083
+ 500 girdi tutar) ne de doğru (API kotası dolar). Doğru davranış listeyi zamana
1084
+ yaymak:
1085
+
1086
+ ```js
1087
+ prewarm: {
1088
+ max: 300, // her turda 300 sayfa
1089
+ rps: 4, // saniyede en fazla 4 istek
1090
+ intervalSeconds: 300, // 5 dakikada bir tur
1091
+ rotate: true, // kuyruk kaldığı yerden devam eder
1092
+ priority: ["/", "/piyasalar/:path*"],
1093
+ }
1094
+ ```
1095
+
1096
+ Bu kurulumda öncelikli sayfalar her turda tazelenir, geri kalan kuyruk turlar
1097
+ boyunca baştan sona dolaşılır ve upstream saniyede dört isteğin üstünü hiç
1098
+ görmez. Veri önbelleği ile birlikte kullanıldığında ikinci turdan sonra ısıtma
1099
+ API'ye neredeyse hiç gitmez: veri katmanından okur.
1100
+
1101
+ Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki tura
1102
+ kalıyor; log bunu ayırt eder:
1103
+ `… , 700 deferred to the next pass`. `rotate: false` ile klasik davranışa
1104
+ dönülür — her tur listenin aynı ilk dilimini ısıtır ve gerisi hiç ısınmaz
1105
+ (`… , 700 over the limit`).
1106
+
1107
+ Bir tur `intervalSeconds`'tan uzun sürerse yeni tur başlatılmaz; üst üste binen
1108
+ turlar upstream'e iki kat yük bindirirdi.
1109
+
1110
+ İstekler `user-agent: jskelet-prewarm` (`brand.prewarmUserAgent`) ve
1111
+ `accept-encoding: br, gzip` başlıklarıyla gider; ikincisi sıkıştırılmış gövdenin
1112
+ de önbelleğe girmesi için.
1113
+
1114
+ `DEV_TOKEN` ayarlıysa ısıtma token'ı çerez olarak taşır; yoksa dev gate tüm
1115
+ sayfalara 404 döner ve önbellek hiç dolmaz.
1116
+
1117
+ Dev panelindeki istek listesi ve terminal, `prewarmUserAgent` taşıyan istekleri
1118
+ filtreler: yüzlerce ısıtma isteği görünümü doldurmasın. İlerleme baloncuğun
1119
+ yanındaki rozette görünür.
1120
+
1121
+ ### Zamanlama
1122
+
1123
+ - Isıtma açılışta **gecikmeyle** başlar: ilk gerçek isteklerle yarışmasın.
1124
+ Varsayılan gecikme prod'da 500 ms, dev'de 3000 ms. Dev'de daha uzun, çünkü
1125
+ dosya kaydı süreci yeniden başlattığı için zamanlayıcı da ölür; yalnızca
1126
+ sunucu bir süre sakin kalınca ısınır.
1127
+ - `PREWARM_INTERVAL_SECONDS` / `cache().prewarm.intervalSeconds` > 0 ise tur
1128
+ periyodik tekrarlanır. Girdiler `revalidate` ile yaşlandığı ve
1129
+ stale-while-revalidate sayesinde ziyaretçi beklemediği için bu **opsiyoneldir**;
1130
+ hiç ziyaret edilmeyen sayfaları da sıcak tutmak isteyen kurulumlar için.
1131
+ - Tüm zamanlayıcılar `unref()` edilmiştir: süreç kapanışını geciktirmezler.
1132
+ - Hiçbir ısıtma hatası süreci düşürmez.
1133
+
1134
+ ### Ayarlar
1135
+
1136
+ Öncelik sırası: **ortam değişkeni → config → kod varsayılanı.** Env önde, çünkü
1137
+ tek seferlik deneyler config'i düzenlemeden yapılabilsin.
1138
+
1139
+ | Ayar | Env | `cache().prewarm` | Varsayılan |
1140
+ | --- | --- | --- | --- |
1141
+ | Açık/kapalı | `PREWARM=0` kapatır, `PREWARM=1` config'i ezip açar | `enabled` | `true` |
1142
+ | Tur başına en fazla yol | `PREWARM_MAX` | `max` | `400` |
1143
+ | Paralellik | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
1144
+ | Saniyedeki istek | `PREWARM_RPS` | `rps` | prod `0` (sınırsız), dev 4 |
1145
+ | Başlangıç gecikmesi (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
1146
+ | Tekrar turu gecikmesi (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
1147
+ | Periyot (saniye) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (kapalı) |
1148
+ | Kuyruk rotasyonu | — | `rotate` | `true` |
1149
+ | Isıtma sırası | — | `priority` | `[]` |
1150
+
1151
+ Sayısal ayarlar yalnızca **pozitif ve sonlu** değer kabul eder; geçersiz bir
1152
+ değer sessizce bir sonraki katmana düşer.
1153
+
1154
+ ### Elle tetikleme
1155
+
1156
+ ```js
1157
+ import { prewarm, prewarmProgress } from "jskelet";
1158
+
1159
+ await prewarm({ origin: "http://127.0.0.1:3000" }); // hook'tan yollar
1160
+ await prewarm({ origin, paths: ["/", "/piyasalar"] }); // yalnızca bu yollar
1161
+ await prewarm({ origin, quiet: true }); // özet basmadan
1162
+ ```
1163
+
1164
+ `paths` verilirse hook hiç çağrılmaz. Dönüş değeri
1165
+ `{ ok, failed, total, elapsed }`.
1166
+
1167
+ `prewarmProgress` canlı durumu tutar ve dev paneli bunu okur:
1168
+
1169
+ ```js
1170
+ {
1171
+ active, done, total, ok, failed, startedAt, finishedAt,
1172
+ entries: [{ path, status, ms, bytes, cache, error }],
1173
+ }
1174
+ ```
1175
+
1176
+ `entries` içindeki `cache` alanı o yolun `X-JSkelet-Cache` yanıtıdır; ısıtma
1177
+ turunun gerçekten `MISS` → önbellek doldurup doldurmadığını buradan görürsünüz.
1178
+
1179
+ ## Teşhis: sık görülen durumlar
1180
+
1181
+ - **Her istek `MISS` dönüyor.** Route'a `revalidate` verilmemiş ya da
1182
+ `cache().html` içindeki desen 0 saniye veriyor. Veya sayfa `status: 200`
1183
+ dışında bir kod dönüyor.
1184
+ - **Sayfa `MISS` dönüyor ama upstream sağlam.** Geçici bir upstream hatası
1185
+ bildirilmiş olabilir; logda `was produced with missing data, not caching it`
1186
+ satırını arayın.
1187
+ - **Sürekli eski veri.** `revalidate` çok yüksek; unutmayın ki gerçek gecikme en
1188
+ fazla `revalidate` + bir tazeleme turudur.
1189
+ - **Önbellek şişiyor.** Query parametreleri anahtara girdiği için kampanya
1190
+ parametreleri girdi çoğaltıyor olabilir.
1191
+ - **Isıtma hiç çalışmıyor.** `hooks.prewarmPaths` tanımlı değil, `PREWARM=0`
1192
+ ayarlı ya da `cache().prewarm.enabled === false`.
1193
+ - **Isıtma turu API'yi 429'a sokuyor.** `rps` verilmemiş. `concurrency`
1194
+ düşürmek yeterli değil; kotayı koruyan ayar toplam hız. Kalıcı çözüm veri
1195
+ önbelleği: ikinci turdan sonra ısıtma upstream'e gitmez.
1196
+ - **Isıtma listesi `max`'tan uzun ve sonu hiç ısınmıyor.** `rotate: false`
1197
+ olabilir; logdaki `over the limit` ifadesi bunu gösterir.
1198
+ - **Bir bölümün tamamı 404 dönüyor.** Upstream düşmüş olabilir. Artık bu durumda
1199
+ sayfa bir kez daha denenir, olmazsa 404 değil önbelleğe girmeyen 503 döner;
1200
+ logda `returned notFound() while upstream is failing` satırını arayın. Hâlâ
1201
+ 404 görüyorsanız hata `fetch` dışı bir istemciden geliyor olabilir
1202
+ (`reportUpstreamFailure()` gerekir) ya da `cache().trackUpstream` kapatılmış.
1203
+
1204
+ ## Sırada ne var
1205
+
1206
+ - Config alanlarının tam referansı ve env tablosu:
1207
+ [07-yapilandirma.md](./07-yapilandirma.md)
1208
+ - Önbelleği dev panelinden izlemek: [09-dev-araclari.md](./09-dev-araclari.md)
1209
+ - CDN/ters proxy ile birlikte kullanım: [10-dagitim.md](./10-dagitim.md)