jskelet 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/docs/06-cache.md +62 -11
- package/docs/07-yapilandirma.md +54 -3
- package/docs/12-panel-ve-oturum.md +88 -0
- package/docs/en/06-caching.md +63 -12
- package/docs/en/07-configuration.md +56 -3
- package/docs/en/12-dashboards-and-sessions.md +91 -0
- package/package.json +2 -2
- package/src/client/index.js +10 -0
- package/src/client/shared-cookie.js +225 -0
- package/src/config/defaults.js +27 -0
- package/src/config/index.js +108 -3
- package/src/http/cookies-entry.js +20 -0
- package/src/http/cookies.js +2 -0
- package/src/http/shared-cookie.js +178 -0
- package/src/index.js +7 -0
- package/src/server/auth/handoff.js +226 -0
- package/src/server/cache-vary.js +113 -0
- package/src/server/create-app.js +14 -0
- package/src/server/html-cache.js +17 -7
- package/src/server/prewarm.js +78 -10
- package/src/server/render.js +16 -7
- package/src/shared/cookie-domain.js +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,29 @@ one is listed under a **Breaking** heading.
|
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
12
|
|
|
13
|
+
- Shared cross-subdomain cookies: `brand.sharedCookieRoots` plus
|
|
14
|
+
`writeSharedCookie` / `clearSharedCookie` on server (`jskelet/cookies`) and
|
|
15
|
+
client (`jskelet/client`). `Secure` follows https / `x-forwarded-proto` (not
|
|
16
|
+
`NODE_ENV`); the client read-back fails into handoff when the browser rejects
|
|
17
|
+
`Domain`. Optional `auth.crossSubdomainHandoff` mounts
|
|
18
|
+
`POST /_jskelet/auth/handoff` (one-time ticket → `?handoff=`) and documents a
|
|
19
|
+
`window.name` bridge. Large tokens are refused — put a short session id in the
|
|
20
|
+
cookie, not a JWT.
|
|
21
|
+
- HTML cache key vary (`cache().vary`): `host: true` adds the public Host
|
|
22
|
+
(`x-forwarded-host` or `Host`, lowercase, no port) as `h=…|` before the path;
|
|
23
|
+
optional `headers` and `fn(req)` add further segments. Required on host-based
|
|
24
|
+
locale sites so one locale's HTML is not served on another. Classic prewarm
|
|
25
|
+
accepts `prewarm.origins` for multi-host warming when vary is on.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Marketing example visual language: darker ink canvas, solid cyan primary
|
|
30
|
+
CTAs, cyan-only glow/grid (indigo accents removed), and a measured trust
|
|
31
|
+
bar on the homepage (payload gzip, Node, license, zero web fonts) instead
|
|
32
|
+
of the marquee.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
13
36
|
- Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`):
|
|
14
37
|
`ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or
|
|
15
38
|
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
|
@@ -186,14 +186,38 @@ birleştirilir.
|
|
|
186
186
|
| `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | Isıtma isteklerinin UA'sı; dev paneli bunu filtreler |
|
|
187
187
|
| `devTokenCookie` | `string` | `"dev_token"` | Dev gate'in çerez ve query parametresi adı |
|
|
188
188
|
| `lang` | `string` | — | `<html lang>` varsayılanı. Verilmezse layout `"en"` kullanır. |
|
|
189
|
+
| `sharedCookieRoots` | `string[]` | `[]` | Paylaşımlı cookie Domain kökleri (örn. `.investvio.com`, `.localhost`). [12](./12-panel-ve-oturum.md) |
|
|
189
190
|
|
|
190
191
|
`lang` için öncelik sırası: `hooks.layoutContext()` → `lang` **>** `brand.lang`
|
|
191
192
|
**>** `"en"`.
|
|
192
193
|
|
|
193
194
|
```js
|
|
194
|
-
brand: {
|
|
195
|
+
brand: {
|
|
196
|
+
lang: "tr",
|
|
197
|
+
poweredBy: "Örnek",
|
|
198
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
199
|
+
}
|
|
195
200
|
```
|
|
196
201
|
|
|
202
|
+
## `auth`
|
|
203
|
+
|
|
204
|
+
**Tip:** `object` — **Varsayılan:** `{ crossSubdomainHandoff: false }`
|
|
205
|
+
|
|
206
|
+
Kimlik framework'te yok; bu bölüm yalnızca alt alan adları arasında kısa
|
|
207
|
+
session id taşımak için handoff köprüsünü açar.
|
|
208
|
+
|
|
209
|
+
| Alan | Tip | Varsayılan | Anlamı |
|
|
210
|
+
| --- | --- | --- | --- |
|
|
211
|
+
| `crossSubdomainHandoff` | `boolean \| object` | `false` | `true` veya `{ ttlSeconds?, path?, maxValueBytes? }` → `POST /_jskelet/auth/handoff` + `?handoff=` redeem |
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
auth: {
|
|
215
|
+
crossSubdomainHandoff: { ttlSeconds: 60 },
|
|
216
|
+
},
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Ayrıntı ve `window.name` yedeği: [12-panel-ve-oturum.md](./12-panel-ve-oturum.md).
|
|
220
|
+
|
|
197
221
|
## `layout`
|
|
198
222
|
|
|
199
223
|
**Tip:** `string` — **Varsayılan:** yok (otomatik çözüm)
|
|
@@ -614,9 +638,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
|
|
|
614
638
|
## `cache()`
|
|
615
639
|
|
|
616
640
|
**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 }` —
|
|
641
|
+
`() => { 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
642
|
**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 } }`
|
|
643
|
+
`{ 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
644
|
|
|
621
645
|
### `cache().html`
|
|
622
646
|
|
|
@@ -671,6 +695,30 @@ Parametreler anahtara **sıralı** yazılır: `?a=1&b=2` ile `?b=2&a=1` aynı gi
|
|
|
671
695
|
paylaşır. `route(fn, { private: true })` bu bölümden etkilenmez; private route
|
|
672
696
|
hiçbir koşulda cache'lenmez.
|
|
673
697
|
|
|
698
|
+
### `cache().vary`
|
|
699
|
+
|
|
700
|
+
HTML cache anahtarına query allowlist'ten **bağımsız** sabit parçalar ekler.
|
|
701
|
+
Host'tan locale üreten sitelerde `host: true` **zorunlu**; aksi halde ilk
|
|
702
|
+
locale'in HTML'i diğer host'a servis edilir. CDN zaten tam URL ile ayırır —
|
|
703
|
+
bu ayar origin L1 ve Redis HTML anahtarı içindir.
|
|
704
|
+
|
|
705
|
+
```js
|
|
706
|
+
vary: {
|
|
707
|
+
host: true, // h=tr.example.com|…
|
|
708
|
+
// headers: ["x-locale"],
|
|
709
|
+
// fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
| Alan | Tip | Varsayılan | Anlamı |
|
|
714
|
+
| --- | --- | --- | --- |
|
|
715
|
+
| `host` | `boolean` | `false` | Public Host (`x-forwarded-host` yoksa `Host`), lowercase, portsuz → `h=…` |
|
|
716
|
+
| `headers` | `string[]` | `[]` | İstek başlıkları `ad=değer` olarak eklenir |
|
|
717
|
+
| `fn` | `(req) => string \| null` | — | Dönüş bir segment olarak eklenir |
|
|
718
|
+
|
|
719
|
+
Anahtar biçimi: `${vary}|${yol}?${query}` (vary yoksa önek yok). Ayrıntı:
|
|
720
|
+
[06-cache.md](./06-cache.md).
|
|
721
|
+
|
|
674
722
|
### `cache().maxEntries`
|
|
675
723
|
|
|
676
724
|
**Tip:** `number` — **Varsayılan:** `500`
|
|
@@ -900,6 +948,7 @@ sayfadaki linkler). Birlikte verilemez — config yüklenirken hata.
|
|
|
900
948
|
| `intervalSeconds` | `number` | `0` | 0'dan büyükse tur periyodik tekrarlanır |
|
|
901
949
|
| `rotate` | `boolean` | `true` | Liste `max`'tan uzunsa periyodik turlar kaldığı yerden devam eder |
|
|
902
950
|
| `priority` | `(string \| RegExp)[]` | `[]` | Isıtma sırası; eşleşen yollar her turda başa alınır |
|
|
951
|
+
| `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
952
|
|
|
904
953
|
`priority` iki biçim kabul eder: config'in her yerinde geçerli olan desen
|
|
905
954
|
sözdizimi ve doğrudan `RegExp`. Önce yazılan önce ısınır.
|
|
@@ -909,6 +958,8 @@ prewarm: {
|
|
|
909
958
|
max: 500,
|
|
910
959
|
rps: 4,
|
|
911
960
|
intervalSeconds: 300,
|
|
961
|
+
// vary.host açıksa loopback tek başına yetmez:
|
|
962
|
+
origins: ["http://localhost", "http://tr.localhost"],
|
|
912
963
|
priority: [
|
|
913
964
|
"/", // ana sayfa
|
|
914
965
|
"/piyasalar/:path*", // tüm piyasa bölümü
|
|
@@ -129,6 +129,94 @@ kapatıyor — cookie çapraz site POST'larında hiç gönderilmiyor.
|
|
|
129
129
|
Cookie **şifrelenmiyor**, imzalanıyor. Değer okunabilir; gizli kalması gereken
|
|
130
130
|
veriyi değil, onun kimliğini koyun.
|
|
131
131
|
|
|
132
|
+
## Alt alan adları: paylaşımlı cookie
|
|
133
|
+
|
|
134
|
+
Host tabanlı i18n (`tr.example.com` / `en.example.com`) oturumu alt alanlar
|
|
135
|
+
arasında paylaşmak ister. Çözüm **kısa session id** + isteğe bağlı
|
|
136
|
+
`Domain=.example.com` — JWT veya büyük access token paylaşımlı cookie'ye
|
|
137
|
+
konmaz (`large token ≠ shared cookie`).
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
// jskelet.config.mjs
|
|
141
|
+
export default {
|
|
142
|
+
brand: {
|
|
143
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
144
|
+
},
|
|
145
|
+
auth: {
|
|
146
|
+
crossSubdomainHandoff: true, // POST /_jskelet/auth/handoff
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Sunucu
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { writeSharedCookie } from "jskelet/cookies";
|
|
155
|
+
|
|
156
|
+
export function startSession(res, req, sessionId) {
|
|
157
|
+
const result = writeSharedCookie(res, "sid", sessionId, {
|
|
158
|
+
req,
|
|
159
|
+
maxAge: 60 * 60 * 8,
|
|
160
|
+
});
|
|
161
|
+
// result.ok === false → result.handoff; istemci handoff denemeli
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`Secure` **protokole** bakılır (`https` / `x-forwarded-proto`), `NODE_ENV`'e
|
|
167
|
+
değil. Domain, isteğin Host'u `sharedCookieRoots` ile eşleşince yazılır.
|
|
168
|
+
Değer ~512 baytı aşarsa yazım reddedilir ve `handoff: true` döner.
|
|
169
|
+
|
|
170
|
+
### İstemci
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
import {
|
|
174
|
+
writeSharedCookie,
|
|
175
|
+
createHandoffUrl,
|
|
176
|
+
handoffViaWindowName,
|
|
177
|
+
consumeWindowNameHandoff,
|
|
178
|
+
} from "jskelet/client";
|
|
179
|
+
|
|
180
|
+
const result = writeSharedCookie("sid", sessionId, {
|
|
181
|
+
roots: [".investvio.com", ".localhost"],
|
|
182
|
+
maxAge: 60 * 60 * 8,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (!result.ok && result.handoff) {
|
|
186
|
+
const url = await createHandoffUrl({
|
|
187
|
+
name: "sid",
|
|
188
|
+
value: sessionId,
|
|
189
|
+
next: "https://tr.investvio.com/panel",
|
|
190
|
+
});
|
|
191
|
+
if (url) location.assign(url);
|
|
192
|
+
else handoffViaWindowName("https://tr.investvio.com/panel", {
|
|
193
|
+
name: "sid",
|
|
194
|
+
value: sessionId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Hedef host'ta (layout / island bootstrap):
|
|
199
|
+
consumeWindowNameHandoff();
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`roots` verilmezse `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`
|
|
203
|
+
okunur. Yazımdan sonra **read-back** yapılır; tarayıcı Domain'i reddettiyse
|
|
204
|
+
`handoff: true`.
|
|
205
|
+
|
|
206
|
+
### Handoff bileti
|
|
207
|
+
|
|
208
|
+
`auth.crossSubdomainHandoff` açıkken:
|
|
209
|
+
|
|
210
|
+
1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (`?handoff=` ekli)
|
|
211
|
+
2. Hedef host'ta GET middleware bileti tek kullanımlık tüketir, cookie yazar
|
|
212
|
+
(önce shared Domain, olmazsa host-only), `handoff` query'siz 303
|
|
213
|
+
|
|
214
|
+
`next` yalnızca aynı `sharedCookieRoots` altındaki host'lara izinli. Bilet
|
|
215
|
+
~60 sn, süreç belleğinde. JWT URL'ye konmaz.
|
|
216
|
+
|
|
217
|
+
`window.name` köprüsü cookie'siz yedek: kaynakta `handoffViaWindowName`,
|
|
218
|
+
hedeefte `consumeWindowNameHandoff`.
|
|
219
|
+
|
|
132
220
|
## CSRF
|
|
133
221
|
|
|
134
222
|
Gövdeyi framework ayrıştırıyor (`express.urlencoded` + `express.json`), yani
|
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
|
|
@@ -191,14 +191,39 @@ shallow-merged with the defaults.
|
|
|
191
191
|
| `prewarmUserAgent` | `string` | `"jskelet-prewarm"` | UA of prewarm requests; the dev panel filters on it |
|
|
192
192
|
| `devTokenCookie` | `string` | `"dev_token"` | Name of the dev gate's cookie and query parameter |
|
|
193
193
|
| `lang` | `string` | — | Default for `<html lang>`. If not given, the layout uses `"en"`. |
|
|
194
|
+
| `sharedCookieRoots` | `string[]` | `[]` | Shared cookie Domain roots (e.g. `.investvio.com`, `.localhost`). [12](./12-dashboards-and-sessions.md) |
|
|
194
195
|
|
|
195
196
|
Precedence for `lang`: `hooks.layoutContext()` → `lang` **>** `brand.lang`
|
|
196
197
|
**>** `"en"`.
|
|
197
198
|
|
|
198
199
|
```js
|
|
199
|
-
brand: {
|
|
200
|
+
brand: {
|
|
201
|
+
lang: "tr",
|
|
202
|
+
poweredBy: "Example",
|
|
203
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
204
|
+
}
|
|
200
205
|
```
|
|
201
206
|
|
|
207
|
+
## `auth`
|
|
208
|
+
|
|
209
|
+
**Type:** `object` — **Default:** `{ crossSubdomainHandoff: false }`
|
|
210
|
+
|
|
211
|
+
The framework does not provide identity; this section only opens the
|
|
212
|
+
cross-subdomain handoff bridge for a short session id.
|
|
213
|
+
|
|
214
|
+
| Field | Type | Default | Meaning |
|
|
215
|
+
| --- | --- | --- | --- |
|
|
216
|
+
| `crossSubdomainHandoff` | `boolean \| object` | `false` | `true` or `{ ttlSeconds?, path?, maxValueBytes? }` → `POST /_jskelet/auth/handoff` + `?handoff=` redeem |
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
auth: {
|
|
220
|
+
crossSubdomainHandoff: { ttlSeconds: 60 },
|
|
221
|
+
},
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Details and the `window.name` fallback:
|
|
225
|
+
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
|
|
226
|
+
|
|
202
227
|
## `layout`
|
|
203
228
|
|
|
204
229
|
**Type:** `string` — **Default:** none (automatic resolution)
|
|
@@ -627,9 +652,9 @@ Details: [03-routing.md](./03-routing.md).
|
|
|
627
652
|
## `cache()`
|
|
628
653
|
|
|
629
654
|
**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 }` —
|
|
655
|
+
`() => { 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
656
|
**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 } }`
|
|
657
|
+
`{ 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
658
|
|
|
634
659
|
### `cache().html`
|
|
635
660
|
|
|
@@ -684,6 +709,31 @@ Parameters are written into the key **sorted**, so `?a=1&b=2` and `?b=2&a=1`
|
|
|
684
709
|
share one entry. `route(fn, { private: true })` is unaffected by this section; a
|
|
685
710
|
private route is never cached under any condition.
|
|
686
711
|
|
|
712
|
+
### `cache().vary`
|
|
713
|
+
|
|
714
|
+
Adds fixed segments to the HTML cache key **independently** of the query
|
|
715
|
+
allowlist. On sites that derive locale from the host, `host: true` is
|
|
716
|
+
**required**; otherwise the first locale's HTML is served to the other host. A
|
|
717
|
+
CDN already separates by full URL — this setting is for the origin L1 and the
|
|
718
|
+
Redis HTML key.
|
|
719
|
+
|
|
720
|
+
```js
|
|
721
|
+
vary: {
|
|
722
|
+
host: true, // h=tr.example.com|…
|
|
723
|
+
// headers: ["x-locale"],
|
|
724
|
+
// fn: (req) => req.hostname.startsWith("tr.") ? "l=tr" : "l=en",
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
| Field | Type | Default | Meaning |
|
|
729
|
+
| --- | --- | --- | --- |
|
|
730
|
+
| `host` | `boolean` | `false` | Public Host (`x-forwarded-host` else `Host`), lowercase, no port → `h=…` |
|
|
731
|
+
| `headers` | `string[]` | `[]` | Request headers added as `name=value` |
|
|
732
|
+
| `fn` | `(req) => string \| null` | — | Return value appended as a segment |
|
|
733
|
+
|
|
734
|
+
Key shape: `${vary}|${path}?${query}` (no prefix when vary is empty). Details:
|
|
735
|
+
[06-caching.md](./06-caching.md).
|
|
736
|
+
|
|
687
737
|
### `cache().maxEntries`
|
|
688
738
|
|
|
689
739
|
**Type:** `number` — **Default:** `500`
|
|
@@ -920,6 +970,7 @@ page just visited). They cannot be combined — config load throws.
|
|
|
920
970
|
| `intervalSeconds` | `number` | `0` | If greater than 0, the pass repeats periodically |
|
|
921
971
|
| `rotate` | `boolean` | `true` | If the list is longer than `max`, periodic passes continue where they left off |
|
|
922
972
|
| `priority` | `(string \| RegExp)[]` | `[]` | Warm-up order; matching paths are taken first on every pass |
|
|
973
|
+
| `origins` | `string[]` | `[]` | Origins for the classic pass. Empty → `http://127.0.0.1:<port>`. With `vary.host`, list the locale hosts here |
|
|
923
974
|
|
|
924
975
|
`priority` accepts two forms: the pattern syntax used everywhere in the config,
|
|
925
976
|
and a plain `RegExp`. Whatever is written first is warmed first.
|
|
@@ -929,6 +980,8 @@ prewarm: {
|
|
|
929
980
|
max: 500,
|
|
930
981
|
rps: 4,
|
|
931
982
|
intervalSeconds: 300,
|
|
983
|
+
// with vary.host, loopback alone is not enough:
|
|
984
|
+
origins: ["http://localhost", "http://tr.localhost"],
|
|
932
985
|
priority: [
|
|
933
986
|
"/", // the home page
|
|
934
987
|
"/markets/:path*", // the whole markets section
|
|
@@ -131,6 +131,97 @@ cookie is simply not sent on cross-site POSTs.
|
|
|
131
131
|
Cookies are **signed, not encrypted**. The value is readable, so store the
|
|
132
132
|
identifier of a secret rather than the secret itself.
|
|
133
133
|
|
|
134
|
+
## Cross-subdomain: shared cookies
|
|
135
|
+
|
|
136
|
+
Host-based i18n (`tr.example.com` / `en.example.com`) often needs a session on
|
|
137
|
+
every locale host. The supported pattern is a **short session id** plus an
|
|
138
|
+
optional `Domain=.example.com` — do not put a JWT or a large access token in a
|
|
139
|
+
shared cookie (`large token ≠ shared cookie`).
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
// jskelet.config.mjs
|
|
143
|
+
export default {
|
|
144
|
+
brand: {
|
|
145
|
+
sharedCookieRoots: [".investvio.com", ".localhost"],
|
|
146
|
+
},
|
|
147
|
+
auth: {
|
|
148
|
+
crossSubdomainHandoff: true, // POST /_jskelet/auth/handoff
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Server
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { writeSharedCookie } from "jskelet/cookies";
|
|
157
|
+
|
|
158
|
+
export function startSession(res, req, sessionId) {
|
|
159
|
+
const result = writeSharedCookie(res, "sid", sessionId, {
|
|
160
|
+
req,
|
|
161
|
+
maxAge: 60 * 60 * 8,
|
|
162
|
+
});
|
|
163
|
+
// result.ok === false → result.handoff; the client should use handoff
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`Secure` follows the **protocol** (`https` / `x-forwarded-proto`), not
|
|
169
|
+
`NODE_ENV`. The Domain is set when the request Host matches
|
|
170
|
+
`sharedCookieRoots`. Values larger than ~512 bytes are refused with
|
|
171
|
+
`handoff: true`.
|
|
172
|
+
|
|
173
|
+
### Client
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import {
|
|
177
|
+
writeSharedCookie,
|
|
178
|
+
createHandoffUrl,
|
|
179
|
+
handoffViaWindowName,
|
|
180
|
+
consumeWindowNameHandoff,
|
|
181
|
+
} from "jskelet/client";
|
|
182
|
+
|
|
183
|
+
const result = writeSharedCookie("sid", sessionId, {
|
|
184
|
+
roots: [".investvio.com", ".localhost"],
|
|
185
|
+
maxAge: 60 * 60 * 8,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!result.ok && result.handoff) {
|
|
189
|
+
const url = await createHandoffUrl({
|
|
190
|
+
name: "sid",
|
|
191
|
+
value: sessionId,
|
|
192
|
+
next: "https://tr.investvio.com/panel",
|
|
193
|
+
});
|
|
194
|
+
if (url) location.assign(url);
|
|
195
|
+
else handoffViaWindowName("https://tr.investvio.com/panel", {
|
|
196
|
+
name: "sid",
|
|
197
|
+
value: sessionId,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// On the target host (layout / island bootstrap):
|
|
202
|
+
consumeWindowNameHandoff();
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
If `roots` is omitted, the client reads
|
|
206
|
+
`<html data-jskelet-cookie-roots=".investvio.com,.localhost">`. After writing,
|
|
207
|
+
a **read-back** runs; if the browser rejected the Domain, `handoff: true`.
|
|
208
|
+
|
|
209
|
+
### Handoff ticket
|
|
210
|
+
|
|
211
|
+
With `auth.crossSubdomainHandoff` on:
|
|
212
|
+
|
|
213
|
+
1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (with `?handoff=`)
|
|
214
|
+
2. On the target host a GET middleware redeems the one-time ticket, sets the
|
|
215
|
+
cookie (shared Domain first, else host-only), and 303-redirects without
|
|
216
|
+
`handoff`
|
|
217
|
+
|
|
218
|
+
`next` must be under the same `sharedCookieRoots`. Tickets live ~60s in process
|
|
219
|
+
memory. Do not put a JWT in the URL.
|
|
220
|
+
|
|
221
|
+
The `window.name` bridge is the cookie-less fallback:
|
|
222
|
+
`handoffViaWindowName` on the source page, `consumeWindowNameHandoff` on the
|
|
223
|
+
target.
|
|
224
|
+
|
|
134
225
|
## CSRF
|
|
135
226
|
|
|
136
227
|
The framework parses the request body (`express.urlencoded` + `express.json`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"./client": "./src/client/index.js",
|
|
34
34
|
"./html": "./src/views/helpers/html.js",
|
|
35
35
|
"./tags": "./src/views/helpers/tags.js",
|
|
36
|
-
"./cookies": "./src/http/cookies.js",
|
|
36
|
+
"./cookies": "./src/http/cookies-entry.js",
|
|
37
37
|
"./log": "./src/log.mjs",
|
|
38
38
|
"./register": "./src/runtime/register.mjs",
|
|
39
39
|
"./layout": "./src/templates/layout.ejs"
|