jskelet 0.6.15 → 0.6.17

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
@@ -17,6 +17,18 @@ one is listed under a **Breaking** heading.
17
17
 
18
18
  ### Added
19
19
 
20
+ - On-demand HTML cache via `cache().prewarm: false`: visited pages still
21
+ write to L1 and Redis/disk L2 with the usual `html` TTLs, but classic
22
+ prewarm, `onVisit` link warming, and early-expiry HTTP refresh stay off
23
+ (`PREWARM=1` does not override). Mutually exclusive with
24
+ `hooks.prewarmPaths`. Resolved `prewarm.mode` is `"off"` | `"classic"` |
25
+ `"onVisit"`.
26
+ - When `build.assets.prefix` is set, the CSS build rewrites root-absolute
27
+ `url()` paths under `static.prefixes` (e.g. `/fonts/…`) to the CDN
28
+ prefix so they match the S3 key (`…/js/fonts/…`). Default upload
29
+ `include` is `["assets", "fonts"]`; prefix also forces `fonts` into
30
+ `include`. Font uploads run PutBucketCors with
31
+ `upload.corsOrigin` (default `"*"`, set `null` to skip).
20
32
  - `asset(name, { origin: "document" })` skips `build.assets.prefix` and
21
33
  returns the site-origin `/assets/…` path. `icon()` always resolves the
22
34
  sprite this way so SVG `<use href>` keeps working when other assets are
package/docs/06-cache.md CHANGED
@@ -1173,10 +1173,34 @@ loopback yalnızca o host'un anahtarını ısıtır — locale sitelerinde
1173
1173
  `origins: ["http://localhost", "http://tr.localhost"]` gibi çoklu origin
1174
1174
  gerekir.
1175
1175
 
1176
- İki mod **karşılıklı dışlayıcıdır**. `cache().prewarm.onVisit` açıksa klasik
1177
- alanlar (`max`, `priority`, `rotate`, `intervalSeconds`, …) ve
1178
- `hooks.prewarmPaths` birlikte verilemez — config yüklenirken hata fırlar.
1179
- Tersi de geçerli: klasik liste ısıtması kullanıyorsanız `onVisit` yazmayın.
1176
+ Üç yüzey **karşılıklı dışlayıcıdır**: klasik (`hooks.prewarmPaths` + alanlar),
1177
+ `onVisit` ve on-demand (`prewarm: false`). Birini seçmek diğerini config
1178
+ yüklenirken reddeder.
1179
+
1180
+ ### Mod: on-demand — `prewarm: false`
1181
+
1182
+ Arka plan ısıtması yok; yalnızca gerçek trafikle gelen sayfa önbelleğe yazılır.
1183
+ TTL `cache().html` (veya route `revalidate`) ile verilir. Redis açıksa L2'ye,
1184
+ değilse `.jskelet/cache/<buildId>/` disk L2'ye yazılır — ziyaret yazımı zaten
1185
+ böyle çalışır; bu mod klasik turu, `onVisit` link crawl'ını ve TTL öncesi
1186
+ early-expiry HTTP rewarm'ı kapatır. `PREWARM=1` bunu ezmez.
1187
+
1188
+ ```js
1189
+ // jskelet.config.mjs
1190
+ export default {
1191
+ cache() {
1192
+ return {
1193
+ html: { "/": 60, "/haber/:slug": 300 },
1194
+ redis: { enabled: true, url: process.env.REDIS_URL },
1195
+ prewarm: false,
1196
+ };
1197
+ },
1198
+ };
1199
+ ```
1200
+
1201
+ `hooks.prewarmPaths` ile birlikte **yasaktır**. `prewarm: { enabled: false }`
1202
+ farklıdır: klasik turu keser ama early-expiry drain açık kalır; tamamen pasif
1203
+ mod için `prewarm: false` kullanın.
1180
1204
 
1181
1205
  ### Mod: `onVisit` — ziyaret edilen sayfanın linkleri
1182
1206
 
@@ -1442,12 +1466,13 @@ turunun gerçekten `MISS` → önbellek doldurup doldurmadığını buradan gör
1442
1466
  `cache().vary.host: true` yoksa ilk locale'in HTML'i diğer host'a servis
1443
1467
  edilir. Prewarm yalnızca `127.0.0.1` ile ısınıyorsa `prewarm.origins` ile
1444
1468
  locale host'larını ekleyin.
1445
- - **Isıtma hiç çalışmıyor.** Klasik modda `hooks.prewarmPaths` tanımlı değil,
1446
- `PREWARM=0` ayarlı ya da `cache().prewarm.enabled === false`. `onVisit`
1469
+ - **Isıtma hiç çalışmıyor.** `prewarm: false` (on-demand) bilinçli olarak
1470
+ hiçbir arka plan ısıtması kurmaz. Klasik modda `hooks.prewarmPaths` tanımlı
1471
+ değil, `PREWARM=0` ayarlı ya da `cache().prewarm.enabled === false`. `onVisit`
1447
1472
  modunda `listen` sonrası logda `onVisit mode` satırını ve public cache'li
1448
1473
  bir sayfa gezildiğini doğrulayın.
1449
- - **Config `onVisit` + `max` / `prewarmPaths` ile düşüyor.** İki mod karşılıklı
1450
- dışlayıcı; yalnızca birini kullanın.
1474
+ - **Config `onVisit` / `prewarm: false` + `max` / `prewarmPaths` ile düşüyor.**
1475
+ Üç yüzey karşılıklı dışlayıcı; yalnızca birini kullanın.
1451
1476
  - **Isıtma turu API'yi 429'a sokuyor.** `rps` verilmemiş. `concurrency`
1452
1477
  düşürmek yeterli değil; kotayı koruyan ayar toplam hız. Kalıcı çözüm veri
1453
1478
  önbelleği: ikinci turdan sonra ısıtma upstream'e gitmez.
@@ -635,7 +635,8 @@ değişmez** — asset'ler origin'den servis edilir.
635
635
  | `assets.upload.keyPrefix` | `string` | `""` | Nesne anahtarı öneki; bucket yolunda verilmişse o kazanır (`prefix` takma adı da kabul). |
636
636
  | `assets.upload.region` | `string` | `us-east-1` | Config; custom `endpoint` varken varsayılan `auto`. |
637
637
  | `assets.upload.endpoint` | `string \| null` | `null` | S3-uyumlu API (R2 / MinIO); config. |
638
- | `assets.upload.include` | `string[]` | `["assets"]` | `public/` altı dizinler (`assets`, isteğe `fonts`, …). |
638
+ | `assets.upload.include` | `string[]` | `["assets", "fonts"]` | `public/` altı dizinler. `assets.prefix` doluysa `fonts` yoksa eklenir. |
639
+ | `assets.upload.corsOrigin` | `string \| null` | `"*"` | Font upload'ta PutBucketCors `AllowedOrigin`; `null` kapatır (önceki CORS'un üzerine yazar). |
639
640
  | `assets.upload.cacheControl` | `string` | `public, max-age=31536000, immutable` | PutObject `Cache-Control`. |
640
641
 
641
642
  **Credential config'e yazılmaz.** Zincir: `AWS_ACCESS_KEY_ID` /
@@ -769,9 +770,9 @@ Ayrıntı: [03-routing.md](./03-routing.md).
769
770
  ## `cache()`
770
771
 
771
772
  **Tip:**
772
- `() => { 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?: object }` —
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 }` —
773
774
  **Varsayılan:**
774
- `{ 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, max: 400, intervalSeconds: 0, origins: [] } }`
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
776
 
776
777
  ### `cache().html`
777
778
 
@@ -1083,14 +1084,28 @@ tam cevabı olmadığı — [06-cache.md](./06-cache.md) içinde.
1083
1084
 
1084
1085
  ### `cache().prewarm`
1085
1086
 
1086
- İki mod: **klasik** (liste + açılış turu) veya **`onVisit`** (ziyaret edilen
1087
- sayfadaki linkler). Birlikte verilemez — config yüklenirken hata.
1087
+ Üç yüzey: **`false`** (on-demand — yalnızca ziyaret yazımı), **klasik** (liste
1088
+ + açılış turu) veya **`onVisit`** (ziyaret edilen sayfadaki linkler). Birlikte
1089
+ verilemez — config yüklenirken hata. Çözümlenen `prewarm.mode`: `"off"` |
1090
+ `"classic"` | `"onVisit"`.
1091
+
1092
+ #### On-demand — `prewarm: false`
1093
+
1094
+ Arka plan ısıtması yok (klasik tur, `onVisit`, early-expiry HTTP rewarm).
1095
+ Ziyaret edilen sayfa `html` TTL'i ile L1 + Redis/disk L2'ye yazılır.
1096
+ `PREWARM=1` bunu ezmez. `hooks.prewarmPaths` ile **yasak**.
1097
+ `{ enabled: false }` klasik turu keser ama early-expiry açık kalır — tamamen
1098
+ pasif mod için `false` kullanın.
1099
+
1100
+ ```js
1101
+ prewarm: false,
1102
+ ```
1088
1103
 
1089
1104
  #### Klasik alanlar
1090
1105
 
1091
1106
  | Alan | Tip | Varsayılan | Anlamı |
1092
1107
  | --- | --- | --- | --- |
1093
- | `enabled` | `boolean` | `true` | `false` ise ısıtma yapılmaz (`PREWARM=1` ile ezilebilir) |
1108
+ | `enabled` | `boolean` | `true` | `false` ise klasik tur yapılmaz (`PREWARM=1` ile ezilebilir); early-expiry drain açık kalır |
1094
1109
  | `max` | `number` | `400` | Bir turda en fazla kaç yol ısıtılır |
1095
1110
  | `concurrency` | `number` | prod 4, dev 1 | Paralel işçi sayısı |
1096
1111
  | `rps` | `number` | prod `0`, dev 4 | Saniyedeki en fazla ısıtma isteği; `0` sınırsız. Upstream kotasını koruyan ayar bu. Dev'deki varsayılan fren, ısıtmanın sayfa isteklerini bekletmemesi için. |
@@ -1134,8 +1149,8 @@ prewarm: {
1134
1149
  }
1135
1150
  ```
1136
1151
 
1137
- `hooks.prewarmPaths` ve klasik alanlar (`max`, `priority`, …) `onVisit` ile
1138
- **yasaktır**. Ayrıntı: [06-cache.md](./06-cache.md).
1152
+ `hooks.prewarmPaths` ve klasik alanlar (`max`, `priority`, …) `onVisit` veya
1153
+ `prewarm: false` ile **yasaktır**. Ayrıntı: [06-cache.md](./06-cache.md).
1139
1154
 
1140
1155
  Sayısal alanların her biri aynı adı taşıyan ortam değişkeniyle ezilebilir; env
1141
1156
  önceliklidir. Ayrıntı: [06-cache.md](./06-cache.md).
@@ -1153,7 +1168,7 @@ varsayılanına döner ve uyarır — sayfa düşmez.
1153
1168
  | `layoutContext` | `({ pathname, metadata }) => object` | Layout local'leri; `lang`, `structuredData`, `extraHead`, `bodyClass` özel yorumlanır | [04](./04-render-ve-sablonlar.md) |
1154
1169
  | `notFound` | `() => object \| null` | 404 sayfa tanımı; `null` ise framework'ün hata sayfası | [03](./03-routing.md) |
1155
1170
  | `error` | `({ status, error }) => object \| string \| null` | 404 dışındaki hata sayfaları (ve `notFound` yoksa 404); sayfa tanımı ya da doğrudan HTML | [03](./03-routing.md) |
1156
- | `prewarmPaths` | `() => string[]` | Klasik ısıtmada ısıtılacak yollar; tanımlı değilse klasik tur kurulmaz. `onVisit` ile birlikte **yasak** | [06](./06-cache.md) |
1171
+ | `prewarmPaths` | `() => string[]` | Klasik ısıtmada ısıtılacak yollar; tanımlı değilse klasik tur kurulmaz. `onVisit` ve `prewarm: false` ile **yasak** | [06](./06-cache.md) |
1157
1172
 
1158
1173
  ```js
1159
1174
  hooks: {
@@ -1246,7 +1261,7 @@ basılmaz.
1246
1261
  | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache yüzeyi | — | API token. Verilene kadar CDN purge'ü ve edge analitiği kapalıdır; config'teki `apiToken`'ı ezer. Token hiçbir cevapta dönmez. [06](./06-cache.md) |
1247
1262
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache yüzeyi | — | Zone kimliği. Token'la birlikte verilmedikçe hiçbir Cloudflare ucu çağrılmaz |
1248
1263
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache yüzeyi | — | Purge URL'lerinin kökü. Panel iç bir adresten açılıyorsa gerekir |
1249
- | `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar |
1264
+ | `PREWARM` | `startPrewarm` | — | `0` ısıtmayı kapatır; `1` config'teki `enabled: false`'u ezip açar. `prewarm: false` (on-demand) ezilmez |
1250
1265
  | `PREWARM_MAX` | `prewarm` | `400` | En fazla kaç yol ısıtılır |
1251
1266
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Paralel işçi sayısı |
1252
1267
  | `PREWARM_RPS` | `prewarm` | `0` | Saniyedeki en fazla ısıtma isteği; `0` sınırsız |
package/docs/08-build.md CHANGED
@@ -91,12 +91,21 @@ dosya adlarını okunur tutuyor. Hash'li olmaları sayesinde bu dosyalara
91
91
  Varsayılan kapalıdır. Prefix / bucket / keyPrefix config'te; credential
92
92
  env → ECS task role → EC2 IMDS zincirinden. Açılınca:
93
93
 
94
- 1. Production build `public/assets/` (ve `include` listesindeki diğer dizinler)
95
- altındaki dosyaları S3'e PutObject ile kopyalar.
96
- 2. Çalışma anında `asset()` / optimize görseller `build.assets.prefix` ile
97
- absolute CDN URL basar. Entry CDN'deyse esbuild chunk'ları göreli yolla aynı
98
- host'a gider. `icon()` sprite'ı ve `asset(name, { origin: "document" })`
99
- site origin'inde kalır.
94
+ 1. Production build `public/assets/` ve `public/fonts/` (varsayılan `include`)
95
+ altındaki dosyaları S3'e PutObject ile kopyalar. `assets.prefix` doluysa
96
+ `include`'da `fonts` yoksa otomatik eklenir.
97
+ 2. CSS derlemesi `static.prefixes` kök-mutlak `url()` yollarını
98
+ (`/fonts/…`, `/assets/…`) `prefix` ile yeniden yazar — aksi hâlde CDN'deki
99
+ stylesheet `url("/fonts/x")` yolunu host köküne çözer ve `keyPrefix`
100
+ kaybolur. `@font-face` CDN CSS içinde kalabilir; font ile CSS aynı host'ta
101
+ olduğu için ek CORS gerekmez.
102
+ 3. Font yüklendiğinde ve `upload.corsOrigin` doluysa (varsayılan `"*"`)
103
+ PutBucketCors bir GET/HEAD kuralı yazar — bucket'taki önceki CORS'un
104
+ **üzerine** yazar; kapatmak için `corsOrigin: null`. HTML'den çapraz
105
+ origin font için bu (veya CloudFront response header) gerekir.
106
+ 4. Çalışma anında `asset()` / optimize görseller `build.assets.prefix` ile
107
+ absolute CDN URL basar. `icon()` sprite'ı ve
108
+ `asset(name, { origin: "document" })` site origin'inde kalır.
100
109
 
101
110
  Bucket adı noktalı olabilir (`static.ornek.com`); `https://…` şeması
102
111
  `prefix`'e yazılır. Config sözlüğü: [07-yapilandirma.md](./07-yapilandirma.md#build).
@@ -297,6 +306,10 @@ Kullanımı stylesheet'te elle yazılır:
297
306
  `.woff2` uzantısı ve `/fonts/` öneki varsayılan `static` kurallarında olduğu için
298
307
  bu dosyalara otomatik olarak `immutable` cache yazılır.
299
308
 
309
+ `build.assets.prefix` açıksa build, CSS'teki `url("/fonts/…")` yollarını CDN
310
+ prefix'iyle yeniden yazar ve fontları S3'e de basar — layout'ta ayrı
311
+ `@font-face` tutmaya gerek kalmaz.
312
+
300
313
  ## İkon sprite
301
314
 
302
315
  **Yalnızca kaynakta kullanılan** ikonlar için bir `<symbol>` seti üretir. Tüm
@@ -1175,10 +1175,35 @@ traffic. With `vary.host`, the default loopback only warms that host's key —
1175
1175
  locale sites need multiple origins such as
1176
1176
  `origins: ["http://localhost", "http://tr.localhost"]`.
1177
1177
 
1178
- The two modes are **mutually exclusive**. If `cache().prewarm.onVisit` is on,
1179
- classic fields (`max`, `priority`, `rotate`, `intervalSeconds`, …) and
1180
- `hooks.prewarmPaths` must not be set together — config load throws. The reverse
1181
- holds too: if you use classic list warming, do not set `onVisit`.
1178
+ Three surfaces are **mutually exclusive**: classic (`hooks.prewarmPaths` +
1179
+ fields), `onVisit`, and on-demand (`prewarm: false`). Choosing one rejects the
1180
+ others at config load.
1181
+
1182
+ ### Mode: on-demand — `prewarm: false`
1183
+
1184
+ No background warming; only pages hit by real traffic are written to the cache.
1185
+ TTL comes from `cache().html` (or the route `revalidate`). With Redis on, the
1186
+ entry goes to L2 Redis; otherwise to the `.jskelet/cache/<buildId>/` disk L2 —
1187
+ visit writes already work that way; this mode turns off the classic pass,
1188
+ `onVisit` link crawling, and early-expiry HTTP refresh before TTL. `PREWARM=1`
1189
+ does not override it.
1190
+
1191
+ ```js
1192
+ // jskelet.config.mjs
1193
+ export default {
1194
+ cache() {
1195
+ return {
1196
+ html: { "/": 60, "/news/:slug": 300 },
1197
+ redis: { enabled: true, url: process.env.REDIS_URL },
1198
+ prewarm: false,
1199
+ };
1200
+ },
1201
+ };
1202
+ ```
1203
+
1204
+ **Forbidden** together with `hooks.prewarmPaths`. `prewarm: { enabled: false }`
1205
+ is different: it stops the classic pass but leaves the early-expiry drain on —
1206
+ use `prewarm: false` for fully passive mode.
1182
1207
 
1183
1208
  ### Mode: `onVisit` — links from the page just visited
1184
1209
 
@@ -1449,12 +1474,13 @@ filled the cache.
1449
1474
  without `cache().vary.host: true` the first locale's HTML is served to the
1450
1475
  other host. If prewarm only hits `127.0.0.1`, add the locale hosts via
1451
1476
  `prewarm.origins`.
1452
- - **The warm-up never runs.** In classic mode `hooks.prewarmPaths` is not
1477
+ - **The warm-up never runs.** `prewarm: false` (on-demand) intentionally
1478
+ installs no background warming. In classic mode `hooks.prewarmPaths` is not
1453
1479
  defined, `PREWARM=0` is set, or `cache().prewarm.enabled === false`. In
1454
1480
  `onVisit` mode check the `onVisit mode` log line after `listen` and that a
1455
1481
  public cacheable page was visited.
1456
- - **Config fails with `onVisit` + `max` / `prewarmPaths`.** The two modes are
1457
- mutually exclusive; use only one.
1482
+ - **Config fails with `onVisit` / `prewarm: false` + `max` / `prewarmPaths`.**
1483
+ The three surfaces are mutually exclusive; use only one.
1458
1484
  - **The warm-up round pushes the API into 429.** No `rps` was given. Lowering
1459
1485
  `concurrency` is not enough; the setting that protects the quota is the total
1460
1486
  rate. The lasting fix is the data cache: after the second round the warm-up
@@ -648,7 +648,8 @@ changes** — assets stay on the origin.
648
648
  | `assets.upload.keyPrefix` | `string` | `""` | Object key prefix; a path on the bucket wins (`prefix` is accepted as an alias). |
649
649
  | `assets.upload.region` | `string` | `us-east-1` | Config; defaults to `auto` when `endpoint` is set. |
650
650
  | `assets.upload.endpoint` | `string \| null` | `null` | S3-compatible API (R2 / MinIO); config. |
651
- | `assets.upload.include` | `string[]` | `["assets"]` | Directories under `public/` (`assets`, optionally `fonts`, …). |
651
+ | `assets.upload.include` | `string[]` | `["assets", "fonts"]` | Directories under `public/`. When `assets.prefix` is set, `fonts` is added if missing. |
652
+ | `assets.upload.corsOrigin` | `string \| null` | `"*"` | PutBucketCors `AllowedOrigin` when fonts upload; `null` disables (replaces prior bucket CORS). |
652
653
  | `assets.upload.cacheControl` | `string` | `public, max-age=31536000, immutable` | PutObject `Cache-Control`. |
653
654
 
654
655
  **Credentials are not written in the config.** Chain: `AWS_ACCESS_KEY_ID` /
@@ -783,9 +784,9 @@ Details: [03-routing.md](./03-routing.md).
783
784
  ## `cache()`
784
785
 
785
786
  **Type:**
786
- `() => { 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?: object }` —
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 }` —
787
788
  **Default:**
788
- `{ 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, max: 400, intervalSeconds: 0, origins: [] } }`
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
790
 
790
791
  ### `cache().html`
791
792
 
@@ -1105,14 +1106,28 @@ edges hold this page" has no exact answer — is in
1105
1106
 
1106
1107
  ### `cache().prewarm`
1107
1108
 
1108
- Two modes: **classic** (list + startup pass) or **`onVisit`** (links from the
1109
- page just visited). They cannot be combined — config load throws.
1109
+ Three surfaces: **`false`** (on-demand — visit writes only), **classic** (list
1110
+ + startup pass), or **`onVisit`** (links from the page just visited). They
1111
+ cannot be combined — config load throws. Resolved `prewarm.mode`: `"off"` |
1112
+ `"classic"` | `"onVisit"`.
1113
+
1114
+ #### On-demand — `prewarm: false`
1115
+
1116
+ No background warming (classic pass, `onVisit`, early-expiry HTTP refresh).
1117
+ Visited pages still write to L1 + Redis/disk L2 with the `html` TTLs.
1118
+ `PREWARM=1` does not override. **Forbidden** with `hooks.prewarmPaths`.
1119
+ `{ enabled: false }` stops the classic pass but leaves early-expiry on — use
1120
+ `false` for fully passive mode.
1121
+
1122
+ ```js
1123
+ prewarm: false,
1124
+ ```
1110
1125
 
1111
1126
  #### Classic fields
1112
1127
 
1113
1128
  | Field | Type | Default | Meaning |
1114
1129
  | --- | --- | --- | --- |
1115
- | `enabled` | `boolean` | `true` | If `false`, no prewarming happens (can be overridden with `PREWARM=1`) |
1130
+ | `enabled` | `boolean` | `true` | If `false`, no classic pass (`PREWARM=1` can override); early-expiry drain stays on |
1116
1131
  | `max` | `number` | `400` | At most how many paths are prewarmed per pass |
1117
1132
  | `concurrency` | `number` | prod 4, dev 1 | Number of parallel workers |
1118
1133
  | `rps` | `number` | prod `0`, dev 4 | At most how many prewarm requests per second; `0` is unlimited. This is the setting that protects the upstream quota. The default brake in dev keeps prewarming from holding page requests up. |
@@ -1157,7 +1172,7 @@ prewarm: {
1157
1172
  ```
1158
1173
 
1159
1174
  `hooks.prewarmPaths` and classic fields (`max`, `priority`, …) are **forbidden**
1160
- with `onVisit`. Details: [06-caching.md](./06-caching.md).
1175
+ with `onVisit` or `prewarm: false`. Details: [06-caching.md](./06-caching.md).
1161
1176
 
1162
1177
  Each numeric field can be overridden by an environment variable of the same
1163
1178
  name; env takes precedence. Details: [06-caching.md](./06-caching.md).
@@ -1175,7 +1190,7 @@ its own default and warns — the page does not go down.
1175
1190
  | `layoutContext` | `({ pathname, metadata }) => object` | Layout locals; `lang`, `structuredData`, `extraHead` and `bodyClass` get special treatment | [04](./04-rendering.md) |
1176
1191
  | `notFound` | `() => object \| null` | 404 page definition; if `null`, the framework's error page | [03](./03-routing.md) |
1177
1192
  | `error` | `({ status, error }) => object \| string \| null` | Error pages other than 404 (and 404 when there is no `notFound`); a page definition or HTML directly | [03](./03-routing.md) |
1178
- | `prewarmPaths` | `() => string[]` | Paths for classic prewarm; if omitted, the classic pass is never set up. **Forbidden** with `onVisit` | [06](./06-caching.md) |
1193
+ | `prewarmPaths` | `() => string[]` | Paths for classic prewarm; if omitted, the classic pass is never set up. **Forbidden** with `onVisit` and `prewarm: false` | [06](./06-caching.md) |
1179
1194
 
1180
1195
  ```js
1181
1196
  hooks: {
@@ -1269,7 +1284,7 @@ and no warning is printed.
1269
1284
  | `JSKELET_CLOUDFLARE_KEY` | Cloudflare cache surface | — | API token. Until it is set, CDN purging and edge analytics stay off; it overrides `apiToken` in the config. The token is never returned in a response. [06](./06-caching.md) |
1270
1285
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
1271
1286
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
1272
- | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
1287
+ | `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on. Does not override `prewarm: false` (on-demand) |
1273
1288
  | `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
1274
1289
  | `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
1275
1290
  | `PREWARM_RPS` | `prewarm` | `0` | At most how many prewarm requests per second; `0` is unlimited |
@@ -94,11 +94,20 @@ They are passed to templates automatically; in server code,
94
94
  Off by default. Prefix / bucket / keyPrefix come from config; credentials
95
95
  resolve from env → ECS task role → EC2 IMDS. When enabled:
96
96
 
97
- 1. A production build PutObjects files under `public/assets/` (and any other
98
- directories in `include`) to S3.
99
- 2. At runtime `asset()` / optimised images emit absolute CDN URLs via
100
- `build.assets.prefix`. When the entry script is on the CDN, esbuild chunks
101
- resolve relatively on the same host. The `icon()` sprite and
97
+ 1. A production build PutObjects `public/assets/` and `public/fonts/`
98
+ (default `include`) to S3. When `assets.prefix` is set, `fonts` is added
99
+ to `include` if missing.
100
+ 2. The CSS step rewrites root-absolute `url()` paths under `static.prefixes`
101
+ (`/fonts/…`, `/assets/…`) with `prefix` — otherwise a stylesheet on the CDN
102
+ resolves `url("/fonts/x")` against the host root and drops `keyPrefix`.
103
+ `@font-face` can stay in the CDN CSS; font and CSS share a host, so no
104
+ extra CORS is needed for that path.
105
+ 3. When fonts are uploaded and `upload.corsOrigin` is set (default `"*"`),
106
+ PutBucketCors writes a GET/HEAD rule — it **replaces** the bucket's
107
+ previous CORS config; set `corsOrigin: null` to skip. Cross-origin fonts
108
+ from HTML still need this (or a CloudFront response-header policy).
109
+ 4. At runtime `asset()` / optimised images emit absolute CDN URLs via
110
+ `build.assets.prefix`. The `icon()` sprite and
102
111
  `asset(name, { origin: "document" })` stay on the document origin.
103
112
 
104
113
  Bucket names may include dots (`static.example.com`); put `https://…` in
@@ -305,6 +314,10 @@ Usage is written by hand in the stylesheet:
305
314
  Because the `.woff2` extension and the `/fonts/` prefix are in the default
306
315
  `static` rules, these files automatically get an `immutable` cache.
307
316
 
317
+ When `build.assets.prefix` is set, the build rewrites `url("/fonts/…")` in CSS
318
+ to the CDN prefix and uploads the font files too — you do not need a separate
319
+ `@font-face` block in the layout.
320
+
308
321
  ## Icon sprite
309
322
 
310
323
  Produces a `<symbol>` set for **only the icons actually used in the source**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.15",
3
+ "version": "0.6.17",
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",
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CDN prefix varken CSS `url(/fonts/…)` yollarını upload path'iyle hizalar.
3
+ *
4
+ * CDN'deki `app.css` kök-mutlak `/fonts/x.woff2` görünce tarayıcı bunu
5
+ * `https://cdn.example.com/fonts/x` diye çözer — `keyPrefix` (`/js` vb.)
6
+ * kaybolur ve 403 gelir. Prefix + aynı göreli yol (`…/js/fonts/x`) doğru
7
+ * nesneye gider; font ile CSS aynı host'ta olduğu için `@font-face` CORS
8
+ * istemez. HTML'den CDN font yüklemek ayrı: bucket/CloudFront CORS gerekir.
9
+ */
10
+ import { applyAssetPrefix } from "../server/assets.js";
11
+
12
+ /**
13
+ * @param {string} css
14
+ * @param {{ prefix: string, prefixes?: string[] }} options
15
+ * @returns {string}
16
+ */
17
+ export function rewriteCssStaticUrls(css, { prefix, prefixes = [] }) {
18
+ if (!prefix || !css) return css;
19
+
20
+ const roots = [...prefixes]
21
+ .filter((entry) => typeof entry === "string" && entry.startsWith("/"))
22
+ .map((entry) => (entry.endsWith("/") ? entry : `${entry}/`))
23
+ .sort((a, b) => b.length - a.length);
24
+
25
+ if (!roots.length) return css;
26
+
27
+ return css.replace(
28
+ /url\(\s*(['"]?)(\/[^)"'\s]+)\1\s*\)/gi,
29
+ (match, quote, urlPath) => {
30
+ const pathOnly = urlPath.split(/[?#]/, 1)[0];
31
+ if (!roots.some((root) => pathOnly === root.slice(0, -1) || pathOnly.startsWith(root))) {
32
+ return match;
33
+ }
34
+
35
+ const rewritten = applyAssetPrefix(prefix, urlPath);
36
+ const q = quote || '"';
37
+ return `url(${q}${rewritten}${q})`;
38
+ },
39
+ );
40
+ }
@@ -16,12 +16,22 @@ import {
16
16
  resolveS3Credentials,
17
17
  s3CredentialsMissingReason,
18
18
  } from "../../server/logs/s3-credentials.js";
19
- import { putObject } from "../../server/logs/s3-put.js";
19
+ import { putBucketCors, putObject } from "../../server/logs/s3-put.js";
20
20
  import * as log from "../../log.mjs";
21
21
  import { paths } from "../paths.mjs";
22
22
 
23
23
  const CONCURRENCY = 8;
24
24
 
25
+ /** Font MIME — CDN'den HTML çapraz origin yüklerken CORS gerekir. */
26
+ const FONT_MIME = new Set([
27
+ "font/woff",
28
+ "font/woff2",
29
+ "font/ttf",
30
+ "font/otf",
31
+ "application/font-woff",
32
+ "application/font-woff2",
33
+ ]);
34
+
25
35
  /** @type {Record<string, string>} */
26
36
  const MIME = {
27
37
  ".js": "application/javascript; charset=utf-8",
@@ -116,6 +126,7 @@ export function assetsUploadSkipReason(config) {
116
126
  /**
117
127
  * @param {import('../../config/index.js').ResolvedConfig} config
118
128
  * @param {{ put?: typeof putObject,
129
+ * putCors?: typeof putBucketCors,
119
130
  * resolveCredentials?: typeof resolveS3Credentials }} [options]
120
131
  * @returns {Promise<void>}
121
132
  */
@@ -135,6 +146,7 @@ export async function uploadAssets(config, options = {}) {
135
146
  }
136
147
 
137
148
  const put = options.put ?? putObject;
149
+ const putCors = options.putCors ?? putBucketCors;
138
150
  // Normalize bazen atlanır (test / elle config); önek `/` ile bitsin.
139
151
  let keyPrefix = (upload.keyPrefix || "").replace(/^\/+/, "");
140
152
  if (keyPrefix && !keyPrefix.endsWith("/")) keyPrefix += "/";
@@ -162,6 +174,27 @@ export async function uploadAssets(config, options = {}) {
162
174
  return;
163
175
  }
164
176
 
177
+ const uploadsFonts = jobs.some((job) =>
178
+ FONT_MIME.has(contentTypeFor(job.file)),
179
+ );
180
+ if (uploadsFonts && upload.corsOrigin) {
181
+ try {
182
+ await putCors({
183
+ bucket: upload.bucket,
184
+ region: upload.region,
185
+ endpoint: upload.endpoint,
186
+ credentials,
187
+ allowedOrigin: upload.corsOrigin,
188
+ });
189
+ log.detail(`cors ${upload.corsOrigin}`);
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ console.warn(
193
+ `[assets] PutBucketCors failed (${message}) — set CDN/bucket CORS manually for cross-origin fonts`,
194
+ );
195
+ }
196
+ }
197
+
165
198
  let uploaded = 0;
166
199
  let index = 0;
167
200
 
@@ -24,6 +24,7 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { paths, writeAsset, writeManifest } from "../paths.mjs";
27
+ import { rewriteCssStaticUrls } from "../rewrite-css-urls.mjs";
27
28
  import { importFromApp, tryImportFromApp } from "../resolve-peer.mjs";
28
29
  import * as log from "../../log.mjs";
29
30
 
@@ -85,6 +86,23 @@ function listPageStyles(dir) {
85
86
  .map((name) => path.join(dir, name));
86
87
  }
87
88
 
89
+ /**
90
+ * `build.assets.prefix` doluysa `static.prefixes` kök-mutlak `url()` yollarını
91
+ * CDN'e çevirir (`/fonts/…` → `https://cdn…/js/fonts/…`).
92
+ *
93
+ * @param {string} css
94
+ * @param {import('../../config/index.js').ResolvedConfig} config
95
+ * @returns {string}
96
+ */
97
+ function applyCdnStaticUrls(css, config) {
98
+ const prefix = config.build?.assets?.prefix ?? "";
99
+ if (!prefix) return css;
100
+ return rewriteCssStaticUrls(css, {
101
+ prefix,
102
+ prefixes: config.static?.prefixes ?? ["/assets/", "/fonts/"],
103
+ });
104
+ }
105
+
88
106
  /**
89
107
  * @param {import('../../config/index.js').ResolvedConfig} config
90
108
  * @param {{ watch?: boolean }} [options]
@@ -101,7 +119,7 @@ export async function buildCss(config, { watch = false } = {}) {
101
119
  const urls = {};
102
120
  let bytes = 0;
103
121
 
104
- const appCss = await compile(input);
122
+ const appCss = applyCdnStaticUrls(await compile(input), config);
105
123
  const appUrl = writeAsset("app.css", appCss);
106
124
  urls["app.css"] = appUrl;
107
125
  bytes += Buffer.byteLength(appCss);
@@ -111,7 +129,7 @@ export async function buildCss(config, { watch = false } = {}) {
111
129
 
112
130
  for (const file of listPageStyles(pagesDir)) {
113
131
  const name = path.basename(file);
114
- const css = await compile(file);
132
+ const css = applyCdnStaticUrls(await compile(file), config);
115
133
  const url = writeAsset(name, css);
116
134
  urls[name] = url;
117
135
  bytes += Buffer.byteLength(css);
@@ -51,12 +51,17 @@ export const DEFAULT_DEV_GATE_BYPASS = [
51
51
  ];
52
52
 
53
53
  /**
54
- * Klasik (liste tabanlı) prewarm ayarları. `onVisit` modu bunlarla birlikte
55
- * kullanılamaz — ya açılış/`prewarmPaths` turu, ya ziyaret edilen sayfadaki
56
- * linkleri ısıtma.
54
+ * Klasik (liste tabanlı) prewarm ayarları. `onVisit` ve `prewarm: false`
55
+ * (on-demand) ile birlikte kullanılamaz — ya açılış/`prewarmPaths` turu, ya
56
+ * ziyaret edilen sayfadaki linkleri ısıtma, ya yalnızca ziyaret yazımı.
57
+ *
58
+ * `mode` çözümleme sonucu: `"classic"` | `"onVisit"` | `"off"`.
59
+ * `prewarm: false` → `"off"` (erken TTL ısıtması dahil hiçbir arka plan yok).
57
60
  */
58
61
  export const DEFAULT_PREWARM = {
59
62
  enabled: true,
63
+ /** @type {"classic" | "onVisit" | "off"} */
64
+ mode: "classic",
60
65
  max: 400,
61
66
  intervalSeconds: 0,
62
67
  /** Paralel ısıtma isteği; dev'de sunucu tek süreç olduğu için düşürülür. */
@@ -508,9 +513,19 @@ export const DEFAULT_BUILD = {
508
513
  keyPrefix: "",
509
514
  region: "us-east-1",
510
515
  endpoint: null,
511
- /** `public/` altı dizin adları. */
512
- include: /** @type {string[]} */ (["assets"]),
516
+ /**
517
+ * `public/` altı dizin adları. `fonts` varsayılan: CDN CSS'teki
518
+ * `/fonts/…` rewrite'ı upload nesnesiyle hizalansın.
519
+ */
520
+ include: /** @type {string[]} */ (["assets", "fonts"]),
513
521
  cacheControl: "public, max-age=31536000, immutable",
522
+ /**
523
+ * Upload sırasında PutBucketCors ile yazılan `AllowedOrigin`.
524
+ * `null` / `false` → CORS adımı atlanır. Bucket'taki önceki CORS
525
+ * kuralının üzerine yazar — paylaşımlı kovada bilinçli açın.
526
+ * CDN CSS + aynı host font için genelde gerekmez.
527
+ */
528
+ corsOrigin: /** @type {string | null} */ ("*"),
514
529
  },
515
530
  },
516
531
  };
@@ -184,6 +184,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
184
184
  * @property {string | null} endpoint
185
185
  * @property {string[]} include
186
186
  * @property {string} cacheControl
187
+ * @property {string | null} corsOrigin Font/CDN PutBucketCors `AllowedOrigin`; `null` kapatır.
187
188
  *
188
189
  * @typedef {object} BuildAssetsConfig
189
190
  * @property {string} prefix CDN origin (`build.assets.prefix`); sonunda `/` yok.
@@ -939,18 +940,38 @@ function normalizeOnVisit(raw) {
939
940
  }
940
941
 
941
942
  /**
942
- * Klasik liste ısıtması ile `onVisit` karşılıklı dışlayıcıdır. İkisini birden
943
- * yazmak sessizce yanlış moda düşmesin diye yüklemede hata verir.
943
+ * Klasik liste ısıtması, `onVisit` ve `prewarm: false` (on-demand) karşılıklı
944
+ * dışlayıcıdır. Yanlış moda sessizce düşülmesin diye yüklemede hata verir.
944
945
  *
945
946
  * @param {unknown} raw
946
947
  * @returns {Record<string, unknown>}
947
948
  */
948
949
  function normalizePrewarm(raw) {
950
+ // On-demand: yalnızca ziyaret yazımı. Klasik / onVisit / early-expiry yok.
951
+ if (raw === false) {
952
+ return {
953
+ ...DEFAULT_PREWARM,
954
+ enabled: false,
955
+ mode: "off",
956
+ onVisit: { ...DEFAULT_PREWARM_ON_VISIT, enabled: false },
957
+ };
958
+ }
959
+
960
+ if (raw != null && (typeof raw !== "object" || Array.isArray(raw))) {
961
+ throw new Error(
962
+ "[config] cache().prewarm must be false, or an object " +
963
+ "(classic settings or { onVisit })",
964
+ );
965
+ }
966
+
949
967
  const source =
950
968
  raw && typeof raw === "object" && !Array.isArray(raw)
951
969
  ? /** @type {Record<string, unknown>} */ ({ ...raw })
952
970
  : {};
953
971
 
972
+ // `mode` çözümleme alanı; kullanıcı yazarsa yok sayılır.
973
+ delete source.mode;
974
+
954
975
  const onVisit = normalizeOnVisit(source.onVisit);
955
976
 
956
977
  if (onVisit.enabled) {
@@ -976,12 +997,15 @@ function normalizePrewarm(raw) {
976
997
  return {
977
998
  ...DEFAULT_PREWARM,
978
999
  enabled: true,
1000
+ mode: "onVisit",
979
1001
  onVisit,
980
1002
  };
981
1003
  }
982
1004
 
983
1005
  // Klasik mod: `onVisit: false` yazılmış olabilir; diğer alanlar varsayılanlarla
984
1006
  // birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
1007
+ // `enabled: false` klasik turu keser ama early-expiry drain açık kalır —
1008
+ // tamamen kapatmak için `prewarm: false` (`mode: "off"`) kullanılır.
985
1009
  const classic = { ...source };
986
1010
  delete classic.onVisit;
987
1011
 
@@ -993,6 +1017,7 @@ function normalizePrewarm(raw) {
993
1017
  return {
994
1018
  ...DEFAULT_PREWARM,
995
1019
  ...classic,
1020
+ mode: "classic",
996
1021
  onVisit,
997
1022
  };
998
1023
  }
@@ -1264,11 +1289,25 @@ export function normalizeBuildAssets(raw) {
1264
1289
  }
1265
1290
  }
1266
1291
 
1292
+ // CDN CSS `/fonts/…` → prefix'li URL yazar; fontlar upload'ta yoksa 403.
1293
+ // Kullanıcı `include: ["assets"]` yazsa bile prefix varken `fonts` eklenir.
1294
+ if (prefix && !include.includes("fonts")) {
1295
+ include = [...include, "fonts"];
1296
+ }
1297
+
1267
1298
  const cacheControl =
1268
1299
  typeof uploadRaw.cacheControl === "string" && uploadRaw.cacheControl.trim()
1269
1300
  ? uploadRaw.cacheControl.trim()
1270
1301
  : DEFAULT_BUILD.assets.upload.cacheControl;
1271
1302
 
1303
+ /** @type {string | null} */
1304
+ let corsOrigin = DEFAULT_BUILD.assets.upload.corsOrigin;
1305
+ if (uploadRaw.corsOrigin === null || uploadRaw.corsOrigin === false) {
1306
+ corsOrigin = null;
1307
+ } else if (typeof uploadRaw.corsOrigin === "string") {
1308
+ corsOrigin = uploadRaw.corsOrigin.trim() || null;
1309
+ }
1310
+
1272
1311
  // Config'te bucket varsa `enabled` unutulmuş olsa bile aç. Açık `false` ezer.
1273
1312
  const enabled =
1274
1313
  uploadRaw.enabled === true ||
@@ -1284,6 +1323,7 @@ export function normalizeBuildAssets(raw) {
1284
1323
  endpoint,
1285
1324
  include,
1286
1325
  cacheControl,
1326
+ corsOrigin,
1287
1327
  },
1288
1328
  };
1289
1329
  }
@@ -1682,6 +1722,17 @@ export async function loadConfig(options = {}) {
1682
1722
  );
1683
1723
  }
1684
1724
 
1725
+ if (
1726
+ config.prewarm?.mode === "off" &&
1727
+ typeof config.hooks?.prewarmPaths === "function"
1728
+ ) {
1729
+ throw new Error(
1730
+ "[config] hooks.prewarmPaths() cannot be used with cache().prewarm: false. " +
1731
+ "On-demand mode only caches pages as they are visited; classic mode uses " +
1732
+ "prewarmPaths. Choose one.",
1733
+ );
1734
+ }
1735
+
1685
1736
  // Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
1686
1737
  // banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
1687
1738
  if (loaded && !process.env.JSKELET_CHILD) {
@@ -14,6 +14,9 @@ import crypto from "node:crypto";
14
14
  * @typedef {{ bucket: string, key: string, body: string | Buffer,
15
15
  * region: string, endpoint?: string | null, credentials: AwsCredentials,
16
16
  * contentType?: string, cacheControl?: string | null, now?: Date }} PutObjectInput
17
+ *
18
+ * @typedef {{ bucket: string, region: string, endpoint?: string | null,
19
+ * credentials: AwsCredentials, allowedOrigin: string, now?: Date }} PutBucketCorsInput
17
20
  */
18
21
 
19
22
  /**
@@ -131,6 +134,24 @@ export function useS3PathStyle(bucket, endpoint) {
131
134
  return bucket.includes(".");
132
135
  }
133
136
 
137
+ /**
138
+ * @param {{ bucket: string, region: string, endpoint?: string | null }} input
139
+ * @returns {{ host: string, base: string, pathStyle: boolean }}
140
+ */
141
+ function s3EndpointParts(input) {
142
+ const pathStyle = useS3PathStyle(input.bucket, input.endpoint);
143
+ if (input.endpoint) {
144
+ const base = String(input.endpoint).replace(/\/+$/, "");
145
+ return { host: new URL(input.endpoint).host, base, pathStyle: true };
146
+ }
147
+ if (pathStyle) {
148
+ const host = `s3.${input.region}.amazonaws.com`;
149
+ return { host, base: `https://${host}`, pathStyle: true };
150
+ }
151
+ const host = `${input.bucket}.s3.${input.region}.amazonaws.com`;
152
+ return { host, base: `https://${host}`, pathStyle: false };
153
+ }
154
+
134
155
  /**
135
156
  * PutObject isteğini imzalar; ağ çağrısı yapmaz — test edilebilir olsun diye.
136
157
  *
@@ -145,22 +166,7 @@ export function buildSignedPutObject(input) {
145
166
  const payloadHash = sha256Hex(body);
146
167
 
147
168
  const keyPath = encodeS3Path(input.key.replace(/^\/+/, ""));
148
- const pathStyle = useS3PathStyle(input.bucket, input.endpoint);
149
-
150
- /** @type {string} */
151
- let host;
152
- /** @type {string} */
153
- let base;
154
- if (input.endpoint) {
155
- base = String(input.endpoint).replace(/\/+$/, "");
156
- host = new URL(input.endpoint).host;
157
- } else if (pathStyle) {
158
- host = `s3.${input.region}.amazonaws.com`;
159
- base = `https://${host}`;
160
- } else {
161
- host = `${input.bucket}.s3.${input.region}.amazonaws.com`;
162
- base = `https://${host}`;
163
- }
169
+ const { host, base, pathStyle } = s3EndpointParts(input);
164
170
 
165
171
  const canonicalUri = pathStyle
166
172
  ? `/${encodeS3Path(input.bucket)}/${keyPath}`
@@ -219,6 +225,132 @@ export function buildSignedPutObject(input) {
219
225
  return { url, headers, body };
220
226
  }
221
227
 
228
+ /**
229
+ * PutBucketCors gövdesi — tek kural, GET/HEAD.
230
+ *
231
+ * @param {string} allowedOrigin
232
+ * @returns {string}
233
+ */
234
+ export function corsConfigurationXml(allowedOrigin) {
235
+ const origin = String(allowedOrigin)
236
+ .replace(/&/g, "&amp;")
237
+ .replace(/</g, "&lt;")
238
+ .replace(/>/g, "&gt;")
239
+ .replace(/"/g, "&quot;");
240
+
241
+ return (
242
+ `<?xml version="1.0" encoding="UTF-8"?>` +
243
+ `<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">` +
244
+ `<CORSRule>` +
245
+ `<AllowedOrigin>${origin}</AllowedOrigin>` +
246
+ `<AllowedMethod>GET</AllowedMethod>` +
247
+ `<AllowedMethod>HEAD</AllowedMethod>` +
248
+ `<AllowedHeader>*</AllowedHeader>` +
249
+ `<ExposeHeader>ETag</ExposeHeader>` +
250
+ `<ExposeHeader>Content-Type</ExposeHeader>` +
251
+ `<MaxAgeSeconds>86400</MaxAgeSeconds>` +
252
+ `</CORSRule>` +
253
+ `</CORSConfiguration>`
254
+ );
255
+ }
256
+
257
+ /**
258
+ * PutBucketCors isteğini imzalar (`PUT /?cors` veya path-style `/bucket?cors`).
259
+ *
260
+ * @param {PutBucketCorsInput} input
261
+ * @returns {{ url: string, headers: Record<string, string>, body: Buffer }}
262
+ */
263
+ export function buildSignedPutBucketCors(input) {
264
+ const body = Buffer.from(corsConfigurationXml(input.allowedOrigin), "utf8");
265
+ const now = input.now ?? new Date();
266
+ const { amzDate, dateStamp } = formatAmzDates(now);
267
+ const payloadHash = sha256Hex(body);
268
+ const { host, base, pathStyle } = s3EndpointParts(input);
269
+
270
+ // AWS: cors alt kaynağının canonical query'si `cors=` (boş değer).
271
+ const canonicalQuerystring = "cors=";
272
+ const canonicalUri = pathStyle ? `/${encodeS3Path(input.bucket)}` : "/";
273
+
274
+ /** @type {Record<string, string>} */
275
+ const headers = {
276
+ host,
277
+ "content-type": "application/xml",
278
+ "content-length": String(body.length),
279
+ "x-amz-content-sha256": payloadHash,
280
+ "x-amz-date": amzDate,
281
+ };
282
+
283
+ if (input.credentials.sessionToken) {
284
+ headers["x-amz-security-token"] = input.credentials.sessionToken;
285
+ }
286
+
287
+ const { canonicalRequest, signedHeaders } = buildCanonicalRequest({
288
+ method: "PUT",
289
+ canonicalUri,
290
+ canonicalQuerystring,
291
+ headers,
292
+ payloadHash,
293
+ });
294
+
295
+ const stringToSign = buildStringToSign({
296
+ amzDate,
297
+ dateStamp,
298
+ region: input.region,
299
+ service: "s3",
300
+ canonicalRequest,
301
+ });
302
+
303
+ const signingKey = deriveSigningKey(
304
+ input.credentials.secretAccessKey,
305
+ dateStamp,
306
+ input.region,
307
+ "s3",
308
+ );
309
+ const signature = hmac("sha256", signingKey, stringToSign).toString("hex");
310
+ const credentialScope = `${dateStamp}/${input.region}/s3/aws4_request`;
311
+
312
+ headers.authorization =
313
+ `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, ` +
314
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
315
+
316
+ const url = pathStyle
317
+ ? `${base}/${encodeS3Path(input.bucket)}?cors`
318
+ : `${base}/?cors`;
319
+
320
+ return { url, headers, body };
321
+ }
322
+
323
+ /**
324
+ * Bucket CORS yazar. Mevcut kuralların **üzerine** yazar; paylaşımlı
325
+ * kovada bilinçli kullanılmalı. İzin yoksa çağıran uyarır, upload sürer.
326
+ *
327
+ * @param {PutBucketCorsInput} input
328
+ * @returns {Promise<void>}
329
+ */
330
+ export async function putBucketCors(input) {
331
+ const signed = buildSignedPutBucketCors(input);
332
+ /** @type {Response} */
333
+ let response;
334
+ try {
335
+ response = await fetch(signed.url, {
336
+ method: "PUT",
337
+ headers: signed.headers,
338
+ body: signed.body,
339
+ });
340
+ } catch (error) {
341
+ const detail =
342
+ error instanceof Error ? error.message : String(error);
343
+ throw new Error(`S3 PutBucketCors fetch failed (${signed.url}): ${detail}`);
344
+ }
345
+
346
+ if (!response.ok) {
347
+ const text = await response.text().catch(() => "");
348
+ throw new Error(
349
+ `S3 PutBucketCors ${response.status}: ${text.slice(0, 200) || response.statusText}`,
350
+ );
351
+ }
352
+ }
353
+
222
354
  /**
223
355
  * @param {PutObjectInput} input
224
356
  * @returns {Promise<void>}
@@ -928,6 +928,8 @@ async function drainVisitWarm() {
928
928
  * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
929
929
  * `onVisit` modunda klasik zamanlayıcı yok; yine de erken-TTL invalidation
930
930
  * drain'i çalışır — soft-bayatlayan sweeper'ın kuyruğu boşalmasın.
931
+ * `prewarm: false` (`mode: "off"`) on-demand: ziyaret yazımı kalır, hiçbir
932
+ * arka plan ısıtması kurulmaz; `PREWARM=1` bunu ezmez.
931
933
  *
932
934
  * @param {{ port: number }} options
933
935
  * @returns {void}
@@ -935,12 +937,14 @@ async function drainVisitWarm() {
935
937
  export function startPrewarm({ port }) {
936
938
  const config = getConfig();
937
939
  if (process.env.PREWARM === "0") return;
940
+ // On-demand: L1/L2 ziyaretle dolar; classic / onVisit / early-expiry yok.
941
+ if (config.prewarm?.mode === "off") return;
938
942
 
939
943
  const origins = resolvePrewarmOrigins(port);
940
944
  const origin = origins[0];
941
945
 
942
946
  // Klasik `prewarmPaths` olmasa da TTL öncesi soft-bayatlayan girdiler
943
- // HTTP ile ısıtılsın. `PREWARM=0` yukarıda her şeyi keser.
947
+ // HTTP ile ısıtılsın. `PREWARM=0` / `mode: "off"` yukarıda her şeyi keser.
944
948
  startEarlyExpirySweep();
945
949
  startExpiryWarmDrain(origins);
946
950
 
@@ -40,12 +40,17 @@ export declare const DEFAULT_STATIC: {
40
40
  /** Dev gate'in hiçbir koşulda kapatmadığı yollar. */
41
41
  export declare const DEFAULT_DEV_GATE_BYPASS: string[];
42
42
  /**
43
- * Klasik (liste tabanlı) prewarm ayarları. `onVisit` modu bunlarla birlikte
44
- * kullanılamaz — ya açılış/`prewarmPaths` turu, ya ziyaret edilen sayfadaki
45
- * linkleri ısıtma.
43
+ * Klasik (liste tabanlı) prewarm ayarları. `onVisit` ve `prewarm: false`
44
+ * (on-demand) ile birlikte kullanılamaz — ya açılış/`prewarmPaths` turu, ya
45
+ * ziyaret edilen sayfadaki linkleri ısıtma, ya yalnızca ziyaret yazımı.
46
+ *
47
+ * `mode` çözümleme sonucu: `"classic"` | `"onVisit"` | `"off"`.
48
+ * `prewarm: false` → `"off"` (erken TTL ısıtması dahil hiçbir arka plan yok).
46
49
  */
47
50
  export declare const DEFAULT_PREWARM: {
48
51
  enabled: boolean;
52
+ /** @type {"classic" | "onVisit" | "off"} */
53
+ mode: "classic" | "onVisit" | "off";
49
54
  max: number;
50
55
  intervalSeconds: number;
51
56
  /** Paralel ısıtma isteği; dev'de sunucu tek süreç olduğu için düşürülür. */
@@ -458,9 +463,19 @@ export declare const DEFAULT_BUILD: {
458
463
  keyPrefix: string;
459
464
  region: string;
460
465
  endpoint: null;
461
- /** `public/` altı dizin adları. */
466
+ /**
467
+ * `public/` altı dizin adları. `fonts` varsayılan: CDN CSS'teki
468
+ * `/fonts/…` rewrite'ı upload nesnesiyle hizalansın.
469
+ */
462
470
  include: string[];
463
471
  cacheControl: string;
472
+ /**
473
+ * Upload sırasında PutBucketCors ile yazılan `AllowedOrigin`.
474
+ * `null` / `false` → CORS adımı atlanır. Bucket'taki önceki CORS
475
+ * kuralının üzerine yazar — paylaşımlı kovada bilinçli açın.
476
+ * CDN CSS + aynı host font için genelde gerekmez.
477
+ */
478
+ corsOrigin: string | null;
464
479
  };
465
480
  };
466
481
  };
@@ -240,6 +240,10 @@ export type BuildAssetsUploadConfig = {
240
240
  endpoint: string | null;
241
241
  include: string[];
242
242
  cacheControl: string;
243
+ /**
244
+ * Font/CDN PutBucketCors `AllowedOrigin`; `null` kapatır.
245
+ */
246
+ corsOrigin: string | null;
243
247
  };
244
248
  export type BuildAssetsConfig = {
245
249
  /**
@@ -14,6 +14,14 @@ export type PutObjectInput = {
14
14
  cacheControl?: string | null;
15
15
  now?: Date;
16
16
  };
17
+ export type PutBucketCorsInput = {
18
+ bucket: string;
19
+ region: string;
20
+ endpoint?: string | null;
21
+ credentials: AwsCredentials;
22
+ allowedOrigin: string;
23
+ now?: Date;
24
+ };
17
25
  /**
18
26
  * @param {string | Buffer} data
19
27
  * @returns {string}
@@ -91,6 +99,32 @@ export declare function buildSignedPutObject(input: PutObjectInput): {
91
99
  headers: Record<string, string>;
92
100
  body: Buffer;
93
101
  };
102
+ /**
103
+ * PutBucketCors gövdesi — tek kural, GET/HEAD.
104
+ *
105
+ * @param {string} allowedOrigin
106
+ * @returns {string}
107
+ */
108
+ export declare function corsConfigurationXml(allowedOrigin: string): string;
109
+ /**
110
+ * PutBucketCors isteğini imzalar (`PUT /?cors` veya path-style `/bucket?cors`).
111
+ *
112
+ * @param {PutBucketCorsInput} input
113
+ * @returns {{ url: string, headers: Record<string, string>, body: Buffer }}
114
+ */
115
+ export declare function buildSignedPutBucketCors(input: PutBucketCorsInput): {
116
+ url: string;
117
+ headers: Record<string, string>;
118
+ body: Buffer;
119
+ };
120
+ /**
121
+ * Bucket CORS yazar. Mevcut kuralların **üzerine** yazar; paylaşımlı
122
+ * kovada bilinçli kullanılmalı. İzin yoksa çağıran uyarır, upload sürer.
123
+ *
124
+ * @param {PutBucketCorsInput} input
125
+ * @returns {Promise<void>}
126
+ */
127
+ export declare function putBucketCors(input: PutBucketCorsInput): Promise<void>;
94
128
  /**
95
129
  * @param {PutObjectInput} input
96
130
  * @returns {Promise<void>}
@@ -122,6 +122,8 @@ export declare function noteVisitWarm(html: string, context: {
122
122
  * Açılışta ısıtmayı tetikler. `listen` geri çağrısından çağrılır.
123
123
  * `onVisit` modunda klasik zamanlayıcı yok; yine de erken-TTL invalidation
124
124
  * drain'i çalışır — soft-bayatlayan sweeper'ın kuyruğu boşalmasın.
125
+ * `prewarm: false` (`mode: "off"`) on-demand: ziyaret yazımı kalır, hiçbir
126
+ * arka plan ısıtması kurulmaz; `PREWARM=1` bunu ezmez.
125
127
  *
126
128
  * @param {{ port: number }} options
127
129
  * @returns {void}