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.
package/CHANGELOG.md CHANGED
@@ -31,23 +31,48 @@ 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
 
50
+ - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
51
+ polling `/stats` every two seconds. The server pushes statistics as they change
52
+ and sends live reload and CSS hot-swap events over the same connection, so an
53
+ open tab no longer keeps hitting the server while the panel is closed. No new
54
+ dependency is involved; if the socket cannot be opened, the panel falls back to
55
+ the previous SSE plus polling path.
38
56
  - `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.
57
+ (`429`, `5xx`, network error) happened during the same render. The page is
58
+ retried first and, if upstream is still failing, responds with an uncached
59
+ `503` and `Retry-After`. A temporary rate limit is no longer frozen into "this
60
+ page does not exist" for the whole TTL. A retry that gets a clean answer saying
61
+ the page is gone still returns a normal 404.
42
62
  - Responses produced with missing data are no longer offered to shared caches:
43
63
  a `degraded` render is sent with `private, no-store` instead of
44
64
  `public, s-maxage=…`. The `X-JSkelet-Cache` diagnostic header is still written.
45
65
  - The prewarm summary distinguishes paths left for the next pass
46
66
  (`700 deferred to the next pass`) from paths dropped entirely
47
67
  (`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.
68
+ - The changelog page of the marketing example is generated from the project's
69
+ `CHANGELOG.md` instead of a hand-written list, and shows the version published
70
+ on npm next to the installed one.
71
+ - The marketing example reads its markdown (documentation and changelog) from
72
+ the repository over GitHub's raw endpoint, falling back to the installed
73
+ package when the network is unavailable, so a deployment that ships without
74
+ `node_modules` can still serve the docs. In development the local file wins
75
+ and nothing is cached. The branch is overridable with `DOCS_REF`.
51
76
 
52
77
  ## [0.1.2] - 2026-08-30
53
78
 
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ı |
@@ -111,9 +111,9 @@ Restart satırı değişen dosyayı ya da sayısını gösterir:
111
111
 
112
112
  ## CSS hot-swap ve tam yenileme
113
113
 
114
- Dev sunucusu `.jskelet/manifest.json` dosyasını izler ve bir SSE kanalı
115
- (`<devBasePath>/events`) üzerinden tarayıcıya olay yayınlar. Manifest her build
116
- turunda yeniden yazıldığı için değişiklik tespiti manifest üzerinden yapılır.
114
+ Dev sunucusu `.jskelet/manifest.json` dosyasını izler ve olayları canlı kanal
115
+ (`<devBasePath>/ws`) üzerinden tarayıcıya yayınlar. Manifest her build turunda
116
+ yeniden yazıldığı için değişiklik tespiti manifest üzerinden yapılır.
117
117
 
118
118
  | Değişen | Davranış |
119
119
  | --- | --- |
@@ -131,6 +131,27 @@ Sunucu yeniden başladığında overlay bunu **boot kimliğinden** anlar: her s
131
131
  kendine özgü bir `boot` değeri yayınlar, overlay değişikliği görüp "restarted"
132
132
  bilgisini gösterir ve kendi durumunu sıfırlamaz.
133
133
 
134
+ ## Canlı kanal
135
+
136
+ Overlay'e giden her şey — istatistikler, live reload ve CSS hot-swap olayları —
137
+ tek bir WebSocket üzerinden gelir (`<devBasePath>/ws`). Panel eskiden
138
+ istatistikleri iki saniyede bir çekiyordu; açık her sekme, panel kapalıyken bile
139
+ sunucuya sürekli istek atıyordu. Artık sunucu değişiklik oldukça iter: bir istek
140
+ ya da hata kaydedildiğinde (120 ms birleştirilerek), ısıtma sürerken saniyede
141
+ bir, geri kalan zamanda yalnızca uptime/bellek tazelensin diye dört saniyede bir.
142
+ Bağlı panel yoksa hiçbir şey hesaplanmaz.
143
+
144
+ El sıkışma HTTP `upgrade` olayında geçtiği ve o olay middleware zincirine hiç
145
+ uğramadığı için kanal `listen` sonrası doğrudan sunucuya bağlanır
146
+ (`attachDevSocket`). Sunucu tarafı `ws` gibi bir bağımlılık kullanmaz: yalnızca
147
+ sunucu→istemci metin çerçevesi yazmak ve istemcinin ping/close çerçevelerini
148
+ yanıtlamak gerekiyor.
149
+
150
+ Soket hiç açılamazsa (araya giren bir proxy WebSocket'i geçirmiyor olabilir)
151
+ overlay eski yola düşer: `/events` SSE akışı + `/stats` yoklaması. Soket kurulup
152
+ sonra düşerse — yani sunucu yeniden başlıyorsa — yarım saniyede bir yeniden
153
+ bağlanır ve gösterge bu sırada "bağlantı yok" der.
154
+
134
155
  ## Devtools overlay
135
156
 
136
157
  Sağ altta yüzen bir baloncuk; `Alt+D` ile açılır, `Esc` ya da karartma alanına
@@ -236,8 +257,9 @@ Rapor katmanı yalnızca development'ta yüklenir, üretim çıktısına hiç gi
236
257
  | --- | --- | --- |
237
258
  | `/overlay.js` | GET | Overlay script'i |
238
259
  | `/logo.png` | GET | Overlay logosu |
239
- | `/events` | GET | SSE: live reload ve CSS hot-swap olayları |
240
- | `/stats` | GET | Anlık istatistikler (overlay 2 saniyede bir çeker) |
260
+ | `/ws` | GET (upgrade) | Canlı kanal: istatistikler, live reload ve CSS hot-swap olayları |
261
+ | `/events` | GET | SSE: yalnızca WebSocket kurulamazsa kullanılan yedek olay akışı |
262
+ | `/stats` | GET | Anlık istatistikler; aynı yedek yolun veri ucu |
241
263
  | `/report` | GET | Rapor sayfası (HTML) |
242
264
  | `/report.js` | GET | Rapor sayfasının script'i |
243
265
  | `/report/data` | GET | Raporun tek veri kaynağı (JSON) |
@@ -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 |
@@ -116,7 +116,7 @@ If `JSKELET_VERBOSE=1` is set, all files are listed when more than one changed.
116
116
  ## CSS hot-swap and full reload
117
117
 
118
118
  The dev server watches `.jskelet/manifest.json` and broadcasts events to the
119
- browser over an SSE channel (`<devBasePath>/events`). Since the manifest is
119
+ browser over the live channel (`<devBasePath>/ws`). Since the manifest is
120
120
  rewritten on every build round, change detection is done through the manifest.
121
121
 
122
122
  | What changed | Behavior |
@@ -134,6 +134,28 @@ When the server restarts, the overlay figures it out from the **boot id**: every
134
134
  process broadcasts a unique `boot` value, the overlay sees the change, shows the
135
135
  "restarted" note and does not reset its own state.
136
136
 
137
+ ## The live channel
138
+
139
+ Everything the overlay shows — statistics, live reload and CSS hot-swap events —
140
+ arrives over a single WebSocket (`<devBasePath>/ws`). The panel used to poll for
141
+ statistics every two seconds, so every open tab kept hitting the server even
142
+ while the panel was closed. Now the server pushes as things change: when a
143
+ request or an error is recorded (coalesced over 120 ms), once per second while
144
+ prewarming runs, and every four seconds otherwise so uptime and memory stay
145
+ fresh. Nothing is computed when no panel is connected.
146
+
147
+ The handshake happens on the HTTP `upgrade` event, and that event never reaches
148
+ the middleware chain, so the channel is attached straight to the server after
149
+ `listen` (`attachDevSocket`). The server side pulls in no dependency such as
150
+ `ws`: all it needs is to write server-to-client text frames and to answer the
151
+ client's ping/close frames.
152
+
153
+ If the socket cannot be opened at all (a proxy in between may not pass WebSocket
154
+ through), the overlay falls back to the old path: the `/events` SSE stream plus
155
+ polling `/stats`. If the socket opens and later drops — that is, the server is
156
+ restarting — it reconnects every half second and the indicator reads
157
+ "server restarting…" in the meantime.
158
+
137
159
  ## Devtools overlay
138
160
 
139
161
  A floating bubble in the bottom right; opened with `Alt+D`, closed with `Esc` or
@@ -240,8 +262,9 @@ Under `brand.devBasePath` (default `/__jskelet/dev`):
240
262
  | --- | --- | --- |
241
263
  | `/overlay.js` | GET | The overlay script |
242
264
  | `/logo.png` | GET | The overlay logo |
243
- | `/events` | GET | SSE: live reload and CSS hot-swap events |
244
- | `/stats` | GET | Current statistics (the overlay polls every 2 seconds) |
265
+ | `/ws` | GET (upgrade) | Live channel: statistics, live reload and CSS hot-swap events |
266
+ | `/events` | GET | SSE: the fallback event stream, used only when WebSocket cannot be established |
267
+ | `/stats` | GET | Current statistics; the data endpoint of that same fallback |
245
268
  | `/report` | GET | The report page (HTML) |
246
269
  | `/report.js` | GET | The report page's script |
247
270
  | `/report/data` | GET | The report's single data source (JSON) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",