jskelet 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -27,7 +27,11 @@ import {
27
27
  guardRequest,
28
28
  withRequestContext,
29
29
  } from "../http/request-context.js";
30
- import { getUpstreamFailures, withUpstreamTracking } from "./upstream-tracking.js";
30
+ import {
31
+ getUpstreamFailures,
32
+ isTransientStatus,
33
+ withUpstreamTracking,
34
+ } from "./upstream-tracking.js";
31
35
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
32
36
  import { renderHeadMeta } from "./metadata.js";
33
37
  import { asset, hasAsset } from "./assets.js";
@@ -226,20 +230,35 @@ export function route(controller, options = {}) {
226
230
  );
227
231
  }
228
232
 
229
- const publicCache = cacheable && !leaked;
233
+ // Eksik veriyle üretilen çıktı süreç içi önbelleğe yazılmıyor; aynı
234
+ // çıktıya CDN'de `s-maxage` vermek o kararı bir katman yukarıda geri
235
+ // almak olurdu. Geçici bir 429 yüzünden üretilen 503, ters proxy'de
236
+ // dakikalarca yaşamamalı.
237
+ const publicCache = cacheable && !leaked && !result.degraded;
230
238
 
231
239
  res.status(result.status);
232
240
  res.setHeader("Content-Type", "text/html; charset=utf-8");
233
241
 
242
+ // Geçici upstream hatası: istemciye ve bota "bu kalıcı değil, sonra gel"
243
+ // demenin standart yolu.
244
+ if (result.retryAfter) {
245
+ res.setHeader("Retry-After", String(result.retryAfter));
246
+ }
247
+
248
+ // Teşhis başlığı `degraded` yanıtta da yazılır: "MISS" görmek, sayfanın
249
+ // önbellek yolundan geçtiğini ama saklanmadığını anlatan tek ipucu.
250
+ if (cacheable && !leaked) {
251
+ res.setHeader(
252
+ getConfig().brand.cacheHeader,
253
+ result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
254
+ );
255
+ }
256
+
234
257
  if (publicCache) {
235
258
  res.setHeader(
236
259
  "Cache-Control",
237
260
  `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
238
261
  );
239
- res.setHeader(
240
- getConfig().brand.cacheHeader,
241
- result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
242
- );
243
262
  } else {
244
263
  res.setHeader("Cache-Control", PRIVATE_CACHE);
245
264
  // Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
@@ -456,39 +475,135 @@ async function sendHtml(req, res, body, encoded, options = {}) {
456
475
  }
457
476
 
458
477
  /**
478
+ * @typedef {{ html: string, status: number, degraded?: boolean,
479
+ * storable?: boolean, retryAfter?: number }} Produced
480
+ */
481
+
482
+ /**
483
+ * Controller'ı bir kez çalıştırır. `notFound()` fırlatıldığında sonucu
484
+ * ayırt edilebilir biçimde döner: çağıran taraf bunun gerçek bir 404 mü,
485
+ * yoksa upstream düştüğü için verinin gelmemesi mi olduğuna karar verecek.
486
+ *
459
487
  * @param {Function} controller
460
488
  * @param {{ pathname: string }} ctx
461
- * @returns {Promise<{ html: string, status: number, degraded?: boolean,
462
- * storable?: boolean }>}
489
+ * @returns {Promise<{ page: Produced } | { notFound: true,
490
+ * transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
463
491
  */
464
- async function produce(controller, ctx) {
492
+ async function attempt(controller, ctx) {
465
493
  try {
466
494
  const page = await controller(ctx);
467
495
  const rendered = await renderPage({ pathname: ctx.pathname, ...page });
468
496
  return {
469
- html: rendered,
470
- status: page.status ?? 200,
471
- degraded: hasUpstreamFailures(ctx.pathname),
472
- // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
473
- // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
474
- storable: getRequestContext()?.tainted !== true,
497
+ page: {
498
+ html: rendered,
499
+ status: page.status ?? 200,
500
+ degraded: hasUpstreamFailures(ctx.pathname),
501
+ // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
502
+ // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
503
+ storable: getRequestContext()?.tainted !== true,
504
+ },
475
505
  };
476
506
  } catch (error) {
477
507
  if (isNotFoundError(error)) {
478
- return { html: await renderNotFound(), status: 404 };
508
+ return { notFound: true, transient: transientUpstreamFailures() };
479
509
  }
480
510
  throw error;
481
511
  }
482
512
  }
483
513
 
484
- /** Ağ hatası (0) ve geçici olduğu varsayılan durumlar. */
485
- const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
514
+ /**
515
+ * @param {Function} controller
516
+ * @param {{ pathname: string }} ctx
517
+ * @returns {Promise<Produced>}
518
+ */
519
+ async function produce(controller, ctx) {
520
+ const first = await attempt(controller, ctx);
521
+ if ("page" in first) return first.page;
522
+ // Deterministik "böyle bir sayfa yok" cevabı: tekrar denemenin anlamı yok.
523
+ if (!first.transient.length) return { html: await renderNotFound(), status: 404 };
524
+
525
+ // Buraya gelindiyse `notFound()` veri gelmediği için çağrılmış. **Var olan
526
+ // bir sayfayı** 404 olarak servis etmek en kötü sonuç: arama motoru geçici
527
+ // bir rate limit'i kalıcı bir kayıp sanar. Bu yüzden sayfa yeniden denenir —
528
+ // ısıtma günlükleri gösteriyor ki aynı yol saniyeler sonra 200 dönüyor.
529
+ const { attempts, delayMs } = transientRetry();
530
+ let failures = first.transient;
531
+
532
+ for (let round = 1; round <= attempts; round += 1) {
533
+ console.warn(
534
+ `[render] ${ctx.pathname} returned notFound() while upstream is failing ` +
535
+ `(${summarizeFailures(failures)}), retrying (${round}/${attempts})`,
536
+ );
537
+
538
+ // Beklemeden tekrar denemek rate limit'e girmiş bir API'de aynı 429'u
539
+ // getirir; kısa bekleme hem pencerenin dönmesine şans verir hem de
540
+ // fırtınayı büyütmez.
541
+ await sleep(delayMs * round);
542
+
543
+ // Her deneme kendi upstream ve istek içi cache bağlamında çalışır: ilk
544
+ // turun hataları ikinci turun kararını kirletmesin ve memoize edilmiş
545
+ // boş cevaplar tekrar kullanılmasın.
546
+ const retried = await withUpstreamTracking(() =>
547
+ withRequestCache(() => attempt(controller, ctx)),
548
+ );
549
+
550
+ if ("page" in retried) return retried.page;
551
+ if (!retried.transient.length) {
552
+ // Bu kez upstream sağlam cevap verdi ve "yok" dedi: gerçek 404.
553
+ return { html: await renderNotFound(), status: 404 };
554
+ }
555
+
556
+ failures = retried.transient;
557
+ }
558
+
559
+ // Denemeler tükendi. 404 yerine 503: önbelleğe girmez, `Retry-After` taşır
560
+ // ve bir sonraki istek gerçek içeriği üretebilir.
561
+ console.warn(
562
+ `[render] ${ctx.pathname} could not be produced, upstream is still failing ` +
563
+ `(${summarizeFailures(failures)}), serving an uncached 503 instead of a 404`,
564
+ );
565
+
566
+ return {
567
+ html: await renderStatusPage(503),
568
+ status: 503,
569
+ degraded: true,
570
+ retryAfter: RETRY_AFTER_SECONDS,
571
+ };
572
+ }
573
+
574
+ /**
575
+ * Geçici bir upstream hatası yüzünden üretilemeyen sayfanın `Retry-After`
576
+ * değeri. Kısa tutuluyor: ziyaretçi de bot da birkaç saniye sonra gerçek
577
+ * içeriği bulabilsin.
578
+ */
579
+ const RETRY_AFTER_SECONDS = 30;
580
+
581
+ /**
582
+ * Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turu; bu yüzden
583
+ * varsayılan tek deneme ve kısa bekleme. Rate limit fırtınasında toplam yük
584
+ * iki katına çıkabilir, ama alternatifi var olan sayfaları 404'e düşürmek.
585
+ *
586
+ * @returns {{ attempts: number, delayMs: number }}
587
+ */
588
+ function transientRetry() {
589
+ const raw = /** @type {any} */ (getConfig().transientRetry ?? {});
590
+ const attempts = Number(raw.attempts);
591
+ const delayMs = Number(raw.delayMs);
592
+
593
+ return {
594
+ attempts: Number.isFinite(attempts) && attempts >= 0 ? Math.floor(attempts) : 1,
595
+ delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 300,
596
+ };
597
+ }
486
598
 
487
- /** @param {number} status */
488
- function isTransient(status) {
489
- return TRANSIENT_STATUSES.has(status) || status >= 500;
599
+ /** @param {number} ms */
600
+ function sleep(ms) {
601
+ return new Promise((resolve) => {
602
+ setTimeout(resolve, ms).unref?.();
603
+ });
490
604
  }
491
605
 
606
+
492
607
  /**
493
608
  * Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
494
609
  * Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
@@ -506,28 +621,39 @@ function hasUpstreamFailures(pathname) {
506
621
  const failures = getUpstreamFailures();
507
622
  if (!failures.length) return false;
508
623
 
509
- /** @param {typeof failures} list */
510
- const summarize = (list) =>
511
- list.map((failure) => `${failure.status} ${failure.path}`).join(", ");
512
-
513
- const transient = failures.filter((failure) => isTransient(failure.status));
514
- const permanent = failures.filter((failure) => !isTransient(failure.status));
624
+ const transient = failures.filter((failure) => isTransientStatus(failure.status));
625
+ const permanent = failures.filter((failure) => !isTransientStatus(failure.status));
515
626
 
516
627
  if (permanent.length) {
517
628
  console.warn(
518
- `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarize(permanent)})`,
629
+ `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarizeFailures(permanent)})`,
519
630
  );
520
631
  }
521
632
 
522
633
  if (!transient.length) return false;
523
634
 
524
635
  console.warn(
525
- `[render] ${pathname} was produced with missing data, not caching it (${summarize(transient)})`,
636
+ `[render] ${pathname} was produced with missing data, not caching it (${summarizeFailures(transient)})`,
526
637
  );
527
638
 
528
639
  return true;
529
640
  }
530
641
 
642
+ /**
643
+ * @returns {import('./upstream-tracking.js').UpstreamFailure[]}
644
+ */
645
+ function transientUpstreamFailures() {
646
+ return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
647
+ }
648
+
649
+ /**
650
+ * @param {import('./upstream-tracking.js').UpstreamFailure[]} failures
651
+ * @returns {string}
652
+ */
653
+ function summarizeFailures(failures) {
654
+ return failures.map((failure) => `${failure.status} ${failure.path}`).join(", ");
655
+ }
656
+
531
657
  /**
532
658
  * 404 sayfası. `renderStatusPage(404)` için kısayol; route dosyalarında en sık
533
659
  * ihtiyaç duyulan durum bu olduğu için ayrı bir ad taşımaya devam ediyor.
@@ -1,22 +1,25 @@
1
1
  /**
2
2
  * Render başına upstream API hatalarını toplar.
3
3
  *
4
- * `render.js` her sayfayı bu bağlam içinde üretir; uygulamanın HTTP istemcisi
5
- * başarısız bir upstream yanıtında `reportUpstreamFailure()` çağırır. Böylece
6
- * HTML önbelleği "bu çıktı eksik veriyle üretildi" bilgisine sahip olur ve
7
- * bozuk sayfayı saklamaz.
4
+ * `render.js` her sayfayı bu bağlam içinde üretir. Böylece HTML önbelleği "bu
5
+ * çıktı eksik veriyle üretildi" bilgisine sahip olur ve bozuk sayfayı saklamaz;
6
+ * `notFound()` de geçici bir hataya denk geldiğinde 404 olmaktan çıkar.
8
7
  *
9
- * Bağımlılık yönü bilinçli olarak tersine çevrilmiş: framework veri katmanını
10
- * tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet
11
- * boş bir dizidir.
8
+ * Bilgi iki yoldan gelir:
12
9
  *
13
- * Kullanım (uygulamanın `lib/api/client.js` içinde):
10
+ * 1. **Otomatik** — `trackUpstreamFetch()` `globalThis.fetch`i sarar ve
11
+ * geçici hataları (429, 5xx, ağ) kendiliğinden bildirir. `createApp()`
12
+ * bunu açılışta kurar, yani hiçbir uygulama kodu gerekmez.
13
+ * 2. **Elle** — `fetch` kullanmayan bir istemci (veritabanı sürücüsü, gRPC,
14
+ * SDK) için:
14
15
  *
15
- * import { reportUpstreamFailure } from "jskelet/server";
16
+ * import { reportUpstreamFailure } from "jskelet";
16
17
  *
17
- * if (!response.ok) {
18
- * reportUpstreamFailure({ status: response.status, path: url });
19
- * }
18
+ * if (!response.ok) {
19
+ * reportUpstreamFailure({ status: response.status, path: url });
20
+ * }
21
+ *
22
+ * İki yol aynı hatayı bildirirse tekilleştirilir.
20
23
  */
21
24
  import { AsyncLocalStorage } from "node:async_hooks";
22
25
 
@@ -33,7 +36,94 @@ const storage = new AsyncLocalStorage();
33
36
  * @returns {void}
34
37
  */
35
38
  export function reportUpstreamFailure(failure) {
36
- storage.getStore()?.failures.push(failure);
39
+ const store = storage.getStore();
40
+ if (!store) return;
41
+
42
+ // Aynı hatayı hem otomatik sarmalayıcı hem uygulamanın istemcisi
43
+ // bildirebilir; aynı satırı iki kez loglamanın faydası yok.
44
+ const duplicate = store.failures.some(
45
+ (existing) => existing.status === failure.status && existing.path === failure.path,
46
+ );
47
+ if (!duplicate) store.failures.push(failure);
48
+ }
49
+
50
+ /**
51
+ * Geçici sayılan durumlar: tekrar denemekle düzelebilenler. Bu liste
52
+ * `render.js` ile paylaşılır — hangi hatanın önbelleği engellediği ve hangi
53
+ * hatanın `notFound()`u 404 olmaktan çıkardığı tek yerde tanımlı olsun.
54
+ */
55
+ const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
56
+
57
+ /**
58
+ * @param {number} status
59
+ * @returns {boolean}
60
+ */
61
+ export function isTransientStatus(status) {
62
+ return TRANSIENT_STATUSES.has(status) || status >= 500;
63
+ }
64
+
65
+ /**
66
+ * `globalThis.fetch`i sarıp **geçici** upstream hatalarını kendiliğinden
67
+ * bildirir.
68
+ *
69
+ * Gerekçesi pratik: `reportUpstreamFailure()` sözleşmesi uygulamanın HTTP
70
+ * istemcisine bir satır eklemeyi gerektiriyor ve o satır yazılmadığında
71
+ * framework rate limit'i hiç göremiyor — veri gelmediği için `notFound()`
72
+ * çağıran sayfa 404 olarak servis ediliyordu. Otomatik izleme bu bilgiyi
73
+ * varsayılan hâle getirir; elle çağrı hâlâ geçerli ve tekilleştirilir.
74
+ *
75
+ * Yalnızca geçici durumlar bildirilir. `404`/`403` gibi deterministik
76
+ * cevaplar birçok API'de "böyle bir kayıt yok" anlamına geliyor ve onları
77
+ * otomatik olarak "eksik veri" saymak her sayfada yanlış uyarı üretirdi.
78
+ *
79
+ * Kendi sunucumuza yapılan istekler atlanır: ısıtma turu ve sağlık kontrolü
80
+ * upstream değil.
81
+ *
82
+ * @returns {void}
83
+ */
84
+ export function trackUpstreamFetch() {
85
+ const original = globalThis.fetch;
86
+ if (/** @type {any} */ (original).__jskeletUpstreamTracked) return;
87
+
88
+ /** @type {typeof fetch} */
89
+ const wrapped = async (input, init) => {
90
+ // İstek bir render bağlamı içinde değilse (script, zamanlayıcı) hiçbir
91
+ // şey yapılmaz: sarmalayıcının maliyeti bir `getStore()` çağrısı.
92
+ if (!storage.getStore()) return original(input, init);
93
+
94
+ const url = requestUrl(input);
95
+ if (isSelfRequest(url)) return original(input, init);
96
+
97
+ try {
98
+ const response = await original(input, init);
99
+ if (!response.ok && isTransientStatus(response.status)) {
100
+ reportUpstreamFailure({ status: response.status, path: url });
101
+ }
102
+ return response;
103
+ } catch (error) {
104
+ // Yanıt hiç gelmedi: ağ hatası her zaman geçicidir.
105
+ reportUpstreamFailure({ status: 0, path: url });
106
+ throw error;
107
+ }
108
+ };
109
+
110
+ /** @type {any} */ (wrapped).__jskeletUpstreamTracked = true;
111
+ globalThis.fetch = wrapped;
112
+ }
113
+
114
+ /**
115
+ * @param {RequestInfo | URL} input
116
+ * @returns {string}
117
+ */
118
+ function requestUrl(input) {
119
+ if (typeof input === "string") return input;
120
+ if (input instanceof URL) return input.href;
121
+ return /** @type {Request} */ (input)?.url ?? String(input);
122
+ }
123
+
124
+ /** @param {string} url */
125
+ function isSelfRequest(url) {
126
+ return /^https?:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:|\/|$)/i.test(url);
37
127
  }
38
128
 
39
129
  /**