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.
- package/AGENTS.md +132 -132
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +287 -287
- package/docs/03-routing.md +480 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1209 -1202
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +305 -305
- package/docs/en/03-routing.md +497 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1239 -1232
- package/docs/en/07-configuration.md +986 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +359 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/cache-panel/i18n.js +670 -0
- package/src/client/cache-panel/login.html +74 -71
- package/src/client/cache-panel/panel.css +756 -740
- package/src/client/cache-panel/panel.html +308 -307
- package/src/client/cache-panel/panel.js +915 -808
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-panel.js +759 -738
- package/src/server/cloudflare.js +607 -595
- package/src/server/create-app.js +291 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
package/src/server/html-cache.js
CHANGED
|
@@ -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
|
+
}
|