jskelet 0.5.1 → 0.5.3

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,25 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - HTML cache key vary (`cache().vary`): `host: true` adds the public Host
14
+ (`x-forwarded-host` or `Host`, lowercase, no port) as `h=…|` before the path;
15
+ optional `headers` and `fn(req)` add further segments. Required on host-based
16
+ locale sites so one locale's HTML is not served on another. Classic prewarm
17
+ accepts `prewarm.origins` for multi-host warming when vary is on.
18
+
19
+ ### Changed
20
+
21
+ - Marketing example visual language: darker ink canvas, solid cyan primary
22
+ CTAs, cyan-only glow/grid (indigo accents removed), and a measured trust
23
+ bar on the homepage (payload gzip, Node, license, zero web fonts) instead
24
+ of the marquee.
25
+
26
+ ### Added
27
+
28
+ - Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`):
29
+ `ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or
30
+ raw SVG into PNG when `sharp` is installed (SVG fallback otherwise). Wired in
31
+ `examples/blog` as `/og/blog/:slug.png` and `metadata.openGraph.image`.
13
32
  - Early HTML cache refresh before TTL expiry: the last successful produce time
14
33
  (`produceMs`) sets a lead window (`min(max(produceMs×2, 250ms), ttl/2)`). A
15
34
  still-fresh `HIT` in that window revalidates in the background; idle entries
@@ -35,6 +35,10 @@ gerekmez:
35
35
  | `redirect` | `jskelet` → `redirect` |
36
36
  | `permanentRedirect` | `jskelet` → `permanentRedirect` |
37
37
  | `seeOther` | `jskelet` → `seeOther` |
38
+ | `ogHandler` | `jskelet` → `ogHandler` |
39
+ | `ogImage` | `jskelet` → `ogImage` |
40
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
41
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
38
42
 
39
43
  İstersen doğrudan import da edebilirsin; `api` yalnızca kolaylık:
40
44
 
@@ -511,6 +511,64 @@ return {
511
511
  `renderHeadMeta(metadata)` fonksiyonu dışa açıktır; layout dışında (ör. bir
512
512
  fragment ya da e-posta) aynı etiketleri üretmek gerekirse kullanılabilir.
513
513
 
514
+ ## Dinamik OG görselleri
515
+
516
+ Next.js `ImageResponse` / `opengraph-image.tsx` karşılığı. JSX yok: kart
517
+ alanları (`title`, `description`, `siteName`, renkler) ya da ham `svg` verilir.
518
+ `sharp` (opsiyonel peer) kuruluysa PNG, yoksa SVG döner. Sosyal kazıyıcıların
519
+ çoğu PNG beklediği için prod'da `sharp` önerilir.
520
+
521
+ HTML değil görsel döndüğü için `route()` kullanılmaz — `ogHandler` düz bir
522
+ Express handler üretir. `notFound()` ve `null` dönüşü 404 olur.
523
+
524
+ ```js
525
+ // routes/35-og.mjs
526
+ export default function register(app, { ogHandler, notFound }) {
527
+ app.get(
528
+ "/og/blog/:slug.png",
529
+ ogHandler(async ({ params }) => {
530
+ const post = getPost(params.slug);
531
+ if (!post) notFound();
532
+ return {
533
+ title: post.title,
534
+ description: post.excerpt,
535
+ siteName: "Blog",
536
+ };
537
+ }),
538
+ );
539
+ }
540
+ ```
541
+
542
+ Sayfa metadata'sında mutlak URL ve boyut verin:
543
+
544
+ ```js
545
+ openGraph: {
546
+ type: "article",
547
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
548
+ imageWidth: 1200,
549
+ imageHeight: 630,
550
+ },
551
+ ```
552
+
553
+ Ham SVG veya Next benzeri sınıf:
554
+
555
+ ```js
556
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
557
+
558
+ app.get("/og/custom.png", async (req, res) => {
559
+ const image = new ImageResponse(
560
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
561
+ OG_SIZE,
562
+ );
563
+ await image.send(res);
564
+ // veya: await sendOgImage(res, { title: "…", format: "svg" });
565
+ });
566
+ ```
567
+
568
+ Varsayılan `Cache-Control`:
569
+ `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
570
+ `cacheControl` seçeneğiyle ezilir. Çalışan örnek: `examples/blog/routes/35-og.mjs`.
571
+
514
572
  ## Hook'lar
515
573
 
516
574
  Hook'lar `jskelet.config.mjs` → `hooks` altında tanımlanır. Hepsi opsiyonel,
package/docs/06-cache.md CHANGED
@@ -43,8 +43,9 @@ sayfa da ilk ziyaretçide milisaniyeler içinde üretilir ve kota harcamaz.
43
43
  ## Public ve kişiye özel ayrımı
44
44
 
45
45
  Bu belgedeki her şey **herkese aynı gidebilen** HTML için geçerli. Cache
46
- anahtarında kimlik yok (yalnızca yol + query), yani önbellekteki bir sayfa onu
47
- ilk isteyen kişinin değil, o yolun cevabıdır.
46
+ anahtarında kimlik yok (yalnızca yol + query + isteğe bağlı `vary`); yani
47
+ önbellekteki bir sayfa onu ilk isteyen kişinin değil, o yolun (ve vary
48
+ parçalarının) cevabıdır.
48
49
 
49
50
  Kullanıcıya bağlı bir sayfa bu yüzden ayrı bir yoldan geçer:
50
51
 
@@ -110,13 +111,14 @@ saklayabiliyordu.
110
111
  ## Cache anahtarı
111
112
 
112
113
  ```
113
- `${yol}?${izin verilen query parametreleri, sıralı}`
114
+ `${varyPrefix}${yol}?${izin verilen query parametreleri, sıralı}`
114
115
  ```
115
116
 
116
- Query'siz bir istek için anahtar yalnızca yoldur. **Query parametresi taşıyan
117
- istek varsayılan olarak dinamiktir**: önbelleğe hiç girmez ve `private,
118
- no-store` ile gider. Bir yolun bütün varyantlarını cache'lemek `?utm_source=…`
119
- gibi sonsuz sayıda anahtar üretiyor ve 500 girdilik store'da LRU, gerçek
117
+ `varyPrefix` varsayılan olarak boştur. Query'siz bir istek için anahtar
118
+ `${varyPrefix}${yol}?` biçimindedir. **Query parametresi taşıyan istek
119
+ varsayılan olarak dinamiktir**: önbelleğe hiç girmez ve `private, no-store`
120
+ ile gider. Bir yolun bütün varyantlarını cache'lemek `?utm_source=…` gibi
121
+ sonsuz sayıda anahtar üretiyor ve 500 girdilik store'da LRU, gerçek
120
122
  sayfaları kampanya varyantları için dışarı atıyor.
121
123
 
122
124
  Hangi parametrenin çıktıyı gerçekten değiştirdiğini uygulama bildirir —
@@ -135,6 +137,48 @@ Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (dikkat: gir
135
137
  sayısını sınırlayan tek şey `maxEntries` olur), `[]` ile eşlenirse query tamamen
136
138
  yok sayılır. Ayrıntı: [07-yapilandirma.md](./07-yapilandirma.md).
137
139
 
140
+ ### Host / locale: `cache().vary`
141
+
142
+ CDN zaten tam URL ile ayırır; asıl risk **origin L1** ve Redis HTML anahtarıdır.
143
+ Host'tan locale üreten sitelerde (`tr.example.com` / `en.example.com`) vary
144
+ olmadan ilk locale'in HTML'i diğer host'a servis edilir — Express 5'te istek
145
+ nesnesine locale yazmak kırılgan bir kaçış yoludur.
146
+
147
+ ```js
148
+ cache: () => ({
149
+ html: { "/": 300, "/instruments/:slug": 300 },
150
+ vary: {
151
+ // true → public Host (x-forwarded-host || host), lowercase, portsuz
152
+ host: true,
153
+ // veya özel:
154
+ // headers: ["x-locale"],
155
+ // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
156
+ },
157
+ }),
158
+ ```
159
+
160
+ Örnek anahtarlar: `h=tr.investvio.com|/instruments/aapl?`,
161
+ `h=tr.example.com&l=tr|/…?`.
162
+
163
+ | Alan | Tip | Anlamı |
164
+ | --- | --- | --- |
165
+ | `host` | `boolean` | Public Host'u `h=…` olarak anahtara ekler |
166
+ | `headers` | `string[]` | Verilen istek başlıklarını (`ad=değer`) ekler |
167
+ | `fn` | `(req) => string \| null` | Dönüş değeri bir segment olarak eklenir (tam kontrol) |
168
+
169
+ **Prewarm:** varsayılan ısıtma `http://127.0.0.1:<port>` üzerinden gider.
170
+ `vary.host` açıksa bu yalnızca loopback anahtarını ısıtır; locale sitelerinde
171
+ çoklu origin gerekir:
172
+
173
+ ```js
174
+ prewarm: {
175
+ origins: ["http://localhost", "http://tr.localhost"],
176
+ },
177
+ ```
178
+
179
+ Port yazılmazsa dinleme portu eklenir. `onVisit` modunda ısıtma, vary açıkken
180
+ ziyaretçinin `Host` başlığını kullanır.
181
+
138
182
  ## Stale-while-revalidate
139
183
 
140
184
  Girdi yapısı:
@@ -771,7 +815,7 @@ her istek ağ zaman aşımı beklemez.
771
815
  ### Anahtar düzeni
772
816
 
773
817
  ```
774
- _jskelet:{namespace}:{buildId}:html:{yol}?{query}
818
+ _jskelet:{namespace}:{buildId}:html:{vary|}{yol}?{query}
775
819
  _jskelet:{namespace}:{buildId}:data:{anahtar}
776
820
  _jskelet:{namespace}:events
777
821
  ```
@@ -1074,9 +1118,12 @@ da trafik geldikçe (`onVisit`) yapılır. Kazanç aynı — tıklanan / komşu
1074
1118
  soğuk render'ı beklemez — fakat veri dondurulmaz; her girdi route'un
1075
1119
  `revalidate` süresiyle yaşlanır ve stale-while-revalidate ile arkada tazelenir.
1076
1120
 
1077
- Isıtma **gerçek HTTP istekleriyle** yapılır (`http://127.0.0.1:<port>`), çünkü
1078
- cache anahtarı, sıkıştırma ve middleware zinciri normal trafikle bire bir aynı
1079
- olsun.
1121
+ Isıtma **gerçek HTTP istekleriyle** yapılır (`http://127.0.0.1:<port>` ya da
1122
+ `cache().prewarm.origins`), çünkü cache anahtarı, sıkıştırma ve middleware
1123
+ zinciri normal trafikle bire bir aynı olsun. `vary.host` açıksa varsayılan
1124
+ loopback yalnızca o host'un anahtarını ısıtır — locale sitelerinde
1125
+ `origins: ["http://localhost", "http://tr.localhost"]` gibi çoklu origin
1126
+ gerekir.
1080
1127
 
1081
1128
  İki mod **karşılıklı dışlayıcıdır**. `cache().prewarm.onVisit` açıksa klasik
1082
1129
  alanlar (`max`, `priority`, `rotate`, `intervalSeconds`, …) ve
@@ -1336,6 +1383,10 @@ turunun gerçekten `MISS` → önbellek doldurup doldurmadığını buradan gör
1336
1383
  fazla `revalidate` + bir tazeleme turudur.
1337
1384
  - **Önbellek şişiyor.** Query parametreleri anahtara girdiği için kampanya
1338
1385
  parametreleri girdi çoğaltıyor olabilir.
1386
+ - **Yanlış dil / host HTML'i geliyor.** Host'tan locale üreten bir sitede
1387
+ `cache().vary.host: true` yoksa ilk locale'in HTML'i diğer host'a servis
1388
+ edilir. Prewarm yalnızca `127.0.0.1` ile ısınıyorsa `prewarm.origins` ile
1389
+ locale host'larını ekleyin.
1339
1390
  - **Isıtma hiç çalışmıyor.** Klasik modda `hooks.prewarmPaths` tanımlı değil,
1340
1391
  `PREWARM=0` ayarlı ya da `cache().prewarm.enabled === false`. `onVisit`
1341
1392
  modunda `listen` sonrası logda `onVisit mode` satırını ve public cache'li
@@ -614,9 +614,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
614
614
  ## `cache()`
615
615
 
616
616
  **Tip:**
617
- `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
617
+ `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, vary?: { host?: boolean, headers?: string[], fn?: (req) => string | null }, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
618
618
  **Varsayılan:**
619
- `{ html: {}, query: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
619
+ `{ html: {}, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0, origins: [] } }`
620
620
 
621
621
  ### `cache().html`
622
622
 
@@ -671,6 +671,30 @@ Parametreler anahtara **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı gi
671
671
  paylaşır. `route(fn, { private: true })` bu bölümden etkilenmez; private route
672
672
  hiçbir koşulda cache'lenmez.
673
673
 
674
+ ### `cache().vary`
675
+
676
+ HTML cache anahtarına query allowlist'ten **bağımsız** sabit parçalar ekler.
677
+ Host'tan locale üreten sitelerde `host: true` **zorunlu**; aksi halde ilk
678
+ locale'in HTML'i diğer host'a servis edilir. CDN zaten tam URL ile ayırır —
679
+ bu ayar origin L1 ve Redis HTML anahtarı içindir.
680
+
681
+ ```js
682
+ vary: {
683
+ host: true, // h=tr.example.com|…
684
+ // headers: ["x-locale"],
685
+ // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
686
+ }
687
+ ```
688
+
689
+ | Alan | Tip | Varsayılan | Anlamı |
690
+ | --- | --- | --- | --- |
691
+ | `host` | `boolean` | `false` | Public Host (`x-forwarded-host` yoksa `Host`), lowercase, portsuz → `h=…` |
692
+ | `headers` | `string[]` | `[]` | İstek başlıkları `ad=değer` olarak eklenir |
693
+ | `fn` | `(req) => string \| null` | — | Dönüş bir segment olarak eklenir |
694
+
695
+ Anahtar biçimi: `${vary}|${yol}?${query}` (vary yoksa önek yok). Ayrıntı:
696
+ [06-cache.md](./06-cache.md).
697
+
674
698
  ### `cache().maxEntries`
675
699
 
676
700
  **Tip:** `number` — **Varsayılan:** `500`
@@ -900,6 +924,7 @@ sayfadaki linkler). Birlikte verilemez — config yüklenirken hata.
900
924
  | `intervalSeconds` | `number` | `0` | 0'dan büyükse tur periyodik tekrarlanır |
901
925
  | `rotate` | `boolean` | `true` | Liste `max`'tan uzunsa periyodik turlar kaldığı yerden devam eder |
902
926
  | `priority` | `(string \| RegExp)[]` | `[]` | Isıtma sırası; eşleşen yollar her turda başa alınır |
927
+ | `origins` | `string[]` | `[]` | Klasik turda ısıtılacak origin'ler. Boşsa `http://127.0.0.1:<port>`. `vary.host` açıksa locale host'ları buraya yazın |
903
928
 
904
929
  `priority` iki biçim kabul eder: config'in her yerinde geçerli olan desen
905
930
  sözdizimi ve doğrudan `RegExp`. Önce yazılan önce ısınır.
@@ -909,6 +934,8 @@ prewarm: {
909
934
  max: 500,
910
935
  rps: 4,
911
936
  intervalSeconds: 300,
937
+ // vary.host açıksa loopback tek başına yetmez:
938
+ origins: ["http://localhost", "http://tr.localhost"],
912
939
  priority: [
913
940
  "/", // ana sayfa
914
941
  "/piyasalar/:path*", // tüm piyasa bölümü
package/docs/11-tasima.md CHANGED
@@ -40,6 +40,7 @@ alt kümesine benzetildi — `next.config` sözdizimi, Metadata API, `notFound()
40
40
  | `loading.js` / Suspense | — | Sunucu HTML'i tam; iskelet gerekmiyor |
41
41
  | Streaming SSR | — | Yanıt tek parça |
42
42
  | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Aynı alan adları ([04](./04-render-ve-sablonlar.md)) |
43
+ | `opengraph-image.tsx` / `ImageResponse` | `ogHandler` + `ImageResponse` / `sendOgImage` | SVG veya kart alanları → PNG (`sharp`); [04](./04-render-ve-sablonlar.md) |
43
44
  | `generateStaticParams()` | `hooks.prewarmPaths()` | Build zamanı değil, açılış zamanı ısıtma |
44
45
  | Route Handlers (`route.js`) | Düz Express handler'ı | `app.get/post(...)` |
45
46
  | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
@@ -68,6 +69,7 @@ başlığı elle okuyun.
68
69
  | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` otomatik, dış bağlantıya `rel`/`target` otomatik |
69
70
  | `next/link` prefetch'i | `navigation: { prefetch, prerender }` | Speculation Rules; client runtime'ı yok ([07](./07-yapilandirma.md)) |
70
71
  | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` build manifest'inden |
72
+ | `next/og` `ImageResponse` | `ImageResponse` / `ogHandler` — `jskelet` | JSX yok; SVG veya `title`/`description` kartı |
71
73
  | `next/font/google` | `fonts: [{ family, weights }]` | Self-host woff2, commit edilir |
72
74
  | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build zamanı SVG sprite |
73
75
  | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-render-ve-sablonlar.md)) |
package/docs/README.md CHANGED
@@ -57,8 +57,9 @@ npm --prefix examples/minimal run dev
57
57
 
58
58
  **`examples/blog/`** — dinamik route (`/blog/:slug`), etiket sayfaları,
59
59
  `redirects`/`rewrites`/`headers`/`cache` yapılandırmasının tamamı, fragment ile
60
- gelen sekme panelleri, form gönderimi, prewarm, `robots.txt`/`sitemap.xml`/`rss.xml`
61
- ve dört island (tema, sekme, arama, form).
60
+ gelen sekme panelleri, form gönderimi, prewarm, `robots.txt`/`sitemap.xml`/`rss.xml`,
61
+ dinamik OG görselleri (`/og/blog/:slug.png`) ve dört island (tema, sekme, arama,
62
+ form).
62
63
 
63
64
  ```bash
64
65
  npm --prefix examples/blog install
@@ -36,6 +36,10 @@ don't have to import things one by one in every file:
36
36
  | `redirect` | `jskelet` → `redirect` |
37
37
  | `permanentRedirect` | `jskelet` → `permanentRedirect` |
38
38
  | `seeOther` | `jskelet` → `seeOther` |
39
+ | `ogHandler` | `jskelet` → `ogHandler` |
40
+ | `ogImage` | `jskelet` → `ogImage` |
41
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
42
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
39
43
 
40
44
  You can also import directly if you prefer; `api` is only a convenience:
41
45
 
@@ -522,6 +522,64 @@ The `renderHeadMeta(metadata)` function is exported; it can be used when you
522
522
  need to produce the same tags outside the layout (for example in a fragment or
523
523
  an email).
524
524
 
525
+ ## Dynamic OG images
526
+
527
+ Counterpart to Next.js `ImageResponse` / `opengraph-image.tsx`. There is no JSX:
528
+ pass card fields (`title`, `description`, `siteName`, colours) or a raw `svg`.
529
+ With the optional `sharp` peer installed the response is PNG; otherwise SVG.
530
+ Most social scrapers expect PNG, so install `sharp` in production.
531
+
532
+ Because the response is an image, not HTML, do not use `route()` — `ogHandler`
533
+ returns a plain Express handler. `notFound()` and a `null` return yield 404.
534
+
535
+ ```js
536
+ // routes/35-og.mjs
537
+ export default function register(app, { ogHandler, notFound }) {
538
+ app.get(
539
+ "/og/blog/:slug.png",
540
+ ogHandler(async ({ params }) => {
541
+ const post = getPost(params.slug);
542
+ if (!post) notFound();
543
+ return {
544
+ title: post.title,
545
+ description: post.excerpt,
546
+ siteName: "Blog",
547
+ };
548
+ }),
549
+ );
550
+ }
551
+ ```
552
+
553
+ Point page metadata at the absolute URL and size:
554
+
555
+ ```js
556
+ openGraph: {
557
+ type: "article",
558
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
559
+ imageWidth: 1200,
560
+ imageHeight: 630,
561
+ },
562
+ ```
563
+
564
+ Raw SVG or a Next-like class:
565
+
566
+ ```js
567
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
568
+
569
+ app.get("/og/custom.png", async (req, res) => {
570
+ const image = new ImageResponse(
571
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
572
+ OG_SIZE,
573
+ );
574
+ await image.send(res);
575
+ // or: await sendOgImage(res, { title: "…", format: "svg" });
576
+ });
577
+ ```
578
+
579
+ Default `Cache-Control`:
580
+ `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
581
+ Override with `cacheControl`. Working example: `examples/blog/routes/35-og.mjs`.
582
+
525
583
  ## Hooks
526
584
 
527
585
  Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
@@ -46,9 +46,9 @@ milliseconds on the first visit, and spends no quota.
46
46
  ## Public versus per-visitor
47
47
 
48
48
  Everything in this document applies to HTML that **can go to everyone
49
- unchanged**. There is no identity in the cache key (only path + query), so a
50
- page in the cache is the answer for that path, not the answer for whoever asked
51
- for it first.
49
+ unchanged**. There is no identity in the cache key (only path + query + optional
50
+ `vary`), so a page in the cache is the answer for that path (and vary parts),
51
+ not the answer for whoever asked for it first.
52
52
 
53
53
  A page that depends on the user therefore takes a separate path:
54
54
 
@@ -117,14 +117,15 @@ The cache also only kicks in for `GET` requests.
117
117
  ## The cache key
118
118
 
119
119
  ```
120
- `${path}?${the allowed query parameters, sorted}`
120
+ `${varyPrefix}${path}?${the allowed query parameters, sorted}`
121
121
  ```
122
122
 
123
- For a request without a query the key is just the path. **A request that carries
124
- a query parameter is dynamic by default**: it never enters the cache and is sent
125
- with `private, no-store`. Caching every variant of a path mints an unbounded
126
- number of keys (`?utm_source=…` and friends), and in a 500-entry store LRU then
127
- evicts the real pages in favour of campaign variants.
123
+ `varyPrefix` is empty by default. For a request without a query the key is
124
+ `${varyPrefix}${path}?`. **A request that carries a query parameter is dynamic
125
+ by default**: it never enters the cache and is sent with `private, no-store`.
126
+ Caching every variant of a path mints an unbounded number of keys
127
+ (`?utm_source=…` and friends), and in a 500-entry store LRU then evicts the
128
+ real pages in favour of campaign variants.
128
129
 
129
130
  Which parameter actually changes the output is declared by the application, in
130
131
  `jskelet.config.mjs` → `cache().query`:
@@ -143,6 +144,49 @@ the key (careful: nothing but `maxEntries` then bounds the entry count), and one
143
144
  mapped to `[]` ignores the query entirely. Details:
144
145
  [07-configuration.md](./07-configuration.md).
145
146
 
147
+ ### Host / locale: `cache().vary`
148
+
149
+ A CDN already separates by full URL; the real risk is the **origin L1** and the
150
+ Redis HTML key. On sites that derive locale from the host
151
+ (`tr.example.com` / `en.example.com`), without vary the first locale's HTML is
152
+ served to the other host — mutating the request object for locale is a fragile
153
+ workaround under Express 5.
154
+
155
+ ```js
156
+ cache: () => ({
157
+ html: { "/": 300, "/instruments/:slug": 300 },
158
+ vary: {
159
+ // true → public Host (x-forwarded-host || host), lowercase, no port
160
+ host: true,
161
+ // or custom:
162
+ // headers: ["x-locale"],
163
+ // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
164
+ },
165
+ }),
166
+ ```
167
+
168
+ Example keys: `h=tr.investvio.com|/instruments/aapl?`,
169
+ `h=tr.example.com&l=tr|/…?`.
170
+
171
+ | Field | Type | Meaning |
172
+ | --- | --- | --- |
173
+ | `host` | `boolean` | Adds the public Host as `h=…` |
174
+ | `headers` | `string[]` | Adds the given request headers as `name=value` |
175
+ | `fn` | `(req) => string \| null` | Appends the return value as a segment (full control) |
176
+
177
+ **Prewarm:** the default warm-up goes through `http://127.0.0.1:<port>`. With
178
+ `vary.host` that only warms the loopback key; locale sites need multiple
179
+ origins:
180
+
181
+ ```js
182
+ prewarm: {
183
+ origins: ["http://localhost", "http://tr.localhost"],
184
+ },
185
+ ```
186
+
187
+ If no port is written, the listen port is added. In `onVisit` mode, when vary
188
+ is on, warming uses the visitor's `Host` header.
189
+
146
190
  ## Stale-while-revalidate
147
191
 
148
192
  The entry structure:
@@ -788,7 +832,7 @@ five consecutive failures, so requests do not each wait for a network timeout.
788
832
  ### Key layout
789
833
 
790
834
  ```
791
- _jskelet:{namespace}:{buildId}:html:{path}?{query}
835
+ _jskelet:{namespace}:{buildId}:html:{vary|}{path}?{query}
792
836
  _jskelet:{namespace}:{buildId}:data:{key}
793
837
  _jskelet:{namespace}:events
794
838
  ```
@@ -1075,8 +1119,11 @@ but the data is not frozen; every entry ages with the route's `revalidate` and
1075
1119
  is refreshed in the background with stale-while-revalidate.
1076
1120
 
1077
1121
  The warm-up is done with **real HTTP requests**
1078
- (`http://127.0.0.1:<port>`), so that the cache key, the compression and the
1079
- middleware chain are exactly the same as with normal traffic.
1122
+ (`http://127.0.0.1:<port>` or `cache().prewarm.origins`), so that the cache key,
1123
+ the compression and the middleware chain are exactly the same as with normal
1124
+ traffic. With `vary.host`, the default loopback only warms that host's key —
1125
+ locale sites need multiple origins such as
1126
+ `origins: ["http://localhost", "http://tr.localhost"]`.
1080
1127
 
1081
1128
  The two modes are **mutually exclusive**. If `cache().prewarm.onVisit` is on,
1082
1129
  classic fields (`max`, `priority`, `rotate`, `intervalSeconds`, …) and
@@ -1339,6 +1386,10 @@ filled the cache.
1339
1386
  lag is at most `revalidate` + one refresh round.
1340
1387
  - **The cache is bloating.** Because query parameters go into the key, campaign
1341
1388
  parameters may be multiplying entries.
1389
+ - **Wrong language / host HTML.** On a site that derives locale from the host,
1390
+ without `cache().vary.host: true` the first locale's HTML is served to the
1391
+ other host. If prewarm only hits `127.0.0.1`, add the locale hosts via
1392
+ `prewarm.origins`.
1342
1393
  - **The warm-up never runs.** In classic mode `hooks.prewarmPaths` is not
1343
1394
  defined, `PREWARM=0` is set, or `cache().prewarm.enabled === false`. In
1344
1395
  `onVisit` mode check the `onVisit mode` log line after `listen` and that a
@@ -627,9 +627,9 @@ Details: [03-routing.md](./03-routing.md).
627
627
  ## `cache()`
628
628
 
629
629
  **Type:**
630
- `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
630
+ `() => { html?: Record<string, number>, query?: Record<string, string[] | true>, vary?: { host?: boolean, headers?: string[], fn?: (req) => string | null }, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
631
631
  **Default:**
632
- `{ html: {}, query: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
632
+ `{ html: {}, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0, origins: [] } }`
633
633
 
634
634
  ### `cache().html`
635
635
 
@@ -684,6 +684,31 @@ Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
684
684
  share one entry. `route(fn, { private: true })` is unaffected by this section; a
685
685
  private route is never cached under any condition.
686
686
 
687
+ ### `cache().vary`
688
+
689
+ Adds fixed segments to the HTML cache key **independently** of the query
690
+ allowlist. On sites that derive locale from the host, `host: true` is
691
+ **required**; otherwise the first locale's HTML is served to the other host. A
692
+ CDN already separates by full URL — this setting is for the origin L1 and the
693
+ Redis HTML key.
694
+
695
+ ```js
696
+ vary: {
697
+ host: true, // h=tr.example.com|…
698
+ // headers: ["x-locale"],
699
+ // fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
700
+ }
701
+ ```
702
+
703
+ | Field | Type | Default | Meaning |
704
+ | --- | --- | --- | --- |
705
+ | `host` | `boolean` | `false` | Public Host (`x-forwarded-host` else `Host`), lowercase, no port → `h=…` |
706
+ | `headers` | `string[]` | `[]` | Request headers added as `name=value` |
707
+ | `fn` | `(req) => string \| null` | — | Return value appended as a segment |
708
+
709
+ Key shape: `${vary}|${path}?${query}` (no prefix when vary is empty). Details:
710
+ [06-caching.md](./06-caching.md).
711
+
687
712
  ### `cache().maxEntries`
688
713
 
689
714
  **Type:** `number` — **Default:** `500`
@@ -920,6 +945,7 @@ page just visited). They cannot be combined — config load throws.
920
945
  | `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
921
946
  | `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
922
947
  | `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
948
+ | `origins` | `string[]` | `[]` | Origins for the classic pass. Empty → `http://127.0.0.1:<port>`. With `vary.host`, list the locale hosts here |
923
949
 
924
950
  `priority` accepts two forms: the pattern syntax used everywhere in the config,
925
951
  and a plain `RegExp`. Whatever is written first is warmed first.
@@ -929,6 +955,8 @@ prewarm: {
929
955
  max: 500,
930
956
  rps: 4,
931
957
  intervalSeconds: 300,
958
+ // with vary.host, loopback alone is not enough:
959
+ origins: ["http://localhost", "http://tr.localhost"],
932
960
  priority: [
933
961
  "/", // the home page
934
962
  "/markets/:path*", // the whole markets section
@@ -41,6 +41,7 @@ will feel familiar. The *reasons* behind the differences are in
41
41
  | `loading.js` / Suspense | — | The server HTML is complete; no skeleton needed |
42
42
  | Streaming SSR | — | The response is a single chunk |
43
43
  | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Same field names ([04](./04-rendering.md)) |
44
+ | `opengraph-image.tsx` / `ImageResponse` | `ogHandler` + `ImageResponse` / `sendOgImage` | SVG or card fields → PNG (`sharp`); [04](./04-rendering.md) |
44
45
  | `generateStaticParams()` | `hooks.prewarmPaths()` | Warming at startup time, not build time |
45
46
  | Route Handlers (`route.js`) | A plain Express handler | `app.get/post(...)` |
46
47
  | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
@@ -69,6 +70,7 @@ header manually.
69
70
  | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` automatic, `rel`/`target` automatic for external links |
70
71
  | `next/link` prefetching | `navigation: { prefetch, prerender }` | Speculation Rules; no client runtime ([07](./07-configuration.md)) |
71
72
  | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` from the build manifest |
73
+ | `next/og` `ImageResponse` | `ImageResponse` / `ogHandler` — `jskelet` | No JSX; SVG or `title`/`description` card |
72
74
  | `next/font/google` | `fonts: [{ family, weights }]` | Self-hosted woff2, committed |
73
75
  | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build-time SVG sprite |
74
76
  | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-rendering.md)) |
package/docs/en/README.md CHANGED
@@ -62,8 +62,8 @@ npm --prefix examples/minimal run dev
62
62
  **`examples/blog/`** — a dynamic route (`/blog/:slug`), tag pages, the whole of
63
63
  the `redirects`/`rewrites`/`headers`/`cache` configuration, tab panels arriving
64
64
  as fragments, form submission, prewarm,
65
- `robots.txt`/`sitemap.xml`/`rss.xml` and four islands (theme, tabs, search,
66
- form).
65
+ `robots.txt`/`sitemap.xml`/`rss.xml`, dynamic OG images (`/og/blog/:slug.png`)
66
+ and four islands (theme, tabs, search, form).
67
67
 
68
68
  ```bash
69
69
  npm --prefix examples/blog install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,6 +87,13 @@ export const DEFAULT_PREWARM = {
87
87
  * @type {(string | RegExp)[]}
88
88
  */
89
89
  priority: [],
90
+ /**
91
+ * Klasik turda ısıtılacak origin listesi. Boşsa `http://127.0.0.1:<port>`.
92
+ * `cache().vary.host` açıkken locale host'ları buraya yazılmazsa yalnızca
93
+ * loopback anahtarı ısınır.
94
+ * @type {string[]}
95
+ */
96
+ origins: [],
90
97
  };
91
98
 
92
99
  /**
@@ -122,6 +129,7 @@ export const CLASSIC_PREWARM_KEYS = [
122
129
  "retryDelayMs",
123
130
  "rotate",
124
131
  "priority",
132
+ "origins",
125
133
  ];
126
134
 
127
135
  /**