jskelet 0.5.2 → 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 +15 -0
- package/docs/06-cache.md +62 -11
- package/docs/07-yapilandirma.md +29 -2
- package/docs/en/06-caching.md +63 -12
- package/docs/en/07-configuration.md +30 -2
- package/package.json +1 -1
- package/src/config/defaults.js +8 -0
- package/src/config/index.js +49 -2
- package/src/server/cache-vary.js +113 -0
- package/src/server/html-cache.js +17 -7
- package/src/server/prewarm.js +78 -10
- package/src/server/render.js +16 -7
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,21 @@ 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
|
+
|
|
13
28
|
- Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`):
|
|
14
29
|
`ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or
|
|
15
30
|
raw SVG into PNG when `sharp` is installed (SVG fallback otherwise). Wired in
|
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
|
|
47
|
-
ilk isteyen kişinin değil, o yolun
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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>`
|
|
1078
|
-
cache anahtarı, sıkıştırma ve middleware
|
|
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
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -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/en/06-caching.md
CHANGED
|
@@ -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
|
|
50
|
-
page in the cache is the answer for that path
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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,
|
|
1079
|
-
middleware chain are exactly the same as with normal
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.5.
|
|
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",
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
/**
|
package/src/config/index.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* redirects() → [{ source, destination, permanent?, statusCode? }]
|
|
17
17
|
* rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
|
|
18
18
|
* cache() → { html?: { [source]: saniye },
|
|
19
|
-
* query?: { [source]: string[] | true },
|
|
19
|
+
* query?: { [source]: string[] | true },
|
|
20
|
+
* vary?: { host?: boolean, headers?: string[], fn?: Function },
|
|
21
|
+
* maxEntries?: number,
|
|
20
22
|
* data?: {...}, redis?: {...}, prewarm?: {...} }
|
|
21
23
|
* admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
|
|
22
24
|
* logs → { console?, kinds?, file?, s3? }
|
|
@@ -107,6 +109,10 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
107
109
|
* @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
|
|
108
110
|
* Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
|
|
109
111
|
* parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
|
|
112
|
+
* @property {{ host: boolean, headers: string[],
|
|
113
|
+
* fn: ((req: import('express').Request) => string | null | undefined) | null }} cacheVary
|
|
114
|
+
* Anahtara eklenen sabit parçalar (query allowlist'ten bağımsız). Host'tan
|
|
115
|
+
* locale üreten sitelerde `host: true` zorunlu.
|
|
110
116
|
* @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
|
|
111
117
|
* @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
|
|
112
118
|
* @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
|
|
@@ -633,10 +639,42 @@ function normalizeQueryRules(raw) {
|
|
|
633
639
|
return out;
|
|
634
640
|
}
|
|
635
641
|
|
|
642
|
+
/**
|
|
643
|
+
* `cache().vary` → HTML anahtarına host / header / özel fn parçası.
|
|
644
|
+
*
|
|
645
|
+
* @param {unknown} raw
|
|
646
|
+
* @returns {ResolvedConfig["cacheVary"]}
|
|
647
|
+
*/
|
|
648
|
+
function normalizeVary(raw) {
|
|
649
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
650
|
+
return { host: false, headers: [], fn: null };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const source = /** @type {Record<string, unknown>} */ (raw);
|
|
654
|
+
const headers = asArray(source.headers, "cache().vary.headers")
|
|
655
|
+
.filter((name) => typeof name === "string" && name)
|
|
656
|
+
.map((name) => String(name).toLowerCase());
|
|
657
|
+
|
|
658
|
+
/** @type {ResolvedConfig["cacheVary"]["fn"]} */
|
|
659
|
+
let fn = null;
|
|
660
|
+
if (typeof source.fn === "function") {
|
|
661
|
+
fn = /** @type {ResolvedConfig["cacheVary"]["fn"]} */ (source.fn);
|
|
662
|
+
} else if (source.fn != null) {
|
|
663
|
+
console.warn("[config] cache().vary.fn must be a function, ignoring it");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
host: source.host === true,
|
|
668
|
+
headers,
|
|
669
|
+
fn,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
636
673
|
/**
|
|
637
674
|
* @param {unknown} raw
|
|
638
675
|
* @returns {{ html: ResolvedConfig["html"],
|
|
639
|
-
* cacheQuery: ResolvedConfig["cacheQuery"],
|
|
676
|
+
* cacheQuery: ResolvedConfig["cacheQuery"],
|
|
677
|
+
* cacheVary: ResolvedConfig["cacheVary"], htmlMaxEntries: number,
|
|
640
678
|
* data: Record<string, unknown>, trackUpstream: boolean,
|
|
641
679
|
* trackDependencies: boolean,
|
|
642
680
|
* transientRetry: { attempts: number, delayMs: number },
|
|
@@ -664,6 +702,7 @@ function normalizeCache(raw) {
|
|
|
664
702
|
return {
|
|
665
703
|
html,
|
|
666
704
|
cacheQuery: queryRules,
|
|
705
|
+
cacheVary: normalizeVary(raw?.vary),
|
|
667
706
|
htmlMaxEntries:
|
|
668
707
|
Number.isFinite(maxEntries) && maxEntries > 0
|
|
669
708
|
? Math.floor(maxEntries)
|
|
@@ -775,6 +814,12 @@ function normalizePrewarm(raw) {
|
|
|
775
814
|
// birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
|
|
776
815
|
const classic = { ...source };
|
|
777
816
|
delete classic.onVisit;
|
|
817
|
+
|
|
818
|
+
const origins = asArray(classic.origins, "cache().prewarm.origins")
|
|
819
|
+
.filter((value) => typeof value === "string" && /^https?:\/\//i.test(value))
|
|
820
|
+
.map(String);
|
|
821
|
+
classic.origins = origins;
|
|
822
|
+
|
|
778
823
|
return {
|
|
779
824
|
...DEFAULT_PREWARM,
|
|
780
825
|
...classic,
|
|
@@ -1074,6 +1119,7 @@ export async function loadConfig(options = {}) {
|
|
|
1074
1119
|
const {
|
|
1075
1120
|
html,
|
|
1076
1121
|
cacheQuery,
|
|
1122
|
+
cacheVary,
|
|
1077
1123
|
htmlMaxEntries,
|
|
1078
1124
|
data,
|
|
1079
1125
|
trackUpstream,
|
|
@@ -1097,6 +1143,7 @@ export async function loadConfig(options = {}) {
|
|
|
1097
1143
|
rewrites: normalizeRewrites(rewrites),
|
|
1098
1144
|
html,
|
|
1099
1145
|
cacheQuery,
|
|
1146
|
+
cacheVary,
|
|
1100
1147
|
htmlMaxEntries,
|
|
1101
1148
|
data,
|
|
1102
1149
|
trackUpstream,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML cache anahtarına host / header / özel fn ile sabit vary parçası ekler.
|
|
3
|
+
*
|
|
4
|
+
* CDN zaten tam URL ile ayırır; asıl risk origin L1 ve Redis HTML anahtarı —
|
|
5
|
+
* host'tan locale üreten sitelerde `vary.host: true` olmadan ilk locale'in
|
|
6
|
+
* HTML'i diğer host'a servis edilir.
|
|
7
|
+
*/
|
|
8
|
+
import { getConfig } from "../config/index.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Public Host: `x-forwarded-host` (ilk değer) yoksa `Host`. Lowercase, portsuz.
|
|
12
|
+
* IPv6 (`[::1]:3000`) köşeli parantezleri korur.
|
|
13
|
+
*
|
|
14
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} req
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function publicHost(req) {
|
|
18
|
+
const forwarded = headerValue(req, "x-forwarded-host");
|
|
19
|
+
const raw = forwarded || headerValue(req, "host") || "";
|
|
20
|
+
const first = raw.split(",")[0].trim().toLowerCase();
|
|
21
|
+
return stripPort(first);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} host
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function stripPort(host) {
|
|
29
|
+
if (!host) return "";
|
|
30
|
+
if (host.startsWith("[")) {
|
|
31
|
+
const end = host.indexOf("]");
|
|
32
|
+
return end === -1 ? host : host.slice(0, end + 1);
|
|
33
|
+
}
|
|
34
|
+
// Birden fazla `:` → portsuz IPv6 (Host'ta nadir); tek `:` → host:port.
|
|
35
|
+
const colon = host.lastIndexOf(":");
|
|
36
|
+
if (colon === -1) return host;
|
|
37
|
+
if (host.indexOf(":") !== colon) return host;
|
|
38
|
+
return host.slice(0, colon);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} req
|
|
43
|
+
* @param {string} name
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function headerValue(req, name) {
|
|
47
|
+
const viaGet = req.get?.(name);
|
|
48
|
+
if (typeof viaGet === "string" && viaGet) return viaGet;
|
|
49
|
+
|
|
50
|
+
const raw = req.headers?.[name.toLowerCase()];
|
|
51
|
+
if (Array.isArray(raw)) return raw[0] ? String(raw[0]) : "";
|
|
52
|
+
if (raw == null) return "";
|
|
53
|
+
return String(raw);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Anahtarın başına eklenen önek: `h=tr.example.com|` veya
|
|
58
|
+
* `h=…&x-locale=tr|`. Vary yoksa boş string.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function buildVaryPrefix(req) {
|
|
64
|
+
if (!req) return "";
|
|
65
|
+
|
|
66
|
+
let vary;
|
|
67
|
+
try {
|
|
68
|
+
vary = getConfig().cacheVary;
|
|
69
|
+
} catch {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!vary || (!vary.host && !vary.headers.length && !vary.fn)) return "";
|
|
74
|
+
|
|
75
|
+
/** @type {string[]} */
|
|
76
|
+
const parts = [];
|
|
77
|
+
|
|
78
|
+
if (vary.host) {
|
|
79
|
+
const host = publicHost(req);
|
|
80
|
+
if (host) parts.push(`h=${host}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const name of vary.headers) {
|
|
84
|
+
const value = headerValue(req, name).trim();
|
|
85
|
+
if (value) parts.push(`${name}=${value}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof vary.fn === "function") {
|
|
89
|
+
try {
|
|
90
|
+
const custom = vary.fn(/** @type {import('express').Request} */ (req));
|
|
91
|
+
if (custom != null && custom !== "") parts.push(String(custom));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn("[cache] cache().vary.fn threw, ignoring it", error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return parts.length ? `${parts.join("&")}|` : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Anahtardan yol kısmını çıkarır (`[vary|]yol?query` → `yol`).
|
|
102
|
+
* Invalidation hedefleri `/…` ile başlar; vary öneki eşleşmeye karışmamalı.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} key
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function pathOfCacheKey(key) {
|
|
108
|
+
const mark = key.indexOf("?");
|
|
109
|
+
const beforeQuery = mark === -1 ? key : key.slice(0, mark);
|
|
110
|
+
const sep = beforeQuery.indexOf("|/");
|
|
111
|
+
if (sep !== -1) return beforeQuery.slice(sep + 1);
|
|
112
|
+
return beforeQuery;
|
|
113
|
+
}
|
package/src/server/html-cache.js
CHANGED
|
@@ -34,6 +34,7 @@ import { getConfig } from "../config/index.js";
|
|
|
34
34
|
import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
|
|
35
35
|
import { collectDependencies } from "./cache-deps.js";
|
|
36
36
|
import { compilePattern, matchPattern } from "../config/pattern.js";
|
|
37
|
+
import { pathOfCacheKey } from "./cache-vary.js";
|
|
37
38
|
import {
|
|
38
39
|
cacheKey,
|
|
39
40
|
onCacheEvent,
|
|
@@ -662,8 +663,9 @@ function invalidateKey(key, hard) {
|
|
|
662
663
|
* arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
|
|
663
664
|
* HTML'in gerçekten geçersiz olduğu durumlar için.
|
|
664
665
|
*
|
|
665
|
-
* Anahtar `yol?query`
|
|
666
|
-
* yolun bütün query
|
|
666
|
+
* Anahtar `yol?query` (isteğe bağlı `vary|` önekiyle) olduğundan eşleştirme
|
|
667
|
+
* **yol kısmına** yapılır: bir yolun bütün query / host varyantları tek
|
|
668
|
+
* çağrıyla düşer.
|
|
667
669
|
*
|
|
668
670
|
* @param {string | RegExp | (string | RegExp)[]} target
|
|
669
671
|
* @param {{ hard?: boolean }} [options]
|
|
@@ -733,14 +735,14 @@ function compileMatchers(targets) {
|
|
|
733
735
|
}
|
|
734
736
|
|
|
735
737
|
/**
|
|
736
|
-
* Anahtar `yol?query`; eşleştirme **yol kısmına
|
|
738
|
+
* Anahtar `[vary|]yol?query`; eşleştirme **yol** kısmına yapılır.
|
|
739
|
+
* Vary öneki (`h=…|`) invalidation hedefiyle karışmasın.
|
|
737
740
|
*
|
|
738
741
|
* @param {string} key
|
|
739
742
|
* @returns {string}
|
|
740
743
|
*/
|
|
741
744
|
function pathOf(key) {
|
|
742
|
-
|
|
743
|
-
return mark === -1 ? key : key.slice(0, mark);
|
|
745
|
+
return pathOfCacheKey(key);
|
|
744
746
|
}
|
|
745
747
|
|
|
746
748
|
/**
|
|
@@ -879,6 +881,9 @@ function dropLocalKey(key) {
|
|
|
879
881
|
* boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
|
|
880
882
|
* ısıtmasın diye okuma yıkıcıdır.
|
|
881
883
|
*
|
|
884
|
+
* Vary öneki (`h=…|`) düşülür — HTTP ısıtması yalnızca yolu ister; host
|
|
885
|
+
* ayrımı `prewarm.origins` / istek Host'u ile yapılır.
|
|
886
|
+
*
|
|
882
887
|
* @returns {string[]}
|
|
883
888
|
*/
|
|
884
889
|
export function takeInvalidatedPaths() {
|
|
@@ -886,8 +891,13 @@ export function takeInvalidatedPaths() {
|
|
|
886
891
|
|
|
887
892
|
const paths = [...invalidated];
|
|
888
893
|
invalidated.clear();
|
|
889
|
-
|
|
890
|
-
|
|
894
|
+
return paths.map((key) => {
|
|
895
|
+
const pathname = pathOfCacheKey(key);
|
|
896
|
+
const q = key.indexOf("?");
|
|
897
|
+
if (q === -1) return pathname;
|
|
898
|
+
const query = key.slice(q + 1);
|
|
899
|
+
return query ? `${pathname}?${query}` : pathname;
|
|
900
|
+
});
|
|
891
901
|
}
|
|
892
902
|
|
|
893
903
|
/**
|
package/src/server/prewarm.js
CHANGED
|
@@ -24,6 +24,65 @@ import { getDataCacheStats } from "./data-cache.js";
|
|
|
24
24
|
import { isTransientStatus } from "./upstream-tracking.js";
|
|
25
25
|
import { upstreamCooldownMs } from "./upstream-limiter.js";
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* `cache().prewarm.origins` yoksa loopback. Port'suz origin'lere dinleme
|
|
29
|
+
* portu eklenir — `http://tr.localhost` → `http://tr.localhost:3000`.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} port
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
function resolvePrewarmOrigins(port) {
|
|
35
|
+
const configured = getConfig().prewarm?.origins;
|
|
36
|
+
const list = Array.isArray(configured)
|
|
37
|
+
? configured.filter((value) => typeof value === "string" && value)
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
if (!list.length) return [`http://127.0.0.1:${port}`];
|
|
41
|
+
|
|
42
|
+
return list.map((origin) => {
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(origin);
|
|
45
|
+
if (!url.port) url.port = String(port);
|
|
46
|
+
return url.origin;
|
|
47
|
+
} catch {
|
|
48
|
+
return origin;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Vary açıkken ısıtma isteği ziyaretçinin Host'unu taşısın — aksi halde
|
|
55
|
+
* loopback anahtarı ısınır, gerçek locale host soğuk kalır.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ get?: (name: string) => string | undefined,
|
|
58
|
+
* headers?: Record<string, unknown>, protocol?: string } | undefined} req
|
|
59
|
+
* @param {string} fallback
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function originFromRequest(req, fallback) {
|
|
63
|
+
if (!req) return fallback;
|
|
64
|
+
|
|
65
|
+
let vary;
|
|
66
|
+
try {
|
|
67
|
+
vary = getConfig().cacheVary;
|
|
68
|
+
} catch {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
if (!vary?.host && !vary?.headers?.length && !vary?.fn) return fallback;
|
|
72
|
+
|
|
73
|
+
const host =
|
|
74
|
+
req.get?.("host") ||
|
|
75
|
+
(typeof req.headers?.host === "string" ? req.headers.host : "");
|
|
76
|
+
if (!host) return fallback;
|
|
77
|
+
|
|
78
|
+
const forwarded = req.headers?.["x-forwarded-proto"];
|
|
79
|
+
const protoRaw = Array.isArray(forwarded)
|
|
80
|
+
? forwarded[0]
|
|
81
|
+
: forwarded || req.protocol || "http";
|
|
82
|
+
const proto = String(protoRaw).split(",")[0].trim() || "http";
|
|
83
|
+
return `${proto}://${host}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
27
86
|
/** Klasik turu yöneten env'ler; `onVisit` ile birlikte yasak. */
|
|
28
87
|
const CLASSIC_PREWARM_ENV = [
|
|
29
88
|
"PREWARM_MAX",
|
|
@@ -715,6 +774,9 @@ export function noteVisitWarm(html, context) {
|
|
|
715
774
|
/** @type {string | undefined} */ (context.req?.headers?.["user-agent"]);
|
|
716
775
|
if (ua && ua === getConfig().brand.prewarmUserAgent) return;
|
|
717
776
|
|
|
777
|
+
// Vary açıksa bu ziyaretin Host'u üzerinden ısıt — loopback anahtarı değil.
|
|
778
|
+
visitOrigin = originFromRequest(context.req, visitOrigin);
|
|
779
|
+
|
|
718
780
|
const perPage = Number(getConfig().prewarm?.onVisit?.perPage) || 20;
|
|
719
781
|
const links = extractSameOriginLinks(html, {
|
|
720
782
|
limit: perPage,
|
|
@@ -796,12 +858,13 @@ export function startPrewarm({ port }) {
|
|
|
796
858
|
const config = getConfig();
|
|
797
859
|
if (process.env.PREWARM === "0") return;
|
|
798
860
|
|
|
799
|
-
const
|
|
861
|
+
const origins = resolvePrewarmOrigins(port);
|
|
862
|
+
const origin = origins[0];
|
|
800
863
|
|
|
801
864
|
// Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
|
|
802
865
|
// HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
|
|
803
866
|
startEarlyExpirySweep();
|
|
804
|
-
startExpiryWarmDrain(
|
|
867
|
+
startExpiryWarmDrain(origins);
|
|
805
868
|
|
|
806
869
|
if (config.prewarm?.onVisit?.enabled) {
|
|
807
870
|
const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
|
|
@@ -833,7 +896,10 @@ export function startPrewarm({ port }) {
|
|
|
833
896
|
if (running) return;
|
|
834
897
|
running = true;
|
|
835
898
|
try {
|
|
836
|
-
|
|
899
|
+
// `vary.host` açıkken her origin ayrı anahtar ısıtır.
|
|
900
|
+
for (const next of origins) {
|
|
901
|
+
await prewarm({ origin: next });
|
|
902
|
+
}
|
|
837
903
|
} catch (error) {
|
|
838
904
|
console.error("[prewarm] failed", error);
|
|
839
905
|
} finally {
|
|
@@ -856,8 +922,8 @@ export function startPrewarm({ port }) {
|
|
|
856
922
|
if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
|
|
857
923
|
}
|
|
858
924
|
|
|
859
|
-
/** @type {string
|
|
860
|
-
let
|
|
925
|
+
/** @type {string[]} */
|
|
926
|
+
let expiryOrigins = [];
|
|
861
927
|
|
|
862
928
|
/** @type {boolean} */
|
|
863
929
|
let expiryDraining = false;
|
|
@@ -869,11 +935,11 @@ let expiryDrainTimer = null;
|
|
|
869
935
|
* Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
|
|
870
936
|
* aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
|
|
871
937
|
*
|
|
872
|
-
* @param {string}
|
|
938
|
+
* @param {string[]} origins
|
|
873
939
|
* @returns {void}
|
|
874
940
|
*/
|
|
875
|
-
function startExpiryWarmDrain(
|
|
876
|
-
|
|
941
|
+
function startExpiryWarmDrain(origins) {
|
|
942
|
+
expiryOrigins = origins.length ? origins : [];
|
|
877
943
|
if (expiryDrainTimer) return;
|
|
878
944
|
expiryDrainTimer = setInterval(() => {
|
|
879
945
|
void drainExpiryWarm();
|
|
@@ -885,7 +951,7 @@ function startExpiryWarmDrain(origin) {
|
|
|
885
951
|
* @returns {Promise<void>}
|
|
886
952
|
*/
|
|
887
953
|
async function drainExpiryWarm() {
|
|
888
|
-
if (expiryDraining || !
|
|
954
|
+
if (expiryDraining || !expiryOrigins.length) return;
|
|
889
955
|
|
|
890
956
|
const paths = takeInvalidatedPaths();
|
|
891
957
|
if (!paths.length) return;
|
|
@@ -895,7 +961,9 @@ async function drainExpiryWarm() {
|
|
|
895
961
|
const cold = paths.filter((path) => !isHtmlCacheFresh(path));
|
|
896
962
|
if (!cold.length) return;
|
|
897
963
|
|
|
898
|
-
|
|
964
|
+
for (const origin of expiryOrigins) {
|
|
965
|
+
await prewarm({ origin, paths: cold, quiet: true });
|
|
966
|
+
}
|
|
899
967
|
} catch (error) {
|
|
900
968
|
console.error("[prewarm] expiry warm failed", error);
|
|
901
969
|
} finally {
|
package/src/server/render.js
CHANGED
|
@@ -18,6 +18,7 @@ import fs from "node:fs";
|
|
|
18
18
|
import { pathToFileURL } from "node:url";
|
|
19
19
|
import ejs from "ejs";
|
|
20
20
|
import { withHtmlCache } from "./html-cache.js";
|
|
21
|
+
import { buildVaryPrefix } from "./cache-vary.js";
|
|
21
22
|
import { getConfig, hook } from "../config/index.js";
|
|
22
23
|
import { matchPattern } from "../config/pattern.js";
|
|
23
24
|
import { encodeText, negotiateEncoding } from "./middleware/compression.js";
|
|
@@ -329,12 +330,14 @@ export function route(controller, options = {}) {
|
|
|
329
330
|
// Anahtar `null` ise query bu yol için cache'lenebilir değil: sayfa
|
|
330
331
|
// dinamik davranır. Anahtar yine de gerekiyor (hata sayfası ölçümü,
|
|
331
332
|
// teşhis) ama TTL sıfırlanıp cache yolu kapatılır.
|
|
332
|
-
const key = buildCacheKey(req.path, ctx.query);
|
|
333
|
+
const key = buildCacheKey(req.path, ctx.query, req);
|
|
333
334
|
const cacheable =
|
|
334
335
|
!isPrivate && req.method === "GET" && Boolean(revalidate) && key !== null;
|
|
335
|
-
const cacheKey =
|
|
336
|
-
|
|
337
|
-
|
|
336
|
+
const cacheKey =
|
|
337
|
+
key ??
|
|
338
|
+
`${buildVaryPrefix(req)}${req.path}?${new URLSearchParams(
|
|
339
|
+
Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
|
|
340
|
+
).toString()}`;
|
|
338
341
|
|
|
339
342
|
try {
|
|
340
343
|
const result = await withRequestContext(context, () =>
|
|
@@ -582,6 +585,10 @@ function resolveQueryPolicy(pathname) {
|
|
|
582
585
|
/**
|
|
583
586
|
* HTML cache anahtarı, ya da query bu yol için cache'lenebilir değilse `null`.
|
|
584
587
|
*
|
|
588
|
+
* Biçim: `${varyPrefix}${pathname}?${izin verilen query}`.
|
|
589
|
+
* Vary (`cache().vary`) query allowlist'ten bağımsız; host/locale sitelerinde
|
|
590
|
+
* ilk host'un HTML'inin diğerine servis edilmesini engeller.
|
|
591
|
+
*
|
|
585
592
|
* Varsayılan olarak query parametresi taşıyan istek dinamiktir: `cache().query`
|
|
586
593
|
* altında eşleşen bir kural olmadıkça cache'e hiç girmez. Aksi hâlde bir yolun
|
|
587
594
|
* bütün `?utm_source=…` varyantları ayrı girdi olur ve LRU'daki gerçek
|
|
@@ -592,11 +599,13 @@ function resolveQueryPolicy(pathname) {
|
|
|
592
599
|
*
|
|
593
600
|
* @param {string} pathname
|
|
594
601
|
* @param {Record<string, unknown>} query
|
|
602
|
+
* @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
|
|
595
603
|
* @returns {string | null}
|
|
596
604
|
*/
|
|
597
|
-
function buildCacheKey(pathname, query) {
|
|
605
|
+
function buildCacheKey(pathname, query, req) {
|
|
606
|
+
const vary = buildVaryPrefix(req);
|
|
598
607
|
const entries = Object.entries(query);
|
|
599
|
-
if (!entries.length) return `${pathname}?`;
|
|
608
|
+
if (!entries.length) return `${vary}${pathname}?`;
|
|
600
609
|
|
|
601
610
|
const policy = resolveQueryPolicy(pathname);
|
|
602
611
|
if (policy === null) return null;
|
|
@@ -612,7 +621,7 @@ function buildCacheKey(pathname, query) {
|
|
|
612
621
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
613
622
|
);
|
|
614
623
|
|
|
615
|
-
return `${pathname}?${params.toString()}`;
|
|
624
|
+
return `${vary}${pathname}?${params.toString()}`;
|
|
616
625
|
}
|
|
617
626
|
|
|
618
627
|
/**
|