jskelet 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,462 +1,462 @@
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 kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
24
- * çalışır.
25
- */
26
- import { getConfig } from "../config/index.js";
27
- import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
28
- import { recordDependency } from "./cache-deps.js";
29
- import { invalidateHtmlByDependency } from "./html-cache.js";
30
- import {
31
- cacheKey,
32
- onCacheEvent,
33
- publishCacheEvent,
34
- redisDropMatching,
35
- redisGetJson,
36
- redisSetJson,
37
- redisShares,
38
- } from "./redis.js";
39
-
40
- /**
41
- * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
42
- */
43
-
44
- /** @type {Map<string, DataEntry>} */
45
- const store = new Map();
46
-
47
- /** @type {Map<string, Promise<unknown>>} */
48
- const inflight = new Map();
49
-
50
- /**
51
- * Süreç ömrü boyunca biriken sayaçlar.
52
- *
53
- * Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
54
- * bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
55
- * hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
56
- * fren çağrıları yavaşlatır, sayısını azaltmaz.
57
- */
58
- const stats = {
59
- /** Taze girdiden servis edildi. */
60
- hits: 0,
61
- /** Bayat girdiden servis edildi; tazeleme arkada koştu. */
62
- stale: 0,
63
- /** Girdi yoktu, çağıran bekledi. */
64
- misses: 0,
65
- /** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
66
- coalesced: 0,
67
- /** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
68
- shared: 0,
69
- /** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
70
- produced: 0,
71
- /** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
72
- bypassed: 0,
73
- };
74
-
75
- /**
76
- * Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
77
- * script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
78
- * kod varsayılanına düşülür.
79
- *
80
- * @returns {{ maxEntries: number, staleFactor: number }}
81
- */
82
- function settings() {
83
- try {
84
- const { data } = getConfig();
85
- return {
86
- maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
87
- staleFactor: Number.isFinite(Number(data?.staleFactor))
88
- ? Number(data.staleFactor)
89
- : DEFAULT_DATA_CACHE.staleFactor,
90
- };
91
- } catch {
92
- return { ...DEFAULT_DATA_CACHE };
93
- }
94
- }
95
-
96
- /**
97
- * @param {string} key
98
- * @returns {{ value: unknown, stale: boolean } | null}
99
- */
100
- function read(key) {
101
- const entry = store.get(key);
102
- if (!entry) return null;
103
-
104
- const now = Date.now();
105
- if (now >= entry.staleUntil) {
106
- store.delete(key);
107
- return null;
108
- }
109
-
110
- // LRU: erişilen girdiyi sona taşı.
111
- store.delete(key);
112
- store.set(key, entry);
113
-
114
- return { value: entry.value, stale: now >= entry.expiresAt };
115
- }
116
-
117
- /** Girdi sınırını aşan en eski kayıtları düşürür. */
118
- function evict() {
119
- const { maxEntries } = settings();
120
- while (store.size > maxEntries) {
121
- const oldest = store.keys().next().value;
122
- if (oldest === undefined) break;
123
- store.delete(oldest);
124
- }
125
- }
126
-
127
- /**
128
- * @param {string} key
129
- * @param {unknown} value
130
- * @param {number} ttlSeconds
131
- * @param {number} staleFactor
132
- */
133
- function write(key, value, ttlSeconds, staleFactor) {
134
- const now = Date.now();
135
- const ttl = ttlSeconds * 1000;
136
-
137
- /** @type {DataEntry} */
138
- const entry = {
139
- value,
140
- expiresAt: now + ttl,
141
- staleUntil: now + ttl + ttl * staleFactor,
142
- };
143
-
144
- store.set(key, entry);
145
-
146
- // Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
147
- // bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
148
- if (redisShares("data")) {
149
- redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
150
- }
151
-
152
- evict();
153
- }
154
-
155
- /**
156
- * Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
157
- * mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
158
- * süresiz tazelik kazanır.
159
- *
160
- * @param {string} key
161
- * @param {DataEntry} entry
162
- */
163
- function promote(key, entry) {
164
- store.set(key, entry);
165
- evict();
166
- }
167
-
168
- /**
169
- * Paylaşımlı kademeden okur.
170
- *
171
- * Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
172
- * tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
173
- * yine Redis'i okur ve `producer` hiç çalışmaz.
174
- *
175
- * @param {string} key
176
- * @returns {Promise<DataEntry | null>}
177
- */
178
- async function readShared(key) {
179
- if (!redisShares("data")) return null;
180
-
181
- const entry = await redisGetJson(cacheKey("data", key));
182
- if (!entry || typeof entry.expiresAt !== "number") return null;
183
- if (Date.now() >= entry.expiresAt) return null;
184
-
185
- return /** @type {DataEntry} */ (entry);
186
- }
187
-
188
- /**
189
- * @param {string} key
190
- * @param {number} ttlSeconds
191
- * @param {() => Promise<unknown>} producer
192
- * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
193
- * @returns {Promise<unknown>}
194
- */
195
- function refresh(key, ttlSeconds, producer, options) {
196
- // Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
197
- // Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
198
- const pending = inflight.get(key);
199
- if (pending) {
200
- stats.coalesced += 1;
201
- return pending;
202
- }
203
-
204
- const staleFactor = options.staleFactor ?? settings().staleFactor;
205
-
206
- const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
207
- inflight.delete(key);
208
- });
209
-
210
- inflight.set(key, task);
211
- return task;
212
- }
213
-
214
- /**
215
- * @param {string} key
216
- * @param {number} ttlSeconds
217
- * @param {() => Promise<unknown>} producer
218
- * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
219
- * @param {number} staleFactor
220
- * @returns {Promise<unknown>}
221
- */
222
- async function produce(key, ttlSeconds, producer, options, staleFactor) {
223
- // Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
224
- // Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
225
- const shared = await readShared(key);
226
- if (shared) {
227
- stats.shared += 1;
228
- promote(key, shared);
229
- return shared.value;
230
- }
231
-
232
- stats.produced += 1;
233
- const value = await producer();
234
-
235
- const empty = value === undefined || value === null;
236
- if (!empty || options.storeEmpty === true) {
237
- write(key, value, ttlSeconds, staleFactor);
238
- }
239
-
240
- return value;
241
- }
242
-
243
- /**
244
- * Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
245
- *
246
- * @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
247
- * anahtara yazılır (`quote:v2:${symbol}`).
248
- * @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
249
- * @param {() => Promise<T>} producer
250
- * @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
251
- * `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
252
- * penceresini ayarlar (0 → bayat servis yok).
253
- * @returns {Promise<T>}
254
- * @template T
255
- */
256
- export async function withDataCache(key, ttlSeconds, producer, options = {}) {
257
- if (!ttlSeconds) {
258
- stats.bypassed += 1;
259
- return producer();
260
- }
261
-
262
- // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
263
- // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
264
- recordDependency(key);
265
-
266
- const hit = read(key);
267
-
268
- if (hit) {
269
- if (hit.stale) stats.stale += 1;
270
- else stats.hits += 1;
271
-
272
- // Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
273
- // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
274
- // olması sayfaya yansımaz.
275
- if (hit.stale) {
276
- void refresh(key, ttlSeconds, producer, options).catch((error) => {
277
- console.error(`[data-cache] background refresh failed: ${key}`, error);
278
- });
279
- }
280
- return /** @type {T} */ (hit.value);
281
- }
282
-
283
- stats.misses += 1;
284
-
285
- try {
286
- return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
287
- } catch (error) {
288
- // Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
289
- // durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
290
- // ziyaretçiye hata sayfası göstermekten iyidir.
291
- const stale = read(key);
292
- if (!stale) throw error;
293
-
294
- console.warn(
295
- `[data-cache] producer failed, serving stale value: ${key}`,
296
- error instanceof Error ? error.message : error,
297
- );
298
- return /** @type {T} */ (stale.value);
299
- }
300
- }
301
-
302
- /**
303
- * `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
304
- * `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
305
- * arasında ve TTL'li.
306
- *
307
- * @param {F} fn
308
- * @param {{ key: string, revalidate: number, storeEmpty?: boolean,
309
- * staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
310
- * @returns {F}
311
- * @template {(...args: any[]) => Promise<any>} F
312
- */
313
- export function dataCache(fn, options) {
314
- const wrapped = (...args) =>
315
- withDataCache(
316
- args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
317
- options.revalidate,
318
- () => fn(...args),
319
- options,
320
- );
321
-
322
- return /** @type {F} */ (wrapped);
323
- }
324
-
325
- /**
326
- * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
327
- * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
328
- *
329
- * Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
330
- * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
331
- * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
332
- *
333
- * `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
334
- * node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
335
- * önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
336
- *
337
- * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
338
- * @returns {number} Silinen girdi sayısı.
339
- */
340
- export function clearDataCache(prefix) {
341
- const removed = clearLocal(prefix);
342
-
343
- if (redisShares("data")) {
344
- void redisDropMatching(
345
- "data",
346
- prefix === undefined ? undefined : (key) => key.startsWith(prefix),
347
- );
348
- }
349
-
350
- // Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
351
- // kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
352
- publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
353
-
354
- return removed;
355
- }
356
-
357
- /**
358
- * Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
359
- * bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
360
- *
361
- * @param {string} [prefix]
362
- * @returns {number}
363
- */
364
- function clearLocal(prefix) {
365
- /** @type {string[]} */
366
- const removed = [];
367
-
368
- if (prefix === undefined) {
369
- removed.push(...store.keys());
370
- store.clear();
371
- } else {
372
- for (const key of store.keys()) {
373
- if (key.startsWith(prefix)) {
374
- store.delete(key);
375
- removed.push(key);
376
- }
377
- }
378
- }
379
-
380
- if (removed.length) invalidateHtmlByDependency(removed);
381
- return removed.length;
382
- }
383
-
384
- // Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
385
- // `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
386
- onCacheEvent((event) => {
387
- if (event.type === "data:drop") {
388
- if (typeof event.key !== "string") return;
389
- store.delete(event.key);
390
- invalidateHtmlByDependency([event.key]);
391
- return;
392
- }
393
-
394
- if (event.type !== "data:clear") return;
395
- clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
396
- });
397
-
398
- /**
399
- * Tek bir veri anahtarını düşürür.
400
- *
401
- * `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
402
- * `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
403
- * olarak o anahtar olmalı, komşusu değil.
404
- *
405
- * @param {string} key
406
- * @returns {boolean} Girdi var mıydı.
407
- */
408
- export function dropDataCacheKey(key) {
409
- const existed = store.delete(key);
410
-
411
- // Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
412
- // yalnızca Redis'te sıcak olabilir.
413
- invalidateHtmlByDependency([key]);
414
-
415
- if (redisShares("data")) {
416
- void redisDropMatching("data", (candidate) => candidate === key);
417
- }
418
-
419
- publishCacheEvent({ type: "data:drop", key });
420
-
421
- return existed;
422
- }
423
-
424
- /** @returns {number} */
425
- export function getDataCacheSize() {
426
- return store.size;
427
- }
428
-
429
- /**
430
- * Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
431
- * geri kalan her şey upstream'e hiç gitmemiş bir okuma.
432
- *
433
- * @returns {typeof stats & { reads: number, hitRatio: number }}
434
- * `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
435
- * upstream'e gitmediği (0–1).
436
- */
437
- export function getDataCacheStats() {
438
- const reads = stats.hits + stats.stale + stats.misses;
439
- const avoided = stats.hits + stats.stale;
440
-
441
- return {
442
- ...stats,
443
- reads,
444
- hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
445
- };
446
- }
447
-
448
- /**
449
- * Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
450
- * tamamını bir teşhis ucundan dışa vermek istenmez.
451
- *
452
- * @returns {{ key: string, stale: boolean, expiresIn: number }[]}
453
- */
454
- export function getDataCacheEntries() {
455
- const now = Date.now();
456
-
457
- return [...store.entries()].map(([key, entry]) => ({
458
- key,
459
- stale: now >= entry.expiresAt,
460
- expiresIn: Math.round((entry.expiresAt - now) / 1000),
461
- }));
462
- }
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 kapalı ya da erişilemez olduğunda bu modül birebir eskisi gibi
24
+ * çalışır.
25
+ */
26
+ import { getConfig } from "../config/index.js";
27
+ import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
28
+ import { recordDependency } from "./cache-deps.js";
29
+ import { invalidateHtmlByDependency } from "./html-cache.js";
30
+ import {
31
+ cacheKey,
32
+ onCacheEvent,
33
+ publishCacheEvent,
34
+ redisDropMatching,
35
+ redisGetJson,
36
+ redisSetJson,
37
+ redisShares,
38
+ } from "./redis.js";
39
+
40
+ /**
41
+ * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
42
+ */
43
+
44
+ /** @type {Map<string, DataEntry>} */
45
+ const store = new Map();
46
+
47
+ /** @type {Map<string, Promise<unknown>>} */
48
+ const inflight = new Map();
49
+
50
+ /**
51
+ * Süreç ömrü boyunca biriken sayaçlar.
52
+ *
53
+ * Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
54
+ * bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
55
+ * hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
56
+ * fren çağrıları yavaşlatır, sayısını azaltmaz.
57
+ */
58
+ const stats = {
59
+ /** Taze girdiden servis edildi. */
60
+ hits: 0,
61
+ /** Bayat girdiden servis edildi; tazeleme arkada koştu. */
62
+ stale: 0,
63
+ /** Girdi yoktu, çağıran bekledi. */
64
+ misses: 0,
65
+ /** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
66
+ coalesced: 0,
67
+ /** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
68
+ shared: 0,
69
+ /** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
70
+ produced: 0,
71
+ /** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
72
+ bypassed: 0,
73
+ };
74
+
75
+ /**
76
+ * Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
77
+ * script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
78
+ * kod varsayılanına düşülür.
79
+ *
80
+ * @returns {{ maxEntries: number, staleFactor: number }}
81
+ */
82
+ function settings() {
83
+ try {
84
+ const { data } = getConfig();
85
+ return {
86
+ maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
87
+ staleFactor: Number.isFinite(Number(data?.staleFactor))
88
+ ? Number(data.staleFactor)
89
+ : DEFAULT_DATA_CACHE.staleFactor,
90
+ };
91
+ } catch {
92
+ return { ...DEFAULT_DATA_CACHE };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * @param {string} key
98
+ * @returns {{ value: unknown, stale: boolean } | null}
99
+ */
100
+ function read(key) {
101
+ const entry = store.get(key);
102
+ if (!entry) return null;
103
+
104
+ const now = Date.now();
105
+ if (now >= entry.staleUntil) {
106
+ store.delete(key);
107
+ return null;
108
+ }
109
+
110
+ // LRU: erişilen girdiyi sona taşı.
111
+ store.delete(key);
112
+ store.set(key, entry);
113
+
114
+ return { value: entry.value, stale: now >= entry.expiresAt };
115
+ }
116
+
117
+ /** Girdi sınırını aşan en eski kayıtları düşürür. */
118
+ function evict() {
119
+ const { maxEntries } = settings();
120
+ while (store.size > maxEntries) {
121
+ const oldest = store.keys().next().value;
122
+ if (oldest === undefined) break;
123
+ store.delete(oldest);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * @param {string} key
129
+ * @param {unknown} value
130
+ * @param {number} ttlSeconds
131
+ * @param {number} staleFactor
132
+ */
133
+ function write(key, value, ttlSeconds, staleFactor) {
134
+ const now = Date.now();
135
+ const ttl = ttlSeconds * 1000;
136
+
137
+ /** @type {DataEntry} */
138
+ const entry = {
139
+ value,
140
+ expiresAt: now + ttl,
141
+ staleUntil: now + ttl + ttl * staleFactor,
142
+ };
143
+
144
+ store.set(key, entry);
145
+
146
+ // Redis kopyası ateşle-unut: çağıran taraf beklemez. Anahtarın Redis ömrü
147
+ // bayat penceresinin sonuna kadar, çünkü bayat veri de işe yarıyor.
148
+ if (redisShares("data")) {
149
+ redisSetJson(cacheKey("data", key), entry, entry.staleUntil - now);
150
+ }
151
+
152
+ evict();
153
+ }
154
+
155
+ /**
156
+ * Başka bir node'un yazdığı girdiyi L1'e alır. TTL yeniden başlatılmaz:
157
+ * mutlak zamanlar olduğu gibi korunur, yoksa girdi node'dan node'a atlayarak
158
+ * süresiz tazelik kazanır.
159
+ *
160
+ * @param {string} key
161
+ * @param {DataEntry} entry
162
+ */
163
+ function promote(key, entry) {
164
+ store.set(key, entry);
165
+ evict();
166
+ }
167
+
168
+ /**
169
+ * Paylaşımlı kademeden okur.
170
+ *
171
+ * Yalnızca **taze** girdi kabul edilir. Bayat bir kopyayı L1'e almak
172
+ * tazelemeyi sonsuza kadar ertelerdi: girdi bayat kalır, her tazeleme turu
173
+ * yine Redis'i okur ve `producer` hiç çalışmaz.
174
+ *
175
+ * @param {string} key
176
+ * @returns {Promise<DataEntry | null>}
177
+ */
178
+ async function readShared(key) {
179
+ if (!redisShares("data")) return null;
180
+
181
+ const entry = await redisGetJson(cacheKey("data", key));
182
+ if (!entry || typeof entry.expiresAt !== "number") return null;
183
+ if (Date.now() >= entry.expiresAt) return null;
184
+
185
+ return /** @type {DataEntry} */ (entry);
186
+ }
187
+
188
+ /**
189
+ * @param {string} key
190
+ * @param {number} ttlSeconds
191
+ * @param {() => Promise<unknown>} producer
192
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
193
+ * @returns {Promise<unknown>}
194
+ */
195
+ function refresh(key, ttlSeconds, producer, options) {
196
+ // Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
197
+ // Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
198
+ const pending = inflight.get(key);
199
+ if (pending) {
200
+ stats.coalesced += 1;
201
+ return pending;
202
+ }
203
+
204
+ const staleFactor = options.staleFactor ?? settings().staleFactor;
205
+
206
+ const task = produce(key, ttlSeconds, producer, options, staleFactor).finally(() => {
207
+ inflight.delete(key);
208
+ });
209
+
210
+ inflight.set(key, task);
211
+ return task;
212
+ }
213
+
214
+ /**
215
+ * @param {string} key
216
+ * @param {number} ttlSeconds
217
+ * @param {() => Promise<unknown>} producer
218
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
219
+ * @param {number} staleFactor
220
+ * @returns {Promise<unknown>}
221
+ */
222
+ async function produce(key, ttlSeconds, producer, options, staleFactor) {
223
+ // Başka bir node bu anahtarı çoktan tazelediyse upstream'e hiç gitmeyiz.
224
+ // Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
225
+ const shared = await readShared(key);
226
+ if (shared) {
227
+ stats.shared += 1;
228
+ promote(key, shared);
229
+ return shared.value;
230
+ }
231
+
232
+ stats.produced += 1;
233
+ const value = await producer();
234
+
235
+ const empty = value === undefined || value === null;
236
+ if (!empty || options.storeEmpty === true) {
237
+ write(key, value, ttlSeconds, staleFactor);
238
+ }
239
+
240
+ return value;
241
+ }
242
+
243
+ /**
244
+ * Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
245
+ *
246
+ * @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
247
+ * anahtara yazılır (`quote:v2:${symbol}`).
248
+ * @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
249
+ * @param {() => Promise<T>} producer
250
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
251
+ * `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
252
+ * penceresini ayarlar (0 → bayat servis yok).
253
+ * @returns {Promise<T>}
254
+ * @template T
255
+ */
256
+ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
257
+ if (!ttlSeconds) {
258
+ stats.bypassed += 1;
259
+ return producer();
260
+ }
261
+
262
+ // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
263
+ // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
264
+ recordDependency(key);
265
+
266
+ const hit = read(key);
267
+
268
+ if (hit) {
269
+ if (hit.stale) stats.stale += 1;
270
+ else stats.hits += 1;
271
+
272
+ // Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
273
+ // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
274
+ // olması sayfaya yansımaz.
275
+ if (hit.stale) {
276
+ void refresh(key, ttlSeconds, producer, options).catch((error) => {
277
+ console.error(`[data-cache] background refresh failed: ${key}`, error);
278
+ });
279
+ }
280
+ return /** @type {T} */ (hit.value);
281
+ }
282
+
283
+ stats.misses += 1;
284
+
285
+ try {
286
+ return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
287
+ } catch (error) {
288
+ // Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
289
+ // durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
290
+ // ziyaretçiye hata sayfası göstermekten iyidir.
291
+ const stale = read(key);
292
+ if (!stale) throw error;
293
+
294
+ console.warn(
295
+ `[data-cache] producer failed, serving stale value: ${key}`,
296
+ error instanceof Error ? error.message : error,
297
+ );
298
+ return /** @type {T} */ (stale.value);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
304
+ * `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
305
+ * arasında ve TTL'li.
306
+ *
307
+ * @param {F} fn
308
+ * @param {{ key: string, revalidate: number, storeEmpty?: boolean,
309
+ * staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
310
+ * @returns {F}
311
+ * @template {(...args: any[]) => Promise<any>} F
312
+ */
313
+ export function dataCache(fn, options) {
314
+ const wrapped = (...args) =>
315
+ withDataCache(
316
+ args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
317
+ options.revalidate,
318
+ () => fn(...args),
319
+ options,
320
+ );
321
+
322
+ return /** @type {F} */ (wrapped);
323
+ }
324
+
325
+ /**
326
+ * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
327
+ * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
328
+ *
329
+ * Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
330
+ * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
331
+ * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
332
+ *
333
+ * `cache.redis` açıkken çağrı ayrıca paylaşımlı kademeden siler ve diğer
334
+ * node'lara duyurulur — bugün bir webhook yalnızca isteği alan node'un
335
+ * önbelleğini tazeliyor, diğerleri TTL'i bekliyordu.
336
+ *
337
+ * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
338
+ * @returns {number} Silinen girdi sayısı.
339
+ */
340
+ export function clearDataCache(prefix) {
341
+ const removed = clearLocal(prefix);
342
+
343
+ if (redisShares("data")) {
344
+ void redisDropMatching(
345
+ "data",
346
+ prefix === undefined ? undefined : (key) => key.startsWith(prefix),
347
+ );
348
+ }
349
+
350
+ // Yayın yerel silmeden **sonra** yapılır; diğer node'lar kendi anahtarlarını
351
+ // kendileri tarar, çünkü hangi anahtarın nerede sıcak olduğu node'a bağlı.
352
+ publishCacheEvent({ type: "data:clear", prefix: prefix ?? null });
353
+
354
+ return removed;
355
+ }
356
+
357
+ /**
358
+ * Silmenin yerel kısmı. Uzaktan gelen olay bunu çağırır: yeniden yayın yapan
359
+ * bir dinleyici iki node arasında sonsuz mesaj döngüsü üretir.
360
+ *
361
+ * @param {string} [prefix]
362
+ * @returns {number}
363
+ */
364
+ function clearLocal(prefix) {
365
+ /** @type {string[]} */
366
+ const removed = [];
367
+
368
+ if (prefix === undefined) {
369
+ removed.push(...store.keys());
370
+ store.clear();
371
+ } else {
372
+ for (const key of store.keys()) {
373
+ if (key.startsWith(prefix)) {
374
+ store.delete(key);
375
+ removed.push(key);
376
+ }
377
+ }
378
+ }
379
+
380
+ if (removed.length) invalidateHtmlByDependency(removed);
381
+ return removed.length;
382
+ }
383
+
384
+ // Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
385
+ // `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
386
+ onCacheEvent((event) => {
387
+ if (event.type === "data:drop") {
388
+ if (typeof event.key !== "string") return;
389
+ store.delete(event.key);
390
+ invalidateHtmlByDependency([event.key]);
391
+ return;
392
+ }
393
+
394
+ if (event.type !== "data:clear") return;
395
+ clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
396
+ });
397
+
398
+ /**
399
+ * Tek bir veri anahtarını düşürür.
400
+ *
401
+ * `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
402
+ * `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
403
+ * olarak o anahtar olmalı, komşusu değil.
404
+ *
405
+ * @param {string} key
406
+ * @returns {boolean} Girdi var mıydı.
407
+ */
408
+ export function dropDataCacheKey(key) {
409
+ const existed = store.delete(key);
410
+
411
+ // Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
412
+ // yalnızca Redis'te sıcak olabilir.
413
+ invalidateHtmlByDependency([key]);
414
+
415
+ if (redisShares("data")) {
416
+ void redisDropMatching("data", (candidate) => candidate === key);
417
+ }
418
+
419
+ publishCacheEvent({ type: "data:drop", key });
420
+
421
+ return existed;
422
+ }
423
+
424
+ /** @returns {number} */
425
+ export function getDataCacheSize() {
426
+ return store.size;
427
+ }
428
+
429
+ /**
430
+ * Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
431
+ * geri kalan her şey upstream'e hiç gitmemiş bir okuma.
432
+ *
433
+ * @returns {typeof stats & { reads: number, hitRatio: number }}
434
+ * `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
435
+ * upstream'e gitmediği (0–1).
436
+ */
437
+ export function getDataCacheStats() {
438
+ const reads = stats.hits + stats.stale + stats.misses;
439
+ const avoided = stats.hits + stats.stale;
440
+
441
+ return {
442
+ ...stats,
443
+ reads,
444
+ hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
450
+ * tamamını bir teşhis ucundan dışa vermek istenmez.
451
+ *
452
+ * @returns {{ key: string, stale: boolean, expiresIn: number }[]}
453
+ */
454
+ export function getDataCacheEntries() {
455
+ const now = Date.now();
456
+
457
+ return [...store.entries()].map(([key, entry]) => ({
458
+ key,
459
+ stale: now >= entry.expiresAt,
460
+ expiresIn: Math.round((entry.expiresAt - now) / 1000),
461
+ }));
462
+ }