jskelet 0.6.1 → 0.6.3

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