jskelet 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +633 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +700 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +351 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +708 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +355 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +965 -356
  134. package/src/server/og-raster.mjs +21 -0
  135. package/src/server/port-guard.js +255 -255
  136. package/src/server/prewarm.js +1082 -1082
  137. package/src/server/redis.js +588 -588
  138. package/src/server/render.js +910 -910
  139. package/src/server/router.js +157 -157
  140. package/src/server/status-page.js +265 -265
  141. package/src/server/upstream-limiter.js +376 -376
  142. package/src/server/upstream-tracking.js +166 -166
  143. package/src/shared/cookie-domain.js +66 -66
  144. package/src/start.mjs +22 -22
  145. package/src/templates/layout.ejs +30 -30
  146. package/src/templates/layout.jsk +30 -30
  147. package/src/version.mjs +31 -31
  148. package/src/views/components/loader.js +101 -101
  149. package/src/views/helpers/html.js +102 -102
  150. package/src/views/helpers/tags.js +375 -375
  151. package/types/config/defaults.d.ts +6 -0
  152. package/types/config/index.d.ts +6 -0
  153. package/types/server/cache-control.d.ts +28 -0
  154. package/types/server/og-image.d.ts +51 -3
@@ -1,1196 +1,1196 @@
1
- /**
2
- * ISR ikamesi: route + query anahtarlı, TTL'li LRU HTML cache.
3
- *
4
- * TTL dolduğunda girdi hemen atılmaz: `stale` pencerede eski HTML anında
5
- * döner ve tazeleme arkada çalışır. Böylece ilk ısıtmadan sonra hiçbir istek
6
- * render'ı beklemez; buna karşılık HTML'deki veri en fazla `revalidate + bir
7
- * tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
8
- * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
- *
10
- * TTL dolmadan önce de tazelenir (**erken tazeleme**): son başarılı üretimin
11
- * süresi (`produceMs`) kadar önden arka plan refresh başlar, böylece yavaş
12
- * bir sayfa TTL anında hâlâ soğuk render'a düşmez. Trafik yoksa sweeper
13
- * girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır.
14
- *
15
- * TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
16
- * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
17
- * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
18
- * TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
19
- * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
20
- * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
21
- * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
22
- *
23
- * ## Paylaşımlı kademe
24
- *
25
- * `cache.redis` açıkken store'un ikinci bir kademesi olur. Bellek içi store
26
- * (L1) **birincil kalır**: `read()` senkron, sıkıştırılmış gövdeler girdiyle
27
- * birlikte ve tutarlılık makinesi (`tokens`, `purgedDeps`) tek proseste. Redis
28
- * yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı
29
- * diğer node'lara duyurur. Redis yoksa aynı kayıt `.jskelet/cache/<buildId>/`
30
- * altına yazılır; bu tek makinenin yeniden açılışını karşılar, kümeyi değil.
31
- */
32
-
33
- import { getConfig } from "../config/index.js";
34
- import {
35
- DEFAULT_HTML_CACHE_MAX_ENTRIES,
36
- HTML_CACHE_BYTE_BUDGET,
37
- } from "../config/defaults.js";
38
- import { collectDependencies } from "./cache-deps.js";
39
- import { compilePattern, matchPattern } from "../config/pattern.js";
40
- import { pathOfCacheKey, publicHost } from "./cache-vary.js";
41
- import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
42
- import {
43
- cacheKey,
44
- onCacheEvent,
45
- publishCacheEvent,
46
- redisDrop,
47
- redisDropMatching,
48
- redisGetJson,
49
- redisSetJson,
50
- redisShares,
51
- redisSharesEncoded,
52
- } from "./redis.js";
53
-
54
- /**
55
- * `storedAt`: girdinin üretildiği an. Paylaşımlı kademeden gelen bir girdiyi
56
- * kabul etmeden önce "bu render yerel bir purge'den önce mi başladı" sorusu
57
- * yine sorulur; cevabı bu alan taşıyor.
58
- *
59
- * `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
60
- * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
61
- * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
62
- *
63
- * `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
64
- * türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
65
- *
66
- * @typedef {{ html: string, status: number, expiresAt: number,
67
- * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
68
- * storedAt: number, sharedEncodings: number, produceMs: number }} HtmlEntry
69
- */
70
-
71
- /**
72
- * Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
73
- * siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
74
- * düşüyor. On binlerce yol için `withDataCache` kullanılır.
75
- *
76
- * Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
77
- * durumda kod varsayılanı geçerli.
78
- *
79
- * @returns {number}
80
- */
81
- function maxEntries() {
82
- try {
83
- return getConfig().htmlMaxEntries;
84
- } catch {
85
- return DEFAULT_HTML_CACHE_MAX_ENTRIES;
86
- }
87
- }
88
-
89
- /**
90
- * Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
91
- * uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
92
- *
93
- * @returns {boolean}
94
- */
95
- function trackDependencies() {
96
- try {
97
- return getConfig().trackDependencies;
98
- } catch {
99
- return true;
100
- }
101
- }
102
-
103
- /**
104
- * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
105
- * Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
106
- * yavaş upstream'lerde devreye girer.
107
- */
108
- const STALE_FACTOR = 1;
109
-
110
- /**
111
- * Redis'ten gelen veya süresi bilinmeyen girdiler için erken tazeleme lead'i.
112
- * Ölçülmüş `produceMs` yokken aşırı iyimser (0) kalmamak için.
113
- */
114
- const DEFAULT_PRODUCE_MS = 500;
115
-
116
- /** Erken tazelemenin alt sınırı — çok hızlı sayfalar da TTL'den önce ısınsın. */
117
- const EARLY_REFRESH_MIN_MS = 250;
118
-
119
- /** Trafiksiz girdileri erken pencerede soft-bayatlatma aralığı. */
120
- const EARLY_SWEEP_INTERVAL_MS = 1000;
121
-
122
- /**
123
- * Bir sweep turunda soft-bayatlatılan girdi tavanı. Aynı TTL'i paylaşan
124
- * yüzlerce sayfa tek saniyede kuyruğa girip render dalgası yaratmasın;
125
- * kalanlar sonraki turlara kalır. Öncelik `expiresAt`'i en yakın olan.
126
- */
127
- const EARLY_SWEEP_MARK_BUDGET = 4;
128
-
129
- /** @type {Map<string, HtmlEntry>} */
130
- const store = new Map();
131
-
132
- /**
133
- * `store` içindeki HTML string + sıkıştırılmış gövdelerin toplamı.
134
- * Her `drop` / `install` bunu günceller; sıkıştırma sonradan eklendiğinde
135
- * `noteHtmlCacheGrowth` artışı yazar. Tam tarama yapmamak için tutulur.
136
- */
137
- let storedBytes = 0;
138
-
139
- /**
140
- * Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
141
- * @type {number | null}
142
- */
143
- let byteBudgetOverride = null;
144
-
145
- /** @type {Map<string, Promise<{ html: string, status: number }>>} */
146
- const inflight = new Map();
147
-
148
- /** @type {ReturnType<typeof setInterval> | null} */
149
- let earlySweepTimer = null;
150
-
151
- /**
152
- * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
153
- * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
154
- * okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
155
- * istek yeni bir tur başlatır.
156
- *
157
- * @type {Map<string, object>}
158
- */
159
- const tokens = new Map();
160
-
161
- /**
162
- * Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
163
- * bunu okuyup etkilenen sayfaları bayatlatır.
164
- *
165
- * @type {Map<string, Set<string>>}
166
- */
167
- const dependents = new Map();
168
-
169
- /**
170
- * Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
171
- * kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
172
- * ziyaretçi gelmesini beklemeden tazelenir.
173
- *
174
- * Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
175
- *
176
- * @type {Set<string>}
177
- */
178
- const invalidated = new Set();
179
-
180
- const MAX_INVALIDATED = 500;
181
-
182
- /**
183
- * Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
184
- *
185
- * Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
186
- * tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
187
- * önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
188
- * "doğduğu anda bayat" girdiyi engeller.
189
- *
190
- * Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
191
- * eski kayıt düşer.
192
- *
193
- * @type {Map<string, number>}
194
- */
195
- const purgedDeps = new Map();
196
-
197
- const MAX_PURGED_DEPS = 1000;
198
-
199
- /**
200
- * Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
201
- * yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
202
- *
203
- * @param {number} produceMs
204
- * @param {number} ttlMs
205
- * @returns {number}
206
- */
207
- export function earlyRefreshLeadMs(produceMs, ttlMs) {
208
- const measured =
209
- Number.isFinite(produceMs) && produceMs > 0 ? produceMs : DEFAULT_PRODUCE_MS;
210
- const lead = Math.max(measured * 2, EARLY_REFRESH_MIN_MS);
211
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) return lead;
212
- return Math.min(lead, ttlMs / 2);
213
- }
214
-
215
- /**
216
- * @param {HtmlEntry} entry
217
- * @returns {number}
218
- */
219
- function entryTtlMs(entry) {
220
- // Soft-bayatlatılmış girdide expiresAt 0; orijinal TTL storedAt farkından
221
- // okunamaz. O durumda produceMs üzerinden güvenli bir üst sınır yeter.
222
- if (entry.expiresAt > entry.storedAt) return entry.expiresAt - entry.storedAt;
223
- return Math.max(entry.produceMs * 4, EARLY_REFRESH_MIN_MS * 2);
224
- }
225
-
226
- /**
227
- * @param {HtmlEntry} entry
228
- * @param {number} [now]
229
- * @returns {boolean}
230
- */
231
- function isEarly(entry, now = Date.now()) {
232
- if (now >= entry.expiresAt) return false;
233
- const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
234
- return now >= entry.expiresAt - lead;
235
- }
236
-
237
- /**
238
- * @param {unknown} value
239
- * @returns {number}
240
- */
241
- function normalizeProduceMs(value) {
242
- const n = Number(value);
243
- if (Number.isFinite(n) && n >= 0) return Math.round(n);
244
- return DEFAULT_PRODUCE_MS;
245
- }
246
-
247
- /**
248
- * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
249
- * anahtarlarını tutmaya devam eder ve sessizce sızar.
250
- *
251
- * @param {string} key
252
- * @param {HtmlEntry} entry
253
- */
254
- function unlink(key, entry) {
255
- for (const dep of entry.deps) {
256
- const set = dependents.get(dep);
257
- if (!set) continue;
258
- set.delete(key);
259
- if (!set.size) dependents.delete(dep);
260
- }
261
- }
262
-
263
- /**
264
- * Ham HTML + sıkıştırılmış gövdeler. Bayt bütçesi girdi sayısından bağımsız
265
- * bu ağırlığa bakar.
266
- *
267
- * @param {HtmlEntry} entry
268
- * @returns {number}
269
- */
270
- function entryBytes(entry) {
271
- let bytes = Buffer.byteLength(entry.html);
272
- for (const buffer of entry.encoded.values()) bytes += buffer.length;
273
- return bytes;
274
- }
275
-
276
- /**
277
- * @returns {number}
278
- */
279
- function activeByteBudget() {
280
- return byteBudgetOverride ?? HTML_CACHE_BYTE_BUDGET;
281
- }
282
-
283
- /**
284
- * Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer. Tek başına
285
- * bütçeyi aşan girdi de gider: yanıt o istekte zaten üretilmiştir, L1'de
286
- * durması süreci şişirir.
287
- */
288
- function evictOverflow() {
289
- const limit = maxEntries();
290
- const budget = activeByteBudget();
291
-
292
- while (store.size > 0 && (store.size > limit || storedBytes > budget)) {
293
- const oldest = store.keys().next().value;
294
- if (oldest === undefined) break;
295
- if (!drop(oldest)) break;
296
- }
297
- }
298
-
299
- /**
300
- * Store'dan silmenin **tek** yolu. Ters indeks ve bayt sayacı buraya bağlı;
301
- * hiçbir yerde doğrudan `store.delete()` çağrılmaz. `read()` içindeki
302
- * sil-yaz yalnızca LRU sırası içindir, sayacı değiştirmez.
303
- *
304
- * @param {string} key
305
- * @returns {boolean} Girdi var mıydı.
306
- */
307
- function drop(key) {
308
- const entry = store.get(key);
309
- if (!entry) return false;
310
-
311
- unlink(key, entry);
312
- storedBytes = Math.max(0, storedBytes - entryBytes(entry));
313
- store.delete(key);
314
- return true;
315
- }
316
-
317
- /**
318
- * @param {string} key
319
- * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
320
- * stale: boolean, early: boolean } | null}
321
- */
322
- function read(key) {
323
- const entry = store.get(key);
324
- if (!entry) return null;
325
-
326
- const now = Date.now();
327
- if (now >= entry.staleUntil) {
328
- // Uçuştaki tazeleme bitene kadar girdiyi tut: yavaş upstream'de
329
- // staleUntil dolup MISS'e düşmek erken tazelemenin amacını bozar.
330
- if (!inflight.has(key)) {
331
- drop(key);
332
- return null;
333
- }
334
- }
335
-
336
- // LRU: erişilen girdiyi sona taşı.
337
- store.delete(key);
338
- store.set(key, entry);
339
-
340
- // `encoded` yanıt yolunda dolduğu için yazma anında paylaşılamıyor. Kontrol
341
- // yalnızca `storeEncoded` açıkken yapılır; kapalıyken (varsayılan) bu satır
342
- // tek bir karşılaştırmaya bile girmez.
343
- if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
344
- share(key, entry);
345
- }
346
-
347
- const stale = now >= entry.expiresAt;
348
- return {
349
- html: entry.html,
350
- status: entry.status,
351
- encoded: entry.encoded,
352
- stale,
353
- early: !stale && isEarly(entry, now),
354
- };
355
- }
356
-
357
- /**
358
- * Girdiyi paylaşımlı kademeye yazar. Ateşle-unut: yanıt yolunda beklenmez,
359
- * L1 kopyası bu isteği zaten karşılıyor.
360
- *
361
- * @param {string} key
362
- * @param {HtmlEntry} entry
363
- */
364
- function share(key, entry) {
365
- const onRedis = redisShares("html");
366
- const onDisk = diskShares("html");
367
- if (!onRedis && !onDisk) return;
368
-
369
- const ttlMs = entry.staleUntil - Date.now();
370
- if (ttlMs <= 0) return;
371
-
372
- /** @type {Record<string, unknown>} */
373
- const payload = {
374
- html: entry.html,
375
- status: entry.status,
376
- storedAt: entry.storedAt,
377
- expiresAt: entry.expiresAt,
378
- staleUntil: entry.staleUntil,
379
- produceMs: entry.produceMs,
380
- deps: [...entry.deps],
381
- };
382
-
383
- if (onRedis && redisSharesEncoded() && entry.encoded.size) {
384
- /** @type {Record<string, string>} */
385
- const encoded = {};
386
- for (const [encoding, buffer] of entry.encoded) {
387
- encoded[encoding] = buffer.toString("base64");
388
- }
389
- payload.encoded = encoded;
390
- entry.sharedEncodings = entry.encoded.size;
391
- }
392
-
393
- if (onRedis) redisSetJson(cacheKey("html", key), payload, ttlMs);
394
- else diskSetJson("html", key, payload);
395
- }
396
-
397
- /**
398
- * Paylaşımlı kademeden okur ve L1 girdisine çevirir.
399
- *
400
- * Yalnızca **taze** girdi kabul edilir: bayat bir kopyayı L1'e almak
401
- * tazelemeyi sonsuza kadar ertelerdi — girdi bayat kalır, her tazeleme turu
402
- * yine Redis'i okur ve `producer` hiç çalışmaz.
403
- *
404
- * @param {string} key
405
- * @returns {Promise<HtmlEntry | null>}
406
- */
407
- async function readShared(key) {
408
- const onRedis = redisShares("html");
409
- const onDisk = diskShares("html");
410
- if (!onRedis && !onDisk) return null;
411
-
412
- const payload = onRedis
413
- ? await redisGetJson(cacheKey("html", key))
414
- : await diskGetJson("html", key);
415
- if (!payload || typeof payload.html !== "string") return null;
416
- if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
417
- // Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
418
- if (onDisk) diskDrop("html", [key]);
419
- return null;
420
- }
421
-
422
- const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
423
- const storedAt = Number(payload.storedAt) || 0;
424
-
425
- // Uzak girdi de yerel purge geçmişine takılır: bu proseste düşürülmüş bir
426
- // veriyi okumuş HTML'i geri almak, az önce yapılan invalidation'ı iptal
427
- // etmek olurdu.
428
- if (readsPurgedData(deps, storedAt)) return null;
429
-
430
- /** @type {Map<string, Buffer>} */
431
- const encoded = new Map();
432
- if (payload.encoded && typeof payload.encoded === "object") {
433
- for (const [encoding, base64] of Object.entries(payload.encoded)) {
434
- if (typeof base64 === "string") {
435
- encoded.set(encoding, Buffer.from(base64, "base64"));
436
- }
437
- }
438
- // Eski bir replica iki kodlama yazmış olabilir. L1 tek kopya tutar;
439
- // brotli varsa o kalır.
440
- if (encoded.size > 1) {
441
- const keep = encoded.has("br") ? "br" : encoded.keys().next().value;
442
- const buffer = keep ? encoded.get(keep) : undefined;
443
- encoded.clear();
444
- if (keep && buffer) encoded.set(keep, buffer);
445
- }
446
- }
447
-
448
- return {
449
- html: payload.html,
450
- status: Number(payload.status) || 200,
451
- encoded,
452
- // Mutlak zamanlar korunur: TTL'i yeniden başlatmak, girdinin node'dan
453
- // node'a atlayarak süresiz tazelik kazanması demek.
454
- expiresAt: payload.expiresAt,
455
- staleUntil: Number(payload.staleUntil) || payload.expiresAt,
456
- deps,
457
- storedAt,
458
- sharedEncodings: encoded.size,
459
- produceMs: normalizeProduceMs(payload.produceMs),
460
- };
461
- }
462
-
463
- /**
464
- * @param {string} key
465
- * @param {{ html: string, status: number }} value
466
- * @param {number} ttlSeconds
467
- * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
468
- * @param {number} [produceMs] Son üretimin süresi (ms).
469
- */
470
- function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
471
- const now = Date.now();
472
-
473
- /** @type {HtmlEntry} */
474
- const entry = {
475
- html: value.html,
476
- status: value.status,
477
- // Sıkıştırılmış gövdeler HTML ile aynı ömrü paylaşır: aynı sayfa her
478
- // istekte yeniden brotli'lenmesin.
479
- encoded: new Map(),
480
- expiresAt: now + ttlSeconds * 1000,
481
- staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
482
- deps: deps ?? new Set(),
483
- storedAt: now,
484
- sharedEncodings: 0,
485
- produceMs: normalizeProduceMs(produceMs),
486
- };
487
-
488
- // Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
489
- // geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
490
- if (install(key, entry)) share(key, entry);
491
- }
492
-
493
- /**
494
- * Girdiyi L1'e yerleştirir, ters indekse bağlar ve sınırı uygular. Store'a
495
- * yazmanın tek yolu bu.
496
- *
497
- * @param {string} key
498
- * @param {HtmlEntry} entry
499
- * @returns {boolean} Girdi L1'e girdi mi.
500
- */
501
- function install(key, entry) {
502
- // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
503
- // tazelemeden tazelemeye değişebilir.
504
- drop(key);
505
-
506
- const weight = entryBytes(entry);
507
- // Tek sayfa bütçeden büyükse saklama. Eski kopya da düştü; bu yanıt
508
- // üreticinin döndürdüğü HTML ile gider, L1'e girmez.
509
- if (weight > activeByteBudget()) return false;
510
-
511
- store.set(key, entry);
512
- storedBytes += weight;
513
-
514
- for (const dep of entry.deps) {
515
- let set = dependents.get(dep);
516
- if (!set) dependents.set(dep, (set = new Set()));
517
- set.add(key);
518
- }
519
-
520
- evictOverflow();
521
- return store.has(key);
522
- }
523
-
524
- /**
525
- * Sıkıştırılmış gövde `install()`'dan sonra, ilk brotli/gzip yanıtında
526
- * girdinin `encoded` haritasına eklenir. Sayacı delta ile büyütmek, o sıra
527
- * LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
528
- * store'dan yeniden okumak o artığı taşımaz.
529
- *
530
- * @returns {void}
531
- */
532
- export function noteHtmlCacheGrowth() {
533
- let bytes = 0;
534
- for (const entry of store.values()) bytes += entryBytes(entry);
535
- storedBytes = bytes;
536
- evictOverflow();
537
- }
538
-
539
- /**
540
- * Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
541
- * tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
542
- *
543
- * @param {number | null} bytes
544
- * @returns {void}
545
- */
546
- export function setHtmlCacheByteBudget(bytes) {
547
- byteBudgetOverride = bytes == null ? null : bytes;
548
- evictOverflow();
549
- }
550
-
551
- /**
552
- * Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
553
- *
554
- * @param {Set<string> | null} deps
555
- * @param {number} startedAt
556
- * @returns {boolean}
557
- */
558
- function readsPurgedData(deps, startedAt) {
559
- if (!deps) return false;
560
-
561
- for (const dep of deps) {
562
- const purgedAt = purgedDeps.get(dep);
563
- if (purgedAt !== undefined && purgedAt >= startedAt) return true;
564
- }
565
- return false;
566
- }
567
-
568
- /**
569
- * @param {string} key
570
- * @param {number} ttlSeconds
571
- * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
572
- * storable?: boolean }>} producer
573
- * @returns {Promise<{ html: string, status: number, degraded?: boolean,
574
- * storable?: boolean }>}
575
- */
576
- function refresh(key, ttlSeconds, producer) {
577
- const pending = inflight.get(key);
578
- if (pending) return pending;
579
-
580
- // Tazeleme sürerken staleUntil dolmasın: drop → MISS yolu kapanır.
581
- extendStaleWhileRefreshing(key);
582
-
583
- const token = {};
584
- tokens.set(key, token);
585
-
586
- const task = produce(key, ttlSeconds, producer, token).finally(() => {
587
- inflight.delete(key);
588
- if (tokens.get(key) === token) tokens.delete(key);
589
- });
590
-
591
- inflight.set(key, task);
592
- return task;
593
- }
594
-
595
- /**
596
- * @param {string} key
597
- */
598
- function extendStaleWhileRefreshing(key) {
599
- const entry = store.get(key);
600
- if (!entry) return;
601
- const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
602
- const floor = Date.now() + lead;
603
- if (entry.staleUntil < floor) entry.staleUntil = floor;
604
- }
605
-
606
- /**
607
- * @param {string} key
608
- * @param {number} ttlSeconds
609
- * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
610
- * storable?: boolean }>} producer
611
- * @param {object} token
612
- * @returns {Promise<{ html: string, status: number, degraded?: boolean,
613
- * storable?: boolean }>}
614
- */
615
- async function produce(key, ttlSeconds, producer, token) {
616
- const startedAt = Date.now();
617
-
618
- // Başka bir node bu sayfayı zaten render ettiyse render hiç çalışmaz. Soğuk
619
- // ayağa kalkan bir instance'ın sıcak önbellek bulmasının tek yolu bu.
620
- const shared = await readShared(key);
621
- if (shared && tokens.get(key) === token) {
622
- install(key, shared);
623
- return { html: shared.html, status: shared.status };
624
- }
625
-
626
- // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
627
- // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
628
- const deps = trackDependencies() ? new Set() : null;
629
-
630
- const value = await (deps ? collectDependencies(deps, producer) : producer());
631
-
632
- // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
633
- // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
634
- //
635
- // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
636
- // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
637
- // kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
638
- // kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
639
- // dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
640
- //
641
- // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
642
- // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
643
- const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
644
- if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
645
- write(key, value, ttlSeconds, deps, Date.now() - startedAt);
646
- }
647
-
648
- return value;
649
- }
650
-
651
- /**
652
- * @param {string} key
653
- * @param {number} ttlSeconds 0 → cache yok
654
- * @param {() => Promise<{ html: string, status: number }>} producer
655
- * @returns {Promise<{ html: string, status: number, cached: boolean,
656
- * stale?: boolean, early?: boolean, encoded?: Map<string, Buffer> }>}
657
- */
658
- export async function withHtmlCache(key, ttlSeconds, producer) {
659
- if (!ttlSeconds) {
660
- const fresh = await producer();
661
- return { ...fresh, cached: false };
662
- }
663
-
664
- const hit = read(key);
665
-
666
- if (hit) {
667
- // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
668
- // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
669
- // Erken pencerede de aynı: hâlâ HIT, ama TTL dolmadan taze HTML yazılsın.
670
- if (hit.stale || hit.early) {
671
- invalidated.delete(key);
672
- void refresh(key, ttlSeconds, producer).catch((error) => {
673
- console.error(`[html-cache] background refresh failed: ${key}`, error);
674
- });
675
- }
676
- return { ...hit, cached: true };
677
- }
678
-
679
- invalidated.delete(key);
680
- const value = await refresh(key, ttlSeconds, producer);
681
- return { ...value, encoded: store.get(key)?.encoded, cached: false };
682
- }
683
-
684
- /**
685
- * Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
686
- * çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
687
- * yani gerçekten **geçersiz** — bayatlatmak yetmez.
688
- */
689
- export function clearHtmlCache() {
690
- clearLocal();
691
-
692
- if (redisShares("html")) void redisDropMatching("html");
693
- else if (diskShares("html")) void diskDropMatching("html");
694
- publishCacheEvent({ type: "html:clear" });
695
- }
696
-
697
- /**
698
- * Boşaltmanın yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın
699
- * yapan bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
700
- */
701
- function clearLocal() {
702
- store.clear();
703
- storedBytes = 0;
704
- dependents.clear();
705
- tokens.clear();
706
- invalidated.clear();
707
- purgedDeps.clear();
708
- }
709
-
710
- export function getHtmlCacheSize() {
711
- return store.size;
712
- }
713
-
714
- /**
715
- * Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
716
- *
717
- * Üç biçim kabul edilir:
718
- * `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
719
- * `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
720
- * `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
721
- *
722
- * Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
723
- * kuralı `/haberler`i düşürmemeli.
724
- *
725
- * @param {string | RegExp} target
726
- * @returns {((pathname: string) => boolean) | null}
727
- */
728
- function toMatcher(target) {
729
- if (target instanceof RegExp) return (pathname) => target.test(pathname);
730
-
731
- if (typeof target !== "string" || !target.startsWith("/")) {
732
- console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
733
- return null;
734
- }
735
-
736
- if (target.includes(":")) {
737
- const compiled = compilePattern(target);
738
- if (!compiled) return null;
739
- return (pathname) => matchPattern(compiled, pathname) !== null;
740
- }
741
-
742
- const prefix = target.endsWith("/") ? target : `${target}/`;
743
- return (pathname) => pathname === target || pathname.startsWith(prefix);
744
- }
745
-
746
- /**
747
- * Etkilenen girdiyi bayatlatır ya da düşürür.
748
- *
749
- * @param {string} key
750
- * @param {boolean} hard
751
- */
752
- function invalidateKey(key, hard) {
753
- // Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
754
- // üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
755
- // henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
756
- tokens.delete(key);
757
-
758
- const entry = store.get(key);
759
- if (entry) {
760
- // Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
761
- if (hard || Date.now() >= entry.staleUntil) drop(key);
762
- else entry.expiresAt = 0;
763
- }
764
-
765
- if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
766
- }
767
-
768
- /**
769
- * Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
770
- *
771
- * Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
772
- * sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
773
- * içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
774
- * döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
775
- * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
776
- * HTML'in gerçekten geçersiz olduğu durumlar için.
777
- *
778
- * Anahtar `yol?query` (isteğe bağlı `vary|` önekiyle) olduğundan eşleştirme
779
- * **yol kısmına** yapılır: bir yolun bütün query / host varyantları tek
780
- * çağrıyla düşer.
781
- *
782
- * @param {string | RegExp | (string | RegExp)[]} target
783
- * @param {{ hard?: boolean }} [options]
784
- * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
785
- */
786
- export function invalidateHtmlCache(target, options = {}) {
787
- const targets = Array.isArray(target) ? target : [target];
788
- const hard = options.hard === true;
789
- const count = invalidateLocal(targets, hard);
790
-
791
- // Paylaşımlı kopya yumuşak invalidation'da da **silinir**. Bayatlatmanın
792
- // Redis karşılığı her anahtar için oku-değiştir-yaz turu demek ve bir
793
- // webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç
794
- // görmemiş bir node'un bir kez render etmesi; L1'i sıcak olan node'lar eski
795
- // HTML'i bayat pencerede servis etmeye devam ediyor.
796
- if (redisShares("html") || diskShares("html")) {
797
- const matchers = compileMatchers(targets);
798
- if (matchers.length) {
799
- const match = (key) => matchers.some((matcher) => matcher(pathOf(key)));
800
- if (redisShares("html")) void redisDropMatching("html", match);
801
- else void diskDropMatching("html", match);
802
- }
803
- }
804
-
805
- // Hedefler yayınlanır, eşleşen anahtarlar değil: hangi yolun nerede sıcak
806
- // olduğu node'a bağlı, her node deseni kendi store'una uygular.
807
- publishCacheEvent({
808
- type: "html:invalidate",
809
- hard,
810
- targets: targets.map(serializeTarget).filter((entry) => entry !== null),
811
- });
812
-
813
- return count;
814
- }
815
-
816
- /**
817
- * @param {(string | RegExp)[]} targets
818
- * @param {boolean} hard
819
- * @returns {number}
820
- */
821
- function invalidateLocal(targets, hard) {
822
- const matchers = compileMatchers(targets);
823
- if (!matchers.length) return 0;
824
-
825
- let count = 0;
826
-
827
- // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
828
- // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
829
- // `invalidateKey` sert modda store'dan siliyor.
830
- for (const key of new Set([...store.keys(), ...tokens.keys()])) {
831
- if (!matchers.some((matcher) => matcher(pathOf(key)))) continue;
832
- invalidateKey(key, hard);
833
- count += 1;
834
- }
835
-
836
- return count;
837
- }
838
-
839
- /**
840
- * @param {(string | RegExp)[]} targets
841
- * @returns {((pathname: string) => boolean)[]}
842
- */
843
- function compileMatchers(targets) {
844
- return /** @type {((pathname: string) => boolean)[]} */ (
845
- targets.map(toMatcher).filter((matcher) => matcher !== null)
846
- );
847
- }
848
-
849
- /**
850
- * Anahtar `[vary|]yol?query`; eşleştirme **yol** kısmına yapılır.
851
- * Vary öneki (`h=…|`) invalidation hedefiyle karışmasın.
852
- *
853
- * @param {string} key
854
- * @returns {string}
855
- */
856
- function pathOf(key) {
857
- return pathOfCacheKey(key);
858
- }
859
-
860
- /**
861
- * `RegExp` JSON'a girmez (`JSON.stringify(/x/)` → `{}`), bu yüzden kaynak ve
862
- * bayrakları taşınır.
863
- *
864
- * @param {string | RegExp} target
865
- * @returns {string | { re: string, flags: string } | null}
866
- */
867
- function serializeTarget(target) {
868
- if (typeof target === "string") return target;
869
- if (target instanceof RegExp) return { re: target.source, flags: target.flags };
870
- return null;
871
- }
872
-
873
- /**
874
- * @param {unknown} value
875
- * @returns {string | RegExp | null}
876
- */
877
- function deserializeTarget(value) {
878
- if (typeof value === "string") return value;
879
-
880
- const entry = /** @type {{ re?: unknown, flags?: unknown }} */ (value);
881
- if (!entry || typeof entry.re !== "string") return null;
882
-
883
- try {
884
- return new RegExp(entry.re, typeof entry.flags === "string" ? entry.flags : "");
885
- } catch {
886
- // Bozuk bir desen bu node'u düşürmemeli; olay yok sayılır.
887
- return null;
888
- }
889
- }
890
-
891
- // Uzak bir node invalidation yaptığında bu proses de kendi L1'ini işaretler.
892
- // Dinleyiciler yalnızca yerel yolları çağırır, yoksa mesaj döngüsü oluşur.
893
- onCacheEvent((event) => {
894
- if (event.type === "html:clear") {
895
- clearLocal();
896
- return;
897
- }
898
-
899
- if (event.type === "html:drop") {
900
- if (typeof event.key === "string") dropLocalKey(event.key);
901
- return;
902
- }
903
-
904
- if (event.type !== "html:invalidate") return;
905
-
906
- const targets = /** @type {(string | RegExp)[]} */ (
907
- (Array.isArray(event.targets) ? event.targets : [])
908
- .map(deserializeTarget)
909
- .filter((entry) => entry !== null)
910
- );
911
-
912
- if (targets.length) invalidateLocal(targets, event.hard === true);
913
- });
914
-
915
- /**
916
- * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
917
- * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
918
- *
919
- * Burada **yayın yapılmaz**: çağıran `clearDataCache()` zaten bir
920
- * `data:clear` olayı yayınlıyor ve uzak node'lar aynı zinciri kendi ters
921
- * indeksleri üzerinden çalıştırıyor. Ters indeks node'a özel olduğu için
922
- * doğru olan da bu — bir sayfa yalnızca onu render etmiş node'da kayıtlı.
923
- *
924
- * @param {Iterable<string>} dataKeys
925
- * @returns {number} Etkilenen HTML girdisi sayısı.
926
- */
927
- export function invalidateHtmlByDependency(dataKeys) {
928
- /** @type {Set<string>} */
929
- const keys = new Set();
930
- const now = Date.now();
931
-
932
- for (const dep of dataKeys) {
933
- // Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
934
- // görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
935
- purgedDeps.set(dep, now);
936
-
937
- const set = dependents.get(dep);
938
- if (set) for (const key of set) keys.add(key);
939
- }
940
-
941
- while (purgedDeps.size > MAX_PURGED_DEPS) {
942
- const oldest = purgedDeps.keys().next().value;
943
- if (oldest === undefined) break;
944
- purgedDeps.delete(oldest);
945
- }
946
-
947
- for (const key of keys) invalidateKey(key, false);
948
-
949
- // Paylaşımlı kopyalar da düşer, yoksa soğuk bir node az önce geçersiz
950
- // kılınan HTML'i Redis'ten geri alırdı. Yalnızca bu node'un tanıdığı
951
- // anahtarlar silinebiliyor; hiçbir L1'de sıcak olmayan bir sayfanın Redis
952
- // kopyası TTL'ini bekler.
953
- if (keys.size && redisShares("html")) {
954
- redisDrop([...keys].map((key) => cacheKey("html", key)));
955
- } else if (keys.size && diskShares("html")) {
956
- diskDrop("html", [...keys]);
957
- }
958
-
959
- return keys.size;
960
- }
961
-
962
- /**
963
- * Tek bir önbellek **anahtarını** düşürür.
964
- *
965
- * `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
966
- * varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
967
- * silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
968
- * kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
969
- *
970
- * @param {string} key `yol?query` biçiminde tam anahtar.
971
- * @returns {boolean} Girdi var mıydı.
972
- */
973
- export function dropHtmlCacheKey(key) {
974
- const existed = dropLocalKey(key);
975
-
976
- if (redisShares("html")) redisDrop([cacheKey("html", key)]);
977
- else if (diskShares("html")) diskDrop("html", [key]);
978
- publishCacheEvent({ type: "html:drop", key });
979
-
980
- return existed;
981
- }
982
-
983
- /**
984
- * @param {string} key
985
- * @returns {boolean}
986
- */
987
- function dropLocalKey(key) {
988
- // Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
989
- tokens.delete(key);
990
- invalidated.delete(key);
991
- return drop(key);
992
- }
993
-
994
- /**
995
- * Vary önekinden `h=` parçasını okur. Önek yoksa boş string.
996
- *
997
- * @param {string} key
998
- * @returns {string}
999
- */
1000
- function hostOfCacheKey(key) {
1001
- const sep = key.indexOf("|/");
1002
- if (sep === -1) return "";
1003
- const prefix = key.slice(0, sep);
1004
- for (const part of prefix.split("&")) {
1005
- if (part.startsWith("h=")) return part.slice(2);
1006
- }
1007
- return "";
1008
- }
1009
-
1010
- /**
1011
- * @param {string} key
1012
- * @returns {string}
1013
- */
1014
- function pathWithQuery(key) {
1015
- const pathname = pathOfCacheKey(key);
1016
- const q = key.indexOf("?");
1017
- if (q === -1) return pathname;
1018
- const query = key.slice(q + 1);
1019
- return query ? `${pathname}?${query}` : pathname;
1020
- }
1021
-
1022
- /**
1023
- * Invalidate edilmiş yollar. Okuma yıkıcıdır; iki tur aynı yolu tekrar
1024
- * ısıtmasın. `onlyHost` verilirse başka host'ların anahtarları kuyrukta
1025
- * kalır — süre dolumu onları kendi host'uyla ısıtır, `127.0.0.1` anahtarı
1026
- * açılmaz.
1027
- *
1028
- * @param {string} [onlyHost]
1029
- * @returns {{ key: string, path: string, host: string }[]}
1030
- */
1031
- export function takeInvalidatedTargets(onlyHost) {
1032
- if (!invalidated.size) return [];
1033
-
1034
- /** @type {{ key: string, path: string, host: string }[]} */
1035
- const taken = [];
1036
-
1037
- for (const key of [...invalidated]) {
1038
- const host = hostOfCacheKey(key);
1039
- if (onlyHost && host && host !== onlyHost) continue;
1040
- invalidated.delete(key);
1041
- taken.push({ key, path: pathWithQuery(key), host });
1042
- }
1043
-
1044
- return taken;
1045
- }
1046
-
1047
- /**
1048
- * Yol listesi. Vary öneki düşülür; host ayrımı `takeInvalidatedTargets`.
1049
- *
1050
- * @returns {string[]}
1051
- */
1052
- export function takeInvalidatedPaths() {
1053
- return takeInvalidatedTargets().map((target) => target.path);
1054
- }
1055
-
1056
- /**
1057
- * Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
1058
- * zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
1059
- * boyutu.
1060
- *
1061
- * @returns {{ key: string, bytes: number, status: number, stale: boolean,
1062
- * expiresIn: number, encodings: string[], deps: number }[]}
1063
- */
1064
- export function getHtmlCacheEntries() {
1065
- const now = Date.now();
1066
-
1067
- return [...store.entries()].map(([key, entry]) => ({
1068
- key,
1069
- bytes: Buffer.byteLength(entry.html),
1070
- status: entry.status,
1071
- stale: now >= entry.expiresAt,
1072
- expiresIn: Math.round((entry.expiresAt - now) / 1000),
1073
- encodings: [...entry.encoded.keys()],
1074
- deps: entry.deps.size,
1075
- }));
1076
- }
1077
-
1078
- /**
1079
- * Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
1080
- * soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
1081
- *
1082
- * Gerçek anahtar `h=host|/yol?` biçimindedir: düz `store.get(pathname)` hem
1083
- * vary önekini hem sondaki `?` işaretini kaçırır ve sıcak sayfayı yeniden
1084
- * ısıtır. `vary.host` açıkken yalnızca bu isteğin host'u sayılır; diğer
1085
- * locale'in kopyası bu yolu sıcak yapmaz.
1086
- *
1087
- * @param {string} pathname
1088
- * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
1089
- * @returns {boolean}
1090
- */
1091
- export function isHtmlCacheFresh(pathname, req) {
1092
- if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
1093
-
1094
- let host = "";
1095
- try {
1096
- if (getConfig().cacheVary?.host && req) host = publicHost(req);
1097
- } catch {
1098
- // Config yokken (testler) önek aranmaz; yol eşleşmesi yeter.
1099
- }
1100
-
1101
- const now = Date.now();
1102
- for (const [key, entry] of store) {
1103
- if (now >= entry.expiresAt) continue;
1104
- if (pathOfCacheKey(key) !== pathname) continue;
1105
- const q = key.indexOf("?");
1106
- if (q !== -1 && key.slice(q + 1)) continue;
1107
- if (host && hostOfCacheKey(key) !== host) continue;
1108
- return true;
1109
- }
1110
-
1111
- return false;
1112
- }
1113
-
1114
- /**
1115
- * Bu anahtarın girdisi hâlâ taze mi? Süre dolumu ısıtması anahtarı bildiği
1116
- * için yol taraması yapmaz. Soft-bayat (`expiresAt === 0`) taze sayılmaz:
1117
- * ziyaretçi arada yenilediyse yeni `expiresAt` taze döner ve HTTP atlanır.
1118
- *
1119
- * @param {string} key
1120
- * @returns {boolean}
1121
- */
1122
- export function isHtmlCacheKeyFresh(key) {
1123
- const entry = store.get(key);
1124
- if (!entry) return false;
1125
- return Date.now() < entry.expiresAt;
1126
- }
1127
-
1128
- /**
1129
- * Girdide en fazla bir sıkıştırılmış gövde durur. Yeni kodlama eskisinin
1130
- * yerini alır; ham HTML kalır. Bayt sayacı `noteHtmlCacheGrowth` ile işlenir.
1131
- *
1132
- * @param {Map<string, Buffer>} encoded
1133
- * @param {string} encoding
1134
- * @param {Buffer} buffer
1135
- * @returns {void}
1136
- */
1137
- export function rememberHtmlEncoding(encoded, encoding, buffer) {
1138
- for (const key of [...encoded.keys()]) {
1139
- if (key !== encoding) encoded.delete(key);
1140
- }
1141
- encoded.set(encoding, buffer);
1142
- noteHtmlCacheGrowth();
1143
- }
1144
-
1145
- /**
1146
- * Erken tazeleme penceresine girmiş (veya TTL'i dolmuş) trafiksiz girdileri
1147
- * soft-bayatlatır ve ısıtma kuyruğuna alır. HTTP ısıtması producer'sız
1148
- * çalıştığı için soft-bayat şart: taze HIT yenileme tetiklemez.
1149
- *
1150
- * Tur başına en fazla `EARLY_SWEEP_MARK_BUDGET` girdi. Önce süresi en yakın
1151
- * dolacak olan; kota dolunca kalanlar bir sonraki tura kalır.
1152
- *
1153
- * @returns {number} İşaretlenen girdi sayısı.
1154
- */
1155
- export function sweepEarlyExpiry() {
1156
- const now = Date.now();
1157
- /** @type {{ key: string, expiresAt: number }[]} */
1158
- const due = [];
1159
-
1160
- for (const [key, entry] of store) {
1161
- if (inflight.has(key)) continue;
1162
- // Zaten soft-bayat / kuyrukta — her saniye yeniden ekleme.
1163
- if (entry.expiresAt === 0) continue;
1164
- if (now < entry.expiresAt && !isEarly(entry, now)) continue;
1165
- due.push({ key, expiresAt: entry.expiresAt });
1166
- }
1167
-
1168
- due.sort((a, b) => a.expiresAt - b.expiresAt);
1169
-
1170
- let marked = 0;
1171
- for (const item of due) {
1172
- if (marked >= EARLY_SWEEP_MARK_BUDGET) break;
1173
- const entry = store.get(item.key);
1174
- if (!entry || inflight.has(item.key) || entry.expiresAt === 0) continue;
1175
-
1176
- entry.expiresAt = 0;
1177
- if (invalidated.size < MAX_INVALIDATED) invalidated.add(item.key);
1178
- marked += 1;
1179
- }
1180
-
1181
- return marked;
1182
- }
1183
-
1184
- /**
1185
- * Trafiksiz sayfaların TTL öncesi soft-bayatlatılması. `startPrewarm` açar;
1186
- * `PREWARM=0` iken hiç kurulmaz. `unref` — süreç kapanışını geciktirmez.
1187
- *
1188
- * @returns {void}
1189
- */
1190
- export function startEarlyExpirySweep() {
1191
- if (earlySweepTimer) return;
1192
- earlySweepTimer = setInterval(() => {
1193
- sweepEarlyExpiry();
1194
- }, EARLY_SWEEP_INTERVAL_MS);
1195
- earlySweepTimer.unref();
1196
- }
1
+ /**
2
+ * ISR ikamesi: route + query anahtarlı, TTL'li LRU HTML cache.
3
+ *
4
+ * TTL dolduğunda girdi hemen atılmaz: `stale` pencerede eski HTML anında
5
+ * döner ve tazeleme arkada çalışır. Böylece ilk ısıtmadan sonra hiçbir istek
6
+ * render'ı beklemez; buna karşılık HTML'deki veri en fazla `revalidate + bir
7
+ * tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
8
+ * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
+ *
10
+ * TTL dolmadan önce de tazelenir (**erken tazeleme**): son başarılı üretimin
11
+ * süresi (`produceMs`) kadar önden arka plan refresh başlar, böylece yavaş
12
+ * bir sayfa TTL anında hâlâ soğuk render'a düşmez. Trafik yoksa sweeper
13
+ * girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır.
14
+ *
15
+ * TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
16
+ * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
17
+ * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
18
+ * TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
19
+ * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
20
+ * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
21
+ * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
22
+ *
23
+ * ## Paylaşımlı kademe
24
+ *
25
+ * `cache.redis` açıkken store'un ikinci bir kademesi olur. Bellek içi store
26
+ * (L1) **birincil kalır**: `read()` senkron, sıkıştırılmış gövdeler girdiyle
27
+ * birlikte ve tutarlılık makinesi (`tokens`, `purgedDeps`) tek proseste. Redis
28
+ * yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı
29
+ * diğer node'lara duyurur. Redis yoksa aynı kayıt `.jskelet/cache/<buildId>/`
30
+ * altına yazılır; bu tek makinenin yeniden açılışını karşılar, kümeyi değil.
31
+ */
32
+
33
+ import { getConfig } from "../config/index.js";
34
+ import {
35
+ DEFAULT_HTML_CACHE_MAX_ENTRIES,
36
+ HTML_CACHE_BYTE_BUDGET,
37
+ } from "../config/defaults.js";
38
+ import { collectDependencies } from "./cache-deps.js";
39
+ import { compilePattern, matchPattern } from "../config/pattern.js";
40
+ import { pathOfCacheKey, publicHost } from "./cache-vary.js";
41
+ import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
42
+ import {
43
+ cacheKey,
44
+ onCacheEvent,
45
+ publishCacheEvent,
46
+ redisDrop,
47
+ redisDropMatching,
48
+ redisGetJson,
49
+ redisSetJson,
50
+ redisShares,
51
+ redisSharesEncoded,
52
+ } from "./redis.js";
53
+
54
+ /**
55
+ * `storedAt`: girdinin üretildiği an. Paylaşımlı kademeden gelen bir girdiyi
56
+ * kabul etmeden önce "bu render yerel bir purge'den önce mi başladı" sorusu
57
+ * yine sorulur; cevabı bu alan taşıyor.
58
+ *
59
+ * `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
60
+ * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
61
+ * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
62
+ *
63
+ * `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
64
+ * türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
65
+ *
66
+ * @typedef {{ html: string, status: number, expiresAt: number,
67
+ * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
68
+ * storedAt: number, sharedEncodings: number, produceMs: number }} HtmlEntry
69
+ */
70
+
71
+ /**
72
+ * Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
73
+ * siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
74
+ * düşüyor. On binlerce yol için `withDataCache` kullanılır.
75
+ *
76
+ * Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
77
+ * durumda kod varsayılanı geçerli.
78
+ *
79
+ * @returns {number}
80
+ */
81
+ function maxEntries() {
82
+ try {
83
+ return getConfig().htmlMaxEntries;
84
+ } catch {
85
+ return DEFAULT_HTML_CACHE_MAX_ENTRIES;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
91
+ * uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
92
+ *
93
+ * @returns {boolean}
94
+ */
95
+ function trackDependencies() {
96
+ try {
97
+ return getConfig().trackDependencies;
98
+ } catch {
99
+ return true;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
105
+ * Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
106
+ * yavaş upstream'lerde devreye girer.
107
+ */
108
+ const STALE_FACTOR = 1;
109
+
110
+ /**
111
+ * Redis'ten gelen veya süresi bilinmeyen girdiler için erken tazeleme lead'i.
112
+ * Ölçülmüş `produceMs` yokken aşırı iyimser (0) kalmamak için.
113
+ */
114
+ const DEFAULT_PRODUCE_MS = 500;
115
+
116
+ /** Erken tazelemenin alt sınırı — çok hızlı sayfalar da TTL'den önce ısınsın. */
117
+ const EARLY_REFRESH_MIN_MS = 250;
118
+
119
+ /** Trafiksiz girdileri erken pencerede soft-bayatlatma aralığı. */
120
+ const EARLY_SWEEP_INTERVAL_MS = 1000;
121
+
122
+ /**
123
+ * Bir sweep turunda soft-bayatlatılan girdi tavanı. Aynı TTL'i paylaşan
124
+ * yüzlerce sayfa tek saniyede kuyruğa girip render dalgası yaratmasın;
125
+ * kalanlar sonraki turlara kalır. Öncelik `expiresAt`'i en yakın olan.
126
+ */
127
+ const EARLY_SWEEP_MARK_BUDGET = 4;
128
+
129
+ /** @type {Map<string, HtmlEntry>} */
130
+ const store = new Map();
131
+
132
+ /**
133
+ * `store` içindeki HTML string + sıkıştırılmış gövdelerin toplamı.
134
+ * Her `drop` / `install` bunu günceller; sıkıştırma sonradan eklendiğinde
135
+ * `noteHtmlCacheGrowth` artışı yazar. Tam tarama yapmamak için tutulur.
136
+ */
137
+ let storedBytes = 0;
138
+
139
+ /**
140
+ * Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
141
+ * @type {number | null}
142
+ */
143
+ let byteBudgetOverride = null;
144
+
145
+ /** @type {Map<string, Promise<{ html: string, status: number }>>} */
146
+ const inflight = new Map();
147
+
148
+ /** @type {ReturnType<typeof setInterval> | null} */
149
+ let earlySweepTimer = null;
150
+
151
+ /**
152
+ * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
153
+ * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
154
+ * okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
155
+ * istek yeni bir tur başlatır.
156
+ *
157
+ * @type {Map<string, object>}
158
+ */
159
+ const tokens = new Map();
160
+
161
+ /**
162
+ * Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
163
+ * bunu okuyup etkilenen sayfaları bayatlatır.
164
+ *
165
+ * @type {Map<string, Set<string>>}
166
+ */
167
+ const dependents = new Map();
168
+
169
+ /**
170
+ * Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
171
+ * kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
172
+ * ziyaretçi gelmesini beklemeden tazelenir.
173
+ *
174
+ * Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
175
+ *
176
+ * @type {Set<string>}
177
+ */
178
+ const invalidated = new Set();
179
+
180
+ const MAX_INVALIDATED = 500;
181
+
182
+ /**
183
+ * Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
184
+ *
185
+ * Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
186
+ * tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
187
+ * önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
188
+ * "doğduğu anda bayat" girdiyi engeller.
189
+ *
190
+ * Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
191
+ * eski kayıt düşer.
192
+ *
193
+ * @type {Map<string, number>}
194
+ */
195
+ const purgedDeps = new Map();
196
+
197
+ const MAX_PURGED_DEPS = 1000;
198
+
199
+ /**
200
+ * Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
201
+ * yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
202
+ *
203
+ * @param {number} produceMs
204
+ * @param {number} ttlMs
205
+ * @returns {number}
206
+ */
207
+ export function earlyRefreshLeadMs(produceMs, ttlMs) {
208
+ const measured =
209
+ Number.isFinite(produceMs) && produceMs > 0 ? produceMs : DEFAULT_PRODUCE_MS;
210
+ const lead = Math.max(measured * 2, EARLY_REFRESH_MIN_MS);
211
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return lead;
212
+ return Math.min(lead, ttlMs / 2);
213
+ }
214
+
215
+ /**
216
+ * @param {HtmlEntry} entry
217
+ * @returns {number}
218
+ */
219
+ function entryTtlMs(entry) {
220
+ // Soft-bayatlatılmış girdide expiresAt 0; orijinal TTL storedAt farkından
221
+ // okunamaz. O durumda produceMs üzerinden güvenli bir üst sınır yeter.
222
+ if (entry.expiresAt > entry.storedAt) return entry.expiresAt - entry.storedAt;
223
+ return Math.max(entry.produceMs * 4, EARLY_REFRESH_MIN_MS * 2);
224
+ }
225
+
226
+ /**
227
+ * @param {HtmlEntry} entry
228
+ * @param {number} [now]
229
+ * @returns {boolean}
230
+ */
231
+ function isEarly(entry, now = Date.now()) {
232
+ if (now >= entry.expiresAt) return false;
233
+ const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
234
+ return now >= entry.expiresAt - lead;
235
+ }
236
+
237
+ /**
238
+ * @param {unknown} value
239
+ * @returns {number}
240
+ */
241
+ function normalizeProduceMs(value) {
242
+ const n = Number(value);
243
+ if (Number.isFinite(n) && n >= 0) return Math.round(n);
244
+ return DEFAULT_PRODUCE_MS;
245
+ }
246
+
247
+ /**
248
+ * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
249
+ * anahtarlarını tutmaya devam eder ve sessizce sızar.
250
+ *
251
+ * @param {string} key
252
+ * @param {HtmlEntry} entry
253
+ */
254
+ function unlink(key, entry) {
255
+ for (const dep of entry.deps) {
256
+ const set = dependents.get(dep);
257
+ if (!set) continue;
258
+ set.delete(key);
259
+ if (!set.size) dependents.delete(dep);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Ham HTML + sıkıştırılmış gövdeler. Bayt bütçesi girdi sayısından bağımsız
265
+ * bu ağırlığa bakar.
266
+ *
267
+ * @param {HtmlEntry} entry
268
+ * @returns {number}
269
+ */
270
+ function entryBytes(entry) {
271
+ let bytes = Buffer.byteLength(entry.html);
272
+ for (const buffer of entry.encoded.values()) bytes += buffer.length;
273
+ return bytes;
274
+ }
275
+
276
+ /**
277
+ * @returns {number}
278
+ */
279
+ function activeByteBudget() {
280
+ return byteBudgetOverride ?? HTML_CACHE_BYTE_BUDGET;
281
+ }
282
+
283
+ /**
284
+ * Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer. Tek başına
285
+ * bütçeyi aşan girdi de gider: yanıt o istekte zaten üretilmiştir, L1'de
286
+ * durması süreci şişirir.
287
+ */
288
+ function evictOverflow() {
289
+ const limit = maxEntries();
290
+ const budget = activeByteBudget();
291
+
292
+ while (store.size > 0 && (store.size > limit || storedBytes > budget)) {
293
+ const oldest = store.keys().next().value;
294
+ if (oldest === undefined) break;
295
+ if (!drop(oldest)) break;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Store'dan silmenin **tek** yolu. Ters indeks ve bayt sayacı buraya bağlı;
301
+ * hiçbir yerde doğrudan `store.delete()` çağrılmaz. `read()` içindeki
302
+ * sil-yaz yalnızca LRU sırası içindir, sayacı değiştirmez.
303
+ *
304
+ * @param {string} key
305
+ * @returns {boolean} Girdi var mıydı.
306
+ */
307
+ function drop(key) {
308
+ const entry = store.get(key);
309
+ if (!entry) return false;
310
+
311
+ unlink(key, entry);
312
+ storedBytes = Math.max(0, storedBytes - entryBytes(entry));
313
+ store.delete(key);
314
+ return true;
315
+ }
316
+
317
+ /**
318
+ * @param {string} key
319
+ * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
320
+ * stale: boolean, early: boolean } | null}
321
+ */
322
+ function read(key) {
323
+ const entry = store.get(key);
324
+ if (!entry) return null;
325
+
326
+ const now = Date.now();
327
+ if (now >= entry.staleUntil) {
328
+ // Uçuştaki tazeleme bitene kadar girdiyi tut: yavaş upstream'de
329
+ // staleUntil dolup MISS'e düşmek erken tazelemenin amacını bozar.
330
+ if (!inflight.has(key)) {
331
+ drop(key);
332
+ return null;
333
+ }
334
+ }
335
+
336
+ // LRU: erişilen girdiyi sona taşı.
337
+ store.delete(key);
338
+ store.set(key, entry);
339
+
340
+ // `encoded` yanıt yolunda dolduğu için yazma anında paylaşılamıyor. Kontrol
341
+ // yalnızca `storeEncoded` açıkken yapılır; kapalıyken (varsayılan) bu satır
342
+ // tek bir karşılaştırmaya bile girmez.
343
+ if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
344
+ share(key, entry);
345
+ }
346
+
347
+ const stale = now >= entry.expiresAt;
348
+ return {
349
+ html: entry.html,
350
+ status: entry.status,
351
+ encoded: entry.encoded,
352
+ stale,
353
+ early: !stale && isEarly(entry, now),
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Girdiyi paylaşımlı kademeye yazar. Ateşle-unut: yanıt yolunda beklenmez,
359
+ * L1 kopyası bu isteği zaten karşılıyor.
360
+ *
361
+ * @param {string} key
362
+ * @param {HtmlEntry} entry
363
+ */
364
+ function share(key, entry) {
365
+ const onRedis = redisShares("html");
366
+ const onDisk = diskShares("html");
367
+ if (!onRedis && !onDisk) return;
368
+
369
+ const ttlMs = entry.staleUntil - Date.now();
370
+ if (ttlMs <= 0) return;
371
+
372
+ /** @type {Record<string, unknown>} */
373
+ const payload = {
374
+ html: entry.html,
375
+ status: entry.status,
376
+ storedAt: entry.storedAt,
377
+ expiresAt: entry.expiresAt,
378
+ staleUntil: entry.staleUntil,
379
+ produceMs: entry.produceMs,
380
+ deps: [...entry.deps],
381
+ };
382
+
383
+ if (onRedis && redisSharesEncoded() && entry.encoded.size) {
384
+ /** @type {Record<string, string>} */
385
+ const encoded = {};
386
+ for (const [encoding, buffer] of entry.encoded) {
387
+ encoded[encoding] = buffer.toString("base64");
388
+ }
389
+ payload.encoded = encoded;
390
+ entry.sharedEncodings = entry.encoded.size;
391
+ }
392
+
393
+ if (onRedis) redisSetJson(cacheKey("html", key), payload, ttlMs);
394
+ else diskSetJson("html", key, payload);
395
+ }
396
+
397
+ /**
398
+ * Paylaşımlı kademeden okur ve L1 girdisine çevirir.
399
+ *
400
+ * Yalnızca **taze** girdi kabul edilir: bayat bir kopyayı L1'e almak
401
+ * tazelemeyi sonsuza kadar ertelerdi — girdi bayat kalır, her tazeleme turu
402
+ * yine Redis'i okur ve `producer` hiç çalışmaz.
403
+ *
404
+ * @param {string} key
405
+ * @returns {Promise<HtmlEntry | null>}
406
+ */
407
+ async function readShared(key) {
408
+ const onRedis = redisShares("html");
409
+ const onDisk = diskShares("html");
410
+ if (!onRedis && !onDisk) return null;
411
+
412
+ const payload = onRedis
413
+ ? await redisGetJson(cacheKey("html", key))
414
+ : await diskGetJson("html", key);
415
+ if (!payload || typeof payload.html !== "string") return null;
416
+ if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
417
+ // Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
418
+ if (onDisk) diskDrop("html", [key]);
419
+ return null;
420
+ }
421
+
422
+ const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
423
+ const storedAt = Number(payload.storedAt) || 0;
424
+
425
+ // Uzak girdi de yerel purge geçmişine takılır: bu proseste düşürülmüş bir
426
+ // veriyi okumuş HTML'i geri almak, az önce yapılan invalidation'ı iptal
427
+ // etmek olurdu.
428
+ if (readsPurgedData(deps, storedAt)) return null;
429
+
430
+ /** @type {Map<string, Buffer>} */
431
+ const encoded = new Map();
432
+ if (payload.encoded && typeof payload.encoded === "object") {
433
+ for (const [encoding, base64] of Object.entries(payload.encoded)) {
434
+ if (typeof base64 === "string") {
435
+ encoded.set(encoding, Buffer.from(base64, "base64"));
436
+ }
437
+ }
438
+ // Eski bir replica iki kodlama yazmış olabilir. L1 tek kopya tutar;
439
+ // brotli varsa o kalır.
440
+ if (encoded.size > 1) {
441
+ const keep = encoded.has("br") ? "br" : encoded.keys().next().value;
442
+ const buffer = keep ? encoded.get(keep) : undefined;
443
+ encoded.clear();
444
+ if (keep && buffer) encoded.set(keep, buffer);
445
+ }
446
+ }
447
+
448
+ return {
449
+ html: payload.html,
450
+ status: Number(payload.status) || 200,
451
+ encoded,
452
+ // Mutlak zamanlar korunur: TTL'i yeniden başlatmak, girdinin node'dan
453
+ // node'a atlayarak süresiz tazelik kazanması demek.
454
+ expiresAt: payload.expiresAt,
455
+ staleUntil: Number(payload.staleUntil) || payload.expiresAt,
456
+ deps,
457
+ storedAt,
458
+ sharedEncodings: encoded.size,
459
+ produceMs: normalizeProduceMs(payload.produceMs),
460
+ };
461
+ }
462
+
463
+ /**
464
+ * @param {string} key
465
+ * @param {{ html: string, status: number }} value
466
+ * @param {number} ttlSeconds
467
+ * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
468
+ * @param {number} [produceMs] Son üretimin süresi (ms).
469
+ */
470
+ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
471
+ const now = Date.now();
472
+
473
+ /** @type {HtmlEntry} */
474
+ const entry = {
475
+ html: value.html,
476
+ status: value.status,
477
+ // Sıkıştırılmış gövdeler HTML ile aynı ömrü paylaşır: aynı sayfa her
478
+ // istekte yeniden brotli'lenmesin.
479
+ encoded: new Map(),
480
+ expiresAt: now + ttlSeconds * 1000,
481
+ staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
482
+ deps: deps ?? new Set(),
483
+ storedAt: now,
484
+ sharedEncodings: 0,
485
+ produceMs: normalizeProduceMs(produceMs),
486
+ };
487
+
488
+ // Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
489
+ // geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
490
+ if (install(key, entry)) share(key, entry);
491
+ }
492
+
493
+ /**
494
+ * Girdiyi L1'e yerleştirir, ters indekse bağlar ve sınırı uygular. Store'a
495
+ * yazmanın tek yolu bu.
496
+ *
497
+ * @param {string} key
498
+ * @param {HtmlEntry} entry
499
+ * @returns {boolean} Girdi L1'e girdi mi.
500
+ */
501
+ function install(key, entry) {
502
+ // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
503
+ // tazelemeden tazelemeye değişebilir.
504
+ drop(key);
505
+
506
+ const weight = entryBytes(entry);
507
+ // Tek sayfa bütçeden büyükse saklama. Eski kopya da düştü; bu yanıt
508
+ // üreticinin döndürdüğü HTML ile gider, L1'e girmez.
509
+ if (weight > activeByteBudget()) return false;
510
+
511
+ store.set(key, entry);
512
+ storedBytes += weight;
513
+
514
+ for (const dep of entry.deps) {
515
+ let set = dependents.get(dep);
516
+ if (!set) dependents.set(dep, (set = new Set()));
517
+ set.add(key);
518
+ }
519
+
520
+ evictOverflow();
521
+ return store.has(key);
522
+ }
523
+
524
+ /**
525
+ * Sıkıştırılmış gövde `install()`'dan sonra, ilk brotli/gzip yanıtında
526
+ * girdinin `encoded` haritasına eklenir. Sayacı delta ile büyütmek, o sıra
527
+ * LRU'dan düşmüş bir haritaya yazınca bir daha inmeyen bir artık bırakır;
528
+ * store'dan yeniden okumak o artığı taşımaz.
529
+ *
530
+ * @returns {void}
531
+ */
532
+ export function noteHtmlCacheGrowth() {
533
+ let bytes = 0;
534
+ for (const entry of store.values()) bytes += entryBytes(entry);
535
+ storedBytes = bytes;
536
+ evictOverflow();
537
+ }
538
+
539
+ /**
540
+ * Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
541
+ * tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
542
+ *
543
+ * @param {number | null} bytes
544
+ * @returns {void}
545
+ */
546
+ export function setHtmlCacheByteBudget(bytes) {
547
+ byteBudgetOverride = bytes == null ? null : bytes;
548
+ evictOverflow();
549
+ }
550
+
551
+ /**
552
+ * Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
553
+ *
554
+ * @param {Set<string> | null} deps
555
+ * @param {number} startedAt
556
+ * @returns {boolean}
557
+ */
558
+ function readsPurgedData(deps, startedAt) {
559
+ if (!deps) return false;
560
+
561
+ for (const dep of deps) {
562
+ const purgedAt = purgedDeps.get(dep);
563
+ if (purgedAt !== undefined && purgedAt >= startedAt) return true;
564
+ }
565
+ return false;
566
+ }
567
+
568
+ /**
569
+ * @param {string} key
570
+ * @param {number} ttlSeconds
571
+ * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
572
+ * storable?: boolean }>} producer
573
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
574
+ * storable?: boolean }>}
575
+ */
576
+ function refresh(key, ttlSeconds, producer) {
577
+ const pending = inflight.get(key);
578
+ if (pending) return pending;
579
+
580
+ // Tazeleme sürerken staleUntil dolmasın: drop → MISS yolu kapanır.
581
+ extendStaleWhileRefreshing(key);
582
+
583
+ const token = {};
584
+ tokens.set(key, token);
585
+
586
+ const task = produce(key, ttlSeconds, producer, token).finally(() => {
587
+ inflight.delete(key);
588
+ if (tokens.get(key) === token) tokens.delete(key);
589
+ });
590
+
591
+ inflight.set(key, task);
592
+ return task;
593
+ }
594
+
595
+ /**
596
+ * @param {string} key
597
+ */
598
+ function extendStaleWhileRefreshing(key) {
599
+ const entry = store.get(key);
600
+ if (!entry) return;
601
+ const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
602
+ const floor = Date.now() + lead;
603
+ if (entry.staleUntil < floor) entry.staleUntil = floor;
604
+ }
605
+
606
+ /**
607
+ * @param {string} key
608
+ * @param {number} ttlSeconds
609
+ * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
610
+ * storable?: boolean }>} producer
611
+ * @param {object} token
612
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
613
+ * storable?: boolean }>}
614
+ */
615
+ async function produce(key, ttlSeconds, producer, token) {
616
+ const startedAt = Date.now();
617
+
618
+ // Başka bir node bu sayfayı zaten render ettiyse render hiç çalışmaz. Soğuk
619
+ // ayağa kalkan bir instance'ın sıcak önbellek bulmasının tek yolu bu.
620
+ const shared = await readShared(key);
621
+ if (shared && tokens.get(key) === token) {
622
+ install(key, shared);
623
+ return { html: shared.html, status: shared.status };
624
+ }
625
+
626
+ // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
627
+ // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
628
+ const deps = trackDependencies() ? new Set() : null;
629
+
630
+ const value = await (deps ? collectDependencies(deps, producer) : producer());
631
+
632
+ // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
633
+ // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
634
+ //
635
+ // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
636
+ // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
637
+ // kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
638
+ // kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
639
+ // dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
640
+ //
641
+ // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
642
+ // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
643
+ const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
644
+ if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
645
+ write(key, value, ttlSeconds, deps, Date.now() - startedAt);
646
+ }
647
+
648
+ return value;
649
+ }
650
+
651
+ /**
652
+ * @param {string} key
653
+ * @param {number} ttlSeconds 0 → cache yok
654
+ * @param {() => Promise<{ html: string, status: number }>} producer
655
+ * @returns {Promise<{ html: string, status: number, cached: boolean,
656
+ * stale?: boolean, early?: boolean, encoded?: Map<string, Buffer> }>}
657
+ */
658
+ export async function withHtmlCache(key, ttlSeconds, producer) {
659
+ if (!ttlSeconds) {
660
+ const fresh = await producer();
661
+ return { ...fresh, cached: false };
662
+ }
663
+
664
+ const hit = read(key);
665
+
666
+ if (hit) {
667
+ // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
668
+ // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
669
+ // Erken pencerede de aynı: hâlâ HIT, ama TTL dolmadan taze HTML yazılsın.
670
+ if (hit.stale || hit.early) {
671
+ invalidated.delete(key);
672
+ void refresh(key, ttlSeconds, producer).catch((error) => {
673
+ console.error(`[html-cache] background refresh failed: ${key}`, error);
674
+ });
675
+ }
676
+ return { ...hit, cached: true };
677
+ }
678
+
679
+ invalidated.delete(key);
680
+ const value = await refresh(key, ttlSeconds, producer);
681
+ return { ...value, encoded: store.get(key)?.encoded, cached: false };
682
+ }
683
+
684
+ /**
685
+ * Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
686
+ * çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
687
+ * yani gerçekten **geçersiz** — bayatlatmak yetmez.
688
+ */
689
+ export function clearHtmlCache() {
690
+ clearLocal();
691
+
692
+ if (redisShares("html")) void redisDropMatching("html");
693
+ else if (diskShares("html")) void diskDropMatching("html");
694
+ publishCacheEvent({ type: "html:clear" });
695
+ }
696
+
697
+ /**
698
+ * Boşaltmanın yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın
699
+ * yapan bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
700
+ */
701
+ function clearLocal() {
702
+ store.clear();
703
+ storedBytes = 0;
704
+ dependents.clear();
705
+ tokens.clear();
706
+ invalidated.clear();
707
+ purgedDeps.clear();
708
+ }
709
+
710
+ export function getHtmlCacheSize() {
711
+ return store.size;
712
+ }
713
+
714
+ /**
715
+ * Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
716
+ *
717
+ * Üç biçim kabul edilir:
718
+ * `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
719
+ * `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
720
+ * `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
721
+ *
722
+ * Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
723
+ * kuralı `/haberler`i düşürmemeli.
724
+ *
725
+ * @param {string | RegExp} target
726
+ * @returns {((pathname: string) => boolean) | null}
727
+ */
728
+ function toMatcher(target) {
729
+ if (target instanceof RegExp) return (pathname) => target.test(pathname);
730
+
731
+ if (typeof target !== "string" || !target.startsWith("/")) {
732
+ console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
733
+ return null;
734
+ }
735
+
736
+ if (target.includes(":")) {
737
+ const compiled = compilePattern(target);
738
+ if (!compiled) return null;
739
+ return (pathname) => matchPattern(compiled, pathname) !== null;
740
+ }
741
+
742
+ const prefix = target.endsWith("/") ? target : `${target}/`;
743
+ return (pathname) => pathname === target || pathname.startsWith(prefix);
744
+ }
745
+
746
+ /**
747
+ * Etkilenen girdiyi bayatlatır ya da düşürür.
748
+ *
749
+ * @param {string} key
750
+ * @param {boolean} hard
751
+ */
752
+ function invalidateKey(key, hard) {
753
+ // Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
754
+ // üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
755
+ // henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
756
+ tokens.delete(key);
757
+
758
+ const entry = store.get(key);
759
+ if (entry) {
760
+ // Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
761
+ if (hard || Date.now() >= entry.staleUntil) drop(key);
762
+ else entry.expiresAt = 0;
763
+ }
764
+
765
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
766
+ }
767
+
768
+ /**
769
+ * Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
770
+ *
771
+ * Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
772
+ * sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
773
+ * içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
774
+ * döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
775
+ * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
776
+ * HTML'in gerçekten geçersiz olduğu durumlar için.
777
+ *
778
+ * Anahtar `yol?query` (isteğe bağlı `vary|` önekiyle) olduğundan eşleştirme
779
+ * **yol kısmına** yapılır: bir yolun bütün query / host varyantları tek
780
+ * çağrıyla düşer.
781
+ *
782
+ * @param {string | RegExp | (string | RegExp)[]} target
783
+ * @param {{ hard?: boolean }} [options]
784
+ * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
785
+ */
786
+ export function invalidateHtmlCache(target, options = {}) {
787
+ const targets = Array.isArray(target) ? target : [target];
788
+ const hard = options.hard === true;
789
+ const count = invalidateLocal(targets, hard);
790
+
791
+ // Paylaşımlı kopya yumuşak invalidation'da da **silinir**. Bayatlatmanın
792
+ // Redis karşılığı her anahtar için oku-değiştir-yaz turu demek ve bir
793
+ // webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç
794
+ // görmemiş bir node'un bir kez render etmesi; L1'i sıcak olan node'lar eski
795
+ // HTML'i bayat pencerede servis etmeye devam ediyor.
796
+ if (redisShares("html") || diskShares("html")) {
797
+ const matchers = compileMatchers(targets);
798
+ if (matchers.length) {
799
+ const match = (key) => matchers.some((matcher) => matcher(pathOf(key)));
800
+ if (redisShares("html")) void redisDropMatching("html", match);
801
+ else void diskDropMatching("html", match);
802
+ }
803
+ }
804
+
805
+ // Hedefler yayınlanır, eşleşen anahtarlar değil: hangi yolun nerede sıcak
806
+ // olduğu node'a bağlı, her node deseni kendi store'una uygular.
807
+ publishCacheEvent({
808
+ type: "html:invalidate",
809
+ hard,
810
+ targets: targets.map(serializeTarget).filter((entry) => entry !== null),
811
+ });
812
+
813
+ return count;
814
+ }
815
+
816
+ /**
817
+ * @param {(string | RegExp)[]} targets
818
+ * @param {boolean} hard
819
+ * @returns {number}
820
+ */
821
+ function invalidateLocal(targets, hard) {
822
+ const matchers = compileMatchers(targets);
823
+ if (!matchers.length) return 0;
824
+
825
+ let count = 0;
826
+
827
+ // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
828
+ // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
829
+ // `invalidateKey` sert modda store'dan siliyor.
830
+ for (const key of new Set([...store.keys(), ...tokens.keys()])) {
831
+ if (!matchers.some((matcher) => matcher(pathOf(key)))) continue;
832
+ invalidateKey(key, hard);
833
+ count += 1;
834
+ }
835
+
836
+ return count;
837
+ }
838
+
839
+ /**
840
+ * @param {(string | RegExp)[]} targets
841
+ * @returns {((pathname: string) => boolean)[]}
842
+ */
843
+ function compileMatchers(targets) {
844
+ return /** @type {((pathname: string) => boolean)[]} */ (
845
+ targets.map(toMatcher).filter((matcher) => matcher !== null)
846
+ );
847
+ }
848
+
849
+ /**
850
+ * Anahtar `[vary|]yol?query`; eşleştirme **yol** kısmına yapılır.
851
+ * Vary öneki (`h=…|`) invalidation hedefiyle karışmasın.
852
+ *
853
+ * @param {string} key
854
+ * @returns {string}
855
+ */
856
+ function pathOf(key) {
857
+ return pathOfCacheKey(key);
858
+ }
859
+
860
+ /**
861
+ * `RegExp` JSON'a girmez (`JSON.stringify(/x/)` → `{}`), bu yüzden kaynak ve
862
+ * bayrakları taşınır.
863
+ *
864
+ * @param {string | RegExp} target
865
+ * @returns {string | { re: string, flags: string } | null}
866
+ */
867
+ function serializeTarget(target) {
868
+ if (typeof target === "string") return target;
869
+ if (target instanceof RegExp) return { re: target.source, flags: target.flags };
870
+ return null;
871
+ }
872
+
873
+ /**
874
+ * @param {unknown} value
875
+ * @returns {string | RegExp | null}
876
+ */
877
+ function deserializeTarget(value) {
878
+ if (typeof value === "string") return value;
879
+
880
+ const entry = /** @type {{ re?: unknown, flags?: unknown }} */ (value);
881
+ if (!entry || typeof entry.re !== "string") return null;
882
+
883
+ try {
884
+ return new RegExp(entry.re, typeof entry.flags === "string" ? entry.flags : "");
885
+ } catch {
886
+ // Bozuk bir desen bu node'u düşürmemeli; olay yok sayılır.
887
+ return null;
888
+ }
889
+ }
890
+
891
+ // Uzak bir node invalidation yaptığında bu proses de kendi L1'ini işaretler.
892
+ // Dinleyiciler yalnızca yerel yolları çağırır, yoksa mesaj döngüsü oluşur.
893
+ onCacheEvent((event) => {
894
+ if (event.type === "html:clear") {
895
+ clearLocal();
896
+ return;
897
+ }
898
+
899
+ if (event.type === "html:drop") {
900
+ if (typeof event.key === "string") dropLocalKey(event.key);
901
+ return;
902
+ }
903
+
904
+ if (event.type !== "html:invalidate") return;
905
+
906
+ const targets = /** @type {(string | RegExp)[]} */ (
907
+ (Array.isArray(event.targets) ? event.targets : [])
908
+ .map(deserializeTarget)
909
+ .filter((entry) => entry !== null)
910
+ );
911
+
912
+ if (targets.length) invalidateLocal(targets, event.hard === true);
913
+ });
914
+
915
+ /**
916
+ * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
917
+ * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
918
+ *
919
+ * Burada **yayın yapılmaz**: çağıran `clearDataCache()` zaten bir
920
+ * `data:clear` olayı yayınlıyor ve uzak node'lar aynı zinciri kendi ters
921
+ * indeksleri üzerinden çalıştırıyor. Ters indeks node'a özel olduğu için
922
+ * doğru olan da bu — bir sayfa yalnızca onu render etmiş node'da kayıtlı.
923
+ *
924
+ * @param {Iterable<string>} dataKeys
925
+ * @returns {number} Etkilenen HTML girdisi sayısı.
926
+ */
927
+ export function invalidateHtmlByDependency(dataKeys) {
928
+ /** @type {Set<string>} */
929
+ const keys = new Set();
930
+ const now = Date.now();
931
+
932
+ for (const dep of dataKeys) {
933
+ // Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
934
+ // görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
935
+ purgedDeps.set(dep, now);
936
+
937
+ const set = dependents.get(dep);
938
+ if (set) for (const key of set) keys.add(key);
939
+ }
940
+
941
+ while (purgedDeps.size > MAX_PURGED_DEPS) {
942
+ const oldest = purgedDeps.keys().next().value;
943
+ if (oldest === undefined) break;
944
+ purgedDeps.delete(oldest);
945
+ }
946
+
947
+ for (const key of keys) invalidateKey(key, false);
948
+
949
+ // Paylaşımlı kopyalar da düşer, yoksa soğuk bir node az önce geçersiz
950
+ // kılınan HTML'i Redis'ten geri alırdı. Yalnızca bu node'un tanıdığı
951
+ // anahtarlar silinebiliyor; hiçbir L1'de sıcak olmayan bir sayfanın Redis
952
+ // kopyası TTL'ini bekler.
953
+ if (keys.size && redisShares("html")) {
954
+ redisDrop([...keys].map((key) => cacheKey("html", key)));
955
+ } else if (keys.size && diskShares("html")) {
956
+ diskDrop("html", [...keys]);
957
+ }
958
+
959
+ return keys.size;
960
+ }
961
+
962
+ /**
963
+ * Tek bir önbellek **anahtarını** düşürür.
964
+ *
965
+ * `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
966
+ * varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
967
+ * silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
968
+ * kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
969
+ *
970
+ * @param {string} key `yol?query` biçiminde tam anahtar.
971
+ * @returns {boolean} Girdi var mıydı.
972
+ */
973
+ export function dropHtmlCacheKey(key) {
974
+ const existed = dropLocalKey(key);
975
+
976
+ if (redisShares("html")) redisDrop([cacheKey("html", key)]);
977
+ else if (diskShares("html")) diskDrop("html", [key]);
978
+ publishCacheEvent({ type: "html:drop", key });
979
+
980
+ return existed;
981
+ }
982
+
983
+ /**
984
+ * @param {string} key
985
+ * @returns {boolean}
986
+ */
987
+ function dropLocalKey(key) {
988
+ // Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
989
+ tokens.delete(key);
990
+ invalidated.delete(key);
991
+ return drop(key);
992
+ }
993
+
994
+ /**
995
+ * Vary önekinden `h=` parçasını okur. Önek yoksa boş string.
996
+ *
997
+ * @param {string} key
998
+ * @returns {string}
999
+ */
1000
+ function hostOfCacheKey(key) {
1001
+ const sep = key.indexOf("|/");
1002
+ if (sep === -1) return "";
1003
+ const prefix = key.slice(0, sep);
1004
+ for (const part of prefix.split("&")) {
1005
+ if (part.startsWith("h=")) return part.slice(2);
1006
+ }
1007
+ return "";
1008
+ }
1009
+
1010
+ /**
1011
+ * @param {string} key
1012
+ * @returns {string}
1013
+ */
1014
+ function pathWithQuery(key) {
1015
+ const pathname = pathOfCacheKey(key);
1016
+ const q = key.indexOf("?");
1017
+ if (q === -1) return pathname;
1018
+ const query = key.slice(q + 1);
1019
+ return query ? `${pathname}?${query}` : pathname;
1020
+ }
1021
+
1022
+ /**
1023
+ * Invalidate edilmiş yollar. Okuma yıkıcıdır; iki tur aynı yolu tekrar
1024
+ * ısıtmasın. `onlyHost` verilirse başka host'ların anahtarları kuyrukta
1025
+ * kalır — süre dolumu onları kendi host'uyla ısıtır, `127.0.0.1` anahtarı
1026
+ * açılmaz.
1027
+ *
1028
+ * @param {string} [onlyHost]
1029
+ * @returns {{ key: string, path: string, host: string }[]}
1030
+ */
1031
+ export function takeInvalidatedTargets(onlyHost) {
1032
+ if (!invalidated.size) return [];
1033
+
1034
+ /** @type {{ key: string, path: string, host: string }[]} */
1035
+ const taken = [];
1036
+
1037
+ for (const key of [...invalidated]) {
1038
+ const host = hostOfCacheKey(key);
1039
+ if (onlyHost && host && host !== onlyHost) continue;
1040
+ invalidated.delete(key);
1041
+ taken.push({ key, path: pathWithQuery(key), host });
1042
+ }
1043
+
1044
+ return taken;
1045
+ }
1046
+
1047
+ /**
1048
+ * Yol listesi. Vary öneki düşülür; host ayrımı `takeInvalidatedTargets`.
1049
+ *
1050
+ * @returns {string[]}
1051
+ */
1052
+ export function takeInvalidatedPaths() {
1053
+ return takeInvalidatedTargets().map((target) => target.path);
1054
+ }
1055
+
1056
+ /**
1057
+ * Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
1058
+ * zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
1059
+ * boyutu.
1060
+ *
1061
+ * @returns {{ key: string, bytes: number, status: number, stale: boolean,
1062
+ * expiresIn: number, encodings: string[], deps: number }[]}
1063
+ */
1064
+ export function getHtmlCacheEntries() {
1065
+ const now = Date.now();
1066
+
1067
+ return [...store.entries()].map(([key, entry]) => ({
1068
+ key,
1069
+ bytes: Buffer.byteLength(entry.html),
1070
+ status: entry.status,
1071
+ stale: now >= entry.expiresAt,
1072
+ expiresIn: Math.round((entry.expiresAt - now) / 1000),
1073
+ encodings: [...entry.encoded.keys()],
1074
+ deps: entry.deps.size,
1075
+ }));
1076
+ }
1077
+
1078
+ /**
1079
+ * Yol (query'siz) için taze bir HTML girdisi var mı? Ziyaret ısıtması yalnızca
1080
+ * soğuk / bayat hedefleri kuyruğa alır; HIT'leri yeniden çekmez.
1081
+ *
1082
+ * Gerçek anahtar `h=host|/yol?` biçimindedir: düz `store.get(pathname)` hem
1083
+ * vary önekini hem sondaki `?` işaretini kaçırır ve sıcak sayfayı yeniden
1084
+ * ısıtır. `vary.host` açıkken yalnızca bu isteğin host'u sayılır; diğer
1085
+ * locale'in kopyası bu yolu sıcak yapmaz.
1086
+ *
1087
+ * @param {string} pathname
1088
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
1089
+ * @returns {boolean}
1090
+ */
1091
+ export function isHtmlCacheFresh(pathname, req) {
1092
+ if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
1093
+
1094
+ let host = "";
1095
+ try {
1096
+ if (getConfig().cacheVary?.host && req) host = publicHost(req);
1097
+ } catch {
1098
+ // Config yokken (testler) önek aranmaz; yol eşleşmesi yeter.
1099
+ }
1100
+
1101
+ const now = Date.now();
1102
+ for (const [key, entry] of store) {
1103
+ if (now >= entry.expiresAt) continue;
1104
+ if (pathOfCacheKey(key) !== pathname) continue;
1105
+ const q = key.indexOf("?");
1106
+ if (q !== -1 && key.slice(q + 1)) continue;
1107
+ if (host && hostOfCacheKey(key) !== host) continue;
1108
+ return true;
1109
+ }
1110
+
1111
+ return false;
1112
+ }
1113
+
1114
+ /**
1115
+ * Bu anahtarın girdisi hâlâ taze mi? Süre dolumu ısıtması anahtarı bildiği
1116
+ * için yol taraması yapmaz. Soft-bayat (`expiresAt === 0`) taze sayılmaz:
1117
+ * ziyaretçi arada yenilediyse yeni `expiresAt` taze döner ve HTTP atlanır.
1118
+ *
1119
+ * @param {string} key
1120
+ * @returns {boolean}
1121
+ */
1122
+ export function isHtmlCacheKeyFresh(key) {
1123
+ const entry = store.get(key);
1124
+ if (!entry) return false;
1125
+ return Date.now() < entry.expiresAt;
1126
+ }
1127
+
1128
+ /**
1129
+ * Girdide en fazla bir sıkıştırılmış gövde durur. Yeni kodlama eskisinin
1130
+ * yerini alır; ham HTML kalır. Bayt sayacı `noteHtmlCacheGrowth` ile işlenir.
1131
+ *
1132
+ * @param {Map<string, Buffer>} encoded
1133
+ * @param {string} encoding
1134
+ * @param {Buffer} buffer
1135
+ * @returns {void}
1136
+ */
1137
+ export function rememberHtmlEncoding(encoded, encoding, buffer) {
1138
+ for (const key of [...encoded.keys()]) {
1139
+ if (key !== encoding) encoded.delete(key);
1140
+ }
1141
+ encoded.set(encoding, buffer);
1142
+ noteHtmlCacheGrowth();
1143
+ }
1144
+
1145
+ /**
1146
+ * Erken tazeleme penceresine girmiş (veya TTL'i dolmuş) trafiksiz girdileri
1147
+ * soft-bayatlatır ve ısıtma kuyruğuna alır. HTTP ısıtması producer'sız
1148
+ * çalıştığı için soft-bayat şart: taze HIT yenileme tetiklemez.
1149
+ *
1150
+ * Tur başına en fazla `EARLY_SWEEP_MARK_BUDGET` girdi. Önce süresi en yakın
1151
+ * dolacak olan; kota dolunca kalanlar bir sonraki tura kalır.
1152
+ *
1153
+ * @returns {number} İşaretlenen girdi sayısı.
1154
+ */
1155
+ export function sweepEarlyExpiry() {
1156
+ const now = Date.now();
1157
+ /** @type {{ key: string, expiresAt: number }[]} */
1158
+ const due = [];
1159
+
1160
+ for (const [key, entry] of store) {
1161
+ if (inflight.has(key)) continue;
1162
+ // Zaten soft-bayat / kuyrukta — her saniye yeniden ekleme.
1163
+ if (entry.expiresAt === 0) continue;
1164
+ if (now < entry.expiresAt && !isEarly(entry, now)) continue;
1165
+ due.push({ key, expiresAt: entry.expiresAt });
1166
+ }
1167
+
1168
+ due.sort((a, b) => a.expiresAt - b.expiresAt);
1169
+
1170
+ let marked = 0;
1171
+ for (const item of due) {
1172
+ if (marked >= EARLY_SWEEP_MARK_BUDGET) break;
1173
+ const entry = store.get(item.key);
1174
+ if (!entry || inflight.has(item.key) || entry.expiresAt === 0) continue;
1175
+
1176
+ entry.expiresAt = 0;
1177
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(item.key);
1178
+ marked += 1;
1179
+ }
1180
+
1181
+ return marked;
1182
+ }
1183
+
1184
+ /**
1185
+ * Trafiksiz sayfaların TTL öncesi soft-bayatlatılması. `startPrewarm` açar;
1186
+ * `PREWARM=0` iken hiç kurulmaz. `unref` — süreç kapanışını geciktirmez.
1187
+ *
1188
+ * @returns {void}
1189
+ */
1190
+ export function startEarlyExpirySweep() {
1191
+ if (earlySweepTimer) return;
1192
+ earlySweepTimer = setInterval(() => {
1193
+ sweepEarlyExpiry();
1194
+ }, EARLY_SWEEP_INTERVAL_MS);
1195
+ earlySweepTimer.unref();
1196
+ }