jskelet 0.1.4 → 0.1.6

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
@@ -10,6 +10,24 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a
14
+ path, the config pattern syntax (`/news/:slug`), a regular expression or a list
15
+ of them, and returns how many entries were affected. By default it **stales**
16
+ the entries rather than deleting them, so a webhook that touches hundreds of
17
+ pages does not turn into hundreds of cold renders at the worst possible moment:
18
+ visitors keep getting the old HTML while the refresh runs in the background,
19
+ once per key. Matching is done against the path, so every query variant of a
20
+ page is covered by one call, and a render already in flight when the purge
21
+ arrives is not stored.
22
+ - `clearDataCache()` now refreshes the HTML too. The `withDataCache` keys read
23
+ during a render are recorded, so dropping `news:abc` stales every page that
24
+ actually read it — the article, the home page listing it and the tag page —
25
+ without the application declaring any tags. Turn it off with
26
+ `cache().trackDependencies: false`; `getHtmlCacheEntries()` reports the
27
+ dependency count per page as `deps`.
28
+ - Invalidated paths go to the front of the next prewarm pass, so an updated page
29
+ is refreshed without waiting for a visitor, while still respecting the `rps`
30
+ limit. The pass summary counts them separately.
13
31
  - An upstream data cache: `withDataCache(key, ttlSeconds, producer)` and the
14
32
  `dataCache(fn, { key, revalidate })` wrapper, with `clearDataCache(prefix?)`,
15
33
  `getDataCacheSize()` and `getDataCacheEntries()` alongside them. It keeps JSON
@@ -47,6 +65,17 @@ one is listed under a **Breaking** heading.
47
65
 
48
66
  ### Changed
49
67
 
68
+ - The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
69
+ polling `/stats` every two seconds. The server pushes statistics as they change
70
+ and sends live reload and CSS hot-swap events over the same connection, so an
71
+ open tab no longer keeps hitting the server while the panel is closed. No new
72
+ dependency is involved; if the socket cannot be opened, the panel falls back to
73
+ the previous SSE plus polling path.
74
+ - Prewarming no longer holds up the rest of the dev server. In development it now
75
+ runs with a single worker and a default limit of 4 requests per second
76
+ (`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev
77
+ panel stay responsive while a warm-up round is going on. Production behaviour
78
+ is unchanged.
50
79
  - `notFound()` is no longer served as a 404 when a transient upstream failure
51
80
  (`429`, `5xx`, network error) happened during the same render. The page is
52
81
  retried first and, if upstream is still failing, responds with an uncached
package/docs/06-cache.md CHANGED
@@ -306,8 +306,9 @@ Yönetim yüzeyi:
306
306
  | `getDataCacheEntries()` | Döküm: `{ key, stale, expiresIn }`. Değerin kendisi dönmez. |
307
307
 
308
308
  `clearDataCache("haber:")`, "bu içerik güncellendi" webhook'unun karşılığıdır:
309
- tek bir bölümün verisini düşürüp HTML'in bir sonraki tazelemesinde yeni içeriği
310
- almasını sağlar.
309
+ tek bir bölümün verisini düşürür ve **o veriyi okumuş HTML sayfalarını da**
310
+ bayatlatır, yani güncelleme TTL beklenmeden görünür. Ayrıntısı aşağıda,
311
+ "Otomatik bağımlılık" bölümünde.
311
312
 
312
313
  ## Degraded render: `reportUpstreamFailure`
313
314
 
@@ -437,9 +438,70 @@ düşer.
437
438
  | Fonksiyon | Ne yapar |
438
439
  | --- | --- |
439
440
  | `withHtmlCache(key, ttlSeconds, producer)` | Önbelleği doğrudan kullanmak için. `ttlSeconds` 0 ise producer her zaman çalışır. |
441
+ | `invalidateHtmlCache(target, options?)` | Eşleşen sayfaları bayatlatır (ya da `{ hard: true }` ile düşürür), etkilenen sayı döner. |
440
442
  | `clearHtmlCache()` | Store'u tamamen boşaltır. |
441
443
  | `getHtmlCacheSize()` | Girdi sayısı. |
442
- | `getHtmlCacheEntries()` | Döküm: `{ key, bytes, status, stale, expiresIn, encodings }`. HTML gövdesi dönmez, yalnızca boyutu. |
444
+ | `getHtmlCacheEntries()` | Döküm: `{ key, bytes, status, stale, expiresIn, encodings, deps }`. HTML gövdesi dönmez, yalnızca boyutu. |
445
+
446
+ ### Hedefli invalidation
447
+
448
+ TTL'i beklemekle tüm önbelleği boşaltmak arasındaki boşluğu `invalidateHtmlCache()`
449
+ doldurur:
450
+
451
+ ```js
452
+ import { invalidateHtmlCache } from "jskelet";
453
+
454
+ invalidateHtmlCache("/haber/abc"); // o yol ve altı
455
+ invalidateHtmlCache("/haber/:slug"); // desen sözdizimi
456
+ invalidateHtmlCache([/-yorumlar$/, "/"]); // RegExp ve liste
457
+ ```
458
+
459
+ Varsayılan davranış **bayatlatmaktır**, silmek değil: girdi süresi geçmiş
460
+ sayılır ve normal stale-while-revalidate yoluna düşer. Bir webhook beş yüz
461
+ sayfayı birden düşürdüğünde sert silme, tam da içeriğin güncellendiği anda beş
462
+ yüz soğuk render başlatır ve upstream'i döver. Bayatlatmada ziyaretçi eski
463
+ HTML'i beklemeden alır, tazeleme arkada ve anahtar başına tek seferde koşar.
464
+ Eski HTML'in gerçekten geçersiz olduğu durumlar için `{ hard: true }`.
465
+
466
+ Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir yolun
467
+ bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer. Düz
468
+ string'te önek segment sınırında kesilir — `/haber` kuralı `/haberler`i
469
+ etkilemez.
470
+
471
+ Uçuşta olan bir render de hedeflenir: purge'den önce başlamış bir tur, sonucu
472
+ artık eski veriyi taşıdığı için önbelleğe **yazılmaz** ve bir sonraki istek yeni
473
+ bir tur başlatır.
474
+
475
+ ### Otomatik bağımlılık: `clearDataCache` HTML'i de tazeler
476
+
477
+ Hangi sayfanın hangi içerikten etkilendiğini bildirmek gerekmez. Render
478
+ sırasında okunan her `withDataCache` anahtarı kaydedilir; `clearDataCache()` bir
479
+ anahtarı düşürdüğünde onu **fiilen okumuş** bütün HTML girdileri bayatlar.
480
+
481
+ ```js
482
+ // "bu haber güncellendi" webhook'u
483
+ clearDataCache(`haber:${slug}`);
484
+ ```
485
+
486
+ Bu tek satır haber detayını, o haberi listeleyen ana sayfayı ve etiket
487
+ sayfasını birlikte tazeler — çünkü üçü de o anahtarı okumuştu. Elle tag'lemede
488
+ en sık yapılan hata (detayı işaretleyip listeyi unutmak) burada yapısal olarak
489
+ mümkün değil: bildirim değil, gözlem var.
490
+
491
+ Ayrıntılar:
492
+
493
+ - Bağımlılık **her tazelemede yeniden** toplanır; sayfanın okuduğu anahtarlar
494
+ zamanla değişebilir.
495
+ - Render sürerken gelen bir purge de yakalanır: o turun çıktısı "doğduğu anda
496
+ bayat" olacağı için önbelleğe yazılmaz.
497
+ - Sayfa başına bağımlılık sayısı `getHtmlCacheEntries()` dökümünde `deps`
498
+ alanında görünür. Bir invalidation beklediğiniz sayfayı tazelemiyorsa önce
499
+ buraya bakın: sayfa o veriyi `withDataCache` üzerinden okumuyor olabilir.
500
+ - `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yoktur;
501
+ `cache().trackDependencies: false` ile izleme tamamen kapatılabilir.
502
+ - Bayatlatılan yollar ısıtma kuyruğunun **başına** alınır. `prewarm` kuruluysa
503
+ sayfa, ziyaretçi gelmesini beklemeden tazelenir ve tur özeti bunu ayırt eder:
504
+ `[prewarm] warmed 12/12 pages, 3 invalidated (0.4s)`.
443
505
 
444
506
  Bir yönetim ucu yazmak için:
445
507
 
@@ -514,10 +576,13 @@ Kurallar:
514
576
  1. Liste toplanır. `max`'tan (varsayılan 400) uzunsa bir dilim seçilir:
515
577
  `priority` eşleşenler **her turda** başa alınır, kalan yerler kuyruktan
516
578
  doldurulur.
517
- 2. `concurrency` işçi paralel olarak istek atar (prod'da 4, dev'de 2). Dev'de
518
- daha az paralellik: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
519
- için yarışmasın.
579
+ 2. `concurrency` işçi paralel olarak istek atar (prod'da 4, dev'de 1). Dev'de
580
+ tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU için
581
+ yarışmasın.
520
582
  3. `rps` verilmişse tur bu hızın üstüne çıkmaz — paralellik ne olursa olsun.
583
+ Dev'de varsayılan olarak saniyede 4 istek uygulanır: render tek bir olay
584
+ döngüsünde çalıştığı için aralıksız bir tur, sayfa isteklerini ve dev
585
+ panelinin canlı kanalını arkasında bekletiyor.
521
586
  4. Başarısız yollar için, `retryDelayMs` bekledikten sonra **tek seri tekrar
522
587
  turu** yapılır (`concurrency: 1`). Bekleme bilinçli: rate limit pencereleri
523
588
  saniye mertebesinde, hemen tekrar denemek aynı 429'u almak demek.
@@ -607,8 +672,8 @@ tek seferlik deneyler config'i düzenlemeden yapılabilsin.
607
672
  | --- | --- | --- | --- |
608
673
  | Açık/kapalı | `PREWARM=0` kapatır, `PREWARM=1` config'i ezip açar | `enabled` | `true` |
609
674
  | Tur başına en fazla yol | `PREWARM_MAX` | `max` | `400` |
610
- | Paralellik | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 2 |
611
- | Saniyedeki istek | `PREWARM_RPS` | `rps` | `0` (sınırsız) |
675
+ | Paralellik | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
676
+ | Saniyedeki istek | `PREWARM_RPS` | `rps` | prod `0` (sınırsız), dev 4 |
612
677
  | Başlangıç gecikmesi (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
613
678
  | Tekrar turu gecikmesi (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
614
679
  | Periyot (saniye) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (kapalı) |
@@ -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, trackUpstream?: boolean, transientRetry?: object | false, prewarm?: object }` —
563
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, prewarm?: object }` —
564
564
  **Varsayılan:**
565
- `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
565
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
566
566
 
567
567
  ### `cache().html`
568
568
 
@@ -607,6 +607,16 @@ Açıkken `globalThis.fetch` sarılır ve render sırasındaki geçici upstream
607
607
  hataları (`429`, `5xx`, ağ) kendiliğinden bildirilir; `reportUpstreamFailure()`
608
608
  çağırmak gerekmez. `fetch`i kendisi saran bir uygulama bunu kapatabilir.
609
609
 
610
+ ### `cache().trackDependencies`
611
+
612
+ **Tip:** `boolean` — **Varsayılan:** `true`
613
+
614
+ Açıkken bir render'ın okuduğu `withDataCache` anahtarları kaydedilir ve
615
+ `clearDataCache()` o veriyi okumuş HTML sayfalarını da bayatlatır — hedefli
616
+ invalidation için uygulamanın hiçbir şey bildirmesi gerekmez
617
+ ([06-cache.md](./06-cache.md)). `withDataCache` kullanmayan bir uygulamada
618
+ kaydedilecek bir şey yok; kapatmak bağlam kurma maliyetini de kaldırır.
619
+
610
620
  ### `cache().transientRetry`
611
621
 
612
622
  **Tip:** `{ attempts?: number, delayMs?: number } | false` —
@@ -623,8 +633,8 @@ Ayrıntı: [06-cache.md](./06-cache.md).
623
633
  | --- | --- | --- | --- |
624
634
  | `enabled` | `boolean` | `true` | `false` ise ısıtma yapılmaz (`PREWARM=1` ile ezilebilir) |
625
635
  | `max` | `number` | `400` | Bir turda en fazla kaç yol ısıtılır |
626
- | `concurrency` | `number` | prod 4, dev 2 | Paralel işçi sayısı |
627
- | `rps` | `number` | `0` | Saniyedeki en fazla ısıtma isteği; `0` sınırsız. Upstream kotasını koruyan ayar bu. |
636
+ | `concurrency` | `number` | prod 4, dev 1 | Paralel işçi sayısı |
637
+ | `rps` | `number` | prod `0`, dev 4 | Saniyedeki en fazla ısıtma isteği; `0` sınırsız. Upstream kotasını koruyan ayar bu. Dev'deki varsayılan fren, ısıtmanın sayfa isteklerini bekletmemesi için. |
628
638
  | `delayMs` | `number` | prod 500, dev 3000 | Açılıştan sonra ilk turun gecikmesi |
629
639
  | `retryDelayMs` | `number` | `2000` | Tekrar turundan önce beklenen süre |
630
640
  | `intervalSeconds` | `number` | `0` | 0'dan büyükse tur periyodik tekrarlanır |
@@ -742,7 +752,7 @@ basılmaz.
742
752
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | Ayarlıysa token taşımayan her isteğe 404 döner. Isıtma token'ı çerez olarak taşır. [09](./09-dev-araclari.md) |
743
753
  | `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar |
744
754
  | `PREWARM_MAX` | `prewarm` | `400` | En fazla kaç yol ısıtılır |
745
- | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 2 | Paralel işçi sayısı |
755
+ | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Paralel işçi sayısı |
746
756
  | `PREWARM_RPS` | `prewarm` | `0` | Saniyedeki en fazla ısıtma isteği; `0` sınırsız |
747
757
  | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | İlk turun gecikmesi |
748
758
  | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | Tekrar turundan önceki bekleme |
@@ -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,29 @@ 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
+ ve zamana bağlı alanlar (uptime, bellek, ısıtma sayacı) tazelensin diye iki
142
+ saniyede bir. Kalp atışı bilinçli olarak ısıtmadan bağımsız: kanalın temposu bir
143
+ arka plan işine göre değişirse panel de o işin ritmine bağlanmış olur. Bağlı
144
+ panel yoksa hiçbir şey hesaplanmaz.
145
+
146
+ El sıkışma HTTP `upgrade` olayında geçtiği ve o olay middleware zincirine hiç
147
+ uğramadığı için kanal `listen` sonrası doğrudan sunucuya bağlanır
148
+ (`attachDevSocket`). Sunucu tarafı `ws` gibi bir bağımlılık kullanmaz: yalnızca
149
+ sunucu→istemci metin çerçevesi yazmak ve istemcinin ping/close çerçevelerini
150
+ yanıtlamak gerekiyor.
151
+
152
+ Soket hiç açılamazsa (araya giren bir proxy WebSocket'i geçirmiyor olabilir)
153
+ overlay eski yola düşer: `/events` SSE akışı + `/stats` yoklaması. Soket kurulup
154
+ sonra düşerse — yani sunucu yeniden başlıyorsa — yarım saniyede bir yeniden
155
+ bağlanır ve gösterge bu sırada "bağlantı yok" der.
156
+
134
157
  ## Devtools overlay
135
158
 
136
159
  Sağ altta yüzen bir baloncuk; `Alt+D` ile açılır, `Esc` ya da karartma alanına
@@ -236,8 +259,9 @@ Rapor katmanı yalnızca development'ta yüklenir, üretim çıktısına hiç gi
236
259
  | --- | --- | --- |
237
260
  | `/overlay.js` | GET | Overlay script'i |
238
261
  | `/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) |
262
+ | `/ws` | GET (upgrade) | Canlı kanal: istatistikler, live reload ve CSS hot-swap olayları |
263
+ | `/events` | GET | SSE: yalnızca WebSocket kurulamazsa kullanılan yedek olay akışı |
264
+ | `/stats` | GET | Anlık istatistikler; aynı yedek yolun veri ucu |
241
265
  | `/report` | GET | Rapor sayfası (HTML) |
242
266
  | `/report.js` | GET | Rapor sayfasının script'i |
243
267
  | `/report/data` | GET | Raporun tek veri kaynağı (JSON) |
@@ -294,7 +318,8 @@ yayına açılmamış bir ortam yönlendirme kurallarını bile dışarıya sız
294
318
  | Bozuk route modülü | Uyarı + atla | Fırlat |
295
319
  | Devtools ve rapor | Mount edilir | Hiç yüklenmez |
296
320
  | `globalThis.fetch` | Sarılır (ölçüm) | Dokunulmaz |
297
- | Prewarm paralelliği | 2 | 4 |
321
+ | Prewarm paralelliği | 1 | 4 |
322
+ | Prewarm hız freni | saniyede 4 istek | Sınırsız |
298
323
  | Prewarm gecikmesi | 3000 ms | 500 ms |
299
324
  | Eksik ikon uyarısı | Verilir | Verilmez |
300
325
  | Precompress | Watch'ta çalışmaz | Çalışır |
package/docs/11-tasima.md CHANGED
@@ -52,7 +52,8 @@ alt kümesine benzetildi — `next.config` sözdizimi, Metadata API, `notFound()
52
52
  | `fetch(..., { next: { revalidate } })` | — | Önbellek sayfa düzeyinde |
53
53
  | `unstable_cache` | — | İstek üstü veri önbelleği yok; sayfa önbelleği var |
54
54
  | React `cache()` | `cache()` | Aynı davranış: istek içi memoizasyon |
55
- | `revalidatePath()` | `clearHtmlCache()` | Şu anda tek tek anahtar geçersizleme yok |
55
+ | `revalidatePath()` | `invalidateHtmlCache("/haber/:slug")` | Yol, desen ya da RegExp; varsayılan olarak siler değil bayatlatır |
56
+ | `revalidateTag()` | `clearDataCache("haber:")` | Tag bildirmeye gerek yok: bağımlılık render sırasında gözlenir ([06](./06-cache.md)) |
56
57
  | `cookies()`, `headers()` | `ctx.req.headers`, `ctx.req.cookies`* | Express nesnesine doğrudan erişim |
57
58
  | `dynamic = "force-dynamic"` | `revalidate` vermemek | Önbellek kapalı demek |
58
59
 
@@ -94,8 +95,6 @@ Bunları taşıma planında baştan hesaba katın:
94
95
  önce belgeyi hazırlar, `viewTransition` geçişi yumuşatır
95
96
  ([07](./07-yapilandirma.md)).
96
97
  - **Server Actions.** Form gönderimleri normal `app.post(...)` handler'larıdır.
97
- - **Tek tek yol geçersizleme (`revalidatePath`).** Şimdilik tüm önbelleği
98
- temizlemek (`clearHtmlCache()`) ya da TTL'in dolmasını beklemek var.
99
98
  - **Otomatik görsel optimizasyonu (istek anında).** Optimizasyon build zamanında
100
99
  yapılır ve yalnızca `public/` altındaki yerel görselleri kapsar; uzak görseller
101
100
  olduğu gibi basılır.
@@ -315,8 +315,9 @@ The management surface:
315
315
  | `getDataCacheEntries()` | A dump: `{ key, stale, expiresIn }`. The value itself is not returned. |
316
316
 
317
317
  `clearDataCache("news:")` is the counterpart of a "this content was updated"
318
- webhook: it drops one section's data so the next HTML refresh picks up the new
319
- content.
318
+ webhook: it drops one section's data **and stales the HTML pages that read it**,
319
+ so the update shows up without waiting for a TTL. See "Automatic dependencies"
320
+ below.
320
321
 
321
322
  ## Degraded render: `reportUpstreamFailure`
322
323
 
@@ -447,9 +448,71 @@ through to the 503.
447
448
  | Function | What it does |
448
449
  | --- | --- |
449
450
  | `withHtmlCache(key, ttlSeconds, producer)` | For using the cache directly. If `ttlSeconds` is 0 the producer always runs. |
451
+ | `invalidateHtmlCache(target, options?)` | Stales the matching pages (or drops them with `{ hard: true }`) and returns how many were affected. |
450
452
  | `clearHtmlCache()` | Empties the store completely. |
451
453
  | `getHtmlCacheSize()` | The number of entries. |
452
- | `getHtmlCacheEntries()` | A dump: `{ key, bytes, status, stale, expiresIn, encodings }`. The HTML body is not returned, only its size. |
454
+ | `getHtmlCacheEntries()` | A dump: `{ key, bytes, status, stale, expiresIn, encodings, deps }`. The HTML body is not returned, only its size. |
455
+
456
+ ### Targeted invalidation
457
+
458
+ `invalidateHtmlCache()` fills the gap between waiting for the TTL and flushing
459
+ the whole cache:
460
+
461
+ ```js
462
+ import { invalidateHtmlCache } from "jskelet";
463
+
464
+ invalidateHtmlCache("/news/abc"); // that path and everything under it
465
+ invalidateHtmlCache("/news/:slug"); // the pattern syntax
466
+ invalidateHtmlCache([/-comments$/, "/"]); // regexps and lists
467
+ ```
468
+
469
+ The default is to **stale** the entry, not to delete it: it is treated as
470
+ expired and falls through the normal stale-while-revalidate path. When a webhook
471
+ takes down five hundred pages at once, a hard delete starts five hundred cold
472
+ renders at exactly the moment the content changed, and hammers the upstream.
473
+ Staling instead hands the visitor the old HTML without a wait, and the refresh
474
+ runs in the background, once per key. Use `{ hard: true }` when the old HTML is
475
+ genuinely invalid.
476
+
477
+ Since the key is `path?query`, matching is done against the **path**: every
478
+ query variant of a path (including `?utm_source=…`) is covered by one call. For
479
+ a plain string the prefix stops at a segment boundary — a `/news` rule does not
480
+ touch `/newsletter`.
481
+
482
+ An in-flight render is targeted too: a pass that started before the purge is
483
+ carrying data that is now out of date, so it is **not** stored and the next
484
+ request starts a fresh pass.
485
+
486
+ ### Automatic dependencies: `clearDataCache` refreshes the HTML too
487
+
488
+ You do not have to declare which page is affected by which content. Every
489
+ `withDataCache` key read during a render is recorded, and when `clearDataCache()`
490
+ drops a key, every HTML entry that **actually read it** is staled.
491
+
492
+ ```js
493
+ // the "this article changed" webhook
494
+ clearDataCache(`news:${slug}`);
495
+ ```
496
+
497
+ That single line refreshes the article page, the home page that lists it and the
498
+ tag page together — because all three read that key. The most common mistake in
499
+ manual tagging (marking the detail page and forgetting the listing) is
500
+ structurally impossible here: nothing is declared, everything is observed.
501
+
502
+ Details:
503
+
504
+ - Dependencies are collected **on every refresh**, since the keys a page reads
505
+ can change over time.
506
+ - A purge that lands while a render is in flight is caught as well: that pass
507
+ would be stale the moment it was born, so it is not stored.
508
+ - The dependency count per page shows up as `deps` in the `getHtmlCacheEntries()`
509
+ dump. If an invalidation is not refreshing the page you expected, look there
510
+ first: the page may not be reading that data through `withDataCache`.
511
+ - An application that does not use `withDataCache` has nothing to record;
512
+ tracking can be turned off entirely with `cache().trackDependencies: false`.
513
+ - Staled paths go to the **front** of the prewarm queue. If `prewarm` is set up
514
+ the page is refreshed without waiting for a visitor, and the pass summary says
515
+ so: `[prewarm] warmed 12/12 pages, 3 invalidated (0.4s)`.
453
516
 
454
517
  To write an admin endpoint:
455
518
 
@@ -528,11 +591,13 @@ Rules:
528
591
  1. The list is collected. If it is longer than `max` (400 by default) a slice is
529
592
  selected: the paths matching `priority` are taken first **on every round**,
530
593
  and the remaining slots are filled from the queue.
531
- 2. `concurrency` workers send requests in parallel (4 in prod, 2 in dev). Less
532
- parallelism in dev: so the scan does not compete for CPU with the render of
594
+ 2. `concurrency` workers send requests in parallel (4 in prod, 1 in dev). A
595
+ single worker in dev: so the scan does not compete for CPU with the render of
533
596
  the page you currently have open in the browser.
534
597
  3. If `rps` is given, the round never goes above that rate — no matter the
535
- parallelism.
598
+ parallelism. In dev, 4 requests per second apply by default: rendering runs
599
+ on a single event loop, so an unpaced round leaves page requests and the dev
600
+ panel's live channel waiting behind it.
536
601
  4. **A single serial retry round** is performed for the failed paths after
537
602
  waiting `retryDelayMs` (`concurrency: 1`). The wait is deliberate: rate limit
538
603
  windows are on the order of seconds, so retrying immediately just earns the
@@ -623,8 +688,8 @@ comes first so that one-off experiments can be done without editing the config.
623
688
  | --- | --- | --- | --- |
624
689
  | On/off | `PREWARM=0` disables it, `PREWARM=1` overrides the config and enables it | `enabled` | `true` |
625
690
  | Maximum paths per round | `PREWARM_MAX` | `max` | `400` |
626
- | Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 2 |
627
- | Requests per second | `PREWARM_RPS` | `rps` | `0` (unlimited) |
691
+ | Parallelism | `PREWARM_CONCURRENCY` | `concurrency` | prod 4, dev 1 |
692
+ | Requests per second | `PREWARM_RPS` | `rps` | prod `0` (unlimited), dev 4 |
628
693
  | Startup delay (ms) | `PREWARM_DELAY_MS` | `delayMs` | prod 500, dev 3000 |
629
694
  | Retry round delay (ms) | `PREWARM_RETRY_DELAY_MS` | `retryDelayMs` | `2000` |
630
695
  | Period (seconds) | `PREWARM_INTERVAL_SECONDS` | `intervalSeconds` | `0` (off) |
@@ -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, trackUpstream?: boolean, transientRetry?: object | false, prewarm?: object }` —
575
+ `() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, prewarm?: object }` —
576
576
  **Default:**
577
- `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
577
+ `{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
578
578
 
579
579
  ### `cache().html`
580
580
 
@@ -622,6 +622,17 @@ When on, `globalThis.fetch` is wrapped and transient upstream failures (`429`,
622
622
  `reportUpstreamFailure()` is not required. An application that wraps `fetch`
623
623
  itself can turn this off.
624
624
 
625
+ ### `cache().trackDependencies`
626
+
627
+ **Type:** `boolean` — **Default:** `true`
628
+
629
+ When on, the `withDataCache` keys a render reads are recorded, and
630
+ `clearDataCache()` also stales the HTML pages that read that data — targeted
631
+ invalidation without the application declaring anything
632
+ ([06-caching.md](./06-caching.md)). An application that does not use
633
+ `withDataCache` has nothing to record; turning this off also removes the cost of
634
+ setting up the context.
635
+
625
636
  ### `cache().transientRetry`
626
637
 
627
638
  **Type:** `{ attempts?: number, delayMs?: number } | false` —
@@ -638,8 +649,8 @@ a 404; if the retries are exhausted the response is an uncached 503. `false` or
638
649
  | --- | --- | --- | --- |
639
650
  | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
640
651
  | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
641
- | `concurrency` | `number` | prod 4, dev 2 | Number of parallel workers |
642
- | `rps` | `number` | `0` | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. |
652
+ | `concurrency` | `number` | prod 4, dev 1 | Number of parallel workers |
653
+ | `rps` | `number` | prod `0`, dev 4 | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. The default brake in dev keeps prewarming from holding page requests up. |
643
654
  | `delayMs` | `number` | prod 500, dev 3000 | Delay of the first pass after startup |
644
655
  | `retryDelayMs` | `number` | `2000` | How long to wait before the retry pass |
645
656
  | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
@@ -758,7 +769,7 @@ and no warning is printed.
758
769
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
759
770
  | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
760
771
  | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
761
- | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 2 | Number of parallel workers |
772
+ | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
762
773
  | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
763
774
  | `PREWARM_DELAY_MS` | `startPrewarm` | prod 500, dev 3000 | Delay of the first pass |
764
775
  | `PREWARM_RETRY_DELAY_MS` | `prewarm` | `2000` | The wait before the retry pass |
@@ -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,30 @@ 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), and every two seconds so
144
+ the time-based fields (uptime, memory, the prewarm counter) stay fresh. The
145
+ heartbeat is deliberately independent of prewarming: if the channel's tempo
146
+ followed a background job, the panel would be tied to that job's rhythm. Nothing
147
+ is computed when no panel is connected.
148
+
149
+ The handshake happens on the HTTP `upgrade` event, and that event never reaches
150
+ the middleware chain, so the channel is attached straight to the server after
151
+ `listen` (`attachDevSocket`). The server side pulls in no dependency such as
152
+ `ws`: all it needs is to write server-to-client text frames and to answer the
153
+ client's ping/close frames.
154
+
155
+ If the socket cannot be opened at all (a proxy in between may not pass WebSocket
156
+ through), the overlay falls back to the old path: the `/events` SSE stream plus
157
+ polling `/stats`. If the socket opens and later drops — that is, the server is
158
+ restarting — it reconnects every half second and the indicator reads
159
+ "server restarting…" in the meantime.
160
+
137
161
  ## Devtools overlay
138
162
 
139
163
  A floating bubble in the bottom right; opened with `Alt+D`, closed with `Esc` or
@@ -240,8 +264,9 @@ Under `brand.devBasePath` (default `/__jskelet/dev`):
240
264
  | --- | --- | --- |
241
265
  | `/overlay.js` | GET | The overlay script |
242
266
  | `/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) |
267
+ | `/ws` | GET (upgrade) | Live channel: statistics, live reload and CSS hot-swap events |
268
+ | `/events` | GET | SSE: the fallback event stream, used only when WebSocket cannot be established |
269
+ | `/stats` | GET | Current statistics; the data endpoint of that same fallback |
245
270
  | `/report` | GET | The report page (HTML) |
246
271
  | `/report.js` | GET | The report page's script |
247
272
  | `/report/data` | GET | The report's single data source (JSON) |
@@ -301,7 +326,8 @@ redirect rules.
301
326
  | Broken route module | Warn + skip | Throw |
302
327
  | Devtools and report | Mounted | Never loaded |
303
328
  | `globalThis.fetch` | Wrapped (measurement) | Untouched |
304
- | Prewarm concurrency | 2 | 4 |
329
+ | Prewarm concurrency | 1 | 4 |
330
+ | Prewarm rate limit | 4 requests/second | Unlimited |
305
331
  | Prewarm delay | 3000 ms | 500 ms |
306
332
  | Missing icon warning | Emitted | Not emitted |
307
333
  | Precompress | Does not run in watch | Runs |
@@ -53,7 +53,8 @@ will feel familiar. The *reasons* behind the differences are in
53
53
  | `fetch(..., { next: { revalidate } })` | — | The cache is at page level |
54
54
  | `unstable_cache` | — | No cross-request data cache; there is a page cache |
55
55
  | React `cache()` | `cache()` | Same behavior: in-request memoization |
56
- | `revalidatePath()` | `clearHtmlCache()` | There is currently no per-key invalidation |
56
+ | `revalidatePath()` | `invalidateHtmlCache("/news/:slug")` | A path, a pattern or a regexp; stales rather than deletes by default |
57
+ | `revalidateTag()` | `clearDataCache("news:")` | No tags to declare: the dependency is observed during the render ([06](./06-caching.md)) |
57
58
  | `cookies()`, `headers()` | `ctx.req.headers`, `ctx.req.cookies`* | Direct access to the Express object |
58
59
  | `dynamic = "force-dynamic"` | Not passing `revalidate` | Which means the cache is off |
59
60
 
@@ -97,8 +98,6 @@ Account for these from the start in your migration plan:
97
98
  `viewTransition` smooths the transition
98
99
  ([07](./07-configuration.md)).
99
100
  - **Server Actions.** Form submissions are ordinary `app.post(...)` handlers.
100
- - **Per-path invalidation (`revalidatePath`).** For now there is clearing the
101
- whole cache (`clearHtmlCache()`) or waiting for the TTL to expire.
102
101
  - **Automatic image optimization (at request time).** Optimization happens at
103
102
  build time and only covers local images under `public/`; remote images are
104
103
  emitted as-is.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",