jskelet 0.6.1 → 0.6.3

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