jskelet 0.6.7 → 0.6.9

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
@@ -8,6 +8,19 @@ one is listed under a **Breaking** heading.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - Optional CDN asset publishing: `build.assets.prefix` /
14
+ `JSKELET_ASSET_PREFIX` prefixes `asset()` and optimised image URLs (manifest
15
+ stays relative on disk). When `JSKELET_ASSETS_BUCKET` (or
16
+ `build.assets.upload`) is set, production `jskelet build` PutObjects
17
+ `public/assets/` (and optional `include` dirs) to S3 after precompress.
18
+ Credentials prefer standard `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
19
+ `AWS_SESSION_TOKEN`, then `JSKELET_S3_*`. The Assets upload step always
20
+ appears on production builds; when off it logs a one-line reason
21
+ (`skipped (no JSKELET_ASSETS_BUCKET)`, `skipped (credentials missing)`, …).
22
+ Watch mode never uploads. `.br` / `.gz` siblings are skipped.
23
+
11
24
  ### Changed
12
25
 
13
26
  - Shared cache entries of 1 KB or more are stored as brotli (`JSK\x01`
@@ -95,6 +95,13 @@ export default {
95
95
  client: {
96
96
  groups: { charts: ["chart.js"] },
97
97
  },
98
+ assets: {
99
+ prefix: process.env.JSKELET_ASSET_PREFIX,
100
+ upload: {
101
+ enabled: Boolean(process.env.JSKELET_ASSETS_BUCKET),
102
+ bucket: process.env.JSKELET_ASSETS_BUCKET,
103
+ },
104
+ },
98
105
  },
99
106
 
100
107
  async headers() {
@@ -592,11 +599,14 @@ Elle URL kurmak için `remoteImageUrl(src, { width })` (`jskelet`).
592
599
 
593
600
  ## `build`
594
601
 
595
- **Tip:** `{ client?: { splitting?, groups?, chunkGroup? } }` — **Varsayılan:**
596
- `{ client: { splitting: true, groups: {}, chunkGroup: null } }`
602
+ **Tip:** `{ client?: { splitting?, groups?, chunkGroup? }, assets?: { prefix?, upload? } }`
603
+ — **Varsayılan:**
604
+ `{ client: { splitting: true, groups: {}, chunkGroup: null }, assets: { prefix: "", upload: { enabled: false, … } } }`
605
+
606
+ Client JS (esbuild) bundling knobs ve opsiyonel CDN / S3 asset dağıtımı.
607
+ `fonts` / `icons` / `images` / `clientEnv` hâlâ top-level kalır.
597
608
 
598
- Client JS (esbuild) bundling knobs. `fonts` / `icons` / `images` / `clientEnv`
599
- hâlâ top-level kalır; bu bölüm yalnızca splitting ve chunk gruplaması içindir.
609
+ ### `build.client`
600
610
 
601
611
  | Alan | Tip | Varsayılan | Anlamı |
602
612
  | --- | --- | --- | --- |
@@ -611,6 +621,29 @@ zayıflar — bir üyeyi kullanan sayfa grubun geri kalanını da indirebilir
611
621
  `chunks/chunk.<hash>.js` kalır; grup adı config anahtarıdır, dosya adı değil.
612
622
  Ayrıntı: [08-build.md](./08-build.md).
613
623
 
624
+ ### `build.assets`
625
+
626
+ CDN origin ve production build sonunda S3 upload. **Kapalıyken hiçbir şey
627
+ değişmez** — asset'ler origin'den servis edilir.
628
+
629
+ | Alan | Tip | Varsayılan | Anlamı |
630
+ | --- | --- | --- | --- |
631
+ | `assets.prefix` | `string` | `""` | `asset()` / optimize görsel URL öneki (sonunda `/` yok). `JSKELET_ASSET_PREFIX` ezer. Manifest diskte göreli kalır. |
632
+ | `assets.upload.enabled` | `boolean` | `false` | `true` veya `JSKELET_ASSETS_BUCKET` doluysa açılır; açık `false` ezer. |
633
+ | `assets.upload.bucket` | `string \| null` | `null` | Bucket veya `bucket/key/prefix`. `JSKELET_ASSETS_BUCKET` ezer. |
634
+ | `assets.upload.keyPrefix` | `string` | `""` | Yolda verilmediyse nesne anahtarı öneki (`prefix` takma adı da kabul). |
635
+ | `assets.upload.region` | `string` | `us-east-1` | `AWS_REGION` / `AWS_DEFAULT_REGION` / `JSKELET_S3_REGION`; custom endpoint'te varsayılan `auto`. |
636
+ | `assets.upload.endpoint` | `string \| null` | `null` | S3-uyumlu API; `AWS_ENDPOINT_URL` / `JSKELET_S3_API_URL` ezer. |
637
+ | `assets.upload.include` | `string[]` | `["assets"]` | `public/` altı dizinler (`assets`, isteğe `fonts`, …). |
638
+ | `assets.upload.cacheControl` | `string` | `public, max-age=31536000, immutable` | PutObject `Cache-Control`. |
639
+
640
+ Credential config'e yazılmaz. Öncelik: `AWS_ACCESS_KEY_ID` /
641
+ `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, yoksa `JSKELET_S3_*`. Production
642
+ build'de Assets upload adımı her zaman görünür; kapalıysa
643
+ `skipped (no JSKELET_ASSETS_BUCKET)` / `skipped (credentials missing)` gibi tek
644
+ satır sebep basılır (build düşmez). Watch turunda adım yok. `.br` / `.gz`
645
+ yüklenmez. Ayrıntı: [08-build.md](./08-build.md), [10-dagitim.md](./10-dagitim.md).
646
+
614
647
  ```js
615
648
  build: {
616
649
  client: {
@@ -623,6 +656,14 @@ build: {
623
656
  if (id.includes("node_modules/lodash")) return "vendor";
624
657
  },
625
658
  },
659
+ assets: {
660
+ prefix: process.env.JSKELET_ASSET_PREFIX, // https://dxxxx.cloudfront.net
661
+ upload: {
662
+ enabled: Boolean(process.env.JSKELET_ASSETS_BUCKET),
663
+ bucket: process.env.JSKELET_ASSETS_BUCKET, // my-bucket veya my-bucket/static
664
+ include: ["assets", "fonts"],
665
+ },
666
+ },
626
667
  },
627
668
  ```
628
669
 
@@ -954,7 +995,8 @@ parça silinir.
954
995
  | `s3.flushIntervalMs` | `number` | `5000` | Batch flush aralığı |
955
996
  | `s3.maxBatch` | `number` | `100` | Bu kadar satırda erken flush |
956
997
 
957
- S3 credential'ları config'e yazılmaz: `JSKELET_S3_ACCESS_KEY_ID`,
998
+ S3 credential'ları config'e yazılmaz. Öncelik: `AWS_ACCESS_KEY_ID` /
999
+ `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, yoksa `JSKELET_S3_ACCESS_KEY_ID`,
958
1000
  `JSKELET_S3_SECRET_ACCESS_KEY`, isteğe bağlı `JSKELET_S3_SESSION_TOKEN`.
959
1001
  Bucket/region/credential eksikse uyarı basılır ve S3 sink kapanır; site ayağa
960
1002
  kalkmaya devam eder. Framework `@aws-sdk` taşımaz — PutObject SigV4 ile
@@ -1186,14 +1228,21 @@ basılmaz.
1186
1228
  | `DEV_GATE` | `devGate` | kapalı | `1` gate'i açar, `0` config'te açık olsa da kapatır. `DEV_TOKEN` tek başına açmaz. [09](./09-dev-araclari.md) |
1187
1229
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | Gate açıkken beklenen sır. Yoksa veya gate kapalıysa site açık kalır. Isıtma, gate açıkken token'ı çerez olarak taşır. [09](./09-dev-araclari.md) |
1188
1230
  | `JSKELET_ADMIN` | `createApp` | — | Ayarlıysa yönetim panelini açar; `0` config'te açık olan paneli kapatır. Env config'i ezer, çünkü panel genelde bir arıza sırasında tek seferlik açılır. [06](./06-cache.md) |
1231
+ | `JSKELET_ASSET_PREFIX` | `build.assets.prefix` | — | CDN origin; `asset()` URL'lerine eklenir (sonunda `/` yok) |
1232
+ | `JSKELET_ASSETS_BUCKET` | `build.assets.upload` | — | Asset upload hedefi: bucket veya `bucket/prefix`. Credential ile birlikte production build sonunda PutObject |
1233
+ | `AWS_ACCESS_KEY_ID` | S3 PutObject | — | Standart AWS imza anahtarı (log + asset upload); `JSKELET_S3_*`'dan önce |
1234
+ | `AWS_SECRET_ACCESS_KEY` | S3 PutObject | — | Standart AWS imza sırrı |
1235
+ | `AWS_SESSION_TOKEN` | S3 PutObject | — | Geçici credential (OIDC / STS) |
1236
+ | `AWS_REGION` / `AWS_DEFAULT_REGION` | `build.assets.upload` | — | Asset upload bölgesi |
1237
+ | `AWS_ENDPOINT_URL` | `build.assets.upload` | — | S3-uyumlu endpoint (asset upload) |
1189
1238
  | `JSKELET_LOG_BUCKET` | `logs.s3` | — | Log hedefi: bucket ya da `bucket/prefix` yolu. Credential ile birlikte varsa sink otomatik açılır |
1190
1239
  | `JSKELET_S3_BUCKET` | `logs.s3` | — | `JSKELET_LOG_BUCKET` yoksa bucket; `JSKELET_S3_KEY_PREFIX` ile birleşir |
1191
1240
  | `JSKELET_S3_KEY_PREFIX` | `logs.s3` | — | `JSKELET_S3_BUCKET` ile kullanılır (`bucket/prefix`) |
1192
- | `JSKELET_S3_ACCESS_KEY_ID` | `logs.s3` | — | PutObject imzası |
1193
- | `JSKELET_S3_SECRET_ACCESS_KEY` | `logs.s3` | — | İmza sırrı (`JSKELET_S3_ACCESS_SECRET` yedek ad) |
1194
- | `JSKELET_S3_SESSION_TOKEN` | `logs.s3` | — | Geçici credential için isteğe bağlı |
1195
- | `JSKELET_S3_REGION` | `logs.s3` | `auto` | Verilmezse `auto` |
1196
- | `JSKELET_S3_API_URL` | `logs.s3` | — | S3-uyumlu endpoint; `logs.s3.endpoint`'i ezer |
1241
+ | `JSKELET_S3_ACCESS_KEY_ID` | S3 PutObject | — | `AWS_*` yoksa PutObject imzası (log + asset) |
1242
+ | `JSKELET_S3_SECRET_ACCESS_KEY` | S3 PutObject | — | İmza sırrı (`JSKELET_S3_ACCESS_SECRET` yedek ad) |
1243
+ | `JSKELET_S3_SESSION_TOKEN` | S3 PutObject | — | Geçici credential için isteğe bağlı |
1244
+ | `JSKELET_S3_REGION` | `logs.s3` / asset upload | `auto` (log) | Log'da verilmezse `auto`; asset upload'ta `AWS_REGION` yoksa |
1245
+ | `JSKELET_S3_API_URL` | `logs.s3` / asset upload | — | S3-uyumlu endpoint |
1197
1246
  | `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) |
1198
1247
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache yüzeyi | — | Zone kimliği. Token'la birlikte verilmedikçe hiçbir Cloudflare ucu çağrılmaz |
1199
1248
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache yüzeyi | — | Purge URL'lerinin kökü. Panel iç bir adresten açılıyorsa gerekir |
package/docs/08-build.md CHANGED
@@ -20,6 +20,7 @@ davranışı burada. Çıktının çalışma anında nasıl servis edildiği
20
20
  5. Images config.images !== false, watch değil ve sharp kurulu ise
21
21
  6. Manifest .jskelet/manifest.json
22
22
  7. Precompress watch değilse
23
+ 8. Assets upload watch değilse (kapalıysa `skipped (sebep)`)
23
24
  ```
24
25
 
25
26
  Şablon derlemesi asset taramasından **önce** biter; istek yolunda parse yoktur.
@@ -30,8 +31,11 @@ Sıra rastgele değil:
30
31
 
31
32
  - **CSS ikon sprite'ından sonra gelir.** Sprite bir varlıktır ve sınıf üretmez,
32
33
  ama manifest anahtarı verir.
33
- - **Precompress en sonda:** sıkıştırılacak her şey üretilmiş olmalı.
34
+ - **Precompress, upload'tan hemen önce:** origin hâlâ `.br`/`.gz` üretir; S3
35
+ adımı yalnızca ham dosyaları kopyalar.
34
36
  - **Görseller watch turunda hiç çalışmaz:** `sharp` ile yeniden kodlama pahalı.
37
+ - **Assets upload watch'ta yok:** yalnızca production `jskelet build`; kapalıysa
38
+ yine satır basılır (`skipped (no JSKELET_ASSETS_BUCKET)` vb.).
35
39
 
36
40
  Görevler yalnızca ilgili yapılandırma varsa çalışır. Font tanımlamayan bir proje
37
41
  font adımını hiç görmez; bu, "framework her projeye kendi varsayımlarını
@@ -74,8 +78,25 @@ dosya adlarını okunur tutuyor. Hash'li olmaları sayesinde bu dosyalara
74
78
  ```
75
79
 
76
80
  - `asset(name)` manifest'te varsa hash'li URL'i, yoksa `/assets/<name>` döner.
81
+ `build.assets.prefix` / `JSKELET_ASSET_PREFIX` doluysa bu göreli yolun önüne
82
+ CDN origin eklenir (manifest diskte göreli kalır).
77
83
  - `hasAsset(name)` manifest'te olup olmadığını söyler.
78
84
 
85
+ ### CDN prefix ve S3 upload
86
+
87
+ Varsayılan kapalıdır. Açılınca:
88
+
89
+ 1. Production build `public/assets/` (ve `include` listesindeki diğer dizinler)
90
+ altındaki dosyaları S3'e PutObject ile kopyalar.
91
+ 2. Çalışma anında `asset()` / optimize görseller `JSKELET_ASSET_PREFIX` ile
92
+ absolute CDN URL basar. Entry CDN'deyse esbuild chunk'ları göreli yolla aynı
93
+ host'a gider.
94
+
95
+ Credential: `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (önce), yoksa
96
+ `JSKELET_S3_*`. Bucket: `JSKELET_ASSETS_BUCKET`. Config sözlüğü:
97
+ [07-yapilandirma.md](./07-yapilandirma.md#build). Dağıtım notları:
98
+ [10-dagitim.md](./10-dagitim.md).
99
+
79
100
  Build çalışmadıysa uygulama yine ayağa kalkar: `hasAsset()` false olur, layout
80
101
  stylesheet ve script etiketlerini hiç basmaz. `jskelet build` unutulduğunda hata
81
102
  yerine stilsiz ama çalışan bir sayfa görürsünüz. Manifest hiç yoksa bir kez uyarı
@@ -51,7 +51,28 @@ ayarlamayı düşünmeniz gerekenler:
51
51
  | `PREWARM_MAX` | Site boyutuna göre | Açılışta ısıtılacak sayfa sayısı |
52
52
  | `PREWARM_INTERVAL_SECONDS` | `0` ya da uzun bir değer | Hiç ziyaret edilmeyen sayfaları sıcak tutmak isterseniz |
53
53
  | `DEV_GATE` + `DEV_TOKEN` | Yalnızca staging'de | Yayına açılmamış ortamı gizler. Token tek başına siteyi kilitlemez |
54
- | `JSKELET_S3_*` | Access log'u S3'e yazıyorsanız | Bucket + credential; ayrıntı [07](./07-yapilandirma.md) |
54
+ | `JSKELET_S3_*` / `AWS_*` | Access log veya asset'leri S3'e yazıyorsanız | Credential; AWS deploy'da `AWS_ACCESS_KEY_ID` vb. yeter. Ayrıntı [07](./07-yapilandirma.md) |
55
+ | `JSKELET_ASSETS_BUCKET` | Hash'li asset'leri S3/CloudFront'a basıyorsanız | Production `jskelet build` sonunda PutObject; `JSKELET_ASSET_PREFIX` ile HTML CDN URL basar |
56
+
57
+ ### Asset'leri S3 / CloudFront'a basmak
58
+
59
+ Varsayılan kapalıdır. CI veya prod build ortamında:
60
+
61
+ ```bash
62
+ JSKELET_ASSETS_BUCKET=my-app-assets # veya my-app-assets/static
63
+ JSKELET_ASSET_PREFIX=https://dxxxx.cloudfront.net
64
+ AWS_REGION=eu-central-1
65
+ AWS_ACCESS_KEY_ID=…
66
+ AWS_SECRET_ACCESS_KEY=…
67
+ # geçici credential ise:
68
+ # AWS_SESSION_TOKEN=…
69
+ ```
70
+
71
+ `jskelet build` bitince `public/assets/` (ve config'te `include` edilen
72
+ dizinler) S3'e kopyalanır; origin'deki dosyalar da durur. Bucket veya
73
+ credential yoksa adım `skipped (…)` ile sebep basar, build devam eder. Watch /
74
+ `jskelet dev` upload yapmaz. CSP kullanıyorsanız CDN origin'ini `script-src` /
75
+ `style-src` / `img-src` ve `preconnect` listesine ekleyin.
55
76
 
56
77
  Production'da dosya veya S3 sink açıldığında HTTP access log middleware
57
78
  otomatik mount edilir (`logs.kinds` içinde `http` varsa). Admin paneli ring'i
@@ -99,6 +99,13 @@ export default {
99
99
  client: {
100
100
  groups: { charts: ["chart.js"] },
101
101
  },
102
+ assets: {
103
+ prefix: process.env.JSKELET_ASSET_PREFIX,
104
+ upload: {
105
+ enabled: Boolean(process.env.JSKELET_ASSETS_BUCKET),
106
+ bucket: process.env.JSKELET_ASSETS_BUCKET,
107
+ },
108
+ },
102
109
  },
103
110
 
104
111
  async headers() {
@@ -605,11 +612,14 @@ hand, use `remoteImageUrl(src, { width })` from `jskelet`.
605
612
 
606
613
  ## `build`
607
614
 
608
- **Type:** `{ client?: { splitting?, groups?, chunkGroup? } }` — **Default:**
609
- `{ client: { splitting: true, groups: {}, chunkGroup: null } }`
615
+ **Type:** `{ client?: { splitting?, groups?, chunkGroup? }, assets?: { prefix?, upload? } }`
616
+ — **Default:**
617
+ `{ client: { splitting: true, groups: {}, chunkGroup: null }, assets: { prefix: "", upload: { enabled: false, … } } }`
618
+
619
+ Client JS (esbuild) bundling knobs and optional CDN / S3 asset publishing.
620
+ `fonts` / `icons` / `images` / `clientEnv` stay top-level.
610
621
 
611
- Client JS (esbuild) bundling knobs. `fonts` / `icons` / `images` / `clientEnv`
612
- stay top-level; this section is only for splitting and chunk grouping.
622
+ ### `build.client`
613
623
 
614
624
  | Field | Type | Default | Meaning |
615
625
  | --- | --- | --- | --- |
@@ -624,6 +634,31 @@ group weakens — a page that uses one member may download the rest of the group
624
634
  `chunks/chunk.<hash>.js`; the group name is a config key, not the file name.
625
635
  Details: [08-build.md](./08-build.md).
626
636
 
637
+ ### `build.assets`
638
+
639
+ CDN origin and S3 upload at the end of a production build. **When off, nothing
640
+ changes** — assets stay on the origin.
641
+
642
+ | Field | Type | Default | Meaning |
643
+ | --- | --- | --- | --- |
644
+ | `assets.prefix` | `string` | `""` | Prefix for `asset()` / optimised image URLs (no trailing `/`). `JSKELET_ASSET_PREFIX` overrides. The on-disk manifest stays relative. |
645
+ | `assets.upload.enabled` | `boolean` | `false` | Turns on when `true` or when `JSKELET_ASSETS_BUCKET` is set; explicit `false` wins. |
646
+ | `assets.upload.bucket` | `string \| null` | `null` | Bucket or `bucket/key/prefix`. `JSKELET_ASSETS_BUCKET` overrides. |
647
+ | `assets.upload.keyPrefix` | `string` | `""` | Object key prefix when not given in the path (`prefix` is accepted as an alias). |
648
+ | `assets.upload.region` | `string` | `us-east-1` | `AWS_REGION` / `AWS_DEFAULT_REGION` / `JSKELET_S3_REGION`; defaults to `auto` with a custom endpoint. |
649
+ | `assets.upload.endpoint` | `string \| null` | `null` | S3-compatible API; `AWS_ENDPOINT_URL` / `JSKELET_S3_API_URL` overrides. |
650
+ | `assets.upload.include` | `string[]` | `["assets"]` | Directories under `public/` (`assets`, optionally `fonts`, …). |
651
+ | `assets.upload.cacheControl` | `string` | `public, max-age=31536000, immutable` | PutObject `Cache-Control`. |
652
+
653
+ Credentials are not written in the config. Preference order:
654
+ `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, then
655
+ `JSKELET_S3_*`. On a production build the Assets upload step always appears;
656
+ when off it logs a one-line reason such as
657
+ `skipped (no JSKELET_ASSETS_BUCKET)` or `skipped (credentials missing)` (the
658
+ build does not fail). No upload on watch passes. `.br` / `.gz` siblings are not
659
+ uploaded. Details: [08-build.md](./08-build.md),
660
+ [10-deployment.md](./10-deployment.md).
661
+
627
662
  ```js
628
663
  build: {
629
664
  client: {
@@ -636,6 +671,14 @@ build: {
636
671
  if (id.includes("node_modules/lodash")) return "vendor";
637
672
  },
638
673
  },
674
+ assets: {
675
+ prefix: process.env.JSKELET_ASSET_PREFIX, // https://dxxxx.cloudfront.net
676
+ upload: {
677
+ enabled: Boolean(process.env.JSKELET_ASSETS_BUCKET),
678
+ bucket: process.env.JSKELET_ASSETS_BUCKET, // my-bucket or my-bucket/static
679
+ include: ["assets", "fonts"],
680
+ },
681
+ },
639
682
  },
640
683
  ```
641
684
 
@@ -971,10 +1014,12 @@ deleted.
971
1014
  | `s3.flushIntervalMs` | `number` | `5000` | Batch flush interval |
972
1015
  | `s3.maxBatch` | `number` | `100` | Flush early after this many lines |
973
1016
 
974
- S3 credentials are not written in the config: `JSKELET_S3_ACCESS_KEY_ID`,
975
- `JSKELET_S3_SECRET_ACCESS_KEY`, optional `JSKELET_S3_SESSION_TOKEN`. Missing
976
- bucket/region/credentials warn and disable the S3 sink; the site still starts.
977
- The framework does not ship `@aws-sdk` — PutObject is embedded with SigV4.
1017
+ S3 credentials are not written in the config. Preference order:
1018
+ `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, then
1019
+ `JSKELET_S3_ACCESS_KEY_ID`, `JSKELET_S3_SECRET_ACCESS_KEY`, optional
1020
+ `JSKELET_S3_SESSION_TOKEN`. Missing bucket/region/credentials warn and disable
1021
+ the S3 sink; the site still starts. The framework does not ship `@aws-sdk` —
1022
+ PutObject is embedded with SigV4.
978
1023
 
979
1024
  ```js
980
1025
  logs: {
@@ -1207,14 +1252,21 @@ and no warning is printed.
1207
1252
  | `DEV_GATE` | `devGate` | off | `1` turns the gate on, `0` turns it off even when config enabled it. `DEV_TOKEN` alone does not turn it on. [09](./09-dev-tools.md) |
1208
1253
  | `DEV_TOKEN` | `devGate`, `prewarm` | — | The secret expected while the gate is on. If it is missing, or the gate is off, the site stays public. Prewarming carries the token as a cookie only while the gate is on. [09](./09-dev-tools.md) |
1209
1254
  | `JSKELET_ADMIN` | `createApp` | — | When set, turns the admin panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
1255
+ | `JSKELET_ASSET_PREFIX` | `build.assets.prefix` | — | CDN origin prepended to `asset()` URLs (no trailing `/`) |
1256
+ | `JSKELET_ASSETS_BUCKET` | `build.assets.upload` | — | Asset upload target: bucket or `bucket/prefix`. With credentials, production build PutObject |
1257
+ | `AWS_ACCESS_KEY_ID` | S3 PutObject | — | Standard AWS signing key (logs + asset upload); before `JSKELET_S3_*` |
1258
+ | `AWS_SECRET_ACCESS_KEY` | S3 PutObject | — | Standard AWS signing secret |
1259
+ | `AWS_SESSION_TOKEN` | S3 PutObject | — | Temporary credentials (OIDC / STS) |
1260
+ | `AWS_REGION` / `AWS_DEFAULT_REGION` | `build.assets.upload` | — | Asset upload region |
1261
+ | `AWS_ENDPOINT_URL` | `build.assets.upload` | — | S3-compatible endpoint (asset upload) |
1210
1262
  | `JSKELET_LOG_BUCKET` | `logs.s3` | — | Log target: bucket or `bucket/prefix` path. With credentials, the sink turns on automatically |
1211
1263
  | `JSKELET_S3_BUCKET` | `logs.s3` | — | Bucket when `JSKELET_LOG_BUCKET` is unset; joins with `JSKELET_S3_KEY_PREFIX` |
1212
1264
  | `JSKELET_S3_KEY_PREFIX` | `logs.s3` | — | Used with `JSKELET_S3_BUCKET` (`bucket/prefix`) |
1213
- | `JSKELET_S3_ACCESS_KEY_ID` | `logs.s3` | — | Signs PutObject |
1214
- | `JSKELET_S3_SECRET_ACCESS_KEY` | `logs.s3` | — | Signing secret (`JSKELET_S3_ACCESS_SECRET` is an alias) |
1215
- | `JSKELET_S3_SESSION_TOKEN` | `logs.s3` | — | Optional, for temporary credentials |
1216
- | `JSKELET_S3_REGION` | `logs.s3` | `auto` | Defaults to `auto` when unset |
1217
- | `JSKELET_S3_API_URL` | `logs.s3` | — | S3-compatible endpoint; overrides `logs.s3.endpoint` |
1265
+ | `JSKELET_S3_ACCESS_KEY_ID` | S3 PutObject | — | Signs PutObject when `AWS_*` is unset (logs + assets) |
1266
+ | `JSKELET_S3_SECRET_ACCESS_KEY` | S3 PutObject | — | Signing secret (`JSKELET_S3_ACCESS_SECRET` is an alias) |
1267
+ | `JSKELET_S3_SESSION_TOKEN` | S3 PutObject | — | Optional, for temporary credentials |
1268
+ | `JSKELET_S3_REGION` | `logs.s3` / asset upload | `auto` (logs) | Logs default to `auto`; asset upload uses it when `AWS_REGION` is unset |
1269
+ | `JSKELET_S3_API_URL` | `logs.s3` / asset upload | — | S3-compatible endpoint |
1218
1270
  | `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) |
1219
1271
  | `JSKELET_CLOUDFLARE_ZONE_ID` | Cloudflare cache surface | — | Zone identifier. No Cloudflare endpoint is called unless it is set alongside the token |
1220
1272
  | `JSKELET_CLOUDFLARE_HOSTNAME` | Cloudflare cache surface | — | The root for purge URLs. Required when the panel is opened over an internal address |
@@ -20,6 +20,7 @@ build is in [09-dev-tools.md](./09-dev-tools.md).
20
20
  5. Images if config.images !== false, not watch, and sharp is installed
21
21
  6. Manifest .jskelet/manifest.json
22
22
  7. Precompress if not watch
23
+ 8. Assets upload if not watch (logs `skipped (reason)` when off)
23
24
  ```
24
25
 
25
26
  Template compilation finishes **before** asset scanning; there is no parse on
@@ -30,9 +31,11 @@ The order is not arbitrary:
30
31
 
31
32
  - **CSS comes after the icon sprite.** The sprite is an asset and produces no
32
33
  classes, but it does give a manifest key.
33
- - **Precompress is last:** everything that gets compressed must already be
34
- produced.
34
+ - **Precompress runs just before upload:** the origin still gets `.br`/`.gz`;
35
+ the S3 step copies only the raw files.
35
36
  - **Images never run on a watch pass:** re-encoding with `sharp` is expensive.
37
+ - **Assets upload never runs on watch:** only production `jskelet build`; when
38
+ off the step still prints (`skipped (no JSKELET_ASSETS_BUCKET)`, etc.).
36
39
 
37
40
  Tasks only run if the relevant configuration exists. A project that does not
38
41
  define any fonts never sees the font step; this is the build-side counterpart of
@@ -78,9 +81,26 @@ They are passed to templates automatically; in server code,
78
81
  ```
79
82
 
80
83
  - `asset(name)` returns the hashed URL if it is in the manifest, otherwise
81
- `/assets/<name>`.
84
+ `/assets/<name>`. When `build.assets.prefix` / `JSKELET_ASSET_PREFIX` is set,
85
+ that relative path is prefixed with the CDN origin (the on-disk manifest stays
86
+ relative).
82
87
  - `hasAsset(name)` tells you whether it is in the manifest.
83
88
 
89
+ ### CDN prefix and S3 upload
90
+
91
+ Off by default. When enabled:
92
+
93
+ 1. A production build PutObjects files under `public/assets/` (and any other
94
+ directories in `include`) to S3.
95
+ 2. At runtime `asset()` / optimised images emit absolute CDN URLs via
96
+ `JSKELET_ASSET_PREFIX`. When the entry script is on the CDN, esbuild chunks
97
+ resolve relatively on the same host.
98
+
99
+ Credentials: `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` first, else
100
+ `JSKELET_S3_*`. Bucket: `JSKELET_ASSETS_BUCKET`. Config reference:
101
+ [07-configuration.md](./07-configuration.md#build). Deploy notes:
102
+ [10-deployment.md](./10-deployment.md).
103
+
84
104
  If the build has not run, the application still comes up: `hasAsset()` is false
85
105
  and the layout never emits the stylesheet and script tags. When `jskelet build`
86
106
  is forgotten you get an unstyled but working page instead of an error. If the
@@ -52,7 +52,28 @@ considering in production:
52
52
  | `PREWARM_MAX` | Depends on site size | Number of pages warmed at startup |
53
53
  | `PREWARM_INTERVAL_SECONDS` | `0` or a long value | If you want to keep never-visited pages warm |
54
54
  | `DEV_GATE` + `DEV_TOKEN` | Staging only | Hides an environment that is not public yet. The token alone does not lock the site |
55
- | `JSKELET_S3_*` | If you write access logs to S3 | Bucket + credentials; details in [07](./07-configuration.md) |
55
+ | `JSKELET_S3_*` / `AWS_*` | If you write access logs or assets to S3 | Credentials; on AWS, `AWS_ACCESS_KEY_ID` etc. are enough. Details in [07](./07-configuration.md) |
56
+ | `JSKELET_ASSETS_BUCKET` | If you publish hashed assets to S3/CloudFront | PutObject at the end of production `jskelet build`; `JSKELET_ASSET_PREFIX` makes HTML emit CDN URLs |
57
+
58
+ ### Publishing assets to S3 / CloudFront
59
+
60
+ Off by default. In CI or a production build environment:
61
+
62
+ ```bash
63
+ JSKELET_ASSETS_BUCKET=my-app-assets # or my-app-assets/static
64
+ JSKELET_ASSET_PREFIX=https://dxxxx.cloudfront.net
65
+ AWS_REGION=eu-central-1
66
+ AWS_ACCESS_KEY_ID=…
67
+ AWS_SECRET_ACCESS_KEY=…
68
+ # for temporary credentials:
69
+ # AWS_SESSION_TOKEN=…
70
+ ```
71
+
72
+ When `jskelet build` finishes, files under `public/assets/` (and any other
73
+ `include` directories) are copied to S3; origin copies remain. If the bucket or
74
+ credentials are missing, the step logs `skipped (…)` with the reason and the
75
+ build continues. Watch / `jskelet dev` never upload. If you use CSP, add the CDN
76
+ origin to `script-src` / `style-src` / `img-src` and to `preconnect`.
56
77
 
57
78
  When a file or S3 sink is enabled in production, the HTTP access log middleware
58
79
  mounts automatically (if `http` is in `logs.kinds`). The admin panel ring is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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",
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Tek build girişi: fontlar → ikon sprite → CSS → client JS → görseller →
3
- * manifest → precompress.
3
+ * manifest → precompress → (opsiyonel) S3 asset upload.
4
4
  *
5
5
  * Sıra rastgele değil. CSS Tailwind için şablonları tarar, bu yüzden ikon
6
6
  * sprite'ından sonra gelir (sprite bir varlık, sınıf üretmez ama manifest
7
- * anahtarı verir). Precompress en sonda: sıkıştırılacak her şey üretilmiş
8
- * olmalı.
7
+ * anahtarı verir). Precompress upload'tan önce: origin hâlâ `.br` üretir;
8
+ * S3 adımı ham dosyaları kopyalar. Upload yalnızca `build.assets.upload`
9
+ * açıksa ve watch değilse çalışır.
9
10
  *
10
11
  * Görevler yalnızca ilgili yapılandırma varsa çalışır. Font tanımlamayan bir
11
12
  * proje font adımını hiç görmez; bu, "framework her projeye kendi
@@ -108,6 +109,14 @@ writeManifest(manifest);
108
109
  if (!watch) {
109
110
  const { precompressAssets } = await import("./tasks/precompress.mjs");
110
111
  await task("Precompress", precompressAssets);
112
+
113
+ // Her production build'de satır görünür; kapalıysa `skipped (sebep)`.
114
+ // CDN'e basılacak dosyalar precompress'ten sonra hazır; `.br`/`.gz` yüklenmez.
115
+ {
116
+ const { uploadAssets } = await import("./tasks/assets-upload.mjs");
117
+ await task("Assets upload", () => uploadAssets(config));
118
+ }
119
+
111
120
  log.section("output");
112
121
  for (const row of manifestRows()) log.line(row);
113
122
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Production build sonunda hash'li varlıkları S3'e PutObject ile kopyalar.
3
+ *
4
+ * Watch turunda çalışmaz (çağıran sorumlu). Credential env'den gelir
5
+ * (`AWS_*` önce, `JSKELET_S3_*` yedek). Bucket / credential yoksa veya upload
6
+ * kapalıysa adım yine görünür: `skipped (…)` ile sebep basılır, build düşmez.
7
+ *
8
+ * `.br` / `.gz` kardeşleri yüklenmez: edge kendi sıkıştırmasını yapsın;
9
+ * origin precompress Express için kalır.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { readS3CredentialsFromEnv } from "../../config/index.js";
14
+ import { putObject } from "../../server/logs/s3-put.js";
15
+ import * as log from "../../log.mjs";
16
+ import { paths } from "../paths.mjs";
17
+
18
+ const CONCURRENCY = 8;
19
+
20
+ /** @type {Record<string, string>} */
21
+ const MIME = {
22
+ ".js": "application/javascript; charset=utf-8",
23
+ ".mjs": "application/javascript; charset=utf-8",
24
+ ".css": "text/css; charset=utf-8",
25
+ ".svg": "image/svg+xml",
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".webp": "image/webp",
30
+ ".avif": "image/avif",
31
+ ".gif": "image/gif",
32
+ ".ico": "image/x-icon",
33
+ ".woff": "font/woff",
34
+ ".woff2": "font/woff2",
35
+ ".ttf": "font/ttf",
36
+ ".otf": "font/otf",
37
+ ".json": "application/json",
38
+ ".map": "application/json",
39
+ ".txt": "text/plain; charset=utf-8",
40
+ };
41
+
42
+ /**
43
+ * @param {string} filePath
44
+ * @returns {string}
45
+ */
46
+ export function contentTypeFor(filePath) {
47
+ const ext = path.extname(filePath).toLowerCase();
48
+ return MIME[ext] ?? "application/octet-stream";
49
+ }
50
+
51
+ /**
52
+ * @param {string} dir
53
+ * @returns {string[]} Mutlak dosya yolları
54
+ */
55
+ export function listFilesRecursive(dir) {
56
+ if (!fs.existsSync(dir)) return [];
57
+
58
+ /** @type {string[]} */
59
+ const out = [];
60
+ const walk = (current) => {
61
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
62
+ const full = path.join(current, entry.name);
63
+ if (entry.isDirectory()) {
64
+ walk(full);
65
+ continue;
66
+ }
67
+ if (!entry.isFile()) continue;
68
+ // Precompress kardeşleri origin'e özel; S3'e basma.
69
+ if (entry.name.endsWith(".br") || entry.name.endsWith(".gz")) continue;
70
+ out.push(full);
71
+ }
72
+ };
73
+ walk(dir);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * `include` adını `public/` altı mutlak dizine çevirir.
79
+ *
80
+ * @param {string} name
81
+ * @returns {string | null}
82
+ */
83
+ function resolveIncludeDir(name) {
84
+ if (name === "assets") return paths.assets;
85
+ if (name === "fonts") return paths.fonts;
86
+ if (!name || name.includes("..") || path.isAbsolute(name)) return null;
87
+ return path.join(paths.public, name);
88
+ }
89
+
90
+ /**
91
+ * Upload yapılmayacaksa kısa sebep; yapılacaksa `null`.
92
+ *
93
+ * @param {import('../../config/index.js').ResolvedConfig} config
94
+ * @returns {string | null}
95
+ */
96
+ export function assetsUploadSkipReason(config) {
97
+ const upload = config.build?.assets?.upload;
98
+
99
+ if (!upload?.enabled) {
100
+ // Bucket yoksa asıl eksik env'i söyle — `enabled: false` çoğu zaman
101
+ // bucket tanımlanmadığı için dolaylıdır.
102
+ if (!upload?.bucket) return "no JSKELET_ASSETS_BUCKET";
103
+ return "build.assets.upload.enabled is false";
104
+ }
105
+
106
+ if (!upload.bucket) return "no JSKELET_ASSETS_BUCKET";
107
+
108
+ if (!readS3CredentialsFromEnv()) return "credentials missing";
109
+
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * @param {import('../../config/index.js').ResolvedConfig} config
115
+ * @param {{ put?: typeof putObject }} [options]
116
+ * @returns {Promise<void>}
117
+ */
118
+ export async function uploadAssets(config, options = {}) {
119
+ const reason = assetsUploadSkipReason(config);
120
+ if (reason) {
121
+ log.detail(`skipped (${reason})`);
122
+ return;
123
+ }
124
+
125
+ const upload = config.build.assets.upload;
126
+ const credentials = readS3CredentialsFromEnv();
127
+ // skipReason null iken credential ve bucket vardır; TS/akış için daralt.
128
+ if (!upload.bucket || !credentials) {
129
+ log.detail("skipped (credentials missing)");
130
+ return;
131
+ }
132
+
133
+ const put = options.put ?? putObject;
134
+ const keyPrefix = (upload.keyPrefix || "").replace(/^\/+/, "");
135
+
136
+ /** @type {{ file: string, key: string }[]} */
137
+ const jobs = [];
138
+ for (const name of upload.include) {
139
+ const dir = resolveIncludeDir(name);
140
+ if (!dir) {
141
+ console.warn(`[assets] upload.include "${name}" is invalid, skipping`);
142
+ continue;
143
+ }
144
+ for (const file of listFilesRecursive(dir)) {
145
+ const relative = path
146
+ .relative(paths.public, file)
147
+ .split(path.sep)
148
+ .join("/");
149
+ if (relative.startsWith("..")) continue;
150
+ jobs.push({ file, key: `${keyPrefix}${relative}` });
151
+ }
152
+ }
153
+
154
+ if (!jobs.length) {
155
+ log.detail("0 files");
156
+ return;
157
+ }
158
+
159
+ let uploaded = 0;
160
+ let index = 0;
161
+
162
+ /** @returns {Promise<void>} */
163
+ const worker = async () => {
164
+ while (index < jobs.length) {
165
+ const job = jobs[index++];
166
+ const body = fs.readFileSync(job.file);
167
+ await put({
168
+ bucket: upload.bucket,
169
+ key: job.key,
170
+ body,
171
+ region: upload.region,
172
+ endpoint: upload.endpoint,
173
+ credentials,
174
+ contentType: contentTypeFor(job.file),
175
+ cacheControl: upload.cacheControl,
176
+ });
177
+ uploaded += 1;
178
+ }
179
+ };
180
+
181
+ const workers = Array.from(
182
+ { length: Math.min(CONCURRENCY, jobs.length) },
183
+ () => worker(),
184
+ );
185
+ await Promise.all(workers);
186
+
187
+ const target = `${upload.bucket}/${keyPrefix}`.replace(/\/+/g, "/");
188
+ const via = upload.endpoint ? ` @ ${upload.endpoint}` : "";
189
+ log.detail(`${uploaded} → s3://${target}${via}`);
190
+ }
@@ -471,10 +471,14 @@ export const DEFAULT_SECURITY = {
471
471
  };
472
472
 
473
473
  /**
474
- * Client bundle (esbuild) ayarları.
474
+ * Client bundle (esbuild) ayarları + opsiyonel CDN prefix / S3 upload.
475
475
  *
476
476
  * `groups` / `chunkGroup` esbuild'in native manualChunks'ı olmadığı için
477
477
  * framework plugin'i ile uygulanır — ayrıntı `src/build/chunk-groups.mjs`.
478
+ *
479
+ * `assets.prefix` HTML'deki `asset()` URL'lerine eklenir (manifest diskte
480
+ * göreli kalır). `assets.upload` production build sonunda `public/` altındaki
481
+ * seçili dizinleri S3'e PutObject ile kopyalar; credential env'den okunur.
478
482
  */
479
483
  export const DEFAULT_BUILD = {
480
484
  client: {
@@ -493,6 +497,21 @@ export const DEFAULT_BUILD = {
493
497
  */
494
498
  chunkGroup: null,
495
499
  },
500
+ assets: {
501
+ /** CDN / CloudFront origin; sonunda `/` yok. Env: `JSKELET_ASSET_PREFIX`. */
502
+ prefix: "",
503
+ upload: {
504
+ enabled: false,
505
+ bucket: null,
506
+ /** Nesne anahtarı öneki (`bucket/path` split'inden veya config). */
507
+ keyPrefix: "",
508
+ region: "us-east-1",
509
+ endpoint: null,
510
+ /** `public/` altı dizin adları. */
511
+ include: /** @type {string[]} */ (["assets"]),
512
+ cacheControl: "public, max-age=31536000, immutable",
513
+ },
514
+ },
496
515
  };
497
516
 
498
517
  /**
@@ -161,7 +161,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
161
161
  * @property {{ scan?: string[], dir: string } | false} icons
162
162
  * @property {ImagesConfig | false} images
163
163
  * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
164
- * @property {BuildConfig} build Client bundling (esbuild splitting / chunk groups).
164
+ * @property {BuildConfig} build Client bundling + CDN prefix / S3 asset upload.
165
165
  */
166
166
 
167
167
  /**
@@ -170,8 +170,22 @@ const CONFIG_FILE = "jskelet.config.mjs";
170
170
  * @property {Record<string, string[]>} groups
171
171
  * @property {((id: string) => string | null | undefined) | null} chunkGroup
172
172
  *
173
+ * @typedef {object} BuildAssetsUploadConfig
174
+ * @property {boolean} enabled
175
+ * @property {string | null} bucket
176
+ * @property {string} keyPrefix
177
+ * @property {string} region
178
+ * @property {string | null} endpoint
179
+ * @property {string[]} include
180
+ * @property {string} cacheControl
181
+ *
182
+ * @typedef {object} BuildAssetsConfig
183
+ * @property {string} prefix CDN origin (`JSKELET_ASSET_PREFIX`); sonunda `/` yok.
184
+ * @property {BuildAssetsUploadConfig} upload
185
+ *
173
186
  * @typedef {object} BuildConfig
174
187
  * @property {BuildClientConfig} client
188
+ * @property {BuildAssetsConfig} assets
175
189
  */
176
190
 
177
191
  /**
@@ -514,19 +528,28 @@ function envText(value) {
514
528
  }
515
529
 
516
530
  /**
531
+ * S3 imza credential'ları. AWS deploy'da standart env önce gelir; R2/MinIO
532
+ * için `JSKELET_S3_*` yedek. IAM instance/task role (IMDS) desteklenmez —
533
+ * CI'da `configure-aws-credentials` gibi araçlar zaten `AWS_*` basar.
534
+ *
517
535
  * @returns {{ accessKeyId: string, secretAccessKey: string,
518
536
  * sessionToken: string | null } | null}
519
537
  */
520
538
  export function readS3CredentialsFromEnv() {
521
- const accessKeyId = envText(process.env.JSKELET_S3_ACCESS_KEY_ID);
539
+ const accessKeyId =
540
+ envText(process.env.AWS_ACCESS_KEY_ID) ??
541
+ envText(process.env.JSKELET_S3_ACCESS_KEY_ID);
522
542
  const secretAccessKey =
543
+ envText(process.env.AWS_SECRET_ACCESS_KEY) ??
523
544
  envText(process.env.JSKELET_S3_SECRET_ACCESS_KEY) ??
524
545
  envText(process.env.JSKELET_S3_ACCESS_SECRET);
525
546
  if (!accessKeyId || !secretAccessKey) return null;
526
547
  return {
527
548
  accessKeyId,
528
549
  secretAccessKey,
529
- sessionToken: envText(process.env.JSKELET_S3_SESSION_TOKEN),
550
+ sessionToken:
551
+ envText(process.env.AWS_SESSION_TOKEN) ??
552
+ envText(process.env.JSKELET_S3_SESSION_TOKEN),
530
553
  };
531
554
  }
532
555
 
@@ -1152,7 +1175,122 @@ function normalizeSecurity(raw) {
1152
1175
  }
1153
1176
 
1154
1177
  /**
1155
- * Client build ayarları (`build.client`). Bozuk tipler uyarıyla varsayılana düşer.
1178
+ * @returns {BuildClientConfig}
1179
+ */
1180
+ function defaultBuildClient() {
1181
+ return {
1182
+ splitting: DEFAULT_BUILD.client.splitting,
1183
+ groups: {},
1184
+ chunkGroup: null,
1185
+ };
1186
+ }
1187
+
1188
+ /**
1189
+ * CDN prefix + S3 upload (`build.assets`). Credential config'e yazılmaz.
1190
+ *
1191
+ * @param {unknown} raw
1192
+ * @returns {BuildAssetsConfig}
1193
+ */
1194
+ export function normalizeBuildAssets(raw) {
1195
+ const source =
1196
+ raw != null && typeof raw === "object" && !Array.isArray(raw)
1197
+ ? /** @type {Record<string, any>} */ (raw)
1198
+ : {};
1199
+
1200
+ if (raw != null && (typeof raw !== "object" || Array.isArray(raw))) {
1201
+ console.warn("[config] build.assets must be an object, ignoring it");
1202
+ }
1203
+
1204
+ const envPrefix = envText(process.env.JSKELET_ASSET_PREFIX);
1205
+ const configPrefix =
1206
+ typeof source.prefix === "string" ? source.prefix.trim() : "";
1207
+ const prefix = (envPrefix ?? configPrefix).replace(/\/+$/, "");
1208
+
1209
+ const uploadRaw =
1210
+ source.upload != null &&
1211
+ typeof source.upload === "object" &&
1212
+ !Array.isArray(source.upload)
1213
+ ? /** @type {Record<string, any>} */ (source.upload)
1214
+ : {};
1215
+
1216
+ if (
1217
+ source.upload != null &&
1218
+ (typeof source.upload !== "object" || Array.isArray(source.upload))
1219
+ ) {
1220
+ console.warn("[config] build.assets.upload must be an object, ignoring it");
1221
+ }
1222
+
1223
+ const envBucket = envText(process.env.JSKELET_ASSETS_BUCKET);
1224
+ const bucketPath = splitS3BucketPath(
1225
+ envBucket ?? envText(uploadRaw.bucket),
1226
+ );
1227
+
1228
+ const configKeyPrefix =
1229
+ typeof uploadRaw.keyPrefix === "string" && uploadRaw.keyPrefix
1230
+ ? uploadRaw.keyPrefix.endsWith("/")
1231
+ ? uploadRaw.keyPrefix
1232
+ : `${uploadRaw.keyPrefix}/`
1233
+ : typeof uploadRaw.prefix === "string" && uploadRaw.prefix
1234
+ ? uploadRaw.prefix.endsWith("/")
1235
+ ? uploadRaw.prefix
1236
+ : `${uploadRaw.prefix}/`
1237
+ : DEFAULT_BUILD.assets.upload.keyPrefix;
1238
+
1239
+ const endpoint =
1240
+ envText(process.env.AWS_ENDPOINT_URL) ??
1241
+ envText(process.env.JSKELET_S3_API_URL) ??
1242
+ envText(uploadRaw.endpoint);
1243
+
1244
+ // Gerçek AWS'te region imza için gerekir; custom endpoint'te `auto` yaygın.
1245
+ const region =
1246
+ envText(uploadRaw.region) ??
1247
+ envText(process.env.AWS_REGION) ??
1248
+ envText(process.env.AWS_DEFAULT_REGION) ??
1249
+ envText(process.env.JSKELET_S3_REGION) ??
1250
+ (endpoint ? "auto" : DEFAULT_BUILD.assets.upload.region);
1251
+
1252
+ /** @type {string[]} */
1253
+ let include = [...DEFAULT_BUILD.assets.upload.include];
1254
+ if (uploadRaw.include != null) {
1255
+ const list = asArray(uploadRaw.include, "build.assets.upload.include")
1256
+ .filter((entry) => typeof entry === "string" && entry.trim())
1257
+ .map((entry) => String(entry).trim().replace(/^\/+|\/+$/g, ""));
1258
+ if (list.length) include = [...new Set(list)];
1259
+ else {
1260
+ console.warn(
1261
+ "[config] build.assets.upload.include has no valid entries, using defaults",
1262
+ );
1263
+ }
1264
+ }
1265
+
1266
+ const cacheControl =
1267
+ typeof uploadRaw.cacheControl === "string" && uploadRaw.cacheControl.trim()
1268
+ ? uploadRaw.cacheControl.trim()
1269
+ : DEFAULT_BUILD.assets.upload.cacheControl;
1270
+
1271
+ // `JSKELET_ASSETS_BUCKET` doluysa config'te `enabled` unutulmuş olsa bile aç.
1272
+ // Açık `enabled: false` ezer.
1273
+ const enabled =
1274
+ uploadRaw.enabled === true ||
1275
+ (uploadRaw.enabled !== false && Boolean(bucketPath.bucket));
1276
+
1277
+ return {
1278
+ prefix,
1279
+ upload: {
1280
+ enabled,
1281
+ bucket: bucketPath.bucket,
1282
+ keyPrefix: bucketPath.prefix ?? configKeyPrefix,
1283
+ region,
1284
+ endpoint,
1285
+ include,
1286
+ cacheControl,
1287
+ },
1288
+ };
1289
+ }
1290
+
1291
+ /**
1292
+ * Client build ayarları (`build.client` + `build.assets`). Bozuk tipler
1293
+ * uyarıyla varsayılana düşer.
1156
1294
  *
1157
1295
  * @param {unknown} raw
1158
1296
  * @returns {BuildConfig}
@@ -1160,47 +1298,30 @@ function normalizeSecurity(raw) {
1160
1298
  export function normalizeBuild(raw) {
1161
1299
  if (raw == null) {
1162
1300
  return {
1163
- client: {
1164
- splitting: DEFAULT_BUILD.client.splitting,
1165
- groups: {},
1166
- chunkGroup: null,
1167
- },
1301
+ client: defaultBuildClient(),
1302
+ assets: normalizeBuildAssets(null),
1168
1303
  };
1169
1304
  }
1170
1305
 
1171
1306
  if (typeof raw !== "object" || Array.isArray(raw)) {
1172
1307
  console.warn("[config] build must be an object, ignoring it");
1173
1308
  return {
1174
- client: {
1175
- splitting: DEFAULT_BUILD.client.splitting,
1176
- groups: {},
1177
- chunkGroup: null,
1178
- },
1309
+ client: defaultBuildClient(),
1310
+ assets: normalizeBuildAssets(null),
1179
1311
  };
1180
1312
  }
1181
1313
 
1182
1314
  const source = /** @type {Record<string, any>} */ (raw);
1315
+ const assets = normalizeBuildAssets(source.assets);
1183
1316
  const clientRaw = source.client;
1184
1317
 
1185
1318
  if (clientRaw == null) {
1186
- return {
1187
- client: {
1188
- splitting: DEFAULT_BUILD.client.splitting,
1189
- groups: {},
1190
- chunkGroup: null,
1191
- },
1192
- };
1319
+ return { client: defaultBuildClient(), assets };
1193
1320
  }
1194
1321
 
1195
1322
  if (typeof clientRaw !== "object" || Array.isArray(clientRaw)) {
1196
1323
  console.warn("[config] build.client must be an object, ignoring it");
1197
- return {
1198
- client: {
1199
- splitting: DEFAULT_BUILD.client.splitting,
1200
- groups: {},
1201
- chunkGroup: null,
1202
- },
1203
- };
1324
+ return { client: defaultBuildClient(), assets };
1204
1325
  }
1205
1326
 
1206
1327
  const client = /** @type {Record<string, any>} */ (clientRaw);
@@ -1250,7 +1371,7 @@ export function normalizeBuild(raw) {
1250
1371
  );
1251
1372
  }
1252
1373
 
1253
- return { client: { splitting, groups, chunkGroup } };
1374
+ return { client: { splitting, groups, chunkGroup }, assets };
1254
1375
  }
1255
1376
 
1256
1377
  /**
package/src/index.js CHANGED
@@ -43,7 +43,13 @@ export {
43
43
  writeSharedCookie,
44
44
  } from "./http/shared-cookie.js";
45
45
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
46
- export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
46
+ export {
47
+ asset,
48
+ hasAsset,
49
+ optimizedImage,
50
+ getSpriteIds,
51
+ applyAssetPrefix,
52
+ } from "./server/assets.js";
47
53
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
48
54
  export {
49
55
  ImageResponse,
@@ -5,6 +5,10 @@
5
5
  * döner ve `hasAsset()` false olur; layout stylesheet/script etiketlerini hiç
6
6
  * basmaz. Böylece `jskelet build` unutulduğunda hata yerine stilsiz ama
7
7
  * çalışan bir sayfa görürsünüz.
8
+ *
9
+ * `build.assets.prefix` / `JSKELET_ASSET_PREFIX` doluysa HTML'e basılan
10
+ * URL'lere CDN origin eklenir; manifest diskte göreli kalır (çok ortamlı
11
+ * tek artifact). Dosya sistemi okumaları (`getSpriteIds`) göreli yolu kullanır.
8
12
  */
9
13
  import fs from "node:fs";
10
14
  import path from "node:path";
@@ -44,12 +48,45 @@ function state() {
44
48
  return cached;
45
49
  }
46
50
 
51
+ /**
52
+ * CDN prefix + göreli asset yolu. Zaten mutlak (`https:…`) URL'lere dokunmaz.
53
+ *
54
+ * @param {string} prefix Sonunda `/` olmadan (normalize edilmiş).
55
+ * @param {string} url
56
+ * @returns {string}
57
+ */
58
+ export function applyAssetPrefix(prefix, url) {
59
+ if (!prefix || !url) return url;
60
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
61
+ const pathPart = url.startsWith("/") ? url : `/${url}`;
62
+ return `${prefix.replace(/\/+$/, "")}${pathPart}`;
63
+ }
64
+
65
+ /** @returns {string} */
66
+ function assetPrefix() {
67
+ try {
68
+ return getConfig().build?.assets?.prefix ?? "";
69
+ } catch {
70
+ return "";
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Manifest'teki göreli yol (prefix yok) — disk okuma için.
76
+ *
77
+ * @param {string} name
78
+ * @returns {string}
79
+ */
80
+ function assetPath(name) {
81
+ return state().manifest[name] ?? `/assets/${name}`;
82
+ }
83
+
47
84
  /**
48
85
  * @param {string} name Örn. "app.css", "main.js", "sprite.svg"
49
86
  * @returns {string}
50
87
  */
51
88
  export function asset(name) {
52
- return state().manifest[name] ?? `/assets/${name}`;
89
+ return applyAssetPrefix(assetPrefix(), assetPath(name));
53
90
  }
54
91
 
55
92
  /**
@@ -113,7 +150,20 @@ export function optimizedImage(src) {
113
150
  }
114
151
  }
115
152
 
116
- return src ? images[src] : undefined;
153
+ if (!src) return undefined;
154
+ const entry = images[src];
155
+ if (!entry) return undefined;
156
+
157
+ const prefix = assetPrefix();
158
+ if (!prefix) return entry;
159
+
160
+ return {
161
+ ...entry,
162
+ variants: entry.variants.map((variant) => ({
163
+ ...variant,
164
+ url: applyAssetPrefix(prefix, variant.url),
165
+ })),
166
+ };
117
167
  }
118
168
 
119
169
  /** @type {{ url: string, ids: Set<string> } | null} */
@@ -128,20 +178,21 @@ let spriteIds = null;
128
178
  * @returns {Set<string>}
129
179
  */
130
180
  export function getSpriteIds() {
131
- const url = asset("sprite.svg");
132
- if (spriteIds?.url === url) return spriteIds.ids;
181
+ // Disk yolu göreli olmalı; CDN prefix dosya sisteminde yok.
182
+ const relative = assetPath("sprite.svg");
183
+ if (spriteIds?.url === relative) return spriteIds.ids;
133
184
 
134
185
  /** @type {Set<string>} */
135
186
  const ids = new Set();
136
187
 
137
188
  try {
138
- const file = path.join(getConfig().dirs.public, url.replace(/^\//, ""));
189
+ const file = path.join(getConfig().dirs.public, relative.replace(/^\//, ""));
139
190
  const svg = fs.readFileSync(file, "utf8");
140
191
  for (const match of svg.matchAll(/<symbol id="([^"]+)"/g)) ids.add(match[1]);
141
192
  } catch {
142
193
  // Sprite okunamazsa uyarı üretilmez; asıl akış etkilenmez.
143
194
  }
144
195
 
145
- spriteIds = { url, ids };
196
+ spriteIds = { url: relative, ids };
146
197
  return ids;
147
198
  }
@@ -80,7 +80,7 @@ export async function configureLogs(config) {
80
80
  );
81
81
  } else if (!credentials) {
82
82
  console.warn(
83
- "[logs] s3.enabled but credentials missing — set JSKELET_S3_ACCESS_KEY_ID and JSKELET_S3_SECRET_ACCESS_KEY",
83
+ "[logs] s3.enabled but credentials missing — set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or JSKELET_S3_*",
84
84
  );
85
85
  } else {
86
86
  const region = logs.s3.region || "auto";
@@ -2,10 +2,9 @@
2
2
  * Minimal S3 PutObject — yalnızca SigV4 + `fetch`.
3
3
  *
4
4
  * `@aws-sdk/client-s3` taşınmıyor: tek operasyon için SDK'nın maliyeti ve
5
- * peer yüzeyi gereksiz. Credential zinciri env'den okunur
6
- * (`JSKELET_S3_ACCESS_KEY_ID` / `JSKELET_S3_SECRET_ACCESS_KEY` /
7
- * `JSKELET_S3_SESSION_TOKEN`); IAM role / instance metadata yok —
8
- * container'a key vermeyen kurulumlar için sink zaten açılışta no-op'a düşer.
5
+ * peer yüzeyi gereksiz. Credential zinciri env'den okunur (`AWS_*` önce,
6
+ * sonra `JSKELET_S3_*`); IAM role / instance metadata yok — CI'da
7
+ * `configure-aws-credentials` gibi araçlar key basar.
9
8
  */
10
9
  import crypto from "node:crypto";
11
10
 
@@ -15,7 +14,7 @@ import crypto from "node:crypto";
15
14
  *
16
15
  * @typedef {{ bucket: string, key: string, body: string | Buffer,
17
16
  * region: string, endpoint?: string | null, credentials: AwsCredentials,
18
- * now?: Date }} PutObjectInput
17
+ * contentType?: string, cacheControl?: string | null, now?: Date }} PutObjectInput
19
18
  */
20
19
 
21
20
  /**
@@ -145,12 +144,17 @@ export function buildSignedPutObject(input) {
145
144
  /** @type {Record<string, string>} */
146
145
  const headers = {
147
146
  host,
148
- "content-type": "application/x-ndjson",
147
+ // Log sink varsayılanı; asset upload `contentType` geçirir.
148
+ "content-type": input.contentType ?? "application/x-ndjson",
149
149
  "content-length": String(body.length),
150
150
  "x-amz-content-sha256": payloadHash,
151
151
  "x-amz-date": amzDate,
152
152
  };
153
153
 
154
+ if (input.cacheControl) {
155
+ headers["cache-control"] = input.cacheControl;
156
+ }
157
+
154
158
  if (input.credentials.sessionToken) {
155
159
  headers["x-amz-security-token"] = input.credentials.sessionToken;
156
160
  }
@@ -421,10 +421,14 @@ export declare const DEFAULT_SECURITY: {
421
421
  };
422
422
  };
423
423
  /**
424
- * Client bundle (esbuild) ayarları.
424
+ * Client bundle (esbuild) ayarları + opsiyonel CDN prefix / S3 upload.
425
425
  *
426
426
  * `groups` / `chunkGroup` esbuild'in native manualChunks'ı olmadığı için
427
427
  * framework plugin'i ile uygulanır — ayrıntı `src/build/chunk-groups.mjs`.
428
+ *
429
+ * `assets.prefix` HTML'deki `asset()` URL'lerine eklenir (manifest diskte
430
+ * göreli kalır). `assets.upload` production build sonunda `public/` altındaki
431
+ * seçili dizinleri S3'e PutObject ile kopyalar; credential env'den okunur.
428
432
  */
429
433
  export declare const DEFAULT_BUILD: {
430
434
  client: {
@@ -443,6 +447,21 @@ export declare const DEFAULT_BUILD: {
443
447
  */
444
448
  chunkGroup: ((id: string) => string | null | undefined) | null;
445
449
  };
450
+ assets: {
451
+ /** CDN / CloudFront origin; sonunda `/` yok. Env: `JSKELET_ASSET_PREFIX`. */
452
+ prefix: string;
453
+ upload: {
454
+ enabled: boolean;
455
+ bucket: null;
456
+ /** Nesne anahtarı öneki (`bucket/path` split'inden veya config). */
457
+ keyPrefix: string;
458
+ region: string;
459
+ endpoint: null;
460
+ /** `public/` altı dizin adları. */
461
+ include: string[];
462
+ cacheControl: string;
463
+ };
464
+ };
446
465
  };
447
466
  /**
448
467
  * Build-zamanı görsel adımı + opsiyonel runtime uzak görsel proxy.
@@ -221,7 +221,7 @@ export type ResolvedConfig = {
221
221
  */
222
222
  clientEnv: string[];
223
223
  /**
224
- * Client bundling (esbuild splitting / chunk groups).
224
+ * Client bundling + CDN prefix / S3 asset upload.
225
225
  */
226
226
  build: BuildConfig;
227
227
  };
@@ -230,8 +230,25 @@ export type BuildClientConfig = {
230
230
  groups: Record<string, string[]>;
231
231
  chunkGroup: ((id: string) => string | null | undefined) | null;
232
232
  };
233
+ export type BuildAssetsUploadConfig = {
234
+ enabled: boolean;
235
+ bucket: string | null;
236
+ keyPrefix: string;
237
+ region: string;
238
+ endpoint: string | null;
239
+ include: string[];
240
+ cacheControl: string;
241
+ };
242
+ export type BuildAssetsConfig = {
243
+ /**
244
+ * CDN origin (`JSKELET_ASSET_PREFIX`); sonunda `/` yok.
245
+ */
246
+ prefix: string;
247
+ upload: BuildAssetsUploadConfig;
248
+ };
233
249
  export type BuildConfig = {
234
250
  client: BuildClientConfig;
251
+ assets: BuildAssetsConfig;
235
252
  };
236
253
  export type ImagesRemoteConfig = {
237
254
  enabled: boolean;
@@ -262,6 +279,10 @@ export declare function splitS3BucketPath(value: string | null): {
262
279
  prefix: string | null;
263
280
  };
264
281
  /**
282
+ * S3 imza credential'ları. AWS deploy'da standart env önce gelir; R2/MinIO
283
+ * için `JSKELET_S3_*` yedek. IAM instance/task role (IMDS) desteklenmez —
284
+ * CI'da `configure-aws-credentials` gibi araçlar zaten `AWS_*` basar.
285
+ *
265
286
  * @returns {{ accessKeyId: string, secretAccessKey: string,
266
287
  * sessionToken: string | null } | null}
267
288
  */
@@ -292,7 +313,15 @@ export type SecurityConfig = {
292
313
  };
293
314
  };
294
315
  /**
295
- * Client build ayarları (`build.client`). Bozuk tipler uyarıyla varsayılana düşer.
316
+ * CDN prefix + S3 upload (`build.assets`). Credential config'e yazılmaz.
317
+ *
318
+ * @param {unknown} raw
319
+ * @returns {BuildAssetsConfig}
320
+ */
321
+ export declare function normalizeBuildAssets(raw: unknown): BuildAssetsConfig;
322
+ /**
323
+ * Client build ayarları (`build.client` + `build.assets`). Bozuk tipler
324
+ * uyarıyla varsayılana düşer.
296
325
  *
297
326
  * @param {unknown} raw
298
327
  * @returns {BuildConfig}
package/types/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { cache, withRequestCache } from "./http/request-cache.js";
13
13
  export { clearCookie, getSignedCookie, parseCookies, randomToken, safeEqual, serializeCookie, setCookie, setSignedCookie, } from "./http/cookies.js";
14
14
  export { clearSharedCookie, requestIsHttps, resolveSharedCookieDomain, SHARED_COOKIE_WARN_BYTES, writeSharedCookie, } from "./http/shared-cookie.js";
15
15
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
16
- export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
16
+ export { asset, hasAsset, optimizedImage, getSpriteIds, applyAssetPrefix, } from "./server/assets.js";
17
17
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
18
18
  export { ImageResponse, OG_SIZE, buildOgSvg, escapeXml, ogHandler, ogImage, sendOgImage, wrapText, } from "./server/og-image.js";
19
19
  export { headHints } from "./server/head-hints.js";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * CDN prefix + göreli asset yolu. Zaten mutlak (`https:…`) URL'lere dokunmaz.
3
+ *
4
+ * @param {string} prefix Sonunda `/` olmadan (normalize edilmiş).
5
+ * @param {string} url
6
+ * @returns {string}
7
+ */
8
+ export declare function applyAssetPrefix(prefix: string, url: string): string;
1
9
  /**
2
10
  * @param {string} name Örn. "app.css", "main.js", "sprite.svg"
3
11
  * @returns {string}
@@ -10,6 +10,8 @@ export type PutObjectInput = {
10
10
  region: string;
11
11
  endpoint?: string | null;
12
12
  credentials: AwsCredentials;
13
+ contentType?: string;
14
+ cacheControl?: string | null;
13
15
  now?: Date;
14
16
  };
15
17
  /**