jskelet 0.4.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,16 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - Early HTML cache refresh before TTL expiry: the last successful produce time
14
+ (`produceMs`) sets a lead window (`min(max(produceMs×2, 250ms), ttl/2)`). A
15
+ still-fresh `HIT` in that window revalidates in the background; idle entries
16
+ are soft-staled by a sweeper and drained over HTTP even without classic
17
+ `prewarmPaths` (`PREWARM=0` disables both). In-flight refreshes no longer drop
18
+ the entry when `staleUntil` elapses.
19
+ - Route-level stylesheets: put files in `styles/pages/*.css` and load them from
20
+ the controller with `styles: ["home.css"]` (same contract as island
21
+ `entries`). The layout emits them after global `app.css`; dev hot-swaps any
22
+ changed `.css` manifest key without a full reload.
13
23
  - Dev overlay Errors tab now lists failed SSR and browser `fetch` calls with
14
24
  page path, API URL, optional island name, and expandable response-body
15
25
  details (JSON instead of `[object Object]`). Server `console.error` /
package/docs/02-mimari.md CHANGED
@@ -144,9 +144,14 @@ geciktirmek doğrudan LCP'ye yazılır.
144
144
 
145
145
  Ayrı bir "critical CSS" üretilmez. Ölçümde inline kritik CSS ilk ekranı tam
146
146
  kapsamadığı için sheet gelince sayfa yeniden akıyordu (bir liste sayfasında CLS
147
- 0.307) ve aynı ~27 KB her HTML yanıtında tekrar ediyordu. Sıkıştırılmış tek
148
- sheet'i render-blocking bırakmak hem daha hızlı hem CLS'siz; ikinci ziyarette
149
- zaten `immutable` önbellekten geliyor.
147
+ 0.307) ve aynı ~27 KB her HTML yanıtında tekrar ediyordu. Sıkıştırılmış global
148
+ `app.css`'i render-blocking bırakmak hem daha hızlı hem CLS'siz; ikinci
149
+ ziyarette zaten `immutable` önbellekten geliyor.
150
+
151
+ Sayfaya özel kurallar için `styles/pages/*.css` + controller `styles: [...]`
152
+ eklenebilir ([08-build.md](./08-build.md)); bunlar da render-blocking basılır
153
+ ama yalnızca ilgili sayfada. Tailwind utility'leri global sheet'te kalır —
154
+ sayfa sheet'inde tam `@import "tailwindcss"` utility çıktısını tekrarlar.
150
155
 
151
156
  Aynı mantık ikonlarda da var: her ikon için ayrı istek yerine, build zamanında
152
157
  yalnızca kaynakta kullanılan sembollerden bir SVG sprite üretilir. Tüm Phosphor
@@ -213,6 +213,7 @@ Controller `async (ctx) => sayfa` biçimindedir ve şu alanları döndürebilir:
213
213
  | `head` | `string` | `""` | `<head>`e olduğu gibi basılacak ham HTML (ör. LCP preload'ı). |
214
214
  | `bodyClass` | `string` | `hooks.layoutContext().bodyClass ?? ""` | `<body class="…">`. |
215
215
  | `entries` | `string[]` | `[]` | Bu sayfada ek olarak yüklenecek client entry adları: `["chart.js"]`. |
216
+ | `styles` | `string[]` | `[]` | Bu sayfada ek olarak yüklenecek stylesheet adları: `["home.css"]` → `styles/pages/home.css`. |
216
217
 
217
218
  `revalidate` **`route()`'un ikinci argümanıdır**, controller'ın döndürdüğü
218
219
  nesnenin alanı değil.
@@ -239,6 +240,7 @@ app.get(
239
240
  head: headHints({ href: data.cover }),
240
241
  bodyClass: "bg-slate-50",
241
242
  entries: ["chart.js"],
243
+ styles: ["markets.css"],
242
244
  };
243
245
  },
244
246
  { revalidate: 30 },
@@ -151,8 +151,13 @@ kopyalamaktır.
151
151
  <meta name="viewport" content="width=device-width, initial-scale=1">
152
152
  <%- extraHead %>
153
153
  <% if (hasAsset('app.css')) { %>
154
- <link rel="stylesheet" href="<%= asset('app.css') %>">
154
+ <link rel="stylesheet" href="<%= asset('app.css') %>" data-jskelet-css="app.css">
155
155
  <% } %>
156
+ <% styles.forEach(function (sheet) { %>
157
+ <% if (hasAsset(sheet)) { %>
158
+ <link rel="stylesheet" href="<%= asset(sheet) %>" data-jskelet-css="<%= sheet %>">
159
+ <% } %>
160
+ <% }); %>
156
161
  <%- headMeta %>
157
162
  <% structuredData.forEach(function (item) { %>
158
163
  <script type="application/ld+json"><%- jsonScript(item) %></script>
@@ -177,9 +182,10 @@ Dikkat edilecek noktalar:
177
182
 
178
183
  - **`extraHead` en başta.** Kaynak ipuçlarını (`preconnect`, LCP `preload`)
179
184
  geciktirmek doğrudan LCP'ye yazılır.
180
- - **Tek, render-blocking stylesheet** ve gerekçesi
181
- [02-mimari.md](./02-mimari.md)'de. Build çalışmadıysa `hasAsset('app.css')`
182
- false olur ve etiket hiç basılmaz.
185
+ - **Global `app.css` render-blocking** ve gerekçesi
186
+ [02-mimari.md](./02-mimari.md)'de. Controller `styles: [...]` ile ek sayfa
187
+ sheet'leri de aynı şekilde basılır. Build çalışmadıysa `hasAsset` false olur
188
+ ve etiket hiç basılmaz.
183
189
  - **`hasAsset` kontrolleri** build eksikken sayfanın 404 veren dosyaları
184
190
  istememesini sağlar.
185
191
  - **Devtools script'i** yalnızca `NODE_ENV=development` iken basılır; prod
@@ -196,6 +202,7 @@ Dikkat edilecek noktalar:
196
202
  | `body` | `string` | Sayfa şablonunun render çıktısı |
197
203
  | `bodyClass` | `string` | controller `bodyClass` → `context.bodyClass` → `""` |
198
204
  | `entries` | `string[]` | controller `entries`; varsayılan `[]` |
205
+ | `styles` | `string[]` | controller `styles`; varsayılan `[]` |
199
206
  | `pathname` | `string` | `req.path`; **varsayılan boş string** |
200
207
  | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
201
208
  | `devtools` | `boolean` | `NODE_ENV === "development"` |
package/docs/06-cache.md CHANGED
@@ -142,15 +142,29 @@ Girdi yapısı:
142
142
  ```
143
143
  expiresAt = now + ttl
144
144
  staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
145
+ produceMs = son başarılı üretimin süresi (ms)
145
146
  ```
146
147
 
147
148
  Okuma davranışı:
148
149
 
149
150
  | Durum | Yanıt | Arka plan |
150
151
  | --- | --- | --- |
151
- | `now < expiresAt` | Önbellekteki HTML, `HIT` | — |
152
+ | `now < expiresAt - leadMs` | Önbellekteki HTML, `HIT` | — |
153
+ | `expiresAt - leadMs ≤ now < expiresAt` | Önbellekteki HTML, `HIT` | **Erken tazeleme** başlar |
152
154
  | `expiresAt ≤ now < staleUntil` | Önbellekteki HTML **anında**, `STALE` | Tazeleme başlatılır |
153
- | `now ≥ staleUntil` | Girdi silinir, taze render, `MISS` | — |
155
+ | `now ≥ staleUntil` | Girdi silinir, taze render, `MISS` (uçuştaki tazeleme varken silinmez) | — |
156
+
157
+ `leadMs` sayfanın load süresini hesaba katar:
158
+
159
+ ```
160
+ leadMs = min(max(produceMs * 2, 250ms), ttl / 2)
161
+ ```
162
+
163
+ Böylece yavaş bir sayfa TTL dolduğu anda hâlâ soğuk render'a düşmez: taze
164
+ HTML çoğu zaman `expiresAt` gelmeden yazılmış olur. Trafik yoksa bir sweeper
165
+ aynı pencerede girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır; `startPrewarm`
166
+ ( `PREWARM=0` değilse) kuyruğu HTTP ile boşaltır — klasik `prewarmPaths`
167
+ olmasa da.
154
168
 
155
169
  Stale penceresinde tazelemenin hatası isteği etkilemez: eski HTML pencere
156
170
  boyunca geçerli kalır ve hata yalnızca loglanır
@@ -622,7 +622,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
622
622
 
623
623
  Desen → saniye eşlemesi. Eşleşen kural, route'un kendi `revalidate` değerini
624
624
  **ezer**. Negatif ya da sonlu olmayan değerler yok sayılır; `0` "önbellekleme"
625
- anlamına gelir.
625
+ anlamına gelir. TTL dolmadan önce framework, son render süresine göre erken
626
+ arka plan tazelemesi başlatır (ayrı bir config alanı yok; ayrıntı
627
+ [06-cache.md](./06-cache.md)).
626
628
 
627
629
  ```js
628
630
  html: {
package/docs/08-build.md CHANGED
@@ -64,8 +64,13 @@ dosya adlarını okunur tutuyor. Hash'li olmaları sayesinde bu dosyalara
64
64
 
65
65
  ```ejs
66
66
  <% if (hasAsset('app.css')) { %>
67
- <link rel="stylesheet" href="<%= asset('app.css') %>">
67
+ <link rel="stylesheet" href="<%= asset('app.css') %>" data-jskelet-css="app.css">
68
68
  <% } %>
69
+ <% styles.forEach(function (sheet) { %>
70
+ <% if (hasAsset(sheet)) { %>
71
+ <link rel="stylesheet" href="<%= asset(sheet) %>" data-jskelet-css="<%= sheet %>">
72
+ <% } %>
73
+ <% }); %>
69
74
  ```
70
75
 
71
76
  - `asset(name)` manifest'te varsa hash'li URL'i, yoksa `/assets/<name>` döner.
@@ -85,7 +90,8 @@ Watch turunda yeniden derlenen varlık yeni bir hash'e yazılıp eskisi silinir.
85
90
  yüzden manifest de güncellenmek zorunda (`patchManifest`): aksi hâlde HTML
86
91
  silinmiş dosyayı isteyip 404 alır ve sayfa dev oturumunun kalanında stilsiz ya
87
92
  da JS'siz kalır. CSS ve client görevlerinin ikisi de her turda kendi anahtarını
88
- yamalar; diğer anahtarlar korunur.
93
+ yamalar; diğer anahtarlar korunur. CSS tarafı watch'ta `syncCssManifest` ile
94
+ tüm `.css` anahtarlarını günceller (silinen sayfa sheet'leri de düşer).
89
95
 
90
96
  ## CSS — Tailwind v4
91
97
 
@@ -100,10 +106,32 @@ minifikasyon → `writeAsset("app.css", …)`.
100
106
  şekilde yavaşlatıyor.
101
107
  - **lightningcss opsiyoneldir:** yoksa Tailwind'in kendi çıktısı kullanılır,
102
108
  yalnızca birkaç kB daha büyük olur.
103
- - Çıktı tek bir dosyadır ve layout onu render-blocking olarak yükler. Ayrı bir
104
- "critical CSS" üretilmemesinin ölçüm gerekçesi
109
+ - Global çıktı `app.css`'tir ve layout onu her sayfada render-blocking olarak
110
+ yükler. Ayrı bir "critical CSS" üretilmemesinin ölçüm gerekçesi
105
111
  [02-mimari.md](./02-mimari.md)'de.
106
112
 
113
+ ### Sayfa stylesheet'leri (`styles/pages/`)
114
+
115
+ Island `entries` ile aynı sözleşme. `styles/pages/*.css` altındaki her dosya
116
+ ayrı bir hash'li varlıktır (`home.css` → `/assets/home.<hash>.css`). Controller
117
+ yalnızca istediği sayfada yükler:
118
+
119
+ ```js
120
+ return {
121
+ view: "pages/home",
122
+ styles: ["home.css"],
123
+ };
124
+ ```
125
+
126
+ Dizin, `paths.styles` dosyasının yanındaki `pages/` klasörüdür (`styles` taşınırsa
127
+ pages de yanında kalır). Dizin yoksa veya boşsa adım yalnızca global sheet üretir.
128
+
129
+ Sayfa CSS'i sayfaya özel kurallar içindir. Tailwind utility'leri global sheet'te
130
+ kalmalı — dosyada tam `@import "tailwindcss"` utility çıktısını tekrarlar.
131
+
132
+ Layout `app.css`ten sonra `styles` dizisindeki her sheet için
133
+ `<link data-jskelet-css="…">` basar; `hasAsset` false ise etiket yok.
134
+
107
135
  ### `@source` direktifleri zorunludur
108
136
 
109
137
  Tailwind v4'ün sınıf taraması `globals.css` içindeki `@source` direktiflerine
@@ -117,7 +117,7 @@ yeniden yazıldığı için değişiklik tespiti manifest üzerinden yapılır.
117
117
 
118
118
  | Değişen | Davranış |
119
119
  | --- | --- |
120
- | Yalnızca `app.css` | **CSS hot-swap:** stylesheet takas edilir, sayfa yenilenmez. Durum ve kaydırma korunur. |
120
+ | Yalnızca `.css` anahtarları | **CSS hot-swap:** değişen her stylesheet takas edilir, sayfa yenilenmez. Durum ve kaydırma korunur. |
121
121
  | `main.js`, sprite, başka bir varlık ya da birden fazla anahtar | **Tam yenileme** |
122
122
 
123
123
  Her iki durumda önce HTML önbelleği temizlenir: saklanan HTML eski hash'li varlık
@@ -147,10 +147,16 @@ resource hints (`preconnect`, LCP `preload`) at the **very beginning** of the
147
147
  No separate "critical CSS" is produced. In measurement, because the inline
148
148
  critical CSS did not fully cover the first viewport, the page reflowed once the
149
149
  sheet arrived (CLS 0.307 on a list page) and the same ~27 KB was repeated in
150
- every HTML response. Leaving a single compressed sheet render-blocking is both
151
- faster and free of CLS; on the second visit it already comes from the
150
+ every HTML response. Leaving the compressed global `app.css` render-blocking is
151
+ both faster and free of CLS; on the second visit it already comes from the
152
152
  `immutable` cache.
153
153
 
154
+ Page-specific rules can use `styles/pages/*.css` plus controller
155
+ `styles: [...]` ([08-build.md](./08-build.md)); those are also render-blocking
156
+ but only on the pages that ask for them. Keep Tailwind utilities in the global
157
+ sheet — a full `@import "tailwindcss"` in a page sheet duplicates utility
158
+ output.
159
+
154
160
  The same logic applies to icons: instead of a separate request per icon, an SVG
155
161
  sprite is produced at build time from only the symbols actually used in the
156
162
  source. Shipping the whole Phosphor set is 1500+ icons, that is several
@@ -216,6 +216,7 @@ fields:
216
216
  | `head` | `string` | `""` | Raw HTML to be printed into `<head>` as-is (e.g. the LCP preload). |
217
217
  | `bodyClass` | `string` | `hooks.layoutContext().bodyClass ?? ""` | `<body class="…">`. |
218
218
  | `entries` | `string[]` | `[]` | The names of client entries to be loaded additionally on this page: `["chart.js"]`. |
219
+ | `styles` | `string[]` | `[]` | Extra stylesheets for this page: `["home.css"]` → `styles/pages/home.css`. |
219
220
 
220
221
  `revalidate` is **the second argument of `route()`**, not a field of the object
221
222
  the controller returns.
@@ -242,6 +243,7 @@ app.get(
242
243
  head: headHints({ href: data.cover }),
243
244
  bodyClass: "bg-slate-50",
244
245
  entries: ["chart.js"],
246
+ styles: ["markets.css"],
245
247
  };
246
248
  },
247
249
  { revalidate: 30 },
@@ -151,8 +151,13 @@ most practical way to move to your own layout is to copy that file to
151
151
  <meta name="viewport" content="width=device-width, initial-scale=1">
152
152
  <%- extraHead %>
153
153
  <% if (hasAsset('app.css')) { %>
154
- <link rel="stylesheet" href="<%= asset('app.css') %>">
154
+ <link rel="stylesheet" href="<%= asset('app.css') %>" data-jskelet-css="app.css">
155
155
  <% } %>
156
+ <% styles.forEach(function (sheet) { %>
157
+ <% if (hasAsset(sheet)) { %>
158
+ <link rel="stylesheet" href="<%= asset(sheet) %>" data-jskelet-css="<%= sheet %>">
159
+ <% } %>
160
+ <% }); %>
156
161
  <%- headMeta %>
157
162
  <% structuredData.forEach(function (item) { %>
158
163
  <script type="application/ld+json"><%- jsonScript(item) %></script>
@@ -177,9 +182,10 @@ Points to watch:
177
182
 
178
183
  - **`extraHead` comes first.** Delaying resource hints (`preconnect`, LCP
179
184
  `preload`) writes straight into LCP.
180
- - **A single, render-blocking stylesheet**, with the reasoning in
181
- [02-architecture.md](./02-architecture.md). If the build has not run,
182
- `hasAsset('app.css')` is false and the tag is never emitted.
185
+ - **Global `app.css` is render-blocking**, with the reasoning in
186
+ [02-architecture.md](./02-architecture.md). Controller `styles: [...]` adds
187
+ page sheets the same way. If the build has not run, `hasAsset` is false and
188
+ the tag is never emitted.
183
189
  - **The `hasAsset` checks** keep the page from requesting files that 404 when
184
190
  the build is missing.
185
191
  - **The devtools script** is emitted only when `NODE_ENV=development`; it does
@@ -196,6 +202,7 @@ Points to watch:
196
202
  | `body` | `string` | The render output of the page template |
197
203
  | `bodyClass` | `string` | controller `bodyClass` → `context.bodyClass` → `""` |
198
204
  | `entries` | `string[]` | controller `entries`; defaults to `[]` |
205
+ | `styles` | `string[]` | controller `styles`; defaults to `[]` |
199
206
  | `pathname` | `string` | `req.path`; **defaults to the empty string** |
200
207
  | `lang` | `string` | `context.lang` → `brand.lang` → `"en"` |
201
208
  | `devtools` | `boolean` | `NODE_ENV === "development"` |
@@ -150,15 +150,29 @@ The entry structure:
150
150
  ```
151
151
  expiresAt = now + ttl
152
152
  staleUntil = now + ttl * 2 (STALE_FACTOR = 1)
153
+ produceMs = duration of the last successful produce (ms)
153
154
  ```
154
155
 
155
156
  Read behaviour:
156
157
 
157
158
  | State | Response | Background |
158
159
  | --- | --- | --- |
159
- | `now < expiresAt` | The cached HTML, `HIT` | — |
160
+ | `now < expiresAt - leadMs` | The cached HTML, `HIT` | — |
161
+ | `expiresAt - leadMs ≤ now < expiresAt` | The cached HTML, `HIT` | **Early refresh** starts |
160
162
  | `expiresAt ≤ now < staleUntil` | The cached HTML **immediately**, `STALE` | A refresh is started |
161
- | `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` | — |
163
+ | `now ≥ staleUntil` | The entry is deleted, fresh render, `MISS` (not while a refresh is in flight) | — |
164
+
165
+ `leadMs` accounts for the page’s load time:
166
+
167
+ ```
168
+ leadMs = min(max(produceMs * 2, 250ms), ttl / 2)
169
+ ```
170
+
171
+ So a slow page does not fall back to a cold render the moment TTL ends: fresh
172
+ HTML is usually written before `expiresAt`. With no traffic, a sweeper
173
+ soft-stales the entry in the same window and queues it for warming;
174
+ `startPrewarm` (unless `PREWARM=0`) drains that queue over HTTP — even when
175
+ classic `prewarmPaths` is absent.
162
176
 
163
177
  A failure of the refresh inside the stale window does not affect the request:
164
178
  the old HTML stays valid for the whole window and the error is only logged
@@ -635,7 +635,9 @@ Details: [03-routing.md](./03-routing.md).
635
635
 
636
636
  A pattern → seconds mapping. A matching rule **overrides** the route's own
637
637
  `revalidate` value. Negative or non-finite values are ignored; `0` means "no
638
- caching".
638
+ caching". Before TTL ends the framework starts an early background refresh
639
+ based on the last render duration (no separate config field; see
640
+ [06-caching.md](./06-caching.md)).
639
641
 
640
642
  The one exception is `route(fn, { private: true })`: on that route a matching
641
643
  pattern is ignored. The lock is deliberately one-way — a mistake in the other
@@ -68,8 +68,13 @@ They are passed to templates automatically; in server code,
68
68
 
69
69
  ```ejs
70
70
  <% if (hasAsset('app.css')) { %>
71
- <link rel="stylesheet" href="<%= asset('app.css') %>">
71
+ <link rel="stylesheet" href="<%= asset('app.css') %>" data-jskelet-css="app.css">
72
72
  <% } %>
73
+ <% styles.forEach(function (sheet) { %>
74
+ <% if (hasAsset(sheet)) { %>
75
+ <link rel="stylesheet" href="<%= asset(sheet) %>" data-jskelet-css="<%= sheet %>">
76
+ <% } %>
77
+ <% }); %>
73
78
  ```
74
79
 
75
80
  - `asset(name)` returns the hashed URL if it is in the manifest, otherwise
@@ -88,10 +93,11 @@ hashes) and once in prod.
88
93
  ### Manifest consistency in watch mode
89
94
 
90
95
  On a watch pass, a recompiled asset is written to a new hash and the old one is
91
- deleted. That is why the manifest has to be updated too (`patchManifest`):
92
- otherwise the HTML asks for the deleted file, gets a 404, and the page stays
93
- unstyled or JS-less for the rest of the dev session. Both the CSS and the client
94
- tasks patch their own key on every pass; the other keys are preserved.
96
+ deleted. That is why the manifest has to be updated too (`patchManifest` /
97
+ CSS `syncCssManifest`): otherwise the HTML asks for the deleted file, gets a
98
+ 404, and the page stays unstyled or JS-less for the rest of the dev session.
99
+ Both the CSS and the client tasks refresh their own keys on every pass; the
100
+ other keys are preserved.
95
101
 
96
102
  ## CSS — Tailwind v4
97
103
 
@@ -106,9 +112,32 @@ The pipeline: PostCSS + `@tailwindcss/postcss` → minification with lightningcs
106
112
  noticeably.
107
113
  - **lightningcss is optional:** without it, Tailwind's own output is used, and it
108
114
  is only a few kB bigger.
109
- - The output is a single file and the layout loads it render-blocking. The
110
- measurement-based reasoning for not producing a separate "critical CSS" is in
111
- [02-architecture.md](./02-architecture.md).
115
+ - The global output is `app.css` and the layout loads it render-blocking on every
116
+ page. The measurement-based reasoning for not producing a separate "critical
117
+ CSS" is in [02-architecture.md](./02-architecture.md).
118
+
119
+ ### Page stylesheets (`styles/pages/`)
120
+
121
+ Same contract as island `entries`. Each file under `styles/pages/*.css` becomes
122
+ its own hashed asset (`home.css` → `/assets/home.<hash>.css`). The controller
123
+ loads it only on the pages that need it:
124
+
125
+ ```js
126
+ return {
127
+ view: "pages/home",
128
+ styles: ["home.css"],
129
+ };
130
+ ```
131
+
132
+ The directory is `pages/` next to the `paths.styles` file (if `styles` moves,
133
+ pages stays beside it). If the directory is missing or empty, the step only
134
+ produces the global sheet.
135
+
136
+ Page CSS is for page-specific rules. Keep Tailwind utilities in the global
137
+ sheet — a full `@import "tailwindcss"` in a page file duplicates utility output.
138
+
139
+ After `app.css`, the layout emits a `<link data-jskelet-css="…">` for each name
140
+ in `styles`; if `hasAsset` is false the tag is omitted.
112
141
 
113
142
  ### `@source` directives are mandatory
114
143
 
@@ -121,7 +121,7 @@ rewritten on every build round, change detection is done through the manifest.
121
121
 
122
122
  | What changed | Behavior |
123
123
  | --- | --- |
124
- | Only `app.css` | **CSS hot-swap:** the stylesheet is swapped, the page is not reloaded. State and scroll position are preserved. |
124
+ | Only `.css` keys | **CSS hot-swap:** each changed stylesheet is swapped, the page is not reloaded. State and scroll position are preserved. |
125
125
  | `main.js`, the sprite, another asset, or more than one key | **Full reload** |
126
126
 
127
127
  In both cases the HTML cache is cleared first: the stored HTML carries the old
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.4.8",
3
+ "version": "0.5.1",
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",
@@ -1,11 +1,20 @@
1
1
  /**
2
- * Tailwind v4 → tek statik stylesheet.
2
+ * Tailwind v4 → global `app.css` + isteğe bağlı sayfa stylesheet'leri.
3
+ *
4
+ * Global sheet her sayfada yüklenir (`paths.styles`). Sayfa sheet'leri
5
+ * `styles/pages/*.css` altındadır ve yalnızca controller `styles: ["home.css"]`
6
+ * bildiren sayfalarda basılır — island `entries` ile aynı sözleşme.
3
7
  *
4
8
  * Ayrı bir "critical CSS" üretilmez. Ölçümde inline kritik CSS ilk ekranı tam
5
9
  * kapsamadığı için sheet gelince sayfa yeniden akıyordu (bir liste sayfasında
6
10
  * CLS 0.307) ve aynı ~27 KB her HTML yanıtında tekrar ediyordu. Sıkıştırılmış
7
- * tek sheet'i render-blocking bırakmak hem daha hızlı hem CLS'siz; ikinci
8
- * ziyarette zaten immutable önbellekten geliyor.
11
+ * global sheet'i render-blocking bırakmak hem daha hızlı hem CLS'siz; ikinci
12
+ * ziyarette zaten immutable önbellekten geliyor. Sayfa sheet'leri de
13
+ * render-blocking; yalnızca ilgili sayfada ek maliyet.
14
+ *
15
+ * Tailwind utility'leri global sheet'te kalmalı. Sayfa CSS'inde tam
16
+ * `@import "tailwindcss"` utility çıktısını tekrarlar; sayfaya özel kurallar
17
+ * yeter.
9
18
  *
10
19
  * Tailwind'in sınıf taraması `globals.css` içindeki `@source` direktiflerine
11
20
  * bağlıdır. Otomatik tespit yalnızca stylesheet'in bulunduğu dizini tarar,
@@ -14,12 +23,12 @@
14
23
  */
15
24
  import fs from "node:fs";
16
25
  import path from "node:path";
17
- import { patchManifest, pruneAssets, writeAsset } from "../paths.mjs";
26
+ import { paths, writeAsset, writeManifest } from "../paths.mjs";
18
27
  import { importFromApp, tryImportFromApp } from "../resolve-peer.mjs";
19
28
  import * as log from "../../log.mjs";
20
29
 
21
30
  /**
22
- * PostCSS boru hattı bir kez kurulur: Tailwind'in kendi önbelleği plugin
31
+ * PostCSS boru hattı bir kez kurulur: Tailwind'in kendi önbelleği plugin-++
23
32
  * örneğinde yaşıyor, her derlemede yeniden oluşturmak watch turlarını
24
33
  * belirgin şekilde yavaşlatıyor.
25
34
  *
@@ -53,6 +62,29 @@ async function createCompiler(root) {
53
62
  };
54
63
  }
55
64
 
65
+ /**
66
+ * `paths.styles` dosyasının yanında `pages/` — config'te ayrı alan yok.
67
+ *
68
+ * @param {import('../../config/index.js').ResolvedConfig} config
69
+ * @returns {string}
70
+ */
71
+ function pageStylesDir(config) {
72
+ return path.join(path.dirname(config.dirs.styles), "pages");
73
+ }
74
+
75
+ /**
76
+ * @param {string} dir
77
+ * @returns {string[]} Mutlak yollar, dosya adına göre sıralı.
78
+ */
79
+ function listPageStyles(dir) {
80
+ if (!fs.existsSync(dir)) return [];
81
+ return fs
82
+ .readdirSync(dir)
83
+ .filter((name) => name.endsWith(".css"))
84
+ .sort()
85
+ .map((name) => path.join(dir, name));
86
+ }
87
+
56
88
  /**
57
89
  * @param {import('../../config/index.js').ResolvedConfig} config
58
90
  * @param {{ watch?: boolean }} [options]
@@ -60,27 +92,55 @@ async function createCompiler(root) {
60
92
  */
61
93
  export async function buildCss(config, { watch = false } = {}) {
62
94
  const input = config.dirs.styles;
95
+ const pagesDir = pageStylesDir(config);
63
96
  const compile = await createCompiler(config.root);
64
97
 
65
98
  const run = async () => {
66
99
  const started = Date.now();
67
- const css = await compile(input);
100
+ /** @type {Record<string, string>} */
101
+ const urls = {};
102
+ let bytes = 0;
103
+
104
+ const appCss = await compile(input);
105
+ const appUrl = writeAsset("app.css", appCss);
106
+ urls["app.css"] = appUrl;
107
+ bytes += Buffer.byteLength(appCss);
108
+
109
+ /** @type {string[]} */
110
+ const keep = [path.basename(appUrl)];
111
+
112
+ for (const file of listPageStyles(pagesDir)) {
113
+ const name = path.basename(file);
114
+ const css = await compile(file);
115
+ const url = writeAsset(name, css);
116
+ urls[name] = url;
117
+ bytes += Buffer.byteLength(css);
118
+ keep.push(path.basename(url));
119
+ }
120
+
68
121
  // Önce yaz, sonra eski hash'leri sil — aynı hash'e düşen içerikte 404
69
- // penceresi olmasın (CDN immutable zehirlenmesi).
70
- const url = writeAsset("app.css", css);
71
- pruneAssets(["app."], { keep: [path.basename(url)] });
72
- return { url, bytes: Buffer.byteLength(css), elapsed: Date.now() - started };
122
+ // penceresi olmasın (CDN immutable zehirlenmesi). Silinen sayfa sheet'leri
123
+ // de burada gider; önek listesi tutmaya gerek yok.
124
+ pruneCssOutputs(keep);
125
+
126
+ return { urls, bytes, elapsed: Date.now() - started };
73
127
  };
74
128
 
75
129
  const first = await run();
76
- log.detail(log.size(first.bytes));
130
+ const pageCount = Object.keys(first.urls).length - 1;
131
+ log.detail(
132
+ pageCount > 0
133
+ ? `${log.size(first.bytes)}, ${pageCount} page ${pageCount === 1 ? "sheet" : "sheets"}`
134
+ : log.size(first.bytes),
135
+ );
77
136
 
78
137
  if (watch) {
79
138
  watchCssSources(config, async () => {
80
139
  try {
81
140
  const result = await run();
82
141
  // Yeni hash manifest'e yazılmazsa HTML silinmiş dosyayı ister.
83
- patchManifest("app.css", result.url);
142
+ // Silinen sayfa sheet anahtarları da düşsün.
143
+ syncCssManifest(result.urls);
84
144
  log.event({
85
145
  scope: "css",
86
146
  message: "rebuilt",
@@ -98,12 +158,61 @@ export async function buildCss(config, { watch = false } = {}) {
98
158
  });
99
159
  }
100
160
 
101
- return { "app.css": first.url };
161
+ return first.urls;
162
+ }
163
+
164
+ /**
165
+ * `public/assets/` kökündeki stylesheet çıktıları. `js/` ve `img/` altındakilere
166
+ * dokunulmaz.
167
+ *
168
+ * @param {string[]} keep Yeni yazılan dosya adları (`app.<hash>.css`, …)
169
+ */
170
+ function pruneCssOutputs(keep) {
171
+ if (!fs.existsSync(paths.assets)) return;
172
+
173
+ for (const file of fs.readdirSync(paths.assets)) {
174
+ if (!/\.css(?:\.(?:br|gz))?$/i.test(file)) continue;
175
+ if (keep.some((name) => file === name || file.startsWith(`${name}.`))) {
176
+ continue;
177
+ }
178
+ fs.rmSync(path.join(paths.assets, file), { force: true });
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Watch turunda CSS anahtarlarını toplu günceller; artık üretilmeyen sayfa
184
+ * sheet'lerini manifest'ten siler.
185
+ *
186
+ * @param {Record<string, string>} urls
187
+ */
188
+ function syncCssManifest(urls) {
189
+ const file = path.join(paths.generated, "manifest.json");
190
+ /** @type {Record<string, string>} */
191
+ let current = {};
192
+ try {
193
+ current = JSON.parse(fs.readFileSync(file, "utf8"));
194
+ } catch {
195
+ // İlk yazımda oluşur.
196
+ }
197
+
198
+ /** @type {Record<string, string>} */
199
+ const next = {};
200
+ for (const [key, value] of Object.entries(current)) {
201
+ if (key.endsWith(".css") && !(key in urls)) continue;
202
+ next[key] = value;
203
+ }
204
+ for (const [key, url] of Object.entries(urls)) {
205
+ next[key] = url;
206
+ }
207
+
208
+ if (JSON.stringify(current) === JSON.stringify(next)) return;
209
+ writeManifest(next);
102
210
  }
103
211
 
104
212
  /**
105
213
  * Şablon ve island dosyaları da izlenir: Tailwind sınıfları oradan geliyor,
106
214
  * yalnızca `styles/` izlemek yeni bir utility yazıldığında rebuild etmez.
215
+ * `styles/pages` zaten `dirname(styles)` altında.
107
216
  *
108
217
  * @param {import('../../config/index.js').ResolvedConfig} config
109
218
  * @param {() => void} onChange
@@ -630,7 +630,7 @@ function handleServerMessage(payload) {
630
630
  }
631
631
 
632
632
  if (payload.type === "css") {
633
- swapStylesheet(payload.href);
633
+ swapStylesheet(payload.href, payload.name);
634
634
  return;
635
635
  }
636
636
 
@@ -664,13 +664,18 @@ function startFallback() {
664
664
 
665
665
  /**
666
666
  * Yeni sheet yüklenmeden eskisi kaldırılmaz; böylece stilsiz bir kare oluşmaz.
667
+ * `name` varsa `data-jskelet-css` ile doğru link seçilir (sayfa sheet'leri).
668
+ *
667
669
  * @param {string} href
670
+ * @param {string} [name] Manifest anahtarı (`app.css`, `home.css`, …)
668
671
  */
669
- function swapStylesheet(href) {
670
- const current = document.querySelector('link[rel="stylesheet"]');
672
+ function swapStylesheet(href, name) {
673
+ const current = name
674
+ ? document.querySelector(`link[rel="stylesheet"][data-jskelet-css="${CSS.escape(name)}"]`)
675
+ : document.querySelector('link[rel="stylesheet"]');
671
676
  if (!current || current.getAttribute("href") === href) return;
672
677
 
673
- const next = current.cloneNode();
678
+ const next = /** @type {HTMLLinkElement} */ (current.cloneNode());
674
679
  next.href = href;
675
680
  next.addEventListener("load", () => current.remove(), { once: true });
676
681
  current.after(next);
@@ -426,8 +426,12 @@ function watchManifest() {
426
426
  // sayfa silinmiş dosyayı istemeye devam eder.
427
427
  clearHtmlCache();
428
428
 
429
- if (changed.length === 1 && changed[0] === "app.css") {
430
- broadcast({ type: "css", href: next["app.css"] });
429
+ // Yalnızca stylesheet anahtarları değiştiyse hot-swap; JS/sprite için
430
+ // tam yenileme gerekir. Birden fazla sayfa sheet'i de desteklenir.
431
+ if (changed.every((key) => key.endsWith(".css"))) {
432
+ for (const name of changed) {
433
+ broadcast({ type: "css", name, href: next[name] });
434
+ }
431
435
  return;
432
436
  }
433
437
 
@@ -7,6 +7,11 @@
7
7
  * tazeleme turu` kadar geride olabilir. Fiyat gibi canlı alanlar istemcide
8
8
  * WebSocket'ten güncellendiği için bu gecikme ekranda görünmez.
9
9
  *
10
+ * TTL dolmadan önce de tazelenir (**erken tazeleme**): son başarılı üretimin
11
+ * süresi (`produceMs`) kadar önden arka plan refresh başlar, böylece yavaş
12
+ * bir sayfa TTL anında hâlâ soğuk render'a düşmez. Trafik yoksa sweeper
13
+ * girdiyi soft-bayatlatır ve ısıtma kuyruğuna alır.
14
+ *
10
15
  * TTL'in yanında ikinci bir tazelik kaynağı daha var: **hedefli
11
16
  * invalidation**. Bir içerik güncellendiğinde tüm önbelleği boşaltmak
12
17
  * (`clearHtmlCache()`) o an sıcak olan her sayfayı soğuk render'a çevirir;
@@ -50,9 +55,12 @@ import {
50
55
  * `encoded` haritası yanıt yolunda (`sendHtml`) doluyor, yani yazma anında
51
56
  * boş; `storeEncoded` açıkken harita büyüdüğünde girdi yeniden paylaşılır.
52
57
  *
58
+ * `produceMs`: son başarılı üretimin süresi. Erken tazeleme penceresi bundan
59
+ * türetilir; Redis'ten gelen kopyada yoksa varsayılan kullanılır.
60
+ *
53
61
  * @typedef {{ html: string, status: number, expiresAt: number,
54
62
  * staleUntil: number, encoded: Map<string, Buffer>, deps: Set<string>,
55
- * storedAt: number, sharedEncodings: number }} HtmlEntry
63
+ * storedAt: number, sharedEncodings: number, produceMs: number }} HtmlEntry
56
64
  */
57
65
 
58
66
  /**
@@ -94,12 +102,27 @@ function trackDependencies() {
94
102
  */
95
103
  const STALE_FACTOR = 1;
96
104
 
105
+ /**
106
+ * Redis'ten gelen veya süresi bilinmeyen girdiler için erken tazeleme lead'i.
107
+ * Ölçülmüş `produceMs` yokken aşırı iyimser (0) kalmamak için.
108
+ */
109
+ const DEFAULT_PRODUCE_MS = 500;
110
+
111
+ /** Erken tazelemenin alt sınırı — çok hızlı sayfalar da TTL'den önce ısınsın. */
112
+ const EARLY_REFRESH_MIN_MS = 250;
113
+
114
+ /** Trafiksiz girdileri erken pencerede soft-bayatlatma aralığı. */
115
+ const EARLY_SWEEP_INTERVAL_MS = 1000;
116
+
97
117
  /** @type {Map<string, HtmlEntry>} */
98
118
  const store = new Map();
99
119
 
100
120
  /** @type {Map<string, Promise<{ html: string, status: number }>>} */
101
121
  const inflight = new Map();
102
122
 
123
+ /** @type {ReturnType<typeof setInterval> | null} */
124
+ let earlySweepTimer = null;
125
+
103
126
  /**
104
127
  * Uçuştaki her tazelemenin kimliği. Bir girdi tazelenirken invalidate
105
128
  * edilirse o tazelemenin sonucu **artık geçersizdir**: render, purge'den önce
@@ -148,6 +171,54 @@ const purgedDeps = new Map();
148
171
 
149
172
  const MAX_PURGED_DEPS = 1000;
150
173
 
174
+ /**
175
+ * Erken tazeleme lead'i: son render süresinin 2 katı (en az 250 ms), TTL'in
176
+ * yarısından fazla olamaz — kısa TTL'lerde sürekli refresh döngüsü olmasın.
177
+ *
178
+ * @param {number} produceMs
179
+ * @param {number} ttlMs
180
+ * @returns {number}
181
+ */
182
+ export function earlyRefreshLeadMs(produceMs, ttlMs) {
183
+ const measured =
184
+ Number.isFinite(produceMs) && produceMs > 0 ? produceMs : DEFAULT_PRODUCE_MS;
185
+ const lead = Math.max(measured * 2, EARLY_REFRESH_MIN_MS);
186
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return lead;
187
+ return Math.min(lead, ttlMs / 2);
188
+ }
189
+
190
+ /**
191
+ * @param {HtmlEntry} entry
192
+ * @returns {number}
193
+ */
194
+ function entryTtlMs(entry) {
195
+ // Soft-bayatlatılmış girdide expiresAt 0; orijinal TTL storedAt farkından
196
+ // okunamaz. O durumda produceMs üzerinden güvenli bir üst sınır yeter.
197
+ if (entry.expiresAt > entry.storedAt) return entry.expiresAt - entry.storedAt;
198
+ return Math.max(entry.produceMs * 4, EARLY_REFRESH_MIN_MS * 2);
199
+ }
200
+
201
+ /**
202
+ * @param {HtmlEntry} entry
203
+ * @param {number} [now]
204
+ * @returns {boolean}
205
+ */
206
+ function isEarly(entry, now = Date.now()) {
207
+ if (now >= entry.expiresAt) return false;
208
+ const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
209
+ return now >= entry.expiresAt - lead;
210
+ }
211
+
212
+ /**
213
+ * @param {unknown} value
214
+ * @returns {number}
215
+ */
216
+ function normalizeProduceMs(value) {
217
+ const n = Number(value);
218
+ if (Number.isFinite(n) && n >= 0) return Math.round(n);
219
+ return DEFAULT_PRODUCE_MS;
220
+ }
221
+
151
222
  /**
152
223
  * Girdiyi ters indeksten söker. Bu adım atlanırsa indeks, düşen girdilerin
153
224
  * anahtarlarını tutmaya devam eder ve sessizce sızar.
@@ -183,7 +254,7 @@ function drop(key) {
183
254
  /**
184
255
  * @param {string} key
185
256
  * @returns {{ html: string, status: number, encoded: Map<string, Buffer>,
186
- * stale: boolean } | null}
257
+ * stale: boolean, early: boolean } | null}
187
258
  */
188
259
  function read(key) {
189
260
  const entry = store.get(key);
@@ -191,8 +262,12 @@ function read(key) {
191
262
 
192
263
  const now = Date.now();
193
264
  if (now >= entry.staleUntil) {
194
- drop(key);
195
- return null;
265
+ // Uçuştaki tazeleme bitene kadar girdiyi tut: yavaş upstream'de
266
+ // staleUntil dolup MISS'e düşmek erken tazelemenin amacını bozar.
267
+ if (!inflight.has(key)) {
268
+ drop(key);
269
+ return null;
270
+ }
196
271
  }
197
272
 
198
273
  // LRU: erişilen girdiyi sona taşı.
@@ -206,11 +281,13 @@ function read(key) {
206
281
  share(key, entry);
207
282
  }
208
283
 
284
+ const stale = now >= entry.expiresAt;
209
285
  return {
210
286
  html: entry.html,
211
287
  status: entry.status,
212
288
  encoded: entry.encoded,
213
- stale: now >= entry.expiresAt,
289
+ stale,
290
+ early: !stale && isEarly(entry, now),
214
291
  };
215
292
  }
216
293
 
@@ -234,6 +311,7 @@ function share(key, entry) {
234
311
  storedAt: entry.storedAt,
235
312
  expiresAt: entry.expiresAt,
236
313
  staleUntil: entry.staleUntil,
314
+ produceMs: entry.produceMs,
237
315
  deps: [...entry.deps],
238
316
  };
239
317
 
@@ -298,6 +376,7 @@ async function readShared(key) {
298
376
  deps,
299
377
  storedAt,
300
378
  sharedEncodings: encoded.size,
379
+ produceMs: normalizeProduceMs(payload.produceMs),
301
380
  };
302
381
  }
303
382
 
@@ -306,8 +385,9 @@ async function readShared(key) {
306
385
  * @param {{ html: string, status: number }} value
307
386
  * @param {number} ttlSeconds
308
387
  * @param {Set<string> | null} deps Render sırasında okunan veri anahtarları.
388
+ * @param {number} [produceMs] Son üretimin süresi (ms).
309
389
  */
310
- function write(key, value, ttlSeconds, deps = null) {
390
+ function write(key, value, ttlSeconds, deps = null, produceMs = DEFAULT_PRODUCE_MS) {
311
391
  const now = Date.now();
312
392
 
313
393
  /** @type {HtmlEntry} */
@@ -322,6 +402,7 @@ function write(key, value, ttlSeconds, deps = null) {
322
402
  deps: deps ?? new Set(),
323
403
  storedAt: now,
324
404
  sharedEncodings: 0,
405
+ produceMs: normalizeProduceMs(produceMs),
325
406
  };
326
407
 
327
408
  install(key, entry);
@@ -385,6 +466,9 @@ function refresh(key, ttlSeconds, producer) {
385
466
  const pending = inflight.get(key);
386
467
  if (pending) return pending;
387
468
 
469
+ // Tazeleme sürerken staleUntil dolmasın: drop → MISS yolu kapanır.
470
+ extendStaleWhileRefreshing(key);
471
+
388
472
  const token = {};
389
473
  tokens.set(key, token);
390
474
 
@@ -397,6 +481,17 @@ function refresh(key, ttlSeconds, producer) {
397
481
  return task;
398
482
  }
399
483
 
484
+ /**
485
+ * @param {string} key
486
+ */
487
+ function extendStaleWhileRefreshing(key) {
488
+ const entry = store.get(key);
489
+ if (!entry) return;
490
+ const lead = earlyRefreshLeadMs(entry.produceMs, entryTtlMs(entry));
491
+ const floor = Date.now() + lead;
492
+ if (entry.staleUntil < floor) entry.staleUntil = floor;
493
+ }
494
+
400
495
  /**
401
496
  * @param {string} key
402
497
  * @param {number} ttlSeconds
@@ -436,7 +531,7 @@ async function produce(key, ttlSeconds, producer, token) {
436
531
  // öncesinde başlamış demektir; yazmak az önce düşürüleni geri koyardı.
437
532
  const valid = tokens.get(key) === token && !readsPurgedData(deps, startedAt);
438
533
  if (valid && value.status === 200 && !value.degraded && value.storable !== false) {
439
- write(key, value, ttlSeconds, deps);
534
+ write(key, value, ttlSeconds, deps, Date.now() - startedAt);
440
535
  }
441
536
 
442
537
  return value;
@@ -447,7 +542,7 @@ async function produce(key, ttlSeconds, producer, token) {
447
542
  * @param {number} ttlSeconds 0 → cache yok
448
543
  * @param {() => Promise<{ html: string, status: number }>} producer
449
544
  * @returns {Promise<{ html: string, status: number, cached: boolean,
450
- * stale?: boolean, encoded?: Map<string, Buffer> }>}
545
+ * stale?: boolean, early?: boolean, encoded?: Map<string, Buffer> }>}
451
546
  */
452
547
  export async function withHtmlCache(key, ttlSeconds, producer) {
453
548
  if (!ttlSeconds) {
@@ -460,7 +555,8 @@ export async function withHtmlCache(key, ttlSeconds, producer) {
460
555
  if (hit) {
461
556
  // Süresi geçmiş girdi anında döner; tazeleme arkada yürür ve hatası
462
557
  // isteği etkilemez (eski HTML stale penceresi boyunca geçerli kalır).
463
- if (hit.stale) {
558
+ // Erken pencerede de aynı: hâlâ HIT, ama TTL dolmadan taze HTML yazılsın.
559
+ if (hit.stale || hit.early) {
464
560
  invalidated.delete(key);
465
561
  void refresh(key, ttlSeconds, producer).catch((error) => {
466
562
  console.error(`[html-cache] background refresh failed: ${key}`, error);
@@ -829,3 +925,42 @@ export function isHtmlCacheFresh(pathname) {
829
925
  if (!entry) return false;
830
926
  return Date.now() < entry.expiresAt;
831
927
  }
928
+
929
+ /**
930
+ * Erken tazeleme penceresine girmiş (veya TTL'i dolmuş) trafiksiz girdileri
931
+ * soft-bayatlatır ve ısıtma kuyruğuna alır. HTTP ısıtması producer'sız
932
+ * çalıştığı için soft-bayat şart: taze HIT yenileme tetiklemez.
933
+ *
934
+ * @returns {number} İşaretlenen girdi sayısı.
935
+ */
936
+ export function sweepEarlyExpiry() {
937
+ const now = Date.now();
938
+ let marked = 0;
939
+
940
+ for (const [key, entry] of store) {
941
+ if (inflight.has(key)) continue;
942
+ // Zaten soft-bayat / kuyrukta — her saniye yeniden ekleme.
943
+ if (entry.expiresAt === 0) continue;
944
+ if (now < entry.expiresAt && !isEarly(entry, now)) continue;
945
+
946
+ entry.expiresAt = 0;
947
+ if (invalidated.size < MAX_INVALIDATED) invalidated.add(key);
948
+ marked += 1;
949
+ }
950
+
951
+ return marked;
952
+ }
953
+
954
+ /**
955
+ * Trafiksiz sayfaların TTL öncesi soft-bayatlatılması. `startPrewarm` açar;
956
+ * `PREWARM=0` iken hiç kurulmaz. `unref` — süreç kapanışını geciktirmez.
957
+ *
958
+ * @returns {void}
959
+ */
960
+ export function startEarlyExpirySweep() {
961
+ if (earlySweepTimer) return;
962
+ earlySweepTimer = setInterval(() => {
963
+ sweepEarlyExpiry();
964
+ }, EARLY_SWEEP_INTERVAL_MS);
965
+ earlySweepTimer.unref();
966
+ }
@@ -19,7 +19,7 @@
19
19
  import process from "node:process";
20
20
  import { getConfig, hook } from "../config/index.js";
21
21
  import { getRequestContext } from "../http/request-context.js";
22
- import { isHtmlCacheFresh, takeInvalidatedPaths } from "./html-cache.js";
22
+ import { isHtmlCacheFresh, takeInvalidatedPaths, startEarlyExpirySweep } from "./html-cache.js";
23
23
  import { getDataCacheStats } from "./data-cache.js";
24
24
  import { isTransientStatus } from "./upstream-tracking.js";
25
25
  import { upstreamCooldownMs } from "./upstream-limiter.js";
@@ -786,7 +786,8 @@ async function drainVisitWarm() {
786
786
 
787
787
  /**
788
788
  * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
789
- * `onVisit` modunda zamanlayıcı yok: yalnızca origin kaydı ve kuyruk.
789
+ * `onVisit` modunda klasik zamanlayıcı yok; yine de erken-TTL invalidation
790
+ * drain'i çalışır — soft-bayatlayan sweeper'ın kuyruğu boşalmasın.
790
791
  *
791
792
  * @param {{ port: number }} options
792
793
  * @returns {void}
@@ -797,6 +798,11 @@ export function startPrewarm({ port }) {
797
798
 
798
799
  const origin = `http://127.0.0.1:${port}`;
799
800
 
801
+ // Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
802
+ // HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
803
+ startEarlyExpirySweep();
804
+ startExpiryWarmDrain(origin);
805
+
800
806
  if (config.prewarm?.onVisit?.enabled) {
801
807
  const classicEnv = CLASSIC_PREWARM_ENV.filter((key) => process.env[key]);
802
808
  if (classicEnv.length) {
@@ -814,7 +820,8 @@ export function startPrewarm({ port }) {
814
820
  }
815
821
 
816
822
  if (process.env.PREWARM !== "1" && config.prewarm?.enabled === false) return;
817
- // Isıtacak yol bildirmeyen bir projede zamanlayıcı kurmanın anlamı yok.
823
+ // Isıtacak yol bildirmeyen bir projede klasik tur zamanlayıcısı gerekmez;
824
+ // expiry drain yine de yukarıda kuruldu.
818
825
  if (typeof config.hooks?.prewarmPaths !== "function") return;
819
826
 
820
827
  const isDev = process.env.NODE_ENV === "development";
@@ -848,3 +855,50 @@ export function startPrewarm({ port }) {
848
855
  const interval = setting("PREWARM_INTERVAL_SECONDS", "intervalSeconds", 0);
849
856
  if (interval > 0) setInterval(() => void run(), interval * 1000).unref();
850
857
  }
858
+
859
+ /** @type {string | null} */
860
+ let expiryOrigin = null;
861
+
862
+ /** @type {boolean} */
863
+ let expiryDraining = false;
864
+
865
+ /** @type {ReturnType<typeof setInterval> | null} */
866
+ let expiryDrainTimer = null;
867
+
868
+ /**
869
+ * Soft-bayat / invalidate kuyruğunu periyodik boşaltır. Klasik tur ve onVisit
870
+ * aynı kuyruğu da okur; bu drain `prewarmPaths` yokken de çalışır.
871
+ *
872
+ * @param {string} origin
873
+ * @returns {void}
874
+ */
875
+ function startExpiryWarmDrain(origin) {
876
+ expiryOrigin = origin;
877
+ if (expiryDrainTimer) return;
878
+ expiryDrainTimer = setInterval(() => {
879
+ void drainExpiryWarm();
880
+ }, 1000);
881
+ expiryDrainTimer.unref();
882
+ }
883
+
884
+ /**
885
+ * @returns {Promise<void>}
886
+ */
887
+ async function drainExpiryWarm() {
888
+ if (expiryDraining || !expiryOrigin) return;
889
+
890
+ const paths = takeInvalidatedPaths();
891
+ if (!paths.length) return;
892
+
893
+ expiryDraining = true;
894
+ try {
895
+ const cold = paths.filter((path) => !isHtmlCacheFresh(path));
896
+ if (!cold.length) return;
897
+
898
+ await prewarm({ origin: expiryOrigin, paths: cold, quiet: true });
899
+ } catch (error) {
900
+ console.error("[prewarm] expiry warm failed", error);
901
+ } finally {
902
+ expiryDraining = false;
903
+ }
904
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Controller sözleşmesi:
5
5
  * async (ctx) => { view, data?, metadata?, status?, revalidate?, head?,
6
- * bodyClass?, entries? }
6
+ * bodyClass?, entries?, styles? }
7
7
  * `ctx` → { params, query, pathname, req }
8
8
  *
9
9
  * `route()` üç kapsamı belirli bir sırayla iç içe kurar:
@@ -218,7 +218,8 @@ export async function renderView(view, data = {}) {
218
218
  * Sayfayı layout içinde render eder.
219
219
  *
220
220
  * @param {{ view: string, data?: object, metadata?: object, head?: string,
221
- * bodyClass?: string, entries?: string[], pathname?: string }} page
221
+ * bodyClass?: string, entries?: string[], styles?: string[],
222
+ * pathname?: string }} page
222
223
  * @returns {Promise<string>}
223
224
  */
224
225
  export async function renderPage(page) {
@@ -258,6 +259,7 @@ export async function renderPage(page) {
258
259
  (context.extraHead ?? ""),
259
260
  bodyClass: page.bodyClass ?? context.bodyClass ?? "",
260
261
  entries: page.entries ?? [],
262
+ styles: page.styles ?? [],
261
263
  devtools: isDev,
262
264
  devBasePath: config.brand.devBasePath,
263
265
  body,
@@ -6,8 +6,8 @@
6
6
  noktası olarak kullanın.
7
7
 
8
8
  Kullanılabilir local'ler: metadata, headMeta, extraHead, structuredData,
9
- body, bodyClass, entries, pathname, lang, devtools, devBasePath, asset,
10
- hasAsset + tüm html/tag helper'ları ve views/components/** export'ları.
9
+ body, bodyClass, entries, styles, pathname, lang, devtools, devBasePath,
10
+ asset, hasAsset + tüm html/tag helper'ları ve views/components/** export'ları.
11
11
  `hooks.layoutContext()` döndürdüğü her alan da buraya eklenir.
12
12
  -%>
13
13
  <!DOCTYPE html>
@@ -18,10 +18,16 @@
18
18
  <%# Kaynak ipuçları en başta: preconnect ve LCP preload'ını geciktirmek
19
19
  doğrudan LCP'ye yazılır. Sayfaya özel head buradan gelir. %>
20
20
  <%- extraHead %>
21
- <%# Tek, render-blocking stylesheet — build çalışmadıysa hiç basılmaz. %>
21
+ <%# Global sheet + controller `styles: [...]` sayfa sheet'leri.
22
+ data-jskelet-css: dev hot-swap doğru link'i bulsun diye. %>
22
23
  <% if (hasAsset('app.css')) { %>
23
- <link rel="stylesheet" href="<%= asset('app.css') %>">
24
+ <link rel="stylesheet" href="<%= asset('app.css') %>" data-jskelet-css="app.css">
24
25
  <% } %>
26
+ <% styles.forEach(function (sheet) { %>
27
+ <% if (hasAsset(sheet)) { %>
28
+ <link rel="stylesheet" href="<%= asset(sheet) %>" data-jskelet-css="<%= sheet %>">
29
+ <% } %>
30
+ <% }); %>
25
31
  <%- headMeta %>
26
32
  <% structuredData.forEach(function (item) { %>
27
33
  <script type="application/ld+json"><%- jsonScript(item) %></script>