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 +13 -0
- package/docs/02-mimari.md +2 -0
- package/docs/04-render-ve-sablonlar.md +9 -5
- package/docs/07-yapilandirma.md +36 -9
- package/docs/08-build.md +7 -0
- package/docs/10-dagitim.md +5 -2
- package/docs/11-tasima.md +4 -3
- package/docs/en/02-architecture.md +2 -0
- package/docs/en/04-rendering.md +9 -5
- package/docs/en/07-configuration.md +37 -9
- package/docs/en/08-build.md +7 -0
- package/docs/en/10-deployment.md +6 -3
- package/docs/en/11-migration.md +4 -3
- package/package.json +1 -1
- package/src/config/defaults.js +26 -0
- package/src/config/index.js +99 -2
- package/src/index.js +1 -0
- package/src/server/create-app.js +9 -0
- package/src/server/image-optimizer.js +339 -0
- package/src/views/helpers/tags.js +52 -5
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
|
|
387
|
-
|
|
388
|
-
- `
|
|
389
|
-
|
|
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`).
|
package/docs/07-yapilandirma.md
CHANGED
|
@@ -482,24 +482,51 @@ icons: { scan: ["views", "client", "routes", "lib", "content"] }
|
|
|
482
482
|
|
|
483
483
|
## `images`
|
|
484
484
|
|
|
485
|
-
**Tip:**
|
|
486
|
-
|
|
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
|
-
`
|
|
500
|
+
### `images.remote`
|
|
489
501
|
|
|
490
502
|
| Alan | Tip | Varsayılan | Anlamı |
|
|
491
503
|
| --- | --- | --- | --- |
|
|
492
|
-
| `
|
|
493
|
-
| `
|
|
494
|
-
| `
|
|
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.
|
|
497
|
-
turunda hiç çalışmaz.
|
|
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: {
|
|
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ı
|
package/docs/10-dagitim.md
CHANGED
|
@@ -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).**
|
|
100
|
-
|
|
101
|
-
|
|
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
|
package/docs/en/04-rendering.md
CHANGED
|
@@ -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`.
|
|
393
|
-
|
|
394
|
-
-
|
|
395
|
-
|
|
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:**
|
|
498
|
-
|
|
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
|
|
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]` |
|
|
505
|
-
| `quality` | `number` | `78` | webp quality.
|
|
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
|
-
|
|
509
|
-
|
|
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: {
|
|
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:** `[]`
|
package/docs/en/08-build.md
CHANGED
|
@@ -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:
|
package/docs/en/10-deployment.md
CHANGED
|
@@ -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
|
|
131
|
-
optimization. `--omit=dev` leaves it out (if it was installed
|
|
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
|
|
package/docs/en/11-migration.md
CHANGED
|
@@ -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).**
|
|
103
|
-
build time
|
|
104
|
-
|
|
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.
|
|
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",
|
package/src/config/defaults.js
CHANGED
|
@@ -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.
|
package/src/config/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|
package/src/server/create-app.js
CHANGED
|
@@ -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
|
-
*
|
|
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 =
|
|
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
|
|
95
|
-
|
|
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}
|