jskelet 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +633 -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 +700 -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 +351 -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 +708 -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 +355 -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 +965 -356
- package/src/server/og-raster.mjs +21 -0
- 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 +51 -3
package/src/server/data-cache.js
CHANGED
|
@@ -1,553 +1,553 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
|
|
3
|
-
*
|
|
4
|
-
* HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
|
|
5
|
-
* girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
|
|
6
|
-
* yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
|
|
7
|
-
* için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
|
|
8
|
-
* küçük olduğu için on binlerce girdi bellekte durur.
|
|
9
|
-
*
|
|
10
|
-
* Kazanç iki taraflı:
|
|
11
|
-
* - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
|
|
12
|
-
* upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
|
|
13
|
-
* - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
|
|
14
|
-
*
|
|
15
|
-
* `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
|
|
16
|
-
* genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
|
|
17
|
-
* yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
|
|
18
|
-
* `storeEmpty: true` verir.
|
|
19
|
-
*
|
|
20
|
-
* `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
|
|
21
|
-
* en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
|
|
22
|
-
* kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
|
|
23
|
-
* yeter. Redis yoksa aynı kayıt diske yazılır; süreç yeniden açılınca upstream
|
|
24
|
-
* yeniden çağrılmaz.
|
|
25
|
-
*/
|
|
26
|
-
import { getConfig } from "../config/index.js";
|
|
27
|
-
import { DATA_CACHE_BYTE_BUDGET, DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
28
|
-
import { recordDependency } from "./cache-deps.js";
|
|
29
|
-
import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
|
|
30
|
-
import { invalidateHtmlByDependency } from "./html-cache.js";
|
|
31
|
-
import {
|
|
32
|
-
cacheKey,
|
|
33
|
-
onCacheEvent,
|
|
34
|
-
publishCacheEvent,
|
|
35
|
-
redisDropMatching,
|
|
36
|
-
redisGetJson,
|
|
37
|
-
redisSetJson,
|
|
38
|
-
redisShares,
|
|
39
|
-
} from "./redis.js";
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number }} DataEntry
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
/** @type {Map<string, DataEntry>} */
|
|
46
|
-
const store = new Map();
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* `store` içindeki JSON gövdelerin toplamı. Tam tarama yapmamak için tutulur.
|
|
50
|
-
* Her silme `forget` üzerinden geçer; LRU sırası değişimi sayacı oynatmaz.
|
|
51
|
-
*/
|
|
52
|
-
let storedBytes = 0;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
|
|
56
|
-
* @type {number | null}
|
|
57
|
-
*/
|
|
58
|
-
let byteBudgetOverride = null;
|
|
59
|
-
|
|
60
|
-
/** @type {Map<string, Promise<unknown>>} */
|
|
61
|
-
const inflight = new Map();
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Süreç ömrü boyunca biriken sayaçlar.
|
|
65
|
-
*
|
|
66
|
-
* Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
|
|
67
|
-
* bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
|
|
68
|
-
* hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
|
|
69
|
-
* fren çağrıları yavaşlatır, sayısını azaltmaz.
|
|
70
|
-
*/
|
|
71
|
-
const stats = {
|
|
72
|
-
/** Taze girdiden servis edildi. */
|
|
73
|
-
hits: 0,
|
|
74
|
-
/** Bayat girdiden servis edildi; tazeleme arkada koştu. */
|
|
75
|
-
stale: 0,
|
|
76
|
-
/** Girdi yoktu, çağıran bekledi. */
|
|
77
|
-
misses: 0,
|
|
78
|
-
/** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
|
|
79
|
-
coalesced: 0,
|
|
80
|
-
/** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
|
|
81
|
-
shared: 0,
|
|
82
|
-
/** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
|
|
83
|
-
produced: 0,
|
|
84
|
-
/** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
|
|
85
|
-
bypassed: 0,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
90
|
-
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
91
|
-
* kod varsayılanına düşülür.
|
|
92
|
-
*
|
|
93
|
-
* @returns {{ maxEntries: number, staleFactor: number }}
|
|
94
|
-
*/
|
|
95
|
-
function settings() {
|
|
96
|
-
try {
|
|
97
|
-
const { data } = getConfig();
|
|
98
|
-
return {
|
|
99
|
-
maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
|
|
100
|
-
staleFactor: Number.isFinite(Number(data?.staleFactor))
|
|
101
|
-
? Number(data.staleFactor)
|
|
102
|
-
: DEFAULT_DATA_CACHE.staleFactor,
|
|
103
|
-
};
|
|
104
|
-
} catch {
|
|
105
|
-
return { ...DEFAULT_DATA_CACHE };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* @param {string} key
|
|
111
|
-
* @returns {{ value: unknown, stale: boolean } | null}
|
|
112
|
-
*/
|
|
113
|
-
function read(key) {
|
|
114
|
-
const entry = store.get(key);
|
|
115
|
-
if (!entry) return null;
|
|
116
|
-
|
|
117
|
-
const now = Date.now();
|
|
118
|
-
if (now >= entry.staleUntil) {
|
|
119
|
-
forget(key);
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// LRU: erişilen girdiyi sona taşı. Bayt sayacı değişmez.
|
|
124
|
-
store.delete(key);
|
|
125
|
-
store.set(key, entry);
|
|
126
|
-
|
|
127
|
-
return { value: entry.value, stale: now >= entry.expiresAt };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* @param {unknown} value
|
|
132
|
-
* @returns {number}
|
|
133
|
-
*/
|
|
134
|
-
function valueBytes(value) {
|
|
135
|
-
try {
|
|
136
|
-
const json = JSON.stringify(value);
|
|
137
|
-
if (typeof json !== "string") return 0;
|
|
138
|
-
return Buffer.byteLength(json);
|
|
139
|
-
} catch {
|
|
140
|
-
// Serileşmeyen değer yine saklanır; ölçülemediği için küçük bir pay.
|
|
141
|
-
return 1024;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** @returns {number} */
|
|
146
|
-
function activeByteBudget() {
|
|
147
|
-
return byteBudgetOverride ?? DATA_CACHE_BYTE_BUDGET;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Store'dan silmenin tek yolu. Bayt sayacı buraya bağlı.
|
|
152
|
-
*
|
|
153
|
-
* @param {string} key
|
|
154
|
-
* @returns {boolean}
|
|
155
|
-
*/
|
|
156
|
-
function forget(key) {
|
|
157
|
-
const entry = store.get(key);
|
|
158
|
-
if (!entry) return false;
|
|
159
|
-
storedBytes = Math.max(0, storedBytes - (entry.bytes || 0));
|
|
160
|
-
store.delete(key);
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer.
|
|
166
|
-
* @returns {void}
|
|
167
|
-
*/
|
|
168
|
-
function evict() {
|
|
169
|
-
const { maxEntries } = settings();
|
|
170
|
-
const budget = activeByteBudget();
|
|
171
|
-
|
|
172
|
-
while (store.size > 0 && (store.size > maxEntries || storedBytes > budget)) {
|
|
173
|
-
const oldest = store.keys().next().value;
|
|
174
|
-
if (oldest === undefined) break;
|
|
175
|
-
if (!forget(oldest)) break;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* L1'e yazar. Bütçeden büyük tek değer saklanmaz.
|
|
181
|
-
*
|
|
182
|
-
* @param {string} key
|
|
183
|
-
* @param {DataEntry} entry
|
|
184
|
-
* @returns {boolean}
|
|
185
|
-
*/
|
|
186
|
-
function install(key, entry) {
|
|
187
|
-
const bytes = entry.bytes || valueBytes(entry.value);
|
|
188
|
-
entry.bytes = bytes;
|
|
189
|
-
forget(key);
|
|
190
|
-
if (bytes > activeByteBudget()) return false;
|
|
191
|
-
|
|
192
|
-
store.set(key, entry);
|
|
193
|
-
storedBytes += bytes;
|
|
194
|
-
evict();
|
|
195
|
-
return store.has(key);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* @param {string} key
|
|
200
|
-
* @param {unknown} value
|
|
201
|
-
* @param {number} ttlSeconds
|
|
202
|
-
* @param {number} staleFactor
|
|
203
|
-
*/
|
|
204
|
-
function write(key, value, ttlSeconds, staleFactor) {
|
|
205
|
-
const now = Date.now();
|
|
206
|
-
const ttl = ttlSeconds * 1000;
|
|
207
|
-
|
|
208
|
-
/** @type {DataEntry} */
|
|
209
|
-
const entry = {
|
|
210
|
-
value,
|
|
211
|
-
expiresAt: now + ttl,
|
|
212
|
-
staleUntil: now + ttl + ttl * staleFactor,
|
|
213
|
-
bytes: valueBytes(value),
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
// Bütçeye sığmayan değer Redis'e de yazılmaz: bir sonraki istek onu geri
|
|
217
|
-
// alıp yine reddeder. Çağıran sonuç yine `producer`'ın döndürdüğüdür.
|
|
218
|
-
if (!install(key, entry)) return;
|
|
219
|
-
|
|
220
|
-
// Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
|
|
221
|
-
// bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
|
|
222
|
-
if (redisShares("data")) {
|
|
223
|
-
redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
|
|
224
|
-
} else if (diskShares("data")) {
|
|
225
|
-
diskSetJson("data", key, entry);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
|
|
231
|
-
* mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
|
|
232
|
-
* süresiz tazelik kazanır.
|
|
233
|
-
*
|
|
234
|
-
* @param {string} key
|
|
235
|
-
* @param {DataEntry} entry
|
|
236
|
-
*/
|
|
237
|
-
function promote(key, entry) {
|
|
238
|
-
if (!entry.bytes) entry.bytes = valueBytes(entry.value);
|
|
239
|
-
install(key, entry);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Paylaşımlı kademeden okur.
|
|
244
|
-
*
|
|
245
|
-
* Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
|
|
246
|
-
* tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
|
|
247
|
-
* yine Redis'i okur ve `producer` hiç çalışmaz.
|
|
248
|
-
*
|
|
249
|
-
* @param {string} key
|
|
250
|
-
* @returns {Promise<DataEntry | null>}
|
|
251
|
-
*/
|
|
252
|
-
async function readShared(key) {
|
|
253
|
-
const onRedis = redisShares("data");
|
|
254
|
-
const onDisk = diskShares("data");
|
|
255
|
-
if (!onRedis && !onDisk) return null;
|
|
256
|
-
|
|
257
|
-
const entry = onRedis ? await redisGetJson(cacheKey("data", key)) : await diskGetJson("data", key);
|
|
258
|
-
if (!entry || typeof entry.expiresAt !== "number") return null;
|
|
259
|
-
if (Date.now() >= entry.expiresAt) {
|
|
260
|
-
if (onDisk) diskDrop("data", [key]);
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return /** @type {DataEntry} */ (entry);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* @param {string} key
|
|
269
|
-
* @param {number} ttlSeconds
|
|
270
|
-
* @param {() => Promise<unknown>} producer
|
|
271
|
-
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
272
|
-
* @returns {Promise<unknown>}
|
|
273
|
-
*/
|
|
274
|
-
function refresh(key, ttlSeconds, producer, options) {
|
|
275
|
-
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
276
|
-
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
277
|
-
const pending = inflight.get(key);
|
|
278
|
-
if (pending) {
|
|
279
|
-
stats.coalesced += 1;
|
|
280
|
-
return pending;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
284
|
-
|
|
285
|
-
const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
|
|
286
|
-
inflight.delete(key);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
inflight.set(key, task);
|
|
290
|
-
return task;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* @param {string} key
|
|
295
|
-
* @param {number} ttlSeconds
|
|
296
|
-
* @param {() => Promise<unknown>} producer
|
|
297
|
-
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
298
|
-
* @param {number} staleFactor
|
|
299
|
-
* @returns {Promise<unknown>}
|
|
300
|
-
*/
|
|
301
|
-
async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
302
|
-
// Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
|
|
303
|
-
// Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
|
|
304
|
-
const shared = await readShared(key);
|
|
305
|
-
if (shared) {
|
|
306
|
-
stats.shared += 1;
|
|
307
|
-
promote(key, shared);
|
|
308
|
-
return shared.value;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
stats.produced += 1;
|
|
312
|
-
const value = await producer();
|
|
313
|
-
|
|
314
|
-
const empty = value === undefined || value === null;
|
|
315
|
-
if (!empty || options.storeEmpty === true) {
|
|
316
|
-
write(key, value, ttlSeconds, staleFactor);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return value;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
|
|
324
|
-
*
|
|
325
|
-
* @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
|
|
326
|
-
* anahtara yazılır (`quote:v2:${symbol}`).
|
|
327
|
-
* @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
|
|
328
|
-
* @param {() => Promise<T>} producer
|
|
329
|
-
* @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
|
|
330
|
-
* `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
|
|
331
|
-
* penceresini ayarlar (0 → bayat servis yok).
|
|
332
|
-
* @returns {Promise<T>}
|
|
333
|
-
* @template T
|
|
334
|
-
*/
|
|
335
|
-
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
336
|
-
if (!ttlSeconds) {
|
|
337
|
-
stats.bypassed += 1;
|
|
338
|
-
return producer();
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
|
|
342
|
-
// sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
|
|
343
|
-
recordDependency(key);
|
|
344
|
-
|
|
345
|
-
const hit = read(key);
|
|
346
|
-
|
|
347
|
-
if (hit) {
|
|
348
|
-
if (hit.stale) stats.stale += 1;
|
|
349
|
-
else stats.hits += 1;
|
|
350
|
-
|
|
351
|
-
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
352
|
-
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
353
|
-
// olması sayfaya yansımaz.
|
|
354
|
-
if (hit.stale) {
|
|
355
|
-
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
356
|
-
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
return /** @type {T} */ (hit.value);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
stats.misses += 1;
|
|
363
|
-
|
|
364
|
-
try {
|
|
365
|
-
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
366
|
-
} catch (error) {
|
|
367
|
-
// Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
|
|
368
|
-
// durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
|
|
369
|
-
// ziyaretçiye hata sayfası göstermekten iyidir.
|
|
370
|
-
const stale = read(key);
|
|
371
|
-
if (!stale) throw error;
|
|
372
|
-
|
|
373
|
-
console.warn(
|
|
374
|
-
`[data-cache] producer failed, serving stale value: ${key}`,
|
|
375
|
-
error instanceof Error ? error.message : error,
|
|
376
|
-
);
|
|
377
|
-
return /** @type {T} */ (stale.value);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
|
|
383
|
-
* `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
|
|
384
|
-
* arasında ve TTL'li.
|
|
385
|
-
*
|
|
386
|
-
* @param {F} fn
|
|
387
|
-
* @param {{ key: string, revalidate: number, storeEmpty?: boolean,
|
|
388
|
-
* staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
|
|
389
|
-
* @returns {F}
|
|
390
|
-
* @template {(...args: any[]) => Promise<any>} F
|
|
391
|
-
*/
|
|
392
|
-
export function dataCache(fn, options) {
|
|
393
|
-
const wrapped = (...args) =>
|
|
394
|
-
withDataCache(
|
|
395
|
-
args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
|
|
396
|
-
options.revalidate,
|
|
397
|
-
() => fn(...args),
|
|
398
|
-
options,
|
|
399
|
-
);
|
|
400
|
-
|
|
401
|
-
return /** @type {F} */ (wrapped);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
|
|
406
|
-
* "bu haber güncellendi" bilgisi geldiğinde kullanılır.
|
|
407
|
-
*
|
|
408
|
-
* Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
|
|
409
|
-
* uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
|
|
410
|
-
* gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
|
|
411
|
-
*
|
|
412
|
-
* `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
|
|
413
|
-
* node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
|
|
414
|
-
* önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
|
|
415
|
-
*
|
|
416
|
-
* @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
|
|
417
|
-
* @returns {number} Silinen girdi sayısı.
|
|
418
|
-
*/
|
|
419
|
-
export function clearDataCache(prefix) {
|
|
420
|
-
const removed = clearLocal(prefix);
|
|
421
|
-
|
|
422
|
-
const match = prefix === undefined ? undefined : (key) => key.startsWith(prefix);
|
|
423
|
-
if (redisShares("data")) void redisDropMatching("data", match);
|
|
424
|
-
else if (diskShares("data")) void diskDropMatching("data", match);
|
|
425
|
-
|
|
426
|
-
// Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
|
|
427
|
-
// kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
|
|
428
|
-
publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
|
|
429
|
-
|
|
430
|
-
return removed;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
|
|
435
|
-
* bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
|
|
436
|
-
*
|
|
437
|
-
* @param {string} [prefix]
|
|
438
|
-
* @returns {number}
|
|
439
|
-
*/
|
|
440
|
-
function clearLocal(prefix) {
|
|
441
|
-
/** @type {string[]} */
|
|
442
|
-
const removed = [];
|
|
443
|
-
|
|
444
|
-
if (prefix === undefined) {
|
|
445
|
-
removed.push(...store.keys());
|
|
446
|
-
store.clear();
|
|
447
|
-
storedBytes = 0;
|
|
448
|
-
} else {
|
|
449
|
-
for (const key of [...store.keys()]) {
|
|
450
|
-
if (key.startsWith(prefix)) {
|
|
451
|
-
forget(key);
|
|
452
|
-
removed.push(key);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (removed.length) invalidateHtmlByDependency(removed);
|
|
458
|
-
return removed.length;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
|
|
462
|
-
// `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
|
|
463
|
-
onCacheEvent((event) => {
|
|
464
|
-
if (event.type === "data:drop") {
|
|
465
|
-
if (typeof event.key !== "string") return;
|
|
466
|
-
forget(event.key);
|
|
467
|
-
invalidateHtmlByDependency([event.key]);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (event.type !== "data:clear") return;
|
|
472
|
-
clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Tek bir veri anahtarını düşürür.
|
|
477
|
-
*
|
|
478
|
-
* `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
|
|
479
|
-
* `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
|
|
480
|
-
* olarak o anahtar olmalı, komşusu değil.
|
|
481
|
-
*
|
|
482
|
-
* @param {string} key
|
|
483
|
-
* @returns {boolean} Girdi var mıydı.
|
|
484
|
-
*/
|
|
485
|
-
export function dropDataCacheKey(key) {
|
|
486
|
-
const existed = forget(key);
|
|
487
|
-
|
|
488
|
-
// Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
|
|
489
|
-
// yalnızca Redis'te sıcak olabilir.
|
|
490
|
-
invalidateHtmlByDependency([key]);
|
|
491
|
-
|
|
492
|
-
if (redisShares("data")) {
|
|
493
|
-
void redisDropMatching("data", (candidate) => candidate === key);
|
|
494
|
-
} else if (diskShares("data")) {
|
|
495
|
-
diskDrop("data", [key]);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
publishCacheEvent({ type: "data:drop", key });
|
|
499
|
-
|
|
500
|
-
return existed;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/** @returns {number} */
|
|
504
|
-
export function getDataCacheSize() {
|
|
505
|
-
return store.size;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
|
|
510
|
-
* tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
|
|
511
|
-
*
|
|
512
|
-
* @param {number | null} bytes
|
|
513
|
-
* @returns {void}
|
|
514
|
-
*/
|
|
515
|
-
export function setDataCacheByteBudget(bytes) {
|
|
516
|
-
byteBudgetOverride = bytes == null ? null : bytes;
|
|
517
|
-
evict();
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
|
|
522
|
-
* geri kalan her şey upstream'e hiç gitmemiş bir okuma.
|
|
523
|
-
*
|
|
524
|
-
* @returns {typeof stats & { reads: number, hitRatio: number }}
|
|
525
|
-
* `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
|
|
526
|
-
* upstream'e gitmediği (0–1).
|
|
527
|
-
*/
|
|
528
|
-
export function getDataCacheStats() {
|
|
529
|
-
const reads = stats.hits + stats.stale + stats.misses;
|
|
530
|
-
const avoided = stats.hits + stats.stale;
|
|
531
|
-
|
|
532
|
-
return {
|
|
533
|
-
...stats,
|
|
534
|
-
reads,
|
|
535
|
-
hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
541
|
-
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
|
542
|
-
*
|
|
543
|
-
* @returns {{ key: string, stale: boolean, expiresIn: number }[]}
|
|
544
|
-
*/
|
|
545
|
-
export function getDataCacheEntries() {
|
|
546
|
-
const now = Date.now();
|
|
547
|
-
|
|
548
|
-
return [...store.entries()].map(([key, entry]) => ({
|
|
549
|
-
key,
|
|
550
|
-
stale: now >= entry.expiresAt,
|
|
551
|
-
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
552
|
-
}));
|
|
553
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Upstream veri için TTL + stale-while-revalidate LRU önbelleği.
|
|
3
|
+
*
|
|
4
|
+
* HTML önbelleği (`html-cache.js`) yalnızca **trafiği olan** sayfaları tutar:
|
|
5
|
+
* girdi başına yüz kilobayt düştüğü için sınırı 500 civarındadır ve on binlerce
|
|
6
|
+
* yolluk bir site onu ısıtmaya çalıştığında kendi ısıttığını siler. Uzun kuyruk
|
|
7
|
+
* için doğru katman bu modül: aynı sayfanın JSON'u HTML'inden onlarca kat
|
|
8
|
+
* küçük olduğu için on binlerce girdi bellekte durur.
|
|
9
|
+
*
|
|
10
|
+
* Kazanç iki taraflı:
|
|
11
|
+
* - Hiç ısıtılmamış bir uzun kuyruk sayfası ilk ziyaretçide render edilir ama
|
|
12
|
+
* upstream'e gitmez; gecikme yüzlerce ms değil, şablon render'ı kadardır.
|
|
13
|
+
* - Periyodik ısıtma turları API kotası harcamaz, veri katmanından okur.
|
|
14
|
+
*
|
|
15
|
+
* `null`/`undefined` **saklanmaz**: uygulamaların HTTP istemcisi hatada
|
|
16
|
+
* genellikle `null` döner ve bunu saklamak, geçici bir 429'u TTL boyunca "veri
|
|
17
|
+
* yok" hâline dondurmak olurdu. Boş cevabı bilinçli olarak saklamak isteyen
|
|
18
|
+
* `storeEmpty: true` verir.
|
|
19
|
+
*
|
|
20
|
+
* `cache.redis` açıkken bu önbellek ikinci bir kademeye (L2) yaslanır. Redis'e
|
|
21
|
+
* en uygun katman burası: JSON küçük, sıkıştırılmış varyant sorunu yok ve
|
|
22
|
+
* kazanç doğrudan API kotasına yazılıyor — bir node'un çektiği veri hepsine
|
|
23
|
+
* yeter. Redis yoksa aynı kayıt diske yazılır; süreç yeniden açılınca upstream
|
|
24
|
+
* yeniden çağrılmaz.
|
|
25
|
+
*/
|
|
26
|
+
import { getConfig } from "../config/index.js";
|
|
27
|
+
import { DATA_CACHE_BYTE_BUDGET, DEFAULT_DATA_CACHE } from "../config/defaults.js";
|
|
28
|
+
import { recordDependency } from "./cache-deps.js";
|
|
29
|
+
import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
|
|
30
|
+
import { invalidateHtmlByDependency } from "./html-cache.js";
|
|
31
|
+
import {
|
|
32
|
+
cacheKey,
|
|
33
|
+
onCacheEvent,
|
|
34
|
+
publishCacheEvent,
|
|
35
|
+
redisDropMatching,
|
|
36
|
+
redisGetJson,
|
|
37
|
+
redisSetJson,
|
|
38
|
+
redisShares,
|
|
39
|
+
} from "./redis.js";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number }} DataEntry
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @type {Map<string, DataEntry>} */
|
|
46
|
+
const store = new Map();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `store` içindeki JSON gövdelerin toplamı. Tam tarama yapmamak için tutulur.
|
|
50
|
+
* Her silme `forget` üzerinden geçer; LRU sırası değişimi sayacı oynatmaz.
|
|
51
|
+
*/
|
|
52
|
+
let storedBytes = 0;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Testler tahliyeyi küçük bir bütçeyle doğrular. `null` → üretim tavanı.
|
|
56
|
+
* @type {number | null}
|
|
57
|
+
*/
|
|
58
|
+
let byteBudgetOverride = null;
|
|
59
|
+
|
|
60
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
61
|
+
const inflight = new Map();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Süreç ömrü boyunca biriken sayaçlar.
|
|
65
|
+
*
|
|
66
|
+
* Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
|
|
67
|
+
* bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
|
|
68
|
+
* hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
|
|
69
|
+
* fren çağrıları yavaşlatır, sayısını azaltmaz.
|
|
70
|
+
*/
|
|
71
|
+
const stats = {
|
|
72
|
+
/** Taze girdiden servis edildi. */
|
|
73
|
+
hits: 0,
|
|
74
|
+
/** Bayat girdiden servis edildi; tazeleme arkada koştu. */
|
|
75
|
+
stale: 0,
|
|
76
|
+
/** Girdi yoktu, çağıran bekledi. */
|
|
77
|
+
misses: 0,
|
|
78
|
+
/** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
|
|
79
|
+
coalesced: 0,
|
|
80
|
+
/** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
|
|
81
|
+
shared: 0,
|
|
82
|
+
/** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
|
|
83
|
+
produced: 0,
|
|
84
|
+
/** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
|
|
85
|
+
bypassed: 0,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
90
|
+
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
91
|
+
* kod varsayılanına düşülür.
|
|
92
|
+
*
|
|
93
|
+
* @returns {{ maxEntries: number, staleFactor: number }}
|
|
94
|
+
*/
|
|
95
|
+
function settings() {
|
|
96
|
+
try {
|
|
97
|
+
const { data } = getConfig();
|
|
98
|
+
return {
|
|
99
|
+
maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
|
|
100
|
+
staleFactor: Number.isFinite(Number(data?.staleFactor))
|
|
101
|
+
? Number(data.staleFactor)
|
|
102
|
+
: DEFAULT_DATA_CACHE.staleFactor,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return { ...DEFAULT_DATA_CACHE };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} key
|
|
111
|
+
* @returns {{ value: unknown, stale: boolean } | null}
|
|
112
|
+
*/
|
|
113
|
+
function read(key) {
|
|
114
|
+
const entry = store.get(key);
|
|
115
|
+
if (!entry) return null;
|
|
116
|
+
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (now >= entry.staleUntil) {
|
|
119
|
+
forget(key);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// LRU: erişilen girdiyi sona taşı. Bayt sayacı değişmez.
|
|
124
|
+
store.delete(key);
|
|
125
|
+
store.set(key, entry);
|
|
126
|
+
|
|
127
|
+
return { value: entry.value, stale: now >= entry.expiresAt };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {unknown} value
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
function valueBytes(value) {
|
|
135
|
+
try {
|
|
136
|
+
const json = JSON.stringify(value);
|
|
137
|
+
if (typeof json !== "string") return 0;
|
|
138
|
+
return Buffer.byteLength(json);
|
|
139
|
+
} catch {
|
|
140
|
+
// Serileşmeyen değer yine saklanır; ölçülemediği için küçük bir pay.
|
|
141
|
+
return 1024;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @returns {number} */
|
|
146
|
+
function activeByteBudget() {
|
|
147
|
+
return byteBudgetOverride ?? DATA_CACHE_BYTE_BUDGET;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Store'dan silmenin tek yolu. Bayt sayacı buraya bağlı.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} key
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function forget(key) {
|
|
157
|
+
const entry = store.get(key);
|
|
158
|
+
if (!entry) return false;
|
|
159
|
+
storedBytes = Math.max(0, storedBytes - (entry.bytes || 0));
|
|
160
|
+
store.delete(key);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Sayı tavanı veya bayt bütçesi aşılınca en eski girdiden düşer.
|
|
166
|
+
* @returns {void}
|
|
167
|
+
*/
|
|
168
|
+
function evict() {
|
|
169
|
+
const { maxEntries } = settings();
|
|
170
|
+
const budget = activeByteBudget();
|
|
171
|
+
|
|
172
|
+
while (store.size > 0 && (store.size > maxEntries || storedBytes > budget)) {
|
|
173
|
+
const oldest = store.keys().next().value;
|
|
174
|
+
if (oldest === undefined) break;
|
|
175
|
+
if (!forget(oldest)) break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* L1'e yazar. Bütçeden büyük tek değer saklanmaz.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} key
|
|
183
|
+
* @param {DataEntry} entry
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function install(key, entry) {
|
|
187
|
+
const bytes = entry.bytes || valueBytes(entry.value);
|
|
188
|
+
entry.bytes = bytes;
|
|
189
|
+
forget(key);
|
|
190
|
+
if (bytes > activeByteBudget()) return false;
|
|
191
|
+
|
|
192
|
+
store.set(key, entry);
|
|
193
|
+
storedBytes += bytes;
|
|
194
|
+
evict();
|
|
195
|
+
return store.has(key);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} key
|
|
200
|
+
* @param {unknown} value
|
|
201
|
+
* @param {number} ttlSeconds
|
|
202
|
+
* @param {number} staleFactor
|
|
203
|
+
*/
|
|
204
|
+
function write(key, value, ttlSeconds, staleFactor) {
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
const ttl = ttlSeconds * 1000;
|
|
207
|
+
|
|
208
|
+
/** @type {DataEntry} */
|
|
209
|
+
const entry = {
|
|
210
|
+
value,
|
|
211
|
+
expiresAt: now + ttl,
|
|
212
|
+
staleUntil: now + ttl + ttl * staleFactor,
|
|
213
|
+
bytes: valueBytes(value),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Bütçeye sığmayan değer Redis'e de yazılmaz: bir sonraki istek onu geri
|
|
217
|
+
// alıp yine reddeder. Çağıran sonuç yine `producer`'ın döndürdüğüdür.
|
|
218
|
+
if (!install(key, entry)) return;
|
|
219
|
+
|
|
220
|
+
// Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
|
|
221
|
+
// bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
|
|
222
|
+
if (redisShares("data")) {
|
|
223
|
+
redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
|
|
224
|
+
} else if (diskShares("data")) {
|
|
225
|
+
diskSetJson("data", key, entry);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
|
|
231
|
+
* mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
|
|
232
|
+
* süresiz tazelik kazanır.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} key
|
|
235
|
+
* @param {DataEntry} entry
|
|
236
|
+
*/
|
|
237
|
+
function promote(key, entry) {
|
|
238
|
+
if (!entry.bytes) entry.bytes = valueBytes(entry.value);
|
|
239
|
+
install(key, entry);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Paylaşımlı kademeden okur.
|
|
244
|
+
*
|
|
245
|
+
* Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
|
|
246
|
+
* tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
|
|
247
|
+
* yine Redis'i okur ve `producer` hiç çalışmaz.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} key
|
|
250
|
+
* @returns {Promise<DataEntry | null>}
|
|
251
|
+
*/
|
|
252
|
+
async function readShared(key) {
|
|
253
|
+
const onRedis = redisShares("data");
|
|
254
|
+
const onDisk = diskShares("data");
|
|
255
|
+
if (!onRedis && !onDisk) return null;
|
|
256
|
+
|
|
257
|
+
const entry = onRedis ? await redisGetJson(cacheKey("data", key)) : await diskGetJson("data", key);
|
|
258
|
+
if (!entry || typeof entry.expiresAt !== "number") return null;
|
|
259
|
+
if (Date.now() >= entry.expiresAt) {
|
|
260
|
+
if (onDisk) diskDrop("data", [key]);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return /** @type {DataEntry} */ (entry);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {string} key
|
|
269
|
+
* @param {number} ttlSeconds
|
|
270
|
+
* @param {() => Promise<unknown>} producer
|
|
271
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
272
|
+
* @returns {Promise<unknown>}
|
|
273
|
+
*/
|
|
274
|
+
function refresh(key, ttlSeconds, producer, options) {
|
|
275
|
+
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
276
|
+
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
277
|
+
const pending = inflight.get(key);
|
|
278
|
+
if (pending) {
|
|
279
|
+
stats.coalesced += 1;
|
|
280
|
+
return pending;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
284
|
+
|
|
285
|
+
const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
|
|
286
|
+
inflight.delete(key);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
inflight.set(key, task);
|
|
290
|
+
return task;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} key
|
|
295
|
+
* @param {number} ttlSeconds
|
|
296
|
+
* @param {() => Promise<unknown>} producer
|
|
297
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} options
|
|
298
|
+
* @param {number} staleFactor
|
|
299
|
+
* @returns {Promise<unknown>}
|
|
300
|
+
*/
|
|
301
|
+
async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
302
|
+
// Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
|
|
303
|
+
// Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
|
|
304
|
+
const shared = await readShared(key);
|
|
305
|
+
if (shared) {
|
|
306
|
+
stats.shared += 1;
|
|
307
|
+
promote(key, shared);
|
|
308
|
+
return shared.value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
stats.produced += 1;
|
|
312
|
+
const value = await producer();
|
|
313
|
+
|
|
314
|
+
const empty = value === undefined || value === null;
|
|
315
|
+
if (!empty || options.storeEmpty === true) {
|
|
316
|
+
write(key, value, ttlSeconds, staleFactor);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
|
|
326
|
+
* anahtara yazılır (`quote:v2:${symbol}`).
|
|
327
|
+
* @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
|
|
328
|
+
* @param {() => Promise<T>} producer
|
|
329
|
+
* @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
|
|
330
|
+
* `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
|
|
331
|
+
* penceresini ayarlar (0 → bayat servis yok).
|
|
332
|
+
* @returns {Promise<T>}
|
|
333
|
+
* @template T
|
|
334
|
+
*/
|
|
335
|
+
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
336
|
+
if (!ttlSeconds) {
|
|
337
|
+
stats.bypassed += 1;
|
|
338
|
+
return producer();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
|
|
342
|
+
// sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
|
|
343
|
+
recordDependency(key);
|
|
344
|
+
|
|
345
|
+
const hit = read(key);
|
|
346
|
+
|
|
347
|
+
if (hit) {
|
|
348
|
+
if (hit.stale) stats.stale += 1;
|
|
349
|
+
else stats.hits += 1;
|
|
350
|
+
|
|
351
|
+
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
352
|
+
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
353
|
+
// olması sayfaya yansımaz.
|
|
354
|
+
if (hit.stale) {
|
|
355
|
+
void refresh(key, ttlSeconds, producer, options).catch((error) => {
|
|
356
|
+
console.error(`[data-cache] background refresh failed: ${key}`, error);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return /** @type {T} */ (hit.value);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
stats.misses += 1;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
366
|
+
} catch (error) {
|
|
367
|
+
// Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
|
|
368
|
+
// durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
|
|
369
|
+
// ziyaretçiye hata sayfası göstermekten iyidir.
|
|
370
|
+
const stale = read(key);
|
|
371
|
+
if (!stale) throw error;
|
|
372
|
+
|
|
373
|
+
console.warn(
|
|
374
|
+
`[data-cache] producer failed, serving stale value: ${key}`,
|
|
375
|
+
error instanceof Error ? error.message : error,
|
|
376
|
+
);
|
|
377
|
+
return /** @type {T} */ (stale.value);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
|
|
383
|
+
* `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
|
|
384
|
+
* arasında ve TTL'li.
|
|
385
|
+
*
|
|
386
|
+
* @param {F} fn
|
|
387
|
+
* @param {{ key: string, revalidate: number, storeEmpty?: boolean,
|
|
388
|
+
* staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
|
|
389
|
+
* @returns {F}
|
|
390
|
+
* @template {(...args: any[]) => Promise<any>} F
|
|
391
|
+
*/
|
|
392
|
+
export function dataCache(fn, options) {
|
|
393
|
+
const wrapped = (...args) =>
|
|
394
|
+
withDataCache(
|
|
395
|
+
args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
|
|
396
|
+
options.revalidate,
|
|
397
|
+
() => fn(...args),
|
|
398
|
+
options,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
return /** @type {F} */ (wrapped);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
|
|
406
|
+
* "bu haber güncellendi" bilgisi geldiğinde kullanılır.
|
|
407
|
+
*
|
|
408
|
+
* Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
|
|
409
|
+
* uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
|
|
410
|
+
* gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
|
|
411
|
+
*
|
|
412
|
+
* `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
|
|
413
|
+
* node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
|
|
414
|
+
* önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
|
|
417
|
+
* @returns {number} Silinen girdi sayısı.
|
|
418
|
+
*/
|
|
419
|
+
export function clearDataCache(prefix) {
|
|
420
|
+
const removed = clearLocal(prefix);
|
|
421
|
+
|
|
422
|
+
const match = prefix === undefined ? undefined : (key) => key.startsWith(prefix);
|
|
423
|
+
if (redisShares("data")) void redisDropMatching("data", match);
|
|
424
|
+
else if (diskShares("data")) void diskDropMatching("data", match);
|
|
425
|
+
|
|
426
|
+
// Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
|
|
427
|
+
// kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
|
|
428
|
+
publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
|
|
429
|
+
|
|
430
|
+
return removed;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
|
|
435
|
+
* bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} [prefix]
|
|
438
|
+
* @returns {number}
|
|
439
|
+
*/
|
|
440
|
+
function clearLocal(prefix) {
|
|
441
|
+
/** @type {string[]} */
|
|
442
|
+
const removed = [];
|
|
443
|
+
|
|
444
|
+
if (prefix === undefined) {
|
|
445
|
+
removed.push(...store.keys());
|
|
446
|
+
store.clear();
|
|
447
|
+
storedBytes = 0;
|
|
448
|
+
} else {
|
|
449
|
+
for (const key of [...store.keys()]) {
|
|
450
|
+
if (key.startsWith(prefix)) {
|
|
451
|
+
forget(key);
|
|
452
|
+
removed.push(key);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (removed.length) invalidateHtmlByDependency(removed);
|
|
458
|
+
return removed.length;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
|
|
462
|
+
// `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
|
|
463
|
+
onCacheEvent((event) => {
|
|
464
|
+
if (event.type === "data:drop") {
|
|
465
|
+
if (typeof event.key !== "string") return;
|
|
466
|
+
forget(event.key);
|
|
467
|
+
invalidateHtmlByDependency([event.key]);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (event.type !== "data:clear") return;
|
|
472
|
+
clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Tek bir veri anahtarını düşürür.
|
|
477
|
+
*
|
|
478
|
+
* `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
|
|
479
|
+
* `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
|
|
480
|
+
* olarak o anahtar olmalı, komşusu değil.
|
|
481
|
+
*
|
|
482
|
+
* @param {string} key
|
|
483
|
+
* @returns {boolean} Girdi var mıydı.
|
|
484
|
+
*/
|
|
485
|
+
export function dropDataCacheKey(key) {
|
|
486
|
+
const existed = forget(key);
|
|
487
|
+
|
|
488
|
+
// Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
|
|
489
|
+
// yalnızca Redis'te sıcak olabilir.
|
|
490
|
+
invalidateHtmlByDependency([key]);
|
|
491
|
+
|
|
492
|
+
if (redisShares("data")) {
|
|
493
|
+
void redisDropMatching("data", (candidate) => candidate === key);
|
|
494
|
+
} else if (diskShares("data")) {
|
|
495
|
+
diskDrop("data", [key]);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
publishCacheEvent({ type: "data:drop", key });
|
|
499
|
+
|
|
500
|
+
return existed;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** @returns {number} */
|
|
504
|
+
export function getDataCacheSize() {
|
|
505
|
+
return store.size;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Bellek freninin bayt tavanını geçici olarak değiştirir. Testler LRU
|
|
510
|
+
* tahliyesini küçük bir değerle doğrular; `null` üretim tavanına döner.
|
|
511
|
+
*
|
|
512
|
+
* @param {number | null} bytes
|
|
513
|
+
* @returns {void}
|
|
514
|
+
*/
|
|
515
|
+
export function setDataCacheByteBudget(bytes) {
|
|
516
|
+
byteBudgetOverride = bytes == null ? null : bytes;
|
|
517
|
+
evict();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
|
|
522
|
+
* geri kalan her şey upstream'e hiç gitmemiş bir okuma.
|
|
523
|
+
*
|
|
524
|
+
* @returns {typeof stats & { reads: number, hitRatio: number }}
|
|
525
|
+
* `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
|
|
526
|
+
* upstream'e gitmediği (0–1).
|
|
527
|
+
*/
|
|
528
|
+
export function getDataCacheStats() {
|
|
529
|
+
const reads = stats.hits + stats.stale + stats.misses;
|
|
530
|
+
const avoided = stats.hits + stats.stale;
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
...stats,
|
|
534
|
+
reads,
|
|
535
|
+
hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
541
|
+
* tamamını bir teşhis ucundan dışa vermek istenmez.
|
|
542
|
+
*
|
|
543
|
+
* @returns {{ key: string, stale: boolean, expiresIn: number }[]}
|
|
544
|
+
*/
|
|
545
|
+
export function getDataCacheEntries() {
|
|
546
|
+
const now = Date.now();
|
|
547
|
+
|
|
548
|
+
return [...store.entries()].map(([key, entry]) => ({
|
|
549
|
+
key,
|
|
550
|
+
stale: now >= entry.expiresAt,
|
|
551
|
+
expiresIn: Math.round((entry.expiresAt - now) / 1000),
|
|
552
|
+
}));
|
|
553
|
+
}
|