jskelet 0.7.0 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/04-render-ve-sablonlar.md +20 -9
  3. package/docs/05-islands.md +8 -4
  4. package/docs/06-cache.md +18 -16
  5. package/docs/07-yapilandirma.md +8 -7
  6. package/docs/en/04-rendering.md +21 -9
  7. package/docs/en/05-islands.md +8 -4
  8. package/docs/en/06-caching.md +18 -16
  9. package/docs/en/07-configuration.md +8 -6
  10. package/package.json +1 -1
  11. package/src/build/build.mjs +23 -8
  12. package/src/build/tasks/precompress.mjs +51 -11
  13. package/src/client/registry.js +13 -3
  14. package/src/compile/codegen.js +70 -4
  15. package/src/compile/compile-all.js +18 -3
  16. package/src/compile/index.js +6 -1
  17. package/src/config/defaults.js +9 -5
  18. package/src/config/index.js +3 -3
  19. package/src/http/request-cache.js +19 -1
  20. package/src/index.js +2 -0
  21. package/src/server/assets.js +51 -22
  22. package/src/server/cache-blob.js +33 -0
  23. package/src/server/create-app.js +5 -0
  24. package/src/server/data-cache.js +77 -11
  25. package/src/server/disk-cache.js +2 -2
  26. package/src/server/head-hints.js +21 -9
  27. package/src/server/html-cache.js +169 -39
  28. package/src/server/image-optimizer.js +72 -28
  29. package/src/server/redis.js +2 -2
  30. package/src/server/render.js +183 -60
  31. package/src/templates/layout.jsk +4 -4
  32. package/src/templates/layout.render.js +2 -21
  33. package/src/views/components/loader.js +11 -6
  34. package/src/views/helpers/html.js +13 -5
  35. package/src/views/helpers/tags.js +21 -4
  36. package/types/client/registry.d.ts +2 -0
  37. package/types/compile/codegen.d.ts +9 -4
  38. package/types/compile/compile-all.d.ts +3 -1
  39. package/types/compile/index.d.ts +1 -1
  40. package/types/config/defaults.d.ts +8 -4
  41. package/types/config/index.d.ts +1 -0
  42. package/types/index.d.ts +1 -1
  43. package/types/server/cache-blob.d.ts +8 -0
  44. package/types/server/data-cache.d.ts +1 -0
  45. package/types/server/html-cache.d.ts +4 -5
  46. package/types/server/render.d.ts +19 -0
package/CHANGELOG.md CHANGED
@@ -8,8 +8,64 @@ one is listed under a **Breaking** heading.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Changed
12
+
13
+ - `.jsk` codegen merges adjacent static HTML into fewer `+=` ops and drops
14
+ template indentation (newline-only whitespace). A lone space between
15
+ inline siblings is kept; `pre` / `textarea` / `script` / `style` / `code`
16
+ preserve whitespace. Framework default `layout.render.js` regenerated.
17
+ - `.jsk` page layout no longer copies the full helpers object into `data`
18
+ each render (EJS layouts still receive helpers as locals).
19
+ - `esc()` skips the regex replace when the string has no special characters;
20
+ `attrs()` builds the attribute string without an intermediate array.
21
+ - Compiled templates and JS components load with parallel `import` on first
22
+ render; production skips the `.jsk` mtime scan when a template manifest
23
+ already exists.
24
+ - `icon()` caches the sprite URL in production; `Image` `fill` without an
25
+ extra `class` skips `twMerge`; `optimizedImage()` materializes CDN-prefixed
26
+ variant URLs once at load time.
27
+ - `scripts/measure-ssr.mjs` microbench for fixture `renderView` /
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.
62
+
11
63
  ### Fixed
12
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).
13
69
  - S3 PutObject uses path-style URLs for bucket names that contain dots
14
70
  (e.g. `static.investvio.dev`). Virtual-hosted
15
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 }), → body
21
- │ hooks.layoutContext({ pathname, metadata }), → context
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
- Layout bağlamı ve gövde **paralel** üretilir. Sebebi ölçümden geliyor:
27
- navigasyon çoğu projede upstream'den geliyor ve gövde render'ıyla sırayla
28
- beklemek her sayfaya gereksiz gecikme ekliyor.
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
 
@@ -73,6 +74,12 @@ controller data → import edilmiş render(data, helpers) → HTML
73
74
  Atama, object literal ve rastgele fonksiyon çağrısı yok — mantık controller
74
75
  veya JS bileşende kalır.
75
76
 
77
+ Derleyici bitişik statik HTML’i tek literal’de birleştirir ve **şablon
78
+ girintisini** düşürür: satır sonu içeren salt-whitespace metin düğümleri
79
+ çıktıya girmez; etiketler arasında yalnızca boşluk/tab varsa tek boşluk
80
+ kalır (inline aralık). `pre`, `textarea`, `script`, `style` ve `code`
81
+ içinde whitespace olduğu gibi korunur.
82
+
76
83
  #### Şablon mu, bileşen mi?
77
84
 
78
85
  EJS’den geçerken sınırı erken çizmek işe yarar:
@@ -199,11 +206,15 @@ Dikkat edilecek noktalar:
199
206
  | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
200
207
  | `devtools` | `boolean` | `NODE_ENV === "development"` |
201
208
  | `devBasePath` | `string` | `brand.devBasePath`, varsayılan `/__jskelet/dev` |
202
- | `asset`, `hasAsset` | fonksiyon | Manifest erişimi |
203
- | html/tags yardımcıları | fonksiyon | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
204
- | `views/components/**` export'ları | fonksiyon | Otomatik kayıt |
209
+ | `asset`, `hasAsset` | fonksiyon | Manifest erişimi (EJS local; `.jsk`’de ikinci `helpers` argümanı / yerleşik etiketler) |
210
+ | html/tags yardımcıları | fonksiyon | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` — aynı ayrım |
211
+ | `views/components/**` export'ları | fonksiyon | Otomatik kayıt (PascalCase etiket veya EJS local) |
205
212
  | `hooks.layoutContext()` çıktısındaki her alan | — | Doğrudan local olur |
206
213
 
214
+ `.jsk` layout `(data, helpers)` aldığı için framework helpers nesnesini her
215
+ render’da `data`’ya kopyalamaz; EJS layout hâlâ bare helper adlarını local
216
+ olarak alır.
217
+
207
218
  `pathname`'in boş varsayılanı bilinçli: `"/"` yazmak her sayfayı ana sayfa
208
219
  sanıp logoyu `<h1>` olarak bastıran türde hatalara yol açıyor.
209
220
 
@@ -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 doğrudan bağlar. Ölçümlerin tek seferde okunması da
112
- bilinçli: araya yazma girmediği için tek reflow olur.
113
-
114
- Pratik sonucu: bir modal'ı `hidden` başlatabilirsiniz, island'ı yine bağlanır.
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. Sırayla:
563
+ varsa `notFound()` 404 olarak servis edilmez. Varsayılan (`background: true`):
564
564
 
565
- 1. Sayfa kısa bir beklemeden sonra **yeniden denenir** (varsayılan bir kez,
566
- 300 ms sonra). Deneme kendi upstream ve istek içi cache bağlamında koşar;
567
- ilk turun hatası da memoize edilmiş boş cevabı da ikinci turu etkilemez.
568
- 2. İkinci tur sayfayı üretebilirse ziyaretçi **gerçek içeriği** görür ve çıktı
569
- normal şekilde önbelleğe girer. Isıtma günlükleri bunun sık olduğunu
570
- gösteriyor: aynı yol saniyeler sonra 200 dönüyor.
571
- 3. Denemeler tükendiyse yanıt `503` olur — önbelleğe girmez, `Retry-After`
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 var (`429`, `5xx`, ağ hatası) | Tekrar dene → başarılıysa sayfa; hâlâ olmuyorsa `503`, `Retry-After: 30`, `no-store` |
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/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. Hiçbir şey "yok" olarak dondurulmaz.
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 yerel kalır.** `storeEncoded: true`
890
- ile açılabilir, ama girdi başına boyutu iki-üç katına çıkarır; brotli'yi
891
- yeniden üretmek çoğu zaman Redis'ten indirmekten ucuzdur.
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
@@ -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; denemeler tükenirse yanıt
910
- önbelleğe girmeyen bir 503 olur. `false` ya da `attempts: 0` tekrarı kapatır.
911
- Ayrıntı: [06-cache.md](./06-cache.md).
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` | `false` | Brotli/gzip gövdeleri de paylaşılsın mı; girdi başına boyutu iki-üç katına çıkarır |
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
 
@@ -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 }), → body
22
- │ hooks.layoutContext({ pathname, metadata }), → context
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 layout context and the body are produced **in parallel**. The reason comes
28
- from measurement: in most projects navigation comes from upstream, and waiting
29
- for it in sequence with the body render adds needless latency to every page.
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
 
@@ -74,6 +76,12 @@ The expression language is intentionally small (access, compare, ternary,
74
76
  `.length`). No assignments, object literals, or arbitrary calls — keep logic in
75
77
  controllers or JS components.
76
78
 
79
+ The compiler merges adjacent static HTML into fewer `+=` ops and strips
80
+ **template indentation**: whitespace-only text nodes that contain a newline are
81
+ dropped; a space/tab-only gap between siblings stays as a single space (inline
82
+ separation). Inside `pre`, `textarea`, `script`, `style`, and `code`, whitespace
83
+ is left unchanged.
84
+
77
85
  #### Template or component?
78
86
 
79
87
  When moving off EJS, draw the line early:
@@ -198,11 +206,15 @@ Points to watch:
198
206
  | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
199
207
  | `devtools` | `boolean` | `NODE_ENV === "development"` |
200
208
  | `devBasePath` | `string` | `brand.devBasePath`, defaults to `/__jskelet/dev` |
201
- | `asset`, `hasAsset` | function | Manifest access |
202
- | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` |
203
- | exports of `views/components/**` | function | Automatic registration |
209
+ | `asset`, `hasAsset` | function | Manifest access (EJS local; in `.jsk` the second `helpers` argument / built-in tags) |
210
+ | html/tags helpers | function | `esc`, `attrs`, `cx`, `cn`, `jsonScript`, `link`, `image`, `icon`, `preloadImage`, `toKebab` — same split |
211
+ | exports of `views/components/**` | function | Automatic registration (PascalCase tags or EJS locals) |
204
212
  | every field returned by `hooks.layoutContext()` | — | Becomes a local directly |
205
213
 
214
+ Because a `.jsk` layout receives `(data, helpers)`, the framework does not copy
215
+ the helpers object into `data` on every render; EJS layouts still get bare
216
+ helper names as locals.
217
+
206
218
  The empty default for `pathname` is deliberate: writing `"/"` leads to the kind
207
219
  of bug where every page thinks it is the home page and renders the logo as an
208
220
  `<h1>`.
@@ -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 without a box directly
115
- instead of handing them to the observer. Reading the measurements in one go is
116
- deliberate too: since no write comes in between there is only a single reflow.
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
 
@@ -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. In order:
573
+ happened during the render, `notFound()` is not served as a 404. Default
574
+ (`background: true`):
574
575
 
575
- 1. The page is **retried** after a short delay (once by default, after 300 ms).
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
- 2. If the second round can produce the page, the visitor sees the **real
579
- content** and the output is cached normally. Warm-up logs show this is
580
- common: the same path returns 200 seconds later.
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
- | A transient failure exists (`429`, `5xx`, network error) | Retry → the page if it succeeds; otherwise `503`, `Retry-After: 30`, `no-store` |
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/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 "missing".
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 stay local by default.** `storeEncoded: true` turns this
909
- on, but it doubles or triples the size per entry; recomputing brotli is
910
- usually cheaper than downloading it from Redis.
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; if the retries are exhausted the response is an uncached 503. `false` or
929
- `attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
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` | `false` | Whether brotli/gzip bodies are shared too; doubles or triples the size per entry |
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.0",
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",
@@ -68,14 +68,29 @@ async function task(label, run) {
68
68
  }
69
69
  }
70
70
 
71
- if (config.fonts?.length) {
72
- const { copyFonts } = await import("./tasks/fonts.mjs");
73
- Object.assign(manifest, await task("Fonts", () => copyFonts(config)));
74
- }
75
-
76
- if (config.icons !== false) {
77
- const { buildIconSprite } = await import("./tasks/icons.mjs");
78
- Object.assign(manifest, await task("Icon sprite", () => buildIconSprite(config)));
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
- let count = 0;
61
- let saved = 0;
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
- for (const file of collect(paths.assets)) {
64
- const source = fs.readFileSync(file);
65
- if (source.length < MIN_BYTES) continue;
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
- const br = zlib.brotliCompressSync(source, BROTLI_OPTIONS);
68
- const gz = zlib.gzipSync(source, { level: 9 });
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
- fs.writeFileSync(`${file}.br`, br);
71
- fs.writeFileSync(`${file}.gz`, gz);
106
+ return { count: 1, saved: source.length - br.length };
107
+ }),
108
+ );
72
109
 
73
- count += 1;
74
- saved += source.length - br.length;
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)}`);
@@ -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
- // `hidden` bir drawer/dialog'un düzen kutusu yoktur ve IntersectionObserver
267
- // onu asla bildirmez; bu yüzden gizli olanlar doğrudan bağlanır.
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
  }