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