jskelet 0.1.5 → 0.1.7

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
@@ -53,6 +71,11 @@ one is listed under a **Breaking** heading.
53
71
  open tab no longer keeps hitting the server while the panel is closed. No new
54
72
  dependency is involved; if the socket cannot be opened, the panel falls back to
55
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.
56
79
  - `notFound()` is no longer served as a 404 when a transient upstream failure
57
80
  (`429`, `5xx`, network error) happened during the same render. The page is
58
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 |
@@ -138,8 +138,10 @@ tek bir WebSocket üzerinden gelir (`<devBasePath>/ws`). Panel eskiden
138
138
  istatistikleri iki saniyede bir çekiyordu; açık her sekme, panel kapalıyken bile
139
139
  sunucuya sürekli istek atıyordu. Artık sunucu değişiklik oldukça iter: bir istek
140
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.
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.
143
145
 
144
146
  El sıkışma HTTP `upgrade` olayında geçtiği ve o olay middleware zincirine hiç
145
147
  uğramadığı için kanal `listen` sonrası doğrudan sunucuya bağlanır
@@ -147,10 +149,13 @@ uğramadığı için kanal `listen` sonrası doğrudan sunucuya bağlanır
147
149
  sunucu→istemci metin çerçevesi yazmak ve istemcinin ping/close çerçevelerini
148
150
  yanıtlamak gerekiyor.
149
151
 
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.
152
+ Soket kurulup sonra düşerse — yani sunucu yeniden başlıyorsa — yarım saniyede
153
+ bir yeniden bağlanır ve gösterge bu sırada "bağlantı yok" der. Hiç açılamazsa
154
+ aralığı kademeli açarak dört kez denenir; sayfa, sunucunun yeniden başlama
155
+ penceresinde açılmış olabilir ve tek bir başarısızlık kanalın çalışmadığı
156
+ anlamına gelmez. Denemeler de tutmazsa (araya giren bir proxy WebSocket'i
157
+ geçirmiyor olabilir) overlay eski yola düşer: `/events` SSE akışı + `/stats`
158
+ yoklaması.
154
159
 
155
160
  ## Devtools overlay
156
161
 
@@ -316,7 +321,8 @@ yayına açılmamış bir ortam yönlendirme kurallarını bile dışarıya sız
316
321
  | Bozuk route modülü | Uyarı + atla | Fırlat |
317
322
  | Devtools ve rapor | Mount edilir | Hiç yüklenmez |
318
323
  | `globalThis.fetch` | Sarılır (ölçüm) | Dokunulmaz |
319
- | Prewarm paralelliği | 2 | 4 |
324
+ | Prewarm paralelliği | 1 | 4 |
325
+ | Prewarm hız freni | saniyede 4 istek | Sınırsız |
320
326
  | Prewarm gecikmesi | 3000 ms | 500 ms |
321
327
  | Eksik ikon uyarısı | Verilir | Verilmez |
322
328
  | 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 |
@@ -140,9 +140,11 @@ Everything the overlay shows — statistics, live reload and CSS hot-swap events
140
140
  arrives over a single WebSocket (`<devBasePath>/ws`). The panel used to poll for
141
141
  statistics every two seconds, so every open tab kept hitting the server even
142
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.
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.
146
148
 
147
149
  The handshake happens on the HTTP `upgrade` event, and that event never reaches
148
150
  the middleware chain, so the channel is attached straight to the server after
@@ -150,11 +152,13 @@ the middleware chain, so the channel is attached straight to the server after
150
152
  `ws`: all it needs is to write server-to-client text frames and to answer the
151
153
  client's ping/close frames.
152
154
 
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.
155
+ If the socket opens and later drops — that is, the server is restarting — it
156
+ reconnects every half second and the indicator reads "server restarting…" in the
157
+ meantime. If it never opens, it is retried four times with a widening gap: the
158
+ page may have been loaded during the server's restart window, and a single
159
+ failure does not mean the channel is unavailable. If those attempts fail too (a
160
+ proxy in between may not pass WebSocket through), the overlay falls back to the
161
+ old path: the `/events` SSE stream plus polling `/stats`.
158
162
 
159
163
  ## Devtools overlay
160
164
 
@@ -324,7 +328,8 @@ redirect rules.
324
328
  | Broken route module | Warn + skip | Throw |
325
329
  | Devtools and report | Mounted | Never loaded |
326
330
  | `globalThis.fetch` | Wrapped (measurement) | Untouched |
327
- | Prewarm concurrency | 2 | 4 |
331
+ | Prewarm concurrency | 1 | 4 |
332
+ | Prewarm rate limit | 4 requests/second | Unlimited |
328
333
  | Prewarm delay | 3000 ms | 500 ms |
329
334
  | Missing icon warning | Emitted | Not emitted |
330
335
  | 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.5",
3
+ "version": "0.1.7",
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",
@@ -397,6 +397,15 @@ let restarts = 0;
397
397
  * yenileme yapılır; overlay durumu sekme belleğinde durduğu için panel açık
398
398
  * kalmaya devam eder.
399
399
  */
400
+ /**
401
+ * Hiç açılmadan üst üste bu kadar denemeden sonra kanal yerine yedek yola
402
+ * geçilir. Tek bir başarısızlık yetmiyor: sayfa, sunucunun yeniden başlama
403
+ * penceresinde açılmış olabilir ve o an soket kurulamaz.
404
+ */
405
+ const SOCKET_ATTEMPTS = 4;
406
+
407
+ let socketFailures = 0;
408
+
400
409
  function connectSocket() {
401
410
  let socket;
402
411
  try {
@@ -408,12 +417,11 @@ function connectSocket() {
408
417
  return;
409
418
  }
410
419
 
411
- // Soket hiç açılamazsa (proxy WebSocket'i geçirmiyor olabilir) eski
412
- // SSE + yoklama yoluna düşülür; dev akışı bir ara katman yüzünden körelmesin.
413
420
  let opened = false;
414
421
 
415
422
  socket.addEventListener("open", () => {
416
423
  opened = true;
424
+ socketFailures = 0;
417
425
  });
418
426
 
419
427
  socket.addEventListener("message", (event) => {
@@ -421,18 +429,31 @@ function connectSocket() {
421
429
  });
422
430
 
423
431
  socket.addEventListener("close", () => {
424
- if (!opened) {
425
- startFallback();
426
- return;
427
- }
428
-
429
- // Sunucu yeniden başlıyor: gösterge "bağlantı yok"a döner ve kısa aralıkla
432
+ // Sunucu yeniden başlıyor olabilir: gösterge "bağlantı yok"a döner ve
430
433
  // yeniden denenir. Açılışta gelen `hello` yeniden başlatmayı bildirir.
431
434
  if (!offline) {
432
435
  offline = true;
433
436
  render();
434
437
  }
435
- setTimeout(connectSocket, 500);
438
+
439
+ if (opened) {
440
+ socketFailures = 0;
441
+ setTimeout(connectSocket, 500);
442
+ return;
443
+ }
444
+
445
+ socketFailures += 1;
446
+
447
+ // Kanal hiç kurulamıyorsa araya giren bir katman WebSocket'i geçirmiyor
448
+ // demektir; dev akışı bunun yüzünden körelmesin diye eski yola düşülür.
449
+ if (socketFailures >= SOCKET_ATTEMPTS) {
450
+ startFallback();
451
+ return;
452
+ }
453
+
454
+ // Yeniden başlatma birkaç saniye sürebiliyor; aralık kademeli açılır ki
455
+ // tarayıcı konsolu başarısız bağlantı satırlarıyla dolmasın.
456
+ setTimeout(connectSocket, socketFailures * 700);
436
457
  });
437
458
  }
438
459
 
@@ -70,6 +70,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
70
70
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
71
71
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
72
72
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
73
+ * @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
73
74
  * @property {{ attempts: number, delayMs: number }} transientRetry
74
75
  * @property {Record<string, unknown>} prewarm
75
76
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
@@ -210,6 +211,7 @@ function normalizePriority(raw) {
210
211
  * @param {unknown} raw
211
212
  * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
212
213
  * data: Record<string, unknown>, trackUpstream: boolean,
214
+ * trackDependencies: boolean,
213
215
  * transientRetry: { attempts: number, delayMs: number },
214
216
  * prewarm: Record<string, unknown>,
215
217
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
@@ -238,6 +240,10 @@ function normalizeCache(raw) {
238
240
  // Otomatik upstream izleme kapatılabilir olmalı: `fetch`i kendisi saran
239
241
  // bir uygulama (ölçüm, retry, circuit breaker) çakışma yaşayabilir.
240
242
  trackUpstream: raw?.trackUpstream !== false,
243
+ // Hangi sayfanın hangi veri anahtarını okuduğu kaydedilsin mi.
244
+ // `withDataCache` kullanmayan bir uygulamada kaydedilecek bir şey yok;
245
+ // kapatmak bağlam kurma maliyetini de kaldırır.
246
+ trackDependencies: raw?.trackDependencies !== false,
241
247
  transientRetry:
242
248
  raw?.transientRetry === false
243
249
  ? { attempts: 0, delayMs: 0 }
@@ -457,6 +463,7 @@ export async function loadConfig(options = {}) {
457
463
  htmlMaxEntries,
458
464
  data,
459
465
  trackUpstream,
466
+ trackDependencies,
460
467
  transientRetry,
461
468
  prewarm,
462
469
  prewarmPriority,
@@ -475,6 +482,7 @@ export async function loadConfig(options = {}) {
475
482
  htmlMaxEntries,
476
483
  data,
477
484
  trackUpstream,
485
+ trackDependencies,
478
486
  transientRetry,
479
487
  prewarm,
480
488
  prewarmPriority,
package/src/index.js CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  clearHtmlCache,
44
44
  getHtmlCacheEntries,
45
45
  getHtmlCacheSize,
46
+ invalidateHtmlCache,
46
47
  withHtmlCache,
47
48
  } from "./server/html-cache.js";
48
49
  export {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Bir render'ın hangi veri anahtarlarını okuduğunu kaydeder.
3
+ *
4
+ * `request-cache.js` ile aynı desen: `AsyncLocalStorage`, bağlam yoksa her şey
5
+ * sessizce devre dışı. Script'ten, cron'dan ya da istek dışı bir yerden yapılan
6
+ * `withDataCache` çağrıları hiçbir şeye yazılmaz.
7
+ *
8
+ * Neden gerekli: HTML önbelleğinin elinde "bu sayfa şu içerikten etkilenir"
9
+ * bilgisi yoktu, dolayısıyla bir içerik güncellendiğinde tek seçenek TTL'i
10
+ * beklemek ya da tüm önbelleği boşaltmaktı. Bağımlılığı uygulamanın elle
11
+ * bildirmesi (tag'lemek) ise en sık yapılan hatayı davet ediyor: detay
12
+ * sayfasını işaretleyip aynı içeriği listeleyen ana sayfayı unutmak.
13
+ *
14
+ * Burada bildirim yok, **gözlem** var: render sırasında fiilen okunan anahtarlar
15
+ * kaydedilir. Ana sayfa o veriyi okuduysa listede olur, okumadıysa olmaz.
16
+ */
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+
19
+ /** @type {AsyncLocalStorage<Set<string>>} */
20
+ const storage = new AsyncLocalStorage();
21
+
22
+ /**
23
+ * `run`'ı, içindeki `recordDependency()` çağrılarının `deps`'e yazacağı bir
24
+ * bağlamda çalıştırır.
25
+ *
26
+ * @template T
27
+ * @param {Set<string>} deps
28
+ * @param {() => T} run
29
+ * @returns {T}
30
+ */
31
+ export function collectDependencies(deps, run) {
32
+ return storage.run(deps, run);
33
+ }
34
+
35
+ /**
36
+ * Bu render'ın bir veri anahtarını okuduğunu bildirir. Bağlam yoksa no-op.
37
+ *
38
+ * @param {string} key
39
+ */
40
+ export function recordDependency(key) {
41
+ storage.getStore()?.add(key);
42
+ }
@@ -167,15 +167,17 @@ export async function startServer(options = {}) {
167
167
  console.error("[uncaughtException]", error);
168
168
  });
169
169
 
170
- return new Promise((resolve) => {
171
- const server = app.listen(port, host, async () => {
172
- // Dev panelinin canlı kanalı: el sıkışma `upgrade` olayında geçtiği
173
- // için middleware zincirine değil, doğrudan sunucuya bağlanır.
174
- if (process.env.NODE_ENV === "development") {
175
- const { attachDevSocket } = await import("./dev/devtools.js");
176
- attachDevSocket(server);
177
- }
170
+ // Dev panelinin canlı kanalı: el sıkışma `upgrade` olayında geçtiği için
171
+ // middleware zincirine değil, doğrudan sunucuya bağlanır. Modül `listen`den
172
+ // önce yüklenir; dinleme başladıktan sonra beklenen bir `await` kalırsa ilk
173
+ // upgrade isteği dinleyici yokken gelip reddedilebiliyor.
174
+ const attachDevSocket =
175
+ process.env.NODE_ENV === "development"
176
+ ? (await import("./dev/devtools.js")).attachDevSocket
177
+ : null;
178
178
 
179
+ return new Promise((resolve) => {
180
+ const server = app.listen(port, host, () => {
179
181
  // Bu satırın biçimi sözleşme: `jskelet dev` sunucunun hazır olduğunu
180
182
  // buradan anlar ve özet satırını ona göre basar.
181
183
  console.log(
@@ -184,5 +186,7 @@ export async function startServer(options = {}) {
184
186
  startPrewarm({ port });
185
187
  resolve(server);
186
188
  });
189
+
190
+ attachDevSocket?.(server);
187
191
  });
188
192
  }
@@ -19,6 +19,8 @@
19
19
  */
20
20
  import { getConfig } from "../config/index.js";
21
21
  import { DEFAULT_DATA_CACHE } from "../config/defaults.js";
22
+ import { recordDependency } from "./cache-deps.js";
23
+ import { invalidateHtmlByDependency } from "./html-cache.js";
22
24
 
23
25
  /**
24
26
  * @typedef {{ value: unknown, expiresAt: number, staleUntil: number }} DataEntry
@@ -144,6 +146,10 @@ function refresh(key, ttlSeconds, producer, options) {
144
146
  export async function withDataCache(key, ttlSeconds, producer, options = {}) {
145
147
  if (!ttlSeconds) return producer();
146
148
 
149
+ // Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
150
+ // sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
151
+ recordDependency(key);
152
+
147
153
  const hit = read(key);
148
154
 
149
155
  if (hit) {
@@ -202,24 +208,31 @@ export function dataCache(fn, options) {
202
208
  * Bir anahtarı ya da önek eşleşen tüm anahtarları düşürür. Webhook ile
203
209
  * "bu haber güncellendi" bilgisi geldiğinde kullanılır.
204
210
  *
211
+ * Düşen anahtarları **render sırasında okumuş** HTML girdileri de bayatlar:
212
+ * uygulamanın ayrıca `invalidateHtmlCache()` çağırması gerekmez ve aynı veriyi
213
+ * gösteren liste sayfalarını unutmak mümkün değildir (bkz. `cache-deps.js`).
214
+ *
205
215
  * @param {string} [prefix] Verilmezse tüm önbellek boşaltılır.
206
216
  * @returns {number} Silinen girdi sayısı.
207
217
  */
208
218
  export function clearDataCache(prefix) {
219
+ /** @type {string[]} */
220
+ const removed = [];
221
+
209
222
  if (prefix === undefined) {
210
- const size = store.size;
223
+ removed.push(...store.keys());
211
224
  store.clear();
212
- return size;
213
- }
214
-
215
- let removed = 0;
216
- for (const key of store.keys()) {
217
- if (key.startsWith(prefix)) {
218
- store.delete(key);
219
- removed += 1;
225
+ } else {
226
+ for (const key of store.keys()) {
227
+ if (key.startsWith(prefix)) {
228
+ store.delete(key);
229
+ removed.push(key);
230
+ }
220
231
  }
221
232
  }
222
- return removed;
233
+
234
+ if (removed.length) invalidateHtmlByDependency(removed);
235
+ return removed.length;
223
236
  }
224
237
 
225
238
  /** @returns {number} */
@@ -236,23 +236,18 @@ function pushStats() {
236
236
  }
237
237
 
238
238
  /**
239
- * Zamana bağlı alanlar (uptime, bellek) ve ısıtma ilerlemesi bir olay
240
- * üretmiyor; onlar için düşük frekanslı bir kalp atışı gerekiyor. Isıtma
241
- * sürerken sayaç akıcı görünsün diye sıklaşır.
239
+ * Zamana bağlı alanlar (uptime, bellek, ısıtma sayacı) bir olay üretmiyor;
240
+ * onlar için sabit bir kalp atışı var. Bilinçli olarak ısıtmadan bağımsız:
241
+ * kanalın temposu bir arka plan işine göre değişirse panel de o işin ritmine
242
+ * bağlanmış olur.
242
243
  *
243
244
  * Bağlı panel yokken hiçbir şey hesaplanmaz.
244
245
  */
245
246
  function startHeartbeat() {
246
- let tick = 0;
247
-
248
247
  const timer = setInterval(() => {
249
248
  if (!socketCount()) return;
250
-
251
- tick += 1;
252
- if (!prewarmProgress.active && tick % 4 !== 0) return;
253
-
254
249
  broadcastSocket(statsPayload());
255
- }, 1000);
250
+ }, 2000);
256
251
 
257
252
  timer.unref?.();
258
253
  }
@@ -6,14 +6,24 @@
6
6
  * render'ı beklemez; buna karşılık HTML'deki veri en fazla `revalidate + bir
7
7
  * tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
8
8
  * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
+ *
10
+ * TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
11
+ * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
12
+ * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
13
+ * TTL'i beklemek ise güncellemeyi dakikalarca geciktirir.
14
+ * `invalidateHtmlCache()` ikisinin arasını açar ve varsayılan davranışı
15
+ * **bayatlatmaktır**: girdi silinmez, süresi geçmiş sayılır. Ziyaretçi eski
16
+ * HTML'i beklemeden alır, tazeleme arkada tek seferde koşar.
9
17
  */
10
18
 
11
19
  import { getConfig } from "../config/index.js";
12
20
  import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
21
+ import { collectDependencies } from "./cache-deps.js";
22
+ import { compilePattern, matchPattern } from "../config/pattern.js";
13
23
 
14
24
  /**
15
25
  * @typedef {{ html: string, status: number, expiresAt: number,
16
- * staleUntil: number, encoded: Map<string, Buffer> }} HtmlEntry
26
+ * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string> }} HtmlEntry
17
27
  */
18
28
 
19
29
  /**
@@ -34,6 +44,20 @@ function maxEntries() {
34
44
  }
35
45
  }
36
46
 
47
+ /**
48
+ * Bağımlılık izleme kapatılabilir olmalı: `withDataCache` kullanmayan bir
49
+ * uygulamada hiçbir şey kaydedilmez ama bağlam kurma maliyeti kalır.
50
+ *
51
+ * @returns {boolean}
52
+ */
53
+ function trackDependencies() {
54
+ try {
55
+ return getConfig().trackDependencies;
56
+ } catch {
57
+ return true;
58
+ }
59
+ }
60
+
37
61
  /**
38
62
  * TTL dolduktan sonra eski HTML'in kaç TTL boyunca daha servis edilebileceği.
39
63
  * Tazeleme genelde ilk stale istekte tamamlandığı için bu pencere yalnızca
@@ -47,6 +71,86 @@ const store = new Map();
47
71
  /** @type {Map<string, Promise<{ html: string, status: number }>>} */
48
72
  const inflight = new Map();
49
73
 
74
+ /**
75
+ * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
76
+ * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
77
+ * okunmuş veriyle üretildi. Token silinince `write()` atlanır ve bir sonraki
78
+ * istek yeni bir tur başlatır.
79
+ *
80
+ * @type {Map<string, object>}
81
+ */
82
+ const tokens = new Map();
83
+
84
+ /**
85
+ * Ters indeks: veri anahtarı → onu okumuş HTML anahtarları. `clearDataCache()`
86
+ * bunu okuyup etkilenen sayfaları bayatlatır.
87
+ *
88
+ * @type {Map<string, Set<string>>}
89
+ */
90
+ const dependents = new Map();
91
+
92
+ /**
93
+ * Invalidate edilmiş ama henüz kimsenin istemediği yollar. Isıtma turu bunları
94
+ * kuyruğun başına alır: "içerik güncellendi" bilgisi geldiğinde sayfa,
95
+ * ziyaretçi gelmesini beklemeden tazelenir.
96
+ *
97
+ * Sınırlı tutulur — kimse ısıtma yapmıyorsa bu küme sessizce büyümemeli.
98
+ *
99
+ * @type {Set<string>}
100
+ */
101
+ const invalidated = new Set();
102
+
103
+ const MAX_INVALIDATED = 500;
104
+
105
+ /**
106
+ * Son zamanlarda düşürülen veri anahtarları ve düşürülme zamanları.
107
+ *
108
+ * Bir webhook, sayfa **render edilirken** gelirse ters indeks henüz o sayfayı
109
+ * tanımıyor (bağımlılıklar yazma anında kaydediliyor) ve render, purge'den
110
+ * önce okunmuş veriyle önbelleğe girerdi. Yazma anında bu haritaya bakmak,
111
+ * "doğduğu anda bayat" girdiyi engeller.
112
+ *
113
+ * Render'lar saniyeler sürdüğü için harita kısa tutulur; sınır aşılınca en
114
+ * eski kayıt düşer.
115
+ *
116
+ * @type {Map<string, number>}
117
+ */
118
+ const purgedDeps = new Map();
119
+
120
+ const MAX_PURGED_DEPS = 1000;
121
+
122
+ /**
123
+ * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
124
+ * anahtarlarını tutmaya devam eder ve sessizce sızar.
125
+ *
126
+ * @param {string} key
127
+ * @param {HtmlEntry} entry
128
+ */
129
+ function unlink(key, entry) {
130
+ for (const dep of entry.deps) {
131
+ const set = dependents.get(dep);
132
+ if (!set) continue;
133
+ set.delete(key);
134
+ if (!set.size) dependents.delete(dep);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Store'dan silmenin **tek** yolu. Ters indeks bakımı buraya bağlı olduğu için
140
+ * hiçbir yerde doğrudan `store.delete()` çağrılmaz.
141
+ *
142
+ * @param {string} key
143
+ * @returns {boolean} Girdi var mıydı.
144
+ */
145
+ function drop(key) {
146
+ const entry = store.get(key);
147
+ if (!entry) return false;
148
+
149
+ unlink(key, entry);
150
+ store.delete(key);
151
+ return true;
152
+ }
153
+
50
154
  /**
51
155
  * @param {string} key
52
156
  * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
@@ -58,7 +162,7 @@ function read(key) {
58
162
 
59
163
  const now = Date.now();
60
164
  if (now >= entry.staleUntil) {
61
- store.delete(key);
165
+ drop(key);
62
166
  return null;
63
167
  }
64
168
 
@@ -78,10 +182,15 @@ function read(key) {
78
182
  * @param {string} key
79
183
  * @param {{ html: string, status: number }} value
80
184
  * @param {number} ttlSeconds
185
+ * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
81
186
  */
82
- function write(key, value, ttlSeconds) {
187
+ function write(key, value, ttlSeconds, deps = null) {
83
188
  const now = Date.now();
84
189
 
190
+ // Aynı anahtarın eski girdisi ters indekste kalmasın: bağımlılıklar
191
+ // tazelemeden tazelemeye değişebilir.
192
+ drop(key);
193
+
85
194
  store.set(key, {
86
195
  html: value.html,
87
196
  status: value.status,
@@ -90,14 +199,40 @@ function write(key, value, ttlSeconds) {
90
199
  encoded: new Map(),
91
200
  expiresAt: now + ttlSeconds * 1000,
92
201
  staleUntil: now + ttlSeconds * 1000 * (1 + STALE_FACTOR),
202
+ deps: deps ?? new Set(),
93
203
  });
94
204
 
205
+ if (deps) {
206
+ for (const dep of deps) {
207
+ let set = dependents.get(dep);
208
+ if (!set) dependents.set(dep, (set = new Set()));
209
+ set.add(key);
210
+ }
211
+ }
212
+
95
213
  const limit = maxEntries();
96
214
  while (store.size > limit) {
97
215
  const oldest = store.keys().next().value;
98
216
  if (oldest === undefined) break;
99
- store.delete(oldest);
217
+ drop(oldest);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Bu render, başladıktan sonra düşürülmüş bir veriyi mi okudu.
223
+ *
224
+ * @param {Set<string> | null} deps
225
+ * @param {number} startedAt
226
+ * @returns {boolean}
227
+ */
228
+ function readsPurgedData(deps, startedAt) {
229
+ if (!deps) return false;
230
+
231
+ for (const dep of deps) {
232
+ const purgedAt = purgedDeps.get(dep);
233
+ if (purgedAt !== undefined && purgedAt >= startedAt) return true;
100
234
  }
235
+ return false;
101
236
  }
102
237
 
103
238
  /**
@@ -112,7 +247,15 @@ function refresh(key, ttlSeconds, producer) {
112
247
  const pending = inflight.get(key);
113
248
  if (pending) return pending;
114
249
 
115
- const task = producer()
250
+ const token = {};
251
+ tokens.set(key, token);
252
+ const startedAt = Date.now();
253
+
254
+ // Bağımlılıklar tazelemede de toplanır, ilk üretimde değil sadece: sayfanın
255
+ // okuduğu anahtarlar zamanla değişir (yeni bir widget, kaldırılan bir blok).
256
+ const deps = trackDependencies() ? new Set() : null;
257
+
258
+ const task = (deps ? collectDependencies(deps, producer) : producer())
116
259
  .then((value) => {
117
260
  // `degraded`: upstream düştüğü için eksik veriyle üretilmiş HTML.
118
261
  // Saklanırsa eksik içerik tüm TTL boyunca servis edilir.
@@ -120,13 +263,18 @@ function refresh(key, ttlSeconds, producer) {
120
263
  // `storable: false`: çıktı kullanıcıya bağlı (cookie/Authorization
121
264
  // okundu). Anahtar yalnızca yol + query olduğu için saklamak, bir
122
265
  // kullanıcının HTML'ini bir başkasına servis etmek olur.
123
- if (value.status === 200 && !value.degraded && value.storable !== false) {
124
- write(key, value, ttlSeconds);
266
+ //
267
+ // Token uyuşmuyorsa bu tur, sonucu geçersiz kılan bir invalidation'ın
268
+ // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
269
+ const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
270
+ if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
271
+ write(key, value, ttlSeconds, deps);
125
272
  }
126
273
  return value;
127
274
  })
128
275
  .finally(() => {
129
276
  inflight.delete(key);
277
+ if (tokens.get(key) === token) tokens.delete(key);
130
278
  });
131
279
 
132
280
  inflight.set(key, task);
@@ -152,6 +300,7 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
152
300
  // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
153
301
  // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
154
302
  if (hit.stale) {
303
+ invalidated.delete(key);
155
304
  void refresh(key, ttlSeconds, producer).catch((error) => {
156
305
  console.error(`[html-cache] background refresh failed: ${key}`, error);
157
306
  });
@@ -159,24 +308,177 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
159
308
  return { ...hit, cached: true };
160
309
  }
161
310
 
311
+ invalidated.delete(key);
162
312
  const value = await refresh(key, ttlSeconds, producer);
163
313
  return { ...value, encoded: store.get(key)?.encoded, cached: false };
164
314
  }
165
315
 
316
+ /**
317
+ * Store'u tamamen boşaltır. Dev sunucusu manifest her değiştiğinde bunu
318
+ * çağırır: saklanan HTML artık var olmayan hash'li varlıkları işaret ediyor,
319
+ * yani gerçekten **geçersiz** — bayatlatmak yetmez.
320
+ */
166
321
  export function clearHtmlCache() {
167
322
  store.clear();
323
+ dependents.clear();
324
+ tokens.clear();
325
+ invalidated.clear();
326
+ purgedDeps.clear();
168
327
  }
169
328
 
170
329
  export function getHtmlCacheSize() {
171
330
  return store.size;
172
331
  }
173
332
 
333
+ /**
334
+ * Verilen hedefi HTML anahtarının yol kısmıyla eşleştiren bir eşleyici üretir.
335
+ *
336
+ * Üç biçim kabul edilir:
337
+ * `"/haber/abc"` → o yol ve altındaki her şey (`/haber/abc/yorumlar`)
338
+ * `"/haber/:slug"` → config'in her yerinde geçerli desen sözdizimi
339
+ * `/-yorumlar$/` → desen sözdiziminin karşılamadığı kurallar için
340
+ *
341
+ * Düz string'te "önek" bilinçli olarak **segment sınırında** kesilir: `/haber`
342
+ * kuralı `/haberler`i düşürmemeli.
343
+ *
344
+ * @param {string | RegExp} target
345
+ * @returns {((pathname: string) => boolean) | null}
346
+ */
347
+ function toMatcher(target) {
348
+ if (target instanceof RegExp) return (pathname) => target.test(pathname);
349
+
350
+ if (typeof target !== "string" || !target.startsWith("/")) {
351
+ console.warn(`[html-cache] invalid invalidation target (must start with \`/\`): ${target}`);
352
+ return null;
353
+ }
354
+
355
+ if (target.includes(":")) {
356
+ const compiled = compilePattern(target);
357
+ if (!compiled) return null;
358
+ return (pathname) => matchPattern(compiled, pathname) !== null;
359
+ }
360
+
361
+ const prefix = target.endsWith("/") ? target : `${target}/`;
362
+ return (pathname) => pathname === target || pathname.startsWith(prefix);
363
+ }
364
+
365
+ /**
366
+ * Etkilenen girdiyi bayatlatır ya da düşürür.
367
+ *
368
+ * @param {string} key
369
+ * @param {boolean} hard
370
+ */
371
+ function invalidateKey(key, hard) {
372
+ // Uçuştaki tazeleme bu invalidation'dan önce başladıysa sonucu eski veriyle
373
+ // üretilmiş demektir; token'ı düşürmek onu yazılamaz hâle getirir. Girdi
374
+ // henüz hiç yazılmamış olsa bile (ilk render sürüyor) bu geçerli.
375
+ tokens.delete(key);
376
+
377
+ const entry = store.get(key);
378
+ if (entry) {
379
+ // Bayat penceresi de dolmuşsa girdi zaten ölü: bayatlatmanın etkisi olmaz.
380
+ if (hard || Date.now() >= entry.staleUntil) drop(key);
381
+ else entry.expiresAt = 0;
382
+ }
383
+
384
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
385
+ }
386
+
387
+ /**
388
+ * Hedefli invalidation: TTL'i beklemeden, ama tüm önbelleği boşaltmadan.
389
+ *
390
+ * Varsayılan **yumuşaktır** (`hard: false`): girdi silinmez, süresi geçmiş
391
+ * sayılır. Bir webhook beş yüz sayfayı birden düşürdüğünde sert silme, tam da
392
+ * içeriğin güncellendiği anda beş yüz soğuk render başlatır ve upstream'i
393
+ * döver. Bayatlatmada ise ziyaretçi eski HTML'i beklemeden alır, tazeleme
394
+ * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
395
+ * HTML'in gerçekten geçersiz olduğu durumlar için.
396
+ *
397
+ * Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
398
+ * yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
399
+ *
400
+ * @param {string | RegExp | (string | RegExp)[]} target
401
+ * @param {{ hard?: boolean }} [options]
402
+ * @returns {number} Etkilenen girdi sayısı (uçuştaki render'lar dahil).
403
+ */
404
+ export function invalidateHtmlCache(target, options = {}) {
405
+ const matchers = (Array.isArray(target) ? target : [target])
406
+ .map(toMatcher)
407
+ .filter((matcher) => matcher !== null);
408
+
409
+ if (!matchers.length) return 0;
410
+
411
+ const hard = options.hard === true;
412
+ let count = 0;
413
+
414
+ // Uçuştaki render'lar da hedeflenir: henüz yazılmamış bir tur, purge'den
415
+ // önce okunmuş veriyle önbelleğe girmemeli. Anahtarlar kopyalanır, çünkü
416
+ // `invalidateKey` sert modda store'dan siliyor.
417
+ for (const key of new Set([...store.keys(), ...tokens.keys()])) {
418
+ const mark = key.indexOf("?");
419
+ const pathname = mark === -1 ? key : key.slice(0, mark);
420
+ if (!matchers.some((matcher) => matcher(pathname))) continue;
421
+ invalidateKey(key, hard);
422
+ count += 1;
423
+ }
424
+
425
+ return count;
426
+ }
427
+
428
+ /**
429
+ * Verilen veri anahtarlarını render sırasında okumuş sayfaları bayatlatır.
430
+ * `clearDataCache()` bunu çağırır; uygulamanın hiçbir şey bildirmesi gerekmez.
431
+ *
432
+ * @param {Iterable<string>} dataKeys
433
+ * @returns {number} Etkilenen HTML girdisi sayısı.
434
+ */
435
+ export function invalidateHtmlByDependency(dataKeys) {
436
+ /** @type {Set<string>} */
437
+ const keys = new Set();
438
+ const now = Date.now();
439
+
440
+ for (const dep of dataKeys) {
441
+ // Şu anda render edilen bir sayfa bu veriyi okuduysa ters indekste henüz
442
+ // görünmüyor; yazma anındaki kontrol için zaman damgası bırakılır.
443
+ purgedDeps.set(dep, now);
444
+
445
+ const set = dependents.get(dep);
446
+ if (set) for (const key of set) keys.add(key);
447
+ }
448
+
449
+ while (purgedDeps.size > MAX_PURGED_DEPS) {
450
+ const oldest = purgedDeps.keys().next().value;
451
+ if (oldest === undefined) break;
452
+ purgedDeps.delete(oldest);
453
+ }
454
+
455
+ for (const key of keys) invalidateKey(key, false);
456
+ return keys.size;
457
+ }
458
+
459
+ /**
460
+ * Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
461
+ * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
462
+ * ısıtmasın diye okuma yıkıcıdır.
463
+ *
464
+ * @returns {string[]}
465
+ */
466
+ export function takeInvalidatedPaths() {
467
+ if (!invalidated.size) return [];
468
+
469
+ const paths = [...invalidated];
470
+ invalidated.clear();
471
+ // Anahtar `yol?query`; query boşsa sondaki `?` atılır.
472
+ return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
473
+ }
474
+
174
475
  /**
175
476
  * Dev raporu için önbellek dökümü: hangi sayfa ne kadar HTML tutuyor, ne
176
- * zaman bayatlıyor. HTML gövdesi dönmez, yalnızca boyutu.
477
+ * zaman bayatlıyor, kaç veri anahtarına bağlı. HTML gövdesi dönmez, yalnızca
478
+ * boyutu.
177
479
  *
178
480
  * @returns {{ key: string, bytes: number, status: number, stale: boolean,
179
- * expiresIn: number, encodings: string[] }[]}
481
+ * expiresIn: number, encodings: string[], deps: number }[]}
180
482
  */
181
483
  export function getHtmlCacheEntries() {
182
484
  const now = Date.now();
@@ -188,5 +490,6 @@ export function getHtmlCacheEntries() {
188
490
  stale: now >= entry.expiresAt,
189
491
  expiresIn: Math.round((entry.expiresAt - now) / 1000),
190
492
  encodings: [...entry.encoded.keys()],
493
+ deps: entry.deps.size,
191
494
  }));
192
495
  }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import process from "node:process";
22
22
  import { getConfig, hook } from "../config/index.js";
23
+ import { takeInvalidatedPaths } from "./html-cache.js";
23
24
 
24
25
  /**
25
26
  * Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
@@ -286,24 +287,35 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
286
287
  export async function prewarm({ origin, quiet = false, paths: only }) {
287
288
  const started = Date.now();
288
289
  const limit = setting("PREWARM_MAX", "max", 400);
289
- // Dev'de daha az paralellik: tarama, o an tarayıcıda açtığın sayfanın
290
- // render'ıyla CPU için yarışmasın.
291
- const concurrency = setting(
292
- "PREWARM_CONCURRENCY",
293
- "concurrency",
294
- process.env.NODE_ENV === "development" ? 2 : 4,
295
- );
290
+ const isDev = process.env.NODE_ENV === "development";
296
291
 
297
- const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, 0));
292
+ // Dev'de tek işçi: tarama, o an tarayıcıda açtığın sayfanın render'ıyla CPU
293
+ // için yarışmasın.
294
+ const concurrency = setting("PREWARM_CONCURRENCY", "concurrency", isDev ? 1 : 4);
295
+
296
+ // Render tek bir olay döngüsünde çalışıyor: aralıksız bir tur, geliştirme
297
+ // sırasında sayfa isteklerini ve dev panelinin kanalını arkasında bekletiyor.
298
+ // Dev'de varsayılan bir hız freni bu yüzden var; üretimde ısıtma bir kez
299
+ // olup bittiği için fren yalnızca istenirse (`prewarm.rps`) devreye girer.
300
+ const rps = num(process.env.PREWARM_RPS, num(getConfig().prewarm?.rps, isDev ? 4 : 0));
298
301
  const pace = createPacer(rps);
299
302
 
300
303
  const all = only?.length ? only : await collectPaths();
301
304
  // Elle verilen liste budanmaz ve sıralanmaz: çağıran tam olarak neyi
302
305
  // istediğini biliyor (dev panelindeki "tekrar dene" bunu kullanır).
303
- const paths = only?.length
306
+ const selected = only?.length
304
307
  ? all
305
308
  : selectPrewarmPaths(all, limit, getConfig().prewarm?.rotate !== false);
306
309
 
310
+ // Invalidate edilmiş sayfalar kuyruğun önüne geçer: "içerik güncellendi"
311
+ // bilgisi geldiğinde sayfa, ziyaretçi gelmesini beklemeden tazelenir. Bu
312
+ // yollar `max` bütçesinin dışında tutulur — sayıları zaten gerçekleşen
313
+ // invalidation kadar ve rotasyonun sırasını bozmaları istenmez.
314
+ const pending = only?.length ? [] : takeInvalidatedPaths();
315
+ const paths = pending.length
316
+ ? [...new Set([...pending, ...selected])]
317
+ : selected;
318
+
307
319
  Object.assign(prewarmProgress, {
308
320
  active: true,
309
321
  done: 0,
@@ -363,13 +375,14 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
363
375
  const elapsed = Date.now() - started;
364
376
 
365
377
  if (!quiet && paths.length) {
366
- const skipped = all.length - paths.length;
378
+ const skipped = all.length - selected.length;
367
379
  // Rotasyon açıkken sınırın dışında kalan yollar kaybolmuyor, bir sonraki
368
380
  // tura kalıyor; log bunu ayırt etmeli, yoksa "400 yol atlandı" satırı
369
381
  // hatalı bir kurulum sanılıyor.
370
382
  const rotate = !only?.length && getConfig().prewarm?.rotate !== false;
371
383
  console.log(
372
384
  `[prewarm] warmed ${ok}/${paths.length} pages` +
385
+ `${pending.length ? `, ${pending.length} invalidated` : ""}` +
373
386
  `${failed ? `, ${failed} failed` : ""}` +
374
387
  `${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
375
388
  `${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +