jskelet 0.1.7 → 0.2.0
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 +44 -0
- package/docs/06-cache.md +132 -1
- package/docs/07-yapilandirma.md +37 -3
- package/docs/10-dagitim.md +1 -1
- package/docs/en/06-caching.md +138 -1
- package/docs/en/07-configuration.md +37 -3
- package/docs/en/10-deployment.md +1 -1
- package/package.json +5 -1
- package/src/build/paths.mjs +26 -1
- package/src/config/defaults.js +35 -0
- package/src/config/index.js +47 -1
- package/src/index.js +3 -0
- package/src/server/assets.js +28 -0
- package/src/server/create-app.js +97 -14
- package/src/server/data-cache.js +134 -19
- package/src/server/dev/devtools.js +2 -2
- package/src/server/dev/report.js +4 -0
- package/src/server/dev/socket.js +16 -3
- package/src/server/html-cache.js +329 -43
- package/src/server/prewarm.js +56 -0
- package/src/server/redis.js +461 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,28 @@ one is listed under a **Breaking** heading.
|
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
12
|
|
|
13
|
+
- An optional Redis tier behind both caches, turned on with
|
|
14
|
+
`cache().redis: { enabled: true, url }` and `npm install ioredis`. The
|
|
15
|
+
in-process cache stays primary and every request still reads it; Redis only
|
|
16
|
+
does the two things a single process cannot. An instance that has never seen a
|
|
17
|
+
path finds the HTML another replica already produced, so a fresh container or a
|
|
18
|
+
post-deploy replacement does not re-render and re-fetch everything from
|
|
19
|
+
scratch. And `invalidateHtmlCache()`, `clearHtmlCache()` and
|
|
20
|
+
`clearDataCache()` now reach every replica over pub/sub instead of only the one
|
|
21
|
+
that received the webhook — until now the others waited out the TTL and a
|
|
22
|
+
visitor saw old or new content depending on where they landed. Keys live under
|
|
23
|
+
`_jskelet:{namespace}:{buildId}:…`, where the build id makes HTML from a
|
|
24
|
+
previous deploy expire on its own rather than pointing at asset files that no
|
|
25
|
+
longer exist. Personalised (`storable: false`), degraded and non-200 responses
|
|
26
|
+
are never shared. If `ioredis` is missing, Redis is unreachable or it goes down
|
|
27
|
+
mid-flight, a warning is printed and the site keeps serving from memory.
|
|
28
|
+
- `getRedisStatus()` reports whether the shared tier is connected, which key
|
|
29
|
+
prefix and build id it is using, and how many command failures there have
|
|
30
|
+
been — usable from a healthcheck endpoint. The same summary appears in the dev
|
|
31
|
+
panel report.
|
|
32
|
+
- Servers started with `startServer()` now shut down on `SIGTERM`/`SIGINT`
|
|
33
|
+
instead of being killed: the listener is closed and the Redis connection is
|
|
34
|
+
drained so in-flight writes are not cut mid-command.
|
|
13
35
|
- Targeted HTML invalidation: `invalidateHtmlCache(target, { hard })` takes a
|
|
14
36
|
path, the config pattern syntax (`/news/:slug`), a regular expression or a list
|
|
15
37
|
of them, and returns how many entries were affected. By default it **stales**
|
|
@@ -65,12 +87,27 @@ one is listed under a **Breaking** heading.
|
|
|
65
87
|
|
|
66
88
|
### Changed
|
|
67
89
|
|
|
90
|
+
- Request errors raised during a prewarm pass are no longer logged one by one.
|
|
91
|
+
They are counted while the pass runs and printed as a single summary line
|
|
92
|
+
afterwards, grouped by status and message with the most frequent kinds first,
|
|
93
|
+
so a flaky upstream can no longer bury the "warmed N/M pages" line under
|
|
94
|
+
hundreds of stack traces. Errors from real traffic are logged as before, and
|
|
95
|
+
the dev tools panel still shows the per-path detail.
|
|
96
|
+
- The marketing example's changelog page is now a timeline: releases are laid out
|
|
97
|
+
along a rail with a sticky version column, each change group gets its own card
|
|
98
|
+
with a coloured rule and item count, and a row of version chips at the top
|
|
99
|
+
jumps straight to a release.
|
|
68
100
|
- The dev tools panel is now fed over a WebSocket (`<devBasePath>/ws`) instead of
|
|
69
101
|
polling `/stats` every two seconds. The server pushes statistics as they change
|
|
70
102
|
and sends live reload and CSS hot-swap events over the same connection, so an
|
|
71
103
|
open tab no longer keeps hitting the server while the panel is closed. No new
|
|
72
104
|
dependency is involved; if the socket cannot be opened, the panel falls back to
|
|
73
105
|
the previous SSE plus polling path.
|
|
106
|
+
- The server now binds to `::` instead of `0.0.0.0` when no `HOST` is given, so a
|
|
107
|
+
single dual-stack socket answers both IPv6 and IPv4. Browsers resolve
|
|
108
|
+
`localhost` to `::1` first and, unlike ordinary requests, a WebSocket handshake
|
|
109
|
+
does not fall back to IPv4 — which made the dev panel's live channel fail on an
|
|
110
|
+
IPv4-only socket. Where IPv6 is unavailable the bind falls back to `0.0.0.0`.
|
|
74
111
|
- Prewarming no longer holds up the rest of the dev server. In development it now
|
|
75
112
|
runs with a single worker and a default limit of 4 requests per second
|
|
76
113
|
(`prewarm.rps` / `PREWARM_RPS` still override it), so page requests and the dev
|
|
@@ -97,6 +134,13 @@ one is listed under a **Breaking** heading.
|
|
|
97
134
|
`node_modules` can still serve the docs. In development the local file wins
|
|
98
135
|
and nothing is cached. The branch is overridable with `DOCS_REF`.
|
|
99
136
|
|
|
137
|
+
### Fixed
|
|
138
|
+
|
|
139
|
+
- The dev panel's WebSocket handshake was answered with a `Sec-WebSocket-Accept`
|
|
140
|
+
value derived from a mistyped protocol constant. Browsers verify that value and
|
|
141
|
+
closed the connection immediately with "Incorrect 'Sec-WebSocket-Accept' header
|
|
142
|
+
value", so the panel silently fell back to polling.
|
|
143
|
+
|
|
100
144
|
## [0.1.2] - 2026-08-30
|
|
101
145
|
|
|
102
146
|
### Added
|
package/docs/06-cache.md
CHANGED
|
@@ -530,7 +530,124 @@ silinmiş dosyayı istemeye devam eder ([09-dev-araclari.md](./09-dev-araclari.m
|
|
|
530
530
|
|
|
531
531
|
Önbellek süreç belleğinde yaşadığı için birden fazla süreç/kopya çalıştırıyorsanız
|
|
532
532
|
her birinin kendi önbelleği olur; `clearHtmlCache()` yalnızca çağrıldığı süreci
|
|
533
|
-
etkiler.
|
|
533
|
+
etkiler. Birden fazla instance çalıştıran bir kurulumda bunu aşmanın yolu bir
|
|
534
|
+
sonraki bölümde.
|
|
535
|
+
|
|
536
|
+
## Paylaşımlı önbellek: Redis
|
|
537
|
+
|
|
538
|
+
Varsayılan önbellek tek prosese ait. Bu, tek instance çalışan bir sitede en hızlı
|
|
539
|
+
ve en basit kurulum — ama üç kopya çalıştırdığınızda iki sorun çıkar:
|
|
540
|
+
|
|
541
|
+
1. **Her kopya kendi başına ısınır.** Yeni bir instance açıldığında ya da bir
|
|
542
|
+
deploy sonrası konteyner yenilendiğinde önbellek boştur; aynı sayfa üç kez
|
|
543
|
+
render edilir, aynı veri üç kez çekilir.
|
|
544
|
+
2. **Invalidation tek kopyaya ulaşır.** `invalidateHtmlCache()` çağıran webhook
|
|
545
|
+
yalnızca isteği alan instance'ı tazeler; diğerleri TTL'i bekler. Ziyaretçi
|
|
546
|
+
hangi kopyaya düştüğüne göre eski ya da yeni içeriği görür.
|
|
547
|
+
|
|
548
|
+
`cache().redis` bu iki sorunu çözer. Redis **birincil store olmaz**: bellek içi
|
|
549
|
+
önbellek (L1) aynen kalır ve her istek onu okur; Redis ikinci kademedir (L2).
|
|
550
|
+
|
|
551
|
+
```js
|
|
552
|
+
// jskelet.config.mjs
|
|
553
|
+
export default {
|
|
554
|
+
cache() {
|
|
555
|
+
return {
|
|
556
|
+
html: { "/haber/:slug": 300 },
|
|
557
|
+
redis: {
|
|
558
|
+
enabled: true,
|
|
559
|
+
url: process.env.REDIS_URL,
|
|
560
|
+
namespace: "haber-sitesi",
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`ioredis` opsiyonel bir peer bağımlılıktır, uygulamanın kendisine kurulur:
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
npm install ioredis
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Kurulmadıysa ya da Redis'e bağlanılamıyorsa uyarı basılır ve site **bellek içi
|
|
574
|
+
önbellekle çalışmaya devam eder**. Redis çalışırken düşerse aynı şey olur: bir
|
|
575
|
+
devre kesici art arda beş hatadan sonra katmanı beş saniye baypas eder, böylece
|
|
576
|
+
her istek ağ zaman aşımı beklemez.
|
|
577
|
+
|
|
578
|
+
### Ne kazanırsınız
|
|
579
|
+
|
|
580
|
+
- **Soğuk instance sıcak önbellek bulur.** L1'de olmayan bir yol için render
|
|
581
|
+
çalışmadan önce Redis okunur; başka bir kopya o sayfayı ürettiyse render hiç
|
|
582
|
+
çalışmaz.
|
|
583
|
+
- **Veri önbelleği kotayı bir kez harcar.** `withDataCache` aynı mantıkla
|
|
584
|
+
çalışır ve kazanç burada daha büyük: JSON küçük, bir kopyanın çektiği veri
|
|
585
|
+
hepsine yeter.
|
|
586
|
+
- **Invalidation her kopyaya gider.** `invalidateHtmlCache()`,
|
|
587
|
+
`clearHtmlCache()` ve `clearDataCache()` bir pub/sub kanalına mesaj bırakır;
|
|
588
|
+
her instance kendi L1'ine aynı işlemi uygular. Deseni yayınlar, eşleşen
|
|
589
|
+
anahtarları değil — hangi yolun nerede sıcak olduğu kopyaya bağlı.
|
|
590
|
+
|
|
591
|
+
### Anahtar düzeni
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
_jskelet:{namespace}:{buildId}:html:{yol}?{query}
|
|
595
|
+
_jskelet:{namespace}:{buildId}:data:{anahtar}
|
|
596
|
+
_jskelet:{namespace}:events
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
`buildId` her build'de değişir (`jskelet build` bunu `.jskelet/build.json`
|
|
600
|
+
dosyasına yazar) ve **zorunlu bir parçadır**: saklanan HTML hash'li varlık
|
|
601
|
+
yollarını gömüyor, yani bir deploy'dan sonra eski HTML geçersizdir. Kimlik
|
|
602
|
+
önekte durduğu için yeni sürüm kendiliğinden yeni bir isim alanına yazar, eski
|
|
603
|
+
anahtarlar TTL ile ölür — elle temizlik ya da `FLUSHDB` gerekmez. Build
|
|
604
|
+
çalıştırılmadıysa kimlik `dev` olur.
|
|
605
|
+
|
|
606
|
+
`namespace` aynı Redis'i paylaşan birden fazla uygulamayı ayırır. Olay kanalı
|
|
607
|
+
bilinçli olarak `buildId` **taşımaz**: deploy sırasında eski ve yeni sürüm yan
|
|
608
|
+
yana koşuyor ve bir purge ikisine de ulaşmalı.
|
|
609
|
+
|
|
610
|
+
### Bilmeniz gereken takaslar
|
|
611
|
+
|
|
612
|
+
- **Kişiye özel çıktı paylaşılmaz.** `storable: false` işaretlenen bir render
|
|
613
|
+
(cookie/`Authorization` okuyan sayfa) Redis'e hiç yazılmaz. Tek prosesteyken
|
|
614
|
+
bile geçerli olan bu kural paylaşımlı kademede daha da kritik: sızması, bir
|
|
615
|
+
kullanıcının HTML'ini tüm kümeye servis etmek olur. `degraded` render ve 200
|
|
616
|
+
dışındaki durum kodları da paylaşılmaz.
|
|
617
|
+
- **Sıkıştırılmış gövdeler varsayılan olarak yerel kalır.** `storeEncoded: true`
|
|
618
|
+
ile açılabilir, ama girdi başına boyutu iki-üç katına çıkarır; brotli'yi
|
|
619
|
+
yeniden üretmek çoğu zaman Redis'ten indirmekten ucuzdur.
|
|
620
|
+
- **Yumuşak invalidation Redis kopyasını siler.** Bayatlatmanın Redis karşılığı
|
|
621
|
+
her anahtar için oku-değiştir-yaz turu demek ve bir webhook binlerce anahtarı
|
|
622
|
+
birden düşürüyor. Silmenin bedeli, o yolu hiç görmemiş bir kopyanın bir kez
|
|
623
|
+
render etmesi; L1'i sıcak olan kopyalar eski HTML'i bayat pencerede servis
|
|
624
|
+
etmeye devam eder.
|
|
625
|
+
- **Yalnızca taze girdi kabul edilir.** Bayat bir kopyayı L1'e almak tazelemeyi
|
|
626
|
+
sonsuza kadar ertelerdi: girdi bayat kalır, her tur yine Redis'i okur ve
|
|
627
|
+
render hiç çalışmaz.
|
|
628
|
+
- **Tutarlılık nihai.** Bir purge ile o purge'ün her kopyaya ulaşması arasında
|
|
629
|
+
kısa bir pencere var. Bu pencerede bir kopya eski HTML'i servis edebilir;
|
|
630
|
+
süresi TTL ile sınırlı.
|
|
631
|
+
- **Dev'de kapalı tutun.** Dev sunucusu manifest her değiştiğinde önbelleği
|
|
632
|
+
boşaltıyor; paylaşımlı bir store bunu anlamsızlaştırır. `enabled` yalnızca
|
|
633
|
+
açıkça `true` verildiğinde açılır.
|
|
634
|
+
|
|
635
|
+
### Durumu görmek
|
|
636
|
+
|
|
637
|
+
```js
|
|
638
|
+
import { getRedisStatus } from "jskelet";
|
|
639
|
+
|
|
640
|
+
app.get("/api/healthcheck", (req, res) => {
|
|
641
|
+
res.json({ ok: true, cache: getRedisStatus() });
|
|
642
|
+
});
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Bağlantı kurulmamışken de güvenle çağrılır. Dönen nesne
|
|
646
|
+
`{ enabled, connected, keyPrefix, buildId, errors, bypassed }`; `bypassed` devre
|
|
647
|
+
kesicinin açık olduğunu, `errors` toplam komut hatasını gösterir. Aynı özet dev
|
|
648
|
+
panelinin raporunda da var ([09-dev-araclari.md](./09-dev-araclari.md)).
|
|
649
|
+
|
|
650
|
+
Ayarların tam listesi: [07-yapilandirma.md](./07-yapilandirma.md).
|
|
534
651
|
|
|
535
652
|
## Prewarm — açılışta ısıtma
|
|
536
653
|
|
|
@@ -589,6 +706,20 @@ Kurallar:
|
|
|
589
706
|
5. Özet loglanır:
|
|
590
707
|
`[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
|
|
591
708
|
|
|
709
|
+
Tur sırasında oluşan istek hataları tek tek loglanmaz; sayılır ve tur bitince
|
|
710
|
+
özetin ardından tek bir satırda, en sık görülen türler başta olacak şekilde
|
|
711
|
+
basılır:
|
|
712
|
+
|
|
713
|
+
```text
|
|
714
|
+
[prewarm] 37 request errors were not logged individually:
|
|
715
|
+
31× 502 upstream fetch failed: /api/quotes
|
|
716
|
+
6× 500 Cannot read properties of undefined (reading 'title')
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
Böylece bir anlık upstream arızası "warmed …" satırını yüzlerce yığın izinin
|
|
720
|
+
altına gömmüyor. Gerçek trafiğin hataları eskisi gibi anında loglanır, tek bir
|
|
721
|
+
yolun ayrıntısı için dev panelindeki **Prewarming** sekmesine bakılır.
|
|
722
|
+
|
|
592
723
|
### Isıtma sırası: `priority`
|
|
593
724
|
|
|
594
725
|
```js
|
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, trackDependencies?: boolean, transientRetry?: object | false, prewarm?: object }` —
|
|
563
|
+
`() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, redis?: object, prewarm?: object }` —
|
|
564
564
|
**Varsayılan:**
|
|
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 } }`
|
|
565
|
+
`{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
|
|
566
566
|
|
|
567
567
|
### `cache().html`
|
|
568
568
|
|
|
@@ -627,6 +627,40 @@ denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi; denemeler tükenirse
|
|
|
627
627
|
önbelleğe girmeyen bir 503 olur. `false` ya da `attempts: 0` tekrarı kapatır.
|
|
628
628
|
Ayrıntı: [06-cache.md](./06-cache.md).
|
|
629
629
|
|
|
630
|
+
### `cache().redis`
|
|
631
|
+
|
|
632
|
+
Opsiyonel Redis ikinci kademesi (L2). Bellek içi önbellek birincil kalır; Redis
|
|
633
|
+
yalnızca L1'de bulunmayan bir yol için render'ı atlatır ve invalidation'ı diğer
|
|
634
|
+
instance'lara yayar. `ioredis` uygulamaya kurulmalı (`npm install ioredis`);
|
|
635
|
+
kurulmadıysa ya da bağlanılamıyorsa uyarı basılır ve site bellek içi önbellekle
|
|
636
|
+
çalışmaya devam eder.
|
|
637
|
+
|
|
638
|
+
| Alan | Tip | Varsayılan | Anlamı |
|
|
639
|
+
| --- | --- | --- | --- |
|
|
640
|
+
| `enabled` | `boolean` | `false` | Yalnızca açıkça `true` verildiğinde açılır |
|
|
641
|
+
| `url` | `string \| null` | `null` | `redis://` ya da `rediss://`. Boşsa ioredis varsayılanı (`localhost:6379`) |
|
|
642
|
+
| `namespace` | `string` | `"default"` | Aynı Redis'i paylaşan uygulamaları ayırır |
|
|
643
|
+
| `keyPrefix` | `string` | `"_jskelet"` | Anahtar düzeninin kökü |
|
|
644
|
+
| `html` | `boolean` | `true` | HTML gövdeleri paylaşılsın mı |
|
|
645
|
+
| `data` | `boolean` | `true` | `withDataCache` girdileri paylaşılsın mı |
|
|
646
|
+
| `storeEncoded` | `boolean` | `false` | Brotli/gzip gövdeleri de paylaşılsın mı; girdi başına boyutu iki-üç katına çıkarır |
|
|
647
|
+
| `events` | `boolean` | `true` | pub/sub üzerinden invalidation yayını |
|
|
648
|
+
| `commandTimeoutMs` | `number` | `200` | Tek bir komutun en fazla bekletebileceği süre |
|
|
649
|
+
|
|
650
|
+
Anahtarlar `_jskelet:{namespace}:{buildId}:html:{yol}?{query}` biçiminde yaşar.
|
|
651
|
+
`buildId` her build'de değişir, böylece deploy sonrası eski HTML kendiliğinden
|
|
652
|
+
geçersiz olur. Kişiye özel (`storable: false`), `degraded` ve 200 dışındaki
|
|
653
|
+
yanıtlar paylaşımlı kademeye hiç yazılmaz. Takaslar ve teşhis:
|
|
654
|
+
[06-cache.md](./06-cache.md).
|
|
655
|
+
|
|
656
|
+
```js
|
|
657
|
+
redis: {
|
|
658
|
+
enabled: process.env.NODE_ENV === "production",
|
|
659
|
+
url: process.env.REDIS_URL,
|
|
660
|
+
namespace: "haber-sitesi",
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
630
664
|
### `cache().prewarm`
|
|
631
665
|
|
|
632
666
|
| Alan | Tip | Varsayılan | Anlamı |
|
|
@@ -747,7 +781,7 @@ basılmaz.
|
|
|
747
781
|
| --- | --- | --- | --- |
|
|
748
782
|
| `NODE_ENV` | her yer | `production` (start/build), `development` (dev) | Dev overlay, EJS cache, manifest yeniden okuma, route hata davranışı ve prewarm varsayılanlarını belirler. `jskelet dev` bunu kendisi ayarlar — `cross-env` gerekmez. |
|
|
749
783
|
| `PORT` | `startServer` | `3000` | Dinlenecek port |
|
|
750
|
-
| `HOST` | `startServer` | `0.0.0.0`
|
|
784
|
+
| `HOST` | `startServer` | `::` | Bağlanılacak arayüz. Varsayılan çift yığın dinler (IPv6 + IPv4); IPv6 yoksa `0.0.0.0`'a düşer |
|
|
751
785
|
| `JSKELET_SECRET` | `jskelet/cookies` | — | İmzalı cookie sırrı. `security.cookieSecret` verilmediğinde buradan okunur; ikisi de yoksa imzalı cookie API'si hata verir. [12](./12-panel-ve-oturum.md) |
|
|
752
786
|
| `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) |
|
|
753
787
|
| `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar |
|
package/docs/10-dagitim.md
CHANGED
|
@@ -43,7 +43,7 @@ ayarlamayı düşünmeniz gerekenler:
|
|
|
43
43
|
| --- | --- | --- |
|
|
44
44
|
| `NODE_ENV` | `production` | Şablon cache'i, manifest'in bir kez okunması, bozuk route modülünde fırlatma |
|
|
45
45
|
| `PORT` | `3000` | Orkestratörünüzün beklediği port |
|
|
46
|
-
| `HOST` | `0.0.0.0` |
|
|
46
|
+
| `HOST` | `0.0.0.0` | Yalnızca IPv4 dinlemek gerekiyorsa. Varsayılan `::` zaten çift yığın dinler |
|
|
47
47
|
| `PREWARM_MAX` | Site boyutuna göre | Açılışta ısıtılacak sayfa sayısı |
|
|
48
48
|
| `PREWARM_INTERVAL_SECONDS` | `0` ya da uzun bir değer | Hiç ziyaret edilmeyen sayfaları sıcak tutmak isterseniz |
|
|
49
49
|
| `DEV_TOKEN` | Yalnızca staging'de | Yayına açılmamış ortamı gizler |
|
package/docs/en/06-caching.md
CHANGED
|
@@ -542,7 +542,129 @@ not cleared the page would keep requesting a deleted file
|
|
|
542
542
|
|
|
543
543
|
Because the cache lives in process memory, if you run more than one
|
|
544
544
|
process/replica each one has its own cache; `clearHtmlCache()` only affects the
|
|
545
|
-
process it is called in.
|
|
545
|
+
process it is called in. The next section covers how to get past this when you
|
|
546
|
+
run several instances.
|
|
547
|
+
|
|
548
|
+
## A shared cache: Redis
|
|
549
|
+
|
|
550
|
+
The default cache belongs to a single process. That is the fastest and simplest
|
|
551
|
+
setup for a site running one instance — but two problems appear once you run
|
|
552
|
+
three replicas:
|
|
553
|
+
|
|
554
|
+
1. **Every replica warms up on its own.** When a new instance comes up, or a
|
|
555
|
+
container is replaced after a deploy, its cache is empty: the same page is
|
|
556
|
+
rendered three times and the same data is fetched three times.
|
|
557
|
+
2. **Invalidation reaches one replica.** The webhook that calls
|
|
558
|
+
`invalidateHtmlCache()` only refreshes the instance that received the
|
|
559
|
+
request; the others wait for the TTL. A visitor sees the old or the new
|
|
560
|
+
content depending on which replica they land on.
|
|
561
|
+
|
|
562
|
+
`cache().redis` solves both. Redis is **not the primary store**: the in-process
|
|
563
|
+
cache (L1) stays exactly as it is and every request reads it; Redis is a second
|
|
564
|
+
tier (L2).
|
|
565
|
+
|
|
566
|
+
```js
|
|
567
|
+
// jskelet.config.mjs
|
|
568
|
+
export default {
|
|
569
|
+
cache() {
|
|
570
|
+
return {
|
|
571
|
+
html: { "/news/:slug": 300 },
|
|
572
|
+
redis: {
|
|
573
|
+
enabled: true,
|
|
574
|
+
url: process.env.REDIS_URL,
|
|
575
|
+
namespace: "news-site",
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
`ioredis` is an optional peer dependency, installed in the application itself:
|
|
583
|
+
|
|
584
|
+
```bash
|
|
585
|
+
npm install ioredis
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
If it is not installed, or Redis cannot be reached, a warning is printed and the
|
|
589
|
+
site **keeps running on the in-process cache**. The same happens if Redis goes
|
|
590
|
+
down while running: a circuit breaker bypasses the tier for five seconds after
|
|
591
|
+
five consecutive failures, so requests do not each wait for a network timeout.
|
|
592
|
+
|
|
593
|
+
### What you get
|
|
594
|
+
|
|
595
|
+
- **A cold instance finds a warm cache.** For a path that is not in L1, Redis is
|
|
596
|
+
read before the render runs; if another replica already produced that page, the
|
|
597
|
+
render never happens.
|
|
598
|
+
- **The data cache spends the quota once.** `withDataCache` works the same way,
|
|
599
|
+
and the gain is bigger here: JSON is small, and what one replica fetched is
|
|
600
|
+
enough for all of them.
|
|
601
|
+
- **Invalidation reaches every replica.** `invalidateHtmlCache()`,
|
|
602
|
+
`clearHtmlCache()` and `clearDataCache()` leave a message on a pub/sub
|
|
603
|
+
channel and each instance applies the same operation to its own L1. The
|
|
604
|
+
pattern is published, not the matched keys — which path is hot where depends
|
|
605
|
+
on the replica.
|
|
606
|
+
|
|
607
|
+
### Key layout
|
|
608
|
+
|
|
609
|
+
```
|
|
610
|
+
_jskelet:{namespace}:{buildId}:html:{path}?{query}
|
|
611
|
+
_jskelet:{namespace}:{buildId}:data:{key}
|
|
612
|
+
_jskelet:{namespace}:events
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
`buildId` changes with every build (`jskelet build` writes it to
|
|
616
|
+
`.jskelet/build.json`) and it is a **required** part: the stored HTML embeds
|
|
617
|
+
hashed asset paths, so after a deploy the old HTML is invalid. Because the id
|
|
618
|
+
sits in the prefix, a new version automatically writes into a new namespace and
|
|
619
|
+
the old keys die with their TTL — no manual cleanup and no `FLUSHDB`. When the
|
|
620
|
+
build has not been run the id is `dev`.
|
|
621
|
+
|
|
622
|
+
`namespace` separates several applications sharing one Redis. The event channel
|
|
623
|
+
deliberately does **not** carry `buildId`: during a deploy the old and the new
|
|
624
|
+
version run side by side and a purge has to reach both.
|
|
625
|
+
|
|
626
|
+
### Trade-offs worth knowing
|
|
627
|
+
|
|
628
|
+
- **Personalised output is never shared.** A render marked `storable: false` (a
|
|
629
|
+
page that read a cookie or `Authorization`) is never written to Redis. The
|
|
630
|
+
rule already holds in a single process, but it matters far more in a shared
|
|
631
|
+
tier: a leak would mean serving one user's HTML to the whole cluster.
|
|
632
|
+
`degraded` renders and non-200 status codes are not shared either.
|
|
633
|
+
- **Compressed bodies stay local by default.** `storeEncoded: true` turns this
|
|
634
|
+
on, but it doubles or triples the size per entry; recomputing brotli is
|
|
635
|
+
usually cheaper than downloading it from Redis.
|
|
636
|
+
- **A soft invalidation deletes the Redis copy.** Staling in Redis would mean a
|
|
637
|
+
read-modify-write round per key, and a webhook drops thousands of keys at
|
|
638
|
+
once. The cost of deleting is one render on a replica that never saw that
|
|
639
|
+
path; replicas whose L1 is hot keep serving the old HTML through the stale
|
|
640
|
+
window.
|
|
641
|
+
- **Only fresh entries are accepted.** Promoting a stale copy into L1 would
|
|
642
|
+
postpone the refresh forever: the entry stays stale, every pass reads Redis
|
|
643
|
+
again and the render never runs.
|
|
644
|
+
- **Consistency is eventual.** There is a short window between a purge and that
|
|
645
|
+
purge reaching every replica. During it a replica may serve the old HTML; the
|
|
646
|
+
window is bounded by the TTL.
|
|
647
|
+
- **Keep it off in dev.** The dev server clears the cache whenever the manifest
|
|
648
|
+
changes, which makes a shared store pointless. `enabled` only turns on when
|
|
649
|
+
`true` is passed explicitly.
|
|
650
|
+
|
|
651
|
+
### Seeing the status
|
|
652
|
+
|
|
653
|
+
```js
|
|
654
|
+
import { getRedisStatus } from "jskelet";
|
|
655
|
+
|
|
656
|
+
app.get("/api/healthcheck", (req, res) => {
|
|
657
|
+
res.json({ ok: true, cache: getRedisStatus() });
|
|
658
|
+
});
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Safe to call even with no connection. The returned object is
|
|
662
|
+
`{ enabled, connected, keyPrefix, buildId, errors, bypassed }`; `bypassed` tells
|
|
663
|
+
you the circuit breaker is open and `errors` is the total command failure count.
|
|
664
|
+
The same summary is in the dev panel report
|
|
665
|
+
([09-dev-tools.md](./09-dev-tools.md)).
|
|
666
|
+
|
|
667
|
+
The full list of settings: [07-configuration.md](./07-configuration.md).
|
|
546
668
|
|
|
547
669
|
## Prewarm — warming up at startup
|
|
548
670
|
|
|
@@ -605,6 +727,21 @@ Rules:
|
|
|
605
727
|
5. A summary is logged:
|
|
606
728
|
`[prewarm] warmed 128/130 pages, 2 failed, 5 recovered on the retry pass (12.4s)`
|
|
607
729
|
|
|
730
|
+
Request errors raised during the pass are not logged one by one. They are
|
|
731
|
+
counted while the pass runs and printed after the summary as a single line,
|
|
732
|
+
grouped by status and message with the most frequent kinds first:
|
|
733
|
+
|
|
734
|
+
```text
|
|
735
|
+
[prewarm] 37 request errors were not logged individually:
|
|
736
|
+
31× 502 upstream fetch failed: /api/quotes
|
|
737
|
+
6× 500 Cannot read properties of undefined (reading 'title')
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
This way a momentary upstream failure cannot bury the "warmed …" line under
|
|
741
|
+
hundreds of stack traces. Errors from real traffic are logged immediately as
|
|
742
|
+
before; for the detail of a single path, look at the **Prewarming** tab in the
|
|
743
|
+
dev panel.
|
|
744
|
+
|
|
608
745
|
### Warm-up order: `priority`
|
|
609
746
|
|
|
610
747
|
```js
|
|
@@ -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, trackDependencies?: boolean, transientRetry?: object | false, prewarm?: object }` —
|
|
575
|
+
`() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, redis?: object, prewarm?: object }` —
|
|
576
576
|
**Default:**
|
|
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 } }`
|
|
577
|
+
`{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
|
|
578
578
|
|
|
579
579
|
### `cache().html`
|
|
580
580
|
|
|
@@ -643,6 +643,40 @@ transient upstream failure. The point is that an existing page never turns into
|
|
|
643
643
|
a 404; if the retries are exhausted the response is an uncached 503. `false` or
|
|
644
644
|
`attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
|
|
645
645
|
|
|
646
|
+
### `cache().redis`
|
|
647
|
+
|
|
648
|
+
An optional Redis second tier (L2). The in-process cache stays primary; Redis
|
|
649
|
+
only skips the render for a path that is not in L1 and spreads invalidation to
|
|
650
|
+
the other instances. `ioredis` has to be installed in the application
|
|
651
|
+
(`npm install ioredis`); if it is missing or unreachable a warning is printed and
|
|
652
|
+
the site keeps running on the in-process cache.
|
|
653
|
+
|
|
654
|
+
| Field | Type | Default | Meaning |
|
|
655
|
+
| --- | --- | --- | --- |
|
|
656
|
+
| `enabled` | `boolean` | `false` | Only turns on when `true` is passed explicitly |
|
|
657
|
+
| `url` | `string \| null` | `null` | `redis://` or `rediss://`. When empty, the ioredis default (`localhost:6379`) |
|
|
658
|
+
| `namespace` | `string` | `"default"` | Separates applications sharing one Redis |
|
|
659
|
+
| `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
|
|
660
|
+
| `html` | `boolean` | `true` | Whether HTML bodies are shared |
|
|
661
|
+
| `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
|
|
662
|
+
| `storeEncoded` | `boolean` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
|
|
663
|
+
| `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
|
|
664
|
+
| `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
|
|
665
|
+
|
|
666
|
+
Keys live as `_jskelet:{namespace}:{buildId}:html:{path}?{query}`. `buildId`
|
|
667
|
+
changes with every build, so old HTML becomes invalid on its own after a deploy.
|
|
668
|
+
Personalised (`storable: false`), `degraded` and non-200 responses are never
|
|
669
|
+
written to the shared tier. Trade-offs and diagnosis:
|
|
670
|
+
[06-caching.md](./06-caching.md).
|
|
671
|
+
|
|
672
|
+
```js
|
|
673
|
+
redis: {
|
|
674
|
+
enabled: process.env.NODE_ENV === "production",
|
|
675
|
+
url: process.env.REDIS_URL,
|
|
676
|
+
namespace: "news-site",
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
646
680
|
### `cache().prewarm`
|
|
647
681
|
|
|
648
682
|
| Field | Type | Default | Meaning |
|
|
@@ -764,7 +798,7 @@ and no warning is printed.
|
|
|
764
798
|
| --- | --- | --- | --- |
|
|
765
799
|
| `NODE_ENV` | everywhere | `production` (start/build), `development` (dev) | Determines the dev overlay, EJS cache, manifest re-reading, route error behaviour and prewarm defaults. `jskelet dev` sets it itself — `cross-env` is not needed. |
|
|
766
800
|
| `PORT` | `startServer` | `3000` | Port to listen on |
|
|
767
|
-
| `HOST` | `startServer` | `0.0.0.0`
|
|
801
|
+
| `HOST` | `startServer` | `::` | Interface to bind to. The default listens dual-stack (IPv6 + IPv4); it falls back to `0.0.0.0` where IPv6 is unavailable |
|
|
768
802
|
| `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
|
|
769
803
|
| `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) |
|
|
770
804
|
| `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
|
package/docs/en/10-deployment.md
CHANGED
|
@@ -44,7 +44,7 @@ considering in production:
|
|
|
44
44
|
| --- | --- | --- |
|
|
45
45
|
| `NODE_ENV` | `production` | Template cache, reading the manifest once, throwing on a broken route module |
|
|
46
46
|
| `PORT` | `3000` | The port your orchestrator expects |
|
|
47
|
-
| `HOST` | `0.0.0.0` |
|
|
47
|
+
| `HOST` | `0.0.0.0` | Only if you need to listen on IPv4 alone; the `::` default already listens dual-stack |
|
|
48
48
|
| `PREWARM_MAX` | Depends on site size | Number of pages warmed at startup |
|
|
49
49
|
| `PREWARM_INTERVAL_SECONDS` | `0` or a long value | If you want to keep never-visited pages warm |
|
|
50
50
|
| `DEV_TOKEN` | Staging only | Hides an environment that is not public yet |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@phosphor-icons/core": "^2.1.1",
|
|
68
68
|
"@tailwindcss/postcss": "^4.3.3",
|
|
69
|
+
"ioredis": "^5.4.0 || ^6.0.0",
|
|
69
70
|
"lightningcss": "^1.32.0",
|
|
70
71
|
"postcss": "^8.5.26",
|
|
71
72
|
"sharp": "^0.35.4",
|
|
@@ -78,6 +79,9 @@
|
|
|
78
79
|
"@tailwindcss/postcss": {
|
|
79
80
|
"optional": true
|
|
80
81
|
},
|
|
82
|
+
"ioredis": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
81
85
|
"lightningcss": {
|
|
82
86
|
"optional": true
|
|
83
87
|
},
|
package/src/build/paths.mjs
CHANGED
|
@@ -74,7 +74,32 @@ function manifestFile() {
|
|
|
74
74
|
/** @param {Record<string, string>} manifest */
|
|
75
75
|
export function writeManifest(manifest) {
|
|
76
76
|
fs.mkdirSync(paths.generated, { recursive: true });
|
|
77
|
-
|
|
77
|
+
const json = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
78
|
+
fs.writeFileSync(manifestFile(), json);
|
|
79
|
+
writeBuildId(json);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build kimliği: manifest içeriğinin hash'i.
|
|
84
|
+
*
|
|
85
|
+
* Paylaşımlı bir önbellekte (Redis) HTML anahtarları bu kimliği taşımak
|
|
86
|
+
* zorunda. Saklanan HTML hash'li varlık yollarını gömüyor; yeni bir deploy'dan
|
|
87
|
+
* sonra eski HTML başka bir node'dan geri gelirse artık var olmayan
|
|
88
|
+
* `/assets/app.<eskihash>.css` istenir ve sayfa stilsiz kalır. Kimlik önekte
|
|
89
|
+
* durduğunda yeni build kendiliğinden yeni bir isim alanına yazar, eskisi TTL
|
|
90
|
+
* ile ölür — elle temizlik gerekmez.
|
|
91
|
+
*
|
|
92
|
+
* Kimlik manifest'in **içine** yazılmaz: oradaki her anahtar `asset()`
|
|
93
|
+
* yüzeyine sızıyor.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} manifestJson
|
|
96
|
+
*/
|
|
97
|
+
function writeBuildId(manifestJson) {
|
|
98
|
+
const payload = { id: hash(manifestJson), createdAt: new Date().toISOString() };
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(paths.generated, "build.json"),
|
|
101
|
+
`${JSON.stringify(payload, null, 2)}\n`,
|
|
102
|
+
);
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
/**
|
package/src/config/defaults.js
CHANGED
|
@@ -120,6 +120,41 @@ export const DEFAULT_DATA_CACHE = {
|
|
|
120
120
|
staleFactor: 10,
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Opsiyonel Redis ikinci kademesi (L2).
|
|
125
|
+
*
|
|
126
|
+
* Redis **birincil store değil**: bellek içi önbellek (L1) aynen kalır, Redis
|
|
127
|
+
* iki iş yapar — L1'de bulunmayan bir sayfa için render'ı atlatmak ve
|
|
128
|
+
* invalidation'ı bütün node'lara yaymak. Tek instance çalışan bir kurulumda
|
|
129
|
+
* kazanç neredeyse yok; bu yüzden `enabled` varsayılan olarak kapalı.
|
|
130
|
+
*
|
|
131
|
+
* `storeEncoded` kapalı, çünkü sıkıştırılmış gövdeleri de paylaşmak girdi
|
|
132
|
+
* başına boyutu iki-üç katına çıkarır ve brotli'yi yeniden üretmek Redis'ten
|
|
133
|
+
* indirmekten çoğu zaman daha ucuz.
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_REDIS = {
|
|
136
|
+
enabled: false,
|
|
137
|
+
/** `redis://` ya da `rediss://`. Boşsa ioredis varsayılanı (localhost:6379). */
|
|
138
|
+
url: /** @type {string | null} */ (null),
|
|
139
|
+
/** Aynı Redis'i paylaşan birden fazla uygulamayı ayırır. */
|
|
140
|
+
namespace: "default",
|
|
141
|
+
keyPrefix: "_jskelet",
|
|
142
|
+
/** HTML gövdeleri paylaşılsın mı. */
|
|
143
|
+
html: true,
|
|
144
|
+
/** Veri önbelleği paylaşılsın mı. */
|
|
145
|
+
data: true,
|
|
146
|
+
/** Brotli/gzip gövdeleri de paylaşılsın mı. */
|
|
147
|
+
storeEncoded: false,
|
|
148
|
+
/** pub/sub üzerinden invalidation yayını. */
|
|
149
|
+
events: true,
|
|
150
|
+
/**
|
|
151
|
+
* Tek bir komutun en fazla bekletebileceği süre. Önbellek okuması isteği
|
|
152
|
+
* bloklayan bir adım: Redis takıldığında render'a düşmek, ağı beklemekten
|
|
153
|
+
* iyidir.
|
|
154
|
+
*/
|
|
155
|
+
commandTimeoutMs: 200,
|
|
156
|
+
};
|
|
157
|
+
|
|
123
158
|
/** Oturuma bağlı sayfalar ısıtılmaz; uygulama kendi listesini verebilir. */
|
|
124
159
|
export const DEFAULT_PREWARM_SKIP = ["/api/", "/_fragment/", "/__jskelet/"];
|
|
125
160
|
|