jskelet 0.1.2 → 0.1.3

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.
@@ -0,0 +1,244 @@
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
+ import { getConfig } from "../config/index.js";
21
+ import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
22
+
23
+ /**
24
+ * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
25
+ */
26
+
27
+ /** @type {Map<string, DataEntry>} */
28
+ const store = new Map();
29
+
30
+ /** @type {Map<string, Promise<unknown>>} */
31
+ const inflight = new Map();
32
+
33
+ /**
34
+ * Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
35
+ * script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
36
+ * kod varsayılanına düşülür.
37
+ *
38
+ * @returns {{ maxEntries: number, staleFactor: number }}
39
+ */
40
+ function settings() {
41
+ try {
42
+ const { data } = getConfig();
43
+ return {
44
+ maxEntries: Number(data?.maxEntries) || DEFAULT_DATA_CACHE.maxEntries,
45
+ staleFactor: Number.isFinite(Number(data?.staleFactor))
46
+ ? Number(data.staleFactor)
47
+ : DEFAULT_DATA_CACHE.staleFactor,
48
+ };
49
+ } catch {
50
+ return { ...DEFAULT_DATA_CACHE };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {string} key
56
+ * @returns {{ value: unknown, stale: boolean } | null}
57
+ */
58
+ function read(key) {
59
+ const entry = store.get(key);
60
+ if (!entry) return null;
61
+
62
+ const now = Date.now();
63
+ if (now >= entry.staleUntil) {
64
+ store.delete(key);
65
+ return null;
66
+ }
67
+
68
+ // LRU: erişilen girdiyi sona taşı.
69
+ store.delete(key);
70
+ store.set(key, entry);
71
+
72
+ return { value: entry.value, stale: now >= entry.expiresAt };
73
+ }
74
+
75
+ /**
76
+ * @param {string} key
77
+ * @param {unknown} value
78
+ * @param {number} ttlSeconds
79
+ * @param {number} staleFactor
80
+ */
81
+ function write(key, value, ttlSeconds, staleFactor) {
82
+ const now = Date.now();
83
+ const ttl = ttlSeconds * 1000;
84
+
85
+ store.set(key, {
86
+ value,
87
+ expiresAt: now + ttl,
88
+ staleUntil: now + ttl + ttl * staleFactor,
89
+ });
90
+
91
+ const { maxEntries } = settings();
92
+ while (store.size > maxEntries) {
93
+ const oldest = store.keys().next().value;
94
+ if (oldest === undefined) break;
95
+ store.delete(oldest);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {string} key
101
+ * @param {number} ttlSeconds
102
+ * @param {() => Promise<unknown>} producer
103
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} options
104
+ * @returns {Promise<unknown>}
105
+ */
106
+ function refresh(key, ttlSeconds, producer, options) {
107
+ // Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
108
+ // Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
109
+ const pending = inflight.get(key);
110
+ if (pending) return pending;
111
+
112
+ const staleFactor = options.staleFactor ?? settings().staleFactor;
113
+
114
+ const task = Promise.resolve()
115
+ .then(producer)
116
+ .then((value) => {
117
+ const empty = value === undefined || value === null;
118
+ if (!empty || options.storeEmpty === true) {
119
+ write(key, value, ttlSeconds, staleFactor);
120
+ }
121
+ return value;
122
+ })
123
+ .finally(() => {
124
+ inflight.delete(key);
125
+ });
126
+
127
+ inflight.set(key, task);
128
+ return task;
129
+ }
130
+
131
+ /**
132
+ * Veriyi önbellekten döner, gerekiyorsa `producer` ile üretir.
133
+ *
134
+ * @param {string} key Anahtar tamamen uygulamanın; sürüm/dil gibi ayrımlar
135
+ * anahtara yazılır (`quote:v2:${symbol}`).
136
+ * @param {number} ttlSeconds 0 → önbellek yok, `producer` her çağrıda çalışır.
137
+ * @param {() => Promise<T>} producer
138
+ * @param {{ storeEmpty?: boolean, staleFactor?: number }} [options]
139
+ * `storeEmpty` boş cevabı da saklar, `staleFactor` bu anahtar için bayat
140
+ * penceresini ayarlar (0 → bayat servis yok).
141
+ * @returns {Promise<T>}
142
+ * @template T
143
+ */
144
+ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
145
+ if (!ttlSeconds) return producer();
146
+
147
+ const hit = read(key);
148
+
149
+ if (hit) {
150
+ // Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
151
+ // etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
152
+ // olması sayfaya yansımaz.
153
+ if (hit.stale) {
154
+ void refresh(key, ttlSeconds, producer, options).catch((error) => {
155
+ console.error(`[data-cache] background refresh failed: ${key}`, error);
156
+ });
157
+ }
158
+ return /** @type {T} */ (hit.value);
159
+ }
160
+
161
+ try {
162
+ return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
163
+ } catch (error) {
164
+ // Girdi yoksa hata çağırana gider; asıl kazanç bayat girdinin olduğu
165
+ // durumda: upstream düşmüşken sayfayı eski veriyle ayakta tutmak,
166
+ // ziyaretçiye hata sayfası göstermekten iyidir.
167
+ const stale = read(key);
168
+ if (!stale) throw error;
169
+
170
+ console.warn(
171
+ `[data-cache] producer failed, serving stale value: ${key}`,
172
+ error instanceof Error ? error.message : error,
173
+ );
174
+ return /** @type {T} */ (stale.value);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * `withDataCache`'in fonksiyon sarmalayıcısı: argümanlardan anahtar üretir.
180
+ * `cache()` (istek içi memoizasyon) ile aynı kullanım biçimi, ama istekler
181
+ * arasında ve TTL'li.
182
+ *
183
+ * @param {F} fn
184
+ * @param {{ key: string, revalidate: number, storeEmpty?: boolean,
185
+ * staleFactor?: number }} options `key` önektir; argümanlar sonuna eklenir.
186
+ * @returns {F}
187
+ * @template {(...args: any[]) => Promise<any>} F
188
+ */
189
+ export function dataCache(fn, options) {
190
+ const wrapped = (...args) =>
191
+ withDataCache(
192
+ args.length ? `${options.key}:${JSON.stringify(args)}` : options.key,
193
+ options.revalidate,
194
+ () => fn(...args),
195
+ options,
196
+ );
197
+
198
+ return /** @type {F} */ (wrapped);
199
+ }
200
+
201
+ /**
202
+ * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
203
+ * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
204
+ *
205
+ * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
206
+ * @returns {number} Silinen girdi sayısı.
207
+ */
208
+ export function clearDataCache(prefix) {
209
+ if (prefix === undefined) {
210
+ const size = store.size;
211
+ store.clear();
212
+ return size;
213
+ }
214
+
215
+ let removed = 0;
216
+ for (const key of store.keys()) {
217
+ if (key.startsWith(prefix)) {
218
+ store.delete(key);
219
+ removed += 1;
220
+ }
221
+ }
222
+ return removed;
223
+ }
224
+
225
+ /** @returns {number} */
226
+ export function getDataCacheSize() {
227
+ return store.size;
228
+ }
229
+
230
+ /**
231
+ * Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
232
+ * tamamını bir teşhis ucundan dışa vermek istenmez.
233
+ *
234
+ * @returns {{ key: string, stale: boolean, expiresIn: number }[]}
235
+ */
236
+ export function getDataCacheEntries() {
237
+ const now = Date.now();
238
+
239
+ return [...store.entries()].map(([key, entry]) => ({
240
+ key,
241
+ stale: now >= entry.expiresAt,
242
+ expiresIn: Math.round((entry.expiresAt - now) / 1000),
243
+ }));
244
+ }
@@ -13,6 +13,7 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import zlib from "node:zlib";
15
15
  import { getHtmlCacheEntries, getHtmlCacheSize } from "../html-cache.js";
16
+ import { getDataCacheSize } from "../data-cache.js";
16
17
  import { prewarmProgress } from "../prewarm.js";
17
18
  import { getConfig } from "../../config/index.js";
18
19
 
@@ -343,7 +344,13 @@ export function buildReport(devtools) {
343
344
  pages: pageList.sort((a, b) => (b.visits - a.visits) || b.at - a.at),
344
345
  serverApi: serverApiCalls.slice().reverse(),
345
346
  build: { assets: assets(), ...chunks() },
346
- cache: { size: getHtmlCacheSize(), entries: getHtmlCacheEntries() },
347
+ cache: {
348
+ size: getHtmlCacheSize(),
349
+ entries: getHtmlCacheEntries(),
350
+ // Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
351
+ // binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
352
+ data: getDataCacheSize(),
353
+ },
347
354
  prewarm: { ...prewarmProgress },
348
355
  requests: devtools.requests,
349
356
  errors: devtools.errors,
@@ -8,12 +8,31 @@
8
8
  * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
9
  */
10
10
 
11
+ import { getConfig } from "../config/index.js";
12
+ import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
13
+
11
14
  /**
12
15
  * @typedef {{ html: string, status: number, expiresAt: number,
13
16
  * staleUntil: number, encoded: Map<string, Buffer> }} HtmlEntry
14
17
  */
15
18
 
16
- const MAX_ENTRIES = 500;
19
+ /**
20
+ * Girdi sınırı `cache().maxEntries` ile yükseltilebilir ama uzun kuyruklu bir
21
+ * siteyi buradan çözmeye çalışmak yanlış katman: girdi başına yüz kilobayt
22
+ * düşüyor. On binlerce yol için `withDataCache` kullanılır.
23
+ *
24
+ * Config yüklenmemiş olabilir (testler bu modülü doğrudan çağırıyor); o
25
+ * durumda kod varsayılanı geçerli.
26
+ *
27
+ * @returns {number}
28
+ */
29
+ function maxEntries() {
30
+ try {
31
+ return getConfig().htmlMaxEntries;
32
+ } catch {
33
+ return DEFAULT_HTML_CACHE_MAX_ENTRIES;
34
+ }
35
+ }
17
36
 
18
37
  /**
19
38
  * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
@@ -73,7 +92,8 @@ function write(key, value, ttlSeconds) {
73
92
  staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
74
93
  });
75
94
 
76
- while (store.size > MAX_ENTRIES) {
95
+ const limit = maxEntries();
96
+ while (store.size > limit) {
77
97
  const oldest = store.keys().next().value;
78
98
  if (oldest === undefined) break;
79
99
  store.delete(oldest);
@@ -11,6 +11,12 @@
11
11
  * middleware zinciri normal trafikle bire bir aynı olsun. Hangi yolların
12
12
  * ısıtılacağını uygulama `hooks.prewarmPaths()` ile bildirir; genelde
13
13
  * sitemap üreten fonksiyonun aynısıdır.
14
+ *
15
+ * On binlerce yolluk bir sitede tur bir "damla damla" tarayıcıya dönüşür:
16
+ * `priority` desenleri her turda başa alınır, geri kalan kuyruk turlar
17
+ * arasında kaldığı yerden devam eder (`rotate`) ve `rps` toplam hızı upstream
18
+ * kotasının altında tutar. Amaç, kimse gelmese bile hiçbir sayfanın soğuk
19
+ * kalmaması — ama bunu API'yi düşürmeden yapmak.
14
20
  */
15
21
  import process from "node:process";
16
22
  import { getConfig, hook } from "../config/index.js";
@@ -78,6 +84,100 @@ async function collectPaths() {
78
84
  );
79
85
  }
80
86
 
87
+ /**
88
+ * `cache().prewarm.priority` desenlerine göre sıralar. Eşleşmeyen yollar
89
+ * listenin sonuna, kendi aralarındaki sırayı koruyarak gider — uygulamanın
90
+ * verdiği sıra hâlâ anlamlı olsun.
91
+ *
92
+ * @param {string[]} paths
93
+ * @returns {{ head: string[], tail: string[] }}
94
+ * `head` öncelikli yollar (her turda ısıtılır), `tail` geri kalan kuyruk
95
+ * (turlar arasında dolaşılır).
96
+ */
97
+ function byPriority(paths) {
98
+ const rules = getConfig().prewarmPriority;
99
+ if (!rules.length) return { head: [], tail: paths };
100
+
101
+ /** @type {string[][]} */
102
+ const buckets = rules.map(() => []);
103
+ /** @type {string[]} */
104
+ const tail = [];
105
+
106
+ for (const candidate of paths) {
107
+ const rank = rules.findIndex((rule) => rule.test(candidate));
108
+ if (rank === -1) tail.push(candidate);
109
+ else buckets[rank].push(candidate);
110
+ }
111
+
112
+ return { head: buckets.flat(), tail };
113
+ }
114
+
115
+ /**
116
+ * Kuyruğun kaldığı yer. Periyodik turlar listeyi baştan ısıtıp aynı ilk
117
+ * `max` yolu tekrar tekrar tazelemesin: her tur bir sonraki dilimi alır ve
118
+ * yeterli tur sonunda liste baştan sona ısınır.
119
+ */
120
+ let queueCursor = 0;
121
+
122
+ /**
123
+ * Bir turda ısıtılacak dilimi seçer: önce `priority` eşleşenler, sonra
124
+ * kuyruğun sırası gelen parçası. Dışa açık olması bilinçli — sıralama ve
125
+ * rotasyon, tur çalışmadan doğrulanabilen tek davranış.
126
+ *
127
+ * @param {string[]} all
128
+ * @param {number} limit
129
+ * @param {boolean} rotate
130
+ * @returns {string[]}
131
+ */
132
+ export function selectPrewarmPaths(all, limit, rotate = true) {
133
+ if (all.length <= limit) return all;
134
+
135
+ const { head, tail } = byPriority(all);
136
+ const selected = head.slice(0, limit);
137
+ const room = limit - selected.length;
138
+ if (room <= 0 || !tail.length) return selected;
139
+
140
+ if (!rotate) return [...selected, ...tail.slice(0, room)];
141
+
142
+ // Dilim listenin sonunu aşarsa başa sarar: kuyruk halkasal dolaşılır.
143
+ const start = queueCursor % tail.length;
144
+ const slice = tail.slice(start, start + room);
145
+ if (slice.length < room) slice.push(...tail.slice(0, room - slice.length));
146
+ queueCursor = (start + room) % tail.length;
147
+
148
+ return [...selected, ...slice];
149
+ }
150
+
151
+ /** @param {number} ms */
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => {
154
+ setTimeout(resolve, ms).unref?.();
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Saniyedeki istek sayısını sınırlar. Fren `concurrency`'den bağımsız
160
+ * olmalı: paralellik gecikmeyi kapatmak için var, kotayı koruyan şey toplam
161
+ * hız. İşçiler aynı sayacı paylaştığı için sıra kimde olursa olsun tur
162
+ * verilen hızın üstüne çıkmaz.
163
+ *
164
+ * @param {number} rps 0 → sınırsız
165
+ * @returns {() => Promise<void>}
166
+ */
167
+ function createPacer(rps) {
168
+ if (!rps) return async () => {};
169
+
170
+ const gap = 1000 / rps;
171
+ let nextSlot = 0;
172
+
173
+ return async () => {
174
+ const now = Date.now();
175
+ const slot = Math.max(now, nextSlot);
176
+ nextSlot = slot + gap;
177
+ if (slot > now) await sleep(slot - now);
178
+ };
179
+ }
180
+
81
181
  /**
82
182
  * `DEV_TOKEN` ayarlıyken `devGate` token taşımayan her isteğe 404 döner.
83
183
  * Isıtma kendi sunucusuna istek attığı için token'ı çerez olarak taşımalı;
@@ -100,9 +200,10 @@ function devGateHeader() {
100
200
  * @param {(ok: number, failed: number) => void} [report]
101
201
  * Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
102
202
  * anlamı değiştiği için çağıran taraf kendi formülünü verir.
203
+ * @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
103
204
  * @returns {Promise<{ ok: number, failed: number, failedPaths: string[] }>}
104
205
  */
105
- async function crawl(origin, paths, concurrency, report = undefined) {
206
+ async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
106
207
  const { brand } = getConfig();
107
208
  const cacheHeader = brand.cacheHeader.toLowerCase();
108
209
 
@@ -117,6 +218,8 @@ async function crawl(origin, paths, concurrency, report = undefined) {
117
218
  const target = paths[index];
118
219
  index += 1;
119
220
 
221
+ if (pace) await pace();
222
+
120
223
  const startedAt = Date.now();
121
224
 
122
225
  try {
@@ -191,8 +294,15 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
191
294
  process.env.NODE_ENV === "development" ? 2 : 4,
192
295
  );
193
296
 
297
+ const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, 0));
298
+ const pace = createPacer(rps);
299
+
194
300
  const all = only?.length ? only : await collectPaths();
195
- const paths = all.slice(0, limit);
301
+ // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
302
+ // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
303
+ const paths = only?.length
304
+ ? all
305
+ : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
196
306
 
197
307
  Object.assign(prewarmProgress, {
198
308
  active: true,
@@ -211,7 +321,13 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
211
321
  try {
212
322
  /** @type {string[]} */
213
323
  let failedPaths;
214
- ({ ok, failed, failedPaths } = await crawl(origin, paths, concurrency));
324
+ ({ ok, failed, failedPaths } = await crawl(
325
+ origin,
326
+ paths,
327
+ concurrency,
328
+ undefined,
329
+ pace,
330
+ ));
215
331
 
216
332
  // Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
217
333
  // aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
@@ -219,11 +335,23 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
219
335
  if (failedPaths.length) {
220
336
  const firstOk = ok;
221
337
  const firstFailed = failed;
222
- const retry = await crawl(origin, failedPaths, 1, (retriedOk) => {
223
- // Tekrar turunda her başarı bir hatayı başarıya çevirir.
224
- prewarmProgress.ok = firstOk + retriedOk;
225
- prewarmProgress.failed = firstFailed - retriedOk;
226
- });
338
+
339
+ // Rate limit pencereleri saniye mertebesinde; hemen tekrar denemek aynı
340
+ // 429'u almak demek.
341
+ const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
342
+ await sleep(retryDelay);
343
+
344
+ const retry = await crawl(
345
+ origin,
346
+ failedPaths,
347
+ 1,
348
+ (retriedOk) => {
349
+ // Tekrar turunda her başarı bir hatayı başarıya çevirir.
350
+ prewarmProgress.ok = firstOk + retriedOk;
351
+ prewarmProgress.failed = firstFailed - retriedOk;
352
+ },
353
+ pace,
354
+ );
227
355
  recovered = retry.ok;
228
356
  ok += retry.ok;
229
357
  failed -= retry.ok;
@@ -236,11 +364,15 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
236
364
 
237
365
  if (!quiet && paths.length) {
238
366
  const skipped = all.length - paths.length;
367
+ // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
368
+ // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
369
+ // hatalı bir kurulum sanılıyor.
370
+ const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
239
371
  console.log(
240
372
  `[prewarm] warmed ${ok}/${paths.length} pages` +
241
373
  `${failed ? `, ${failed} failed` : ""}` +
242
374
  `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
243
- `${skipped > 0 ? `, ${skipped} over the limit` : ""}` +
375
+ `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
244
376
  ` (${(elapsed / 1000).toFixed(1)}s)`,
245
377
  );
246
378
  }
@@ -264,20 +396,33 @@ export function startPrewarm({ port }) {
264
396
 
265
397
  const isDev = process.env.NODE_ENV === "development";
266
398
  const origin = `http://127.0.0.1:${port}`;
267
- const run = () =>
268
- prewarm({ origin }).catch((error) => {
399
+
400
+ // Hız frenli bir tur `intervalSeconds`'tan uzun sürebilir; üst üste binen
401
+ // turlar `prewarmProgress`'i bozar ve upstream'e iki kat yük bindirir.
402
+ let running = false;
403
+ const run = async () => {
404
+ if (running) return;
405
+ running = true;
406
+ try {
407
+ await prewarm({ origin });
408
+ } catch (error) {
269
409
  console.error("[prewarm] failed", error);
270
- });
410
+ } finally {
411
+ running = false;
412
+ }
413
+ };
271
414
 
272
415
  // Isıtma ilk isteklerle yarışmasın diye gecikmeyle başlar. Dev'de gecikme
273
416
  // daha uzun: dosya kaydı süreci yeniden başlattığı için zamanlayıcı da
274
417
  // ölür; yalnızca sunucu bir süre sakin kalınca ısınır.
275
418
  const delay = setting("PREWARM_DELAY_MS", "delayMs", isDev ? 3000 : 500);
276
- setTimeout(run, delay).unref();
419
+ setTimeout(() => void run(), delay).unref();
277
420
 
278
421
  // Girdiler `revalidate` ile yaşlanır; stale-while-revalidate sayesinde
279
422
  // ziyaretçi beklemez. Periyodik tur, hiç ziyaret edilmeyen sayfaları da
280
423
  // sıcak tutmak isteyen kurulumlar için opsiyoneldir.
424
+ // `rotate` ile birlikte bu ayar "damla damla ısıtma"ya dönüşür: her tur
425
+ // kuyruğun bir dilimini alır, yeterli tur sonunda liste baştan sona ısınır.
281
426
  const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
282
- if (interval > 0) setInterval(run, interval * 1000).unref();
427
+ if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
283
428
  }
@@ -226,20 +226,35 @@ export function route(controller, options = {}) {
226
226
  );
227
227
  }
228
228
 
229
- const publicCache = cacheable && !leaked;
229
+ // Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
230
+ // çıktıya CDN'de `s-maxage` vermek o kararı bir katman yukarıda geri
231
+ // almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
232
+ // dakikalarca yaşamamalı.
233
+ const publicCache = cacheable && !leaked && !result.degraded;
230
234
 
231
235
  res.status(result.status);
232
236
  res.setHeader("Content-Type", "text/html; charset=utf-8");
233
237
 
238
+ // Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
239
+ // demenin standart yolu.
240
+ if (result.retryAfter) {
241
+ res.setHeader("Retry-After", String(result.retryAfter));
242
+ }
243
+
244
+ // Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
245
+ // önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
246
+ if (cacheable && !leaked) {
247
+ res.setHeader(
248
+ getConfig().brand.cacheHeader,
249
+ result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
250
+ );
251
+ }
252
+
234
253
  if (publicCache) {
235
254
  res.setHeader(
236
255
  "Cache-Control",
237
256
  `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
238
257
  );
239
- res.setHeader(
240
- getConfig().brand.cacheHeader,
241
- result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
242
- );
243
258
  } else {
244
259
  res.setHeader("Cache-Control", PRIVATE_CACHE);
245
260
  // Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
@@ -459,7 +474,7 @@ async function sendHtml(req, res, body, encoded, options = {}) {
459
474
  * @param {Function} controller
460
475
  * @param {{ pathname: string }} ctx
461
476
  * @returns {Promise<{ html: string, status: number, degraded?: boolean,
462
- * storable?: boolean }>}
477
+ * storable?: boolean, retryAfter?: number }>}
463
478
  */
464
479
  async function produce(controller, ctx) {
465
480
  try {
@@ -475,12 +490,39 @@ async function produce(controller, ctx) {
475
490
  };
476
491
  } catch (error) {
477
492
  if (isNotFoundError(error)) {
493
+ // Veri gelmediği için `notFound()` çağrılmış olabilir: geçici bir
494
+ // upstream hatası (429, 5xx, ağ) varken bunu 404 olarak servis etmek iki
495
+ // kere yanlış. Önbelleğe girip TTL boyunca "bu sayfa yok" cevabını
496
+ // sabitler ve arama motoru geçici bir rate limit'i kalıcı 404 sanar.
497
+ // Doğrusu 503: cache'lenmez, `Retry-After` ile gider, sonraki istek
498
+ // gerçek içeriği üretir.
499
+ const transient = transientUpstreamFailures();
500
+ if (transient.length) {
501
+ console.warn(
502
+ `[render] ${ctx.pathname} returned notFound() while upstream is failing ` +
503
+ `(${summarizeFailures(transient)}), serving an uncached 503 instead`,
504
+ );
505
+ return {
506
+ html: await renderStatusPage(503),
507
+ status: 503,
508
+ degraded: true,
509
+ retryAfter: RETRY_AFTER_SECONDS,
510
+ };
511
+ }
512
+
478
513
  return { html: await renderNotFound(), status: 404 };
479
514
  }
480
515
  throw error;
481
516
  }
482
517
  }
483
518
 
519
+ /**
520
+ * Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
521
+ * değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
522
+ * içeriği bulabilsin.
523
+ */
524
+ const RETRY_AFTER_SECONDS = 30;
525
+
484
526
  /** Ağ hatası (0) ve geçici olduğu varsayılan durumlar. */
485
527
  const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
486
528
 
@@ -506,28 +548,39 @@ function hasUpstreamFailures(pathname) {
506
548
  const failures = getUpstreamFailures();
507
549
  if (!failures.length) return false;
508
550
 
509
- /** @param {typeof failures} list */
510
- const summarize = (list) =>
511
- list.map((failure) => `${failure.status} ${failure.path}`).join(", ");
512
-
513
551
  const transient = failures.filter((failure) => isTransient(failure.status));
514
552
  const permanent = failures.filter((failure) => !isTransient(failure.status));
515
553
 
516
554
  if (permanent.length) {
517
555
  console.warn(
518
- `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarize(permanent)})`,
556
+ `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarizeFailures(permanent)})`,
519
557
  );
520
558
  }
521
559
 
522
560
  if (!transient.length) return false;
523
561
 
524
562
  console.warn(
525
- `[render] ${pathname} was produced with missing data, not caching it (${summarize(transient)})`,
563
+ `[render] ${pathname} was produced with missing data, not caching it (${summarizeFailures(transient)})`,
526
564
  );
527
565
 
528
566
  return true;
529
567
  }
530
568
 
569
+ /**
570
+ * @returns {import('./upstream-tracking.js').UpstreamFailure[]}
571
+ */
572
+ function transientUpstreamFailures() {
573
+ return getUpstreamFailures().filter((failure) => isTransient(failure.status));
574
+ }
575
+
576
+ /**
577
+ * @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
578
+ * @returns {string}
579
+ */
580
+ function summarizeFailures(failures) {
581
+ return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
582
+ }
583
+
531
584
  /**
532
585
  * 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
533
586
  * ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.