jskelet 0.7.1 → 0.7.2

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.
@@ -18,6 +18,24 @@ export function withRequestCache(run) {
18
18
  return storage.run(new Map(), run);
19
19
  }
20
20
 
21
+ /**
22
+ * Sık görülen ilkel argümanlarda `JSON.stringify` atlanır.
23
+ *
24
+ * @param {unknown[]} args
25
+ * @returns {string}
26
+ */
27
+ function argsKey(args) {
28
+ if (args.length === 0) return "";
29
+ if (args.length === 1) {
30
+ const value = args[0];
31
+ const type = typeof value;
32
+ if (value == null) return `\0${value}`;
33
+ if (type === "string") return `\0s:${value}`;
34
+ if (type === "number" || type === "boolean") return `\0${type}:${value}`;
35
+ }
36
+ return JSON.stringify(args);
37
+ }
38
+
21
39
  /**
22
40
  * @param {F} fn
23
41
  * @returns {F}
@@ -34,7 +52,7 @@ export function cache(fn) {
34
52
  contextStore.set(fn, perFn);
35
53
  }
36
54
 
37
- const key = args.length === 0 ? "" : JSON.stringify(args);
55
+ const key = argsKey(args);
38
56
  if (perFn.has(key)) return perFn.get(key);
39
57
 
40
58
  const result = fn(...args);
package/src/index.js CHANGED
@@ -11,6 +11,8 @@ export {
11
11
  renderPage,
12
12
  renderView,
13
13
  renderNotFound,
14
+ warmRenderEngine,
15
+ resetRenderEngine,
14
16
  } from "./server/render.js";
15
17
  export { renderStatusPage, statusFromError } from "./server/status-page.js";
16
18
  export { createApp, startServer } from "./server/create-app.js";
@@ -53,6 +53,9 @@ export function encodeCacheValue(value) {
53
53
  }
54
54
 
55
55
  /**
56
+ * Senkron çözücü — küçük test / senkron yollar için. Hot path
57
+ * `decodeCacheValueAsync` kullanır (event loop'u brotli ile bloklamaz).
58
+ *
56
59
  * @param {Buffer | string} raw
57
60
  * @returns {unknown}
58
61
  */
@@ -68,3 +71,33 @@ export function decodeCacheValue(raw) {
68
71
  const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
69
72
  return JSON.parse(text);
70
73
  }
74
+
75
+ /**
76
+ * @param {Buffer | string} raw
77
+ * @returns {Promise<unknown>}
78
+ */
79
+ export function decodeCacheValueAsync(raw) {
80
+ if (
81
+ Buffer.isBuffer(raw) &&
82
+ raw.length > CACHE_MAGIC.length &&
83
+ raw.subarray(0, CACHE_MAGIC.length).equals(CACHE_MAGIC)
84
+ ) {
85
+ const body = raw.subarray(CACHE_MAGIC.length);
86
+ return new Promise((resolve, reject) => {
87
+ zlib.brotliDecompress(body, (error, out) => {
88
+ if (error) {
89
+ reject(error);
90
+ return;
91
+ }
92
+ try {
93
+ resolve(JSON.parse(out.toString("utf8")));
94
+ } catch (parseError) {
95
+ reject(parseError);
96
+ }
97
+ });
98
+ });
99
+ }
100
+
101
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
102
+ return Promise.resolve(JSON.parse(text));
103
+ }
@@ -247,6 +247,11 @@ export async function startServer(options = {}) {
247
247
 
248
248
  const app = await createApp(options);
249
249
 
250
+ // İlk istek TTFB'sine binmesin: şablon motoru + asset manifest burada ısınır.
251
+ // Sayfa prewarm değildir.
252
+ const { warmRenderEngine } = await import("./render.js");
253
+ await warmRenderEngine();
254
+
250
255
  // Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
251
256
  // yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
252
257
  // durumu da kapsar. Tarayıcılar `localhost` için önce `::1` deniyor; sıradan
@@ -27,7 +27,7 @@ import { getConfig } from "../config/index.js";
27
27
  import { DATA_CACHE_BYTE_BUDGET, DEFAULT_DATA_CACHE } from "../config/defaults.js";
28
28
  import { recordDependency } from "./cache-deps.js";
29
29
  import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
30
- import { invalidateHtmlByDependency } from "./html-cache.js";
30
+ import { earlyRefreshLeadMs, invalidateHtmlByDependency } from "./html-cache.js";
31
31
  import {
32
32
  cacheKey,
33
33
  onCacheEvent,
@@ -39,9 +39,12 @@ import {
39
39
  } from "./redis.js";
40
40
 
41
41
  /**
42
- * @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number }} DataEntry
42
+ * @typedef {{ value: unknown, expiresAt: number, staleUntil: number, bytes: number,
43
+ * produceMs: number }} DataEntry
43
44
  */
44
45
 
46
+ const DEFAULT_PRODUCE_MS = 500;
47
+
45
48
  /** @type {Map<string, DataEntry>} */
46
49
  const store = new Map();
47
50
 
@@ -60,6 +63,14 @@ let byteBudgetOverride = null;
60
63
  /** @type {Map<string, Promise<unknown>>} */
61
64
  const inflight = new Map();
62
65
 
66
+ /**
67
+ * L2 miss kısa belleği — boş Redis/disk turunu tekrarlamamak için.
68
+ * @type {Map<string, number>}
69
+ */
70
+ const sharedMissUntil = new Map();
71
+ const SHARED_MISS_TTL_MS = 2_000;
72
+ const MAX_SHARED_MISS = 2_000;
73
+
63
74
  /**
64
75
  * Süreç ömrü boyunca biriken sayaçlar.
65
76
  *
@@ -106,9 +117,25 @@ function settings() {
106
117
  }
107
118
  }
108
119
 
120
+ /**
121
+ * @param {DataEntry} entry
122
+ * @param {number} [now]
123
+ * @returns {boolean}
124
+ */
125
+ function isEarly(entry, now = Date.now()) {
126
+ if (now >= entry.expiresAt) return false;
127
+ // `storedAt` yok; orijinal TTL = bayat penceresi / staleFactor.
128
+ const staleFactor = settings().staleFactor;
129
+ const staleWindow = Math.max(0, entry.staleUntil - entry.expiresAt);
130
+ const originalTtl =
131
+ staleFactor > 0 ? staleWindow / staleFactor : Math.max(staleWindow, entry.produceMs * 4);
132
+ const lead = earlyRefreshLeadMs(entry.produceMs ?? DEFAULT_PRODUCE_MS, originalTtl);
133
+ return now >= entry.expiresAt - lead;
134
+ }
135
+
109
136
  /**
110
137
  * @param {string} key
111
- * @returns {{ value: unknown, stale: boolean } | null}
138
+ * @returns {{ value: unknown, stale: boolean, early: boolean } | null}
112
139
  */
113
140
  function read(key) {
114
141
  const entry = store.get(key);
@@ -124,7 +151,12 @@ function read(key) {
124
151
  store.delete(key);
125
152
  store.set(key, entry);
126
153
 
127
- return { value: entry.value, stale: now >= entry.expiresAt };
154
+ const stale = now >= entry.expiresAt;
155
+ return {
156
+ value: entry.value,
157
+ stale,
158
+ early: !stale && isEarly(entry, now),
159
+ };
128
160
  }
129
161
 
130
162
  /**
@@ -200,8 +232,9 @@ function install(key, entry) {
200
232
  * @param {unknown} value
201
233
  * @param {number} ttlSeconds
202
234
  * @param {number} staleFactor
235
+ * @param {number} [produceMs]
203
236
  */
204
- function write(key, value, ttlSeconds, staleFactor) {
237
+ function write(key, value, ttlSeconds, staleFactor, produceMs = DEFAULT_PRODUCE_MS) {
205
238
  const now = Date.now();
206
239
  const ttl = ttlSeconds * 1000;
207
240
 
@@ -211,8 +244,12 @@ function write(key, value, ttlSeconds, staleFactor) {
211
244
  expiresAt: now + ttl,
212
245
  staleUntil: now + ttl + ttl * staleFactor,
213
246
  bytes: valueBytes(value),
247
+ produceMs:
248
+ Number.isFinite(produceMs) && produceMs >= 0 ? Math.round(produceMs) : DEFAULT_PRODUCE_MS,
214
249
  };
215
250
 
251
+ sharedMissUntil.delete(key);
252
+
216
253
  // Bütçeye sığmayan değer Redis'e de yazılmaz: bir sonraki istek onu geri
217
254
  // alıp yine reddeder. Çağıran sonuç yine `producer`'ın döndürdüğüdür.
218
255
  if (!install(key, entry)) return;
@@ -236,9 +273,25 @@ function write(key, value, ttlSeconds, staleFactor) {
236
273
  */
237
274
  function promote(key, entry) {
238
275
  if (!entry.bytes) entry.bytes = valueBytes(entry.value);
276
+ if (!Number.isFinite(entry.produceMs) || entry.produceMs < 0) {
277
+ entry.produceMs = DEFAULT_PRODUCE_MS;
278
+ }
279
+ sharedMissUntil.delete(key);
239
280
  install(key, entry);
240
281
  }
241
282
 
283
+ /**
284
+ * @param {string} key
285
+ * @returns {void}
286
+ */
287
+ function rememberSharedMiss(key) {
288
+ if (sharedMissUntil.size >= MAX_SHARED_MISS) {
289
+ const oldest = sharedMissUntil.keys().next().value;
290
+ if (oldest !== undefined) sharedMissUntil.delete(oldest);
291
+ }
292
+ sharedMissUntil.set(key, Date.now() + SHARED_MISS_TTL_MS);
293
+ }
294
+
242
295
  /**
243
296
  * Paylaşımlı kademeden okur.
244
297
  *
@@ -254,13 +307,24 @@ async function readShared(key) {
254
307
  const onDisk = diskShares("data");
255
308
  if (!onRedis && !onDisk) return null;
256
309
 
310
+ const missUntil = sharedMissUntil.get(key);
311
+ if (missUntil !== undefined) {
312
+ if (Date.now() < missUntil) return null;
313
+ sharedMissUntil.delete(key);
314
+ }
315
+
257
316
  const entry = onRedis ? await redisGetJson(cacheKey("data", key)) : await diskGetJson("data", key);
258
- if (!entry || typeof entry.expiresAt !== "number") return null;
317
+ if (!entry || typeof entry.expiresAt !== "number") {
318
+ rememberSharedMiss(key);
319
+ return null;
320
+ }
259
321
  if (Date.now() >= entry.expiresAt) {
260
322
  if (onDisk) diskDrop("data", [key]);
323
+ rememberSharedMiss(key);
261
324
  return null;
262
325
  }
263
326
 
327
+ sharedMissUntil.delete(key);
264
328
  return /** @type {DataEntry} */ (entry);
265
329
  }
266
330
 
@@ -309,11 +373,13 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
309
373
  }
310
374
 
311
375
  stats.produced += 1;
376
+ const startedAt = Date.now();
312
377
  const value = await producer();
378
+ const produceMs = Date.now() - startedAt;
313
379
 
314
380
  const empty = value === undefined || value === null;
315
381
  if (!empty || options.storeEmpty === true) {
316
- write(key, value, ttlSeconds, staleFactor);
382
+ write(key, value, ttlSeconds, staleFactor, produceMs);
317
383
  }
318
384
 
319
385
  return value;
@@ -348,10 +414,9 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
348
414
  if (hit.stale) stats.stale += 1;
349
415
  else stats.hits += 1;
350
416
 
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) {
417
+ // Bayat veya erken penceredeki girdi anında döner; tazeleme arkada yürür
418
+ // — HTML cache ile aynı: TTL dolmadan upstream çağrısı bitsin.
419
+ if (hit.stale || hit.early) {
355
420
  void refresh(key, ttlSeconds, producer, options).catch((error) => {
356
421
  console.error(`[data-cache] background refresh failed: ${key}`, error);
357
422
  });
@@ -445,6 +510,7 @@ function clearLocal(prefix) {
445
510
  removed.push(...store.keys());
446
511
  store.clear();
447
512
  storedBytes = 0;
513
+ sharedMissUntil.clear();
448
514
  } else {
449
515
  for (const key of [...store.keys()]) {
450
516
  if (key.startsWith(prefix)) {
@@ -14,7 +14,7 @@ import fs from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { getConfig } from "../config/index.js";
16
16
  import { getBuildId } from "./assets.js";
17
- import { decodeCacheValue, encodeCacheValue } from "./cache-blob.js";
17
+ import { decodeCacheValueAsync, encodeCacheValue } from "./cache-blob.js";
18
18
  import { redisShares } from "./redis.js";
19
19
 
20
20
  /** @type {string | null} `null` → config'den çöz. Testler mutlak yol verir. */
@@ -137,7 +137,7 @@ export async function diskGetJson(kind, key) {
137
137
  try {
138
138
  const framed = unframe(await fs.readFile(file));
139
139
  if (!framed) return null;
140
- return decodeCacheValue(framed.body);
140
+ return decodeCacheValueAsync(framed.body);
141
141
  } catch {
142
142
  return null;
143
143
  }
@@ -24,15 +24,27 @@ let cached = null;
24
24
  export function preconnectHints() {
25
25
  if (cached != null) return cached;
26
26
 
27
- cached = getConfig()
28
- .preconnect.map((origin) => {
29
- try {
30
- return `<link rel="preconnect" href="${esc(new URL(origin).origin)}">`;
31
- } catch {
32
- console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
33
- return "";
34
- }
35
- })
27
+ /** @type {Set<string>} */
28
+ const origins = new Set();
29
+
30
+ for (const origin of getConfig().preconnect) {
31
+ try {
32
+ origins.add(new URL(origin).origin);
33
+ } catch {
34
+ console.warn(`[head] invalid preconnect URL, skipped: ${origin}`);
35
+ }
36
+ }
37
+
38
+ // CDN asset prefix config'e yazılmadan da preconnect alsın.
39
+ try {
40
+ const prefix = getConfig().build?.assets?.prefix;
41
+ if (prefix) origins.add(new URL(prefix).origin);
42
+ } catch {
43
+ /* geçersiz prefix — config zaten uyarmış olabilir */
44
+ }
45
+
46
+ cached = [...origins]
47
+ .map((origin) => `<link rel="preconnect" href="${esc(origin)}">`)
36
48
  .join("");
37
49
 
38
50
  return cached;
@@ -39,6 +39,7 @@ import { collectDependencies } from "./cache-deps.js";
39
39
  import { compilePattern, matchPattern } from "../config/pattern.js";
40
40
  import { pathOfCacheKey, publicHost } from "./cache-vary.js";
41
41
  import { diskDrop, diskDropMatching, diskGetJson, diskSetJson, diskShares } from "./disk-cache.js";
42
+ import { encodeText } from "./middleware/compression.js";
42
43
  import {
43
44
  cacheKey,
44
45
  onCacheEvent,
@@ -57,8 +58,9 @@ import {
57
58
  * yine sorulur; cevabı bu alan taşıyor.
58
59
  *
59
60
  * `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.
61
+ * `encoded` yazmadan sonra arka planda brotli ile doldurulur (ve `sendHtml`
62
+ * ilk yanıtta da yazabilir); `storeEncoded` açıkken harita büyüdüğünde girdi
63
+ * yeniden paylaşılır.
62
64
  *
63
65
  * `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
64
66
  * türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
@@ -196,6 +198,19 @@ const purgedDeps = new Map();
196
198
 
197
199
  const MAX_PURGED_DEPS = 1000;
198
200
 
201
+ /**
202
+ * L2'de bulunamayan anahtarlar — kısa süre `readShared` atlanır (boş Redis/
203
+ * disk round-trip + timeout maliyeti). Yazınca düşer.
204
+ *
205
+ * @type {Map<string, number>}
206
+ */
207
+ const sharedMissUntil = new Map();
208
+
209
+ /** @type {number} */
210
+ const SHARED_MISS_TTL_MS = 2_000;
211
+
212
+ const MAX_SHARED_MISS = 2_000;
213
+
199
214
  /**
200
215
  * Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
201
216
  * yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
@@ -337,9 +352,8 @@ function read(key) {
337
352
  store.delete(key);
338
353
  store.set(key, entry);
339
354
 
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.
355
+ // `encoded` yanıt yolunda büyüyebilir (gzip vb.). Kontrol yalnızca
356
+ // `storeEncoded` açıkken yapılır; kapalıyken bu satır karşılaştırmaya girmez.
343
357
  if (redisSharesEncoded() && entry.encoded.size !== entry.sharedEncodings) {
344
358
  share(key, entry);
345
359
  }
@@ -404,20 +418,43 @@ function share(key, entry) {
404
418
  * @param {string} key
405
419
  * @returns {Promise<HtmlEntry | null>}
406
420
  */
421
+ /**
422
+ * @param {string} key
423
+ * @returns {void}
424
+ */
425
+ function rememberSharedMiss(key) {
426
+ if (sharedMissUntil.size >= MAX_SHARED_MISS) {
427
+ const oldest = sharedMissUntil.keys().next().value;
428
+ if (oldest !== undefined) sharedMissUntil.delete(oldest);
429
+ }
430
+ sharedMissUntil.set(key, Date.now() + SHARED_MISS_TTL_MS);
431
+ }
432
+
407
433
  async function readShared(key) {
408
434
  const onRedis = redisShares("html");
409
435
  const onDisk = diskShares("html");
410
436
  if (!onRedis && !onDisk) return null;
411
437
 
438
+ const missUntil = sharedMissUntil.get(key);
439
+ if (missUntil !== undefined) {
440
+ if (Date.now() < missUntil) return null;
441
+ sharedMissUntil.delete(key);
442
+ }
443
+
412
444
  const payload = onRedis
413
445
  ? await redisGetJson(cacheKey("html", key))
414
446
  : await diskGetJson("html", key);
415
- if (!payload || typeof payload.html !== "string") return null;
447
+ if (!payload || typeof payload.html !== "string") {
448
+ rememberSharedMiss(key);
449
+ return null;
450
+ }
416
451
  if (typeof payload.expiresAt !== "number" || Date.now() >= payload.expiresAt) {
417
452
  // Dosyanın TTL'i yok; süresi dolmuş kopya okununca silinir.
418
453
  if (onDisk) diskDrop("html", [key]);
454
+ rememberSharedMiss(key);
419
455
  return null;
420
456
  }
457
+ sharedMissUntil.delete(key);
421
458
 
422
459
  const deps = new Set(Array.isArray(payload.deps) ? payload.deps.map(String) : []);
423
460
  const storedAt = Number(payload.storedAt) || 0;
@@ -466,8 +503,9 @@ async function readShared(key) {
466
503
  * @param {number} ttlSeconds
467
504
  * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
468
505
  * @param {number} [produceMs] Son üretimin süresi (ms).
506
+ * @returns {Promise<void>}
469
507
  */
470
- function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
508
+ async function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
471
509
  const now = Date.now();
472
510
 
473
511
  /** @type {HtmlEntry} */
@@ -487,7 +525,30 @@ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_
487
525
 
488
526
  // Bütçeye sığmayan sayfa Redis'e de yazılmaz: bir sonraki istek onu
489
527
  // geri alıp yine reddeder, stringify ise o anki RSS'i şişirir.
490
- if (install(key, entry)) share(key, entry);
528
+ sharedMissUntil.delete(key);
529
+ if (!install(key, entry)) return;
530
+
531
+ // MISS yanıtı br ile gidebilsin: encode bitmeden dönme. share encode sonrası
532
+ // (storeEncoded açıkken br L2'ye de gider).
533
+ await preencodeEntry(key, entry);
534
+ share(key, entry);
535
+ }
536
+
537
+ /**
538
+ * Yazılan HTML için brotli üretir.
539
+ *
540
+ * @param {string} key
541
+ * @param {HtmlEntry} entry
542
+ * @returns {Promise<void>}
543
+ */
544
+ async function preencodeEntry(key, entry) {
545
+ try {
546
+ const buffer = await encodeText(entry.html, "br");
547
+ if (store.get(key) !== entry) return;
548
+ rememberHtmlEncoding(entry.encoded, "br", buffer);
549
+ } catch {
550
+ // Encode başarısızsa sonraki sendHtml dener; cache yazımı bozulmaz.
551
+ }
491
552
  }
492
553
 
493
554
  /**
@@ -522,10 +583,8 @@ function install(key, entry) {
522
583
  }
523
584
 
524
585
  /**
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.
586
+ * Bayt sayacını store'dan yeniden hesaplar. Testler elle `encoded` doldurunca
587
+ * çağırır; hot path `rememberHtmlEncoding` delta kullanır.
529
588
  *
530
589
  * @returns {void}
531
590
  */
@@ -612,42 +671,94 @@ function extendStaleWhileRefreshing(key) {
612
671
  * @returns {Promise<{ html: string, status: number, degraded?: boolean,
613
672
  * storable?: boolean }>}
614
673
  */
674
+ /** L2 yanıtı bu sürede gelmezse local produce başlar (yavaş Redis TTFB'si). */
675
+ const SHARED_WAIT_MS = 50;
676
+
615
677
  async function produce(key, ttlSeconds, producer, token) {
616
678
  const startedAt = Date.now();
617
679
 
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 };
680
+ // L2 ile yarış: hızlı HIT kullan; yavaşsa local üret. Local hata L2'yi
681
+ // iptal etmez — disk/Redis decode 50 ms'yi aşabilir.
682
+ const sharedPromise = readShared(key);
683
+ const timedOut = await Promise.race([
684
+ sharedPromise.then((shared) => ({ shared, timeout: false })),
685
+ sleep(SHARED_WAIT_MS).then(() => ({ shared: null, timeout: true })),
686
+ ]);
687
+
688
+ if (timedOut.shared && tokens.get(key) === token) {
689
+ install(key, timedOut.shared);
690
+ return { html: timedOut.shared.html, status: timedOut.shared.status };
624
691
  }
625
692
 
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
693
  const deps = trackDependencies() ? new Set() : null;
694
+ const runLocal = () =>
695
+ deps ? collectDependencies(deps, producer) : producer();
696
+
697
+ /** @type {{ html: string, status: number, degraded?: boolean, storable?: boolean } | null} */
698
+ let value = null;
699
+
700
+ if (timedOut.timeout) {
701
+ const localSettled = runLocal().then(
702
+ (v) => /** @type {const} */ ({ ok: true, value: v }),
703
+ (err) => /** @type {const} */ ({ ok: false, err }),
704
+ );
705
+
706
+ const winner = await Promise.race([
707
+ sharedPromise.then((shared) =>
708
+ shared
709
+ ? /** @type {const} */ ({ source: "shared", shared })
710
+ : /** @type {const} */ ({ source: "miss" }),
711
+ ),
712
+ localSettled.then((result) =>
713
+ /** @type {const} */ ({ source: "local", result }),
714
+ ),
715
+ ]);
716
+
717
+ if (winner.source === "shared" && tokens.get(key) === token) {
718
+ install(key, winner.shared);
719
+ return { html: winner.shared.html, status: winner.shared.status };
720
+ }
721
+
722
+ if (winner.source === "miss") {
723
+ const result = await localSettled;
724
+ if (!result.ok) throw result.err;
725
+ value = result.value;
726
+ } else {
727
+ // Local önce bitti; geç L2 HIT varsa onu tercih et (yazma yok).
728
+ const late = await sharedPromise;
729
+ if (late && tokens.get(key) === token) {
730
+ install(key, late);
731
+ return { html: late.html, status: late.status };
732
+ }
733
+ if (!winner.result.ok) throw winner.result.err;
734
+ value = winner.result.value;
735
+ }
736
+ } else {
737
+ // L2 miss (null) — local üret.
738
+ value = await runLocal();
739
+ }
629
740
 
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
741
  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);
742
+ if (
743
+ value &&
744
+ valid &&
745
+ value.status === 200 &&
746
+ !value.degraded &&
747
+ value.storable !== false
748
+ ) {
749
+ await write(key, value, ttlSeconds, deps, Date.now() - startedAt);
646
750
  }
647
751
 
648
752
  return value;
649
753
  }
650
754
 
755
+ /** @param {number} ms */
756
+ function sleep(ms) {
757
+ return new Promise((resolve) => {
758
+ setTimeout(resolve, ms).unref?.();
759
+ });
760
+ }
761
+
651
762
  /**
652
763
  * @param {string} key
653
764
  * @param {number} ttlSeconds 0 → cache yok
@@ -705,6 +816,7 @@ function clearLocal() {
705
816
  tokens.clear();
706
817
  invalidated.clear();
707
818
  purgedDeps.clear();
819
+ sharedMissUntil.clear();
708
820
  }
709
821
 
710
822
  export function getHtmlCacheSize() {
@@ -1127,7 +1239,8 @@ export function isHtmlCacheKeyFresh(key) {
1127
1239
 
1128
1240
  /**
1129
1241
  * 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.
1242
+ * yerini alır; ham HTML kalır. Bayt sayacı delta ile güncellenir — harita
1243
+ * LRU'dan düşmüşse (store'da yok) sayaç oynatılmaz.
1131
1244
  *
1132
1245
  * @param {Map<string, Buffer>} encoded
1133
1246
  * @param {string} encoding
@@ -1135,11 +1248,28 @@ export function isHtmlCacheKeyFresh(key) {
1135
1248
  * @returns {void}
1136
1249
  */
1137
1250
  export function rememberHtmlEncoding(encoded, encoding, buffer) {
1138
- for (const key of [...encoded.keys()]) {
1139
- if (key !== encoding) encoded.delete(key);
1251
+ let removed = 0;
1252
+ for (const [key, buf] of [...encoded.entries()]) {
1253
+ if (key !== encoding) {
1254
+ removed += buf.length;
1255
+ encoded.delete(key);
1256
+ }
1140
1257
  }
1258
+ const previous = encoded.get(encoding);
1259
+ if (previous) removed += previous.length;
1141
1260
  encoded.set(encoding, buffer);
1142
- noteHtmlCacheGrowth();
1261
+
1262
+ let owned = false;
1263
+ for (const entry of store.values()) {
1264
+ if (entry.encoded === encoded) {
1265
+ owned = true;
1266
+ break;
1267
+ }
1268
+ }
1269
+ if (!owned) return;
1270
+
1271
+ storedBytes = Math.max(0, storedBytes - removed + buffer.length);
1272
+ evictOverflow();
1143
1273
  }
1144
1274
 
1145
1275
  /**