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 +25 -6
- package/docs/06-cache.md +65 -12
- package/docs/07-yapilandirma.md +20 -2
- package/docs/en/06-caching.md +70 -15
- package/docs/en/07-configuration.md +21 -2
- package/package.json +1 -1
- package/src/config/defaults.js +14 -0
- package/src/config/index.js +24 -3
- package/src/server/create-app.js +6 -0
- package/src/server/render.js +111 -38
- package/src/server/upstream-tracking.js +103 -13
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)
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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 (
|
|
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
|
-
|
|
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`
|
|
374
|
-
|
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
|
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
|
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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ı |
|
package/docs/en/06-caching.md
CHANGED
|
@@ -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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
378
|
-
|
|
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`
|
|
383
|
-
|
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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.
|
|
632
|
-
does not enter the cache is
|
|
633
|
-
`returned notFound() while upstream is failing`
|
|
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
|
+
"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",
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
*
|
package/src/config/index.js
CHANGED
|
@@ -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>,
|
|
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 {
|
|
444
|
-
|
|
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,
|
package/src/server/create-app.js
CHANGED
|
@@ -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");
|
package/src/server/render.js
CHANGED
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
guardRequest,
|
|
28
28
|
withRequestContext,
|
|
29
29
|
} from "../http/request-context.js";
|
|
30
|
-
import {
|
|
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<{
|
|
477
|
-
*
|
|
489
|
+
* @returns {Promise<{ page: Produced } | { notFound: true,
|
|
490
|
+
* transient: import('./upstream-tracking.js').UpstreamFailure[] }>}
|
|
478
491
|
*/
|
|
479
|
-
async function
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
527
|
-
|
|
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}
|
|
530
|
-
function
|
|
531
|
-
return
|
|
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) =>
|
|
552
|
-
const permanent = failures.filter((failure) => !
|
|
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) =>
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
+
* import { reportUpstreamFailure } from "jskelet";
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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()
|
|
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
|
/**
|