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 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
@@ -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` | Bağlanılacak arayüz |
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 |
@@ -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` | Kapsayıcı içinde dışarıdan erişim için (varsayılan) |
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 |
@@ -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` | Interface to bind to |
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 |
@@ -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` | For external access inside a container (the default) |
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.1.7",
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
  },
@@ -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
- fs.writeFileSync(manifestFile(), `${JSON.stringify(manifest, null, 2)}\n`);
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
  /**
@@ -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