jskelet 0.1.3 → 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.
package/CHANGELOG.md CHANGED
@@ -31,23 +31,42 @@ one is listed under a **Breaking** heading.
31
31
  retry pass, since rate limit windows are measured in seconds.
32
32
  - `cache().maxEntries` configures the HTML cache limit, which used to be a fixed
33
33
  500.
34
+ - Transient upstream failures are now detected without any application code:
35
+ `globalThis.fetch` is wrapped during startup and `429`, `5xx` and network
36
+ errors raised inside a render are reported on their own, so rate limits stop
37
+ turning existing pages into 404s even when the data layer never calls
38
+ `reportUpstreamFailure()`. Requests outside a render and requests to the
39
+ server itself are ignored, deterministic answers such as `404` are not
40
+ reported, and the wrapper can be turned off with `cache().trackUpstream:
41
+ false`.
42
+ - `cache().transientRetry` (`{ attempts: 1, delayMs: 300 }` by default) retries a
43
+ page that called `notFound()` while upstream was failing. Each attempt runs in
44
+ a fresh upstream and per-request cache scope, so a page whose data arrives on
45
+ the second try is served and cached as usual instead of degrading to an error.
34
46
  - The dev report now includes the data cache entry count under `cache.data`.
35
47
 
36
48
  ### Changed
37
49
 
38
50
  - `notFound()` is no longer served as a 404 when a transient upstream failure
39
- (`429`, `5xx`, network error) was reported during the same render. Those pages
40
- now respond with an uncached `503` and `Retry-After`, so a temporary rate limit
41
- is not frozen into "this page does not exist" for the whole TTL.
51
+ (`429`, `5xx`, network error) happened during the same render. The page is
52
+ retried first and, if upstream is still failing, responds with an uncached
53
+ `503` and `Retry-After`. A temporary rate limit is no longer frozen into "this
54
+ page does not exist" for the whole TTL. A retry that gets a clean answer saying
55
+ the page is gone still returns a normal 404.
42
56
  - Responses produced with missing data are no longer offered to shared caches:
43
57
  a `degraded` render is sent with `private, no-store` instead of
44
58
  `public, s-maxage=…`. The `X-JSkelet-Cache` diagnostic header is still written.
45
59
  - The prewarm summary distinguishes paths left for the next pass
46
60
  (`700 deferred to the next pass`) from paths dropped entirely
47
61
  (`700 over the limit`).
48
- - The changelog page of the marketing example is generated from the installed
49
- package's `CHANGELOG.md` instead of a hand-written list, and shows the version
50
- published on npm next to the installed one.
62
+ - The changelog page of the marketing example is generated from the project's
63
+ `CHANGELOG.md` instead of a hand-written list, and shows the version published
64
+ on npm next to the installed one.
65
+ - The marketing example reads its markdown (documentation and changelog) from
66
+ the repository over GitHub's raw endpoint, falling back to the installed
67
+ package when the network is unavailable, so a deployment that ships without
68
+ `node_modules` can still serve the docs. In development the local file wins
69
+ and nothing is cached. The branch is overridable with `DOCS_REF`.
51
70
 
52
71
  ## [0.1.2] - 2026-08-30
53
72
 
package/docs/06-cache.md CHANGED
@@ -5,8 +5,8 @@ Bu belge JSkelet'in ISR ikamesini bütün ayrıntılarıyla anlatır: HTML TTL
5
5
  cache anahtarının nasıl kurulduğu, `X-JSkelet-Cache` başlığının değerleri,
6
6
  sıkıştırılmış gövdenin neden önbellekte durduğu, istek içi memoizasyon
7
7
  (`withRequestCache` / `cache()`), veri önbelleği (`withDataCache`), upstream
8
- hatalarının önbelleği nasıl etkilediği (`reportUpstreamFailure`) ve sunucu
9
- açılışındaki ısıtma turu.
8
+ hatalarının önbelleği nasıl etkilediği (otomatik izleme ve
9
+ `reportUpstreamFailure`) ve sunucu açılışındaki ısıtma turu.
10
10
  Kararların arkasındaki ölçüm gerekçeleri [02-mimari.md](./02-mimari.md)'de,
11
11
  config alanlarının tam referansı [07-yapilandirma.md](./07-yapilandirma.md)'de.
12
12
 
@@ -315,9 +315,34 @@ Render sırasında upstream düştüyse çıktı eksik veri içeriyor demektir.
315
315
  HTML'i tüm TTL boyunca servis etmek yerine önbelleğe **hiç yazmamak** doğru
316
316
  davranış: sonraki istek yeniden dener.
317
317
 
318
+ Bu bilgi iki yoldan gelir.
319
+
320
+ ### Otomatik izleme (varsayılan)
321
+
322
+ `createApp()` açılışta `globalThis.fetch`i sarar ve render sırasında yapılan
323
+ çağrılardaki **geçici** hataları (`429`, `5xx`, ağ hatası) kendiliğinden
324
+ bildirir. Uygulama tarafında hiçbir satır gerekmez; `fetch` ile konuşan bir API
325
+ istemcisi varsa rate limit koruması hazırdır.
326
+
327
+ Ayrıntılar:
328
+
329
+ - Yalnızca bir render bağlamı içindeki çağrılar sayılır. Script'ten, cron'dan
330
+ ya da istek dışı bir yerden yapılan `fetch` dokunulmaz kalır.
331
+ - Kendi sunucumuza yapılan istekler (`localhost`, `127.0.0.1`) atlanır: ısıtma
332
+ turu ve sağlık kontrolü upstream değildir.
333
+ - `404`/`403` gibi deterministik cevaplar **otomatik olarak bildirilmez**. Çoğu
334
+ API'de `404` "böyle bir kayıt yok" demektir; onu eksik veri saymak her yok
335
+ sayfasında yanlış uyarı üretirdi.
336
+ - Kapatmak için `cache().trackUpstream: false`. `fetch`i kendisi saran bir
337
+ uygulama (ölçüm, retry, circuit breaker) bunu tercih edebilir.
338
+
339
+ ### Elle bildirim
340
+
341
+ `fetch` kullanmayan bir istemci (veritabanı sürücüsü, gRPC, satıcı SDK'sı) ya da
342
+ kalıcı hataları da işaretlemek isteyen bir katman için sözleşme aynı kaldı.
318
343
  Bağımlılık yönü bilinçli olarak tersine çevrilmiştir: framework veri katmanını
319
344
  tanımaz, veri katmanı framework'e haber verir. Hiç çağıran olmazsa maliyet boş
320
- bir dizidir.
345
+ bir dizidir. Aynı hata her iki yoldan bildirilirse tekilleştirilir.
321
346
 
322
347
  ```js
323
348
  // lib/api/client.js
@@ -366,18 +391,44 @@ geçici bir kota sorunu TTL boyunca "bu sayfa yok" cevabına dönüşür. Arama
366
391
  için bu kalıcı bir kayıp.
367
392
 
368
393
  Framework bu durumu ayırır: render sırasında **geçici** bir upstream hatası
369
- bildirilmişse `notFound()` 404 olarak servis edilmez.
394
+ varsa `notFound()` 404 olarak servis edilmez. Sırayla:
395
+
396
+ 1. Sayfa kısa bir beklemeden sonra **yeniden denenir** (varsayılan bir kez,
397
+ 300 ms sonra). Deneme kendi upstream ve istek içi cache bağlamında koşar;
398
+ ilk turun hatası da memoize edilmiş boş cevabı da ikinci turu etkilemez.
399
+ 2. İkinci tur sayfayı üretebilirse ziyaretçi **gerçek içeriği** görür ve çıktı
400
+ normal şekilde önbelleğe girer. Isıtma günlükleri bunun sık olduğunu
401
+ gösteriyor: aynı yol saniyeler sonra 200 dönüyor.
402
+ 3. Denemeler tükendiyse yanıt `503` olur — önbelleğe girmez, `Retry-After`
403
+ taşır, sonraki istek yine gerçek içeriği üretebilir.
370
404
 
371
405
  | Render sırasında | `notFound()` sonucu |
372
406
  | --- | --- |
373
- | Geçici hata var (`429`, `5xx`, ağ hatası) | `503`, `Retry-After: 30`, `no-store` — önbelleğe **girmez**, sonraki istek gerçek içeriği üretir |
374
- | Kalıcı hata var (`404`, `403`…) ya da hata yok | Normal `404` |
407
+ | Geçici hata var (`429`, `5xx`, ağ hatası) | Tekrar dene → başarılıysa sayfa; hâlâ olmuyorsa `503`, `Retry-After: 30`, `no-store` |
408
+ | Tekrar denemede upstream sağlam cevap verip "yok" dedi | Normal `404` |
409
+ | Kalıcı hata var (`404`, `403`…) ya da hata yok | Normal `404`, tekrar denenmez |
410
+
411
+ Log satırları:
375
412
 
376
- Log satırı:
377
- `[render] /haber/x returned notFound() while upstream is failing (429 /api/...), serving an uncached 503 instead`
413
+ ```
414
+ [render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
415
+ [render] /haber/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
416
+ ```
417
+
418
+ Yani **var olan bir sayfa hiçbir koşulda 404'e dönüşmez**: ya gerçek içerik
419
+ gelir, ya önbelleğe girmeyen bir 503. Hiçbir şey "yok" olarak dondurulmaz.
420
+
421
+ Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turudur; bu yüzden
422
+ varsayılan tek deneme. Ayar `cache().transientRetry`:
423
+
424
+ ```js
425
+ cache: {
426
+ transientRetry: { attempts: 2, delayMs: 500 },
427
+ }
428
+ ```
378
429
 
379
- Yani upstream'in kotası dolduğunda sayfa dinamik olarak, önbelleğe yazılmadan
380
- üretilir; hiçbir şey "yok" olarak dondurulmaz.
430
+ `transientRetry: false` (ya da `attempts: 0`) tekrarı kapatır ve doğrudan 503'e
431
+ düşer.
381
432
 
382
433
  ## Önbelleği yönetmek
383
434
 
@@ -612,8 +663,10 @@ turunun gerçekten `MISS` → önbellek doldurup doldurmadığını buradan gör
612
663
  - **Isıtma listesi `max`'tan uzun ve sonu hiç ısınmıyor.** `rotate: false`
613
664
  olabilir; logdaki `over the limit` ifadesi bunu gösterir.
614
665
  - **Bir bölümün tamamı 404 dönüyor.** Upstream düşmüş olabilir. Artık bu durumda
615
- 404 değil önbelleğe girmeyen 503 dönüyor; logda `returned notFound() while
616
- upstream is failing` satırını arayın.
666
+ sayfa bir kez daha denenir, olmazsa 404 değil önbelleğe girmeyen 503 döner;
667
+ logda `returned notFound() while upstream is failing` satırını arayın. Hâlâ
668
+ 404 görüyorsanız hata `fetch` dışı bir istemciden geliyor olabilir
669
+ (`reportUpstreamFailure()` gerekir) ya da `cache().trackUpstream` kapatılmış.
617
670
 
618
671
  ## Sırada ne var
619
672
 
@@ -560,9 +560,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
560
560
  ## `cache()`
561
561
 
562
562
  **Tip:**
563
- `() => { html?: Record<string, number>, maxEntries?: number, data?: object, prewarm?: object }` —
563
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, transientRetry?: object | false, prewarm?: object }` —
564
564
  **Varsayılan:**
565
- `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
565
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
566
566
 
567
567
  ### `cache().html`
568
568
 
@@ -599,6 +599,24 @@ Upstream veri önbelleği (`withDataCache`). Ayrıntı: [06-cache.md](./06-cache
599
599
  | `maxEntries` | `number` | `10000` | LRU girdi sınırı. JSON, HTML'e göre onlarca kat küçük olduğu için sınır yüksek. |
600
600
  | `staleFactor` | `number` | `10` | TTL dolduktan sonra girdinin kaç TTL boyunca daha kullanılabileceği. `0` → bayat servis yok. |
601
601
 
602
+ ### `cache().trackUpstream`
603
+
604
+ **Tip:** `boolean` — **Varsayılan:** `true`
605
+
606
+ Açıkken `globalThis.fetch` sarılır ve render sırasındaki geçici upstream
607
+ hataları (`429`, `5xx`, ağ) kendiliğinden bildirilir; `reportUpstreamFailure()`
608
+ çağırmak gerekmez. `fetch`i kendisi saran bir uygulama bunu kapatabilir.
609
+
610
+ ### `cache().transientRetry`
611
+
612
+ **Tip:** `{ attempts?: number, delayMs?: number } | false` —
613
+ **Varsayılan:** `{ attempts: 1, delayMs: 300 }`
614
+
615
+ Geçici bir upstream hatası yüzünden `notFound()` çağrılan sayfa kaç kez daha
616
+ denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi; denemeler tükenirse yanıt
617
+ önbelleğe girmeyen bir 503 olur. `false` ya da `attempts: 0` tekrarı kapatır.
618
+ Ayrıntı: [06-cache.md](./06-cache.md).
619
+
602
620
  ### `cache().prewarm`
603
621
 
604
622
  | Alan | Tip | Varsayılan | Anlamı |
@@ -5,7 +5,7 @@ cache and its stale-while-revalidate behaviour, where `revalidate` comes from,
5
5
  how the cache key is built, the values of the `X-JSkelet-Cache` header, why the
6
6
  compressed body is kept in the cache, per-request memoization
7
7
  (`withRequestCache` / `cache()`), the data cache (`withDataCache`), how upstream
8
- failures affect the cache (`reportUpstreamFailure`) and the prewarm round at
8
+ failures affect the cache (automatic tracking and `reportUpstreamFailure`) and the prewarm round at
9
9
  server startup. The
10
10
  measurement rationale behind the decisions is in
11
11
  [02-architecture.md](./02-architecture.md), and the full reference of config
@@ -324,9 +324,35 @@ If upstream went down during the render, the output contains missing data.
324
324
  Rather than serving such HTML for the whole TTL, the right behaviour is to
325
325
  **never write it** to the cache: the next request tries again.
326
326
 
327
- The dependency direction is deliberately inverted: the framework does not know
328
- about the data layer, the data layer notifies the framework. If nobody ever
329
- calls it, the cost is an empty array.
327
+ This information arrives through two paths.
328
+
329
+ ### Automatic tracking (the default)
330
+
331
+ At startup `createApp()` wraps `globalThis.fetch` and reports **transient**
332
+ failures (`429`, `5xx`, network errors) from calls made during a render on its
333
+ own. No application code is needed; if your API client talks over `fetch`, the
334
+ rate limit protection is already in place.
335
+
336
+ The details:
337
+
338
+ - Only calls inside a render scope count. A `fetch` from a script, a cron job or
339
+ anywhere outside a request is left untouched.
340
+ - Requests to our own server (`localhost`, `127.0.0.1`) are skipped: the warm-up
341
+ round and the health check are not upstream.
342
+ - Deterministic answers such as `404`/`403` are **not** reported automatically.
343
+ In most APIs a `404` means "no such record"; treating it as missing data would
344
+ produce a false warning on every not-found page.
345
+ - To turn it off: `cache().trackUpstream: false`. An application that wraps
346
+ `fetch` itself (metrics, retries, a circuit breaker) may prefer that.
347
+
348
+ ### Manual reporting
349
+
350
+ For a client that does not use `fetch` (a database driver, gRPC, a vendor SDK),
351
+ or for a layer that wants to flag permanent failures too, the contract is
352
+ unchanged. The dependency direction is deliberately inverted: the framework does
353
+ not know about the data layer, the data layer notifies the framework. If nobody
354
+ ever calls it, the cost is an empty array. If the same failure arrives through
355
+ both paths it is de-duplicated.
330
356
 
331
357
  ```js
332
358
  // lib/api/client.js
@@ -374,19 +400,45 @@ site into 404s when upstream is rate limited — and because those 404s enter th
374
400
  cache, a temporary quota problem becomes a "this page does not exist" answer for
375
401
  the whole TTL. For a search engine that is a permanent loss.
376
402
 
377
- The framework separates the two cases: if a **transient** upstream failure was
378
- reported during the render, `notFound()` is not served as a 404.
403
+ The framework separates the two cases: if a **transient** upstream failure
404
+ happened during the render, `notFound()` is not served as a 404. In order:
405
+
406
+ 1. The page is **retried** after a short delay (once by default, after 300 ms).
407
+ The retry runs in its own upstream and per-request cache scope, so neither
408
+ the first round's failure nor its memoized empty answers affect it.
409
+ 2. If the second round can produce the page, the visitor sees the **real
410
+ content** and the output is cached normally. Warm-up logs show this is
411
+ common: the same path returns 200 seconds later.
412
+ 3. If the retries are exhausted the response is a `503` — not cached, carrying
413
+ `Retry-After`, and the next request can still produce the real content.
379
414
 
380
415
  | During the render | Result of `notFound()` |
381
416
  | --- | --- |
382
- | A transient failure exists (`429`, `5xx`, network error) | `503`, `Retry-After: 30`, `no-store` — **not** written to the cache, the next request produces the real content |
383
- | A permanent failure (`404`, `403`…) or no failure | A normal `404` |
417
+ | A transient failure exists (`429`, `5xx`, network error) | Retry → the page if it succeeds; otherwise `503`, `Retry-After: 30`, `no-store` |
418
+ | The retry got a clean answer saying "not there" | A normal `404` |
419
+ | A permanent failure (`404`, `403`…) or no failure | A normal `404`, no retry |
420
+
421
+ The log lines:
384
422
 
385
- The log line:
386
- `[render] /news/x returned notFound() while upstream is failing (429 /api/...), serving an uncached 503 instead`
423
+ ```
424
+ [render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying (1/1)
425
+ [render] /news/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
426
+ ```
427
+
428
+ So **an existing page never turns into a 404**: either the real content arrives,
429
+ or an uncached 503 does. Nothing is frozen as "missing".
430
+
431
+ The cost of a retry is a second round of requests on upstream, which is why the
432
+ default is a single attempt. The setting is `cache().transientRetry`:
433
+
434
+ ```js
435
+ cache: {
436
+ transientRetry: { attempts: 2, delayMs: 500 },
437
+ }
438
+ ```
387
439
 
388
- So when upstream runs out of quota the page is produced dynamically, without
389
- being written to the cache; nothing is frozen as "missing".
440
+ `transientRetry: false` (or `attempts: 0`) disables the retry and falls straight
441
+ through to the 503.
390
442
 
391
443
  ## Managing the cache
392
444
 
@@ -628,9 +680,12 @@ filled the cache.
628
680
  does not reach upstream.
629
681
  - **The warm-up list is longer than `max` and its tail never warms.** `rotate`
630
682
  may be `false`; the `over the limit` phrase in the log shows this.
631
- - **A whole section returns 404.** Upstream may be down. In that case a 503 that
632
- does not enter the cache is now returned instead of a 404; look for the
633
- `returned notFound() while upstream is failing` line in the log.
683
+ - **A whole section returns 404.** Upstream may be down. The page is now retried
684
+ once and, failing that, a 503 that does not enter the cache is returned
685
+ instead of a 404; look for the `returned notFound() while upstream is failing`
686
+ line in the log. If you still see 404s, the failure may come from a non-`fetch`
687
+ client (which needs `reportUpstreamFailure()`) or `cache().trackUpstream` is
688
+ off.
634
689
 
635
690
  ## What's next
636
691
 
@@ -572,9 +572,9 @@ Details: [03-routing.md](./03-routing.md).
572
572
  ## `cache()`
573
573
 
574
574
  **Type:**
575
- `() => { html?: Record<string, number>, maxEntries?: number, data?: object, prewarm?: object }` —
575
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, transientRetry?: object | false, prewarm?: object }` —
576
576
  **Default:**
577
- `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
577
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
578
578
 
579
579
  ### `cache().html`
580
580
 
@@ -613,6 +613,25 @@ The upstream data cache (`withDataCache`). Details:
613
613
  | `maxEntries` | `number` | `10000` | The LRU entry limit. The limit is high because JSON is tens of times smaller than HTML. |
614
614
  | `staleFactor` | `number` | `10` | For how many TTLs an entry stays usable after the TTL expired. `0` → no stale serving. |
615
615
 
616
+ ### `cache().trackUpstream`
617
+
618
+ **Type:** `boolean` — **Default:** `true`
619
+
620
+ When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
621
+ `5xx`, network) during a render are reported automatically; calling
622
+ `reportUpstreamFailure()` is not required. An application that wraps `fetch`
623
+ itself can turn this off.
624
+
625
+ ### `cache().transientRetry`
626
+
627
+ **Type:** `{ attempts?: number, delayMs?: number } | false` —
628
+ **Default:** `{ attempts: 1, delayMs: 300 }`
629
+
630
+ How many extra times a page is tried when `notFound()` was called because of a
631
+ transient upstream failure. The point is that an existing page never turns into
632
+ a 404; if the retries are exhausted the response is an uncached 503. `false` or
633
+ `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
634
+
616
635
  ### `cache().prewarm`
617
636
 
618
637
  | Field | Type | Default | Meaning |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -88,6 +88,20 @@ export const DEFAULT_PREWARM = {
88
88
  */
89
89
  export const DEFAULT_HTML_CACHE_MAX_ENTRIES = 500;
90
90
 
91
+ /**
92
+ * `notFound()` geçici bir upstream hatasına denk geldiğinde sayfanın kaç kez
93
+ * daha denenmesi gerektiği.
94
+ *
95
+ * Varsayılan tek deneme: maliyeti upstream'e binen ikinci bir istek turu, ama
96
+ * alternatifi var olan bir sayfayı 404 olarak servis etmek — arama motoru için
97
+ * geçici bir rate limit'in kalıcı kayba dönüşmesi. `attempts: 0` tekrarı
98
+ * kapatır ve doğrudan önbelleğe girmeyen 503'e düşer.
99
+ */
100
+ export const DEFAULT_TRANSIENT_RETRY = {
101
+ attempts: 1,
102
+ delayMs: 300,
103
+ };
104
+
91
105
  /**
92
106
  * Upstream veri önbelleği.
93
107
  *
@@ -38,6 +38,7 @@ import {
38
38
  DEFAULT_PREWARM_SKIP,
39
39
  DEFAULT_SECURITY,
40
40
  DEFAULT_STATIC,
41
+ DEFAULT_TRANSIENT_RETRY,
41
42
  } from "./defaults.js";
42
43
 
43
44
  /** Framework paketinin kökü — kendi şablonlarına ve varlıklarına erişir. */
@@ -68,6 +69,8 @@ const CONFIG_FILE = "jskelet.config.mjs";
68
69
  * @property {{ pattern: CompiledPattern, seconds: number }[]} html
69
70
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
70
71
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
72
+ * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
73
+ * @property {{ attempts: number, delayMs: number }} transientRetry
71
74
  * @property {Record<string, unknown>} prewarm
72
75
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
73
76
  * @property {Record<string, unknown>} brand
@@ -206,7 +209,9 @@ function normalizePriority(raw) {
206
209
  /**
207
210
  * @param {unknown} raw
208
211
  * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
209
- * data: Record<string, unknown>, prewarm: Record<string, unknown>,
212
+ * data: Record<string, unknown>, trackUpstream: boolean,
213
+ * transientRetry: { attempts: number, delayMs: number },
214
+ * prewarm: Record<string, unknown>,
210
215
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
211
216
  */
212
217
  function normalizeCache(raw) {
@@ -230,6 +235,13 @@ function normalizeCache(raw) {
230
235
  ? Math.floor(maxEntries)
231
236
  : DEFAULT_HTML_CACHE_MAX_ENTRIES,
232
237
  data: { ...DEFAULT_DATA_CACHE, ...(raw?.data ?? {}) },
238
+ // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
239
+ // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
240
+ trackUpstream: raw?.trackUpstream !== false,
241
+ transientRetry:
242
+ raw?.transientRetry === false
243
+ ? { attempts: 0, delayMs: 0 }
244
+ : { ...DEFAULT_TRANSIENT_RETRY, ...(raw?.transientRetry ?? {}) },
233
245
  // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
234
246
  // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
235
247
  prewarm,
@@ -440,8 +452,15 @@ export async function loadConfig(options = {}) {
440
452
  section("cache"),
441
453
  ]);
442
454
 
443
- const { html, htmlMaxEntries, data, prewarm, prewarmPriority } =
444
- normalizeCache(cache);
455
+ const {
456
+ html,
457
+ htmlMaxEntries,
458
+ data,
459
+ trackUpstream,
460
+ transientRetry,
461
+ prewarm,
462
+ prewarmPriority,
463
+ } = normalizeCache(cache);
445
464
  const dirs = resolveDirs(root, source.paths);
446
465
  const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
447
466
 
@@ -455,6 +474,8 @@ export async function loadConfig(options = {}) {
455
474
  html,
456
475
  htmlMaxEntries,
457
476
  data,
477
+ trackUpstream,
478
+ transientRetry,
458
479
  prewarm,
459
480
  prewarmPriority,
460
481
  brand,
@@ -34,6 +34,7 @@ import { registerRoutes } from "./router.js";
34
34
  import { renderNotFound } from "./render.js";
35
35
  import { renderStatusPage, statusFromError } from "./status-page.js";
36
36
  import { startPrewarm } from "./prewarm.js";
37
+ import { trackUpstreamFetch } from "./upstream-tracking.js";
37
38
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
38
39
 
39
40
  /**
@@ -45,6 +46,11 @@ export async function createApp(options = {}) {
45
46
  await loadConfig(options);
46
47
  const config = getConfig();
47
48
 
49
+ // Upstream hatalarının izlenmesi route'lardan önce kurulmalı: sarmalayıcı
50
+ // yalnızca render bağlamı içindeki `fetch` çağrılarına bakar, ama bağlamın
51
+ // ilk kurulduğu istek de kapsanmalı.
52
+ if (config.trackUpstream) trackUpstreamFetch();
53
+
48
54
  const app = express();
49
55
 
50
56
  app.disable("x-powered-by");
@@ -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
  /**