jskelet 0.4.4 → 0.4.6

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,15 @@ one is listed under a **Breaking** heading.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - Runtime remote image optimizer: set `images.remote.allowHosts` to proxy
14
+ allowlisted http(s) images through `/_jskelet/image?url=&w=&q=` as resized
15
+ webp (disk cache under `.jskelet/image-cache/`). `image()` rewrites matching
16
+ remote `src` values automatically; `remoteImageUrl()` builds URLs by hand.
17
+ Requires `sharp` at runtime; without it the endpoint 302-redirects to the
18
+ source.
19
+
11
20
  ### Breaking
12
21
 
13
22
  - The cache admin panel moved to a top-level `admin()` config section at
@@ -19,6 +28,10 @@ one is listed under a **Breaking** heading.
19
28
 
20
29
  ### Fixed
21
30
 
31
+ - Remote image optimizer cache hits no longer 404. Disk cache lives under
32
+ `.jskelet/image-cache/`; Express `sendFile` ignores dotfiles by default, so
33
+ the file was written but the response still failed. `sendCached` now passes
34
+ `dotfiles: "allow"`.
22
35
  - Cloudflare analytics in the cache panel no longer asks for an open-ended
23
36
  window. Queries used only `datetime_geq`, so Cloudflare closed the range at
24
37
  query time and a default 24h lookback became `1d` plus network delay — Free
package/docs/02-mimari.md CHANGED
@@ -42,6 +42,8 @@ JSkelet bu gözlemi mimarinin merkezine alır:
42
42
  ├─ staticPrecompressed build'de üretilmiş .br/.gz kopyalar (kalite 11)
43
43
  ├─ express.static public/ altındaki dosyalar
44
44
  ├─ (dev) devtools yalnızca NODE_ENV=development
45
+ ├─ admin (açıksa) /_jskelet/admin
46
+ ├─ image optimizer (remote) /_jskelet/image — allowHosts doluysa
45
47
  ├─ body parser'lar urlencoded 64kb + json 256kb
46
48
  ├─ rewrites(afterFiles) statik denendikten sonra
47
49
  ├─ route'lar
@@ -383,12 +383,16 @@ Davranış:
383
383
 
384
384
  - `public/` altındaki yerel raster görseller için build'de üretilen webp
385
385
  varyantları (`.jskelet/images.json`) otomatik olarak `srcset` + intrinsic
386
- `width`/`height` olarak eklenir. Manifest'te olmayan ya da uzak görseller
387
- olduğu gibi basılır.
388
- - `srcset` elle verilmişse ya da `unoptimized: true` ise manifest'e hiç
389
- bakılmaz.
386
+ `width`/`height` olarak eklenir. Manifest'te olmayan yerel yollar olduğu
387
+ gibi basılır.
388
+ - `images.remote.allowHosts` açıksa uzak `http(s)` URL'leri
389
+ `/_jskelet/image?url=&w=` proxy'sine çevrilir (webp). `width` varsa 1x/2x
390
+ + config `widths` ile `srcset` üretilir.
391
+ - `srcset` elle verilmişse ya da `unoptimized: true` ise ne manifest ne de
392
+ remote proxy kullanılır.
390
393
  - Yalnızca **tek** varyant üretilmişse (kaynak zaten küçükse) `srcset`/`sizes`
391
- yazılmaz; gürültüden ibaret olurdu.
394
+ yazılmaz; gürültüden ibaret olurdu. Remote'da tek genişlikte bile `src`
395
+ yine optimize URL'dir.
392
396
  - `sizes` verilmezse makul bir varsayılan üretilir: görsel kendi intrinsic
393
397
  genişliğinden büyütülmez, dar ekranlarda viewport'u kaplar
394
398
  (`(max-width: Npx) 100vw, Npx`).
@@ -482,24 +482,51 @@ icons: { scan: ["views", "client", "routes", "lib", "content"] }
482
482
 
483
483
  ## `images`
484
484
 
485
- **Tip:** `{ widths?: number[], quality?: number, skip?: string[] } | false` —
486
- **Varsayılan:** `{}`
485
+ **Tip:**
486
+ `{ widths?: number[], quality?: number, skip?: string[], remote?: { allowHosts: string[], path?: string, maxWidth?: number, cacheMaxAge?: number, fetchTimeoutMs?: number, maxBytes?: number } | false } | false`
487
+ — **Varsayılan:** `{ widths, quality, skip, remote: false }` (remote kapalı)
488
+
489
+ `public/` altındaki png/jpg görsellerin webp varyantlarını **build**'de üretir.
490
+ `remote.allowHosts` verilirse çalışma anında uzak görselleri de proxy eder
491
+ (`/_jskelet/image?url=&w=&q=` → webp).
492
+
493
+ | Alan | Tip | Varsayılan | Anlamı |
494
+ | --- | --- | --- | --- |
495
+ | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Build ve remote `srcset` adayları. Kaynaktan büyük olanlar build'de elenir; kaynağın kendi genişliği (en fazla 1920) her zaman eklenir. |
496
+ | `quality` | `number` | `78` | webp kalitesi. Build'de imzaya girer; remote uçta `q` varsayılanı. |
497
+ | `skip` | `string[]` | `[]` | Build'de taranmayacak **dizin adları**. `assets` ve `fonts` her zaman atlanır. |
498
+ | `remote` | `object \| false` | kapalı | Runtime optimizer. `allowHosts` **zorunlu**; boşsa uç mount edilmez. |
487
499
 
488
- `public/` altındaki png/jpg görsellerin webp varyantlarını üretir.
500
+ ### `images.remote`
489
501
 
490
502
  | Alan | Tip | Varsayılan | Anlamı |
491
503
  | --- | --- | --- | --- |
492
- | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Üretilecek genişlikler. Kaynaktan büyük olanlar elenir; kaynağın kendi genişliği (en fazla 1920) her zaman eklenir. |
493
- | `quality` | `number` | `78` | webp kalitesi. Değişince kodlayıcı imzası değişir ve tüm görseller yeniden kodlanır. |
494
- | `skip` | `string[]` | `[]` | Taranmayacak **dizin adları**. `assets` ve `fonts` her zaman atlanır. |
504
+ | `allowHosts` | `string[]` | `[]` | Çekilebilecek host'lar. `*.cdn.example.com` sonek jokerini destekler. |
505
+ | `path` | `string` | `/_jskelet/image` | Optimizer GET yolu. |
506
+ | `maxWidth` | `number` | `1920` | `w` üst sınırı. |
507
+ | `cacheMaxAge` | `number` | `2592000` (30 gün) | Yanıt `Cache-Control` max-age (saniye). Disk önbelleği `.jskelet/image-cache/`. |
508
+ | `fetchTimeoutMs` | `number` | `10000` | Upstream fetch zaman aşımı. |
509
+ | `maxBytes` | `number` | `10485760` (10 MiB) | Upstream gövde üst sınırı. |
495
510
 
496
- `false` verilirse görsel adımı hiç çalışmaz. Adım `sharp` gerektirir ve watch
497
- turunda hiç çalışmaz. Ayrıntı: [08-build.md](./08-build.md).
511
+ `false` verilirse görsel adımı hiç çalışmaz. Build adımı `sharp` gerektirir ve
512
+ watch turunda hiç çalışmaz. Remote açıksa `sharp` **runtime**'da da gerekir;
513
+ yoksa optimizer kaynak URL'ye 302 yönlendirir. Ayrıntı: [08-build.md](./08-build.md).
498
514
 
499
515
  ```js
500
- images: { widths: [400, 800, 1200], quality: 82, skip: ["indirmeler"] }
516
+ images: {
517
+ widths: [400, 800, 1200],
518
+ quality: 82,
519
+ skip: ["indirmeler"],
520
+ remote: {
521
+ allowHosts: ["static.ornek.com", "*.cdn.ornek.com"],
522
+ },
523
+ }
501
524
  ```
502
525
 
526
+ `image({ src: "https://static.ornek.com/a.jpg", width: 96, alt: "…" })` bu
527
+ ayarla `src` / `srcset`'i `/_jskelet/image?url=…&w=96` biçimine çevirir.
528
+ Elle URL kurmak için `remoteImageUrl(src, { width })` (`jskelet`).
529
+
503
530
  ## `clientEnv`
504
531
 
505
532
  **Tip:** `string[]` — **Varsayılan:** `[]`
package/docs/08-build.md CHANGED
@@ -290,6 +290,13 @@ değiştirmez ([04-render-ve-sablonlar.md](./04-render-ve-sablonlar.md)).
290
290
  Bu adım `sharp` gerektirir. Kurulu değilse adım sessizce atlanır ve `image()`
291
291
  orijinal dosyaya döner. Watch turunda hiç çalışmaz.
292
292
 
293
+ ## Runtime uzak görsel proxy
294
+
295
+ `images.remote.allowHosts` verilirse `createApp` `/_jskelet/image` ucunu
296
+ mount eder. CMS / CDN kapakları build'e girmediği için `image()` bu host'lardaki
297
+ URL'leri `?url=&w=` biçiminde yeniden yazar; uç sharp ile webp üretir ve
298
+ `.jskelet/image-cache/` altına yazar. Ayrıntı: [07-yapilandirma.md](./07-yapilandirma.md).
299
+
293
300
  ## Precompress
294
301
 
295
302
  Build çıktısı varlıkların brotli (kalite 11) ve gzip (seviye 9) kopyalarını
@@ -97,6 +97,7 @@ ENV HOST=0.0.0.0
97
97
 
98
98
  COPY package.json package-lock.json ./
99
99
  # sharp ve tailwind yalnızca build zamanı gerekli; çalışma imajına girmesin.
100
+ # images.remote kullanıyorsanız sharp'ı production dependencies'e alın.
100
101
  RUN npm ci --omit=dev && npm cache clean --force
101
102
 
102
103
  COPY --from=build /app/jskelet.config.mjs ./jskelet.config.mjs
@@ -126,9 +127,11 @@ Notlar:
126
127
  anında yapılıyor. `lib/` yalnızca projenizde varsa kopyalayın.
127
128
  - **`.jskelet/` gerekir:** `manifest.json` olmadan `asset()` hash'li URL'leri
128
129
  bulamaz ve `jskelet start` build'i baştan çalıştırmaya kalkar.
129
- - **`sharp` çalışma imajında gerekmez:** yalnızca build zamanı görsel
130
+ - **`sharp` çalışma imajında genelde gerekmez:** yalnızca build zamanı görsel
130
131
  optimizasyonu için. `--omit=dev` ile dışarıda kalır (devDependency olarak
131
- kurulmuşsa).
132
+ kurulmuşsa). **`images.remote` açıksa** sharp runtime bağımlılığıdır —
133
+ production `dependencies`'e alın ya da runtime imajında ayrıca kurun; yoksa
134
+ optimizer kaynak URL'ye 302 yönlendirir.
132
135
  - `jskelet start`ı `npx` olmadan çağırmak isterseniz
133
136
  `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` de çalışır.
134
137
 
package/docs/11-tasima.md CHANGED
@@ -96,9 +96,10 @@ Bunları taşıma planında baştan hesaba katın:
96
96
  önce belgeyi hazırlar, `viewTransition` geçişi yumuşatır
97
97
  ([07](./07-yapilandirma.md)).
98
98
  - **Server Actions.** Form gönderimleri normal `app.post(...)` handler'larıdır.
99
- - **Otomatik görsel optimizasyonu (istek anında).** Optimizasyon build zamanında
100
- yapılır ve yalnızca `public/` altındaki yerel görselleri kapsar; uzak görseller
101
- olduğu gibi basılır.
99
+ - **Otomatik görsel optimizasyonu (istek anında).** Yerel `public/` görselleri
100
+ hâlâ build zamanında optimize edilir. Uzak `http(s)` görseller
101
+ `images.remote.allowHosts` verilirse çalışma anında proxy edilir
102
+ (`/_jskelet/image` → webp); config yoksa olduğu gibi basılır.
102
103
 
103
104
  ## Yan yana örnek
104
105
 
@@ -42,6 +42,8 @@ Request
42
42
  ├─ staticPrecompressed .br/.gz copies produced at build (quality 11)
43
43
  ├─ express.static files under public/
44
44
  ├─ (dev) devtools only when NODE_ENV=development
45
+ ├─ admin (if enabled) /_jskelet/admin
46
+ ├─ image optimizer (remote) /_jskelet/image — when allowHosts is set
45
47
  ├─ body parsers urlencoded 64kb + json 256kb
46
48
  ├─ rewrites(afterFiles) after static has been tried
47
49
  ├─ routes
@@ -389,12 +389,16 @@ Behaviour:
389
389
 
390
390
  - For local raster images under `public/`, the webp variants generated at build
391
391
  time (`.jskelet/images.json`) are added automatically as `srcset` plus
392
- intrinsic `width`/`height`. Images that are not in the manifest, or remote
393
- ones, are emitted as-is.
394
- - If `srcset` is given by hand, or `unoptimized: true` is set, the manifest is
395
- not consulted at all.
392
+ intrinsic `width`/`height`. Local paths missing from the manifest are emitted
393
+ as-is.
394
+ - When `images.remote.allowHosts` is set, remote `http(s)` URLs are rewritten to
395
+ the `/_jskelet/image?url=&w=` proxy (webp). If `width` is set, `srcset`
396
+ includes 1x/2x plus config `widths`.
397
+ - If `srcset` is given by hand, or `unoptimized: true` is set, neither the
398
+ manifest nor the remote proxy is used.
396
399
  - If only **one** variant was produced (because the source is already small),
397
- `srcset`/`sizes` are not written; they would be pure noise.
400
+ `srcset`/`sizes` are not written; they would be pure noise. For remote images,
401
+ a single width still rewrites `src` to the optimized URL.
398
402
  - If `sizes` is not given a reasonable default is produced: the image is not
399
403
  scaled beyond its own intrinsic width, and it fills the viewport on narrow
400
404
  screens (`(max-width: Npx) 100vw, Npx`).
@@ -494,24 +494,52 @@ icons: { scan: ["views", "client", "routes", "lib", "content"] }
494
494
 
495
495
  ## `images`
496
496
 
497
- **Type:** `{ widths?: number[], quality?: number, skip?: string[] } | false` —
498
- **Default:** `{}`
497
+ **Type:**
498
+ `{ widths?: number[], quality?: number, skip?: string[], remote?: { allowHosts: string[], path?: string, maxWidth?: number, cacheMaxAge?: number, fetchTimeoutMs?: number, maxBytes?: number } | false } | false`
499
+ — **Default:** `{ widths, quality, skip, remote: false }` (remote off)
499
500
 
500
- Generates webp variants of the png/jpg images under `public/`.
501
+ Generates webp variants of png/jpg files under `public/` at **build** time.
502
+ When `remote.allowHosts` is set, also proxies remote images at runtime
503
+ (`/_jskelet/image?url=&w=&q=` → webp).
501
504
 
502
505
  | Field | Type | Default | Meaning |
503
506
  | --- | --- | --- | --- |
504
- | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Widths to generate. Ones larger than the source are dropped; the source's own width (at most 1920) is always added. |
505
- | `quality` | `number` | `78` | webp quality. When it changes, the encoder signature changes and every image is re-encoded. |
506
- | `skip` | `string[]` | `[]` | **Directory names** not to scan. `assets` and `fonts` are always skipped. |
507
+ | `widths` | `number[]` | `[400, 640, 960, 1280, 1920]` | Candidates for build and remote `srcset`. Ones larger than the source are dropped at build; the source's own width (at most 1920) is always added. |
508
+ | `quality` | `number` | `78` | webp quality. Part of the build encoder signature; default `q` on the remote endpoint. |
509
+ | `skip` | `string[]` | `[]` | **Directory names** not to scan at build. `assets` and `fonts` are always skipped. |
510
+ | `remote` | `object \| false` | off | Runtime optimizer. `allowHosts` is **required**; empty disables the route. |
511
+
512
+ ### `images.remote`
507
513
 
508
- If `false` is given, the image step never runs. The step requires `sharp` and
509
- never runs on a watch pass. Details: [08-build.md](./08-build.md).
514
+ | Field | Type | Default | Meaning |
515
+ | --- | --- | --- | --- |
516
+ | `allowHosts` | `string[]` | `[]` | Hosts that may be fetched. Supports a `*.cdn.example.com` suffix wildcard. |
517
+ | `path` | `string` | `/_jskelet/image` | Optimizer GET path. |
518
+ | `maxWidth` | `number` | `1920` | Cap for `w`. |
519
+ | `cacheMaxAge` | `number` | `2592000` (30 days) | Response `Cache-Control` max-age (seconds). Disk cache under `.jskelet/image-cache/`. |
520
+ | `fetchTimeoutMs` | `number` | `10000` | Upstream fetch timeout. |
521
+ | `maxBytes` | `number` | `10485760` (10 MiB) | Upstream body size limit. |
522
+
523
+ If `false` is given, neither surface runs. The build step requires `sharp` and
524
+ never runs on a watch pass. With remote enabled, `sharp` is also needed at
525
+ **runtime**; without it the optimizer 302-redirects to the source URL. Details:
526
+ [08-build.md](./08-build.md).
510
527
 
511
528
  ```js
512
- images: { widths: [400, 800, 1200], quality: 82, skip: ["downloads"] }
529
+ images: {
530
+ widths: [400, 800, 1200],
531
+ quality: 82,
532
+ skip: ["downloads"],
533
+ remote: {
534
+ allowHosts: ["static.example.com", "*.cdn.example.com"],
535
+ },
536
+ }
513
537
  ```
514
538
 
539
+ `image({ src: "https://static.example.com/a.jpg", width: 96, alt: "…" })`
540
+ rewrites `src` / `srcset` to `/_jskelet/image?url=…&w=96`. To build URLs by
541
+ hand, use `remoteImageUrl(src, { width })` from `jskelet`.
542
+
515
543
  ## `clientEnv`
516
544
 
517
545
  **Type:** `string[]` — **Default:** `[]`
@@ -305,6 +305,13 @@ to the `.jskelet/images.json` manifest. `image()` looks at that manifest and add
305
305
  This step requires `sharp`. If it is not installed the step is silently skipped
306
306
  and `image()` falls back to the original file. It never runs on a watch pass.
307
307
 
308
+ ## Runtime remote image proxy
309
+
310
+ When `images.remote.allowHosts` is set, `createApp` mounts `/_jskelet/image`.
311
+ CMS / CDN covers never enter the build, so `image()` rewrites those host URLs to
312
+ `?url=&w=`; the endpoint encodes webp with sharp and stores files under
313
+ `.jskelet/image-cache/`. Details: [07-configuration.md](./07-configuration.md).
314
+
308
315
  ## Precompress
309
316
 
310
317
  Produces brotli (quality 11) and gzip (level 9) copies of the built assets:
@@ -98,6 +98,7 @@ ENV HOST=0.0.0.0
98
98
 
99
99
  COPY package.json package-lock.json ./
100
100
  # sharp and tailwind are only needed at build time; keep them out of the runtime image.
101
+ # If you use images.remote, move sharp to production dependencies.
101
102
  RUN npm ci --omit=dev && npm cache clean --force
102
103
 
103
104
  COPY --from=build /app/jskelet.config.mjs ./jskelet.config.mjs
@@ -127,9 +128,11 @@ Notes:
127
128
  rendering happens at runtime. Copy `lib/` only if your project has one.
128
129
  - **`.jskelet/` is needed:** without `manifest.json`, `asset()` cannot find the
129
130
  hashed URLs and `jskelet start` will try to run the build from scratch.
130
- - **`sharp` is not needed in the runtime image:** it is only for build-time image
131
- optimization. `--omit=dev` leaves it out (if it was installed as a
132
- devDependency).
131
+ - **`sharp` is usually not needed in the runtime image:** it is only for
132
+ build-time image optimization. `--omit=dev` leaves it out (if it was installed
133
+ as a devDependency). **If `images.remote` is enabled**, sharp is a runtime
134
+ dependency — move it to production `dependencies` or install it in the
135
+ runtime image; otherwise the optimizer 302-redirects to the source URL.
133
136
  - If you would rather call `jskelet start` without `npx`,
134
137
  `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` works too.
135
138
 
@@ -99,9 +99,10 @@ Account for these from the start in your migration plan:
99
99
  `viewTransition` smooths the transition
100
100
  ([07](./07-configuration.md)).
101
101
  - **Server Actions.** Form submissions are ordinary `app.post(...)` handlers.
102
- - **Automatic image optimization (at request time).** Optimization happens at
103
- build time and only covers local images under `public/`; remote images are
104
- emitted as-is.
102
+ - **Automatic image optimization (at request time).** Local `public/` images
103
+ are still optimized at build time. Remote `http(s)` images can be proxied at
104
+ runtime when `images.remote.allowHosts` is set (`/_jskelet/image` → webp);
105
+ without that config they are emitted as-is.
105
106
 
106
107
  ## A side-by-side example
107
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -367,6 +367,32 @@ export const DEFAULT_SECURITY = {
367
367
  },
368
368
  };
369
369
 
370
+ /**
371
+ * Build-zamanı görsel adımı + opsiyonel runtime uzak görsel proxy.
372
+ *
373
+ * `remote` kapalıyken `image()` uzak URL'leri olduğu gibi basar (eski davranış).
374
+ * Açılınca yalnızca `allowHosts` listesindeki host'lar proxy edilir — boş liste
375
+ * açık proxy / SSRF kapısı olurdu, bu yüzden allowHosts olmadan remote hiç
376
+ * mount edilmez.
377
+ */
378
+ export const DEFAULT_IMAGES = {
379
+ widths: [400, 640, 960, 1280, 1920],
380
+ quality: 78,
381
+ skip: /** @type {string[]} */ ([]),
382
+ remote: {
383
+ /** @type {string[]} */
384
+ allowHosts: [],
385
+ path: "/_jskelet/image",
386
+ /** İzin verilen en büyük `w` (retina üstü israf). */
387
+ maxWidth: 1920,
388
+ /** Disk önbelleği Cache-Control max-age (saniye). */
389
+ cacheMaxAge: 60 * 60 * 24 * 30,
390
+ fetchTimeoutMs: 10_000,
391
+ /** Upstream gövde üst sınırı; aşılırsa 502. */
392
+ maxBytes: 10 * 1024 * 1024,
393
+ },
394
+ };
395
+
370
396
  /**
371
397
  * Markalama. Header adı ve dev overlay yolu tek yerden değişsin diye
372
398
  * config'ten okunur — fork eden proje kendi adını verebilir.
@@ -37,6 +37,7 @@ import {
37
37
  DEFAULT_DEV_GATE_BYPASS,
38
38
  DEFAULT_DIRS,
39
39
  DEFAULT_HTML_CACHE_MAX_ENTRIES,
40
+ DEFAULT_IMAGES,
40
41
  DEFAULT_LOGS,
41
42
  DEFAULT_NAVIGATION,
42
43
  DEFAULT_NAVIGATION_EXCLUDE,
@@ -130,10 +131,29 @@ const CONFIG_FILE = "jskelet.config.mjs";
130
131
  * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
131
132
  * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
132
133
  * @property {{ scan?: string[] } | false} icons
133
- * @property {{ widths?: number[], quality?: number, skip?: string[] } | false} images
134
+ * @property {ImagesConfig | false} images
134
135
  * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
135
136
  */
136
137
 
138
+ /**
139
+ * @typedef {object} ImagesRemoteConfig
140
+ * @property {boolean} enabled
141
+ * @property {string[]} allowHosts
142
+ * @property {string} path
143
+ * @property {number} maxWidth
144
+ * @property {number} cacheMaxAge
145
+ * @property {number} fetchTimeoutMs
146
+ * @property {number} maxBytes
147
+ */
148
+
149
+ /**
150
+ * @typedef {object} ImagesConfig
151
+ * @property {number[]} widths
152
+ * @property {number} quality
153
+ * @property {string[]} skip
154
+ * @property {ImagesRemoteConfig | false} remote
155
+ */
156
+
137
157
  /** @type {ResolvedConfig | null} */
138
158
  let config = null;
139
159
 
@@ -772,6 +792,83 @@ function normalizeSecurity(raw) {
772
792
  };
773
793
  }
774
794
 
795
+ /**
796
+ * Build + runtime görsel ayarları. `false` → her iki yüzey de kapalı.
797
+ * `remote.allowHosts` boşsa remote kapalı kalır (açık proxy olmasın).
798
+ *
799
+ * @param {unknown} raw
800
+ * @returns {ImagesConfig | false}
801
+ */
802
+ function normalizeImages(raw) {
803
+ if (raw === false) return false;
804
+
805
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
806
+ const widths = asArray(source.widths ?? DEFAULT_IMAGES.widths, "images.widths")
807
+ .map((entry) => Number(entry))
808
+ .filter((entry) => Number.isFinite(entry) && entry > 0)
809
+ .map((entry) => Math.round(entry));
810
+
811
+ const quality = Number(source.quality ?? DEFAULT_IMAGES.quality);
812
+ const skip = asArray(source.skip ?? DEFAULT_IMAGES.skip, "images.skip")
813
+ .filter((entry) => typeof entry === "string")
814
+ .map(String);
815
+
816
+ /** @type {ImagesRemoteConfig | false} */
817
+ let remote = false;
818
+ if (source.remote !== false && source.remote != null) {
819
+ const rem = /** @type {Record<string, any>} */ (
820
+ source.remote === true ? {} : source.remote
821
+ );
822
+ const allowHosts = asArray(
823
+ rem.allowHosts ?? DEFAULT_IMAGES.remote.allowHosts,
824
+ "images.remote.allowHosts",
825
+ )
826
+ .filter((entry) => typeof entry === "string" && entry.trim())
827
+ .map((entry) => String(entry).trim().toLowerCase());
828
+
829
+ if (allowHosts.length === 0) {
830
+ if (source.remote === true || rem.allowHosts != null) {
831
+ console.warn(
832
+ "[config] images.remote needs a non-empty allowHosts list; remote optimizer disabled",
833
+ );
834
+ }
835
+ } else {
836
+ remote = {
837
+ enabled: true,
838
+ allowHosts,
839
+ path: String(rem.path ?? DEFAULT_IMAGES.remote.path),
840
+ maxWidth: Math.max(
841
+ 1,
842
+ Number(rem.maxWidth ?? DEFAULT_IMAGES.remote.maxWidth) ||
843
+ DEFAULT_IMAGES.remote.maxWidth,
844
+ ),
845
+ cacheMaxAge: Math.max(
846
+ 0,
847
+ Number(rem.cacheMaxAge ?? DEFAULT_IMAGES.remote.cacheMaxAge) ||
848
+ DEFAULT_IMAGES.remote.cacheMaxAge,
849
+ ),
850
+ fetchTimeoutMs: Math.max(
851
+ 1000,
852
+ Number(rem.fetchTimeoutMs ?? DEFAULT_IMAGES.remote.fetchTimeoutMs) ||
853
+ DEFAULT_IMAGES.remote.fetchTimeoutMs,
854
+ ),
855
+ maxBytes: Math.max(
856
+ 1024,
857
+ Number(rem.maxBytes ?? DEFAULT_IMAGES.remote.maxBytes) ||
858
+ DEFAULT_IMAGES.remote.maxBytes,
859
+ ),
860
+ };
861
+ }
862
+ }
863
+
864
+ return {
865
+ widths: widths.length ? widths : [...DEFAULT_IMAGES.widths],
866
+ quality: Number.isFinite(quality) && quality > 0 ? quality : DEFAULT_IMAGES.quality,
867
+ skip,
868
+ remote,
869
+ };
870
+ }
871
+
775
872
  /**
776
873
  * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
777
874
  * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
@@ -942,7 +1039,7 @@ export async function loadConfig(options = {}) {
942
1039
  // olsun diye aynı yerden geçer.
943
1040
  fonts: source.fonts ?? [],
944
1041
  icons: source.icons ?? {},
945
- images: source.images ?? {},
1042
+ images: normalizeImages(source.images),
946
1043
  clientEnv: source.clientEnv ?? [],
947
1044
  };
948
1045
 
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  } from "./http/cookies.js";
38
38
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
39
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
+ export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
40
41
  export { headHints } from "./server/head-hints.js";
41
42
  export { renderHeadMeta } from "./server/metadata.js";
42
43
  export {
@@ -17,6 +17,8 @@
17
17
  * static'e düşer ve middleware anında sıkıştırır (kalite 5).
18
18
  * 4b. admin paneli (açıksa) — statikten sonra, route'lardan önce: kendi
19
19
  * gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
20
+ * 4c. image optimizer (images.remote) — uzak görselleri webp'ye çevirir;
21
+ * body parser'dan önce, admin ile aynı katmanda.
20
22
  * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
21
23
  * maliyeti ödenmesin.
22
24
  * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
@@ -137,6 +139,13 @@ export async function createApp(options = {}) {
137
139
  mountAdmin(app);
138
140
  }
139
141
 
142
+ // Uzak görsel proxy: allowHosts doluysa mount. Statikten sonra, body
143
+ // parser'dan önce — görsel GET'lerinde gövde ayrıştırma maliyeti ödenmesin.
144
+ if (config.images && config.images !== false && config.images.remote) {
145
+ const { mountImageOptimizer } = await import("./image-optimizer.js");
146
+ await mountImageOptimizer(app);
147
+ }
148
+
140
149
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
141
150
  app.use(express.json({ limit: "256kb" }));
142
151
 
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Runtime uzak görsel proxy: allowlist'teki host'lardan çeker, sharp ile
3
+ * webp'ye çevirir, diske yazar ve uzun Cache-Control ile servis eder.
4
+ *
5
+ * Next.js `/_next/image` karşılığı. Build zamanı `images.mjs` yalnızca
6
+ * `public/` altındaki yerel dosyaları kapsar; CMS / CDN kapakları için bu uç
7
+ * gerekir. Kapalıyken (allowHosts yok) router hiç mount edilmez.
8
+ *
9
+ * sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
10
+ * olmaz. Deployment notu: remote açıksa sharp runtime bağımlılığıdır.
11
+ */
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import process from "node:process";
16
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
17
+ import { getConfig } from "../config/index.js";
18
+
19
+ /** @type {typeof import('sharp') | null | undefined} */
20
+ let sharpModule;
21
+
22
+ /** @type {string | null} */
23
+ let cacheDir = null;
24
+
25
+ /**
26
+ * @returns {import('../config/index.js').ImagesRemoteConfig | null}
27
+ */
28
+ export function getRemoteImages() {
29
+ const images = getConfig().images;
30
+ if (!images || images === false || !images.remote || !images.remote.enabled) {
31
+ return null;
32
+ }
33
+ return images.remote;
34
+ }
35
+
36
+ /**
37
+ * Mutlak http(s) URL mi ve allowHosts'ta mı.
38
+ * @param {string} src
39
+ * @returns {URL | null}
40
+ */
41
+ export function parseAllowedRemoteUrl(src) {
42
+ const remote = getRemoteImages();
43
+ if (!remote || typeof src !== "string") return null;
44
+
45
+ let url;
46
+ try {
47
+ url = new URL(src);
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
53
+ if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
54
+ if (isBlockedAddress(url.hostname)) return null;
55
+
56
+ return url;
57
+ }
58
+
59
+ /**
60
+ * @param {string} hostname
61
+ * @param {string[]} allowHosts
62
+ * @returns {boolean}
63
+ */
64
+ export function isHostAllowed(hostname, allowHosts) {
65
+ const host = hostname.toLowerCase();
66
+ return allowHosts.some((entry) => {
67
+ const pattern = entry.toLowerCase();
68
+ if (pattern.startsWith("*.")) {
69
+ const suffix = pattern.slice(1); // ".example.com"
70
+ return host.endsWith(suffix) || host === pattern.slice(2);
71
+ }
72
+ return host === pattern;
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Literal private / link-local / loopback host'ları reddet (SSRF).
78
+ * Allowlist asıl koruma; bu ek bir savunma katmanı.
79
+ * @param {string} hostname
80
+ * @returns {boolean}
81
+ */
82
+ export function isBlockedAddress(hostname) {
83
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
84
+ if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) {
85
+ return true;
86
+ }
87
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
88
+
89
+ // IPv4
90
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
91
+ if (ipv4) {
92
+ const parts = ipv4.slice(1).map(Number);
93
+ if (parts.some((n) => n > 255)) return true;
94
+ const [a, b] = parts;
95
+ if (a === 10 || a === 127 || a === 0) return true;
96
+ if (a === 169 && b === 254) return true;
97
+ if (a === 172 && b >= 16 && b <= 31) return true;
98
+ if (a === 192 && b === 168) return true;
99
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
100
+ return false;
101
+ }
102
+
103
+ // Ham IPv6 private / ULA — basit önek kontrolü
104
+ if (host.includes(":")) {
105
+ if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
106
+ return true;
107
+ }
108
+ }
109
+
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Optimizer URL'si üret. `image()` ve elle URL kuran uygulamalar için.
115
+ * @param {string} src Uzak görsel URL'si
116
+ * @param {{ width: number, quality?: number }} options
117
+ * @returns {string | null} Allowlist dışıysa null
118
+ */
119
+ export function remoteImageUrl(src, options) {
120
+ const remote = getRemoteImages();
121
+ const allowed = parseAllowedRemoteUrl(src);
122
+ if (!remote || !allowed) return null;
123
+
124
+ const images = getConfig().images;
125
+ const quality =
126
+ options.quality ??
127
+ (images && images !== false ? images.quality : 78) ??
128
+ 78;
129
+ const width = clampWidth(options.width, remote.maxWidth);
130
+
131
+ const params = new URLSearchParams({
132
+ url: allowed.href,
133
+ w: String(width),
134
+ q: String(quality),
135
+ });
136
+ return `${remote.path}?${params}`;
137
+ }
138
+
139
+ /**
140
+ * @param {number} width
141
+ * @param {number} maxWidth
142
+ * @returns {number}
143
+ */
144
+ export function clampWidth(width, maxWidth) {
145
+ const n = Math.round(Number(width));
146
+ if (!Number.isFinite(n) || n < 1) return Math.min(640, maxWidth);
147
+ return Math.min(n, maxWidth);
148
+ }
149
+
150
+ /**
151
+ * Görüntülenen genişliğe göre srcset adayları (1x + 2x + config widths).
152
+ * @param {number} displayWidth
153
+ * @param {number[]} widths
154
+ * @param {number} maxWidth
155
+ * @returns {number[]}
156
+ */
157
+ export function srcsetWidths(displayWidth, widths, maxWidth) {
158
+ const base = Math.max(1, Math.round(displayWidth));
159
+ const candidates = new Set([
160
+ base,
161
+ Math.min(base * 2, maxWidth),
162
+ ...widths.filter((w) => w >= base && w <= maxWidth),
163
+ ]);
164
+ return [...candidates].sort((a, b) => a - b);
165
+ }
166
+
167
+ /**
168
+ * @param {import('express').Express} app
169
+ * @returns {Promise<void>}
170
+ */
171
+ export async function mountImageOptimizer(app) {
172
+ const remote = getRemoteImages();
173
+ if (!remote) return;
174
+
175
+ const config = getConfig();
176
+ cacheDir = path.join(config.dirs.generated, "image-cache");
177
+ fs.mkdirSync(cacheDir, { recursive: true });
178
+
179
+ sharpModule = await tryImportFromApp(config.root, "sharp");
180
+ if (!sharpModule) {
181
+ console.warn(
182
+ "[images.remote] sharp not installed; optimizer will redirect to the source URL",
183
+ );
184
+ }
185
+
186
+ app.get(remote.path, (req, res) => {
187
+ void handleOptimize(req, res, remote);
188
+ });
189
+ }
190
+
191
+ /**
192
+ * @param {import('express').Request} req
193
+ * @param {import('express').Response} res
194
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
195
+ */
196
+ async function handleOptimize(req, res, remote) {
197
+ const rawUrl = typeof req.query.url === "string" ? req.query.url : "";
198
+ const allowed = parseAllowedRemoteUrl(rawUrl);
199
+ if (!allowed) {
200
+ res.status(400).type("text").send("Invalid or disallowed image url");
201
+ return;
202
+ }
203
+
204
+ const images = getConfig().images;
205
+ const defaultQ = images && images !== false ? images.quality : 78;
206
+ const quality = clampQuality(
207
+ typeof req.query.q === "string" ? req.query.q : defaultQ,
208
+ defaultQ,
209
+ );
210
+ const width = clampWidth(
211
+ typeof req.query.w === "string" ? req.query.w : 640,
212
+ remote.maxWidth,
213
+ );
214
+
215
+ if (!sharpModule) {
216
+ res.redirect(302, allowed.href);
217
+ return;
218
+ }
219
+
220
+ const key = cacheKey(allowed.href, width, quality);
221
+ const filePath = path.join(/** @type {string} */ (cacheDir), `${key}.webp`);
222
+
223
+ try {
224
+ if (fs.existsSync(filePath)) {
225
+ sendCached(res, filePath, remote.cacheMaxAge);
226
+ return;
227
+ }
228
+
229
+ const upstream = await fetchUpstream(allowed.href, remote);
230
+ if (!upstream.ok) {
231
+ res.status(502).type("text").send("Upstream image fetch failed");
232
+ return;
233
+ }
234
+
235
+ const sharp = sharpModule.default;
236
+ const buffer = await sharp(upstream.buffer, { failOn: "none" })
237
+ .rotate()
238
+ .resize({
239
+ width,
240
+ withoutEnlargement: true,
241
+ fit: "inside",
242
+ })
243
+ .webp({ quality, effort: 4 })
244
+ .toBuffer();
245
+
246
+ // Atomik yaz: yarım dosya immutable cache'e düşmesin.
247
+ const tmp = `${filePath}.${process.pid}.tmp`;
248
+ await fs.promises.writeFile(tmp, buffer);
249
+ await fs.promises.rename(tmp, filePath);
250
+
251
+ sendCached(res, filePath, remote.cacheMaxAge);
252
+ } catch (error) {
253
+ console.warn("[images.remote] optimize failed:", error);
254
+ // Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
255
+ if (!res.headersSent) res.redirect(302, allowed.href);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * @param {string} href
261
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
262
+ * @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
263
+ */
264
+ async function fetchUpstream(href, remote) {
265
+ const controller = new AbortController();
266
+ const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
267
+
268
+ try {
269
+ const response = await fetch(href, {
270
+ signal: controller.signal,
271
+ redirect: "follow",
272
+ headers: {
273
+ // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
274
+ Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
275
+ "User-Agent": "jskelet-image-optimizer/1",
276
+ },
277
+ });
278
+
279
+ if (!response.ok) return { ok: false };
280
+
281
+ const type = response.headers.get("content-type") ?? "";
282
+ if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
283
+ return { ok: false };
284
+ }
285
+
286
+ const length = Number(response.headers.get("content-length") ?? 0);
287
+ if (length > remote.maxBytes) return { ok: false };
288
+
289
+ const buffer = Buffer.from(await response.arrayBuffer());
290
+ if (buffer.byteLength > remote.maxBytes) return { ok: false };
291
+
292
+ return { ok: true, buffer };
293
+ } catch {
294
+ return { ok: false };
295
+ } finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * @param {import('express').Response} res
302
+ * @param {string} filePath
303
+ * @param {number} maxAge
304
+ */
305
+ function sendCached(res, filePath, maxAge) {
306
+ res.setHeader("Content-Type", "image/webp");
307
+ res.setHeader(
308
+ "Cache-Control",
309
+ `public, max-age=${maxAge}, stale-while-revalidate=${Math.min(maxAge, 86400)}`,
310
+ );
311
+ res.setHeader("Vary", "Accept");
312
+ // Cache dir is under .jskelet/; Express send ignores dotfiles by default.
313
+ res.sendFile(path.resolve(filePath), { dotfiles: "allow" });
314
+ }
315
+
316
+ /**
317
+ * @param {string} href
318
+ * @param {number} width
319
+ * @param {number} quality
320
+ * @returns {string}
321
+ */
322
+ function cacheKey(href, width, quality) {
323
+ return crypto
324
+ .createHash("sha256")
325
+ .update(`webp-q${quality}-e4:${width}:${href}`)
326
+ .digest("hex")
327
+ .slice(0, 32);
328
+ }
329
+
330
+ /**
331
+ * @param {unknown} value
332
+ * @param {number} fallback
333
+ * @returns {number}
334
+ */
335
+ function clampQuality(value, fallback) {
336
+ const n = Math.round(Number(value));
337
+ if (!Number.isFinite(n)) return fallback;
338
+ return Math.min(100, Math.max(1, n));
339
+ }
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import { attrs, esc, cn } from "./html.js";
6
6
  import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
7
+ import {
8
+ parseAllowedRemoteUrl,
9
+ remoteImageUrl,
10
+ srcsetWidths,
11
+ } from "../../server/image-optimizer.js";
7
12
  import { getRequestContext, markTainted } from "../../http/request-context.js";
8
13
  import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
9
14
  import { getConfig } from "../../config/index.js";
@@ -53,7 +58,11 @@ export function link(props) {
53
58
  * `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
54
59
  * üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
55
60
  * `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
56
- * ya da uzak görseller olduğu gibi basılır.
61
+ * yerel yollar olduğu gibi basılır.
62
+ *
63
+ * `images.remote.allowHosts` açıksa uzak http(s) URL'leri `/_jskelet/image`
64
+ * proxy'sine çevrilir (webp + `w`). `unoptimized` veya elle `srcset` bunu
65
+ * atlar.
57
66
  *
58
67
  * `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
59
68
  * @param {{ src: string, alt: string, width?: number, height?: number,
@@ -79,20 +88,27 @@ export function image(props) {
79
88
  } = props;
80
89
 
81
90
  const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
91
+ const remote = unoptimized || srcset ? null : remoteResponsive(src, width);
82
92
  const largest = optimized?.variants.at(-1);
83
93
  // Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
84
- const responsive = optimized && optimized.variants.length > 1;
94
+ const responsive =
95
+ (optimized && optimized.variants.length > 1) ||
96
+ (remote && remote.srcset);
85
97
 
86
98
  const attributes = attrs({
87
- src: largest?.url ?? src,
99
+ src: largest?.url ?? remote?.src ?? src,
88
100
  alt: alt ?? "",
89
101
  width: fill ? undefined : (width ?? optimized?.width),
90
102
  height: fill ? undefined : (height ?? optimized?.height),
91
103
  class: fill
92
104
  ? cn("absolute inset-0 h-full w-full object-cover", className)
93
105
  : className,
94
- sizes: responsive ? (sizes ?? defaultSizes(optimized)) : sizes,
95
- srcset: responsive ? toSrcSet(optimized) : srcset,
106
+ sizes: responsive
107
+ ? (sizes ?? (optimized ? defaultSizes(optimized) : remote?.sizes))
108
+ : sizes,
109
+ srcset: responsive
110
+ ? (optimized ? toSrcSet(optimized) : remote?.srcset)
111
+ : srcset,
96
112
  loading: loading ?? (priority ? "eager" : "lazy"),
97
113
  decoding: priority ? "sync" : "async",
98
114
  fetchpriority: priority ? "high" : undefined,
@@ -102,6 +118,37 @@ export function image(props) {
102
118
  return `<img${attributes}>`;
103
119
  }
104
120
 
121
+ /**
122
+ * Uzak URL → optimizer `src` / `srcset`. Allowlist dışıysa null.
123
+ * @param {string} src
124
+ * @param {number | undefined} width
125
+ * @returns {{ src: string, srcset?: string, sizes?: string } | null}
126
+ */
127
+ function remoteResponsive(src, width) {
128
+ if (!parseAllowedRemoteUrl(src)) return null;
129
+
130
+ const images = getConfig().images;
131
+ if (!images || images === false || !images.remote) return null;
132
+
133
+ const display = width && width > 0 ? width : 640;
134
+ const widths = srcsetWidths(display, images.widths, images.remote.maxWidth);
135
+ const urls = widths
136
+ .map((w) => {
137
+ const href = remoteImageUrl(src, { width: w });
138
+ return href ? `${href} ${w}w` : null;
139
+ })
140
+ .filter(Boolean);
141
+
142
+ const primary = remoteImageUrl(src, { width: display });
143
+ if (!primary) return null;
144
+
145
+ return {
146
+ src: primary,
147
+ srcset: urls.length > 1 ? urls.join(", ") : undefined,
148
+ sizes: `(max-width: ${display}px) 100vw, ${display}px`,
149
+ };
150
+ }
151
+
105
152
  /**
106
153
  * @param {import("../../server/assets.js").OptimizedImage} optimized
107
154
  * @returns {string}