jskelet 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,817 +1,817 @@
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'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
11
- * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
12
- * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
13
- * TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
14
- * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
15
- * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
16
- * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
17
- *
18
- * ## Paylaşımlı kademe
19
- *
20
- * `cache.redis` açıkken store'un ikinci bir kademesi olur. Bellek içi store
21
- * (L1) **birincil kalır**: `read()` senkron, sıkıştırılmış gövdeler girdiyle
22
- * birlikte ve tutarlılık makinesi (`tokens`, `purgedDeps`) tek proseste. Redis
23
- * yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı
24
- * diğer node'lara duyurur. Redis erişilemez olduğunda bu modül birebir eskisi
25
- * gibi çalışır.
26
- */
27
-
28
- import { getConfig } from "../config/index.js";
29
- import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
30
- import { collectDependencies } from "./cache-deps.js";
31
- import { compilePattern, matchPattern } from "../config/pattern.js";
32
- import {
33
- cacheKey,
34
- onCacheEvent,
35
- publishCacheEvent,
36
- redisDrop,
37
- redisDropMatching,
38
- redisGetJson,
39
- redisSetJson,
40
- redisShares,
41
- redisSharesEncoded,
42
- } from "./redis.js";
43
-
44
- /**
45
- * `storedAt`: girdinin üretildiği an. Paylaşımlı kademeden gelen bir girdiyi
46
- * kabul etmeden önce "bu render yerel bir purge'den önce mi başladı" sorusu
47
- * yine sorulur; cevabı bu alan taşıyor.
48
- *
49
- * `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
50
- * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
51
- * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
52
- *
53
- * @typedef {{ html: string, status: number, expiresAt: number,
54
- * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
55
- * storedAt: number, sharedEncodings: number }} HtmlEntry
56
- */
57
-
58
- /**
59
- * Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
60
- * siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
61
- * düşüyor. On binlerce yol için `withDataCache` kullanılır.
62
- *
63
- * Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
64
- * durumda kod varsayılanı geçerli.
65
- *
66
- * @returns {number}
67
- */
68
- function maxEntries() {
69
- try {
70
- return getConfig().htmlMaxEntries;
71
- } catch {
72
- return DEFAULT_HTML_CACHE_MAX_ENTRIES;
73
- }
74
- }
75
-
76
- /**
77
- * Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
78
- * uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
79
- *
80
- * @returns {boolean}
81
- */
82
- function trackDependencies() {
83
- try {
84
- return getConfig().trackDependencies;
85
- } catch {
86
- return true;
87
- }
88
- }
89
-
90
- /**
91
- * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
92
- * Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
93
- * yavaş upstream'lerde devreye girer.
94
- */
95
- const STALE_FACTOR = 1;
96
-
97
- /** @type {Map<string, HtmlEntry>} */
98
- const store = new Map();
99
-
100
- /** @type {Map<string, Promise<{ html: string, status: number }>>} */
101
- const inflight = new Map();
102
-
103
- /**
104
- * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
105
- * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
106
- * okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
107
- * istek yeni bir tur başlatır.
108
- *
109
- * @type {Map<string, object>}
110
- */
111
- const tokens = new Map();
112
-
113
- /**
114
- * Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
115
- * bunu okuyup etkilenen sayfaları bayatlatır.
116
- *
117
- * @type {Map<string, Set<string>>}
118
- */
119
- const dependents = new Map();
120
-
121
- /**
122
- * Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
123
- * kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
124
- * ziyaretçi gelmesini beklemeden tazelenir.
125
- *
126
- * Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
127
- *
128
- * @type {Set<string>}
129
- */
130
- const invalidated = new Set();
131
-
132
- const MAX_INVALIDATED = 500;
133
-
134
- /**
135
- * Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
136
- *
137
- * Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
138
- * tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
139
- * önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
140
- * "doğduğu anda bayat" girdiyi engeller.
141
- *
142
- * Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
143
- * eski kayıt düşer.
144
- *
145
- * @type {Map<string, number>}
146
- */
147
- const purgedDeps = new Map();
148
-
149
- const MAX_PURGED_DEPS = 1000;
150
-
151
- /**
152
- * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
153
- * anahtarlarını tutmaya devam eder ve sessizce sızar.
154
- *
155
- * @param {string} key
156
- * @param {HtmlEntry} entry
157
- */
158
- function unlink(key, entry) {
159
- for (const dep of entry.deps) {
160
- const set = dependents.get(dep);
161
- if (!set) continue;
162
- set.delete(key);
163
- if (!set.size) dependents.delete(dep);
164
- }
165
- }
166
-
167
- /**
168
- * Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
169
- * hiçbir yerde doğrudan `store.delete()` çağrılmaz.
170
- *
171
- * @param {string} key
172
- * @returns {boolean} Girdi var mıydı.
173
- */
174
- function drop(key) {
175
- const entry = store.get(key);
176
- if (!entry) return false;
177
-
178
- unlink(key, entry);
179
- store.delete(key);
180
- return true;
181
- }
182
-
183
- /**
184
- * @param {string} key
185
- * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
186
- * stale: boolean } | null}
187
- */
188
- function read(key) {
189
- const entry = store.get(key);
190
- if (!entry) return null;
191
-
192
- const now = Date.now();
193
- if (now >= entry.staleUntil) {
194
- drop(key);
195
- return null;
196
- }
197
-
198
- // LRU: erişilen girdiyi sona taşı.
199
- store.delete(key);
200
- store.set(key, entry);
201
-
202
- // `encoded` yanıt yolunda dolduğu için yazma anında paylaşılamıyor. Kontrol
203
- // yalnızca `storeEncoded` açıkken yapılır; kapalıyken (varsayılan) bu satır
204
- // tek bir karşılaştırmaya bile girmez.
205
- if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
206
- share(key, entry);
207
- }
208
-
209
- return {
210
- html: entry.html,
211
- status: entry.status,
212
- encoded: entry.encoded,
213
- stale: now >= entry.expiresAt,
214
- };
215
- }
216
-
217
- /**
218
- * Girdiyi paylaşımlı kademeye yazar. Ateşle-unut: yanıt yolunda beklenmez,
219
- * L1 kopyası bu isteği zaten karşılıyor.
220
- *
221
- * @param {string} key
222
- * @param {HtmlEntry} entry
223
- */
224
- function share(key, entry) {
225
- if (!redisShares("html")) return;
226
-
227
- const ttlMs = entry.staleUntil - Date.now();
228
- if (ttlMs <= 0) return;
229
-
230
- /** @type {Record<string, unknown>} */
231
- const payload = {
232
- html: entry.html,
233
- status: entry.status,
234
- storedAt: entry.storedAt,
235
- expiresAt: entry.expiresAt,
236
- staleUntil: entry.staleUntil,
237
- deps: [...entry.deps],
238
- };
239
-
240
- if (redisSharesEncoded() && entry.encoded.size) {
241
- /** @type {Record<string, string>} */
242
- const encoded = {};
243
- for (const [encoding, buffer] of entry.encoded) {
244
- encoded[encoding] = buffer.toString("base64");
245
- }
246
- payload.encoded = encoded;
247
- entry.sharedEncodings = entry.encoded.size;
248
- }
249
-
250
- redisSetJson(cacheKey("html", key), payload, ttlMs);
251
- }
252
-
253
- /**
254
- * Paylaşımlı kademeden okur ve L1 girdisine çevirir.
255
- *
256
- * Yalnızca **taze** girdi kabul edilir: bayat bir kopyayı L1'e almak
257
- * tazelemeyi sonsuza kadar ertelerdi — girdi bayat kalır, her tazeleme turu
258
- * yine Redis'i okur ve `producer` hiç çalışmaz.
259
- *
260
- * @param {string} key
261
- * @returns {Promise<HtmlEntry | null>}
262
- */
263
- async function readShared(key) {
264
- if (!redisShares("html")) return null;
265
-
266
- const payload = await redisGetJson(cacheKey("html", key));
267
- if (!payload || typeof payload.html !== "string") return null;
268
- if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
269
- return null;
270
- }
271
-
272
- const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
273
- const storedAt = Number(payload.storedAt) || 0;
274
-
275
- // Uzak girdi de yerel purge geçmişine takılır: bu proseste düşürülmüş bir
276
- // veriyi okumuş HTML'i geri almak, az önce yapılan invalidation'ı iptal
277
- // etmek olurdu.
278
- if (readsPurgedData(deps, storedAt)) return null;
279
-
280
- /** @type {Map<string, Buffer>} */
281
- const encoded = new Map();
282
- if (payload.encoded && typeof payload.encoded === "object") {
283
- for (const [encoding, base64] of Object.entries(payload.encoded)) {
284
- if (typeof base64 === "string") {
285
- encoded.set(encoding, Buffer.from(base64, "base64"));
286
- }
287
- }
288
- }
289
-
290
- return {
291
- html: payload.html,
292
- status: Number(payload.status) || 200,
293
- encoded,
294
- // Mutlak zamanlar korunur: TTL'i yeniden başlatmak, girdinin node'dan
295
- // node'a atlayarak süresiz tazelik kazanması demek.
296
- expiresAt: payload.expiresAt,
297
- staleUntil: Number(payload.staleUntil) || payload.expiresAt,
298
- deps,
299
- storedAt,
300
- sharedEncodings: encoded.size,
301
- };
302
- }
303
-
304
- /**
305
- * @param {string} key
306
- * @param {{ html: string, status: number }} value
307
- * @param {number} ttlSeconds
308
- * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
309
- */
310
- function write(key, value, ttlSeconds, deps = null) {
311
- const now = Date.now();
312
-
313
- /** @type {HtmlEntry} */
314
- const entry = {
315
- html: value.html,
316
- status: value.status,
317
- // Sıkıştırılmış gövdeler HTML ile aynı ömrü paylaşır: aynı sayfa her
318
- // istekte yeniden brotli'lenmesin.
319
- encoded: new Map(),
320
- expiresAt: now + ttlSeconds * 1000,
321
- staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
322
- deps: deps ?? new Set(),
323
- storedAt: now,
324
- sharedEncodings: 0,
325
- };
326
-
327
- install(key, entry);
328
- share(key, entry);
329
- }
330
-
331
- /**
332
- * Girdiyi L1'e yerleştirir, ters indekse bağlar ve sınırı uygular. Store'a
333
- * yazmanın tek yolu bu.
334
- *
335
- * @param {string} key
336
- * @param {HtmlEntry} entry
337
- */
338
- function install(key, entry) {
339
- // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
340
- // tazelemeden tazelemeye değişebilir.
341
- drop(key);
342
-
343
- store.set(key, entry);
344
-
345
- for (const dep of entry.deps) {
346
- let set = dependents.get(dep);
347
- if (!set) dependents.set(dep, (set = new Set()));
348
- set.add(key);
349
- }
350
-
351
- const limit = maxEntries();
352
- while (store.size > limit) {
353
- const oldest = store.keys().next().value;
354
- if (oldest === undefined) break;
355
- drop(oldest);
356
- }
357
- }
358
-
359
- /**
360
- * Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
361
- *
362
- * @param {Set<string> | null} deps
363
- * @param {number} startedAt
364
- * @returns {boolean}
365
- */
366
- function readsPurgedData(deps, startedAt) {
367
- if (!deps) return false;
368
-
369
- for (const dep of deps) {
370
- const purgedAt = purgedDeps.get(dep);
371
- if (purgedAt !== undefined && purgedAt >= startedAt) return true;
372
- }
373
- return false;
374
- }
375
-
376
- /**
377
- * @param {string} key
378
- * @param {number} ttlSeconds
379
- * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
380
- * storable?: boolean }>} producer
381
- * @returns {Promise<{ html: string, status: number, degraded?: boolean,
382
- * storable?: boolean }>}
383
- */
384
- function refresh(key, ttlSeconds, producer) {
385
- const pending = inflight.get(key);
386
- if (pending) return pending;
387
-
388
- const token = {};
389
- tokens.set(key, token);
390
-
391
- const task = produce(key, ttlSeconds, producer, token).finally(() => {
392
- inflight.delete(key);
393
- if (tokens.get(key) === token) tokens.delete(key);
394
- });
395
-
396
- inflight.set(key, task);
397
- return task;
398
- }
399
-
400
- /**
401
- * @param {string} key
402
- * @param {number} ttlSeconds
403
- * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
404
- * storable?: boolean }>} producer
405
- * @param {object} token
406
- * @returns {Promise<{ html: string, status: number, degraded?: boolean,
407
- * storable?: boolean }>}
408
- */
409
- async function produce(key, ttlSeconds, producer, token) {
410
- const startedAt = Date.now();
411
-
412
- // Başka bir node bu sayfayı zaten render ettiyse render hiç çalışmaz. Soğuk
413
- // ayağa kalkan bir instance'ın sıcak önbellek bulmasının tek yolu bu.
414
- const shared = await readShared(key);
415
- if (shared && tokens.get(key) === token) {
416
- install(key, shared);
417
- return { html: shared.html, status: shared.status };
418
- }
419
-
420
- // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
421
- // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
422
- const deps = trackDependencies() ? new Set() : null;
423
-
424
- const value = await (deps ? collectDependencies(deps, producer) : producer());
425
-
426
- // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
427
- // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
428
- //
429
- // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
430
- // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
431
- // kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
432
- // kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
433
- // dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
434
- //
435
- // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
436
- // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
437
- const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
438
- if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
439
- write(key, value, ttlSeconds, deps);
440
- }
441
-
442
- return value;
443
- }
444
-
445
- /**
446
- * @param {string} key
447
- * @param {number} ttlSeconds 0 → cache yok
448
- * @param {() => Promise<{ html: string, status: number }>} producer
449
- * @returns {Promise<{ html: string, status: number, cached: boolean,
450
- * stale?: boolean, encoded?: Map<string, Buffer> }>}
451
- */
452
- export async function withHtmlCache(key, ttlSeconds, producer) {
453
- if (!ttlSeconds) {
454
- const fresh = await producer();
455
- return { ...fresh, cached: false };
456
- }
457
-
458
- const hit = read(key);
459
-
460
- if (hit) {
461
- // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
462
- // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
463
- if (hit.stale) {
464
- invalidated.delete(key);
465
- void refresh(key, ttlSeconds, producer).catch((error) => {
466
- console.error(`[html-cache] background refresh failed: ${key}`, error);
467
- });
468
- }
469
- return { ...hit, cached: true };
470
- }
471
-
472
- invalidated.delete(key);
473
- const value = await refresh(key, ttlSeconds, producer);
474
- return { ...value, encoded: store.get(key)?.encoded, cached: false };
475
- }
476
-
477
- /**
478
- * Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
479
- * çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
480
- * yani gerçekten **geçersiz** — bayatlatmak yetmez.
481
- */
482
- export function clearHtmlCache() {
483
- clearLocal();
484
-
485
- if (redisShares("html")) void redisDropMatching("html");
486
- publishCacheEvent({ type: "html:clear" });
487
- }
488
-
489
- /**
490
- * Boşaltmanın yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın
491
- * yapan bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
492
- */
493
- function clearLocal() {
494
- store.clear();
495
- dependents.clear();
496
- tokens.clear();
497
- invalidated.clear();
498
- purgedDeps.clear();
499
- }
500
-
501
- export function getHtmlCacheSize() {
502
- return store.size;
503
- }
504
-
505
- /**
506
- * Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
507
- *
508
- * Üç biçim kabul edilir:
509
- * `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
510
- * `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
511
- * `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
512
- *
513
- * Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
514
- * kuralı `/haberler`i düşürmemeli.
515
- *
516
- * @param {string | RegExp} target
517
- * @returns {((pathname: string) => boolean) | null}
518
- */
519
- function toMatcher(target) {
520
- if (target instanceof RegExp) return (pathname) => target.test(pathname);
521
-
522
- if (typeof target !== "string" || !target.startsWith("/")) {
523
- console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
524
- return null;
525
- }
526
-
527
- if (target.includes(":")) {
528
- const compiled = compilePattern(target);
529
- if (!compiled) return null;
530
- return (pathname) => matchPattern(compiled, pathname) !== null;
531
- }
532
-
533
- const prefix = target.endsWith("/") ? target : `${target}/`;
534
- return (pathname) => pathname === target || pathname.startsWith(prefix);
535
- }
536
-
537
- /**
538
- * Etkilenen girdiyi bayatlatır ya da düşürür.
539
- *
540
- * @param {string} key
541
- * @param {boolean} hard
542
- */
543
- function invalidateKey(key, hard) {
544
- // Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
545
- // üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
546
- // henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
547
- tokens.delete(key);
548
-
549
- const entry = store.get(key);
550
- if (entry) {
551
- // Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
552
- if (hard || Date.now() >= entry.staleUntil) drop(key);
553
- else entry.expiresAt = 0;
554
- }
555
-
556
- if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
557
- }
558
-
559
- /**
560
- * Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
561
- *
562
- * Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
563
- * sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
564
- * içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
565
- * döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
566
- * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
567
- * HTML'in gerçekten geçersiz olduğu durumlar için.
568
- *
569
- * Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
570
- * yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
571
- *
572
- * @param {string | RegExp | (string | RegExp)[]} target
573
- * @param {{ hard?: boolean }} [options]
574
- * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
575
- */
576
- export function invalidateHtmlCache(target, options = {}) {
577
- const targets = Array.isArray(target) ? target : [target];
578
- const hard = options.hard === true;
579
- const count = invalidateLocal(targets, hard);
580
-
581
- // Paylaşımlı kopya yumuşak invalidation'da da **silinir**. Bayatlatmanın
582
- // Redis karşılığı her anahtar için oku-değiştir-yaz turu demek ve bir
583
- // webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç
584
- // görmemiş bir node'un bir kez render etmesi; L1'i sıcak olan node'lar eski
585
- // HTML'i bayat pencerede servis etmeye devam ediyor.
586
- if (redisShares("html")) {
587
- const matchers = compileMatchers(targets);
588
- if (matchers.length) {
589
- void redisDropMatching("html", (key) =>
590
- matchers.some((matcher) => matcher(pathOf(key))),
591
- );
592
- }
593
- }
594
-
595
- // Hedefler yayınlanır, eşleşen anahtarlar değil: hangi yolun nerede sıcak
596
- // olduğu node'a bağlı, her node deseni kendi store'una uygular.
597
- publishCacheEvent({
598
- type: "html:invalidate",
599
- hard,
600
- targets: targets.map(serializeTarget).filter((entry) => entry !== null),
601
- });
602
-
603
- return count;
604
- }
605
-
606
- /**
607
- * @param {(string | RegExp)[]} targets
608
- * @param {boolean} hard
609
- * @returns {number}
610
- */
611
- function invalidateLocal(targets, hard) {
612
- const matchers = compileMatchers(targets);
613
- if (!matchers.length) return 0;
614
-
615
- let count = 0;
616
-
617
- // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
618
- // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
619
- // `invalidateKey` sert modda store'dan siliyor.
620
- for (const key of new Set([...store.keys(), ...tokens.keys()])) {
621
- if (!matchers.some((matcher) => matcher(pathOf(key)))) continue;
622
- invalidateKey(key, hard);
623
- count += 1;
624
- }
625
-
626
- return count;
627
- }
628
-
629
- /**
630
- * @param {(string | RegExp)[]} targets
631
- * @returns {((pathname: string) => boolean)[]}
632
- */
633
- function compileMatchers(targets) {
634
- return /** @type {((pathname: string) => boolean)[]} */ (
635
- targets.map(toMatcher).filter((matcher) => matcher !== null)
636
- );
637
- }
638
-
639
- /**
640
- * Anahtar `yol?query`; eşleştirme **yol kısmına** yapılır.
641
- *
642
- * @param {string} key
643
- * @returns {string}
644
- */
645
- function pathOf(key) {
646
- const mark = key.indexOf("?");
647
- return mark === -1 ? key : key.slice(0, mark);
648
- }
649
-
650
- /**
651
- * `RegExp` JSON'a girmez (`JSON.stringify(/x/)` → `{}`), bu yüzden kaynak ve
652
- * bayrakları taşınır.
653
- *
654
- * @param {string | RegExp} target
655
- * @returns {string | { re: string, flags: string } | null}
656
- */
657
- function serializeTarget(target) {
658
- if (typeof target === "string") return target;
659
- if (target instanceof RegExp) return { re: target.source, flags: target.flags };
660
- return null;
661
- }
662
-
663
- /**
664
- * @param {unknown} value
665
- * @returns {string | RegExp | null}
666
- */
667
- function deserializeTarget(value) {
668
- if (typeof value === "string") return value;
669
-
670
- const entry = /** @type {{ re?: unknown, flags?: unknown }} */ (value);
671
- if (!entry || typeof entry.re !== "string") return null;
672
-
673
- try {
674
- return new RegExp(entry.re, typeof entry.flags === "string" ? entry.flags : "");
675
- } catch {
676
- // Bozuk bir desen bu node'u düşürmemeli; olay yok sayılır.
677
- return null;
678
- }
679
- }
680
-
681
- // Uzak bir node invalidation yaptığında bu proses de kendi L1'ini işaretler.
682
- // Dinleyiciler yalnızca yerel yolları çağırır, yoksa mesaj döngüsü oluşur.
683
- onCacheEvent((event) => {
684
- if (event.type === "html:clear") {
685
- clearLocal();
686
- return;
687
- }
688
-
689
- if (event.type === "html:drop") {
690
- if (typeof event.key === "string") dropLocalKey(event.key);
691
- return;
692
- }
693
-
694
- if (event.type !== "html:invalidate") return;
695
-
696
- const targets = /** @type {(string | RegExp)[]} */ (
697
- (Array.isArray(event.targets) ? event.targets : [])
698
- .map(deserializeTarget)
699
- .filter((entry) => entry !== null)
700
- );
701
-
702
- if (targets.length) invalidateLocal(targets, event.hard === true);
703
- });
704
-
705
- /**
706
- * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
707
- * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
708
- *
709
- * Burada **yayın yapılmaz**: çağıran `clearDataCache()` zaten bir
710
- * `data:clear` olayı yayınlıyor ve uzak node'lar aynı zinciri kendi ters
711
- * indeksleri üzerinden çalıştırıyor. Ters indeks node'a özel olduğu için
712
- * doğru olan da bu — bir sayfa yalnızca onu render etmiş node'da kayıtlı.
713
- *
714
- * @param {Iterable<string>} dataKeys
715
- * @returns {number} Etkilenen HTML girdisi sayısı.
716
- */
717
- export function invalidateHtmlByDependency(dataKeys) {
718
- /** @type {Set<string>} */
719
- const keys = new Set();
720
- const now = Date.now();
721
-
722
- for (const dep of dataKeys) {
723
- // Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
724
- // görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
725
- purgedDeps.set(dep, now);
726
-
727
- const set = dependents.get(dep);
728
- if (set) for (const key of set) keys.add(key);
729
- }
730
-
731
- while (purgedDeps.size > MAX_PURGED_DEPS) {
732
- const oldest = purgedDeps.keys().next().value;
733
- if (oldest === undefined) break;
734
- purgedDeps.delete(oldest);
735
- }
736
-
737
- for (const key of keys) invalidateKey(key, false);
738
-
739
- // Paylaşımlı kopyalar da düşer, yoksa soğuk bir node az önce geçersiz
740
- // kılınan HTML'i Redis'ten geri alırdı. Yalnızca bu node'un tanıdığı
741
- // anahtarlar silinebiliyor; hiçbir L1'de sıcak olmayan bir sayfanın Redis
742
- // kopyası TTL'ini bekler.
743
- if (keys.size && redisShares("html")) {
744
- redisDrop([...keys].map((key) => cacheKey("html", key)));
745
- }
746
-
747
- return keys.size;
748
- }
749
-
750
- /**
751
- * Tek bir önbellek **anahtarını** düşürür.
752
- *
753
- * `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
754
- * varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
755
- * silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
756
- * kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
757
- *
758
- * @param {string} key `yol?query` biçiminde tam anahtar.
759
- * @returns {boolean} Girdi var mıydı.
760
- */
761
- export function dropHtmlCacheKey(key) {
762
- const existed = dropLocalKey(key);
763
-
764
- if (redisShares("html")) redisDrop([cacheKey("html", key)]);
765
- publishCacheEvent({ type: "html:drop", key });
766
-
767
- return existed;
768
- }
769
-
770
- /**
771
- * @param {string} key
772
- * @returns {boolean}
773
- */
774
- function dropLocalKey(key) {
775
- // Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
776
- tokens.delete(key);
777
- invalidated.delete(key);
778
- return drop(key);
779
- }
780
-
781
- /**
782
- * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
783
- * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
784
- * ısıtmasın diye okuma yıkıcıdır.
785
- *
786
- * @returns {string[]}
787
- */
788
- export function takeInvalidatedPaths() {
789
- if (!invalidated.size) return [];
790
-
791
- const paths = [...invalidated];
792
- invalidated.clear();
793
- // Anahtar `yol?query`; query boşsa sondaki `?` atılır.
794
- return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
795
- }
796
-
797
- /**
798
- * Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
799
- * zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
800
- * boyutu.
801
- *
802
- * @returns {{ key: string, bytes: number, status: number, stale: boolean,
803
- * expiresIn: number, encodings: string[], deps: number }[]}
804
- */
805
- export function getHtmlCacheEntries() {
806
- const now = Date.now();
807
-
808
- return [...store.entries()].map(([key, entry]) => ({
809
- key,
810
- bytes: Buffer.byteLength(entry.html),
811
- status: entry.status,
812
- stale: now >= entry.expiresAt,
813
- expiresIn: Math.round((entry.expiresAt - now) / 1000),
814
- encodings: [...entry.encoded.keys()],
815
- deps: entry.deps.size,
816
- }));
817
- }
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'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
11
+ * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
12
+ * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
13
+ * TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
14
+ * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
15
+ * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
16
+ * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
17
+ *
18
+ * ## Paylaşımlı kademe
19
+ *
20
+ * `cache.redis` açıkken store'un ikinci bir kademesi olur. Bellek içi store
21
+ * (L1) **birincil kalır**: `read()` senkron, sıkıştırılmış gövdeler girdiyle
22
+ * birlikte ve tutarlılık makinesi (`tokens`, `purgedDeps`) tek proseste. Redis
23
+ * yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı
24
+ * diğer node'lara duyurur. Redis erişilemez olduğunda bu modül birebir eskisi
25
+ * gibi çalışır.
26
+ */
27
+
28
+ import { getConfig } from "../config/index.js";
29
+ import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
30
+ import { collectDependencies } from "./cache-deps.js";
31
+ import { compilePattern, matchPattern } from "../config/pattern.js";
32
+ import {
33
+ cacheKey,
34
+ onCacheEvent,
35
+ publishCacheEvent,
36
+ redisDrop,
37
+ redisDropMatching,
38
+ redisGetJson,
39
+ redisSetJson,
40
+ redisShares,
41
+ redisSharesEncoded,
42
+ } from "./redis.js";
43
+
44
+ /**
45
+ * `storedAt`: girdinin üretildiği an. Paylaşımlı kademeden gelen bir girdiyi
46
+ * kabul etmeden önce "bu render yerel bir purge'den önce mi başladı" sorusu
47
+ * yine sorulur; cevabı bu alan taşıyor.
48
+ *
49
+ * `sharedEncodings`: Redis'e en son kaç sıkıştırılmış gövde yazıldığı.
50
+ * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
51
+ * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
52
+ *
53
+ * @typedef {{ html: string, status: number, expiresAt: number,
54
+ * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
55
+ * storedAt: number, sharedEncodings: number }} HtmlEntry
56
+ */
57
+
58
+ /**
59
+ * Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
60
+ * siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
61
+ * düşüyor. On binlerce yol için `withDataCache` kullanılır.
62
+ *
63
+ * Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
64
+ * durumda kod varsayılanı geçerli.
65
+ *
66
+ * @returns {number}
67
+ */
68
+ function maxEntries() {
69
+ try {
70
+ return getConfig().htmlMaxEntries;
71
+ } catch {
72
+ return DEFAULT_HTML_CACHE_MAX_ENTRIES;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
78
+ * uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
79
+ *
80
+ * @returns {boolean}
81
+ */
82
+ function trackDependencies() {
83
+ try {
84
+ return getConfig().trackDependencies;
85
+ } catch {
86
+ return true;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
92
+ * Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
93
+ * yavaş upstream'lerde devreye girer.
94
+ */
95
+ const STALE_FACTOR = 1;
96
+
97
+ /** @type {Map<string, HtmlEntry>} */
98
+ const store = new Map();
99
+
100
+ /** @type {Map<string, Promise<{ html: string, status: number }>>} */
101
+ const inflight = new Map();
102
+
103
+ /**
104
+ * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
105
+ * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
106
+ * okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
107
+ * istek yeni bir tur başlatır.
108
+ *
109
+ * @type {Map<string, object>}
110
+ */
111
+ const tokens = new Map();
112
+
113
+ /**
114
+ * Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
115
+ * bunu okuyup etkilenen sayfaları bayatlatır.
116
+ *
117
+ * @type {Map<string, Set<string>>}
118
+ */
119
+ const dependents = new Map();
120
+
121
+ /**
122
+ * Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
123
+ * kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
124
+ * ziyaretçi gelmesini beklemeden tazelenir.
125
+ *
126
+ * Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
127
+ *
128
+ * @type {Set<string>}
129
+ */
130
+ const invalidated = new Set();
131
+
132
+ const MAX_INVALIDATED = 500;
133
+
134
+ /**
135
+ * Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
136
+ *
137
+ * Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
138
+ * tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
139
+ * önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
140
+ * "doğduğu anda bayat" girdiyi engeller.
141
+ *
142
+ * Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
143
+ * eski kayıt düşer.
144
+ *
145
+ * @type {Map<string, number>}
146
+ */
147
+ const purgedDeps = new Map();
148
+
149
+ const MAX_PURGED_DEPS = 1000;
150
+
151
+ /**
152
+ * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
153
+ * anahtarlarını tutmaya devam eder ve sessizce sızar.
154
+ *
155
+ * @param {string} key
156
+ * @param {HtmlEntry} entry
157
+ */
158
+ function unlink(key, entry) {
159
+ for (const dep of entry.deps) {
160
+ const set = dependents.get(dep);
161
+ if (!set) continue;
162
+ set.delete(key);
163
+ if (!set.size) dependents.delete(dep);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
169
+ * hiçbir yerde doğrudan `store.delete()` çağrılmaz.
170
+ *
171
+ * @param {string} key
172
+ * @returns {boolean} Girdi var mıydı.
173
+ */
174
+ function drop(key) {
175
+ const entry = store.get(key);
176
+ if (!entry) return false;
177
+
178
+ unlink(key, entry);
179
+ store.delete(key);
180
+ return true;
181
+ }
182
+
183
+ /**
184
+ * @param {string} key
185
+ * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
186
+ * stale: boolean } | null}
187
+ */
188
+ function read(key) {
189
+ const entry = store.get(key);
190
+ if (!entry) return null;
191
+
192
+ const now = Date.now();
193
+ if (now >= entry.staleUntil) {
194
+ drop(key);
195
+ return null;
196
+ }
197
+
198
+ // LRU: erişilen girdiyi sona taşı.
199
+ store.delete(key);
200
+ store.set(key, entry);
201
+
202
+ // `encoded` yanıt yolunda dolduğu için yazma anında paylaşılamıyor. Kontrol
203
+ // yalnızca `storeEncoded` açıkken yapılır; kapalıyken (varsayılan) bu satır
204
+ // tek bir karşılaştırmaya bile girmez.
205
+ if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
206
+ share(key, entry);
207
+ }
208
+
209
+ return {
210
+ html: entry.html,
211
+ status: entry.status,
212
+ encoded: entry.encoded,
213
+ stale: now >= entry.expiresAt,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Girdiyi paylaşımlı kademeye yazar. Ateşle-unut: yanıt yolunda beklenmez,
219
+ * L1 kopyası bu isteği zaten karşılıyor.
220
+ *
221
+ * @param {string} key
222
+ * @param {HtmlEntry} entry
223
+ */
224
+ function share(key, entry) {
225
+ if (!redisShares("html")) return;
226
+
227
+ const ttlMs = entry.staleUntil - Date.now();
228
+ if (ttlMs <= 0) return;
229
+
230
+ /** @type {Record<string, unknown>} */
231
+ const payload = {
232
+ html: entry.html,
233
+ status: entry.status,
234
+ storedAt: entry.storedAt,
235
+ expiresAt: entry.expiresAt,
236
+ staleUntil: entry.staleUntil,
237
+ deps: [...entry.deps],
238
+ };
239
+
240
+ if (redisSharesEncoded() && entry.encoded.size) {
241
+ /** @type {Record<string, string>} */
242
+ const encoded = {};
243
+ for (const [encoding, buffer] of entry.encoded) {
244
+ encoded[encoding] = buffer.toString("base64");
245
+ }
246
+ payload.encoded = encoded;
247
+ entry.sharedEncodings = entry.encoded.size;
248
+ }
249
+
250
+ redisSetJson(cacheKey("html", key), payload, ttlMs);
251
+ }
252
+
253
+ /**
254
+ * Paylaşımlı kademeden okur ve L1 girdisine çevirir.
255
+ *
256
+ * Yalnızca **taze** girdi kabul edilir: bayat bir kopyayı L1'e almak
257
+ * tazelemeyi sonsuza kadar ertelerdi — girdi bayat kalır, her tazeleme turu
258
+ * yine Redis'i okur ve `producer` hiç çalışmaz.
259
+ *
260
+ * @param {string} key
261
+ * @returns {Promise<HtmlEntry | null>}
262
+ */
263
+ async function readShared(key) {
264
+ if (!redisShares("html")) return null;
265
+
266
+ const payload = await redisGetJson(cacheKey("html", key));
267
+ if (!payload || typeof payload.html !== "string") return null;
268
+ if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
269
+ return null;
270
+ }
271
+
272
+ const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
273
+ const storedAt = Number(payload.storedAt) || 0;
274
+
275
+ // Uzak girdi de yerel purge geçmişine takılır: bu proseste düşürülmüş bir
276
+ // veriyi okumuş HTML'i geri almak, az önce yapılan invalidation'ı iptal
277
+ // etmek olurdu.
278
+ if (readsPurgedData(deps, storedAt)) return null;
279
+
280
+ /** @type {Map<string, Buffer>} */
281
+ const encoded = new Map();
282
+ if (payload.encoded && typeof payload.encoded === "object") {
283
+ for (const [encoding, base64] of Object.entries(payload.encoded)) {
284
+ if (typeof base64 === "string") {
285
+ encoded.set(encoding, Buffer.from(base64, "base64"));
286
+ }
287
+ }
288
+ }
289
+
290
+ return {
291
+ html: payload.html,
292
+ status: Number(payload.status) || 200,
293
+ encoded,
294
+ // Mutlak zamanlar korunur: TTL'i yeniden başlatmak, girdinin node'dan
295
+ // node'a atlayarak süresiz tazelik kazanması demek.
296
+ expiresAt: payload.expiresAt,
297
+ staleUntil: Number(payload.staleUntil) || payload.expiresAt,
298
+ deps,
299
+ storedAt,
300
+ sharedEncodings: encoded.size,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * @param {string} key
306
+ * @param {{ html: string, status: number }} value
307
+ * @param {number} ttlSeconds
308
+ * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
309
+ */
310
+ function write(key, value, ttlSeconds, deps = null) {
311
+ const now = Date.now();
312
+
313
+ /** @type {HtmlEntry} */
314
+ const entry = {
315
+ html: value.html,
316
+ status: value.status,
317
+ // Sıkıştırılmış gövdeler HTML ile aynı ömrü paylaşır: aynı sayfa her
318
+ // istekte yeniden brotli'lenmesin.
319
+ encoded: new Map(),
320
+ expiresAt: now + ttlSeconds * 1000,
321
+ staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
322
+ deps: deps ?? new Set(),
323
+ storedAt: now,
324
+ sharedEncodings: 0,
325
+ };
326
+
327
+ install(key, entry);
328
+ share(key, entry);
329
+ }
330
+
331
+ /**
332
+ * Girdiyi L1'e yerleştirir, ters indekse bağlar ve sınırı uygular. Store'a
333
+ * yazmanın tek yolu bu.
334
+ *
335
+ * @param {string} key
336
+ * @param {HtmlEntry} entry
337
+ */
338
+ function install(key, entry) {
339
+ // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
340
+ // tazelemeden tazelemeye değişebilir.
341
+ drop(key);
342
+
343
+ store.set(key, entry);
344
+
345
+ for (const dep of entry.deps) {
346
+ let set = dependents.get(dep);
347
+ if (!set) dependents.set(dep, (set = new Set()));
348
+ set.add(key);
349
+ }
350
+
351
+ const limit = maxEntries();
352
+ while (store.size > limit) {
353
+ const oldest = store.keys().next().value;
354
+ if (oldest === undefined) break;
355
+ drop(oldest);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
361
+ *
362
+ * @param {Set<string> | null} deps
363
+ * @param {number} startedAt
364
+ * @returns {boolean}
365
+ */
366
+ function readsPurgedData(deps, startedAt) {
367
+ if (!deps) return false;
368
+
369
+ for (const dep of deps) {
370
+ const purgedAt = purgedDeps.get(dep);
371
+ if (purgedAt !== undefined && purgedAt >= startedAt) return true;
372
+ }
373
+ return false;
374
+ }
375
+
376
+ /**
377
+ * @param {string} key
378
+ * @param {number} ttlSeconds
379
+ * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
380
+ * storable?: boolean }>} producer
381
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
382
+ * storable?: boolean }>}
383
+ */
384
+ function refresh(key, ttlSeconds, producer) {
385
+ const pending = inflight.get(key);
386
+ if (pending) return pending;
387
+
388
+ const token = {};
389
+ tokens.set(key, token);
390
+
391
+ const task = produce(key, ttlSeconds, producer, token).finally(() => {
392
+ inflight.delete(key);
393
+ if (tokens.get(key) === token) tokens.delete(key);
394
+ });
395
+
396
+ inflight.set(key, task);
397
+ return task;
398
+ }
399
+
400
+ /**
401
+ * @param {string} key
402
+ * @param {number} ttlSeconds
403
+ * @param {() => Promise<{ html: string, status: number, degraded?: boolean,
404
+ * storable?: boolean }>} producer
405
+ * @param {object} token
406
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
407
+ * storable?: boolean }>}
408
+ */
409
+ async function produce(key, ttlSeconds, producer, token) {
410
+ const startedAt = Date.now();
411
+
412
+ // Başka bir node bu sayfayı zaten render ettiyse render hiç çalışmaz. Soğuk
413
+ // ayağa kalkan bir instance'ın sıcak önbellek bulmasının tek yolu bu.
414
+ const shared = await readShared(key);
415
+ if (shared && tokens.get(key) === token) {
416
+ install(key, shared);
417
+ return { html: shared.html, status: shared.status };
418
+ }
419
+
420
+ // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
421
+ // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
422
+ const deps = trackDependencies() ? new Set() : null;
423
+
424
+ const value = await (deps ? collectDependencies(deps, producer) : producer());
425
+
426
+ // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
427
+ // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
428
+ //
429
+ // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
430
+ // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
431
+ // kullanıcının HTML'ini bir başkasına servis etmek olur. Paylaşımlı
432
+ // kademede bunun bedeli daha da ağır — bir kullanıcının HTML'i tüm kümeye
433
+ // dağılırdı — bu yüzden kontrol Redis yazımından önce, `write()` içinde.
434
+ //
435
+ // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
436
+ // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
437
+ const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
438
+ if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
439
+ write(key, value, ttlSeconds, deps);
440
+ }
441
+
442
+ return value;
443
+ }
444
+
445
+ /**
446
+ * @param {string} key
447
+ * @param {number} ttlSeconds 0 → cache yok
448
+ * @param {() => Promise<{ html: string, status: number }>} producer
449
+ * @returns {Promise<{ html: string, status: number, cached: boolean,
450
+ * stale?: boolean, encoded?: Map<string, Buffer> }>}
451
+ */
452
+ export async function withHtmlCache(key, ttlSeconds, producer) {
453
+ if (!ttlSeconds) {
454
+ const fresh = await producer();
455
+ return { ...fresh, cached: false };
456
+ }
457
+
458
+ const hit = read(key);
459
+
460
+ if (hit) {
461
+ // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
462
+ // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
463
+ if (hit.stale) {
464
+ invalidated.delete(key);
465
+ void refresh(key, ttlSeconds, producer).catch((error) => {
466
+ console.error(`[html-cache] background refresh failed: ${key}`, error);
467
+ });
468
+ }
469
+ return { ...hit, cached: true };
470
+ }
471
+
472
+ invalidated.delete(key);
473
+ const value = await refresh(key, ttlSeconds, producer);
474
+ return { ...value, encoded: store.get(key)?.encoded, cached: false };
475
+ }
476
+
477
+ /**
478
+ * Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
479
+ * çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
480
+ * yani gerçekten **geçersiz** — bayatlatmak yetmez.
481
+ */
482
+ export function clearHtmlCache() {
483
+ clearLocal();
484
+
485
+ if (redisShares("html")) void redisDropMatching("html");
486
+ publishCacheEvent({ type: "html:clear" });
487
+ }
488
+
489
+ /**
490
+ * Boşaltmanın yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın
491
+ * yapan bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
492
+ */
493
+ function clearLocal() {
494
+ store.clear();
495
+ dependents.clear();
496
+ tokens.clear();
497
+ invalidated.clear();
498
+ purgedDeps.clear();
499
+ }
500
+
501
+ export function getHtmlCacheSize() {
502
+ return store.size;
503
+ }
504
+
505
+ /**
506
+ * Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
507
+ *
508
+ * Üç biçim kabul edilir:
509
+ * `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
510
+ * `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
511
+ * `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
512
+ *
513
+ * Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
514
+ * kuralı `/haberler`i düşürmemeli.
515
+ *
516
+ * @param {string | RegExp} target
517
+ * @returns {((pathname: string) => boolean) | null}
518
+ */
519
+ function toMatcher(target) {
520
+ if (target instanceof RegExp) return (pathname) => target.test(pathname);
521
+
522
+ if (typeof target !== "string" || !target.startsWith("/")) {
523
+ console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
524
+ return null;
525
+ }
526
+
527
+ if (target.includes(":")) {
528
+ const compiled = compilePattern(target);
529
+ if (!compiled) return null;
530
+ return (pathname) => matchPattern(compiled, pathname) !== null;
531
+ }
532
+
533
+ const prefix = target.endsWith("/") ? target : `${target}/`;
534
+ return (pathname) => pathname === target || pathname.startsWith(prefix);
535
+ }
536
+
537
+ /**
538
+ * Etkilenen girdiyi bayatlatır ya da düşürür.
539
+ *
540
+ * @param {string} key
541
+ * @param {boolean} hard
542
+ */
543
+ function invalidateKey(key, hard) {
544
+ // Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
545
+ // üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
546
+ // henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
547
+ tokens.delete(key);
548
+
549
+ const entry = store.get(key);
550
+ if (entry) {
551
+ // Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
552
+ if (hard || Date.now() >= entry.staleUntil) drop(key);
553
+ else entry.expiresAt = 0;
554
+ }
555
+
556
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
557
+ }
558
+
559
+ /**
560
+ * Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
561
+ *
562
+ * Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
563
+ * sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
564
+ * içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
565
+ * döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
566
+ * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
567
+ * HTML'in gerçekten geçersiz olduğu durumlar için.
568
+ *
569
+ * Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
570
+ * yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
571
+ *
572
+ * @param {string | RegExp | (string | RegExp)[]} target
573
+ * @param {{ hard?: boolean }} [options]
574
+ * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
575
+ */
576
+ export function invalidateHtmlCache(target, options = {}) {
577
+ const targets = Array.isArray(target) ? target : [target];
578
+ const hard = options.hard === true;
579
+ const count = invalidateLocal(targets, hard);
580
+
581
+ // Paylaşımlı kopya yumuşak invalidation'da da **silinir**. Bayatlatmanın
582
+ // Redis karşılığı her anahtar için oku-değiştir-yaz turu demek ve bir
583
+ // webhook binlerce anahtarı birden düşürüyor. Silmenin bedeli, o yolu hiç
584
+ // görmemiş bir node'un bir kez render etmesi; L1'i sıcak olan node'lar eski
585
+ // HTML'i bayat pencerede servis etmeye devam ediyor.
586
+ if (redisShares("html")) {
587
+ const matchers = compileMatchers(targets);
588
+ if (matchers.length) {
589
+ void redisDropMatching("html", (key) =>
590
+ matchers.some((matcher) => matcher(pathOf(key))),
591
+ );
592
+ }
593
+ }
594
+
595
+ // Hedefler yayınlanır, eşleşen anahtarlar değil: hangi yolun nerede sıcak
596
+ // olduğu node'a bağlı, her node deseni kendi store'una uygular.
597
+ publishCacheEvent({
598
+ type: "html:invalidate",
599
+ hard,
600
+ targets: targets.map(serializeTarget).filter((entry) => entry !== null),
601
+ });
602
+
603
+ return count;
604
+ }
605
+
606
+ /**
607
+ * @param {(string | RegExp)[]} targets
608
+ * @param {boolean} hard
609
+ * @returns {number}
610
+ */
611
+ function invalidateLocal(targets, hard) {
612
+ const matchers = compileMatchers(targets);
613
+ if (!matchers.length) return 0;
614
+
615
+ let count = 0;
616
+
617
+ // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
618
+ // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
619
+ // `invalidateKey` sert modda store'dan siliyor.
620
+ for (const key of new Set([...store.keys(), ...tokens.keys()])) {
621
+ if (!matchers.some((matcher) => matcher(pathOf(key)))) continue;
622
+ invalidateKey(key, hard);
623
+ count += 1;
624
+ }
625
+
626
+ return count;
627
+ }
628
+
629
+ /**
630
+ * @param {(string | RegExp)[]} targets
631
+ * @returns {((pathname: string) => boolean)[]}
632
+ */
633
+ function compileMatchers(targets) {
634
+ return /** @type {((pathname: string) => boolean)[]} */ (
635
+ targets.map(toMatcher).filter((matcher) => matcher !== null)
636
+ );
637
+ }
638
+
639
+ /**
640
+ * Anahtar `yol?query`; eşleştirme **yol kısmına** yapılır.
641
+ *
642
+ * @param {string} key
643
+ * @returns {string}
644
+ */
645
+ function pathOf(key) {
646
+ const mark = key.indexOf("?");
647
+ return mark === -1 ? key : key.slice(0, mark);
648
+ }
649
+
650
+ /**
651
+ * `RegExp` JSON'a girmez (`JSON.stringify(/x/)` → `{}`), bu yüzden kaynak ve
652
+ * bayrakları taşınır.
653
+ *
654
+ * @param {string | RegExp} target
655
+ * @returns {string | { re: string, flags: string } | null}
656
+ */
657
+ function serializeTarget(target) {
658
+ if (typeof target === "string") return target;
659
+ if (target instanceof RegExp) return { re: target.source, flags: target.flags };
660
+ return null;
661
+ }
662
+
663
+ /**
664
+ * @param {unknown} value
665
+ * @returns {string | RegExp | null}
666
+ */
667
+ function deserializeTarget(value) {
668
+ if (typeof value === "string") return value;
669
+
670
+ const entry = /** @type {{ re?: unknown, flags?: unknown }} */ (value);
671
+ if (!entry || typeof entry.re !== "string") return null;
672
+
673
+ try {
674
+ return new RegExp(entry.re, typeof entry.flags === "string" ? entry.flags : "");
675
+ } catch {
676
+ // Bozuk bir desen bu node'u düşürmemeli; olay yok sayılır.
677
+ return null;
678
+ }
679
+ }
680
+
681
+ // Uzak bir node invalidation yaptığında bu proses de kendi L1'ini işaretler.
682
+ // Dinleyiciler yalnızca yerel yolları çağırır, yoksa mesaj döngüsü oluşur.
683
+ onCacheEvent((event) => {
684
+ if (event.type === "html:clear") {
685
+ clearLocal();
686
+ return;
687
+ }
688
+
689
+ if (event.type === "html:drop") {
690
+ if (typeof event.key === "string") dropLocalKey(event.key);
691
+ return;
692
+ }
693
+
694
+ if (event.type !== "html:invalidate") return;
695
+
696
+ const targets = /** @type {(string | RegExp)[]} */ (
697
+ (Array.isArray(event.targets) ? event.targets : [])
698
+ .map(deserializeTarget)
699
+ .filter((entry) => entry !== null)
700
+ );
701
+
702
+ if (targets.length) invalidateLocal(targets, event.hard === true);
703
+ });
704
+
705
+ /**
706
+ * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
707
+ * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
708
+ *
709
+ * Burada **yayın yapılmaz**: çağıran `clearDataCache()` zaten bir
710
+ * `data:clear` olayı yayınlıyor ve uzak node'lar aynı zinciri kendi ters
711
+ * indeksleri üzerinden çalıştırıyor. Ters indeks node'a özel olduğu için
712
+ * doğru olan da bu — bir sayfa yalnızca onu render etmiş node'da kayıtlı.
713
+ *
714
+ * @param {Iterable<string>} dataKeys
715
+ * @returns {number} Etkilenen HTML girdisi sayısı.
716
+ */
717
+ export function invalidateHtmlByDependency(dataKeys) {
718
+ /** @type {Set<string>} */
719
+ const keys = new Set();
720
+ const now = Date.now();
721
+
722
+ for (const dep of dataKeys) {
723
+ // Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
724
+ // görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
725
+ purgedDeps.set(dep, now);
726
+
727
+ const set = dependents.get(dep);
728
+ if (set) for (const key of set) keys.add(key);
729
+ }
730
+
731
+ while (purgedDeps.size > MAX_PURGED_DEPS) {
732
+ const oldest = purgedDeps.keys().next().value;
733
+ if (oldest === undefined) break;
734
+ purgedDeps.delete(oldest);
735
+ }
736
+
737
+ for (const key of keys) invalidateKey(key, false);
738
+
739
+ // Paylaşımlı kopyalar da düşer, yoksa soğuk bir node az önce geçersiz
740
+ // kılınan HTML'i Redis'ten geri alırdı. Yalnızca bu node'un tanıdığı
741
+ // anahtarlar silinebiliyor; hiçbir L1'de sıcak olmayan bir sayfanın Redis
742
+ // kopyası TTL'ini bekler.
743
+ if (keys.size && redisShares("html")) {
744
+ redisDrop([...keys].map((key) => cacheKey("html", key)));
745
+ }
746
+
747
+ return keys.size;
748
+ }
749
+
750
+ /**
751
+ * Tek bir önbellek **anahtarını** düşürür.
752
+ *
753
+ * `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
754
+ * varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
755
+ * silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
756
+ * kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
757
+ *
758
+ * @param {string} key `yol?query` biçiminde tam anahtar.
759
+ * @returns {boolean} Girdi var mıydı.
760
+ */
761
+ export function dropHtmlCacheKey(key) {
762
+ const existed = dropLocalKey(key);
763
+
764
+ if (redisShares("html")) redisDrop([cacheKey("html", key)]);
765
+ publishCacheEvent({ type: "html:drop", key });
766
+
767
+ return existed;
768
+ }
769
+
770
+ /**
771
+ * @param {string} key
772
+ * @returns {boolean}
773
+ */
774
+ function dropLocalKey(key) {
775
+ // Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
776
+ tokens.delete(key);
777
+ invalidated.delete(key);
778
+ return drop(key);
779
+ }
780
+
781
+ /**
782
+ * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
783
+ * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
784
+ * ısıtmasın diye okuma yıkıcıdır.
785
+ *
786
+ * @returns {string[]}
787
+ */
788
+ export function takeInvalidatedPaths() {
789
+ if (!invalidated.size) return [];
790
+
791
+ const paths = [...invalidated];
792
+ invalidated.clear();
793
+ // Anahtar `yol?query`; query boşsa sondaki `?` atılır.
794
+ return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
795
+ }
796
+
797
+ /**
798
+ * Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
799
+ * zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
800
+ * boyutu.
801
+ *
802
+ * @returns {{ key: string, bytes: number, status: number, stale: boolean,
803
+ * expiresIn: number, encodings: string[], deps: number }[]}
804
+ */
805
+ export function getHtmlCacheEntries() {
806
+ const now = Date.now();
807
+
808
+ return [...store.entries()].map(([key, entry]) => ({
809
+ key,
810
+ bytes: Buffer.byteLength(entry.html),
811
+ status: entry.status,
812
+ stale: now >= entry.expiresAt,
813
+ expiresIn: Math.round((entry.expiresAt - now) / 1000),
814
+ encodings: [...entry.encoded.keys()],
815
+ deps: entry.deps.size,
816
+ }));
817
+ }