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