jskelet 0.7.1 → 0.7.2
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 +37 -0
- package/docs/04-render-ve-sablonlar.md +7 -6
- package/docs/05-islands.md +8 -4
- package/docs/06-cache.md +18 -16
- package/docs/07-yapilandirma.md +8 -7
- package/docs/en/04-rendering.md +8 -6
- package/docs/en/05-islands.md +8 -4
- package/docs/en/06-caching.md +18 -16
- package/docs/en/07-configuration.md +8 -6
- package/package.json +1 -1
- package/src/build/build.mjs +23 -8
- package/src/build/tasks/precompress.mjs +51 -11
- package/src/client/registry.js +13 -3
- package/src/compile/compile-all.js +14 -8
- package/src/config/defaults.js +9 -5
- package/src/config/index.js +3 -3
- package/src/http/request-cache.js +19 -1
- package/src/index.js +2 -0
- package/src/server/cache-blob.js +33 -0
- package/src/server/create-app.js +5 -0
- package/src/server/data-cache.js +77 -11
- package/src/server/disk-cache.js +2 -2
- package/src/server/head-hints.js +21 -9
- package/src/server/html-cache.js +169 -39
- package/src/server/image-optimizer.js +72 -28
- package/src/server/redis.js +2 -2
- package/src/server/render.js +165 -53
- package/types/client/registry.d.ts +2 -0
- package/types/config/defaults.d.ts +8 -4
- package/types/config/index.d.ts +1 -0
- package/types/index.d.ts +1 -1
- package/types/server/cache-blob.d.ts +8 -0
- package/types/server/data-cache.d.ts +1 -0
- package/types/server/html-cache.d.ts +4 -5
- package/types/server/render.d.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,9 +26,46 @@ one is listed under a **Breaking** heading.
|
|
|
26
26
|
variant URLs once at load time.
|
|
27
27
|
- `scripts/measure-ssr.mjs` microbench for fixture `renderView` /
|
|
28
28
|
`renderPage` and live cache MISS/HIT timings against a running app.
|
|
29
|
+
- HTML cache awaits brotli on write so the MISS response can reuse it;
|
|
30
|
+
`rememberHtmlEncoding` updates the byte budget by delta instead of
|
|
31
|
+
rescanning the store. Short negative cache skips empty L2 (Redis/disk)
|
|
32
|
+
lookups after a miss.
|
|
33
|
+
- Data cache early-refreshes near TTL (same lead formula as HTML) and skips
|
|
34
|
+
repeated empty L2 reads the same way; `produceMs` is stored on entries.
|
|
35
|
+
- Image optimizer coalesces concurrent work for the same `url+w+q` and caches
|
|
36
|
+
DNS safety checks for 60s.
|
|
37
|
+
- Disk/Redis L2 decode uses async brotli (no sync decompress on the event
|
|
38
|
+
loop). `cache()` skips `JSON.stringify` for a single primitive argument.
|
|
39
|
+
- `preconnectHints()` adds `build.assets.prefix` origin automatically. Build
|
|
40
|
+
runs Fonts and Icon sprite in parallel; precompress writes `.br`/`.gz` in
|
|
41
|
+
parallel per file.
|
|
42
|
+
- Cold SSR without prewarm: `warmRenderEngine()` runs before listen; page
|
|
43
|
+
`.jsk` templates load lazily (layout + `components/*` stay eager); production
|
|
44
|
+
`ensureTemplatesCompiled` returns on manifest without walking sources.
|
|
45
|
+
- L2 read races a 50ms budget then produces locally; Redis `storeEncoded`
|
|
46
|
+
defaults to on.
|
|
47
|
+
- `renderPage` runs the body with `page.metadata` in parallel with the
|
|
48
|
+
`metadata` hook + `layoutContext` chain.
|
|
49
|
+
- `sendHtml` always negotiates brotli/gzip for HTML (including
|
|
50
|
+
`fragment` / `private`), instead of relying on compression middleware.
|
|
51
|
+
- Transient upstream `notFound()` defaults to an immediate 503 with
|
|
52
|
+
background retry (`cache().transientRetry.background`; set `false` for the
|
|
53
|
+
old sync loop). `warmRenderEngine` / `resetRenderEngine` are exported.
|
|
54
|
+
|
|
55
|
+
### Breaking
|
|
56
|
+
|
|
57
|
+
- Redis `storeEncoded` defaults to `true` (was `false`). Opt out with
|
|
58
|
+
`cache().redis.storeEncoded: false` if L2 size must stay minimal.
|
|
59
|
+
- Transient retry no longer delays the visitor by default
|
|
60
|
+
(`background: true`). Set `cache().transientRetry.background: false` to
|
|
61
|
+
wait for sync retries before responding.
|
|
29
62
|
|
|
30
63
|
### Fixed
|
|
31
64
|
|
|
65
|
+
- `hydrate()` mounts zero-box hosts (`hidden` / no layout box) through
|
|
66
|
+
`mountWhenReady()` instead of calling `mountIsland()` immediately, so
|
|
67
|
+
`data-island-idle` on a hidden host still waits for `load` plus idle time.
|
|
68
|
+
`data-island-eager` is unchanged (mounts right away).
|
|
32
69
|
- S3 PutObject uses path-style URLs for bucket names that contain dots
|
|
33
70
|
(e.g. `static.investvio.dev`). Virtual-hosted
|
|
34
71
|
`bucket.s3.region.amazonaws.com` fails TLS because the cert only covers
|
|
@@ -15,17 +15,18 @@ route(controller)
|
|
|
15
15
|
└─ produce()
|
|
16
16
|
├─ controller(ctx) → sayfa tanımı
|
|
17
17
|
└─ renderPage(page)
|
|
18
|
-
├─ hooks.metadata(page) + page.metadata → metadata
|
|
19
18
|
├─ Promise.all([
|
|
20
|
-
│ renderView(page.view, { …data, metadata }),
|
|
21
|
-
│ hooks.
|
|
19
|
+
│ renderView(page.view, { …data, page.metadata }), → body
|
|
20
|
+
│ hooks.metadata(page) + page.metadata → metadata
|
|
21
|
+
│ └─ hooks.layoutContext({ pathname, metadata }) → context
|
|
22
22
|
│ ])
|
|
23
23
|
└─ layout (.jsk derlenmiş veya .ejs) → tam HTML
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
Gövde, `hooks.metadata` + `layoutContext` zinciriyle **paralel** üretilir.
|
|
27
|
+
Şablon locals'ında `metadata` önce controller'ın `page.metadata`'sıdır; layout
|
|
28
|
+
birleşik (hook + sayfa) metadata'yı alır. Navigasyon çoğu projede upstream'den
|
|
29
|
+
gelir — gövdeyi metadata zincirinin bitmesini bekletmek her MISS'e gecikme ekler.
|
|
29
30
|
|
|
30
31
|
## `.jsk` — build-time derlenmiş şablonlar
|
|
31
32
|
|
package/docs/05-islands.md
CHANGED
|
@@ -108,10 +108,14 @@ ve doğrudan boş zamana kaydırılır.
|
|
|
108
108
|
`hidden` bir drawer ya da dialog'un düzen kutusu yoktur ve
|
|
109
109
|
`IntersectionObserver` onu **asla** bildirmez. Bu yüzden `hydrate()` ölçümleri
|
|
110
110
|
tek seferde okur (`getClientRects().length > 0`) ve kutusu olmayan elementleri
|
|
111
|
-
gözlemciye vermek yerine
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
gözlemciye vermek yerine `mountWhenReady()` ile bağlar — yani
|
|
112
|
+
`data-island-idle` gizli host'ta da geçerlidir (`load` + boş zaman); idle
|
|
113
|
+
olmayanlar boş zamana kaydırılır. Ölçümlerin tek seferde okunması bilinçli:
|
|
114
|
+
araya yazma girmediği için tek reflow olur. `data-island-eager` hâlâ hemen
|
|
115
|
+
bağlanır.
|
|
116
|
+
|
|
117
|
+
Pratik sonucu: bir modal'ı `hidden` başlatabilirsiniz, island'ı yine bağlanır;
|
|
118
|
+
ağır bir gizli host için `data-island-idle` LCP ile yarışmayı keser.
|
|
115
119
|
|
|
116
120
|
## `client/` dizini
|
|
117
121
|
|
package/docs/06-cache.md
CHANGED
|
@@ -560,39 +560,40 @@ geçici bir kota sorunu TTL boyunca "bu sayfa yok" cevabına dönüşür. Arama
|
|
|
560
560
|
için bu kalıcı bir kayıp.
|
|
561
561
|
|
|
562
562
|
Framework bu durumu ayırır: render sırasında **geçici** bir upstream hatası
|
|
563
|
-
varsa `notFound()` 404 olarak servis edilmez.
|
|
563
|
+
varsa `notFound()` 404 olarak servis edilmez. Varsayılan (`background: true`):
|
|
564
564
|
|
|
565
|
-
1.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
taşır, sonraki istek yine gerçek içeriği üretebilir.
|
|
565
|
+
1. Ziyaretçiye hemen önbelleğe girmeyen bir `503` + `Retry-After` gider.
|
|
566
|
+
2. Aynı yol arka planda yeniden denenir (varsayılan bir kez, 300 ms sonra).
|
|
567
|
+
Deneme kendi upstream ve istek içi cache bağlamında koşar; ilk turun hatası
|
|
568
|
+
da memoize edilmiş boş cevabı da ikinci turu etkilemez. Başarılı olursa
|
|
569
|
+
sonraki istek L1/L2 HIT alır.
|
|
570
|
+
3. `background: false` eski senkron döngüyü açar: önce bekle + dene, başarısızsa
|
|
571
|
+
503; başarılıysa ziyaretçi gerçek içeriği görür.
|
|
573
572
|
|
|
574
573
|
| Render sırasında | `notFound()` sonucu |
|
|
575
574
|
| --- | --- |
|
|
576
|
-
| Geçici hata
|
|
575
|
+
| Geçici hata + `background: true` (varsayılan) | Hemen `503`, `Retry-After: 30`, `no-store`; retry arka planda |
|
|
576
|
+
| Geçici hata + `background: false` | Tekrar dene → başarılıysa sayfa; değilse `503` |
|
|
577
577
|
| Tekrar denemede upstream sağlam cevap verip "yok" dedi | Normal `404` |
|
|
578
578
|
| Kalıcı hata var (`404`, `403`…) ya da hata yok | Normal `404`, tekrar denenmez |
|
|
579
579
|
|
|
580
580
|
Log satırları:
|
|
581
581
|
|
|
582
582
|
```
|
|
583
|
-
[render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying (1
|
|
583
|
+
[render] /haber/x returned notFound() while upstream is failing (429 /api/...), retrying in background (1×)
|
|
584
584
|
[render] /haber/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
|
|
585
585
|
```
|
|
586
586
|
|
|
587
587
|
Yani **var olan bir sayfa hiçbir koşulda 404'e dönüşmez**: ya gerçek içerik
|
|
588
|
-
gelir, ya önbelleğe girmeyen bir 503.
|
|
588
|
+
gelir (senkron retry veya sonraki istek), ya önbelleğe girmeyen bir 503.
|
|
589
|
+
Hiçbir şey "yok" olarak dondurulmaz.
|
|
589
590
|
|
|
590
591
|
Tekrar denemenin maliyeti upstream'e binen ikinci bir istek turudur; bu yüzden
|
|
591
592
|
varsayılan tek deneme. Ayar `cache().transientRetry`:
|
|
592
593
|
|
|
593
594
|
```js
|
|
594
595
|
cache: {
|
|
595
|
-
transientRetry: { attempts: 2, delayMs: 500 },
|
|
596
|
+
transientRetry: { attempts: 2, delayMs: 500, background: true },
|
|
596
597
|
}
|
|
597
598
|
```
|
|
598
599
|
|
|
@@ -886,9 +887,10 @@ bir sonraki yazışta siler. `clearHtmlCache()` ve invalidation dosyayı da dü
|
|
|
886
887
|
bile geçerli olan bu kural paylaşımlı kademede daha da kritik: sızması, bir
|
|
887
888
|
kullanıcının HTML'ini tüm kümeye servis etmek olur. `degraded` render ve 200
|
|
888
889
|
dışındaki durum kodları da paylaşılmaz.
|
|
889
|
-
- **Sıkıştırılmış gövdeler varsayılan olarak
|
|
890
|
-
|
|
891
|
-
|
|
890
|
+
- **Sıkıştırılmış gövdeler varsayılan olarak paylaşılır.** `storeEncoded`
|
|
891
|
+
(varsayılan `true`) soğuk replica'nın L2 HIT'te yeniden brotli ödemesini
|
|
892
|
+
önler; girdi başına boyutu iki-üç katına çıkarır. L2 boyutunu kısıtlamak
|
|
893
|
+
için `storeEncoded: false` verin.
|
|
892
894
|
- **Yumuşak invalidation Redis kopyasını siler.** Bayatlatmanın Redis karşılığı
|
|
893
895
|
her anahtar için oku-değiştir-yaz turu demek ve bir webhook binlerce anahtarı
|
|
894
896
|
birden düşürüyor. Silmenin bedeli, o yolu hiç görmemiş bir kopyanın bir kez
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -772,7 +772,7 @@ Ayrıntı: [03-routing.md](./03-routing.md).
|
|
|
772
772
|
**Tip:**
|
|
773
773
|
`() => { html?: Record<string, number>, staleWhileRevalidate?: 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?: false | object }` —
|
|
774
774
|
**Varsayılan:**
|
|
775
|
-
`{ html: {}, staleWhileRevalidate: 60, 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, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (veya `prewarm: false` → `mode: "off"`)
|
|
775
|
+
`{ html: {}, staleWhileRevalidate: 60, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300, background: true }, upstream: { rate: 0 }, redis: { enabled: false, storeEncoded: true }, prewarm: { enabled: true, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (veya `prewarm: false` → `mode: "off"`)
|
|
776
776
|
|
|
777
777
|
### `cache().html`
|
|
778
778
|
|
|
@@ -902,13 +902,14 @@ kaydedilecek bir şey yok; kapatmak bağlam kurma maliyetini de kaldırır.
|
|
|
902
902
|
|
|
903
903
|
### `cache().transientRetry`
|
|
904
904
|
|
|
905
|
-
**Tip:** `{ attempts?: number, delayMs?: number } | false` —
|
|
906
|
-
**Varsayılan:** `{ attempts: 1, delayMs: 300 }`
|
|
905
|
+
**Tip:** `{ attempts?: number, delayMs?: number, background?: boolean } | false` —
|
|
906
|
+
**Varsayılan:** `{ attempts: 1, delayMs: 300, background: true }`
|
|
907
907
|
|
|
908
908
|
Geçici bir upstream hatası yüzünden `notFound()` çağrılan sayfa kaç kez daha
|
|
909
|
-
denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi
|
|
910
|
-
|
|
911
|
-
|
|
909
|
+
denenir. Amaç var olan bir sayfanın 404'e dönüşmemesi. `background: true`
|
|
910
|
+
(varsayılan) ziyaretçiye hemen 503 verir ve retry'yi arka planda koşturur;
|
|
911
|
+
`false` eski senkron döngüyü açar (önce bekle + dene, sonra yanıt). `false` ya
|
|
912
|
+
da `attempts: 0` tekrarı kapatır. Ayrıntı: [06-cache.md](./06-cache.md).
|
|
912
913
|
|
|
913
914
|
### `cache().upstream`
|
|
914
915
|
|
|
@@ -959,7 +960,7 @@ kurulmadıysa ya da bağlanılamıyorsa uyarı basılır ve site bellek içi ön
|
|
|
959
960
|
| `keyPrefix` | `string` | `"_jskelet"` | Anahtar düzeninin kökü |
|
|
960
961
|
| `html` | `boolean` | `true` | HTML gövdeleri paylaşılsın mı |
|
|
961
962
|
| `data` | `boolean` | `true` | `withDataCache` girdileri paylaşılsın mı |
|
|
962
|
-
| `storeEncoded` | `boolean` | `
|
|
963
|
+
| `storeEncoded` | `boolean` | `true` | Brotli/gzip gövdeleri de paylaşılsın mı; girdi başına boyutu iki-üç katına çıkarır |
|
|
963
964
|
| `events` | `boolean` | `true` | pub/sub üzerinden invalidation yayını |
|
|
964
965
|
| `commandTimeoutMs` | `number` | `200` | Tek bir komutun en fazla bekletebileceği süre |
|
|
965
966
|
|
package/docs/en/04-rendering.md
CHANGED
|
@@ -16,17 +16,19 @@ route(controller)
|
|
|
16
16
|
└─ produce()
|
|
17
17
|
├─ controller(ctx) → page definition
|
|
18
18
|
└─ renderPage(page)
|
|
19
|
-
├─ hooks.metadata(page) + page.metadata → metadata
|
|
20
19
|
├─ Promise.all([
|
|
21
|
-
│ renderView(page.view, { …data, metadata }),
|
|
22
|
-
│ hooks.
|
|
20
|
+
│ renderView(page.view, { …data, page.metadata }), → body
|
|
21
|
+
│ hooks.metadata(page) + page.metadata → metadata
|
|
22
|
+
│ └─ hooks.layoutContext({ pathname, metadata }) → context
|
|
23
23
|
│ ])
|
|
24
24
|
└─ layout (.jsk compiled or .ejs) → full HTML
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
The
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
The body runs **in parallel** with the `hooks.metadata` + `layoutContext`
|
|
28
|
+
chain. Template locals see the controller's `page.metadata` first; the layout
|
|
29
|
+
receives the merged (hook + page) metadata. Navigation often comes from
|
|
30
|
+
upstream — waiting for the metadata chain before starting the body adds latency
|
|
31
|
+
to every MISS.
|
|
30
32
|
|
|
31
33
|
## `.jsk` — build-time compiled templates
|
|
32
34
|
|
package/docs/en/05-islands.md
CHANGED
|
@@ -111,12 +111,16 @@ skipped and it is deferred straight to idle time.
|
|
|
111
111
|
|
|
112
112
|
A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
|
|
113
113
|
**never** reports it. That is why `hydrate()` reads its measurements in one go
|
|
114
|
-
(`getClientRects().length > 0`) and mounts elements
|
|
115
|
-
instead of handing them to the observer
|
|
116
|
-
|
|
114
|
+
(`getClientRects().length > 0`) and mounts zero-box elements via
|
|
115
|
+
`mountWhenReady()` instead of handing them to the observer — so
|
|
116
|
+
`data-island-idle` still applies on a hidden host (`load` plus idle time);
|
|
117
|
+
non-idle hosts are deferred to idle time. Reading the measurements in one go
|
|
118
|
+
is deliberate: since no write comes in between there is only a single reflow.
|
|
119
|
+
`data-island-eager` still mounts immediately.
|
|
117
120
|
|
|
118
121
|
The practical consequence: you can start a modal as `hidden` and its island
|
|
119
|
-
will still mount
|
|
122
|
+
will still mount; use `data-island-idle` on a heavy hidden host so it does not
|
|
123
|
+
compete with LCP.
|
|
120
124
|
|
|
121
125
|
## The `client/` directory
|
|
122
126
|
|
package/docs/en/06-caching.md
CHANGED
|
@@ -570,39 +570,41 @@ cache, a temporary quota problem becomes a "this page does not exist" answer for
|
|
|
570
570
|
the whole TTL. For a search engine that is a permanent loss.
|
|
571
571
|
|
|
572
572
|
The framework separates the two cases: if a **transient** upstream failure
|
|
573
|
-
happened during the render, `notFound()` is not served as a 404.
|
|
573
|
+
happened during the render, `notFound()` is not served as a 404. Default
|
|
574
|
+
(`background: true`):
|
|
574
575
|
|
|
575
|
-
1. The
|
|
576
|
+
1. The visitor gets an immediate uncached `503` + `Retry-After`.
|
|
577
|
+
2. The same path is retried in the background (once by default, after 300 ms).
|
|
576
578
|
The retry runs in its own upstream and per-request cache scope, so neither
|
|
577
|
-
the first round's failure nor its memoized empty answers affect it.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
3. If the retries are exhausted the response is a `503` — not cached, carrying
|
|
582
|
-
`Retry-After`, and the next request can still produce the real content.
|
|
579
|
+
the first round's failure nor its memoized empty answers affect it. On
|
|
580
|
+
success the next request is an L1/L2 HIT.
|
|
581
|
+
3. `background: false` restores the old sync loop: wait + retry first; on
|
|
582
|
+
success the visitor sees real content, otherwise a 503.
|
|
583
583
|
|
|
584
584
|
| During the render | Result of `notFound()` |
|
|
585
585
|
| --- | --- |
|
|
586
|
-
|
|
|
586
|
+
| Transient failure + `background: true` (default) | Immediate `503`, `Retry-After: 30`, `no-store`; retry in background |
|
|
587
|
+
| Transient failure + `background: false` | Retry → the page if it succeeds; otherwise `503` |
|
|
587
588
|
| The retry got a clean answer saying "not there" | A normal `404` |
|
|
588
589
|
| A permanent failure (`404`, `403`…) or no failure | A normal `404`, no retry |
|
|
589
590
|
|
|
590
591
|
The log lines:
|
|
591
592
|
|
|
592
593
|
```
|
|
593
|
-
[render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying (1
|
|
594
|
+
[render] /news/x returned notFound() while upstream is failing (429 /api/...), retrying in background (1×)
|
|
594
595
|
[render] /news/x could not be produced, upstream is still failing (429 /api/...), serving an uncached 503 instead of a 404
|
|
595
596
|
```
|
|
596
597
|
|
|
597
|
-
So **an existing page never turns into a 404**: either the real content arrives
|
|
598
|
-
or an uncached 503 does. Nothing is frozen as
|
|
598
|
+
So **an existing page never turns into a 404**: either the real content arrives
|
|
599
|
+
(sync retry or the next request), or an uncached 503 does. Nothing is frozen as
|
|
600
|
+
"missing".
|
|
599
601
|
|
|
600
602
|
The cost of a retry is a second round of requests on upstream, which is why the
|
|
601
603
|
default is a single attempt. The setting is `cache().transientRetry`:
|
|
602
604
|
|
|
603
605
|
```js
|
|
604
606
|
cache: {
|
|
605
|
-
transientRetry: { attempts: 2, delayMs: 500 },
|
|
607
|
+
transientRetry: { attempts: 2, delayMs: 500, background: true },
|
|
606
608
|
}
|
|
607
609
|
```
|
|
608
610
|
|
|
@@ -905,9 +907,9 @@ file too.
|
|
|
905
907
|
rule already holds in a single process, but it matters far more in a shared
|
|
906
908
|
tier: a leak would mean serving one user's HTML to the whole cluster.
|
|
907
909
|
`degraded` renders and non-200 status codes are not shared either.
|
|
908
|
-
- **Compressed bodies
|
|
909
|
-
|
|
910
|
-
|
|
910
|
+
- **Compressed bodies are shared by default.** `storeEncoded` (default `true`)
|
|
911
|
+
keeps a cold replica from paying brotli again on an L2 HIT; it doubles or
|
|
912
|
+
triples the size per entry. Set `storeEncoded: false` to keep L2 lean.
|
|
911
913
|
- **A soft invalidation deletes the Redis copy.** Staling in Redis would mean a
|
|
912
914
|
read-modify-write round per key, and a webhook drops thousands of keys at
|
|
913
915
|
once. The cost of deleting is one render on a replica that never saw that
|
|
@@ -786,7 +786,7 @@ Details: [03-routing.md](./03-routing.md).
|
|
|
786
786
|
**Type:**
|
|
787
787
|
`() => { html?: Record<string, number>, staleWhileRevalidate?: 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?: false | object }` —
|
|
788
788
|
**Default:**
|
|
789
|
-
`{ html: {}, staleWhileRevalidate: 60, 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, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (or `prewarm: false` → `mode: "off"`)
|
|
789
|
+
`{ html: {}, staleWhileRevalidate: 60, query: {}, vary: { host: false }, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300, background: true }, upstream: { rate: 0 }, redis: { enabled: false, storeEncoded: true }, prewarm: { enabled: true, mode: "classic", max: 400, intervalSeconds: 0, origins: [] } }` (or `prewarm: false` → `mode: "off"`)
|
|
790
790
|
|
|
791
791
|
### `cache().html`
|
|
792
792
|
|
|
@@ -920,13 +920,15 @@ setting up the context.
|
|
|
920
920
|
|
|
921
921
|
### `cache().transientRetry`
|
|
922
922
|
|
|
923
|
-
**Type:** `{ attempts?: number, delayMs?: number } | false` —
|
|
924
|
-
**Default:** `{ attempts: 1, delayMs: 300 }`
|
|
923
|
+
**Type:** `{ attempts?: number, delayMs?: number, background?: boolean } | false` —
|
|
924
|
+
**Default:** `{ attempts: 1, delayMs: 300, background: true }`
|
|
925
925
|
|
|
926
926
|
How many extra times a page is tried when `notFound()` was called because of a
|
|
927
927
|
transient upstream failure. The point is that an existing page never turns into
|
|
928
|
-
a 404
|
|
929
|
-
|
|
928
|
+
a 404. `background: true` (default) returns 503 immediately and runs the retry
|
|
929
|
+
in the background; `false` restores the old sync loop (wait + retry, then
|
|
930
|
+
respond). `false` or `attempts: 0` disables the retry. Details:
|
|
931
|
+
[06-caching.md](./06-caching.md).
|
|
930
932
|
|
|
931
933
|
### `cache().upstream`
|
|
932
934
|
|
|
@@ -977,7 +979,7 @@ the site keeps running on the in-process cache.
|
|
|
977
979
|
| `keyPrefix` | `string` | `"_jskelet"` | Root of the key layout |
|
|
978
980
|
| `html` | `boolean` | `true` | Whether HTML bodies are shared |
|
|
979
981
|
| `data` | `boolean` | `true` | Whether `withDataCache` entries are shared |
|
|
980
|
-
| `storeEncoded` | `boolean` | `
|
|
982
|
+
| `storeEncoded` | `boolean` | `true` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
|
|
981
983
|
| `events` | `boolean` | `true` | Invalidation broadcast over pub/sub |
|
|
982
984
|
| `commandTimeoutMs` | `number` | `200` | At most how long a single command may block |
|
|
983
985
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/build/build.mjs
CHANGED
|
@@ -68,14 +68,29 @@ async function task(label, run) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
// Fonts ve icon sprite birbirine bağlı değil — paralel.
|
|
72
|
+
{
|
|
73
|
+
/** @type {Promise<Record<string, string> | void>[]} */
|
|
74
|
+
const parallel = [];
|
|
75
|
+
if (config.fonts?.length) {
|
|
76
|
+
parallel.push(
|
|
77
|
+
(async () => {
|
|
78
|
+
const { copyFonts } = await import("./tasks/fonts.mjs");
|
|
79
|
+
return task("Fonts", () => copyFonts(config));
|
|
80
|
+
})(),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (config.icons !== false) {
|
|
84
|
+
parallel.push(
|
|
85
|
+
(async () => {
|
|
86
|
+
const { buildIconSprite } = await import("./tasks/icons.mjs");
|
|
87
|
+
return task("Icon sprite", () => buildIconSprite(config));
|
|
88
|
+
})(),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
for (const part of await Promise.all(parallel)) {
|
|
92
|
+
if (part) Object.assign(manifest, part);
|
|
93
|
+
}
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
if (fs.existsSync(config.dirs.styles)) {
|
|
@@ -49,6 +49,33 @@ function collect(dir) {
|
|
|
49
49
|
return files;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {Buffer} source
|
|
54
|
+
* @param {object} options
|
|
55
|
+
* @returns {Promise<Buffer>}
|
|
56
|
+
*/
|
|
57
|
+
function brotli(source, options) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
zlib.brotliCompress(source, options, (error, out) => {
|
|
60
|
+
if (error) reject(error);
|
|
61
|
+
else resolve(out);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Buffer} source
|
|
68
|
+
* @returns {Promise<Buffer>}
|
|
69
|
+
*/
|
|
70
|
+
function gzip(source) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
zlib.gzip(source, { level: 9 }, (error, out) => {
|
|
73
|
+
if (error) reject(error);
|
|
74
|
+
else resolve(out);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
export async function precompressAssets() {
|
|
53
80
|
// Önceki turdan kalan kopyalar kaynak değişince bayatlamasın.
|
|
54
81
|
for (const file of collect(paths.assets)) {
|
|
@@ -57,21 +84,34 @@ export async function precompressAssets() {
|
|
|
57
84
|
}
|
|
58
85
|
}
|
|
59
86
|
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
const candidates = collect(paths.assets).filter((file) => {
|
|
88
|
+
try {
|
|
89
|
+
return fs.statSync(file).size >= MIN_BYTES;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
62
94
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
const results = await Promise.all(
|
|
96
|
+
candidates.map(async (file) => {
|
|
97
|
+
const source = await fs.promises.readFile(file);
|
|
98
|
+
if (source.length < MIN_BYTES) return { count: 0, saved: 0 };
|
|
66
99
|
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
const [br, gz] = await Promise.all([brotli(source, BROTLI_OPTIONS), gzip(source)]);
|
|
101
|
+
await Promise.all([
|
|
102
|
+
fs.promises.writeFile(`${file}.br`, br),
|
|
103
|
+
fs.promises.writeFile(`${file}.gz`, gz),
|
|
104
|
+
]);
|
|
69
105
|
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
return { count: 1, saved: source.length - br.length };
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
110
|
+
let count = 0;
|
|
111
|
+
let saved = 0;
|
|
112
|
+
for (const row of results) {
|
|
113
|
+
count += row.count;
|
|
114
|
+
saved += row.saved;
|
|
75
115
|
}
|
|
76
116
|
|
|
77
117
|
log.detail(`${count} files, ${log.size(saved)}`);
|
package/src/client/registry.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* `data-island-idle` görünür olsa bile sayfa sakinleşene (load + boş zaman)
|
|
17
17
|
* kadar bekletir. İlk ekranda görünen ama kritik olmayan ağır modüller
|
|
18
18
|
* (ör. grafik kütüphanesi çeken mini grafik) LCP ile yarışmasın diye.
|
|
19
|
+
* Gizli (zero-box) host'larda da aynı yol kullanılır — `hydrate()` onları
|
|
20
|
+
* `mountWhenReady` ile bağlar, hemen `mountIsland` çağırmaz.
|
|
19
21
|
*
|
|
20
22
|
* `data-island-props` JSON ise parse edilip props olarak geçer.
|
|
21
23
|
*/
|
|
@@ -263,13 +265,21 @@ export function hydrate(root = document) {
|
|
|
263
265
|
const boxed = pending.map((node) => node.getClientRects().length > 0);
|
|
264
266
|
|
|
265
267
|
pending.forEach((node, i) => {
|
|
266
|
-
// `
|
|
267
|
-
|
|
268
|
-
if ("islandEager" in node.dataset || !boxed[i]) {
|
|
268
|
+
// `data-island-eager`: görünürlük / idle yok — hemen bağla.
|
|
269
|
+
if ("islandEager" in node.dataset) {
|
|
269
270
|
mountIsland(node);
|
|
270
271
|
return;
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
// `hidden` drawer/dialog'un düzen kutusu yoktur; IntersectionObserver
|
|
275
|
+
// onu asla bildirmez. Gözlemciye vermek yerine bağlarız, ama yol
|
|
276
|
+
// `mountWhenReady` — `data-island-idle` olan gizli host'lar `load` +
|
|
277
|
+
// boş zamana kadar bekler (LCP ile yarışmasın).
|
|
278
|
+
if (!boxed[i]) {
|
|
279
|
+
mountWhenReady(node);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
getLazyObserver().observe(node);
|
|
274
284
|
});
|
|
275
285
|
}
|
|
@@ -107,18 +107,24 @@ export async function compileAll(config, _options = {}) {
|
|
|
107
107
|
* @returns {Promise<{ count: number, rebuilt: boolean }>}
|
|
108
108
|
*/
|
|
109
109
|
export async function ensureTemplatesCompiled(config) {
|
|
110
|
-
const viewRoots = getViewRoots(config);
|
|
111
|
-
const jskFiles = discoverJskFiles(viewRoots);
|
|
112
|
-
if (jskFiles.size === 0) {
|
|
113
|
-
return { count: 0, rebuilt: false };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
110
|
const outDir = path.join(config.dirs.generated, "templates");
|
|
117
111
|
const manifestPath = path.join(outDir, "manifest.json");
|
|
118
112
|
const isProd = process.env.NODE_ENV === "production";
|
|
119
113
|
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
// Prod'da manifest varsa kaynak ağacını hiç gezme — cold start FS maliyeti.
|
|
115
|
+
if (isProd && fs.existsSync(manifestPath)) {
|
|
116
|
+
try {
|
|
117
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
118
|
+
return { count: Object.keys(manifest).length, rebuilt: false };
|
|
119
|
+
} catch {
|
|
120
|
+
return { count: 0, rebuilt: false };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const viewRoots = getViewRoots(config);
|
|
125
|
+
const jskFiles = discoverJskFiles(viewRoots);
|
|
126
|
+
if (jskFiles.size === 0) {
|
|
127
|
+
return { count: 0, rebuilt: false };
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
let needs = !fs.existsSync(manifestPath);
|
package/src/config/defaults.js
CHANGED
|
@@ -209,6 +209,11 @@ export const DATA_CACHE_MAX_ENTRIES_CEILING = 20_000;
|
|
|
209
209
|
export const DEFAULT_TRANSIENT_RETRY = {
|
|
210
210
|
attempts: 1,
|
|
211
211
|
delayMs: 300,
|
|
212
|
+
/**
|
|
213
|
+
* `true` (varsayılan): ilk transient `notFound`ta hemen 503; retry arka
|
|
214
|
+
* planda data cache'i ısıtır. `false`: yanıt öncesi senkron retry (eski).
|
|
215
|
+
*/
|
|
216
|
+
background: true,
|
|
212
217
|
};
|
|
213
218
|
|
|
214
219
|
/**
|
|
@@ -273,9 +278,8 @@ export const DEFAULT_UPSTREAM_LIMIT = {
|
|
|
273
278
|
* invalidation'ı bütün node'lara yaymak. Tek instance çalışan bir kurulumda
|
|
274
279
|
* kazanç neredeyse yok; bu yüzden `enabled` varsayılan olarak kapalı.
|
|
275
280
|
*
|
|
276
|
-
* `storeEncoded`
|
|
277
|
-
*
|
|
278
|
-
* indirmekten çoğu zaman daha ucuz.
|
|
281
|
+
* `storeEncoded` açık: soğuk replica L2 HIT'te yeniden brotli ödemesin.
|
|
282
|
+
* Boyut artar; tek encoding (br) tutulduğu için maliyet sınırlıdır.
|
|
279
283
|
*/
|
|
280
284
|
export const DEFAULT_REDIS = {
|
|
281
285
|
enabled: false,
|
|
@@ -288,8 +292,8 @@ export const DEFAULT_REDIS = {
|
|
|
288
292
|
html: true,
|
|
289
293
|
/** Veri önbelleği paylaşılsın mı. */
|
|
290
294
|
data: true,
|
|
291
|
-
/** Brotli
|
|
292
|
-
storeEncoded:
|
|
295
|
+
/** Brotli gövdeleri de paylaşılsın mı (yazma sonrası preencode). */
|
|
296
|
+
storeEncoded: true,
|
|
293
297
|
/** pub/sub üzerinden invalidation yayını. */
|
|
294
298
|
events: true,
|
|
295
299
|
/**
|
package/src/config/index.js
CHANGED
|
@@ -140,7 +140,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
140
140
|
* @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
|
|
141
141
|
* @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
|
|
142
142
|
* @property {boolean} trackDependencies Render'ın okuduğu veri anahtarları kaydedilsin mi.
|
|
143
|
-
* @property {{ attempts: number, delayMs: number }} transientRetry
|
|
143
|
+
* @property {{ attempts: number, delayMs: number, background?: boolean }} transientRetry
|
|
144
144
|
* @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
|
|
145
145
|
* @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
|
|
146
146
|
* @property {LogsConfig} logs Kalıcı log sink'leri (dosya + S3).
|
|
@@ -348,7 +348,7 @@ function normalizeRedis(raw) {
|
|
|
348
348
|
keyPrefix: String(source.keyPrefix ?? DEFAULT_REDIS.keyPrefix),
|
|
349
349
|
html: source.html !== false,
|
|
350
350
|
data: source.data !== false,
|
|
351
|
-
storeEncoded: source.storeEncoded
|
|
351
|
+
storeEncoded: source.storeEncoded !== false,
|
|
352
352
|
events: source.events !== false,
|
|
353
353
|
commandTimeoutMs:
|
|
354
354
|
Number.isFinite(timeout) && timeout > 0
|
|
@@ -738,7 +738,7 @@ function normalizeVary(raw) {
|
|
|
738
738
|
* staleWhileRevalidate: number,
|
|
739
739
|
* data: Record<string, unknown>, trackUpstream: boolean,
|
|
740
740
|
* trackDependencies: boolean,
|
|
741
|
-
* transientRetry: { attempts: number, delayMs: number },
|
|
741
|
+
* transientRetry: { attempts: number, delayMs: number, background?: boolean },
|
|
742
742
|
* redis: RedisConfig,
|
|
743
743
|
* upstream: typeof DEFAULT_UPSTREAM_LIMIT,
|
|
744
744
|
* cloudflare: typeof DEFAULT_CLOUDFLARE,
|