jskelet 0.1.5 → 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 +23 -0
- package/docs/06-cache.md +73 -8
- package/docs/07-yapilandirma.md +15 -5
- package/docs/09-dev-araclari.md +6 -3
- package/docs/11-tasima.md +2 -3
- package/docs/en/06-caching.md +73 -8
- package/docs/en/07-configuration.md +16 -5
- package/docs/en/09-dev-tools.md +7 -4
- package/docs/en/11-migration.md +2 -3
- package/package.json +1 -1
- package/src/config/index.js +8 -0
- package/src/index.js +1 -0
- package/src/server/cache-deps.js +42 -0
- package/src/server/create-app.js +12 -8
- package/src/server/data-cache.js +23 -10
- package/src/server/dev/devtools.js +5 -10
- package/src/server/html-cache.js +312 -9
- package/src/server/prewarm.js +23 -10
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
|
|
310
|
-
|
|
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
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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ı) |
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -560,9 +560,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
|
|
|
560
560
|
## `cache()`
|
|
561
561
|
|
|
562
562
|
**Tip:**
|
|
563
|
-
`() => { html?: Record<string, number>, maxEntries?: number, data?: object, 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
|
|
627
|
-
| `rps` | `number` | `0
|
|
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
|
|
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 |
|
package/docs/09-dev-araclari.md
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
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
|
|
@@ -316,7 +318,8 @@ yayına açılmamış bir ortam yönlendirme kurallarını bile dışarıya sız
|
|
|
316
318
|
| Bozuk route modülü | Uyarı + atla | Fırlat |
|
|
317
319
|
| Devtools ve rapor | Mount edilir | Hiç yüklenmez |
|
|
318
320
|
| `globalThis.fetch` | Sarılır (ölçüm) | Dokunulmaz |
|
|
319
|
-
| Prewarm paralelliği |
|
|
321
|
+
| Prewarm paralelliği | 1 | 4 |
|
|
322
|
+
| Prewarm hız freni | saniyede 4 istek | Sınırsız |
|
|
320
323
|
| Prewarm gecikmesi | 3000 ms | 500 ms |
|
|
321
324
|
| Eksik ikon uyarısı | Verilir | Verilmez |
|
|
322
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()` | `
|
|
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.
|
package/docs/en/06-caching.md
CHANGED
|
@@ -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
|
|
319
|
-
|
|
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,
|
|
532
|
-
|
|
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
|
|
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
|
|
642
|
-
| `rps` | `number` | `0
|
|
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
|
|
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 |
|
package/docs/en/09-dev-tools.md
CHANGED
|
@@ -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),
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
@@ -324,7 +326,8 @@ redirect rules.
|
|
|
324
326
|
| Broken route module | Warn + skip | Throw |
|
|
325
327
|
| Devtools and report | Mounted | Never loaded |
|
|
326
328
|
| `globalThis.fetch` | Wrapped (measurement) | Untouched |
|
|
327
|
-
| Prewarm concurrency |
|
|
329
|
+
| Prewarm concurrency | 1 | 4 |
|
|
330
|
+
| Prewarm rate limit | 4 requests/second | Unlimited |
|
|
328
331
|
| Prewarm delay | 3000 ms | 500 ms |
|
|
329
332
|
| Missing icon warning | Emitted | Not emitted |
|
|
330
333
|
| Precompress | Does not run in watch | Runs |
|
package/docs/en/11-migration.md
CHANGED
|
@@ -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()` | `
|
|
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.
|
|
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",
|
package/src/config/index.js
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/src/server/create-app.js
CHANGED
|
@@ -167,15 +167,17 @@ export async function startServer(options = {}) {
|
|
|
167
167
|
console.error("[uncaughtException]", error);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
}
|
package/src/server/data-cache.js
CHANGED
|
@@ -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
|
-
|
|
223
|
+
removed.push(...store.keys());
|
|
211
224
|
store.clear();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
-
},
|
|
250
|
+
}, 2000);
|
|
256
251
|
|
|
257
252
|
timer.unref?.();
|
|
258
253
|
}
|
package/src/server/html-cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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
|
}
|
package/src/server/prewarm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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"}` : ""}` +
|