jskelet 0.1.3 → 0.1.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.
@@ -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";
@@ -471,49 +475,100 @@ async function sendHtml(req, res, body, encoded, options = {}) {
471
475
  }
472
476
 
473
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
+ *
474
487
  * @param {Function} controller
475
488
  * @param {{ pathname: string }} ctx
476
- * @returns {Promise<{ html: string, status: number, degraded?: boolean,
477
- * storable?: boolean, retryAfter?: number }>}
489
+ * @returns {Promise<{ page: Produced } | { notFound: true,
490
+ * transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
478
491
  */
479
- async function produce(controller, ctx) {
492
+ async function attempt(controller, ctx) {
480
493
  try {
481
494
  const page = await controller(ctx);
482
495
  const rendered = await renderPage({ pathname: ctx.pathname, ...page });
483
496
  return {
484
- html: rendered,
485
- status: page.status ?? 200,
486
- degraded: hasUpstreamFailures(ctx.pathname),
487
- // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
488
- // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
489
- 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
+ },
490
505
  };
491
506
  } catch (error) {
492
507
  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
- }
508
+ return { notFound: true, transient: transientUpstreamFailures() };
509
+ }
510
+ throw error;
511
+ }
512
+ }
513
+
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
+ );
512
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.
513
553
  return { html: await renderNotFound(), status: 404 };
514
554
  }
515
- throw error;
555
+
556
+ failures = retried.transient;
516
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
+ };
517
572
  }
518
573
 
519
574
  /**
@@ -523,14 +578,32 @@ async function produce(controller, ctx) {
523
578
  */
524
579
  const RETRY_AFTER_SECONDS = 30;
525
580
 
526
- /** Ağ hatası (0) ve geçici olduğu varsayılan durumlar. */
527
- const TRANSIENT_STATUSES = new Set([0, 408, 425, 429]);
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
+ }
528
598
 
529
- /** @param {number} status */
530
- function isTransient(status) {
531
- 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
+ });
532
604
  }
533
605
 
606
+
534
607
  /**
535
608
  * Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
536
609
  * Böyle bir HTML önbelleğe yazılmaz: sonraki istek yeniden dener.
@@ -548,8 +621,8 @@ function hasUpstreamFailures(pathname) {
548
621
  const failures = getUpstreamFailures();
549
622
  if (!failures.length) return false;
550
623
 
551
- const transient = failures.filter((failure) => isTransient(failure.status));
552
- 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));
553
626
 
554
627
  if (permanent.length) {
555
628
  console.warn(
@@ -570,7 +643,7 @@ function hasUpstreamFailures(pathname) {
570
643
  * @returns {import('./upstream-tracking.js').UpstreamFailure[]}
571
644
  */
572
645
  function transientUpstreamFailures() {
573
- return getUpstreamFailures().filter((failure) => isTransient(failure.status));
646
+ return getUpstreamFailures().filter((failure) => isTransientStatus(failure.status));
574
647
  }
575
648
 
576
649
  /**
@@ -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
  /**